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