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