001/*
002 * The contents of this file are subject to the license and copyright
003 * detailed in the LICENSE and NOTICE files at the root of the source
004 * tree.
005 */
006package org.fcrepo.kernel.impl;
007
008import static org.fcrepo.kernel.api.FedoraTypes.FEDORA_ID_PREFIX;
009import static org.slf4j.LoggerFactory.getLogger;
010
011import java.sql.Timestamp;
012import java.time.Instant;
013import java.time.temporal.ChronoUnit;
014import java.util.Collections;
015import java.util.List;
016import java.util.Map;
017import java.util.NoSuchElementException;
018import java.util.Queue;
019import java.util.Spliterator;
020import java.util.Spliterators;
021import java.util.concurrent.ConcurrentLinkedQueue;
022import java.util.concurrent.TimeUnit;
023import java.util.function.Consumer;
024import java.util.stream.Stream;
025import java.util.stream.StreamSupport;
026
027import javax.annotation.Nonnull;
028import javax.annotation.PostConstruct;
029import javax.inject.Inject;
030import javax.sql.DataSource;
031
032import org.fcrepo.common.db.DbPlatform;
033import org.fcrepo.config.FedoraPropsConfig;
034import org.fcrepo.kernel.api.ContainmentIndex;
035import org.fcrepo.kernel.api.Transaction;
036import org.fcrepo.kernel.api.exception.RepositoryRuntimeException;
037import org.fcrepo.kernel.api.identifiers.FedoraId;
038
039import org.slf4j.Logger;
040import org.springframework.dao.EmptyResultDataAccessException;
041import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
042import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
043import org.springframework.stereotype.Component;
044import org.springframework.transaction.annotation.Propagation;
045import org.springframework.transaction.annotation.Transactional;
046
047import com.github.benmanes.caffeine.cache.Cache;
048import com.github.benmanes.caffeine.cache.Caffeine;
049
050/**
051 * @author peichman
052 * @author whikloj
053 * @since 6.0.0
054 */
055@Component("containmentIndexImpl")
056public class ContainmentIndexImpl implements ContainmentIndex {
057
058    private static final Logger LOGGER = getLogger(ContainmentIndexImpl.class);
059
060    private int containsLimit = 50000;
061
062    @Inject
063    private DataSource dataSource;
064
065    private NamedParameterJdbcTemplate jdbcTemplate;
066
067    private DbPlatform dbPlatform;
068
069    public static final String RESOURCES_TABLE = "containment";
070
071    private static final String TRANSACTION_OPERATIONS_TABLE = "containment_transactions";
072
073    public static final String FEDORA_ID_COLUMN = "fedora_id";
074
075    private static final String PARENT_COLUMN = "parent";
076
077    private static final String TRANSACTION_ID_COLUMN = "transaction_id";
078
079    private static final String OPERATION_COLUMN = "operation";
080
081    private static final String START_TIME_COLUMN = "start_time";
082
083    private static final String END_TIME_COLUMN = "end_time";
084
085    private static final String UPDATED_COLUMN = "updated";
086
087    /*
088     * Select children of a resource that are not marked as deleted.
089     */
090    private static final String SELECT_CHILDREN = "SELECT " + FEDORA_ID_COLUMN +
091            " FROM " + RESOURCES_TABLE + " WHERE " + PARENT_COLUMN + " = :parent AND " + END_TIME_COLUMN + " IS NULL" +
092            " ORDER BY " + FEDORA_ID_COLUMN + " LIMIT :containsLimit OFFSET :offSet";
093
094    /*
095     * Select children of a memento of a resource.
096     */
097    private static final String SELECT_CHILDREN_OF_MEMENTO = "SELECT " + FEDORA_ID_COLUMN +
098            " FROM " + RESOURCES_TABLE + " WHERE " + PARENT_COLUMN + " = :parent AND " + START_TIME_COLUMN +
099            " <= :asOfTime AND (" + END_TIME_COLUMN + " > :asOfTime OR " + END_TIME_COLUMN + " IS NULL) ORDER BY " +
100            FEDORA_ID_COLUMN + " LIMIT :containsLimit OFFSET :offSet";
101
102    /*
103     * Select children of a parent from resources table and from the transaction table with an 'add' operation,
104     * but exclude any records that also exist in the transaction table with a 'delete' or 'purge' operation.
105     */
106    private static final String SELECT_CHILDREN_IN_TRANSACTION = "SELECT x." + FEDORA_ID_COLUMN + " FROM" +
107            " (SELECT " + FEDORA_ID_COLUMN + " FROM " + RESOURCES_TABLE + " WHERE " + PARENT_COLUMN + " = :parent" +
108            " AND " + END_TIME_COLUMN + " IS NULL " +
109            " UNION SELECT " + FEDORA_ID_COLUMN + " FROM " + TRANSACTION_OPERATIONS_TABLE +
110            " WHERE " + PARENT_COLUMN + " = :parent AND " + TRANSACTION_ID_COLUMN + " = :transactionId" +
111            " AND " + OPERATION_COLUMN + " = 'add') x" +
112            " WHERE NOT EXISTS " +
113            " (SELECT 1 FROM " + TRANSACTION_OPERATIONS_TABLE +
114            " WHERE " + PARENT_COLUMN + " = :parent AND " + FEDORA_ID_COLUMN + " = x." + FEDORA_ID_COLUMN +
115            " AND " + TRANSACTION_ID_COLUMN + " = :transactionId AND " + OPERATION_COLUMN + " IN ('delete', 'purge'))" +
116            " ORDER BY x." + FEDORA_ID_COLUMN + " LIMIT :containsLimit OFFSET :offSet";
117
118    /*
119     * Select all children of a resource that are marked for deletion.
120     */
121    private static final String SELECT_DELETED_CHILDREN = "SELECT " + FEDORA_ID_COLUMN +
122            " FROM " + RESOURCES_TABLE + " WHERE " + PARENT_COLUMN + " = :parent AND " + END_TIME_COLUMN +
123            " IS NOT NULL ORDER BY " + FEDORA_ID_COLUMN + " LIMIT :containsLimit OFFSET :offSet";
124
125    /*
126     * Select children of a resource plus children 'delete'd in the non-committed transaction, but excluding any
127     * 'add'ed in the non-committed transaction.
128     */
129    private static final String SELECT_DELETED_CHILDREN_IN_TRANSACTION = "SELECT x." + FEDORA_ID_COLUMN +
130            " FROM (SELECT " + FEDORA_ID_COLUMN + " FROM " + RESOURCES_TABLE +
131            " WHERE " + PARENT_COLUMN + " = :parent AND " + END_TIME_COLUMN + " IS NOT NULL UNION" +
132            " SELECT " + FEDORA_ID_COLUMN + " FROM " + TRANSACTION_OPERATIONS_TABLE + " WHERE " +
133            PARENT_COLUMN + " = :parent AND " + TRANSACTION_ID_COLUMN + " = :transactionId AND " +
134            OPERATION_COLUMN + " = 'delete') x" +
135            " WHERE NOT EXISTS " +
136            "(SELECT 1 FROM " + TRANSACTION_OPERATIONS_TABLE + " WHERE " + PARENT_COLUMN + " = :parent AND " +
137            FEDORA_ID_COLUMN + " = x." + FEDORA_ID_COLUMN + " AND " + TRANSACTION_ID_COLUMN + " = :transactionId AND " +
138            OPERATION_COLUMN + " = 'add') ORDER BY x." + FEDORA_ID_COLUMN + " LIMIT :containsLimit OFFSET :offSet";
139
140    /*
141     * Upsert a parent child relationship to the transaction operation table.
142     */
143    private static final String UPSERT_RECORDS_POSTGRESQL = "INSERT INTO " + TRANSACTION_OPERATIONS_TABLE +
144            " ( " + PARENT_COLUMN + ", " + FEDORA_ID_COLUMN + ", " + START_TIME_COLUMN + ", " + END_TIME_COLUMN + ", " +
145            TRANSACTION_ID_COLUMN + ", " + OPERATION_COLUMN + ") VALUES (:parent, :child, :startTime, :endTime, " +
146            ":transactionId, :operation) ON CONFLICT ( " +  FEDORA_ID_COLUMN + ", " + TRANSACTION_ID_COLUMN + ") " +
147            "DO UPDATE SET " + PARENT_COLUMN + " = EXCLUDED." + PARENT_COLUMN + ", " +
148            START_TIME_COLUMN + " = EXCLUDED." + START_TIME_COLUMN + ", " + END_TIME_COLUMN + " = EXCLUDED." +
149            END_TIME_COLUMN + ", " + OPERATION_COLUMN + " = EXCLUDED." + OPERATION_COLUMN;
150
151    private static final String UPSERT_RECORDS_MYSQL_MARIA = "INSERT INTO " + TRANSACTION_OPERATIONS_TABLE +
152            " (" + PARENT_COLUMN + ", " + FEDORA_ID_COLUMN + ", " + START_TIME_COLUMN + ", " + END_TIME_COLUMN + ", " +
153            TRANSACTION_ID_COLUMN + ", " + OPERATION_COLUMN + ") VALUES (:parent, :child, :startTime, :endTime, " +
154            ":transactionId, :operation) ON DUPLICATE KEY UPDATE " +
155            PARENT_COLUMN + " = VALUES(" + PARENT_COLUMN + "), " + START_TIME_COLUMN + " = VALUES(" +
156            START_TIME_COLUMN + "), " + END_TIME_COLUMN + " = VALUES(" + END_TIME_COLUMN + "), " + OPERATION_COLUMN +
157            " = VALUES(" + OPERATION_COLUMN + ")";
158
159    private static final String UPSERT_RECORDS_H2 = "MERGE INTO " + TRANSACTION_OPERATIONS_TABLE +
160            " (" + PARENT_COLUMN + ", " + FEDORA_ID_COLUMN + ", " + START_TIME_COLUMN + ", " + END_TIME_COLUMN + ", " +
161            TRANSACTION_ID_COLUMN + ", " + OPERATION_COLUMN + ") KEY (" + FEDORA_ID_COLUMN + ", " +
162            TRANSACTION_ID_COLUMN + ") VALUES (:parent, :child, :startTime, :endTime, :transactionId, :operation)";
163
164    private static final String DIRECT_UPDATE_END_TIME = "UPDATE " + RESOURCES_TABLE +
165            " SET " + END_TIME_COLUMN + " = :endTime WHERE " +
166            PARENT_COLUMN + " = :parent AND " + FEDORA_ID_COLUMN + " = :child";
167
168    private static final String DIRECT_INSERT_RECORDS = "INSERT INTO " + RESOURCES_TABLE +
169            " (" + PARENT_COLUMN + ", " + FEDORA_ID_COLUMN + ", " + START_TIME_COLUMN + ", " + END_TIME_COLUMN + ")" +
170            " VALUES (:parent, :child, :startTime, :endTime)";
171
172    private static final Map<DbPlatform, String> UPSERT_MAPPING = Map.of(
173            DbPlatform.H2, UPSERT_RECORDS_H2,
174            DbPlatform.MYSQL, UPSERT_RECORDS_MYSQL_MARIA,
175            DbPlatform.MARIADB, UPSERT_RECORDS_MYSQL_MARIA,
176            DbPlatform.POSTGRESQL, UPSERT_RECORDS_POSTGRESQL
177    );
178
179    private static final String DIRECT_PURGE = "DELETE FROM containment WHERE fedora_id = :child";
180
181    /*
182     * Remove an insert row from the transaction operation table for this parent child relationship.
183     */
184    private static final String UNDO_INSERT_CHILD_IN_TRANSACTION = "DELETE FROM " + TRANSACTION_OPERATIONS_TABLE +
185            " WHERE " + PARENT_COLUMN + " = :parent AND " + FEDORA_ID_COLUMN + " = :child AND " + TRANSACTION_ID_COLUMN
186            + " = :transactionId AND " + OPERATION_COLUMN + " = 'add'";
187
188    /*
189     * Remove a mark as deleted row from the transaction operation table for this child relationship (no parent).
190     */
191    private static final String UNDO_DELETE_CHILD_IN_TRANSACTION_NO_PARENT = "DELETE FROM " +
192            TRANSACTION_OPERATIONS_TABLE + " WHERE " + FEDORA_ID_COLUMN + " = :child AND " + TRANSACTION_ID_COLUMN
193            + " = :transactionId AND " + OPERATION_COLUMN + " = 'delete'";
194
195    /*
196     * Is this parent child relationship being added in this transaction?
197     */
198    private static final String IS_CHILD_ADDED_IN_TRANSACTION = "SELECT TRUE FROM " + TRANSACTION_OPERATIONS_TABLE +
199            " WHERE " + FEDORA_ID_COLUMN + " = :child AND " + PARENT_COLUMN + " = :parent" +
200            " AND " + TRANSACTION_ID_COLUMN + " = :transactionId AND " + OPERATION_COLUMN + " = 'add'";
201
202    /*
203     * Is this child's relationship being marked for deletion in this transaction (no parent)?
204     */
205    private static final String IS_CHILD_DELETED_IN_TRANSACTION_NO_PARENT = "SELECT TRUE FROM " +
206            TRANSACTION_OPERATIONS_TABLE + " WHERE " + FEDORA_ID_COLUMN + " = :child " +
207            " AND " + TRANSACTION_ID_COLUMN + " = :transactionId AND " + OPERATION_COLUMN + " = 'delete'";
208
209   /*
210    * Delete all rows from the transaction operation table for this transaction.
211    */
212    private static final String DELETE_ENTIRE_TRANSACTION = "DELETE FROM " + TRANSACTION_OPERATIONS_TABLE + " WHERE " +
213            TRANSACTION_ID_COLUMN + " = :transactionId";
214
215    /*
216     * Add to the main table all rows from the transaction operation table marked 'add' for this transaction.
217     */
218    private static final String COMMIT_ADD_RECORDS_POSTGRESQL = "INSERT INTO " + RESOURCES_TABLE +
219            " ( " + FEDORA_ID_COLUMN + ", " + PARENT_COLUMN + ", " + START_TIME_COLUMN + ", " + END_TIME_COLUMN + ") " +
220            "SELECT " + FEDORA_ID_COLUMN + ", " + PARENT_COLUMN + ", " + START_TIME_COLUMN + ", " + END_TIME_COLUMN +
221            " FROM " + TRANSACTION_OPERATIONS_TABLE + " WHERE " + OPERATION_COLUMN + " = 'add' AND " +
222            TRANSACTION_ID_COLUMN + " = :transactionId ON CONFLICT ( " +  FEDORA_ID_COLUMN + " )" +
223            " DO UPDATE SET " + PARENT_COLUMN + " = EXCLUDED." + PARENT_COLUMN + ", " +
224            START_TIME_COLUMN + " = EXCLUDED." + START_TIME_COLUMN + ", " + END_TIME_COLUMN + " = EXCLUDED." +
225            END_TIME_COLUMN;
226
227    private static final String COMMIT_ADD_RECORDS_MYSQL_MARIA = "INSERT INTO " + RESOURCES_TABLE +
228            " (" + FEDORA_ID_COLUMN + ", " + PARENT_COLUMN + ", " + START_TIME_COLUMN + ", " + END_TIME_COLUMN + ") " +
229            "SELECT " + FEDORA_ID_COLUMN + ", " + PARENT_COLUMN + ", " + START_TIME_COLUMN + ", " + END_TIME_COLUMN +
230            " FROM " + TRANSACTION_OPERATIONS_TABLE + " WHERE " + OPERATION_COLUMN + " = 'add' AND " +
231            TRANSACTION_ID_COLUMN + " = :transactionId ON DUPLICATE KEY UPDATE " +
232            PARENT_COLUMN + " = VALUES(" + PARENT_COLUMN + "), " + START_TIME_COLUMN + " = VALUES(" +
233            START_TIME_COLUMN + "), " + END_TIME_COLUMN + " = VALUES(" + END_TIME_COLUMN + ")";
234
235    private static final String COMMIT_ADD_RECORDS_H2 = "MERGE INTO " + RESOURCES_TABLE +
236            " (" + FEDORA_ID_COLUMN + ", " + PARENT_COLUMN + ", " + START_TIME_COLUMN + ", " + END_TIME_COLUMN + ") " +
237            "KEY (" + FEDORA_ID_COLUMN + ") SELECT " + FEDORA_ID_COLUMN + ", " + PARENT_COLUMN + ", " +
238            START_TIME_COLUMN + ", " + END_TIME_COLUMN + " FROM " + TRANSACTION_OPERATIONS_TABLE + " WHERE " +
239            OPERATION_COLUMN + " = 'add' AND " + TRANSACTION_ID_COLUMN + " = :transactionId";
240
241    private static final Map<DbPlatform, String> COMMIT_ADD_RECORDS_MAP = Map.of(
242            DbPlatform.H2, COMMIT_ADD_RECORDS_H2,
243            DbPlatform.MYSQL, COMMIT_ADD_RECORDS_MYSQL_MARIA,
244            DbPlatform.MARIADB, COMMIT_ADD_RECORDS_MYSQL_MARIA,
245            DbPlatform.POSTGRESQL, COMMIT_ADD_RECORDS_POSTGRESQL
246    );
247
248    /*
249     * Add an end time to the rows in the main table that match all rows from transaction operation table marked
250     * 'delete' for this transaction.
251     */
252    private static final String COMMIT_DELETE_RECORDS_H2 = "UPDATE " + RESOURCES_TABLE +
253            " r SET r." + END_TIME_COLUMN + " = ( SELECT t." + END_TIME_COLUMN + " FROM " +
254            TRANSACTION_OPERATIONS_TABLE + " t " +
255            " WHERE t." + FEDORA_ID_COLUMN + " = r." + FEDORA_ID_COLUMN + " AND t." + TRANSACTION_ID_COLUMN +
256            " = :transactionId AND t." +  OPERATION_COLUMN +
257            " = 'delete' AND t." + PARENT_COLUMN + " = r." + PARENT_COLUMN + " AND r." +
258            END_TIME_COLUMN + " IS NULL)" +
259            " WHERE EXISTS (SELECT 1 FROM " + TRANSACTION_OPERATIONS_TABLE + " t WHERE t." + FEDORA_ID_COLUMN +
260            " = r." + FEDORA_ID_COLUMN + " AND t." + TRANSACTION_ID_COLUMN + " = :transactionId AND t." +
261            OPERATION_COLUMN + " = 'delete' AND t." + PARENT_COLUMN + " = r." + PARENT_COLUMN + " AND r." +
262            END_TIME_COLUMN + " IS NULL)";
263
264    private static final String COMMIT_DELETE_RECORDS_MYSQL = "UPDATE " + RESOURCES_TABLE +
265            " r INNER JOIN " + TRANSACTION_OPERATIONS_TABLE + " t ON t." + FEDORA_ID_COLUMN + " = r." +
266            FEDORA_ID_COLUMN + " SET r." + END_TIME_COLUMN + " = t." + END_TIME_COLUMN +
267            " WHERE t." + PARENT_COLUMN + " = r." +
268            PARENT_COLUMN + " AND t." + TRANSACTION_ID_COLUMN + " = :transactionId AND t." +  OPERATION_COLUMN +
269            " = 'delete' AND r." + END_TIME_COLUMN + " IS NULL";
270
271    private static final String COMMIT_DELETE_RECORDS_POSTGRES = "UPDATE " + RESOURCES_TABLE + " SET " +
272            END_TIME_COLUMN + " = t." + END_TIME_COLUMN + " FROM " + TRANSACTION_OPERATIONS_TABLE + " t WHERE t." +
273            FEDORA_ID_COLUMN + " = " + RESOURCES_TABLE + "." + FEDORA_ID_COLUMN + " AND t." + PARENT_COLUMN +
274            " = " + RESOURCES_TABLE + "." + PARENT_COLUMN + " AND t." + TRANSACTION_ID_COLUMN +
275            " = :transactionId AND t." + OPERATION_COLUMN + " = 'delete' AND " + RESOURCES_TABLE + "." +
276            END_TIME_COLUMN + " IS NULL";
277
278    private Map<DbPlatform, String> COMMIT_DELETE_RECORDS = Map.of(
279            DbPlatform.H2, COMMIT_DELETE_RECORDS_H2,
280            DbPlatform.MARIADB, COMMIT_DELETE_RECORDS_MYSQL,
281            DbPlatform.MYSQL, COMMIT_DELETE_RECORDS_MYSQL,
282            DbPlatform.POSTGRESQL, COMMIT_DELETE_RECORDS_POSTGRES
283    );
284
285    /*
286     * Remove from the main table all rows from transaction operation table marked 'purge' for this transaction.
287     */
288    private static final String COMMIT_PURGE_RECORDS = "DELETE FROM " + RESOURCES_TABLE + " WHERE " +
289            "EXISTS (SELECT 1 FROM " + TRANSACTION_OPERATIONS_TABLE + " t WHERE t." +
290            TRANSACTION_ID_COLUMN + " = :transactionId AND t." +  OPERATION_COLUMN + " = 'purge' AND" +
291            " t." + FEDORA_ID_COLUMN + " = " + RESOURCES_TABLE + "." + FEDORA_ID_COLUMN +
292            " AND t." + PARENT_COLUMN + " = " + RESOURCES_TABLE + "." + PARENT_COLUMN + ")";
293
294    /*
295     * Query if a resource exists in the main table and is not deleted.
296     */
297    private static final String RESOURCE_EXISTS = "SELECT " + FEDORA_ID_COLUMN + " FROM " + RESOURCES_TABLE +
298            " WHERE " + FEDORA_ID_COLUMN + " = :child AND " + END_TIME_COLUMN + " IS NULL";
299
300    /*
301     * Resource exists as a record in the transaction operations table with an 'add' operation and not also
302     * exists as a 'delete' operation.
303     */
304    private static final String RESOURCE_EXISTS_IN_TRANSACTION = "SELECT x." + FEDORA_ID_COLUMN + " FROM" +
305            " (SELECT " + FEDORA_ID_COLUMN + " FROM " + RESOURCES_TABLE + " WHERE " + FEDORA_ID_COLUMN + " = :child" +
306            "  AND " + END_TIME_COLUMN + " IS NULL UNION SELECT " + FEDORA_ID_COLUMN + " FROM " +
307            TRANSACTION_OPERATIONS_TABLE + " WHERE " + FEDORA_ID_COLUMN + " = :child AND " + TRANSACTION_ID_COLUMN +
308            " = :transactionId" + " AND " + OPERATION_COLUMN + " = 'add') x WHERE NOT EXISTS " +
309            " (SELECT 1 FROM " + TRANSACTION_OPERATIONS_TABLE +
310            " WHERE " + FEDORA_ID_COLUMN + " = :child AND " + TRANSACTION_ID_COLUMN + " = :transactionId" +
311            " AND " + OPERATION_COLUMN + " IN ('delete', 'purge'))";
312
313    /*
314     * Query if a resource exists in the main table even if it is deleted.
315     */
316    private static final String RESOURCE_OR_TOMBSTONE_EXISTS = "SELECT " + FEDORA_ID_COLUMN + " FROM " +
317            RESOURCES_TABLE + " WHERE " + FEDORA_ID_COLUMN + " = :child";
318
319    /*
320     * Resource exists as a record in the main table even if deleted or in the transaction operations table with an
321     * 'add' operation and not also exists as a 'delete' operation.
322     */
323    private static final String RESOURCE_OR_TOMBSTONE_EXISTS_IN_TRANSACTION = "SELECT x." + FEDORA_ID_COLUMN + " FROM" +
324            " (SELECT " + FEDORA_ID_COLUMN + " FROM " + RESOURCES_TABLE + " WHERE " + FEDORA_ID_COLUMN + " = :child" +
325            " UNION SELECT " + FEDORA_ID_COLUMN + " FROM " +
326            TRANSACTION_OPERATIONS_TABLE + " WHERE " + FEDORA_ID_COLUMN + " = :child AND " + TRANSACTION_ID_COLUMN +
327            " = :transactionId" + " AND " + OPERATION_COLUMN + " = 'add') x WHERE NOT EXISTS " +
328            " (SELECT 1 FROM " + TRANSACTION_OPERATIONS_TABLE +
329            " WHERE " + FEDORA_ID_COLUMN + " = :child AND " + TRANSACTION_ID_COLUMN + " = :transactionId" +
330            " AND " + OPERATION_COLUMN + " IN ('delete', 'purge'))";
331
332
333    /*
334     * Get the parent ID for this resource from the main table if not deleted.
335     */
336    private static final String PARENT_EXISTS = "SELECT " + PARENT_COLUMN + " FROM " + RESOURCES_TABLE +
337            " WHERE " + FEDORA_ID_COLUMN + " = :child AND " + END_TIME_COLUMN + " IS NULL";
338
339    /*
340     * Get the parent ID for this resource from the operations table for an 'add' operation in this transaction, but
341     * exclude any 'delete' operations for this resource in this transaction.
342     */
343    private static final String PARENT_EXISTS_IN_TRANSACTION = "SELECT x." + PARENT_COLUMN + " FROM" +
344            " (SELECT " + PARENT_COLUMN + " FROM " + RESOURCES_TABLE + " WHERE " + FEDORA_ID_COLUMN + " = :child" +
345            " AND " + END_TIME_COLUMN + " IS NULL" +
346            " UNION SELECT " + PARENT_COLUMN + " FROM " + TRANSACTION_OPERATIONS_TABLE +
347            " WHERE " + FEDORA_ID_COLUMN + " = :child AND " + TRANSACTION_ID_COLUMN + " = :transactionId" +
348            " AND " + OPERATION_COLUMN + " = 'add') x" +
349            " WHERE NOT EXISTS " +
350            " (SELECT 1 FROM " + TRANSACTION_OPERATIONS_TABLE +
351            " WHERE " + FEDORA_ID_COLUMN + " = :child AND " + TRANSACTION_ID_COLUMN + " = :transactionId" +
352            " AND " + OPERATION_COLUMN + " = 'delete')";
353
354    /*
355     * Get the parent ID for this resource from the main table if deleted.
356     */
357    private static final String PARENT_EXISTS_DELETED = "SELECT " + PARENT_COLUMN + " FROM " + RESOURCES_TABLE +
358            " WHERE " + FEDORA_ID_COLUMN + " = :child AND " + END_TIME_COLUMN + " IS NOT NULL";
359
360    /*
361     * Get the parent ID for this resource from main table and the operations table for a 'delete' operation in this
362     * transaction, excluding any 'add' operations for this resource in this transaction.
363     */
364    private static final String PARENT_EXISTS_DELETED_IN_TRANSACTION = "SELECT x." + PARENT_COLUMN + " FROM" +
365            " (SELECT " + PARENT_COLUMN + " FROM " + RESOURCES_TABLE + " WHERE " + FEDORA_ID_COLUMN + " = :child" +
366            " AND " + END_TIME_COLUMN + " IS NOT NULL UNION SELECT " + PARENT_COLUMN + " FROM " +
367            TRANSACTION_OPERATIONS_TABLE + " WHERE " + FEDORA_ID_COLUMN + " = :child AND " + TRANSACTION_ID_COLUMN +
368            " = :transactionId AND " + OPERATION_COLUMN + " = 'delete') x WHERE NOT EXISTS " +
369            " (SELECT 1 FROM " + TRANSACTION_OPERATIONS_TABLE + " WHERE " + FEDORA_ID_COLUMN + " = :child AND " +
370            TRANSACTION_ID_COLUMN + " = :transactionId AND " + OPERATION_COLUMN + " = 'add')";
371
372    /*
373     * Does this resource exist in the transaction operation table for an 'add' record.
374     */
375    private static final String IS_CHILD_ADDED_IN_TRANSACTION_NO_PARENT = "SELECT TRUE FROM " +
376            TRANSACTION_OPERATIONS_TABLE + " WHERE " + FEDORA_ID_COLUMN + " = :child AND " +
377            TRANSACTION_ID_COLUMN + " = :transactionId AND " + OPERATION_COLUMN + " = 'add'";
378
379    /*
380     * Delete a row from the transaction operation table with this resource and 'add' operation, no parent required.
381     */
382    private static final String UNDO_INSERT_CHILD_IN_TRANSACTION_NO_PARENT = "DELETE FROM " +
383            TRANSACTION_OPERATIONS_TABLE + " WHERE " + FEDORA_ID_COLUMN + " = :child AND " + TRANSACTION_ID_COLUMN
384            + " = :transactionId AND " + OPERATION_COLUMN + " = 'add'";
385
386    private static final String TRUNCATE_TABLE = "TRUNCATE TABLE ";
387
388    /*
389     * Any record tracked in the containment index is either active or a tombstone. Either way it exists for the
390     * purpose of finding ghost nodes.
391     */
392    private static final String SELECT_ID_LIKE = "SELECT " + FEDORA_ID_COLUMN + " FROM " + RESOURCES_TABLE + " WHERE " +
393            FEDORA_ID_COLUMN + " LIKE :resourceId";
394
395    private static final String SELECT_ID_LIKE_IN_TRANSACTION = "SELECT x." + FEDORA_ID_COLUMN + " FROM (SELECT " +
396            FEDORA_ID_COLUMN + " FROM " + RESOURCES_TABLE + " WHERE " + FEDORA_ID_COLUMN + " LIKE :resourceId" +
397            " UNION SELECT " + FEDORA_ID_COLUMN + " FROM " + TRANSACTION_OPERATIONS_TABLE + " WHERE " +
398            FEDORA_ID_COLUMN + " LIKE :resourceId AND " + TRANSACTION_ID_COLUMN + " = :transactionId AND " +
399            OPERATION_COLUMN + " = 'add') x WHERE NOT EXISTS (SELECT 1 FROM " + TRANSACTION_OPERATIONS_TABLE +
400            " WHERE " + FEDORA_ID_COLUMN + " LIKE :resourceId AND " + TRANSACTION_ID_COLUMN + " = :transactionId AND " +
401            OPERATION_COLUMN + " = 'delete')";
402
403    private static final String SELECT_LAST_UPDATED = "SELECT " + UPDATED_COLUMN + " FROM " + RESOURCES_TABLE +
404            " WHERE " + FEDORA_ID_COLUMN + " = :resourceId";
405
406    private static final String UPDATE_LAST_UPDATED = "UPDATE " + RESOURCES_TABLE + " SET " + UPDATED_COLUMN +
407            " = :updated WHERE " + FEDORA_ID_COLUMN + " = :resourceId";
408
409    private static final String CONDITIONALLY_UPDATE_LAST_UPDATED = "UPDATE " + RESOURCES_TABLE +
410            " SET " + UPDATED_COLUMN + " = :updated WHERE " + FEDORA_ID_COLUMN + " = :resourceId" +
411            " AND (" + UPDATED_COLUMN + " IS NULL OR " + UPDATED_COLUMN + " < :updated)";
412
413    private static final String SELECT_LAST_UPDATED_IN_TX = "SELECT MAX(x.updated)" +
414            " FROM (SELECT " + UPDATED_COLUMN + " as updated FROM " + RESOURCES_TABLE + " WHERE " +
415            FEDORA_ID_COLUMN + " = :resourceId UNION SELECT " + START_TIME_COLUMN +
416            " as updated FROM " + TRANSACTION_OPERATIONS_TABLE + " WHERE " + PARENT_COLUMN + " = :resourceId AND " +
417            OPERATION_COLUMN + " = 'add' AND " + TRANSACTION_ID_COLUMN + " = :transactionId UNION SELECT " +
418            END_TIME_COLUMN + " as updated FROM " + TRANSACTION_OPERATIONS_TABLE + " WHERE " + PARENT_COLUMN +
419            " = :resourceId AND " + OPERATION_COLUMN + " = 'delete' AND " + TRANSACTION_ID_COLUMN +
420            " = :transactionId UNION SELECT " + END_TIME_COLUMN +
421            " as updated FROM " + TRANSACTION_OPERATIONS_TABLE + " WHERE " + PARENT_COLUMN + " = :resourceId AND " +
422            OPERATION_COLUMN + " = 'add' AND " + TRANSACTION_ID_COLUMN + " = :transactionId) x";
423
424    private static final String GET_UPDATED_RESOURCES = "SELECT DISTINCT " + PARENT_COLUMN + " FROM " +
425            TRANSACTION_OPERATIONS_TABLE + " WHERE " + TRANSACTION_ID_COLUMN + " = :transactionId AND " +
426            OPERATION_COLUMN + " in ('add', 'delete')";
427
428    /*
429     * Get the startTime for the specified resource from the main table, if it exists.
430     */
431    private static final String GET_START_TIME = "SELECT " + START_TIME_COLUMN + " FROM " + RESOURCES_TABLE +
432            " WHERE " + FEDORA_ID_COLUMN + " = :child";
433
434    /*
435     * Get all resources deleted in this transaction
436     */
437    private static final String GET_DELETED_RESOURCES = "SELECT " + FEDORA_ID_COLUMN + " FROM " +
438            TRANSACTION_OPERATIONS_TABLE + " WHERE " + TRANSACTION_ID_COLUMN + " = :transactionId AND " +
439            OPERATION_COLUMN + " = 'delete'";
440
441    /*
442     * Get all resources added in this transaction
443     */
444    private static final String GET_ADDED_RESOURCES = "SELECT " + FEDORA_ID_COLUMN + " FROM " +
445            TRANSACTION_OPERATIONS_TABLE + " WHERE " + TRANSACTION_ID_COLUMN + " = :transactionId AND " +
446            OPERATION_COLUMN + " = 'add'";
447
448    @Inject
449    private FedoraPropsConfig fedoraPropsConfig;
450
451    private Cache<String, String> getContainedByCache;
452
453    private Cache<String, Boolean> resourceExistsCache;
454
455    /**
456     * Connect to the database
457     */
458    @PostConstruct
459    private void setup() {
460        jdbcTemplate = getNamedParameterJdbcTemplate();
461        dbPlatform = DbPlatform.fromDataSource(dataSource);
462        this.getContainedByCache = Caffeine.newBuilder()
463                .maximumSize(fedoraPropsConfig.getContainmentCacheSize())
464                .expireAfterAccess(fedoraPropsConfig.getContainmentCacheTimeout(), TimeUnit.MINUTES)
465                .build();
466        this.resourceExistsCache = Caffeine.newBuilder()
467                .maximumSize(fedoraPropsConfig.getContainmentCacheSize())
468                .expireAfterAccess(fedoraPropsConfig.getContainmentCacheTimeout(), TimeUnit.MINUTES)
469                .build();
470    }
471
472    private NamedParameterJdbcTemplate getNamedParameterJdbcTemplate() {
473        return new NamedParameterJdbcTemplate(getDataSource());
474    }
475
476    void setContainsLimit(final int limit) {
477        containsLimit = limit;
478    }
479
480    @Override
481    public Stream<String> getContains(@Nonnull final Transaction tx, final FedoraId fedoraId) {
482        final String resourceId = fedoraId.isMemento() ? fedoraId.getBaseId() : fedoraId.getFullId();
483        final Instant asOfTime = fedoraId.isMemento() ? fedoraId.getMementoInstant() : null;
484        final MapSqlParameterSource parameterSource = new MapSqlParameterSource();
485        parameterSource.addValue("parent", resourceId);
486
487        LOGGER.debug("getContains for {} in transaction {} and instant {}", resourceId, tx, asOfTime);
488
489        final String query;
490        if (asOfTime == null) {
491            if (tx.isOpenLongRunning()) {
492                // we are in a transaction
493                parameterSource.addValue("transactionId", tx.getId());
494                query = SELECT_CHILDREN_IN_TRANSACTION;
495            } else {
496                // not in a transaction
497                query = SELECT_CHILDREN;
498            }
499        } else {
500            parameterSource.addValue("asOfTime", formatInstant(asOfTime));
501            query = SELECT_CHILDREN_OF_MEMENTO;
502        }
503
504        return StreamSupport.stream(new ContainmentIterator(query, parameterSource), false);
505    }
506
507    @Override
508    public Stream<String> getContainsDeleted(@Nonnull final Transaction tx, final FedoraId fedoraId) {
509        final String resourceId = fedoraId.getFullId();
510        final MapSqlParameterSource parameterSource = new MapSqlParameterSource();
511        parameterSource.addValue("parent", resourceId);
512
513        final String query;
514        if (tx.isOpenLongRunning()) {
515            // we are in a transaction
516            parameterSource.addValue("transactionId", tx.getId());
517            query = SELECT_DELETED_CHILDREN_IN_TRANSACTION;
518        } else {
519            // not in a transaction
520            query = SELECT_DELETED_CHILDREN;
521        }
522        LOGGER.debug("getContainsDeleted for {} in transaction {}", resourceId, tx);
523        return StreamSupport.stream(new ContainmentIterator(query, parameterSource), false);
524    }
525
526    @Override
527    public String getContainedBy(@Nonnull final Transaction tx, final FedoraId resource) {
528        final String resourceID = resource.getFullId();
529        final String parentID;
530        if (tx.isOpenLongRunning()) {
531            parentID = jdbcTemplate.queryForList(PARENT_EXISTS_IN_TRANSACTION, Map.of("child", resourceID,
532                    "transactionId", tx.getId()), String.class).stream().findFirst().orElse(null);
533        } else {
534            parentID = this.getContainedByCache.get(resourceID, key ->
535                    jdbcTemplate.queryForList(PARENT_EXISTS, Map.of("child", key), String.class).stream()
536                    .findFirst().orElse(null)
537            );
538        }
539        return parentID;
540    }
541
542    @Override
543    public void addContainedBy(@Nonnull final Transaction tx, final FedoraId parent, final FedoraId child) {
544        addContainedBy(tx, parent, child, Instant.now(), null);
545    }
546
547    @Override
548    public void addContainedBy(@Nonnull final Transaction tx, final FedoraId parent, final FedoraId child,
549                               final Instant startTime, final Instant endTime) {
550        tx.doInTx(() -> {
551            final String parentID = parent.getFullId();
552            final String childID = child.getFullId();
553
554            if (!tx.isShortLived()) {
555                LOGGER.debug("Adding: parent: {}, child: {}, in txn: {}, start time {}, end time {}", parentID, childID,
556                        tx.getId(), formatInstant(startTime), formatInstant(endTime));
557                doUpsert(tx, parentID, childID, startTime, endTime, "add");
558            } else {
559                LOGGER.debug("Adding: parent: {}, child: {}, start time {}, end time {}", parentID, childID,
560                        formatInstant(startTime), formatInstant(endTime));
561                doDirectUpsert(parentID, childID, startTime, endTime);
562            }
563        });
564    }
565
566    @Override
567    public void removeContainedBy(@Nonnull final Transaction tx, final FedoraId parent, final FedoraId child) {
568        tx.doInTx(() -> {
569            final String parentID = parent.getFullId();
570            final String childID = child.getFullId();
571
572            if (!tx.isShortLived()) {
573                final MapSqlParameterSource parameterSource = new MapSqlParameterSource();
574                parameterSource.addValue("parent", parentID);
575                parameterSource.addValue("child", childID);
576                parameterSource.addValue("transactionId", tx.getId());
577                final boolean addedInTxn = !jdbcTemplate.queryForList(IS_CHILD_ADDED_IN_TRANSACTION, parameterSource)
578                        .isEmpty();
579                if (addedInTxn) {
580                    jdbcTemplate.update(UNDO_INSERT_CHILD_IN_TRANSACTION, parameterSource);
581                } else {
582                    doUpsert(tx, parentID, childID, null, Instant.now(), "delete");
583                }
584            } else {
585                doDirectUpsert(parentID, childID, null, Instant.now());
586                this.getContainedByCache.invalidate(childID);
587            }
588        });
589    }
590
591    @Override
592    public void removeResource(@Nonnull final Transaction tx, final FedoraId resource) {
593        tx.doInTx(() -> {
594            final String resourceID = resource.getFullId();
595
596            if (!tx.isShortLived()) {
597                final MapSqlParameterSource parameterSource = new MapSqlParameterSource();
598                parameterSource.addValue("child", resourceID);
599                parameterSource.addValue("transactionId", tx.getId());
600                final boolean addedInTxn = !jdbcTemplate.queryForList(IS_CHILD_ADDED_IN_TRANSACTION_NO_PARENT,
601                        parameterSource).isEmpty();
602                if (addedInTxn) {
603                    jdbcTemplate.update(UNDO_INSERT_CHILD_IN_TRANSACTION_NO_PARENT, parameterSource);
604                } else {
605                    final String parent = getContainedBy(tx, resource);
606                    if (parent != null) {
607                        LOGGER.debug("Marking containment relationship between parent ({}) and child ({}) deleted",
608                                parent, resourceID);
609                        doUpsert(tx, parent, resourceID, null, Instant.now(), "delete");
610                    }
611                }
612            } else {
613                final String parent = getContainedBy(tx, resource);
614                if (parent != null) {
615                    LOGGER.debug("Marking containment relationship between parent ({}) and child ({}) deleted", parent,
616                            resourceID);
617                    doDirectUpsert(parent, resourceID, null, Instant.now());
618                    this.getContainedByCache.invalidate(resourceID);
619                }
620            }
621        });
622    }
623
624    @Override
625    public void purgeResource(@Nonnull final Transaction tx, final FedoraId resource) {
626        tx.doInTx(() -> {
627            final String resourceID = resource.getFullId();
628
629            final String parent = getContainedByDeleted(tx, resource);
630
631            if (parent != null) {
632                LOGGER.debug("Removing containment relationship between parent ({}) and child ({})",
633                        parent, resourceID);
634
635                if (!tx.isShortLived()) {
636                    doUpsert(tx, parent, resourceID, null, null, "purge");
637                } else {
638                    final MapSqlParameterSource parameterSource = new MapSqlParameterSource();
639                    parameterSource.addValue("child", resourceID);
640                    jdbcTemplate.update(DIRECT_PURGE, parameterSource);
641                }
642            }
643        });
644    }
645
646    /**
647     * Do the Upsert action to the transaction table.
648     * @param tx the transaction
649     * @param parentId the containing resource id
650     * @param resourceId the contained resource id
651     * @param startTime the instant the relationship started, if null get the current time from the main table.
652     * @param endTime the instant the relationship ended or null for none.
653     * @param operation the operation to perform.
654     */
655    private void doUpsert(final Transaction tx, final String parentId, final String resourceId, final Instant startTime,
656                          final Instant endTime, final String operation) {
657        final var parameterSource = new MapSqlParameterSource();
658        parameterSource.addValue("child", resourceId);
659        parameterSource.addValue("transactionId", tx.getId());
660        parameterSource.addValue("parent", parentId);
661        if (startTime == null) {
662            parameterSource.addValue("startTime", formatInstant(getCurrentStartTime(resourceId)));
663        } else {
664            parameterSource.addValue("startTime", formatInstant(startTime));
665        }
666        parameterSource.addValue("endTime", formatInstant(endTime));
667        parameterSource.addValue("operation", operation);
668        jdbcTemplate.update(UPSERT_MAPPING.get(dbPlatform), parameterSource);
669    }
670
671    /**
672     * Do the Upsert directly to the containment index; not the tx table
673     *
674     * @param parentId the containing resource id
675     * @param resourceId the contained resource id
676     * @param startTime the instant the relationship started, if null get the current time from the main table.
677     * @param endTime the instant the relationship ended or null for none.
678     */
679    private void doDirectUpsert(final String parentId, final String resourceId, final Instant startTime,
680                                final Instant endTime) {
681        final var parameterSource = new MapSqlParameterSource();
682        parameterSource.addValue("child", resourceId);
683        parameterSource.addValue("parent", parentId);
684        parameterSource.addValue("endTime", formatInstant(endTime));
685
686        final String query;
687
688        if (startTime == null) {
689            // This the case for an update
690            query = DIRECT_UPDATE_END_TIME;
691        } else {
692            // This is the case for a new record
693            parameterSource.addValue("startTime", formatInstant(startTime));
694            query = DIRECT_INSERT_RECORDS;
695        }
696
697        jdbcTemplate.update(query, parameterSource);
698        updateParentTimestamp(parentId, startTime, endTime);
699        resourceExistsCache.invalidate(resourceId);
700    }
701
702    private void updateParentTimestamp(final String parentId, final Instant startTime, final Instant endTime) {
703        final var parameterSource = new MapSqlParameterSource();
704        final var updated = endTime == null ? startTime : endTime;
705        parameterSource.addValue("resourceId", parentId);
706        parameterSource.addValue("updated", formatInstant(updated));
707        jdbcTemplate.update(CONDITIONALLY_UPDATE_LAST_UPDATED, parameterSource);
708    }
709
710    /**
711     * Find parent for a resource using a deleted containment relationship.
712     * @param tx the transaction.
713     * @param resource the child resource id.
714     * @return the parent id.
715     */
716    private String getContainedByDeleted(final Transaction tx, final FedoraId resource) {
717        final String resourceID = resource.getFullId();
718        final MapSqlParameterSource parameterSource = new MapSqlParameterSource();
719        parameterSource.addValue("child", resourceID);
720        final List<String> parentID;
721        if (tx.isOpenLongRunning()) {
722            parameterSource.addValue("transactionId", tx.getId());
723            parentID = jdbcTemplate.queryForList(PARENT_EXISTS_DELETED_IN_TRANSACTION, parameterSource, String.class);
724        } else {
725            parentID = jdbcTemplate.queryForList(PARENT_EXISTS_DELETED, parameterSource, String.class);
726        }
727        return parentID.stream().findFirst().orElse(null);
728    }
729
730    @Override
731    public void commitTransaction(final Transaction tx) {
732        if (!tx.isShortLived()) {
733            tx.ensureCommitting();
734            try {
735                final MapSqlParameterSource parameterSource = new MapSqlParameterSource();
736                parameterSource.addValue("transactionId", tx.getId());
737                final List<String> changedParents = jdbcTemplate.queryForList(GET_UPDATED_RESOURCES, parameterSource,
738                        String.class);
739                final List<String> removedResources = jdbcTemplate.queryForList(GET_DELETED_RESOURCES, parameterSource,
740                        String.class);
741                final List<String> addedResources = jdbcTemplate.queryForList(GET_ADDED_RESOURCES, parameterSource,
742                        String.class);
743                final int purged = jdbcTemplate.update(COMMIT_PURGE_RECORDS, parameterSource);
744                final int deleted = jdbcTemplate.update(COMMIT_DELETE_RECORDS.get(dbPlatform), parameterSource);
745                final int added = jdbcTemplate.update(COMMIT_ADD_RECORDS_MAP.get(dbPlatform), parameterSource);
746                for (final var parent : changedParents) {
747                    final var updated = jdbcTemplate.queryForObject(SELECT_LAST_UPDATED_IN_TX,
748                            Map.of("resourceId", parent, "transactionId", tx.getId()), Timestamp.class);
749                    if (updated != null) {
750                        jdbcTemplate.update(UPDATE_LAST_UPDATED,
751                                Map.of("resourceId", parent, "updated", updated));
752                    }
753                }
754                jdbcTemplate.update(DELETE_ENTIRE_TRANSACTION, parameterSource);
755                this.getContainedByCache.invalidateAll(removedResources);
756                // Add inserted records to removed records list.
757                removedResources.addAll(addedResources);
758                this.resourceExistsCache.invalidateAll(removedResources);
759                LOGGER.debug("Commit of tx {} complete with {} adds, {} deletes and {} purges",
760                        tx.getId(), added, deleted, purged);
761            } catch (final Exception e) {
762                LOGGER.warn("Unable to commit containment index transaction {}: {}", tx, e.getMessage());
763                throw new RepositoryRuntimeException("Unable to commit containment index transaction", e);
764            }
765        }
766    }
767
768    @Transactional(propagation = Propagation.NOT_SUPPORTED)
769    @Override
770    public void rollbackTransaction(final Transaction tx) {
771        if (!tx.isShortLived()) {
772            final MapSqlParameterSource parameterSource = new MapSqlParameterSource();
773            parameterSource.addValue("transactionId", tx.getId());
774            jdbcTemplate.update(DELETE_ENTIRE_TRANSACTION, parameterSource);
775        }
776    }
777
778    @Override
779    public boolean resourceExists(@Nonnull final Transaction tx, final FedoraId fedoraId,
780                                  final boolean includeDeleted) {
781        // Get the containing ID because fcr:metadata will not exist here but MUST exist if the containing resource does
782        final String resourceId = fedoraId.getBaseId();
783        LOGGER.debug("Checking if {} exists in transaction {}", resourceId, tx);
784        if (fedoraId.isRepositoryRoot()) {
785            // Root always exists.
786            return true;
787        }
788        if (tx.isOpenLongRunning()) {
789            final var queryToUse = includeDeleted ? RESOURCE_OR_TOMBSTONE_EXISTS_IN_TRANSACTION :
790                    RESOURCE_EXISTS_IN_TRANSACTION;
791            return !jdbcTemplate.queryForList(queryToUse,
792                    Map.of("child", resourceId, "transactionId", tx.getId()), String.class).isEmpty();
793        } else if (includeDeleted) {
794            final Boolean exists = resourceExistsCache.getIfPresent(resourceId);
795            if (exists != null && exists) {
796                // Only return true, false values might change once deleted resources are included.
797                return true;
798            }
799            return !jdbcTemplate.queryForList(RESOURCE_OR_TOMBSTONE_EXISTS,
800                    Map.of("child", resourceId), String.class).isEmpty();
801        } else {
802            return resourceExistsCache.get(resourceId, key -> !jdbcTemplate.queryForList(RESOURCE_EXISTS,
803                        Map.of("child", resourceId), String.class).isEmpty()
804            );
805        }
806    }
807
808    @Override
809    public FedoraId getContainerIdByPath(final Transaction tx, final FedoraId fedoraId, final boolean checkDeleted) {
810        if (fedoraId.isRepositoryRoot()) {
811            // If we are root then we are the top.
812            return fedoraId;
813        }
814        final String parent = getContainedBy(tx, fedoraId);
815        if (parent != null) {
816            return FedoraId.create(parent);
817        }
818        String fullId = fedoraId.getFullId();
819        while (fullId.contains("/")) {
820            fullId = fedoraId.getResourceId().substring(0, fullId.lastIndexOf("/"));
821            if (fullId.equals(FEDORA_ID_PREFIX)) {
822                return FedoraId.getRepositoryRootId();
823            }
824            final FedoraId testID = FedoraId.create(fullId);
825            if (resourceExists(tx, testID, checkDeleted)) {
826                return testID;
827            }
828        }
829        return FedoraId.getRepositoryRootId();
830    }
831
832    @Override
833    public void reset() {
834        try {
835            jdbcTemplate.update(TRUNCATE_TABLE + RESOURCES_TABLE, Collections.emptyMap());
836            jdbcTemplate.update(TRUNCATE_TABLE + TRANSACTION_OPERATIONS_TABLE, Collections.emptyMap());
837            this.getContainedByCache.invalidateAll();
838        } catch (final Exception e) {
839            throw new RepositoryRuntimeException("Failed to truncate containment tables", e);
840        }
841    }
842
843    @Override
844    public boolean hasResourcesStartingWith(final Transaction tx, final FedoraId fedoraId) {
845        final MapSqlParameterSource parameterSource = new MapSqlParameterSource();
846        parameterSource.addValue("resourceId", fedoraId.getFullId() + "/%");
847        final boolean matchingIds;
848        if (tx.isOpenLongRunning()) {
849            parameterSource.addValue("transactionId", tx.getId());
850            matchingIds = !jdbcTemplate.queryForList(SELECT_ID_LIKE_IN_TRANSACTION, parameterSource, String.class)
851                .isEmpty();
852        } else {
853            matchingIds = !jdbcTemplate.queryForList(SELECT_ID_LIKE, parameterSource, String.class).isEmpty();
854        }
855        return matchingIds;
856    }
857
858    @Override
859    public Instant containmentLastUpdated(final Transaction tx, final FedoraId fedoraId) {
860        final MapSqlParameterSource parameterSource = new MapSqlParameterSource();
861        parameterSource.addValue("resourceId", fedoraId.getFullId());
862        final String queryToUse;
863        if (tx.isOpenLongRunning()) {
864            parameterSource.addValue("transactionId", tx.getId());
865            queryToUse = SELECT_LAST_UPDATED_IN_TX;
866        } else {
867            queryToUse = SELECT_LAST_UPDATED;
868        }
869        try {
870            return fromTimestamp(jdbcTemplate.queryForObject(queryToUse, parameterSource, Timestamp.class));
871        } catch (final EmptyResultDataAccessException e) {
872            return null;
873        }
874    }
875
876    /**
877     * Get the data source backing this containment index
878     * @return data source
879     */
880    public DataSource getDataSource() {
881        return dataSource;
882    }
883
884    /**
885     * Set the data source backing this containment index
886     * @param dataSource data source
887     */
888    public void setDataSource(final DataSource dataSource) {
889        this.dataSource = dataSource;
890    }
891
892    /**
893     * Get the current startTime for the resource
894     * @param resourceId id of the resource
895     * @return start time or null if no committed record.
896     */
897    private Instant getCurrentStartTime(final String resourceId) {
898        return fromTimestamp(jdbcTemplate.queryForObject(GET_START_TIME, Map.of(
899                "child", resourceId
900        ), Timestamp.class));
901    }
902
903    private Instant fromTimestamp(final Timestamp timestamp) {
904        if (timestamp != null) {
905            return timestamp.toInstant();
906        }
907        return null;
908    }
909
910    /**
911     * Format an instant to a timestamp without milliseconds, due to precision
912     * issues with memento datetimes.
913     * @param instant the instant to format.
914     * @return the datetime timestamp
915     */
916    private Timestamp formatInstant(final Instant instant) {
917        if (instant == null) {
918            return null;
919        }
920        return Timestamp.from(instant.truncatedTo(ChronoUnit.SECONDS));
921    }
922
923    /**
924     * Private class to back a stream with a paged DB query.
925     *
926     * If this needs to be run in parallel we will have to override trySplit() and determine a good method to split on.
927     */
928    private class ContainmentIterator extends Spliterators.AbstractSpliterator<String> {
929        final Queue<String> children = new ConcurrentLinkedQueue<>();
930        int numOffsets = 0;
931        final String queryToUse;
932        final MapSqlParameterSource parameterSource;
933
934        public ContainmentIterator(final String query, final MapSqlParameterSource parameters) {
935            super(Long.MAX_VALUE, Spliterator.ORDERED);
936            queryToUse = query;
937            parameterSource = parameters;
938            parameterSource.addValue("containsLimit", containsLimit);
939        }
940
941        @Override
942        public boolean tryAdvance(final Consumer<? super String> action) {
943            try {
944                action.accept(children.remove());
945            } catch (final NoSuchElementException e) {
946                parameterSource.addValue("offSet", numOffsets * containsLimit);
947                numOffsets += 1;
948                children.addAll(jdbcTemplate.queryForList(queryToUse, parameterSource, String.class));
949                if (children.size() == 0) {
950                    // no more elements.
951                    return false;
952                }
953                action.accept(children.remove());
954            }
955            return true;
956        }
957    }
958}