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