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