001/*
002 * The contents of this file are subject to the license and copyright
003 * detailed in the LICENSE and NOTICE files at the root of the source
004 * tree.
005 */
006package org.fcrepo.kernel.impl.services;
007
008import static org.fcrepo.kernel.api.FedoraTypes.FEDORA_ID_PREFIX;
009import static org.slf4j.LoggerFactory.getLogger;
010
011import java.util.List;
012import java.util.Map;
013import java.util.function.Predicate;
014import java.util.stream.Collectors;
015import java.util.stream.Stream;
016
017import javax.annotation.Nonnull;
018import javax.annotation.PostConstruct;
019import javax.inject.Inject;
020import javax.sql.DataSource;
021
022import org.fcrepo.kernel.api.ContainmentIndex;
023import org.fcrepo.kernel.api.RdfStream;
024import org.fcrepo.kernel.api.Transaction;
025import org.fcrepo.kernel.api.exception.RepositoryRuntimeException;
026import org.fcrepo.kernel.api.identifiers.FedoraId;
027import org.fcrepo.kernel.api.models.FedoraResource;
028import org.fcrepo.kernel.api.models.NonRdfSourceDescription;
029import org.fcrepo.kernel.api.observer.EventAccumulator;
030import org.fcrepo.kernel.api.rdf.DefaultRdfStream;
031import org.fcrepo.kernel.api.services.ReferenceService;
032import org.fcrepo.kernel.impl.operations.ReferenceOperation;
033import org.fcrepo.kernel.impl.operations.ReferenceOperationBuilder;
034
035import org.apache.jena.graph.Node;
036import org.apache.jena.graph.NodeFactory;
037import org.apache.jena.graph.Triple;
038import org.apache.jena.sparql.core.Quad;
039import org.slf4j.Logger;
040import org.springframework.beans.factory.annotation.Autowired;
041import org.springframework.beans.factory.annotation.Qualifier;
042import org.springframework.jdbc.core.RowMapper;
043import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
044import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
045import org.springframework.stereotype.Component;
046import org.springframework.transaction.annotation.Propagation;
047import org.springframework.transaction.annotation.Transactional;
048
049/**
050 * Implementation of reference service.
051 * @author whikloj
052 * @since 6.0.0
053 */
054@Component("referenceServiceImpl")
055public class ReferenceServiceImpl implements ReferenceService {
056
057    private static final Logger LOGGER = getLogger(ReferenceServiceImpl.class);
058
059    @Inject
060    private DataSource dataSource;
061
062    @Inject
063    private EventAccumulator eventAccumulator;
064
065    @Autowired
066    @Qualifier("containmentIndex")
067    private ContainmentIndex containmentIndex;
068
069    private NamedParameterJdbcTemplate jdbcTemplate;
070
071    private static final String TABLE_NAME = "reference";
072
073    private static final String TRANSACTION_TABLE = "reference_transaction_operations";
074
075    private static final String RESOURCE_COLUMN = "fedora_id";
076
077    private static final String SUBJECT_COLUMN = "subject_id";
078
079    private static final String PROPERTY_COLUMN = "property";
080
081    private static final String TARGET_COLUMN = "target_id";
082
083    private static final String OPERATION_COLUMN = "operation";
084
085    private static final String TRANSACTION_COLUMN = "transaction_id";
086
087    private static final String SELECT_INBOUND = "SELECT " + SUBJECT_COLUMN + ", " + PROPERTY_COLUMN + " FROM " +
088            TABLE_NAME + " WHERE " + TARGET_COLUMN + " = :targetId";
089
090    private static final String SELECT_INBOUND_IN_TRANSACTION = "SELECT x." + SUBJECT_COLUMN + ", x." +
091            PROPERTY_COLUMN + " FROM " + "(SELECT " + SUBJECT_COLUMN + ", " + PROPERTY_COLUMN + " FROM " + TABLE_NAME +
092            " WHERE " + TARGET_COLUMN + " = :targetId UNION " + "SELECT " + SUBJECT_COLUMN + ", " + PROPERTY_COLUMN +
093            " FROM " + TRANSACTION_TABLE + " WHERE " + TARGET_COLUMN + " = :targetId AND "
094            + TRANSACTION_COLUMN + " = :transactionId AND " + OPERATION_COLUMN + " = 'add') x WHERE NOT EXISTS " +
095            "(SELECT 1 FROM " + TRANSACTION_TABLE + " WHERE " + TARGET_COLUMN + " = :targetId AND " +
096            OPERATION_COLUMN + " = 'delete')";
097
098    private static final String SELECT_OUTBOUND = "SELECT " + SUBJECT_COLUMN + ", " + TARGET_COLUMN + ", " +
099            PROPERTY_COLUMN + " FROM " + TABLE_NAME + " WHERE " + RESOURCE_COLUMN + " = :resourceId";
100
101    private static final String SELECT_OUTBOUND_IN_TRANSACTION = "SELECT x." + SUBJECT_COLUMN + ", x." + TARGET_COLUMN +
102            ", x." + PROPERTY_COLUMN + " FROM " + "(SELECT " + SUBJECT_COLUMN + ", " + TARGET_COLUMN + ", " +
103            PROPERTY_COLUMN + " FROM " + TABLE_NAME + " WHERE " + RESOURCE_COLUMN + " = :resourceId UNION " +
104            "SELECT " + SUBJECT_COLUMN + ", " + TARGET_COLUMN + ", " + PROPERTY_COLUMN + " FROM " + TRANSACTION_TABLE +
105            " WHERE " + RESOURCE_COLUMN + " = :resourceId " + "AND " + TRANSACTION_COLUMN + " = :transactionId AND " +
106            OPERATION_COLUMN + " = 'add') x WHERE NOT EXISTS (SELECT 1 FROM " + TRANSACTION_TABLE + " WHERE " +
107            RESOURCE_COLUMN + " = :resourceId AND " + OPERATION_COLUMN + " = 'delete')";
108
109    private static final String INSERT_REFERENCE_IN_TRANSACTION = "INSERT INTO " + TRANSACTION_TABLE + "(" +
110            RESOURCE_COLUMN + ", " + SUBJECT_COLUMN + ", " + PROPERTY_COLUMN + ", " + TARGET_COLUMN + ", " +
111            TRANSACTION_COLUMN + ", " + OPERATION_COLUMN + ") VALUES (:resourceId, :subjectId, :property, :targetId, " +
112            ":transactionId, 'add')";
113
114    private static final String INSERT_REFERENCE_DIRECT = "INSERT INTO " + TABLE_NAME + "(" +
115            RESOURCE_COLUMN + ", " + SUBJECT_COLUMN + ", " + PROPERTY_COLUMN + ", " + TARGET_COLUMN +
116            ") VALUES (:resourceId, :subjectId, :property, :targetId)";
117
118    private static final String UNDO_INSERT_REFERENCE_IN_TRANSACTION = "DELETE FROM " + TRANSACTION_TABLE + " WHERE " +
119            RESOURCE_COLUMN + " = :resourceId AND " + SUBJECT_COLUMN + " = :subjectId AND " + PROPERTY_COLUMN +
120            " = :property AND " + TARGET_COLUMN + " = :targetId AND " + TRANSACTION_COLUMN + " = :transactionId AND " +
121            OPERATION_COLUMN + " = 'add'";
122
123    private static final String DELETE_REFERENCE_IN_TRANSACTION = "INSERT INTO " + TRANSACTION_TABLE + "(" +
124            RESOURCE_COLUMN + ", " + SUBJECT_COLUMN + ", " + PROPERTY_COLUMN + ", " + TARGET_COLUMN + ", " +
125            TRANSACTION_COLUMN + ", " + OPERATION_COLUMN + ") VALUES (:resourceId, :subjectId, :property, :targetId, " +
126            ":transactionId, 'delete')";
127
128    private static final String DELETE_REFERENCE_DIRECT = "DELETE FROM reference" +
129            " WHERE fedora_id = :resourceId AND subject_id = :subjectId" +
130            " AND property = :property AND target_id = :targetId";
131
132    private static final String UNDO_DELETE_REFERENCE_IN_TRANSACTION = "DELETE FROM " + TRANSACTION_TABLE + " WHERE " +
133            RESOURCE_COLUMN + " = :resourceId AND " + SUBJECT_COLUMN + " = :subjectId AND " + PROPERTY_COLUMN +
134            " = :property AND " + TARGET_COLUMN + " = :targetId AND " + TRANSACTION_COLUMN + " = :transactionId AND " +
135            OPERATION_COLUMN + " = 'delete'";
136
137    private static final String IS_REFERENCE_ADDED_IN_TRANSACTION = "SELECT TRUE FROM " + TRANSACTION_TABLE + " WHERE "
138            + RESOURCE_COLUMN + " = :resourceId AND " + SUBJECT_COLUMN + " = :subjectId AND " + PROPERTY_COLUMN +
139            " = :property AND " + TARGET_COLUMN + " = :targetId AND " + TRANSACTION_COLUMN + " = :transactionId AND " +
140            OPERATION_COLUMN + " = 'add'";
141
142    private static final String IS_REFERENCE_DELETED_IN_TRANSACTION = "SELECT TRUE FROM " + TRANSACTION_TABLE +
143            " WHERE " + RESOURCE_COLUMN + " = :resourceId AND " + SUBJECT_COLUMN + " = :subjectId AND " +
144            PROPERTY_COLUMN + " = :property AND " + TARGET_COLUMN + " = :targetId AND " + TRANSACTION_COLUMN +
145            " = :transactionId AND " + OPERATION_COLUMN + " = 'delete'";
146
147    private static final String COMMIT_ADD_RECORDS = "INSERT INTO " + TABLE_NAME + " ( " + RESOURCE_COLUMN + ", " +
148            SUBJECT_COLUMN + ", " + PROPERTY_COLUMN + ", " + TARGET_COLUMN + " ) SELECT " + RESOURCE_COLUMN + ", " +
149            SUBJECT_COLUMN + ", " + PROPERTY_COLUMN + ", " + TARGET_COLUMN + " FROM " + TRANSACTION_TABLE + " WHERE " +
150            TRANSACTION_COLUMN + " = :transactionId AND " + OPERATION_COLUMN + " = 'add'";
151
152    private static final String COMMIT_DELETE_RECORDS = "DELETE FROM " + TABLE_NAME + " WHERE " +
153            "EXISTS (SELECT * FROM " + TRANSACTION_TABLE + " t WHERE t." +
154            TRANSACTION_COLUMN + " = :transactionId AND t." +  OPERATION_COLUMN + " = 'delete' AND" +
155            " t." + RESOURCE_COLUMN + " = " + TABLE_NAME + "." + RESOURCE_COLUMN + " AND" +
156            " t." + SUBJECT_COLUMN + " = " + TABLE_NAME + "." + SUBJECT_COLUMN +
157            " AND t." + PROPERTY_COLUMN + " = " + TABLE_NAME + "." + PROPERTY_COLUMN +
158            " AND t." + TARGET_COLUMN + " = " + TABLE_NAME + "." + TARGET_COLUMN + ")";
159
160    private static final String DELETE_TRANSACTION = "DELETE FROM " + TRANSACTION_TABLE + " WHERE " +
161            TRANSACTION_COLUMN + " = :transactionId";
162
163    private static final String TRUNCATE_TABLE = "TRUNCATE TABLE " + TABLE_NAME;
164    private static final String TRUNCATE_TX_TABLE = "TRUNCATE TABLE " + TRANSACTION_TABLE;
165
166    @PostConstruct
167    public void setUp() {
168        jdbcTemplate = new NamedParameterJdbcTemplate(getDataSource());
169    }
170
171    @Override
172    public RdfStream getInboundReferences(@Nonnull final Transaction tx, final FedoraResource resource) {
173        final String resourceId = resource.getFedoraId().getFullId();
174        final Node subject = NodeFactory.createURI(resourceId);
175        final Stream<Triple> stream = getReferencesInternal(tx, resourceId);
176        if (resource instanceof NonRdfSourceDescription) {
177            final Stream<Triple> stream2 = getReferencesInternal(tx, resource.getFedoraId().getBaseId());
178            return new DefaultRdfStream(subject, Stream.concat(stream, stream2));
179        }
180        return new DefaultRdfStream(subject, stream);
181    }
182
183    /**
184     * Get the inbound references for the resource Id and the transaction id.
185     * @param tx transaction or null for none.
186     * @param targetId the id that will be the target of references.
187     * @return RDF stream of inbound references
188     */
189    private Stream<Triple> getReferencesInternal(final Transaction tx, final String targetId) {
190        final MapSqlParameterSource parameterSource = new MapSqlParameterSource();
191        parameterSource.addValue("targetId", targetId);
192        final Node targetNode = NodeFactory.createURI(targetId);
193
194        final RowMapper<Triple> inboundMapper = (rs, rowNum) ->
195                Triple.create(NodeFactory.createURI(rs.getString(SUBJECT_COLUMN)),
196                        NodeFactory.createURI(rs.getString(PROPERTY_COLUMN)),
197                        targetNode);
198
199        final String query;
200
201        if (tx.isOpenLongRunning()) {
202            // we are in a transaction
203            parameterSource.addValue("transactionId", tx.getId());
204            query = SELECT_INBOUND_IN_TRANSACTION;
205        } else {
206            // not in a transaction
207            query = SELECT_INBOUND;
208        }
209
210        final var references = jdbcTemplate.query(query, parameterSource, inboundMapper);
211
212        LOGGER.debug("getInboundReferences for {} in transaction {} found {} references",
213                targetId, tx, references.size());
214        return references.stream();
215    }
216
217    @Override
218    public void deleteAllReferences(@Nonnull final Transaction tx, final FedoraId resourceId) {
219        final List<Quad> deleteReferences = getOutboundReferences(tx, resourceId);
220        if (resourceId.isDescription()) {
221            // Also get the binary references
222            deleteReferences.addAll(getOutboundReferences(tx, resourceId.asBaseId()));
223        }
224        // Remove all the existing references.
225        deleteReferences.forEach(t -> removeReference(tx, t));
226    }
227
228    /**
229     * Get a stream of quads of resources being referenced from the provided resource, the graph of the quad is the
230     * URI of the resource the reference is from.
231     * @param tx transaction Id or null if none.
232     * @param resourceId the resource Id.
233     * @return list of Quads
234     */
235    private List<Quad> getOutboundReferences(final Transaction tx, final FedoraId resourceId) {
236        final MapSqlParameterSource parameterSource = new MapSqlParameterSource();
237        parameterSource.addValue("resourceId", resourceId.getFullId());
238        final Node subjectNode = NodeFactory.createURI(resourceId.getFullId());
239
240        final RowMapper<Quad> outboundMapper = (rs, rowNum) ->
241                Quad.create(subjectNode,
242                        NodeFactory.createURI(rs.getString(SUBJECT_COLUMN)),
243                        NodeFactory.createURI(rs.getString(PROPERTY_COLUMN)),
244                        NodeFactory.createURI(rs.getString(TARGET_COLUMN)));
245
246        final String query;
247
248        if (tx.isOpenLongRunning()) {
249            // we are in a long-running transaction
250            parameterSource.addValue("transactionId", tx.getId());
251            query = SELECT_OUTBOUND_IN_TRANSACTION;
252        } else {
253            // not in a transaction or in a short-lived transaction
254            query = SELECT_OUTBOUND;
255        }
256
257        final var references = jdbcTemplate.query(query, parameterSource, outboundMapper);
258
259        LOGGER.debug("getOutboundReferences for {} in transaction {} found {} references",
260                resourceId, tx, references.size());
261        return references;
262    }
263
264    @Override
265    public void updateReferences(@Nonnull final Transaction tx, final FedoraId resourceId, final String userPrincipal,
266                                 final RdfStream rdfStream) {
267        try {
268            final List<Triple> addReferences = getReferencesFromRdf(rdfStream).collect(Collectors.toList());
269            // This predicate checks for items we are adding, so we don't bother to delete and then re-add them.
270            final Predicate<Quad> notInAdds = q -> !addReferences.contains(q.asTriple());
271            // References from this resource.
272            final List<Quad> existingReferences = getOutboundReferences(tx, resourceId);
273            if (resourceId.isDescription()) {
274                // Resource is a binary description so also get the binary references.
275                existingReferences.addAll(getOutboundReferences(tx, resourceId.asBaseId()));
276            }
277            // Remove any existing references not being re-added.
278            existingReferences.stream().filter(notInAdds).forEach(t -> removeReference(tx, t));
279            final Node resourceNode = NodeFactory.createURI(resourceId.getFullId());
280            // This predicate checks for references that didn't already exist in the database.
281            final Predicate<Triple> alreadyExists = t -> !existingReferences.contains(Quad.create(resourceNode, t));
282            // Add the new references.
283            addReferences.stream().filter(alreadyExists).forEach(r ->
284                    addReference(tx, Quad.create(resourceNode, r), userPrincipal));
285        } catch (final Exception e) {
286            LOGGER.warn("Unable to update reference index for resource {} in transaction {}: {}",
287                    resourceId.getFullId(), tx.getId(), e.getMessage());
288            throw new RepositoryRuntimeException("Unable to update reference index", e);
289        }
290    }
291
292    @Override
293    public void commitTransaction(final Transaction tx) {
294        if (!tx.isShortLived()) {
295            tx.ensureCommitting();
296            try {
297                final Map<String, String> parameterSource = Map.of("transactionId", tx.getId());
298                jdbcTemplate.update(COMMIT_DELETE_RECORDS, parameterSource);
299                jdbcTemplate.update(COMMIT_ADD_RECORDS, parameterSource);
300                jdbcTemplate.update(DELETE_TRANSACTION, parameterSource);
301            } catch (final Exception e) {
302                LOGGER.warn("Unable to commit reference index transaction {}: {}", tx, e.getMessage());
303                throw new RepositoryRuntimeException("Unable to commit reference index transaction", e);
304            }
305        }
306    }
307
308    @Transactional(propagation = Propagation.NOT_SUPPORTED)
309    @Override
310    public void rollbackTransaction(final Transaction tx) {
311        if (!tx.isShortLived()) {
312            try {
313                final Map<String, String> parameterSource = Map.of("transactionId", tx.getId());
314                jdbcTemplate.update(DELETE_TRANSACTION, parameterSource);
315            } catch (final Exception e) {
316                LOGGER.warn("Unable to rollback reference index transaction {}: {}", tx, e.getMessage());
317                throw new RepositoryRuntimeException("Unable to rollback reference index transaction", e);
318            }
319        }
320    }
321
322    @Override
323    public void clearAllTransactions() {
324        jdbcTemplate.update(TRUNCATE_TX_TABLE, Map.of());
325    }
326
327    @Override
328    public void reset() {
329        try {
330            jdbcTemplate.update(TRUNCATE_TABLE, Map.of());
331            jdbcTemplate.update(TRUNCATE_TX_TABLE, Map.of());
332        } catch (final Exception e) {
333            LOGGER.warn("Unable to reset reference index: {}", e.getMessage());
334            throw new RepositoryRuntimeException("Unable to reset reference index", e);
335        }
336    }
337
338    /**
339     * Remove a reference.
340     * @param tx the transaction
341     * @param reference the quad with the reference, is Quad(resourceId, subjectId, propertyId, targetId)
342     */
343    private void removeReference(final Transaction tx, final Quad reference) {
344        tx.doInTx(() -> {
345            final var parameterSource = new MapSqlParameterSource();
346            parameterSource.addValue("resourceId", reference.getGraph().getURI());
347            parameterSource.addValue("subjectId", reference.getSubject().getURI());
348            parameterSource.addValue("property", reference.getPredicate().getURI());
349            parameterSource.addValue("targetId", reference.getObject().getURI());
350
351            if (!tx.isShortLived()) {
352                parameterSource.addValue("transactionId", tx.getId());
353                final boolean addedInTx = !jdbcTemplate.queryForList(IS_REFERENCE_ADDED_IN_TRANSACTION, parameterSource)
354                        .isEmpty();
355                if (addedInTx) {
356                    jdbcTemplate.update(UNDO_INSERT_REFERENCE_IN_TRANSACTION, parameterSource);
357                } else {
358                    jdbcTemplate.update(DELETE_REFERENCE_IN_TRANSACTION, parameterSource);
359                }
360            } else {
361                jdbcTemplate.update(DELETE_REFERENCE_DIRECT, parameterSource);
362            }
363        });
364    }
365
366    /**
367     * Add a reference
368     * @param transaction the transaction Id.
369     * @param reference the quad with the reference, is is Quad(resourceId, subjectId, propertyId, targetId)
370     * @param userPrincipal the user adding the reference.
371     */
372    private void addReference(@Nonnull final Transaction transaction, final Quad reference,
373                              final String userPrincipal) {
374        transaction.doInTx(() -> {
375            final String targetId = reference.getObject().getURI();
376
377            final var parameterSource = new MapSqlParameterSource();
378            parameterSource.addValue("resourceId", reference.getGraph().getURI());
379            parameterSource.addValue("subjectId", reference.getSubject().getURI());
380            parameterSource.addValue("property", reference.getPredicate().getURI());
381            parameterSource.addValue("targetId", targetId);
382
383            if (!transaction.isShortLived()) {
384                parameterSource.addValue("transactionId", transaction.getId());
385                final boolean addedInTx = !jdbcTemplate.queryForList(
386                        IS_REFERENCE_DELETED_IN_TRANSACTION, parameterSource)
387                        .isEmpty();
388                if (addedInTx) {
389                    jdbcTemplate.update(UNDO_DELETE_REFERENCE_IN_TRANSACTION, parameterSource);
390                } else {
391                    jdbcTemplate.update(INSERT_REFERENCE_IN_TRANSACTION, parameterSource);
392                    recordEvent(transaction, targetId, userPrincipal);
393                }
394            } else {
395                jdbcTemplate.update(INSERT_REFERENCE_DIRECT, parameterSource);
396                recordEvent(transaction, targetId, userPrincipal);
397            }
398        });
399    }
400
401    /**
402     * Record the inbound reference event if the target exists.
403     * @param transaction the transaction.
404     * @param resourceId the id of the target of the inbound reference.
405     * @param userPrincipal the user making the reference.
406     */
407    private void recordEvent(final Transaction transaction, final String resourceId, final String userPrincipal) {
408        final FedoraId fedoraId = FedoraId.create(resourceId);
409        if (this.containmentIndex.resourceExists(transaction, fedoraId, false)) {
410            this.eventAccumulator.recordEventForOperation(transaction, fedoraId, getOperation(transaction, fedoraId,
411                    userPrincipal));
412        }
413    }
414
415    /**
416     * Create a ReferenceOperation for the current add.
417     * @param tx the transaction for the current operation.
418     * @param id the target resource of the reference.
419     * @param user the user making the change
420     * @return a ReferenceOperation
421     */
422    private static ReferenceOperation getOperation(final Transaction tx, final FedoraId id, final String user) {
423        final ReferenceOperationBuilder builder = new ReferenceOperationBuilder(tx, id);
424        builder.userPrincipal(user);
425        return builder.build();
426    }
427
428    /**
429     * Utility to filter a RDFStream to just the URIs from subjects and objects within the repository.
430     * @param stream the provided stream
431     * @return stream of triples with internal references.
432     */
433    private Stream<Triple> getReferencesFromRdf(final RdfStream stream) {
434        final Predicate<Triple> isInternalReference = t -> {
435            final Node s = t.getSubject();
436            final Node o = t.getObject();
437            return (s.isURI() && s.getURI().startsWith(FEDORA_ID_PREFIX) && o.isURI() &&
438                    o.getURI().startsWith(FEDORA_ID_PREFIX));
439        };
440        return stream.filter(isInternalReference);
441    }
442
443    /**
444     * Set the JDBC datastore.
445     * @param dataSource the dataStore.
446     */
447    public void setDataSource(final DataSource dataSource) {
448        this.dataSource = dataSource;
449    }
450
451    /**
452     * Get the JDBC datastore.
453     * @return the dataStore.
454     */
455    public DataSource getDataSource() {
456        return dataSource;
457    }
458}