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