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