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        // Don't add containment for these types of children
571        if (childShouldNotBeContained(child)) {
572            return;
573        }
574        tx.doInTx(() -> {
575            final String parentID = parent.getFullId();
576            final String childID = child.getFullId();
577
578            if (!tx.isShortLived()) {
579                LOGGER.debug("Adding: parent: {}, child: {}, in txn: {}, start time {}, end time {}", parentID, childID,
580                        tx.getId(), formatInstant(startTime), formatInstant(endTime));
581                doUpsert(tx, parentID, childID, startTime, endTime, "add");
582            } else {
583                LOGGER.debug("Adding: parent: {}, child: {}, start time {}, end time {}", parentID, childID,
584                        formatInstant(startTime), formatInstant(endTime));
585                doDirectUpsert(parentID, childID, startTime, endTime);
586            }
587        });
588    }
589
590    private boolean childShouldNotBeContained(final FedoraId child) {
591        return child.isAcl();
592    }
593
594    @Override
595    public void removeContainedBy(@Nonnull final Transaction tx, final FedoraId parent, final FedoraId child) {
596        tx.doInTx(() -> {
597            final String parentID = parent.getFullId();
598            final String childID = child.getFullId();
599
600            if (!tx.isShortLived()) {
601                final MapSqlParameterSource parameterSource = new MapSqlParameterSource();
602                parameterSource.addValue("parent", parentID);
603                parameterSource.addValue("child", childID);
604                parameterSource.addValue("transactionId", tx.getId());
605                final boolean addedInTxn = !jdbcTemplate.queryForList(IS_CHILD_ADDED_IN_TRANSACTION, parameterSource)
606                        .isEmpty();
607                if (addedInTxn) {
608                    jdbcTemplate.update(UNDO_INSERT_CHILD_IN_TRANSACTION, parameterSource);
609                } else {
610                    doUpsert(tx, parentID, childID, null, Instant.now(), "delete");
611                }
612            } else {
613                doDirectUpsert(parentID, childID, null, Instant.now());
614                this.getContainedByCache.invalidate(childID);
615            }
616        });
617    }
618
619    @Override
620    public void removeResource(@Nonnull final Transaction tx, final FedoraId resource) {
621        tx.doInTx(() -> {
622            final String resourceID = resource.getFullId();
623
624            if (!tx.isShortLived()) {
625                final MapSqlParameterSource parameterSource = new MapSqlParameterSource();
626                parameterSource.addValue("child", resourceID);
627                parameterSource.addValue("transactionId", tx.getId());
628                final boolean addedInTxn = !jdbcTemplate.queryForList(IS_CHILD_ADDED_IN_TRANSACTION_NO_PARENT,
629                        parameterSource).isEmpty();
630                if (addedInTxn) {
631                    jdbcTemplate.update(UNDO_INSERT_CHILD_IN_TRANSACTION_NO_PARENT, parameterSource);
632                } else {
633                    final String parent = getContainedBy(tx, resource);
634                    if (parent != null) {
635                        LOGGER.debug("Marking containment relationship between parent ({}) and child ({}) deleted",
636                                parent, resourceID);
637                        doUpsert(tx, parent, resourceID, null, Instant.now(), "delete");
638                    }
639                }
640            } else {
641                final String parent = getContainedBy(tx, resource);
642                if (parent != null) {
643                    LOGGER.debug("Marking containment relationship between parent ({}) and child ({}) deleted", parent,
644                            resourceID);
645                    doDirectUpsert(parent, resourceID, null, Instant.now());
646                    this.getContainedByCache.invalidate(resourceID);
647                }
648            }
649        });
650    }
651
652    @Override
653    public void purgeResource(@Nonnull final Transaction tx, final FedoraId resource) {
654        tx.doInTx(() -> {
655            final String resourceID = resource.getFullId();
656
657            final String parent = getContainedByDeleted(tx, resource);
658
659            if (parent != null) {
660                LOGGER.debug("Removing containment relationship between parent ({}) and child ({})",
661                        parent, resourceID);
662
663                if (!tx.isShortLived()) {
664                    doUpsert(tx, parent, resourceID, null, null, "purge");
665                } else {
666                    final MapSqlParameterSource parameterSource = new MapSqlParameterSource();
667                    parameterSource.addValue("child", resourceID);
668                    jdbcTemplate.update(DIRECT_PURGE, parameterSource);
669                }
670            }
671        });
672    }
673
674    /**
675     * Do the Upsert action to the transaction table.
676     * @param tx the transaction
677     * @param parentId the containing resource id
678     * @param resourceId the contained resource id
679     * @param startTime the instant the relationship started, if null get the current time from the main table.
680     * @param endTime the instant the relationship ended or null for none.
681     * @param operation the operation to perform.
682     */
683    private void doUpsert(final Transaction tx, final String parentId, final String resourceId, final Instant startTime,
684                          final Instant endTime, final String operation) {
685        final var parameterSource = new MapSqlParameterSource();
686        parameterSource.addValue("child", resourceId);
687        parameterSource.addValue("transactionId", tx.getId());
688        parameterSource.addValue("parent", parentId);
689        if (startTime == null) {
690            parameterSource.addValue("startTime", formatInstant(getCurrentStartTime(resourceId)));
691        } else {
692            parameterSource.addValue("startTime", formatInstant(startTime));
693        }
694        parameterSource.addValue("endTime", formatInstant(endTime));
695        parameterSource.addValue("operation", operation);
696        jdbcTemplate.update(UPSERT_MAPPING.get(dbPlatform), parameterSource);
697    }
698
699    /**
700     * Do the Upsert directly to the containment index; not the tx table
701     *
702     * @param parentId the containing resource id
703     * @param resourceId the contained resource id
704     * @param startTime the instant the relationship started, if null get the current time from the main table.
705     * @param endTime the instant the relationship ended or null for none.
706     */
707    private void doDirectUpsert(final String parentId, final String resourceId, final Instant startTime,
708                                final Instant endTime) {
709        final var parameterSource = new MapSqlParameterSource();
710        parameterSource.addValue("child", resourceId);
711        parameterSource.addValue("parent", parentId);
712        parameterSource.addValue("endTime", formatInstant(endTime));
713
714        final String query;
715
716        if (startTime == null) {
717            // This the case for an update
718            query = DIRECT_UPDATE_END_TIME;
719        } else {
720            // This is the case for a new record
721            parameterSource.addValue("startTime", formatInstant(startTime));
722            query = DIRECT_UPSERT_MAPPING.get(dbPlatform);
723        }
724
725        jdbcTemplate.update(query, parameterSource);
726        updateParentTimestamp(parentId, startTime, endTime);
727        resourceExistsCache.invalidate(resourceId);
728    }
729
730    private void updateParentTimestamp(final String parentId, final Instant startTime, final Instant endTime) {
731        final var parameterSource = new MapSqlParameterSource();
732        final var updated = endTime == null ? startTime : endTime;
733        parameterSource.addValue("resourceId", parentId);
734        parameterSource.addValue("updated", formatInstant(updated));
735        jdbcTemplate.update(CONDITIONALLY_UPDATE_LAST_UPDATED, parameterSource);
736    }
737
738    /**
739     * Find parent for a resource using a deleted containment relationship.
740     * @param tx the transaction.
741     * @param resource the child resource id.
742     * @return the parent id.
743     */
744    private String getContainedByDeleted(final Transaction tx, final FedoraId resource) {
745        final String resourceID = resource.getFullId();
746        final MapSqlParameterSource parameterSource = new MapSqlParameterSource();
747        parameterSource.addValue("child", resourceID);
748        final List<String> parentID;
749        if (tx.isOpenLongRunning()) {
750            parameterSource.addValue("transactionId", tx.getId());
751            parentID = jdbcTemplate.queryForList(PARENT_EXISTS_DELETED_IN_TRANSACTION, parameterSource, String.class);
752        } else {
753            parentID = jdbcTemplate.queryForList(PARENT_EXISTS_DELETED, parameterSource, String.class);
754        }
755        return parentID.stream().findFirst().orElse(null);
756    }
757
758    @Override
759    public void commitTransaction(final Transaction tx) {
760        if (!tx.isShortLived()) {
761            tx.ensureCommitting();
762            try {
763                final MapSqlParameterSource parameterSource = new MapSqlParameterSource();
764                parameterSource.addValue("transactionId", tx.getId());
765                final List<String> changedParents = jdbcTemplate.queryForList(GET_UPDATED_RESOURCES, parameterSource,
766                        String.class);
767                final List<String> removedResources = jdbcTemplate.queryForList(GET_DELETED_RESOURCES, parameterSource,
768                        String.class);
769                final List<String> addedResources = jdbcTemplate.queryForList(GET_ADDED_RESOURCES, parameterSource,
770                        String.class);
771                final int purged = jdbcTemplate.update(COMMIT_PURGE_RECORDS, parameterSource);
772                final int deleted = jdbcTemplate.update(COMMIT_DELETE_RECORDS.get(dbPlatform), parameterSource);
773                final int added = jdbcTemplate.update(COMMIT_ADD_RECORDS_MAP.get(dbPlatform), parameterSource);
774                for (final var parent : changedParents) {
775                    final var updated = jdbcTemplate.queryForObject(SELECT_LAST_UPDATED_IN_TX,
776                            Map.of("resourceId", parent, "transactionId", tx.getId()), Timestamp.class);
777                    if (updated != null) {
778                        jdbcTemplate.update(UPDATE_LAST_UPDATED,
779                                Map.of("resourceId", parent, "updated", updated));
780                    }
781                }
782                jdbcTemplate.update(DELETE_ENTIRE_TRANSACTION, parameterSource);
783                this.getContainedByCache.invalidateAll(removedResources);
784                // Add inserted records to removed records list.
785                removedResources.addAll(addedResources);
786                this.resourceExistsCache.invalidateAll(removedResources);
787                LOGGER.debug("Commit of tx {} complete with {} adds, {} deletes and {} purges",
788                        tx.getId(), added, deleted, purged);
789            } catch (final Exception e) {
790                LOGGER.warn("Unable to commit containment index transaction {}: {}", tx, e.getMessage());
791                throw new RepositoryRuntimeException("Unable to commit containment index transaction", e);
792            }
793        }
794    }
795
796    @Transactional(propagation = Propagation.NOT_SUPPORTED)
797    @Override
798    public void rollbackTransaction(final Transaction tx) {
799        if (!tx.isShortLived()) {
800            final MapSqlParameterSource parameterSource = new MapSqlParameterSource();
801            parameterSource.addValue("transactionId", tx.getId());
802            jdbcTemplate.update(DELETE_ENTIRE_TRANSACTION, parameterSource);
803        }
804    }
805
806    @Override
807    public void clearAllTransactions() {
808        jdbcTemplate.update(TRUNCATE_TABLE + TRANSACTION_OPERATIONS_TABLE, Collections.emptyMap());
809    }
810
811    @Override
812    public boolean resourceExists(@Nonnull final Transaction tx, final FedoraId fedoraId,
813                                  final boolean includeDeleted) {
814        // Get the containing ID because fcr:metadata will not exist here but MUST exist if the containing resource does
815        final String resourceId = fedoraId.getBaseId();
816        LOGGER.debug("Checking if {} exists in transaction {}", resourceId, tx);
817        if (fedoraId.isRepositoryRoot()) {
818            // Root always exists.
819            return true;
820        }
821        if (tx.isOpenLongRunning()) {
822            final var queryToUse = includeDeleted ? RESOURCE_OR_TOMBSTONE_EXISTS_IN_TRANSACTION :
823                    RESOURCE_EXISTS_IN_TRANSACTION;
824            return !jdbcTemplate.queryForList(queryToUse,
825                    Map.of("child", resourceId, "transactionId", tx.getId()), String.class).isEmpty();
826        } else if (includeDeleted) {
827            final Boolean exists = resourceExistsCache.getIfPresent(resourceId);
828            if (exists != null && exists) {
829                // Only return true, false values might change once deleted resources are included.
830                return true;
831            }
832            return !jdbcTemplate.queryForList(RESOURCE_OR_TOMBSTONE_EXISTS,
833                    Map.of("child", resourceId), String.class).isEmpty();
834        } else {
835            return resourceExistsCache.get(resourceId, key -> !jdbcTemplate.queryForList(RESOURCE_EXISTS,
836                        Map.of("child", resourceId), String.class).isEmpty()
837            );
838        }
839    }
840
841    @Override
842    public FedoraId getContainerIdByPath(final Transaction tx, final FedoraId fedoraId, final boolean checkDeleted) {
843        if (fedoraId.isRepositoryRoot()) {
844            // If we are root then we are the top.
845            return fedoraId;
846        }
847        final String parent = getContainedBy(tx, fedoraId);
848        if (parent != null) {
849            return FedoraId.create(parent);
850        }
851        String fullId = fedoraId.getFullId();
852        while (fullId.contains("/")) {
853            fullId = fedoraId.getResourceId().substring(0, fullId.lastIndexOf("/"));
854            if (fullId.equals(FEDORA_ID_PREFIX)) {
855                return FedoraId.getRepositoryRootId();
856            }
857            final FedoraId testID = FedoraId.create(fullId);
858            if (resourceExists(tx, testID, checkDeleted)) {
859                return testID;
860            }
861        }
862        return FedoraId.getRepositoryRootId();
863    }
864
865    @Override
866    public void reset() {
867        try {
868            jdbcTemplate.update(TRUNCATE_TABLE + RESOURCES_TABLE, Collections.emptyMap());
869            jdbcTemplate.update(TRUNCATE_TABLE + TRANSACTION_OPERATIONS_TABLE, Collections.emptyMap());
870            this.getContainedByCache.invalidateAll();
871        } catch (final Exception e) {
872            throw new RepositoryRuntimeException("Failed to truncate containment tables", e);
873        }
874    }
875
876    @Override
877    public boolean hasResourcesStartingWith(final Transaction tx, final FedoraId fedoraId) {
878        final MapSqlParameterSource parameterSource = new MapSqlParameterSource();
879        parameterSource.addValue("resourceId", fedoraId.getFullId() + "/%");
880        final boolean matchingIds;
881        if (tx.isOpenLongRunning()) {
882            parameterSource.addValue("transactionId", tx.getId());
883            matchingIds = !jdbcTemplate.queryForList(SELECT_ID_LIKE_IN_TRANSACTION, parameterSource, String.class)
884                .isEmpty();
885        } else {
886            matchingIds = !jdbcTemplate.queryForList(SELECT_ID_LIKE, parameterSource, String.class).isEmpty();
887        }
888        return matchingIds;
889    }
890
891    @Override
892    public Instant containmentLastUpdated(final Transaction tx, final FedoraId fedoraId) {
893        final MapSqlParameterSource parameterSource = new MapSqlParameterSource();
894        parameterSource.addValue("resourceId", fedoraId.getFullId());
895        final String queryToUse;
896        if (tx.isOpenLongRunning()) {
897            parameterSource.addValue("transactionId", tx.getId());
898            queryToUse = SELECT_LAST_UPDATED_IN_TX;
899        } else {
900            queryToUse = SELECT_LAST_UPDATED;
901        }
902        try {
903            return fromTimestamp(jdbcTemplate.queryForObject(queryToUse, parameterSource, Timestamp.class));
904        } catch (final EmptyResultDataAccessException e) {
905            return null;
906        }
907    }
908
909    /**
910     * Get the data source backing this containment index
911     * @return data source
912     */
913    public DataSource getDataSource() {
914        return dataSource;
915    }
916
917    /**
918     * Set the data source backing this containment index
919     * @param dataSource data source
920     */
921    public void setDataSource(final DataSource dataSource) {
922        this.dataSource = dataSource;
923    }
924
925    /**
926     * Get the current startTime for the resource
927     * @param resourceId id of the resource
928     * @return start time or null if no committed record.
929     */
930    private Instant getCurrentStartTime(final String resourceId) {
931        return fromTimestamp(jdbcTemplate.queryForObject(GET_START_TIME, Map.of(
932                "child", resourceId
933        ), Timestamp.class));
934    }
935
936    private Instant fromTimestamp(final Timestamp timestamp) {
937        if (timestamp != null) {
938            return timestamp.toInstant();
939        }
940        return null;
941    }
942
943    /**
944     * Format an instant to a timestamp without milliseconds, due to precision
945     * issues with memento datetimes.
946     * @param instant the instant to format.
947     * @return the datetime timestamp
948     */
949    private Timestamp formatInstant(final Instant instant) {
950        if (instant == null) {
951            return null;
952        }
953        return Timestamp.from(instant.truncatedTo(ChronoUnit.SECONDS));
954    }
955
956    /**
957     * Private class to back a stream with a paged DB query.
958     *
959     * If this needs to be run in parallel we will have to override trySplit() and determine a good method to split on.
960     */
961    private class ContainmentIterator extends Spliterators.AbstractSpliterator<String> {
962        final Queue<String> children = new ConcurrentLinkedQueue<>();
963        int numOffsets = 0;
964        final String queryToUse;
965        final MapSqlParameterSource parameterSource;
966
967        public ContainmentIterator(final String query, final MapSqlParameterSource parameters) {
968            super(Long.MAX_VALUE, Spliterator.ORDERED);
969            queryToUse = query;
970            parameterSource = parameters;
971            parameterSource.addValue("containsLimit", containsLimit);
972        }
973
974        @Override
975        public boolean tryAdvance(final Consumer<? super String> action) {
976            try {
977                action.accept(children.remove());
978            } catch (final NoSuchElementException e) {
979                parameterSource.addValue("offSet", numOffsets * containsLimit);
980                numOffsets += 1;
981                children.addAll(jdbcTemplate.queryForList(queryToUse, parameterSource, String.class));
982                if (children.size() == 0) {
983                    // no more elements.
984                    return false;
985                }
986                action.accept(children.remove());
987            }
988            return true;
989        }
990    }
991}