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 org.apache.jena.graph.Node;
009import org.apache.jena.graph.NodeFactory;
010import org.apache.jena.graph.Triple;
011import org.apache.jena.rdf.model.Property;
012import org.apache.jena.rdf.model.Resource;
013import org.apache.jena.rdf.model.Statement;
014
015import org.fcrepo.config.OcflPropsConfig;
016import org.fcrepo.kernel.api.RdfLexicon;
017import org.fcrepo.kernel.api.RdfStream;
018import org.fcrepo.kernel.api.Transaction;
019import org.fcrepo.kernel.api.exception.PathNotFoundException;
020import org.fcrepo.kernel.api.exception.PathNotFoundRuntimeException;
021import org.fcrepo.kernel.api.identifiers.FedoraId;
022import org.fcrepo.kernel.api.models.Binary;
023import org.fcrepo.kernel.api.models.Container;
024import org.fcrepo.kernel.api.models.FedoraResource;
025import org.fcrepo.kernel.api.models.ResourceFactory;
026import org.fcrepo.kernel.api.models.Tombstone;
027import org.fcrepo.kernel.api.rdf.DefaultRdfStream;
028import org.fcrepo.kernel.api.services.MembershipService;
029import org.slf4j.Logger;
030import org.springframework.stereotype.Component;
031
032import javax.annotation.Nonnull;
033import javax.inject.Inject;
034import java.time.Instant;
035import java.util.ArrayList;
036import java.util.List;
037import java.util.Objects;
038import java.util.stream.Collectors;
039
040import static org.fcrepo.kernel.api.RdfCollectors.toModel;
041import static org.slf4j.LoggerFactory.getLogger;
042import static org.apache.jena.rdf.model.ResourceFactory.createProperty;
043
044/**
045 * Implementation of a service which updates and persists membership properties for resources
046 *
047 * @author bbpennel
048 * @since 6.0.0
049 */
050@Component
051public class MembershipServiceImpl implements MembershipService {
052    private static final Logger log = getLogger(MembershipServiceImpl.class);
053
054    public static final Instant NO_END_INSTANT = Instant.parse("9999-12-31T00:00:00.000Z");
055
056    @Inject
057    private MembershipIndexManager indexManager;
058
059    @Inject
060    private ResourceFactory resourceFactory;
061
062    @Inject
063    private OcflPropsConfig propsConfig;
064
065    private enum ContainerType {
066        Direct, Indirect;
067    }
068
069    @Override
070    public void resourceCreated(final Transaction tx, final FedoraId fedoraId) {
071        final var fedoraResc = getFedoraResource(tx, fedoraId);
072
073        // Only need to compute membership for created containers and binaries
074        if (!(fedoraResc instanceof Container || fedoraResc instanceof Binary)) {
075            return;
076        }
077
078        if (isChildOfRoot(fedoraResc)) {
079            return;
080        }
081
082        final var parentResc = getParentResource(fedoraResc);
083        final var containerProperties = new DirectContainerProperties(parentResc);
084
085        if (containerProperties.containerType != null) {
086            final var newMembership = generateMembership(containerProperties, fedoraResc);
087            indexManager.addMembership(tx, parentResc.getFedoraId(), fedoraResc.getFedoraId(),
088                    newMembership, fedoraResc.getCreatedDate());
089        }
090    }
091
092    @Override
093    public void resourceModified(final Transaction tx, final FedoraId fedoraId) {
094        final var fedoraResc = getFedoraResource(tx, fedoraId);
095        final var containerProperties = new DirectContainerProperties(fedoraResc);
096
097        if (containerProperties.containerType != null) {
098            log.debug("Modified DirectContainer {}, recomputing generated membership relations", fedoraId);
099
100            if (propsConfig.isAutoVersioningEnabled()) {
101                modifyDCAutoversioned(tx, fedoraResc, containerProperties);
102            } else {
103                modifyDCOnDemandVersioning(tx, fedoraResc);
104            }
105        }
106
107        if (isChildOfRoot(fedoraResc)) {
108            return;
109        }
110
111        final var parentResc = getParentResource(fedoraResc);
112        final var parentProperties = new DirectContainerProperties(parentResc);
113
114        // Handle updates of proxies in IndirectContainer
115        if (ContainerType.Indirect.equals(parentProperties.containerType)) {
116            modifyProxy(tx, fedoraResc, parentProperties);
117        }
118    }
119
120    private void modifyProxy(final Transaction tx, final FedoraResource proxyResc,
121            final DirectContainerProperties containerProperties) {
122        final var lastModified = proxyResc.getLastModifiedDate();
123
124        if (propsConfig.isAutoVersioningEnabled()) {
125            // end existing stuff
126            indexManager.endMembershipFromChild(tx, containerProperties.id, proxyResc.getFedoraId(), lastModified);
127            // add new membership
128        } else {
129            final var mementoDatetimes = proxyResc.getTimeMap().listMementoDatetimes();
130            final Instant lastVersionDatetime;
131            if (mementoDatetimes.size() == 0) {
132                // If no previous versions of proxy, then cleanup and repopulate everything
133                lastVersionDatetime = null;
134            } else {
135                // If at least one past version, then reindex membership involving the last version and after
136                lastVersionDatetime = mementoDatetimes.get(mementoDatetimes.size() - 1);
137            }
138            indexManager.deleteMembershipForProxyAfter(tx, containerProperties.id,
139                    proxyResc.getFedoraId(), lastVersionDatetime);
140        }
141
142        indexManager.addMembership(tx, containerProperties.id, proxyResc.getFedoraId(),
143                generateMembership(containerProperties, proxyResc), lastModified);
144    }
145
146    private void modifyDCAutoversioned(final Transaction tx, final FedoraResource dcResc,
147            final DirectContainerProperties containerProperties) {
148        final var dcId = dcResc.getFedoraId();
149        final var dcLastModified = dcResc.getLastModifiedDate();
150        // Delete/end existing membership from this container
151        indexManager.endMembershipForSource(tx, dcResc.getFedoraId(), dcLastModified);
152
153        // Add updated membership properties for all non-tombstone children
154        dcResc.getChildren()
155                .filter(child -> !(child instanceof Tombstone))
156                .forEach(child -> {
157                    final var newMembership = generateMembership(containerProperties, child);
158                    indexManager.addMembership(tx, dcId, child.getFedoraId(),
159                            newMembership, dcLastModified);
160                });
161    }
162
163    private void modifyDCOnDemandVersioning(final Transaction tx, final FedoraResource dcResc) {
164        final var dcId = dcResc.getFedoraId();
165        final var mementoDatetimes = dcResc.getTimeMap().listMementoDatetimes();
166        final Instant lastVersionDatetime;
167        if (mementoDatetimes.size() == 0) {
168            // If no previous versions of DC, then cleanup and repopulate everything
169            lastVersionDatetime = null;
170        } else {
171            // If at least one past version, then reindex membership involving the last version and after
172            lastVersionDatetime = mementoDatetimes.get(mementoDatetimes.size() - 1);
173        }
174        indexManager.deleteMembershipForSourceAfter(tx, dcId, lastVersionDatetime);
175        populateMembershipHistory(tx, dcResc, lastVersionDatetime);
176    }
177
178    private Triple generateMembership(final DirectContainerProperties properties, final FedoraResource childResc) {
179        final var childRdfResc = getRdfResource(childResc.getFedoraId());
180
181        final Node memberNode;
182        if (ContainerType.Indirect.equals(properties.containerType)) {
183            // Special case to use child as the member subject
184            if (RdfLexicon.MEMBER_SUBJECT.equals(properties.insertedContentRelation)) {
185                memberNode = childRdfResc.asNode();
186            } else {
187                // get the member node from the child resource's insertedContentRelation property
188                final var childModelResc = getRdfResource(childResc);
189                final Statement stmt = childModelResc.getProperty(properties.insertedContentRelation);
190                // Ignore the child if it is missing the insertedContentRelation or its object is not a resource
191                if (stmt == null || !(stmt.getObject() instanceof Resource)) {
192                    return null;
193                }
194                memberNode = stmt.getResource().asNode();
195            }
196        } else {
197            memberNode = childRdfResc.asNode();
198        }
199
200        return generateMembershipTriple(properties.membershipResource, memberNode,
201                properties.hasMemberRelation, properties.isMemberOfRelation);
202    }
203
204    private Triple generateMembershipTriple(final Node membership, final Node member,
205            final Node hasMemberRel, final Node memberOfRel) {
206        if (memberOfRel != null) {
207            return new Triple(member, memberOfRel, membership);
208        } else {
209            return new Triple(membership, hasMemberRel, member);
210        }
211    }
212
213    private Resource getRdfResource(final FedoraResource fedoraResc) {
214        final var model = fedoraResc.getTriples().collect(toModel());
215        return model.getResource(fedoraResc.getFedoraId().getFullId());
216    }
217
218    private Resource getRdfResource(final FedoraId fedoraId) {
219        return org.apache.jena.rdf.model.ResourceFactory.createResource(fedoraId.getFullId());
220    }
221
222    private FedoraResource getFedoraResource(final Transaction transaction, final FedoraId fedoraId) {
223        try {
224            return resourceFactory.getResource(transaction, fedoraId);
225        } catch (final PathNotFoundException e) {
226            throw new PathNotFoundRuntimeException(e.getMessage(), e);
227        }
228    }
229
230    private FedoraResource getParentResource(final FedoraResource resc) {
231        try {
232            return resc.getParent();
233        } catch (final PathNotFoundException e) {
234            throw new PathNotFoundRuntimeException(e.getMessage(), e);
235        }
236    }
237
238    @Override
239    public void resourceDeleted(@Nonnull final Transaction transaction, final FedoraId fedoraId) {
240        // delete DirectContainer, end all membership for that source
241        FedoraResource fedoraResc;
242        try {
243            fedoraResc = getFedoraResource(transaction, fedoraId);
244        } catch (final PathNotFoundRuntimeException e) {
245            log.debug("Deleted resource {} does not have a tombstone, cleanup any references", fedoraId);
246            indexManager.deleteMembershipReferences(transaction.getId(), fedoraId);
247            return;
248        }
249        if (fedoraResc instanceof Tombstone) {
250            fedoraResc = ((Tombstone) fedoraResc).getDeletedObject();
251        }
252
253        final var resourceContainerType = getContainerType(fedoraResc);
254        if (resourceContainerType != null) {
255            log.debug("Ending membership for deleted Direct/IndirectContainer {} in {}", fedoraId, transaction);
256            indexManager.endMembershipForSource(transaction, fedoraId, fedoraResc.getLastModifiedDate());
257        }
258
259        if (isChildOfRoot(fedoraResc)) {
260            return;
261        }
262
263        // delete child of DirectContainer, clear from tx and end existing
264        final var parentResc = getParentResource(fedoraResc);
265        final var parentContainerType = getContainerType(parentResc);
266
267        if (parentContainerType != null) {
268            log.debug("Ending membership for deleted proxy or member in tx {} for {} at {}",
269                    transaction, parentResc.getFedoraId(), fedoraResc.getLastModifiedDate());
270            indexManager.endMembershipFromChild(transaction, parentResc.getFedoraId(), fedoraResc.getFedoraId(),
271                    fedoraResc.getLastModifiedDate());
272        }
273    }
274
275    @Override
276    public RdfStream getMembership(final Transaction tx, final FedoraId fedoraId) {
277        final FedoraId subjectId;
278        if (fedoraId.isDescription()) {
279            subjectId = fedoraId.asBaseId();
280        } else {
281            subjectId = fedoraId;
282        }
283        final var subject = NodeFactory.createURI(subjectId.getBaseId());
284        final var membershipStream = indexManager.getMembership(tx, subjectId);
285        return new DefaultRdfStream(subject, membershipStream);
286    }
287
288    @Override
289    public void commitTransaction(final Transaction tx) {
290        indexManager.commitTransaction(tx);
291    }
292
293    @Override
294    public void rollbackTransaction(final Transaction tx) {
295        indexManager.deleteTransaction(tx);
296    }
297
298    @Override
299    public void reset() {
300        indexManager.clearIndex();
301    }
302
303    @Override
304    public void populateMembershipHistory(@Nonnull final Transaction transaction, final FedoraId containerId) {
305        final FedoraResource fedoraResc = getFedoraResource(transaction, containerId);
306        final var containerType = getContainerType(fedoraResc);
307
308        if (containerType != null) {
309            populateMembershipHistory(transaction, fedoraResc, null);
310        }
311    }
312
313    private void populateMembershipHistory(final Transaction tx, final FedoraResource fedoraResc,
314            final Instant afterTime) {
315        final var containerId = fedoraResc.getFedoraId();
316        final var propertyTimeline = makePropertyTimeline(fedoraResc);
317        final List<DirectContainerProperties> timeline;
318        // If provided, filter the timeline to just entries active on or after the specified time
319        if (afterTime != null) {
320            timeline = propertyTimeline.stream().filter(e -> e.startDatetime.compareTo(afterTime) >= 0
321                    || e.endDatetime.compareTo(afterTime) >= 0)
322                .collect(Collectors.toList());
323        } else {
324            timeline = propertyTimeline;
325        }
326
327        // get all the members of the DC and index the history for each, accounting for changes to the DC
328        fedoraResc.getChildren().forEach(member -> {
329            // must ensure the tx does not close before indexing is complete
330            tx.refresh();
331
332            final var memberDeleted = member instanceof Tombstone;
333            log.debug("Populating membership history for DirectContainer {}member {}",
334                    memberDeleted ? "deleted " : "", member.getFedoraId());
335            final Instant memberCreated;
336            // Get the creation time from the deleted object if the member is a tombstone
337            if (memberDeleted) {
338                memberCreated = ((Tombstone) member).getDeletedObject().getCreatedDate();
339            } else {
340                memberCreated = member.getCreatedDate();
341            }
342            final var memberModified = member.getLastModifiedDate();
343            final var memberEnd = memberDeleted ? memberModified : NO_END_INSTANT;
344
345            // Reduce timeline to just states in effect after the member was created
346            var timelineStream = timeline.stream()
347                    .filter(e -> e.endDatetime.compareTo(memberCreated) > 0);
348            // If the member was deleted, then reduce timeline to states before the deletion
349            if (memberDeleted) {
350                timelineStream = timelineStream.filter(e -> e.startDatetime.compareTo(memberModified) < 0);
351            }
352            // Index each addition or change to the membership generated by this member
353            timelineStream.forEach(e -> {
354                // Start time of the membership is the later of member creation or membership resc memento time
355                indexManager.addMembership(tx, containerId, member.getFedoraId(),
356                        generateMembership(e, member),
357                        instantMax(memberCreated, e.startDatetime),
358                        instantMin(memberEnd, e.endDatetime));
359            });
360        });
361    }
362
363    private Instant instantMax(final Instant first, final Instant second) {
364        if (first.isAfter(second)) {
365            return first;
366        } else {
367            return second;
368        }
369    }
370
371    private Instant instantMin(final Instant first, final Instant second) {
372        if (first.isBefore(second)) {
373            return first;
374        } else {
375            return second;
376        }
377    }
378
379    /**
380     * Creates a timeline of states for a DirectContainer, tracking changes to its
381     * properties that impact membership.
382     * @param fedoraResc resource subject of the timeline
383     * @return timeline
384     */
385    private List<DirectContainerProperties> makePropertyTimeline(final FedoraResource fedoraResc) {
386        final var entryList = fedoraResc.getTimeMap().getChildren()
387                .map(memento -> new DirectContainerProperties(memento))
388                .collect(Collectors.toCollection(ArrayList::new));
389        // For versioning on demand, add the head version to the timeline
390        if (!propsConfig.isAutoVersioningEnabled()) {
391            entryList.add(new DirectContainerProperties(fedoraResc));
392        }
393        // First entry starts at creation time of the resource
394        entryList.get(0).startDatetime = fedoraResc.getCreatedDate();
395
396        // Reduce timeline to entries where significant properties change
397        final var changeEntries = new ArrayList<DirectContainerProperties>();
398        var curr = entryList.get(0);
399        changeEntries.add(curr);
400        for (int i = 1; i < entryList.size(); i++) {
401            final var next = entryList.get(i);
402            if (!Objects.equals(next.membershipResource, curr.membershipResource)
403                    || !Objects.equals(next.hasMemberRelation, curr.hasMemberRelation)
404                    || !Objects.equals(next.isMemberOfRelation, curr.isMemberOfRelation)) {
405                // Adjust the end the previous entry before the next state begins
406                curr.endDatetime = next.startDatetime;
407                changeEntries.add(next);
408                curr = next;
409            }
410        }
411        return changeEntries;
412    }
413
414    /**
415     * The properties of a Direct or Indirect Container at a point in time.
416     * @author bbpennel
417     */
418    private static class DirectContainerProperties {
419        public Node membershipResource;
420        public Node hasMemberRelation;
421        public Node isMemberOfRelation;
422        public Property insertedContentRelation;
423        public FedoraId id;
424        public ContainerType containerType;
425        public Instant startDatetime;
426        public Instant endDatetime = NO_END_INSTANT;
427
428        /**
429         * @param fedoraResc resource/memento from which the properties will be extracted
430         */
431        public DirectContainerProperties(final FedoraResource fedoraResc) {
432            this.containerType = getContainerType(fedoraResc);
433            if (containerType == null) {
434                return;
435            }
436            id = fedoraResc.getFedoraId();
437            startDatetime = fedoraResc.isMemento() ?
438                    fedoraResc.getMementoDatetime() : fedoraResc.getLastModifiedDate();
439            fedoraResc.getTriples().forEach(triple -> {
440                if (RdfLexicon.MEMBERSHIP_RESOURCE.asNode().equals(triple.getPredicate())) {
441                    membershipResource = triple.getObject();
442                } else if (RdfLexicon.HAS_MEMBER_RELATION.asNode().equals(triple.getPredicate())) {
443                    hasMemberRelation = triple.getObject();
444                } else if (RdfLexicon.IS_MEMBER_OF_RELATION.asNode().equals(triple.getPredicate())) {
445                    isMemberOfRelation = triple.getObject();
446                } else if (RdfLexicon.INSERTED_CONTENT_RELATION.asNode().equals(triple.getPredicate())) {
447                    insertedContentRelation = createProperty(triple.getObject().getURI());
448                }
449            });
450            if (hasMemberRelation == null && isMemberOfRelation == null) {
451                hasMemberRelation = RdfLexicon.LDP_MEMBER.asNode();
452            }
453        }
454    }
455
456    private static ContainerType getContainerType(final FedoraResource fedoraResc) {
457        if (!(fedoraResc instanceof Container)) {
458            return null;
459        }
460
461        if (RdfLexicon.INDIRECT_CONTAINER.getURI().equals(fedoraResc.getInteractionModel())) {
462            return ContainerType.Indirect;
463        }
464
465        if (RdfLexicon.DIRECT_CONTAINER.getURI().equals(fedoraResc.getInteractionModel())) {
466            return ContainerType.Direct;
467        }
468
469        return null;
470    }
471
472    @Override
473    public Instant getLastUpdatedTimestamp(final Transaction transaction, final FedoraId fedoraId) {
474        return indexManager.getLastUpdated(transaction, fedoraId);
475    }
476
477    private boolean isChildOfRoot(final FedoraResource resource) {
478        return resource.getParentId().isRepositoryRoot();
479    }
480
481    /**
482     * @param indexManager the indexManager to set
483     */
484    public void setMembershipIndexManager(final MembershipIndexManager indexManager) {
485        this.indexManager = indexManager;
486    }
487
488    /**
489     * @param resourceFactory the resourceFactory to set
490     */
491    public void setResourceFactory(final ResourceFactory resourceFactory) {
492        this.resourceFactory = resourceFactory;
493    }
494}