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 com.google.common.base.Preconditions;
021import org.fcrepo.common.db.DbPlatform;
022import org.fcrepo.kernel.api.ContainmentIndex;
023import org.fcrepo.kernel.api.exception.RepositoryRuntimeException;
024import org.fcrepo.kernel.api.identifiers.FedoraId;
025import org.slf4j.Logger;
026import org.springframework.core.io.DefaultResourceLoader;
027import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
028import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
029import org.springframework.jdbc.datasource.init.DatabasePopulatorUtils;
030import org.springframework.jdbc.datasource.init.ResourceDatabasePopulator;
031import org.springframework.stereotype.Component;
032import org.springframework.transaction.annotation.Transactional;
033
034import javax.annotation.Nonnull;
035import javax.annotation.PostConstruct;
036import javax.inject.Inject;
037import javax.sql.DataSource;
038
039import java.sql.Timestamp;
040import java.time.Instant;
041import java.util.Collections;
042import java.util.List;
043import java.util.Map;
044import java.util.stream.Stream;
045
046import static org.fcrepo.kernel.api.FedoraTypes.FEDORA_ID_PREFIX;
047import static org.slf4j.LoggerFactory.getLogger;
048
049/**
050 * @author peichman
051 * @author whikloj
052 * @since 6.0.0
053 */
054@Component("containmentIndexImpl")
055public class ContainmentIndexImpl implements ContainmentIndex {
056
057    private static final Logger LOGGER = getLogger(ContainmentIndexImpl.class);
058
059    @Inject
060    private DataSource dataSource;
061
062    private NamedParameterJdbcTemplate jdbcTemplate;
063
064    private DbPlatform dbPlatform;
065
066    public static final String RESOURCES_TABLE = "containment";
067
068    private static final String TRANSACTION_OPERATIONS_TABLE = "containment_transactions";
069
070    public static final String FEDORA_ID_COLUMN = "fedora_id";
071
072    private static final String PARENT_COLUMN = "parent";
073
074    private static final String TRANSACTION_ID_COLUMN = "transaction_id";
075
076    private static final String OPERATION_COLUMN = "operation";
077
078    private static final String START_TIME_COLUMN = "start_time";
079
080    private static final String END_TIME_COLUMN = "end_time";
081
082    /*
083     * Select children of a resource that are not marked as deleted.
084     */
085    private static final String SELECT_CHILDREN = "SELECT " + FEDORA_ID_COLUMN +
086            " FROM " + RESOURCES_TABLE + " WHERE " + PARENT_COLUMN + " = :parent AND " + END_TIME_COLUMN + " IS NULL";
087
088    /*
089     * Select children of a memento of a resource.
090     */
091    private static final String SELECT_CHILDREN_OF_MEMENTO = "SELECT " + FEDORA_ID_COLUMN +
092            " FROM " + RESOURCES_TABLE + " WHERE " + PARENT_COLUMN + " = :parent AND " + START_TIME_COLUMN +
093            " <= :asOfTime AND (" + END_TIME_COLUMN + " > :asOfTime OR " + END_TIME_COLUMN + " IS NULL)";
094
095    /*
096     * Select children of a parent from resources table and from the transaction table with an 'add' operation,
097     * but exclude any records that also exist in the transaction table with a 'delete' or 'purge' operation.
098     */
099    private static final String SELECT_CHILDREN_IN_TRANSACTION = "SELECT x." + FEDORA_ID_COLUMN + " FROM" +
100            " (SELECT " + FEDORA_ID_COLUMN + " FROM " + RESOURCES_TABLE + " WHERE " + PARENT_COLUMN + " = :parent" +
101            " AND " + END_TIME_COLUMN + " IS NULL " +
102            " UNION SELECT " + FEDORA_ID_COLUMN + " FROM " + TRANSACTION_OPERATIONS_TABLE +
103            " WHERE " + PARENT_COLUMN + " = :parent AND " + TRANSACTION_ID_COLUMN + " = :transactionId" +
104            " AND " + OPERATION_COLUMN + " = 'add') x" +
105            " WHERE NOT EXISTS " +
106            " (SELECT 1 FROM " + TRANSACTION_OPERATIONS_TABLE +
107            " WHERE " + PARENT_COLUMN + " = :parent AND " + FEDORA_ID_COLUMN + " = x." + FEDORA_ID_COLUMN +
108            " AND " + TRANSACTION_ID_COLUMN + " = :transactionId AND " + OPERATION_COLUMN + " IN ('delete', 'purge'))";
109
110    /*
111     * Select all children of a resource that are marked for deletion.
112     */
113    private static final String SELECT_DELETED_CHILDREN = "SELECT " + FEDORA_ID_COLUMN +
114            " FROM " + RESOURCES_TABLE + " WHERE " + PARENT_COLUMN + " = :parent AND " + END_TIME_COLUMN +
115            " IS NOT NULL";
116
117    /*
118     * Select children of a resource plus children 'delete'd in the non-committed transaction, but excluding any
119     * 'add'ed in the non-committed transaction.
120     */
121    private static final String SELECT_DELETED_CHILDREN_IN_TRANSACTION = "SELECT x." + FEDORA_ID_COLUMN +
122            " FROM (SELECT " + FEDORA_ID_COLUMN + " FROM " + RESOURCES_TABLE +
123            " WHERE " + PARENT_COLUMN + " = :parent AND " + END_TIME_COLUMN + " IS NOT NULL UNION" +
124            " SELECT " + FEDORA_ID_COLUMN + " FROM " + TRANSACTION_OPERATIONS_TABLE + " WHERE " +
125            PARENT_COLUMN + " = :parent AND " + TRANSACTION_ID_COLUMN + " = :transactionId AND " +
126            OPERATION_COLUMN + " = 'delete') x" +
127            " WHERE NOT EXISTS " +
128            "(SELECT 1 FROM " + TRANSACTION_OPERATIONS_TABLE + " WHERE " + PARENT_COLUMN + " = :parent AND " +
129            FEDORA_ID_COLUMN + " = x." + FEDORA_ID_COLUMN + " AND " + TRANSACTION_ID_COLUMN + " = :transactionId AND " +
130            OPERATION_COLUMN + " = 'add')";
131
132    /*
133     * Insert a parent child relationship to the transaction operation table.
134     */
135    private static final String INSERT_CHILD_IN_TRANSACTION = "INSERT INTO " + TRANSACTION_OPERATIONS_TABLE +
136            " ( " + PARENT_COLUMN + ", " + FEDORA_ID_COLUMN + ", " + START_TIME_COLUMN + ", " + END_TIME_COLUMN + ", " +
137            TRANSACTION_ID_COLUMN + ", " + OPERATION_COLUMN + " ) VALUES (:parent, :child, :startTime, :endTime, " +
138            ":transactionId, 'add')";
139
140    /*
141     * Remove an insert row from the transaction operation table for this parent child relationship.
142     */
143    private static final String UNDO_INSERT_CHILD_IN_TRANSACTION = "DELETE FROM " + TRANSACTION_OPERATIONS_TABLE +
144            " WHERE " + PARENT_COLUMN + " = :parent AND " + FEDORA_ID_COLUMN + " = :child AND " + TRANSACTION_ID_COLUMN
145            + " = :transactionId AND " + OPERATION_COLUMN + " = 'add'";
146
147    /*
148     * Add a parent child relationship deletion to the transaction operation table.
149     */
150    private static final String DELETE_CHILD_IN_TRANSACTION = "INSERT INTO " + TRANSACTION_OPERATIONS_TABLE +
151            " ( " + PARENT_COLUMN + ", " + FEDORA_ID_COLUMN + ", " + END_TIME_COLUMN + ", " + TRANSACTION_ID_COLUMN +
152            ", " + OPERATION_COLUMN + " ) VALUES (:parent, :child, :endTime, :transactionId, 'delete')";
153
154    /*
155     * Add a parent child relationship purge to the transaction operation table.
156     */
157    private static final String PURGE_CHILD_IN_TRANSACTION = "INSERT INTO " + TRANSACTION_OPERATIONS_TABLE +
158            " ( " + PARENT_COLUMN + ", " + FEDORA_ID_COLUMN + ", " + TRANSACTION_ID_COLUMN + ", " + OPERATION_COLUMN +
159            " ) VALUES (:parent, :child, :transactionId, 'purge')";
160
161    /*
162     * Remove a mark as deleted row from the transaction operation table for this child relationship (no parent).
163     */
164    private static final String UNDO_DELETE_CHILD_IN_TRANSACTION_NO_PARENT = "DELETE FROM " +
165            TRANSACTION_OPERATIONS_TABLE + " WHERE " + FEDORA_ID_COLUMN + " = :child AND " + TRANSACTION_ID_COLUMN
166            + " = :transactionId AND " + OPERATION_COLUMN + " = 'delete'";
167
168    /*
169     * Remove a purge row from the transaction operation table for this parent child relationship.
170     */
171    private static final String UNDO_PURGE_CHILD_IN_TRANSACTION = "DELETE FROM " + TRANSACTION_OPERATIONS_TABLE +
172            " WHERE " + PARENT_COLUMN + " = :parent AND " + FEDORA_ID_COLUMN + " = :child AND " + TRANSACTION_ID_COLUMN
173            + " = :transactionId AND " + OPERATION_COLUMN + " = 'purge'";
174
175    /*
176     * Is this parent child relationship being added in this transaction?
177     */
178    private static final String IS_CHILD_ADDED_IN_TRANSACTION = "SELECT TRUE FROM " + TRANSACTION_OPERATIONS_TABLE +
179            " WHERE " + FEDORA_ID_COLUMN + " = :child AND " + PARENT_COLUMN + " = :parent" +
180            " AND " + TRANSACTION_ID_COLUMN + " = :transactionId AND " + OPERATION_COLUMN + " = 'add'";
181
182    /*
183     * Is this child's relationship being marked for deletion in this transaction (no parent)?
184     */
185    private static final String IS_CHILD_DELETED_IN_TRANSACTION_NO_PARENT = "SELECT TRUE FROM " +
186            TRANSACTION_OPERATIONS_TABLE + " WHERE " + FEDORA_ID_COLUMN + " = :child " +
187            " AND " + TRANSACTION_ID_COLUMN + " = :transactionId AND " + OPERATION_COLUMN + " = 'delete'";
188
189    /*
190     * Is this parent child relationship being purged in this transaction?
191     */
192    private static final String IS_CHILD_PURGED_IN_TRANSACTION = "SELECT TRUE FROM " + TRANSACTION_OPERATIONS_TABLE +
193            " WHERE " + FEDORA_ID_COLUMN + " = :child AND " + PARENT_COLUMN + " = :parent" +
194            " AND " + TRANSACTION_ID_COLUMN + " = :transactionId AND " + OPERATION_COLUMN + " = 'purge'";
195
196   /*
197    * Delete all rows from the transaction operation table for this transaction.
198    */
199    private static final String DELETE_ENTIRE_TRANSACTION = "DELETE FROM " + TRANSACTION_OPERATIONS_TABLE + " WHERE " +
200            TRANSACTION_ID_COLUMN + " = :transactionId";
201
202    /*
203     * Add to the main table all rows from the transaction operation table marked 'add' for this transaction.
204     */
205    private static final String COMMIT_ADD_RECORDS = "INSERT INTO " + RESOURCES_TABLE + " ( " + FEDORA_ID_COLUMN + ", "
206            + PARENT_COLUMN + ", " + START_TIME_COLUMN + ", " + END_TIME_COLUMN + " ) SELECT " + FEDORA_ID_COLUMN +
207            ", " + PARENT_COLUMN + ", " + START_TIME_COLUMN + ", " + END_TIME_COLUMN + " FROM " +
208            TRANSACTION_OPERATIONS_TABLE + " WHERE " + TRANSACTION_ID_COLUMN + " = :transactionId AND " +
209            OPERATION_COLUMN + " = 'add'";
210
211    /*
212     * Add an end time to the rows in the main table that match all rows from transaction operation table marked
213     * 'delete' for this transaction.
214     */
215    private static final String COMMIT_DELETE_RECORDS_H2 = "UPDATE " + RESOURCES_TABLE +
216            " r SET r." + END_TIME_COLUMN + " = ( SELECT t." + END_TIME_COLUMN + " FROM " +
217            TRANSACTION_OPERATIONS_TABLE + " t " +
218            " WHERE t." + FEDORA_ID_COLUMN + " = r." + FEDORA_ID_COLUMN + " AND t." + TRANSACTION_ID_COLUMN +
219            " = :transactionId AND t." +  OPERATION_COLUMN +
220            " = 'delete' AND t." + PARENT_COLUMN + " = r." + PARENT_COLUMN + " AND r." +
221            END_TIME_COLUMN + " IS NULL)" +
222            " WHERE EXISTS (SELECT * FROM " + TRANSACTION_OPERATIONS_TABLE + " t WHERE t." + FEDORA_ID_COLUMN +
223            " = r." + FEDORA_ID_COLUMN + " AND t." + TRANSACTION_ID_COLUMN + " = :transactionId AND t." +
224            OPERATION_COLUMN + " = 'delete' AND t." + PARENT_COLUMN + " = r." + PARENT_COLUMN + " AND r." +
225            END_TIME_COLUMN + " IS NULL)";
226
227    private static final String COMMIT_DELETE_RECORDS_MYSQL = "UPDATE " + RESOURCES_TABLE +
228            " r INNER JOIN " + TRANSACTION_OPERATIONS_TABLE + " t ON t." + FEDORA_ID_COLUMN + " = r." +
229            FEDORA_ID_COLUMN + " SET r." + END_TIME_COLUMN + " = t." + END_TIME_COLUMN +
230            ", r." + START_TIME_COLUMN + " = r." + START_TIME_COLUMN + " WHERE t." + PARENT_COLUMN + " = r." +
231            PARENT_COLUMN + " AND t." + TRANSACTION_ID_COLUMN + " = :transactionId AND t." +  OPERATION_COLUMN +
232            " = 'delete' AND r." + END_TIME_COLUMN + " IS NULL";
233
234    private static final String COMMIT_DELETE_RECORDS_POSTGRES = "UPDATE " + RESOURCES_TABLE + " SET " +
235            END_TIME_COLUMN + " = t." + END_TIME_COLUMN + " FROM " + TRANSACTION_OPERATIONS_TABLE + " t WHERE t." +
236            FEDORA_ID_COLUMN + " = " + RESOURCES_TABLE + "." + FEDORA_ID_COLUMN + " AND t." + PARENT_COLUMN +
237            " = " + RESOURCES_TABLE + "." + PARENT_COLUMN + " AND t." + TRANSACTION_ID_COLUMN +
238            " = :transactionId AND t." + OPERATION_COLUMN + " = 'delete' AND " + RESOURCES_TABLE + "." +
239            END_TIME_COLUMN + " IS NULL";
240
241    private Map<DbPlatform, String> COMMIT_DELETE_RECORDS = Map.of(
242            DbPlatform.H2, COMMIT_DELETE_RECORDS_H2,
243            DbPlatform.MARIADB, COMMIT_DELETE_RECORDS_MYSQL,
244            DbPlatform.MYSQL, COMMIT_DELETE_RECORDS_MYSQL,
245            DbPlatform.POSTGRESQL, COMMIT_DELETE_RECORDS_POSTGRES
246    );
247
248    /*
249     * Remove from the main table all rows from transaction operation table marked 'purge' for this transaction.
250     */
251    private static final String COMMIT_PURGE_RECORDS = "DELETE FROM " + RESOURCES_TABLE + " WHERE " +
252            "EXISTS (SELECT * FROM " + TRANSACTION_OPERATIONS_TABLE + " t WHERE t." +
253            TRANSACTION_ID_COLUMN + " = :transactionId AND t." +  OPERATION_COLUMN + " = 'purge' AND" +
254            " t." + FEDORA_ID_COLUMN + " = " + RESOURCES_TABLE + "." + FEDORA_ID_COLUMN +
255            " AND t." + PARENT_COLUMN + " = " + RESOURCES_TABLE + "." + PARENT_COLUMN + ")";
256
257    /*
258     * Query if a resource exists in the main table and is not deleted.
259     */
260    private static final String RESOURCE_EXISTS = "SELECT " + FEDORA_ID_COLUMN + " FROM " + RESOURCES_TABLE +
261            " WHERE " + FEDORA_ID_COLUMN + " = :child AND " + END_TIME_COLUMN + " IS NULL";
262
263    /*
264     * Resource exists as a record in the transaction operations table with an 'add' operation and not also
265     * exists as a 'delete' operation.
266     */
267    private static final String RESOURCE_EXISTS_IN_TRANSACTION = "SELECT x." + FEDORA_ID_COLUMN + " FROM" +
268            " (SELECT " + FEDORA_ID_COLUMN + " FROM " + RESOURCES_TABLE + " WHERE " + FEDORA_ID_COLUMN + " = :child" +
269            "  AND " + END_TIME_COLUMN + " IS NULL UNION SELECT " + FEDORA_ID_COLUMN + " FROM " +
270            TRANSACTION_OPERATIONS_TABLE + " WHERE " + FEDORA_ID_COLUMN + " = :child AND " + TRANSACTION_ID_COLUMN +
271            " = :transactionId" + " AND " + OPERATION_COLUMN + " = 'add') x WHERE NOT EXISTS " +
272            " (SELECT 1 FROM " + TRANSACTION_OPERATIONS_TABLE +
273            " WHERE " + FEDORA_ID_COLUMN + " = :child AND " + TRANSACTION_ID_COLUMN + " = :transactionId" +
274            " AND " + OPERATION_COLUMN + " IN ('delete', 'purge'))";
275
276    /*
277     * Query if a resource exists in the main table even if it is deleted.
278     */
279    private static final String RESOURCE_OR_TOMBSTONE_EXISTS = "SELECT " + FEDORA_ID_COLUMN + " FROM " +
280            RESOURCES_TABLE + " WHERE " + FEDORA_ID_COLUMN + " = :child";
281
282    /*
283     * Resource exists as a record in the main table even if deleted or in the transaction operations table with an
284     * 'add' operation and not also exists as a 'delete' operation.
285     */
286    private static final String RESOURCE_OR_TOMBSTONE_EXISTS_IN_TRANSACTION = "SELECT x." + FEDORA_ID_COLUMN + " FROM" +
287            " (SELECT " + FEDORA_ID_COLUMN + " FROM " + RESOURCES_TABLE + " WHERE " + FEDORA_ID_COLUMN + " = :child" +
288            " UNION SELECT " + FEDORA_ID_COLUMN + " FROM " +
289            TRANSACTION_OPERATIONS_TABLE + " WHERE " + FEDORA_ID_COLUMN + " = :child AND " + TRANSACTION_ID_COLUMN +
290            " = :transactionId" + " AND " + OPERATION_COLUMN + " = 'add') x WHERE NOT EXISTS " +
291            " (SELECT 1 FROM " + TRANSACTION_OPERATIONS_TABLE +
292            " WHERE " + FEDORA_ID_COLUMN + " = :child AND " + TRANSACTION_ID_COLUMN + " = :transactionId" +
293            " AND " + OPERATION_COLUMN + " IN ('delete', 'purge'))";
294
295
296    /*
297     * Get the parent ID for this resource from the main table if not deleted.
298     */
299    private static final String PARENT_EXISTS = "SELECT " + PARENT_COLUMN + " FROM " + RESOURCES_TABLE +
300            " WHERE " + FEDORA_ID_COLUMN + " = :child AND " + END_TIME_COLUMN + " IS NULL";
301
302    /*
303     * Get the parent ID for this resource from the operations table for an 'add' operation in this transaction, but
304     * exclude any 'delete' operations for this resource in this transaction.
305     */
306    private static final String PARENT_EXISTS_IN_TRANSACTION = "SELECT x." + PARENT_COLUMN + " FROM" +
307            " (SELECT " + PARENT_COLUMN + " FROM " + RESOURCES_TABLE + " WHERE " + FEDORA_ID_COLUMN + " = :child" +
308            " AND " + END_TIME_COLUMN + " IS NULL" +
309            " UNION SELECT " + PARENT_COLUMN + " FROM " + TRANSACTION_OPERATIONS_TABLE +
310            " WHERE " + FEDORA_ID_COLUMN + " = :child AND " + TRANSACTION_ID_COLUMN + " = :transactionId" +
311            " AND " + OPERATION_COLUMN + " = 'add') x" +
312            " WHERE NOT EXISTS " +
313            " (SELECT 1 FROM " + TRANSACTION_OPERATIONS_TABLE +
314            " WHERE " + FEDORA_ID_COLUMN + " = :child AND " + TRANSACTION_ID_COLUMN + " = :transactionId" +
315            " AND " + OPERATION_COLUMN + " = 'delete')";
316
317    /*
318     * Get the parent ID for this resource from the main table if deleted.
319     */
320    private static final String PARENT_EXISTS_DELETED = "SELECT " + PARENT_COLUMN + " FROM " + RESOURCES_TABLE +
321            " WHERE " + FEDORA_ID_COLUMN + " = :child AND " + END_TIME_COLUMN + " IS NOT NULL";
322
323    /*
324     * Get the parent ID for this resource from main table and the operations table for a 'delete' operation in this
325     * transaction, excluding any 'add' operations for this resource in this transaction.
326     */
327    private static final String PARENT_EXISTS_DELETED_IN_TRANSACTION = "SELECT x." + PARENT_COLUMN + " FROM" +
328            " (SELECT " + PARENT_COLUMN + " FROM " + RESOURCES_TABLE + " WHERE " + FEDORA_ID_COLUMN + " = :child" +
329            " AND " + END_TIME_COLUMN + " IS NOT NULL UNION SELECT " + PARENT_COLUMN + " FROM " +
330            TRANSACTION_OPERATIONS_TABLE + " WHERE " + FEDORA_ID_COLUMN + " = :child AND " + TRANSACTION_ID_COLUMN +
331            " = :transactionId AND " + OPERATION_COLUMN + " = 'delete') x WHERE NOT EXISTS " +
332            " (SELECT 1 FROM " + TRANSACTION_OPERATIONS_TABLE + " WHERE " + FEDORA_ID_COLUMN + " = :child AND " +
333            TRANSACTION_ID_COLUMN + " = :transactionId AND " + OPERATION_COLUMN + " = 'add')";
334
335    /*
336     * Does this resource exist in the transaction operation table for an 'add' record.
337     */
338    private static final String IS_CHILD_ADDED_IN_TRANSACTION_NO_PARENT = "SELECT TRUE FROM " +
339            TRANSACTION_OPERATIONS_TABLE + " WHERE " + FEDORA_ID_COLUMN + " = :child AND " +
340            TRANSACTION_ID_COLUMN + " = :transactionId AND " + OPERATION_COLUMN + " = 'add'";
341
342    /*
343     * Delete a row from the transaction operation table with this resource and 'add' operation, no parent required.
344     */
345    private static final String UNDO_INSERT_CHILD_IN_TRANSACTION_NO_PARENT = "DELETE FROM " +
346            TRANSACTION_OPERATIONS_TABLE + " WHERE " + FEDORA_ID_COLUMN + " = :child AND " + TRANSACTION_ID_COLUMN
347            + " = :transactionId AND " + OPERATION_COLUMN + " = 'add'";
348
349    private static final String TRUNCATE_TABLE = "TRUNCATE TABLE ";
350
351    /*
352     * Any record tracked in the containment index is either active or a tombstone. Either way it exists for the
353     * purpose of finding ghost nodes.
354     */
355    private static final String SELECT_ID_LIKE = "SELECT " + FEDORA_ID_COLUMN + " FROM " + RESOURCES_TABLE + " WHERE " +
356            FEDORA_ID_COLUMN + " LIKE :resourceId";
357
358    private static final String SELECT_ID_LIKE_IN_TRANSACTION = "SELECT x." + FEDORA_ID_COLUMN + " FROM (SELECT " +
359            FEDORA_ID_COLUMN + " FROM " + RESOURCES_TABLE + " WHERE " + FEDORA_ID_COLUMN + " LIKE :resourceId" +
360            " UNION SELECT " + FEDORA_ID_COLUMN + " FROM " + TRANSACTION_OPERATIONS_TABLE + " WHERE " +
361            FEDORA_ID_COLUMN + " LIKE :resourceId AND " + TRANSACTION_ID_COLUMN + " = :transactionId AND " +
362            OPERATION_COLUMN + " = 'add') x WHERE NOT EXISTS (SELECT 1 FROM " + TRANSACTION_OPERATIONS_TABLE +
363            " WHERE " + FEDORA_ID_COLUMN + " LIKE :resourceId AND " + TRANSACTION_ID_COLUMN + " = :transactionId AND " +
364            OPERATION_COLUMN + " = 'delete')";
365
366    private static final Map<DbPlatform, String> DDL_MAP = Map.of(
367            DbPlatform.MYSQL, "sql/mysql-containment.sql",
368            DbPlatform.H2, "sql/default-containment.sql",
369            DbPlatform.POSTGRESQL, "sql/postgres-containment.sql",
370            DbPlatform.MARIADB, "sql/default-containment.sql"
371    );
372
373    /**
374     * Connect to the database
375     */
376    @PostConstruct
377    private void setup() {
378        jdbcTemplate = getNamedParameterJdbcTemplate();
379
380        dbPlatform = DbPlatform.fromDataSource(dataSource);
381
382        Preconditions.checkArgument(DDL_MAP.containsKey(dbPlatform),
383                "Missing DDL mapping for %s", dbPlatform);
384
385        final var ddl = DDL_MAP.get(dbPlatform);
386        LOGGER.info("Applying ddl: {}", ddl);
387        DatabasePopulatorUtils.execute(
388                new ResourceDatabasePopulator(new DefaultResourceLoader().getResource("classpath:" + ddl)),
389                dataSource);
390    }
391
392    private NamedParameterJdbcTemplate getNamedParameterJdbcTemplate() {
393        return new NamedParameterJdbcTemplate(getDataSource());
394    }
395
396    @Override
397    public Stream<String> getContains(final String txId, final FedoraId fedoraId) {
398        final String resourceId = fedoraId.isMemento() ? fedoraId.getBaseId() : fedoraId.getFullId();
399        final Instant asOfTime = fedoraId.isMemento() ? fedoraId.getMementoInstant() : null;
400        final MapSqlParameterSource parameterSource = new MapSqlParameterSource();
401        parameterSource.addValue("parent", resourceId);
402
403        final List<String> children;
404        if (asOfTime == null) {
405            if (txId != null) {
406                // we are in a transaction
407                parameterSource.addValue("transactionId", txId);
408                children = jdbcTemplate.queryForList(SELECT_CHILDREN_IN_TRANSACTION, parameterSource, String.class);
409            } else {
410                // not in a transaction
411                children = jdbcTemplate.queryForList(SELECT_CHILDREN, parameterSource, String.class);
412            }
413        } else {
414            parameterSource.addValue("asOfTime", formatInstant(asOfTime));
415            children = jdbcTemplate.queryForList(SELECT_CHILDREN_OF_MEMENTO, parameterSource, String.class);
416        }
417        LOGGER.debug("getContains for {} in transaction {} and instant {} found {} children",
418                resourceId, txId, asOfTime, children.size());
419        return children.stream();
420    }
421
422    @Override
423    public Stream<String> getContainsDeleted(final String txId, final FedoraId fedoraId) {
424        final String resourceId = fedoraId.getFullId();
425        final MapSqlParameterSource parameterSource = new MapSqlParameterSource();
426        parameterSource.addValue("parent", resourceId);
427
428        final List<String> children;
429        if (txId != null) {
430            // we are in a transaction
431            parameterSource.addValue("transactionId", txId);
432            children = jdbcTemplate.queryForList(SELECT_DELETED_CHILDREN_IN_TRANSACTION, parameterSource, String.class);
433        } else {
434            // not in a transaction
435            children = jdbcTemplate.queryForList(SELECT_DELETED_CHILDREN, parameterSource, String.class);
436        }
437        LOGGER.debug("getContainsDeleted for {} in transaction {} found {} children",
438                resourceId, txId, children.size());
439        return children.stream();
440    }
441
442    @Override
443    public String getContainedBy(final String txId, final FedoraId resource) {
444        final String resourceID = resource.getFullId();
445        final MapSqlParameterSource parameterSource = new MapSqlParameterSource();
446        parameterSource.addValue("child", resourceID);
447        final List<String> parentID;
448        if (txId != null) {
449            parameterSource.addValue("transactionId", txId);
450            parentID = jdbcTemplate.queryForList(PARENT_EXISTS_IN_TRANSACTION, parameterSource, String.class);
451        } else {
452            parentID = jdbcTemplate.queryForList(PARENT_EXISTS, parameterSource, String.class);
453        }
454        return parentID.stream().findFirst().orElse(null);
455    }
456
457    @Override
458    public void addContainedBy(@Nonnull final String txId, final FedoraId parent, final FedoraId child) {
459        addContainedBy(txId, parent, child, Instant.now(), null);
460    }
461
462    @Override
463    public void addContainedBy(@Nonnull final String txId, final FedoraId parent, final FedoraId child,
464                               final Instant startTime, final Instant endTime) {
465        final String parentID = parent.getFullId();
466        final String childID = child.getFullId();
467        final MapSqlParameterSource parameterSource = new MapSqlParameterSource();
468
469        LOGGER.debug("Adding: parent: {}, child: {}, in txn: {}, start time {}, end time {}", parentID, childID, txId,
470                formatInstant(startTime), formatInstant(endTime));
471
472        parameterSource.addValue("parent", parentID);
473        parameterSource.addValue("child", childID);
474        parameterSource.addValue("transactionId", txId);
475        parameterSource.addValue("startTime", formatInstant(startTime));
476        parameterSource.addValue("endTime", formatInstant(endTime));
477        final boolean purgedInTxn = !jdbcTemplate.queryForList(IS_CHILD_PURGED_IN_TRANSACTION, parameterSource)
478                .isEmpty();
479        if (purgedInTxn) {
480            // We purged it, but are re-adding it so remove the purge operation.
481            jdbcTemplate.update(UNDO_PURGE_CHILD_IN_TRANSACTION, parameterSource);
482        }
483        jdbcTemplate.update(INSERT_CHILD_IN_TRANSACTION, parameterSource);
484    }
485
486    @Override
487    public void removeContainedBy(@Nonnull final String txId, final FedoraId parent, final FedoraId child) {
488        final String parentID = parent.getFullId();
489        final String childID = child.getFullId();
490        final MapSqlParameterSource parameterSource = new MapSqlParameterSource();
491        parameterSource.addValue("parent", parentID);
492        parameterSource.addValue("child", childID);
493        parameterSource.addValue("transactionId", txId);
494        parameterSource.addValue("endTime", formatInstant(Instant.now()));
495        final boolean addedInTxn = !jdbcTemplate.queryForList(IS_CHILD_ADDED_IN_TRANSACTION, parameterSource)
496                .isEmpty();
497        if (addedInTxn) {
498            jdbcTemplate.update(UNDO_INSERT_CHILD_IN_TRANSACTION, parameterSource);
499        } else {
500            jdbcTemplate.update(DELETE_CHILD_IN_TRANSACTION, parameterSource);
501        }
502    }
503
504    @Override
505    public void removeResource(@Nonnull final String txId, final FedoraId resource) {
506        final String resourceID = resource.getFullId();
507        final MapSqlParameterSource parameterSource = new MapSqlParameterSource();
508        parameterSource.addValue("child", resourceID);
509        parameterSource.addValue("transactionId", txId);
510        final boolean addedInTxn = !jdbcTemplate.queryForList(IS_CHILD_ADDED_IN_TRANSACTION_NO_PARENT,
511                parameterSource).isEmpty();
512        if (addedInTxn) {
513            jdbcTemplate.update(UNDO_INSERT_CHILD_IN_TRANSACTION_NO_PARENT, parameterSource);
514        } else {
515            final String parent = getContainedBy(txId, resource);
516            if (parent != null) {
517                LOGGER.debug("Marking containment relationship between parent ({}) and child ({}) deleted", parent,
518                        resourceID);
519                parameterSource.addValue("parent", parent);
520                parameterSource.addValue("endTime", formatInstant(Instant.now()));
521                jdbcTemplate.update(DELETE_CHILD_IN_TRANSACTION, parameterSource);
522            }
523        }
524    }
525
526    @Override
527    public void purgeResource(@Nonnull final String txId, final FedoraId resource) {
528        final String resourceID = resource.getFullId();
529        final MapSqlParameterSource parameterSource = new MapSqlParameterSource();
530        parameterSource.addValue("child", resourceID);
531        parameterSource.addValue("transactionId", txId);
532        final String parent = getContainedByDeleted(txId, resource);
533        final boolean deletedInTxn = !jdbcTemplate.queryForList(IS_CHILD_DELETED_IN_TRANSACTION_NO_PARENT,
534                parameterSource).isEmpty();
535        if (deletedInTxn) {
536            jdbcTemplate.update(UNDO_DELETE_CHILD_IN_TRANSACTION_NO_PARENT, parameterSource);
537        }
538        if (parent != null) {
539            LOGGER.debug("Removing containment relationship between parent ({}) and child ({})", parent, resourceID);
540            parameterSource.addValue("parent", parent);
541            jdbcTemplate.update(PURGE_CHILD_IN_TRANSACTION, parameterSource);
542        }
543    }
544
545    /**
546     * Find parent for a resource using a deleted containment relationship.
547     * @param txId the transaction id.
548     * @param resource the child resource id.
549     * @return the parent id.
550     */
551    private String getContainedByDeleted(final String txId, final FedoraId resource) {
552        final String resourceID = resource.getFullId();
553        final MapSqlParameterSource parameterSource = new MapSqlParameterSource();
554        parameterSource.addValue("child", resourceID);
555        final List<String> parentID;
556        if (txId != null) {
557            parameterSource.addValue("transactionId", txId);
558            parentID = jdbcTemplate.queryForList(PARENT_EXISTS_DELETED_IN_TRANSACTION, parameterSource, String.class);
559        } else {
560            parentID = jdbcTemplate.queryForList(PARENT_EXISTS_DELETED, parameterSource, String.class);
561        }
562        return parentID.stream().findFirst().orElse(null);
563    }
564
565    @Transactional
566    @Override
567    public void commitTransaction(final String txId) {
568        if (txId != null) {
569            try {
570                final MapSqlParameterSource parameterSource = new MapSqlParameterSource();
571                parameterSource.addValue("transactionId", txId);
572
573                jdbcTemplate.update(COMMIT_PURGE_RECORDS, parameterSource);
574                jdbcTemplate.update(COMMIT_DELETE_RECORDS.get(dbPlatform), parameterSource);
575                jdbcTemplate.update(COMMIT_ADD_RECORDS, parameterSource);
576                jdbcTemplate.update(DELETE_ENTIRE_TRANSACTION, parameterSource);
577            } catch (final Exception e) {
578                LOGGER.warn("Unable to commit containment index transaction {}: {}", txId, e.getMessage());
579                throw new RepositoryRuntimeException("Unable to commit containment index transaction", e);
580            }
581        }
582    }
583
584    @Override
585    public void rollbackTransaction(final String txId) {
586        if (txId != null) {
587            final MapSqlParameterSource parameterSource = new MapSqlParameterSource();
588            parameterSource.addValue("transactionId", txId);
589            jdbcTemplate.update(DELETE_ENTIRE_TRANSACTION, parameterSource);
590        }
591    }
592
593    @Override
594    public boolean resourceExists(final String txId, final FedoraId fedoraId, final boolean includeDeleted) {
595        // Get the containing ID because fcr:metadata will not exist here but MUST exist if the containing resource does
596        final String resourceId = fedoraId.getBaseId();
597        LOGGER.debug("Checking if {} exists in transaction {}", resourceId, txId);
598        if (fedoraId.isRepositoryRoot()) {
599            // Root always exists.
600            return true;
601        }
602        final MapSqlParameterSource parameterSource = new MapSqlParameterSource();
603        parameterSource.addValue("child", resourceId);
604        final boolean exists;
605        if (txId != null) {
606            final var queryToUse = includeDeleted ? RESOURCE_OR_TOMBSTONE_EXISTS_IN_TRANSACTION :
607                    RESOURCE_EXISTS_IN_TRANSACTION;
608            parameterSource.addValue("transactionId", txId);
609            exists = !jdbcTemplate.queryForList(queryToUse, parameterSource, String.class)
610                    .isEmpty();
611        } else {
612            final var queryToUse = includeDeleted ? RESOURCE_OR_TOMBSTONE_EXISTS :
613                    RESOURCE_EXISTS;
614            exists = !jdbcTemplate.queryForList(queryToUse, parameterSource, String.class).isEmpty();
615        }
616        return exists;
617    }
618
619    @Override
620    public FedoraId getContainerIdByPath(final String txId, final FedoraId fedoraId, final boolean checkDeleted) {
621        if (fedoraId.isRepositoryRoot()) {
622            // If we are root then we are the top.
623            return fedoraId;
624        }
625        final String parent = getContainedBy(txId, fedoraId);
626        if (parent != null) {
627            return FedoraId.create(parent);
628        }
629        String fullId = fedoraId.getFullId();
630        while (fullId.contains("/")) {
631            fullId = fedoraId.getResourceId().substring(0, fullId.lastIndexOf("/"));
632            if (fullId.equals(FEDORA_ID_PREFIX)) {
633                return FedoraId.getRepositoryRootId();
634            }
635            final FedoraId testID = FedoraId.create(fullId);
636            if (resourceExists(txId, testID, checkDeleted)) {
637                return testID;
638            }
639        }
640        return FedoraId.getRepositoryRootId();
641    }
642
643    @Transactional
644    @Override
645    public void reset() {
646        try {
647            jdbcTemplate.update(TRUNCATE_TABLE + RESOURCES_TABLE, Collections.emptyMap());
648            jdbcTemplate.update(TRUNCATE_TABLE + TRANSACTION_OPERATIONS_TABLE, Collections.emptyMap());
649        } catch (final Exception e) {
650            throw new RepositoryRuntimeException("Failed to truncate containment tables", e);
651        }
652    }
653
654    @Override
655    public boolean hasResourcesStartingWith(final String txId, final FedoraId fedoraId) {
656        final MapSqlParameterSource parameterSource = new MapSqlParameterSource();
657        parameterSource.addValue("resourceId", fedoraId.getFullId() + "/%");
658        final boolean matchingIds;
659        if (txId != null) {
660            parameterSource.addValue("transactionId", txId);
661            matchingIds = !jdbcTemplate.queryForList(SELECT_ID_LIKE_IN_TRANSACTION, parameterSource, String.class)
662                .isEmpty();
663        } else {
664            matchingIds = !jdbcTemplate.queryForList(SELECT_ID_LIKE, parameterSource, String.class).isEmpty();
665        }
666        return matchingIds;
667    }
668
669    /**
670     * Get the data source backing this containment index
671     * @return data source
672     */
673    public DataSource getDataSource() {
674        return dataSource;
675    }
676
677    /**
678     * Set the data source backing this containment index
679     * @param dataSource data source
680     */
681    public void setDataSource(final DataSource dataSource) {
682        this.dataSource = dataSource;
683    }
684
685    /**
686     * Format an instant to a timestamp without milliseconds, due to precision
687     * issues with memento datetimes.
688     * @param instant
689     * @return the timestamp
690     */
691    private Timestamp formatInstant(final Instant instant) {
692        if (instant == null) {
693            return null;
694        }
695        final var timestamp = Timestamp.from(instant);
696        timestamp.setNanos(0);
697        return timestamp;
698    }
699}