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 */
006
007package org.fcrepo.persistence.ocfl.impl;
008
009import java.util.Collections;
010import java.util.List;
011import java.util.Map;
012import java.util.concurrent.TimeUnit;
013
014import javax.annotation.Nonnull;
015import javax.annotation.PostConstruct;
016import javax.inject.Inject;
017import javax.sql.DataSource;
018
019import org.fcrepo.common.db.DbPlatform;
020import org.fcrepo.kernel.api.Transaction;
021import org.fcrepo.config.OcflPropsConfig;
022import org.fcrepo.kernel.api.exception.InvalidResourceIdentifierException;
023import org.fcrepo.kernel.api.exception.RepositoryRuntimeException;
024import org.fcrepo.kernel.api.identifiers.FedoraId;
025import org.fcrepo.persistence.ocfl.api.FedoraOcflMappingNotFoundException;
026import org.fcrepo.persistence.ocfl.api.FedoraToOcflObjectIndex;
027import org.fcrepo.storage.ocfl.cache.Cache;
028import org.fcrepo.storage.ocfl.cache.CaffeineCache;
029
030import org.slf4j.Logger;
031import org.slf4j.LoggerFactory;
032import org.springframework.beans.factory.annotation.Autowired;
033import org.springframework.dao.DataIntegrityViolationException;
034import org.springframework.dao.DuplicateKeyException;
035import org.springframework.dao.EmptyResultDataAccessException;
036import org.springframework.jdbc.BadSqlGrammarException;
037import org.springframework.jdbc.core.RowMapper;
038import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
039import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
040import org.springframework.stereotype.Component;
041import org.springframework.transaction.annotation.Propagation;
042import org.springframework.transaction.annotation.Transactional;
043
044import com.github.benmanes.caffeine.cache.Caffeine;
045
046/**
047 * Maps Fedora IDs to the OCFL IDs of the OCFL objects the Fedora resource is stored in. This implementation is backed
048 * by a relational database.
049 *
050 * @author pwinckles
051 */
052@Component("ocflIndexImpl")
053public class DbFedoraToOcflObjectIndex implements FedoraToOcflObjectIndex {
054
055    private static final Logger LOGGER = LoggerFactory.getLogger(DbFedoraToOcflObjectIndex.class);
056
057    private static final String MAPPING_TABLE = "ocfl_id_map";
058
059    private static final String FEDORA_ID_COLUMN = "fedora_id";
060
061    private static final String FEDORA_ROOT_ID_COLUMN = "fedora_root_id";
062
063    private static final String OCFL_ID_COLUMN = "ocfl_id";
064
065    private static final String TRANSACTION_OPERATIONS_TABLE = "ocfl_id_map_session_operations";
066
067    private static final String TRANSACTION_ID_COLUMN = "session_id";
068
069    private static final String OPERATION_COLUMN = "operation";
070
071    /*
072     * Lookup all mappings for the resource id.
073     */
074    private static final String LOOKUP_MAPPING = "SELECT " + FEDORA_ROOT_ID_COLUMN + ", " + OCFL_ID_COLUMN + " FROM " +
075            MAPPING_TABLE + " WHERE " + FEDORA_ID_COLUMN + " = :fedoraId";
076
077    /*
078     * Lookup all mappings from the mapping table as well as any new 'add's and excluding any 'delete's in this
079     * transaction.
080     */
081    private static final String LOOKUP_MAPPING_IN_TRANSACTION = "SELECT x." + FEDORA_ROOT_ID_COLUMN + "," +
082            " x." + OCFL_ID_COLUMN + " FROM" +
083            " (SELECT " + FEDORA_ROOT_ID_COLUMN + ", " + OCFL_ID_COLUMN + " FROM " + MAPPING_TABLE + " WHERE " +
084            FEDORA_ID_COLUMN + " = :fedoraId" +
085            " UNION SELECT " + FEDORA_ROOT_ID_COLUMN + ", " + OCFL_ID_COLUMN + " FROM " + TRANSACTION_OPERATIONS_TABLE +
086            " WHERE " + FEDORA_ID_COLUMN + " = :fedoraId AND " + TRANSACTION_ID_COLUMN + " = :transactionId" +
087            " AND " + OPERATION_COLUMN + " = 'add') x";
088
089    /*
090     * Add an 'add' operation to the transaction table.
091     */
092    private static final String UPSERT_MAPPING_TX_POSTGRESQL = "INSERT INTO " + TRANSACTION_OPERATIONS_TABLE +
093            " ( " + FEDORA_ID_COLUMN + ", " + FEDORA_ROOT_ID_COLUMN + ", " + OCFL_ID_COLUMN + ", " +
094            TRANSACTION_ID_COLUMN + ", " + OPERATION_COLUMN + ") VALUES (:fedoraId, :fedoraRootId, :ocflId," +
095            " :transactionId, :operation) ON CONFLICT (" + FEDORA_ID_COLUMN + ", " + TRANSACTION_ID_COLUMN + ")" +
096            " DO UPDATE SET " + FEDORA_ROOT_ID_COLUMN + " = EXCLUDED." + FEDORA_ROOT_ID_COLUMN + ", " +
097            OCFL_ID_COLUMN + " = EXCLUDED." + OCFL_ID_COLUMN + ", " + OPERATION_COLUMN + " = EXCLUDED." +
098            OPERATION_COLUMN;
099
100    private static final String UPSERT_MAPPING_TX_MYSQL_MARIA = "INSERT INTO " + TRANSACTION_OPERATIONS_TABLE +
101            " (" + FEDORA_ID_COLUMN + ", " + FEDORA_ROOT_ID_COLUMN + ", " + OCFL_ID_COLUMN + ", " +
102            TRANSACTION_ID_COLUMN + ", " + OPERATION_COLUMN + ")" +
103            " VALUES (:fedoraId, :fedoraRootId, :ocflId, :transactionId, :operation) ON DUPLICATE KEY UPDATE " +
104            FEDORA_ROOT_ID_COLUMN + " = VALUES(" + FEDORA_ROOT_ID_COLUMN + "), " + OCFL_ID_COLUMN + " = VALUES(" +
105            OCFL_ID_COLUMN + "), " + OPERATION_COLUMN + " = VALUES(" + OPERATION_COLUMN + ")";
106
107    private static final String UPSERT_MAPPING_TX_H2 = "MERGE INTO " + TRANSACTION_OPERATIONS_TABLE +
108            " (" + FEDORA_ID_COLUMN + ", " + FEDORA_ROOT_ID_COLUMN + ", " + OCFL_ID_COLUMN + ", " +
109            TRANSACTION_ID_COLUMN + ", " + OPERATION_COLUMN + ")" +
110            " KEY (" + FEDORA_ID_COLUMN + ", " + TRANSACTION_ID_COLUMN + ")" +
111            " VALUES (:fedoraId, :fedoraRootId, :ocflId, :transactionId, :operation)";
112
113    private static final String DIRECT_INSERT_MAPPING = "INSERT INTO " + MAPPING_TABLE +
114            " (" + FEDORA_ID_COLUMN + ", " + FEDORA_ROOT_ID_COLUMN + ", " + OCFL_ID_COLUMN + ")" +
115            " VALUES (:fedoraId, :fedoraRootId, :ocflId)";
116
117    /**
118     * Map of database product to UPSERT into operations table SQL.
119     */
120    private static final Map<DbPlatform, String> UPSERT_MAPPING_TX_MAP = Map.of(
121            DbPlatform.MYSQL, UPSERT_MAPPING_TX_MYSQL_MARIA,
122            DbPlatform.H2, UPSERT_MAPPING_TX_H2,
123            DbPlatform.POSTGRESQL, UPSERT_MAPPING_TX_POSTGRESQL,
124            DbPlatform.MARIADB, UPSERT_MAPPING_TX_MYSQL_MARIA
125    );
126
127    private static final String DIRECT_DELETE_MAPPING = "DELETE FROM ocfl_id_map WHERE fedora_id = :fedoraId";
128
129    private static final String COMMIT_ADD_MAPPING_POSTGRESQL = "INSERT INTO " + MAPPING_TABLE +
130            " ( " + FEDORA_ID_COLUMN + ", " + FEDORA_ROOT_ID_COLUMN + ", " + OCFL_ID_COLUMN + ") SELECT " +
131            FEDORA_ID_COLUMN + ", " + FEDORA_ROOT_ID_COLUMN + ", " + OCFL_ID_COLUMN + " FROM " +
132            TRANSACTION_OPERATIONS_TABLE + " WHERE " + OPERATION_COLUMN + " = 'add' AND " + TRANSACTION_ID_COLUMN +
133            " = :transactionId ON CONFLICT ( " +  FEDORA_ID_COLUMN + " )" +
134            " DO UPDATE SET " + FEDORA_ROOT_ID_COLUMN + " = EXCLUDED." + FEDORA_ROOT_ID_COLUMN + ", " +
135            OCFL_ID_COLUMN + " = EXCLUDED." + OCFL_ID_COLUMN;
136
137    private static final String COMMIT_ADD_MAPPING_MYSQL_MARIA = "INSERT INTO " + MAPPING_TABLE +
138            " (" + FEDORA_ID_COLUMN + ", " + FEDORA_ROOT_ID_COLUMN + ", " + OCFL_ID_COLUMN + ") SELECT " +
139            FEDORA_ID_COLUMN + ", " + FEDORA_ROOT_ID_COLUMN + ", " + OCFL_ID_COLUMN + " FROM " +
140            TRANSACTION_OPERATIONS_TABLE + " WHERE " + OPERATION_COLUMN + " = 'add' AND " + TRANSACTION_ID_COLUMN +
141            " = :transactionId ON DUPLICATE KEY UPDATE " +
142            FEDORA_ROOT_ID_COLUMN + " = VALUES(" + FEDORA_ROOT_ID_COLUMN + "), " + OCFL_ID_COLUMN + " = VALUES(" +
143            OCFL_ID_COLUMN + ")";
144
145    private static final String COMMIT_ADD_MAPPING_H2 = "MERGE INTO " + MAPPING_TABLE +
146            " (" + FEDORA_ID_COLUMN + ", " + FEDORA_ROOT_ID_COLUMN + ", " + OCFL_ID_COLUMN + ")" +
147            " SELECT " + FEDORA_ID_COLUMN + ", " + FEDORA_ROOT_ID_COLUMN + ", " + OCFL_ID_COLUMN + " FROM " +
148            TRANSACTION_OPERATIONS_TABLE + " WHERE " + OPERATION_COLUMN + " = 'add'";
149
150    /**
151     * Map of database product name to COMMIT to mapping table from operations table
152     */
153    private static final Map<DbPlatform, String> COMMIT_ADD_MAPPING_MAP = Map.of(
154            DbPlatform.MYSQL, COMMIT_ADD_MAPPING_MYSQL_MARIA,
155            DbPlatform.H2, COMMIT_ADD_MAPPING_H2,
156            DbPlatform.POSTGRESQL, COMMIT_ADD_MAPPING_POSTGRESQL,
157            DbPlatform.MARIADB, COMMIT_ADD_MAPPING_MYSQL_MARIA
158    );
159
160    /*
161     * Delete records from the mapping table that are to be deleted in this transaction.
162     */
163    private static final String COMMIT_DELETE_RECORDS = "DELETE FROM " + MAPPING_TABLE + " WHERE " +
164            "EXISTS (SELECT * FROM " + TRANSACTION_OPERATIONS_TABLE + " WHERE " +
165            TRANSACTION_ID_COLUMN + " = :transactionId AND " +  OPERATION_COLUMN + " = 'delete' AND " +
166            MAPPING_TABLE + "." + FEDORA_ID_COLUMN + " = " + TRANSACTION_OPERATIONS_TABLE + "." + FEDORA_ID_COLUMN +
167            ")";
168
169    /*
170     * Collect IDs to invalidate on transaction commit.
171     */
172    private static final String GET_DELETE_IDS = "SELECT " + FEDORA_ID_COLUMN + " FROM " +
173            TRANSACTION_OPERATIONS_TABLE + " WHERE " + TRANSACTION_ID_COLUMN + " = :transactionId AND " +
174            OPERATION_COLUMN + " = 'delete'";
175
176    private static final String TRUNCATE_MAPPINGS = "TRUNCATE TABLE " + MAPPING_TABLE;
177
178    private static final String TRUNCATE_TRANSACTIONS = "TRUNCATE TABLE " + TRANSACTION_OPERATIONS_TABLE;
179
180    /*
181     * Delete all records from the transaction table for the specified transaction.
182     */
183    private static final String DELETE_ENTIRE_TRANSACTION = "DELETE FROM " + TRANSACTION_OPERATIONS_TABLE + " WHERE " +
184            TRANSACTION_ID_COLUMN + " = :transactionId";
185
186    /*
187     * Row mapper for the Lookup queries.
188     */
189    private static final RowMapper<FedoraOcflMapping> GET_MAPPING_ROW_MAPPER = (resultSet, i) -> new FedoraOcflMapping(
190            FedoraId.create(resultSet.getString(1)),
191            resultSet.getString(2)
192    );
193
194    private Cache<String, FedoraOcflMapping> mappingCache;
195
196    private final DataSource dataSource;
197
198    private final NamedParameterJdbcTemplate jdbcTemplate;
199
200    private DbPlatform dbPlatform;
201
202    @Inject
203    private OcflPropsConfig ocflPropsConfig;
204
205    public DbFedoraToOcflObjectIndex(@Autowired final DataSource dataSource) {
206        this.dataSource = dataSource;
207        this.jdbcTemplate = new NamedParameterJdbcTemplate(dataSource);
208    }
209
210    @PostConstruct
211    public void setup() {
212        dbPlatform = DbPlatform.fromDataSource(dataSource);
213        final var cache = Caffeine.newBuilder()
214                .maximumSize(ocflPropsConfig.getFedoraToOcflCacheSize())
215                .expireAfterAccess(ocflPropsConfig.getFedoraToOcflCacheTimeout(), TimeUnit.MINUTES)
216                .build();
217        this.mappingCache = new CaffeineCache<>(cache);
218    }
219
220    @Override
221    public FedoraOcflMapping getMapping(final Transaction transaction, final FedoraId fedoraId)
222            throws FedoraOcflMappingNotFoundException {
223        try {
224            if (transaction.isOpenLongRunning()) {
225                final MapSqlParameterSource parameterSource = new MapSqlParameterSource();
226                parameterSource.addValue("fedoraId", fedoraId.getResourceId());
227                parameterSource.addValue("transactionId", transaction.getId());
228                return jdbcTemplate.queryForObject(LOOKUP_MAPPING_IN_TRANSACTION, parameterSource,
229                        GET_MAPPING_ROW_MAPPER);
230            } else {
231                return this.mappingCache.get(fedoraId.getResourceId(), key ->
232                        jdbcTemplate.queryForObject(LOOKUP_MAPPING, Map.of("fedoraId", key), GET_MAPPING_ROW_MAPPER)
233                );
234            }
235        } catch (final EmptyResultDataAccessException e) {
236            throw new FedoraOcflMappingNotFoundException("No OCFL mapping found for " + fedoraId);
237        }
238    }
239
240    @Override
241    public FedoraOcflMapping addMapping(@Nonnull final Transaction transaction, final FedoraId fedoraId,
242                                        final FedoraId fedoraRootId, final String ocflId) {
243        transaction.doInTx(() -> {
244            if (!transaction.isShortLived()) {
245                upsert(transaction, fedoraId, "add", fedoraRootId, ocflId);
246            } else {
247                directInsert(fedoraId, fedoraRootId, ocflId);
248            }
249        });
250
251        return new FedoraOcflMapping(fedoraRootId, ocflId);
252    }
253
254    @Override
255    public void removeMapping(@Nonnull final Transaction transaction, final FedoraId fedoraId) {
256        transaction.doInTx(() -> {
257            if (!transaction.isShortLived()) {
258                upsert(transaction, fedoraId, "delete");
259            } else {
260                final MapSqlParameterSource parameterSource = new MapSqlParameterSource();
261                parameterSource.addValue("fedoraId", fedoraId.getResourceId());
262                jdbcTemplate.update(DIRECT_DELETE_MAPPING, parameterSource);
263                this.mappingCache.invalidate(fedoraId.getResourceId());
264            }
265        });
266    }
267
268    private void upsert(final Transaction transaction, final FedoraId fedoraId, final String operation) {
269        upsert(transaction, fedoraId, operation, null, null);
270    }
271
272    /**
273     * Perform the upsert to the operations table.
274     *
275     * @param transaction the transaction/session id.
276     * @param fedoraId the resource id.
277     * @param operation the operation we are performing (add or delete)
278     * @param fedoraRootId the fedora root id (for add only)
279     * @param ocflId the ocfl id (for add only).
280     */
281    private void upsert(final Transaction transaction, final FedoraId fedoraId, final String operation,
282                        final FedoraId fedoraRootId, final String ocflId) {
283        final MapSqlParameterSource parameterSource = new MapSqlParameterSource();
284        parameterSource.addValue("fedoraId", fedoraId.getResourceId());
285        parameterSource.addValue("fedoraRootId", fedoraRootId == null ? null : fedoraRootId.getResourceId());
286        parameterSource.addValue("ocflId", ocflId);
287        parameterSource.addValue("transactionId", transaction.getId());
288        parameterSource.addValue("operation", operation);
289        try {
290            jdbcTemplate.update(UPSERT_MAPPING_TX_MAP.get(dbPlatform), parameterSource);
291        } catch (final DataIntegrityViolationException | BadSqlGrammarException e) {
292            handleInsertException(fedoraId, e);
293        }
294    }
295
296    private void directInsert(final FedoraId fedoraId, final FedoraId fedoraRootId, final String ocflId) {
297        final MapSqlParameterSource parameterSource = new MapSqlParameterSource();
298        parameterSource.addValue("fedoraId", fedoraId.getResourceId());
299        parameterSource.addValue("fedoraRootId", fedoraRootId == null ? null : fedoraRootId.getResourceId());
300        parameterSource.addValue("ocflId", ocflId);
301        try {
302            jdbcTemplate.update(DIRECT_INSERT_MAPPING, parameterSource);
303        } catch (final DataIntegrityViolationException | BadSqlGrammarException e) {
304            handleInsertException(fedoraId, e);
305        }
306    }
307
308    @Override
309    public void reset() {
310        try {
311            jdbcTemplate.update(TRUNCATE_MAPPINGS, Collections.emptyMap());
312            jdbcTemplate.update(TRUNCATE_TRANSACTIONS, Collections.emptyMap());
313            this.mappingCache.invalidateAll();
314        } catch (final Exception e) {
315            throw new RepositoryRuntimeException("Failed to truncate FedoraToOcfl index tables", e);
316        }
317    }
318
319    @Override
320    public void commit(@Nonnull final Transaction transaction) {
321        if (!transaction.isShortLived()) {
322            transaction.ensureCommitting();
323
324            LOGGER.debug("Committing FedoraToOcfl index changes from transaction {}", transaction.getId());
325            final Map<String, String> map = Map.of("transactionId", transaction.getId());
326            try {
327                final List<String> deleteIds = jdbcTemplate.queryForList(GET_DELETE_IDS, map, String.class);
328                jdbcTemplate.update(COMMIT_DELETE_RECORDS, map);
329                jdbcTemplate.update(COMMIT_ADD_MAPPING_MAP.get(dbPlatform), map);
330                jdbcTemplate.update(DELETE_ENTIRE_TRANSACTION, map);
331                this.mappingCache.invalidateAll(deleteIds);
332            } catch (final Exception e) {
333                LOGGER.warn("Unable to commit FedoraToOcfl index transaction {}: {}", transaction, e.getMessage());
334                throw new RepositoryRuntimeException("Unable to commit FedoraToOcfl index transaction", e);
335            }
336        }
337    }
338
339    @Transactional(propagation = Propagation.NOT_SUPPORTED)
340    @Override
341    public void rollback(@Nonnull final Transaction transaction) {
342        if (!transaction.isShortLived()) {
343            jdbcTemplate.update(DELETE_ENTIRE_TRANSACTION, Map.of("transactionId", transaction.getId()));
344        }
345    }
346
347    private void handleInsertException(final FedoraId fedoraId, final Exception e) {
348        if (e.getMessage().contains("too long for")) {
349            throw new InvalidResourceIdentifierException("Database error - Fedora ID path too long",e);
350        } else if (e instanceof DuplicateKeyException) {
351            throw new RepositoryRuntimeException("Database error - primary key already exists for Fedora ID: " +
352                                                 fedoraId, e);
353        } else {
354            throw new RepositoryRuntimeException("Database error - error during upsert",e);
355        }
356    }
357
358}