001/*
002 * Licensed to DuraSpace under one or more contributor license agreements.
003 * See the NOTICE file distributed with this work for additional information
004 * regarding copyright ownership.
005 *
006 * DuraSpace licenses this file to you under the Apache License,
007 * Version 2.0 (the "License"); you may not use this file except in
008 * compliance with the License.  You may obtain a copy of the License at
009 *
010 *     http://www.apache.org/licenses/LICENSE-2.0
011 *
012 * Unless required by applicable law or agreed to in writing, software
013 * distributed under the License is distributed on an "AS IS" BASIS,
014 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
015 * See the License for the specific language governing permissions and
016 * limitations under the License.
017 */
018package org.fcrepo.kernel.impl;
019
020import static org.fcrepo.kernel.api.FedoraTypes.FEDORA_ID_PREFIX;
021import static org.slf4j.LoggerFactory.getLogger;
022
023import java.sql.Timestamp;
024import java.time.Instant;
025import java.time.temporal.ChronoUnit;
026import java.util.Collections;
027import java.util.List;
028import java.util.Map;
029import java.util.NoSuchElementException;
030import java.util.Queue;
031import java.util.Spliterator;
032import java.util.Spliterators;
033import java.util.concurrent.ConcurrentLinkedQueue;
034import java.util.function.Consumer;
035import java.util.stream.Stream;
036import java.util.stream.StreamSupport;
037
038import javax.annotation.Nonnull;
039import javax.annotation.PostConstruct;
040import javax.inject.Inject;
041import javax.sql.DataSource;
042
043import org.fcrepo.common.db.DbPlatform;
044import org.fcrepo.kernel.api.ContainmentIndex;
045import org.fcrepo.kernel.api.exception.RepositoryRuntimeException;
046import org.fcrepo.kernel.api.identifiers.FedoraId;
047
048import org.slf4j.Logger;
049import org.springframework.dao.EmptyResultDataAccessException;
050import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
051import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
052import org.springframework.stereotype.Component;
053import org.springframework.transaction.annotation.Transactional;
054
055/**
056 * @author peichman
057 * @author whikloj
058 * @since 6.0.0
059 */
060@Component("containmentIndexImpl")
061public class ContainmentIndexImpl implements ContainmentIndex {
062
063    private static final Logger LOGGER = getLogger(ContainmentIndexImpl.class);
064
065    private int containsLimit = 50000;
066
067    @Inject
068    private DataSource dataSource;
069
070    private NamedParameterJdbcTemplate jdbcTemplate;
071
072    private DbPlatform dbPlatform;
073
074    public static final String RESOURCES_TABLE = "containment";
075
076    private static final String TRANSACTION_OPERATIONS_TABLE = "containment_transactions";
077
078    public static final String FEDORA_ID_COLUMN = "fedora_id";
079
080    private static final String PARENT_COLUMN = "parent";
081
082    private static final String TRANSACTION_ID_COLUMN = "transaction_id";
083
084    private static final String OPERATION_COLUMN = "operation";
085
086    private static final String START_TIME_COLUMN = "start_time";
087
088    private static final String END_TIME_COLUMN = "end_time";
089
090    private static final String UPDATED_COLUMN = "updated";
091
092    /*
093     * Select children of a resource that are not marked as deleted.
094     */
095    private static final String SELECT_CHILDREN = "SELECT " + FEDORA_ID_COLUMN +
096            " FROM " + RESOURCES_TABLE + " WHERE " + PARENT_COLUMN + " = :parent AND " + END_TIME_COLUMN + " IS NULL" +
097            " ORDER BY " + FEDORA_ID_COLUMN + " LIMIT :containsLimit OFFSET :offSet";
098
099    /*
100     * Select children of a memento of a resource.
101     */
102    private static final String SELECT_CHILDREN_OF_MEMENTO = "SELECT " + FEDORA_ID_COLUMN +
103            " FROM " + RESOURCES_TABLE + " WHERE " + PARENT_COLUMN + " = :parent AND " + START_TIME_COLUMN +
104            " <= :asOfTime AND (" + END_TIME_COLUMN + " > :asOfTime OR " + END_TIME_COLUMN + " IS NULL) ORDER BY " +
105            FEDORA_ID_COLUMN + " LIMIT :containsLimit OFFSET :offSet";
106
107    /*
108     * Select children of a parent from resources table and from the transaction table with an 'add' operation,
109     * but exclude any records that also exist in the transaction table with a 'delete' or 'purge' operation.
110     */
111    private static final String SELECT_CHILDREN_IN_TRANSACTION = "SELECT x." + FEDORA_ID_COLUMN + " FROM" +
112            " (SELECT " + FEDORA_ID_COLUMN + " FROM " + RESOURCES_TABLE + " WHERE " + PARENT_COLUMN + " = :parent" +
113            " AND " + END_TIME_COLUMN + " IS NULL " +
114            " UNION SELECT " + FEDORA_ID_COLUMN + " FROM " + TRANSACTION_OPERATIONS_TABLE +
115            " WHERE " + PARENT_COLUMN + " = :parent AND " + TRANSACTION_ID_COLUMN + " = :transactionId" +
116            " AND " + OPERATION_COLUMN + " = 'add') x" +
117            " WHERE NOT EXISTS " +
118            " (SELECT 1 FROM " + TRANSACTION_OPERATIONS_TABLE +
119            " WHERE " + PARENT_COLUMN + " = :parent AND " + FEDORA_ID_COLUMN + " = x." + FEDORA_ID_COLUMN +
120            " AND " + TRANSACTION_ID_COLUMN + " = :transactionId AND " + OPERATION_COLUMN + " IN ('delete', 'purge'))" +
121            " ORDER BY x." + FEDORA_ID_COLUMN + " LIMIT :containsLimit OFFSET :offSet";
122
123    /*
124     * Select all children of a resource that are marked for deletion.
125     */
126    private static final String SELECT_DELETED_CHILDREN = "SELECT " + FEDORA_ID_COLUMN +
127            " FROM " + RESOURCES_TABLE + " WHERE " + PARENT_COLUMN + " = :parent AND " + END_TIME_COLUMN +
128            " IS NOT NULL ORDER BY " + FEDORA_ID_COLUMN + " LIMIT :containsLimit OFFSET :offSet";
129
130    /*
131     * Select children of a resource plus children 'delete'd in the non-committed transaction, but excluding any
132     * 'add'ed in the non-committed transaction.
133     */
134    private static final String SELECT_DELETED_CHILDREN_IN_TRANSACTION = "SELECT x." + FEDORA_ID_COLUMN +
135            " FROM (SELECT " + FEDORA_ID_COLUMN + " FROM " + RESOURCES_TABLE +
136            " WHERE " + PARENT_COLUMN + " = :parent AND " + END_TIME_COLUMN + " IS NOT NULL UNION" +
137            " SELECT " + FEDORA_ID_COLUMN + " FROM " + TRANSACTION_OPERATIONS_TABLE + " WHERE " +
138            PARENT_COLUMN + " = :parent AND " + TRANSACTION_ID_COLUMN + " = :transactionId AND " +
139            OPERATION_COLUMN + " = 'delete') x" +
140            " WHERE NOT EXISTS " +
141            "(SELECT 1 FROM " + TRANSACTION_OPERATIONS_TABLE + " WHERE " + PARENT_COLUMN + " = :parent AND " +
142            FEDORA_ID_COLUMN + " = x." + FEDORA_ID_COLUMN + " AND " + TRANSACTION_ID_COLUMN + " = :transactionId AND " +
143            OPERATION_COLUMN + " = 'add') ORDER BY x." + FEDORA_ID_COLUMN + " LIMIT :containsLimit OFFSET :offSet";
144
145    /*
146     * Insert a parent child relationship to the transaction operation table.
147     */
148    private static final String INSERT_CHILD_IN_TRANSACTION = "INSERT INTO " + TRANSACTION_OPERATIONS_TABLE +
149            " ( " + PARENT_COLUMN + ", " + FEDORA_ID_COLUMN + ", " + START_TIME_COLUMN + ", " + END_TIME_COLUMN + ", " +
150            TRANSACTION_ID_COLUMN + ", " + OPERATION_COLUMN + " ) VALUES (:parent, :child, :startTime, :endTime, " +
151            ":transactionId, 'add')";
152
153    /*
154     * Remove an insert row from the transaction operation table for this parent child relationship.
155     */
156    private static final String UNDO_INSERT_CHILD_IN_TRANSACTION = "DELETE FROM " + TRANSACTION_OPERATIONS_TABLE +
157            " WHERE " + PARENT_COLUMN + " = :parent AND " + FEDORA_ID_COLUMN + " = :child AND " + TRANSACTION_ID_COLUMN
158            + " = :transactionId AND " + OPERATION_COLUMN + " = 'add'";
159
160    /*
161     * Add a parent child relationship deletion to the transaction operation table.
162     */
163    private static final String DELETE_CHILD_IN_TRANSACTION = "INSERT INTO " + TRANSACTION_OPERATIONS_TABLE +
164            " ( " + PARENT_COLUMN + ", " + FEDORA_ID_COLUMN + ", " + END_TIME_COLUMN + ", " + TRANSACTION_ID_COLUMN +
165            ", " + OPERATION_COLUMN + " ) VALUES (:parent, :child, :endTime, :transactionId, 'delete')";
166
167    /*
168     * Add a parent child relationship purge to the transaction operation table.
169     */
170    private static final String PURGE_CHILD_IN_TRANSACTION = "INSERT INTO " + TRANSACTION_OPERATIONS_TABLE +
171            " ( " + PARENT_COLUMN + ", " + FEDORA_ID_COLUMN + ", " + TRANSACTION_ID_COLUMN + ", " + OPERATION_COLUMN +
172            " ) VALUES (:parent, :child, :transactionId, 'purge')";
173
174    /*
175     * Remove a mark as deleted row from the transaction operation table for this child relationship (no parent).
176     */
177    private static final String UNDO_DELETE_CHILD_IN_TRANSACTION_NO_PARENT = "DELETE FROM " +
178            TRANSACTION_OPERATIONS_TABLE + " WHERE " + FEDORA_ID_COLUMN + " = :child AND " + TRANSACTION_ID_COLUMN
179            + " = :transactionId AND " + OPERATION_COLUMN + " = 'delete'";
180
181    /*
182     * Remove a purge row from the transaction operation table for this parent child relationship.
183     */
184    private static final String UNDO_PURGE_CHILD_IN_TRANSACTION = "DELETE FROM " + TRANSACTION_OPERATIONS_TABLE +
185            " WHERE " + PARENT_COLUMN + " = :parent AND " + FEDORA_ID_COLUMN + " = :child AND " + TRANSACTION_ID_COLUMN
186            + " = :transactionId AND " + OPERATION_COLUMN + " = 'purge'";
187
188    /*
189     * Is this parent child relationship being added in this transaction?
190     */
191    private static final String IS_CHILD_ADDED_IN_TRANSACTION = "SELECT TRUE FROM " + TRANSACTION_OPERATIONS_TABLE +
192            " WHERE " + FEDORA_ID_COLUMN + " = :child AND " + PARENT_COLUMN + " = :parent" +
193            " AND " + TRANSACTION_ID_COLUMN + " = :transactionId AND " + OPERATION_COLUMN + " = 'add'";
194
195    /*
196     * Is this child's relationship being marked for deletion in this transaction (no parent)?
197     */
198    private static final String IS_CHILD_DELETED_IN_TRANSACTION_NO_PARENT = "SELECT TRUE FROM " +
199            TRANSACTION_OPERATIONS_TABLE + " WHERE " + FEDORA_ID_COLUMN + " = :child " +
200            " AND " + TRANSACTION_ID_COLUMN + " = :transactionId AND " + OPERATION_COLUMN + " = 'delete'";
201
202    /*
203     * Is this parent child relationship being purged in this transaction?
204     */
205    private static final String IS_CHILD_PURGED_IN_TRANSACTION = "SELECT TRUE FROM " + TRANSACTION_OPERATIONS_TABLE +
206            " WHERE " + FEDORA_ID_COLUMN + " = :child AND " + PARENT_COLUMN + " = :parent" +
207            " AND " + TRANSACTION_ID_COLUMN + " = :transactionId AND " + OPERATION_COLUMN + " = 'purge'";
208
209   /*
210    * Delete all rows from the transaction operation table for this transaction.
211    */
212    private static final String DELETE_ENTIRE_TRANSACTION = "DELETE FROM " + TRANSACTION_OPERATIONS_TABLE + " WHERE " +
213            TRANSACTION_ID_COLUMN + " = :transactionId";
214
215    /*
216     * Add to the main table all rows from the transaction operation table marked 'add' for this transaction.
217     */
218    private static final String COMMIT_ADD_RECORDS = "INSERT INTO " + RESOURCES_TABLE + " ( " + FEDORA_ID_COLUMN + ", "
219            + PARENT_COLUMN + ", " + START_TIME_COLUMN + ", " + END_TIME_COLUMN + " ) SELECT " + FEDORA_ID_COLUMN +
220            ", " + PARENT_COLUMN + ", " + START_TIME_COLUMN + ", " + END_TIME_COLUMN + " FROM " +
221            TRANSACTION_OPERATIONS_TABLE + " WHERE " + TRANSACTION_ID_COLUMN + " = :transactionId AND " +
222            OPERATION_COLUMN + " = 'add'";
223
224    /*
225     * Add an end time to the rows in the main table that match all rows from transaction operation table marked
226     * 'delete' for this transaction.
227     */
228    private static final String COMMIT_DELETE_RECORDS_H2 = "UPDATE " + RESOURCES_TABLE +
229            " r SET r." + END_TIME_COLUMN + " = ( SELECT t." + END_TIME_COLUMN + " FROM " +
230            TRANSACTION_OPERATIONS_TABLE + " t " +
231            " WHERE t." + FEDORA_ID_COLUMN + " = r." + FEDORA_ID_COLUMN + " AND t." + TRANSACTION_ID_COLUMN +
232            " = :transactionId AND t." +  OPERATION_COLUMN +
233            " = 'delete' AND t." + PARENT_COLUMN + " = r." + PARENT_COLUMN + " AND r." +
234            END_TIME_COLUMN + " IS NULL)" +
235            " WHERE EXISTS (SELECT * FROM " + TRANSACTION_OPERATIONS_TABLE + " t WHERE t." + FEDORA_ID_COLUMN +
236            " = r." + FEDORA_ID_COLUMN + " AND t." + TRANSACTION_ID_COLUMN + " = :transactionId AND t." +
237            OPERATION_COLUMN + " = 'delete' AND t." + PARENT_COLUMN + " = r." + PARENT_COLUMN + " AND r." +
238            END_TIME_COLUMN + " IS NULL)";
239
240    private static final String COMMIT_DELETE_RECORDS_MYSQL = "UPDATE " + RESOURCES_TABLE +
241            " r INNER JOIN " + TRANSACTION_OPERATIONS_TABLE + " t ON t." + FEDORA_ID_COLUMN + " = r." +
242            FEDORA_ID_COLUMN + " SET r." + END_TIME_COLUMN + " = t." + END_TIME_COLUMN +
243            " WHERE t." + PARENT_COLUMN + " = r." +
244            PARENT_COLUMN + " AND t." + TRANSACTION_ID_COLUMN + " = :transactionId AND t." +  OPERATION_COLUMN +
245            " = 'delete' AND r." + END_TIME_COLUMN + " IS NULL";
246
247    private static final String COMMIT_DELETE_RECORDS_POSTGRES = "UPDATE " + RESOURCES_TABLE + " SET " +
248            END_TIME_COLUMN + " = t." + END_TIME_COLUMN + " FROM " + TRANSACTION_OPERATIONS_TABLE + " t WHERE t." +
249            FEDORA_ID_COLUMN + " = " + RESOURCES_TABLE + "." + FEDORA_ID_COLUMN + " AND t." + PARENT_COLUMN +
250            " = " + RESOURCES_TABLE + "." + PARENT_COLUMN + " AND t." + TRANSACTION_ID_COLUMN +
251            " = :transactionId AND t." + OPERATION_COLUMN + " = 'delete' AND " + RESOURCES_TABLE + "." +
252            END_TIME_COLUMN + " IS NULL";
253
254    private Map<DbPlatform, String> COMMIT_DELETE_RECORDS = Map.of(
255            DbPlatform.H2, COMMIT_DELETE_RECORDS_H2,
256            DbPlatform.MARIADB, COMMIT_DELETE_RECORDS_MYSQL,
257            DbPlatform.MYSQL, COMMIT_DELETE_RECORDS_MYSQL,
258            DbPlatform.POSTGRESQL, COMMIT_DELETE_RECORDS_POSTGRES
259    );
260
261    /*
262     * Remove from the main table all rows from transaction operation table marked 'purge' for this transaction.
263     */
264    private static final String COMMIT_PURGE_RECORDS = "DELETE FROM " + RESOURCES_TABLE + " WHERE " +
265            "EXISTS (SELECT * FROM " + TRANSACTION_OPERATIONS_TABLE + " t WHERE t." +
266            TRANSACTION_ID_COLUMN + " = :transactionId AND t." +  OPERATION_COLUMN + " = 'purge' AND" +
267            " t." + FEDORA_ID_COLUMN + " = " + RESOURCES_TABLE + "." + FEDORA_ID_COLUMN +
268            " AND t." + PARENT_COLUMN + " = " + RESOURCES_TABLE + "." + PARENT_COLUMN + ")";
269
270    /*
271     * Query if a resource exists in the main table and is not deleted.
272     */
273    private static final String RESOURCE_EXISTS = "SELECT " + FEDORA_ID_COLUMN + " FROM " + RESOURCES_TABLE +
274            " WHERE " + FEDORA_ID_COLUMN + " = :child AND " + END_TIME_COLUMN + " IS NULL";
275
276    /*
277     * Resource exists as a record in the transaction operations table with an 'add' operation and not also
278     * exists as a 'delete' operation.
279     */
280    private static final String RESOURCE_EXISTS_IN_TRANSACTION = "SELECT x." + FEDORA_ID_COLUMN + " FROM" +
281            " (SELECT " + FEDORA_ID_COLUMN + " FROM " + RESOURCES_TABLE + " WHERE " + FEDORA_ID_COLUMN + " = :child" +
282            "  AND " + END_TIME_COLUMN + " IS NULL UNION SELECT " + FEDORA_ID_COLUMN + " FROM " +
283            TRANSACTION_OPERATIONS_TABLE + " WHERE " + FEDORA_ID_COLUMN + " = :child AND " + TRANSACTION_ID_COLUMN +
284            " = :transactionId" + " AND " + OPERATION_COLUMN + " = 'add') x WHERE NOT EXISTS " +
285            " (SELECT 1 FROM " + TRANSACTION_OPERATIONS_TABLE +
286            " WHERE " + FEDORA_ID_COLUMN + " = :child AND " + TRANSACTION_ID_COLUMN + " = :transactionId" +
287            " AND " + OPERATION_COLUMN + " IN ('delete', 'purge'))";
288
289    /*
290     * Query if a resource exists in the main table even if it is deleted.
291     */
292    private static final String RESOURCE_OR_TOMBSTONE_EXISTS = "SELECT " + FEDORA_ID_COLUMN + " FROM " +
293            RESOURCES_TABLE + " WHERE " + FEDORA_ID_COLUMN + " = :child";
294
295    /*
296     * Resource exists as a record in the main table even if deleted or in the transaction operations table with an
297     * 'add' operation and not also exists as a 'delete' operation.
298     */
299    private static final String RESOURCE_OR_TOMBSTONE_EXISTS_IN_TRANSACTION = "SELECT x." + FEDORA_ID_COLUMN + " FROM" +
300            " (SELECT " + FEDORA_ID_COLUMN + " FROM " + RESOURCES_TABLE + " WHERE " + FEDORA_ID_COLUMN + " = :child" +
301            " UNION SELECT " + FEDORA_ID_COLUMN + " FROM " +
302            TRANSACTION_OPERATIONS_TABLE + " WHERE " + FEDORA_ID_COLUMN + " = :child AND " + TRANSACTION_ID_COLUMN +
303            " = :transactionId" + " AND " + OPERATION_COLUMN + " = 'add') x WHERE NOT EXISTS " +
304            " (SELECT 1 FROM " + TRANSACTION_OPERATIONS_TABLE +
305            " WHERE " + FEDORA_ID_COLUMN + " = :child AND " + TRANSACTION_ID_COLUMN + " = :transactionId" +
306            " AND " + OPERATION_COLUMN + " IN ('delete', 'purge'))";
307
308
309    /*
310     * Get the parent ID for this resource from the main table if not deleted.
311     */
312    private static final String PARENT_EXISTS = "SELECT " + PARENT_COLUMN + " FROM " + RESOURCES_TABLE +
313            " WHERE " + FEDORA_ID_COLUMN + " = :child AND " + END_TIME_COLUMN + " IS NULL";
314
315    /*
316     * Get the parent ID for this resource from the operations table for an 'add' operation in this transaction, but
317     * exclude any 'delete' operations for this resource in this transaction.
318     */
319    private static final String PARENT_EXISTS_IN_TRANSACTION = "SELECT x." + PARENT_COLUMN + " FROM" +
320            " (SELECT " + PARENT_COLUMN + " FROM " + RESOURCES_TABLE + " WHERE " + FEDORA_ID_COLUMN + " = :child" +
321            " AND " + END_TIME_COLUMN + " IS NULL" +
322            " UNION SELECT " + PARENT_COLUMN + " FROM " + TRANSACTION_OPERATIONS_TABLE +
323            " WHERE " + FEDORA_ID_COLUMN + " = :child AND " + TRANSACTION_ID_COLUMN + " = :transactionId" +
324            " AND " + OPERATION_COLUMN + " = 'add') x" +
325            " WHERE NOT EXISTS " +
326            " (SELECT 1 FROM " + TRANSACTION_OPERATIONS_TABLE +
327            " WHERE " + FEDORA_ID_COLUMN + " = :child AND " + TRANSACTION_ID_COLUMN + " = :transactionId" +
328            " AND " + OPERATION_COLUMN + " = 'delete')";
329
330    /*
331     * Get the parent ID for this resource from the main table if deleted.
332     */
333    private static final String PARENT_EXISTS_DELETED = "SELECT " + PARENT_COLUMN + " FROM " + RESOURCES_TABLE +
334            " WHERE " + FEDORA_ID_COLUMN + " = :child AND " + END_TIME_COLUMN + " IS NOT NULL";
335
336    /*
337     * Get the parent ID for this resource from main table and the operations table for a 'delete' operation in this
338     * transaction, excluding any 'add' operations for this resource in this transaction.
339     */
340    private static final String PARENT_EXISTS_DELETED_IN_TRANSACTION = "SELECT x." + PARENT_COLUMN + " FROM" +
341            " (SELECT " + PARENT_COLUMN + " FROM " + RESOURCES_TABLE + " WHERE " + FEDORA_ID_COLUMN + " = :child" +
342            " AND " + END_TIME_COLUMN + " IS NOT NULL UNION SELECT " + PARENT_COLUMN + " FROM " +
343            TRANSACTION_OPERATIONS_TABLE + " WHERE " + FEDORA_ID_COLUMN + " = :child AND " + TRANSACTION_ID_COLUMN +
344            " = :transactionId AND " + OPERATION_COLUMN + " = 'delete') x WHERE NOT EXISTS " +
345            " (SELECT 1 FROM " + TRANSACTION_OPERATIONS_TABLE + " WHERE " + FEDORA_ID_COLUMN + " = :child AND " +
346            TRANSACTION_ID_COLUMN + " = :transactionId AND " + OPERATION_COLUMN + " = 'add')";
347
348    /*
349     * Does this resource exist in the transaction operation table for an 'add' record.
350     */
351    private static final String IS_CHILD_ADDED_IN_TRANSACTION_NO_PARENT = "SELECT TRUE FROM " +
352            TRANSACTION_OPERATIONS_TABLE + " WHERE " + FEDORA_ID_COLUMN + " = :child AND " +
353            TRANSACTION_ID_COLUMN + " = :transactionId AND " + OPERATION_COLUMN + " = 'add'";
354
355    /*
356     * Delete a row from the transaction operation table with this resource and 'add' operation, no parent required.
357     */
358    private static final String UNDO_INSERT_CHILD_IN_TRANSACTION_NO_PARENT = "DELETE FROM " +
359            TRANSACTION_OPERATIONS_TABLE + " WHERE " + FEDORA_ID_COLUMN + " = :child AND " + TRANSACTION_ID_COLUMN
360            + " = :transactionId AND " + OPERATION_COLUMN + " = 'add'";
361
362    private static final String TRUNCATE_TABLE = "TRUNCATE TABLE ";
363
364    /*
365     * Any record tracked in the containment index is either active or a tombstone. Either way it exists for the
366     * purpose of finding ghost nodes.
367     */
368    private static final String SELECT_ID_LIKE = "SELECT " + FEDORA_ID_COLUMN + " FROM " + RESOURCES_TABLE + " WHERE " +
369            FEDORA_ID_COLUMN + " LIKE :resourceId";
370
371    private static final String SELECT_ID_LIKE_IN_TRANSACTION = "SELECT x." + FEDORA_ID_COLUMN + " FROM (SELECT " +
372            FEDORA_ID_COLUMN + " FROM " + RESOURCES_TABLE + " WHERE " + FEDORA_ID_COLUMN + " LIKE :resourceId" +
373            " UNION SELECT " + FEDORA_ID_COLUMN + " FROM " + TRANSACTION_OPERATIONS_TABLE + " WHERE " +
374            FEDORA_ID_COLUMN + " LIKE :resourceId AND " + TRANSACTION_ID_COLUMN + " = :transactionId AND " +
375            OPERATION_COLUMN + " = 'add') x WHERE NOT EXISTS (SELECT 1 FROM " + TRANSACTION_OPERATIONS_TABLE +
376            " WHERE " + FEDORA_ID_COLUMN + " LIKE :resourceId AND " + TRANSACTION_ID_COLUMN + " = :transactionId AND " +
377            OPERATION_COLUMN + " = 'delete')";
378
379    private static final String SELECT_LAST_UPDATED = "SELECT " + UPDATED_COLUMN + " FROM " + RESOURCES_TABLE +
380            " WHERE " + FEDORA_ID_COLUMN + " = :resourceId";
381
382    private static final String UPDATE_LAST_UPDATED = "UPDATE " + RESOURCES_TABLE + " SET " + UPDATED_COLUMN +
383            " = :updated WHERE " + FEDORA_ID_COLUMN + " = :resourceId";
384
385    private static final String SELECT_LAST_UPDATED_IN_TX = "SELECT MAX(x.updated)" +
386            " FROM (SELECT " + UPDATED_COLUMN + " as updated FROM " + RESOURCES_TABLE + " WHERE " +
387            FEDORA_ID_COLUMN + " = :resourceId UNION SELECT " + START_TIME_COLUMN +
388            " as updated FROM " + TRANSACTION_OPERATIONS_TABLE + " WHERE " + PARENT_COLUMN + " = :resourceId AND " +
389            OPERATION_COLUMN + " = 'add' AND " + TRANSACTION_ID_COLUMN + " = :transactionId UNION SELECT " +
390            END_TIME_COLUMN + " as updated FROM " + TRANSACTION_OPERATIONS_TABLE + " WHERE " + PARENT_COLUMN +
391            " = :resourceId AND " + OPERATION_COLUMN + " = 'delete' AND " + TRANSACTION_ID_COLUMN +
392            " = :transactionId UNION SELECT " + END_TIME_COLUMN +
393            " as updated FROM " + TRANSACTION_OPERATIONS_TABLE + " WHERE " + PARENT_COLUMN + " = :resourceId AND " +
394            OPERATION_COLUMN + " = 'add' AND " + TRANSACTION_ID_COLUMN + " = :transactionId) x";
395
396    private static final String GET_UPDATED_RESOURCES = "SELECT DISTINCT " + PARENT_COLUMN + " FROM " +
397            TRANSACTION_OPERATIONS_TABLE + " WHERE " + TRANSACTION_ID_COLUMN + " = :transactionId AND " +
398            OPERATION_COLUMN + " in ('add', 'delete')";
399
400    /**
401     * Connect to the database
402     */
403    @PostConstruct
404    private void setup() {
405        jdbcTemplate = getNamedParameterJdbcTemplate();
406        dbPlatform = DbPlatform.fromDataSource(dataSource);
407    }
408
409    private NamedParameterJdbcTemplate getNamedParameterJdbcTemplate() {
410        return new NamedParameterJdbcTemplate(getDataSource());
411    }
412
413    void setContainsLimit(final int limit) {
414        containsLimit = limit;
415    }
416
417    @Override
418    public Stream<String> getContains(final String txId, final FedoraId fedoraId) {
419        final String resourceId = fedoraId.isMemento() ? fedoraId.getBaseId() : fedoraId.getFullId();
420        final Instant asOfTime = fedoraId.isMemento() ? fedoraId.getMementoInstant() : null;
421        final MapSqlParameterSource parameterSource = new MapSqlParameterSource();
422        parameterSource.addValue("parent", resourceId);
423
424        LOGGER.debug("getContains for {} in transaction {} and instant {}", resourceId, txId, asOfTime);
425
426        final String query;
427        if (asOfTime == null) {
428            if (txId != null) {
429                // we are in a transaction
430                parameterSource.addValue("transactionId", txId);
431                query = SELECT_CHILDREN_IN_TRANSACTION;
432            } else {
433                // not in a transaction
434                query = SELECT_CHILDREN;
435            }
436        } else {
437            parameterSource.addValue("asOfTime", formatInstant(asOfTime));
438            query = SELECT_CHILDREN_OF_MEMENTO;
439        }
440
441        return StreamSupport.stream(new ContainmentIterator(query, parameterSource), false);
442    }
443
444    @Override
445    public Stream<String> getContainsDeleted(final String txId, final FedoraId fedoraId) {
446        final String resourceId = fedoraId.getFullId();
447        final MapSqlParameterSource parameterSource = new MapSqlParameterSource();
448        parameterSource.addValue("parent", resourceId);
449
450        final String query;
451        if (txId != null) {
452            // we are in a transaction
453            parameterSource.addValue("transactionId", txId);
454            query = SELECT_DELETED_CHILDREN_IN_TRANSACTION;
455        } else {
456            // not in a transaction
457            query = SELECT_DELETED_CHILDREN;
458        }
459        LOGGER.debug("getContainsDeleted for {} in transaction {}", resourceId, txId);
460        return StreamSupport.stream(new ContainmentIterator(query, parameterSource), false);
461    }
462
463    @Override
464    public String getContainedBy(final String txId, final FedoraId resource) {
465        final String resourceID = resource.getFullId();
466        final MapSqlParameterSource parameterSource = new MapSqlParameterSource();
467        parameterSource.addValue("child", resourceID);
468        final List<String> parentID;
469        if (txId != null) {
470            parameterSource.addValue("transactionId", txId);
471            parentID = jdbcTemplate.queryForList(PARENT_EXISTS_IN_TRANSACTION, parameterSource, String.class);
472        } else {
473            parentID = jdbcTemplate.queryForList(PARENT_EXISTS, parameterSource, String.class);
474        }
475        return parentID.stream().findFirst().orElse(null);
476    }
477
478    @Override
479    public void addContainedBy(@Nonnull final String txId, final FedoraId parent, final FedoraId child) {
480        addContainedBy(txId, parent, child, Instant.now(), null);
481    }
482
483    @Override
484    public void addContainedBy(@Nonnull final String txId, final FedoraId parent, final FedoraId child,
485                               final Instant startTime, final Instant endTime) {
486        final String parentID = parent.getFullId();
487        final String childID = child.getFullId();
488        final MapSqlParameterSource parameterSource = new MapSqlParameterSource();
489
490        LOGGER.debug("Adding: parent: {}, child: {}, in txn: {}, start time {}, end time {}", parentID, childID, txId,
491                formatInstant(startTime), formatInstant(endTime));
492
493        parameterSource.addValue("parent", parentID);
494        parameterSource.addValue("child", childID);
495        parameterSource.addValue("transactionId", txId);
496        parameterSource.addValue("startTime", formatInstant(startTime));
497        parameterSource.addValue("endTime", formatInstant(endTime));
498        final boolean purgedInTxn = !jdbcTemplate.queryForList(IS_CHILD_PURGED_IN_TRANSACTION, parameterSource)
499                .isEmpty();
500        if (purgedInTxn) {
501            // We purged it, but are re-adding it so remove the purge operation.
502            jdbcTemplate.update(UNDO_PURGE_CHILD_IN_TRANSACTION, parameterSource);
503        }
504        jdbcTemplate.update(INSERT_CHILD_IN_TRANSACTION, parameterSource);
505    }
506
507    @Override
508    public void removeContainedBy(@Nonnull final String txId, final FedoraId parent, final FedoraId child) {
509        final String parentID = parent.getFullId();
510        final String childID = child.getFullId();
511        final MapSqlParameterSource parameterSource = new MapSqlParameterSource();
512        parameterSource.addValue("parent", parentID);
513        parameterSource.addValue("child", childID);
514        parameterSource.addValue("transactionId", txId);
515        parameterSource.addValue("endTime", formatInstant(Instant.now()));
516        final boolean addedInTxn = !jdbcTemplate.queryForList(IS_CHILD_ADDED_IN_TRANSACTION, parameterSource)
517                .isEmpty();
518        if (addedInTxn) {
519            jdbcTemplate.update(UNDO_INSERT_CHILD_IN_TRANSACTION, parameterSource);
520        } else {
521            jdbcTemplate.update(DELETE_CHILD_IN_TRANSACTION, parameterSource);
522        }
523    }
524
525    @Override
526    public void removeResource(@Nonnull final String txId, final FedoraId resource) {
527        final String resourceID = resource.getFullId();
528        final MapSqlParameterSource parameterSource = new MapSqlParameterSource();
529        parameterSource.addValue("child", resourceID);
530        parameterSource.addValue("transactionId", txId);
531        final boolean addedInTxn = !jdbcTemplate.queryForList(IS_CHILD_ADDED_IN_TRANSACTION_NO_PARENT,
532                parameterSource).isEmpty();
533        if (addedInTxn) {
534            jdbcTemplate.update(UNDO_INSERT_CHILD_IN_TRANSACTION_NO_PARENT, parameterSource);
535        } else {
536            final String parent = getContainedBy(txId, resource);
537            if (parent != null) {
538                LOGGER.debug("Marking containment relationship between parent ({}) and child ({}) deleted", parent,
539                        resourceID);
540                parameterSource.addValue("parent", parent);
541                parameterSource.addValue("endTime", formatInstant(Instant.now()));
542                jdbcTemplate.update(DELETE_CHILD_IN_TRANSACTION, parameterSource);
543            }
544        }
545    }
546
547    @Override
548    public void purgeResource(@Nonnull final String txId, final FedoraId resource) {
549        final String resourceID = resource.getFullId();
550        final MapSqlParameterSource parameterSource = new MapSqlParameterSource();
551        parameterSource.addValue("child", resourceID);
552        parameterSource.addValue("transactionId", txId);
553        final String parent = getContainedByDeleted(txId, resource);
554        final boolean deletedInTxn = !jdbcTemplate.queryForList(IS_CHILD_DELETED_IN_TRANSACTION_NO_PARENT,
555                parameterSource).isEmpty();
556        if (deletedInTxn) {
557            jdbcTemplate.update(UNDO_DELETE_CHILD_IN_TRANSACTION_NO_PARENT, parameterSource);
558        }
559        if (parent != null) {
560            LOGGER.debug("Removing containment relationship between parent ({}) and child ({})", parent, resourceID);
561            parameterSource.addValue("parent", parent);
562            jdbcTemplate.update(PURGE_CHILD_IN_TRANSACTION, parameterSource);
563        }
564    }
565
566    /**
567     * Find parent for a resource using a deleted containment relationship.
568     * @param txId the transaction id.
569     * @param resource the child resource id.
570     * @return the parent id.
571     */
572    private String getContainedByDeleted(final String txId, final FedoraId resource) {
573        final String resourceID = resource.getFullId();
574        final MapSqlParameterSource parameterSource = new MapSqlParameterSource();
575        parameterSource.addValue("child", resourceID);
576        final List<String> parentID;
577        if (txId != null) {
578            parameterSource.addValue("transactionId", txId);
579            parentID = jdbcTemplate.queryForList(PARENT_EXISTS_DELETED_IN_TRANSACTION, parameterSource, String.class);
580        } else {
581            parentID = jdbcTemplate.queryForList(PARENT_EXISTS_DELETED, parameterSource, String.class);
582        }
583        return parentID.stream().findFirst().orElse(null);
584    }
585
586    @Transactional
587    @Override
588    public void commitTransaction(final String txId) {
589        if (txId != null) {
590            try {
591                final MapSqlParameterSource parameterSource = new MapSqlParameterSource();
592                parameterSource.addValue("transactionId", txId);
593                final List<String> changedParents = jdbcTemplate.queryForList(GET_UPDATED_RESOURCES, parameterSource,
594                        String.class);
595                final int purged = jdbcTemplate.update(COMMIT_PURGE_RECORDS, parameterSource);
596                final int deleted = jdbcTemplate.update(COMMIT_DELETE_RECORDS.get(dbPlatform), parameterSource);
597                final int added = jdbcTemplate.update(COMMIT_ADD_RECORDS, parameterSource);
598                for (final var parent : changedParents) {
599                    final var updated = jdbcTemplate.queryForObject(SELECT_LAST_UPDATED_IN_TX,
600                            Map.of("resourceId", parent, "transactionId", txId), Instant.class);
601                    if (updated != null) {
602                        jdbcTemplate.update(UPDATE_LAST_UPDATED,
603                                Map.of("resourceId", parent, "updated", formatInstant(updated)));
604                    }
605                }
606                jdbcTemplate.update(DELETE_ENTIRE_TRANSACTION, parameterSource);
607                LOGGER.debug("Commit of tx {} complete with {} adds, {} deletes and {} purges",
608                        txId, added, deleted, purged);
609            } catch (final Exception e) {
610                LOGGER.warn("Unable to commit containment index transaction {}: {}", txId, e.getMessage());
611                throw new RepositoryRuntimeException("Unable to commit containment index transaction", e);
612            }
613        }
614    }
615
616    @Override
617    public void rollbackTransaction(final String txId) {
618        if (txId != null) {
619            final MapSqlParameterSource parameterSource = new MapSqlParameterSource();
620            parameterSource.addValue("transactionId", txId);
621            jdbcTemplate.update(DELETE_ENTIRE_TRANSACTION, parameterSource);
622        }
623    }
624
625    @Override
626    public boolean resourceExists(final String txId, final FedoraId fedoraId, final boolean includeDeleted) {
627        // Get the containing ID because fcr:metadata will not exist here but MUST exist if the containing resource does
628        final String resourceId = fedoraId.getBaseId();
629        LOGGER.debug("Checking if {} exists in transaction {}", resourceId, txId);
630        if (fedoraId.isRepositoryRoot()) {
631            // Root always exists.
632            return true;
633        }
634        final MapSqlParameterSource parameterSource = new MapSqlParameterSource();
635        parameterSource.addValue("child", resourceId);
636        final String queryToUse;
637        if (txId != null) {
638            queryToUse = includeDeleted ? RESOURCE_OR_TOMBSTONE_EXISTS_IN_TRANSACTION :
639                    RESOURCE_EXISTS_IN_TRANSACTION;
640            parameterSource.addValue("transactionId", txId);
641        } else {
642            queryToUse = includeDeleted ? RESOURCE_OR_TOMBSTONE_EXISTS :
643                    RESOURCE_EXISTS;
644        }
645        return !jdbcTemplate.queryForList(queryToUse, parameterSource, String.class).isEmpty();
646    }
647
648    @Override
649    public FedoraId getContainerIdByPath(final String txId, final FedoraId fedoraId, final boolean checkDeleted) {
650        if (fedoraId.isRepositoryRoot()) {
651            // If we are root then we are the top.
652            return fedoraId;
653        }
654        final String parent = getContainedBy(txId, fedoraId);
655        if (parent != null) {
656            return FedoraId.create(parent);
657        }
658        String fullId = fedoraId.getFullId();
659        while (fullId.contains("/")) {
660            fullId = fedoraId.getResourceId().substring(0, fullId.lastIndexOf("/"));
661            if (fullId.equals(FEDORA_ID_PREFIX)) {
662                return FedoraId.getRepositoryRootId();
663            }
664            final FedoraId testID = FedoraId.create(fullId);
665            if (resourceExists(txId, testID, checkDeleted)) {
666                return testID;
667            }
668        }
669        return FedoraId.getRepositoryRootId();
670    }
671
672    @Transactional
673    @Override
674    public void reset() {
675        try {
676            jdbcTemplate.update(TRUNCATE_TABLE + RESOURCES_TABLE, Collections.emptyMap());
677            jdbcTemplate.update(TRUNCATE_TABLE + TRANSACTION_OPERATIONS_TABLE, Collections.emptyMap());
678        } catch (final Exception e) {
679            throw new RepositoryRuntimeException("Failed to truncate containment tables", e);
680        }
681    }
682
683    @Override
684    public boolean hasResourcesStartingWith(final String txId, final FedoraId fedoraId) {
685        final MapSqlParameterSource parameterSource = new MapSqlParameterSource();
686        parameterSource.addValue("resourceId", fedoraId.getFullId() + "/%");
687        final boolean matchingIds;
688        if (txId != null) {
689            parameterSource.addValue("transactionId", txId);
690            matchingIds = !jdbcTemplate.queryForList(SELECT_ID_LIKE_IN_TRANSACTION, parameterSource, String.class)
691                .isEmpty();
692        } else {
693            matchingIds = !jdbcTemplate.queryForList(SELECT_ID_LIKE, parameterSource, String.class).isEmpty();
694        }
695        return matchingIds;
696    }
697
698    @Override
699    public Instant containmentLastUpdated(final String txId, final FedoraId fedoraId) {
700        final MapSqlParameterSource parameterSource = new MapSqlParameterSource();
701        parameterSource.addValue("resourceId", fedoraId.getFullId());
702        final String queryToUse;
703        if (txId == null) {
704            queryToUse = SELECT_LAST_UPDATED;
705        } else {
706            parameterSource.addValue("transactionId", txId);
707            queryToUse = SELECT_LAST_UPDATED_IN_TX;
708        }
709        try {
710            return jdbcTemplate.queryForObject(queryToUse, parameterSource, Instant.class);
711        } catch (final EmptyResultDataAccessException e) {
712            return null;
713        }
714    }
715
716    /**
717     * Get the data source backing this containment index
718     * @return data source
719     */
720    public DataSource getDataSource() {
721        return dataSource;
722    }
723
724    /**
725     * Set the data source backing this containment index
726     * @param dataSource data source
727     */
728    public void setDataSource(final DataSource dataSource) {
729        this.dataSource = dataSource;
730    }
731
732    /**
733     * Format an instant to a timestamp without milliseconds, due to precision
734     * issues with memento datetimes.
735     * @param instant the instant to format.
736     * @return the datetime timestamp
737     */
738    private Timestamp formatInstant(final Instant instant) {
739        if (instant == null) {
740            return null;
741        }
742        return Timestamp.from(instant.truncatedTo(ChronoUnit.SECONDS));
743    }
744
745    /**
746     * Private class to back a stream with a paged DB query.
747     *
748     * If this needs to be run in parallel we will have to override trySplit() and determine a good method to split on.
749     */
750    private class ContainmentIterator extends Spliterators.AbstractSpliterator<String> {
751        final Queue<String> children = new ConcurrentLinkedQueue<>();
752        int numOffsets = 0;
753        final String queryToUse;
754        final MapSqlParameterSource parameterSource;
755
756        public ContainmentIterator(final String query, final MapSqlParameterSource parameters) {
757            super(Long.MAX_VALUE, Spliterator.ORDERED);
758            queryToUse = query;
759            parameterSource = parameters;
760            parameterSource.addValue("containsLimit", containsLimit);
761        }
762
763        @Override
764        public boolean tryAdvance(final Consumer<? super String> action) {
765            try {
766                action.accept(children.remove());
767            } catch (final NoSuchElementException e) {
768                parameterSource.addValue("offSet", numOffsets * containsLimit);
769                numOffsets += 1;
770                children.addAll(jdbcTemplate.queryForList(queryToUse, parameterSource, String.class));
771                if (children.size() == 0) {
772                    // no more elements.
773                    return false;
774                }
775                action.accept(children.remove());
776            }
777            return true;
778        }
779    }
780}