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.sql.ResultSet;
023import java.sql.SQLException;
024import java.sql.Timestamp;
025import java.time.Instant;
026import java.util.Map;
027import java.util.NoSuchElementException;
028import java.util.Queue;
029import java.util.Spliterator;
030import java.util.Spliterators;
031import java.util.concurrent.ConcurrentLinkedQueue;
032import java.util.function.Consumer;
033import java.util.stream.Stream;
034import java.util.stream.StreamSupport;
035
036import javax.annotation.PostConstruct;
037import javax.inject.Inject;
038import javax.sql.DataSource;
039import javax.transaction.Transactional;
040import org.apache.jena.graph.Node;
041import org.apache.jena.graph.NodeFactory;
042import org.apache.jena.graph.Triple;
043import org.fcrepo.common.db.DbPlatform;
044import org.fcrepo.kernel.api.identifiers.FedoraId;
045import org.slf4j.Logger;
046import org.springframework.core.io.DefaultResourceLoader;
047import org.springframework.jdbc.core.RowCallbackHandler;
048import org.springframework.jdbc.core.RowMapper;
049import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
050import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
051import org.springframework.jdbc.datasource.init.DatabasePopulatorUtils;
052import org.springframework.jdbc.datasource.init.ResourceDatabasePopulator;
053import org.springframework.stereotype.Component;
054
055import com.google.common.base.Preconditions;
056
057/**
058 * Manager for the membership index
059 *
060 * @author bbpennel
061 */
062@Component
063public class MembershipIndexManager {
064    private static final Logger log = getLogger(MembershipIndexManager.class);
065
066    private static final Timestamp NO_END_TIMESTAMP = Timestamp.from(MembershipServiceImpl.NO_END_INSTANT);
067    private static final Timestamp NO_START_TIMESTAMP = Timestamp.from(Instant.parse("1000-01-01T00:00:00.000Z"));
068
069    private static final String ADD_OPERATION = "add";
070    private static final String DELETE_OPERATION = "delete";
071    private static final String FORCE_FLAG = "force";
072
073    private static final String TX_ID_PARAM = "txId";
074    private static final String SUBJECT_ID_PARAM = "subjectId";
075    private static final String NO_END_TIME_PARAM = "noEndTime";
076    private static final String ADD_OP_PARAM = "addOp";
077    private static final String DELETE_OP_PARAM = "deleteOp";
078    private static final String MEMENTO_TIME_PARAM = "mementoTime";
079    private static final String PROPERTY_PARAM = "property";
080    private static final String TARGET_ID_PARAM = "targetId";
081    private static final String SOURCE_ID_PARAM = "sourceId";
082    private static final String START_TIME_PARAM = "startTime";
083    private static final String END_TIME_PARAM = "endTime";
084    private static final String LAST_UPDATED_PARAM = "lastUpdated";
085    private static final String OPERATION_PARAM = "operation";
086    private static final String FORCE_PARAM = "forceFlag";
087    private static final String OBJECT_ID_PARAM = "objectId";
088    private static final String LIMIT_PARAM = "limit";
089    private static final String OFFSET_PARAM = "offSet";
090
091    private static final String SELECT_ALL_MEMBERSHIP = "SELECT * FROM membership";
092
093    private static final String SELECT_ALL_OPERATIONS = "SELECT * FROM membership_tx_operations";
094
095    private static final String SELECT_MEMBERSHIP_IN_TX =
096            "SELECT property, object_id" +
097            " FROM membership m" +
098            " WHERE subject_id = :subjectId" +
099                " AND end_time = :noEndTime" +
100                " AND NOT EXISTS (" +
101                    " SELECT 1" +
102                    " FROM membership_tx_operations mto" +
103                    " WHERE mto.subject_id = :subjectId" +
104                        " AND mto.source_id = m.source_id" +
105                        " AND mto.object_id = m.object_id" +
106                        " AND mto.tx_id = :txId" +
107                        " AND mto.operation = :deleteOp)" +
108            " UNION" +
109            " SELECT property, object_id" +
110            " FROM membership_tx_operations" +
111            " WHERE subject_id = :subjectId" +
112                " AND tx_id = :txId" +
113                " AND end_time = :noEndTime" +
114                " AND operation = :addOp" +
115            " ORDER BY property, object_id" +
116            " LIMIT :limit OFFSET :offSet";
117
118    private static final String SELECT_MEMBERSHIP_MEMENTO_IN_TX =
119            "SELECT property, object_id" +
120            " FROM membership m" +
121            " WHERE m.subject_id = :subjectId" +
122                " AND m.start_time <= :mementoTime" +
123                " AND m.end_time > :mementoTime" +
124                " AND NOT EXISTS (" +
125                    " SELECT 1" +
126                    " FROM membership_tx_operations mto" +
127                    " WHERE mto.subject_id = :subjectId" +
128                        " AND mto.source_id = m.source_id" +
129                        " AND mto.property = m.property" +
130                        " AND mto.object_id = m.object_id" +
131                        " AND mto.end_time <= :mementoTime" +
132                        " AND mto.tx_id = :txId" +
133                        " AND mto.operation = :deleteOp)" +
134            " UNION" +
135            " SELECT property, object_id" +
136            " FROM membership_tx_operations" +
137            " WHERE subject_id = :subjectId" +
138                " AND tx_id = :txId" +
139                " AND start_time <= :mementoTime" +
140                " AND end_time > :mementoTime" +
141                " AND operation = :addOp" +
142            " ORDER BY property, object_id" +
143            " LIMIT :limit OFFSET :offSet";
144
145    private static final String SELECT_LAST_UPDATED =
146            "SELECT max(last_updated) as last_updated" +
147            " FROM membership" +
148            " WHERE subject_id = :subjectId";
149
150    // For mementos, use the start_time instead of last_updated as the
151    // end_time reflects when the next version starts
152    private static final String SELECT_LAST_UPDATED_MEMENTO =
153            "SELECT max(start_time)" +
154            " FROM membership" +
155            " WHERE subject_id = :subjectId" +
156                " AND start_time <= :mementoTime" +
157                " AND end_time > :mementoTime";
158
159    private static final String SELECT_LAST_UPDATED_IN_TX =
160            "SELECT max(combined.updated) as last_updated" +
161            " FROM (" +
162                " SELECT max(last_updated) as updated" +
163                " FROM membership m" +
164                " WHERE subject_id = :subjectId" +
165                    " AND NOT EXISTS (" +
166                        " SELECT 1" +
167                        " FROM membership_tx_operations mto" +
168                        " WHERE mto.subject_id = :subjectId" +
169                            " AND mto.source_id = m.source_id" +
170                            " AND mto.object_id = m.object_id" +
171                            " AND mto.tx_id = :txId" +
172                            " AND mto.operation = :deleteOp)" +
173                " UNION" +
174                " SELECT max(last_updated) as updated" +
175                " FROM membership_tx_operations" +
176                " WHERE subject_id = :subjectId" +
177                    " AND tx_id = :txId" +
178            ") combined";
179
180    private static final String INSERT_MEMBERSHIP_IN_TX =
181            "INSERT INTO membership_tx_operations" +
182            " (subject_id, property, object_id, source_id, start_time, end_time, last_updated, tx_id, operation)" +
183            " VALUES" +
184            " (:subjectId, :property, :targetId, :sourceId, :startTime, :endTime, :lastUpdated, :txId, :operation)";
185
186    private static final String END_EXISTING_MEMBERSHIP =
187            "INSERT INTO membership_tx_operations" +
188            " (subject_id, property, object_id, source_id, start_time, end_time, last_updated, tx_id, operation)" +
189            " SELECT m.subject_id, m.property, m.object_id, m.source_id, m.start_time," +
190                    " :endTime, :endTime, :txId, :deleteOp" +
191            " FROM membership m" +
192            " WHERE m.source_id = :sourceId" +
193                " AND m.end_time = :noEndTime" +
194                " AND m.subject_id = :subjectId" +
195                " AND m.property = :property" +
196                " AND m.object_id = :objectId";
197
198    private static final String CLEAR_ENTRY_IN_TX =
199            "DELETE FROM membership_tx_operations" +
200            " WHERE source_id = :sourceId" +
201                " AND tx_id = :txId" +
202                " AND subject_id = :subjectId" +
203                " AND property = :property" +
204                " AND object_id = :objectId" +
205                " AND operation = :operation" +
206                " AND force_flag IS NULL";
207
208    private static final String CLEAR_ALL_ADDED_FOR_SOURCE_IN_TX =
209            "DELETE FROM membership_tx_operations" +
210            " WHERE source_id = :sourceId" +
211                " AND tx_id = :txId" +
212                " AND operation = :addOp";
213
214    // Add "delete" entries for all existing membership from the given source, if not already deleted
215    private static final String END_EXISTING_FOR_SOURCE =
216            "INSERT INTO membership_tx_operations" +
217            " (subject_id, property, object_id, source_id, start_time, end_time, last_updated, tx_id, operation)" +
218            " SELECT subject_id, property, object_id, source_id, start_time, :endTime, :endTime, :txId, :deleteOp" +
219            " FROM membership m" +
220            " WHERE source_id = :sourceId" +
221                " AND end_time = :noEndTime" +
222                " AND NOT EXISTS (" +
223                    " SELECT TRUE" +
224                    " FROM membership_tx_operations mtx" +
225                    " WHERE mtx.subject_id = m.subject_id" +
226                        " AND mtx.property = m.property" +
227                        " AND mtx.object_id = m.object_id" +
228                        " AND mtx.source_id = m.source_id" +
229                        " AND mtx.operation = :deleteOp" +
230                    ")";
231
232    private static final String DELETE_EXISTING_FOR_SOURCE_AFTER =
233            "INSERT INTO membership_tx_operations(subject_id, property, object_id, source_id," +
234                    " start_time, end_time, last_updated, tx_id, operation, force_flag)" +
235            " SELECT subject_id, property, object_id, source_id, start_time, end_time," +
236                    " last_updated, :txId, :deleteOp, :forceFlag" +
237            " FROM membership m" +
238            " WHERE m.source_id = :sourceId" +
239                " AND (m.start_time >= :startTime" +
240                " OR m.end_time >= :startTime)";
241
242    private static final String PURGE_ALL_REFERENCES_MEMBERSHIP =
243            "DELETE from membership" +
244            " where source_id = :targetId" +
245                " OR subject_id = :targetId" +
246                " OR object_id = :targetId";
247
248    private static final String PURGE_ALL_REFERENCES_TRANSACTION =
249            "DELETE from membership_tx_operations" +
250            " WHERE tx_id = :txId" +
251                " AND (source_id = :targetId" +
252                " OR subject_id = :targetId" +
253                " OR object_id = :targetId)";
254
255    private static final String COMMIT_DELETES =
256            "DELETE from membership" +
257            " WHERE EXISTS (" +
258                " SELECT TRUE" +
259                " FROM membership_tx_operations mto" +
260                " WHERE mto.tx_id = :txId" +
261                    " AND mto.operation = :deleteOp" +
262                    " AND mto.force_flag = :forceFlag" +
263                    " AND membership.source_id = mto.source_id" +
264                    " AND membership.subject_id = mto.subject_id" +
265                    " AND membership.property = mto.property" +
266                    " AND membership.object_id = mto.object_id" +
267                " )";
268
269    private static final String COMMIT_ENDS_H2 =
270            "UPDATE membership m" +
271            " SET end_time = (" +
272                " SELECT mto.end_time" +
273                " FROM membership_tx_operations mto" +
274                " WHERE mto.tx_id = :txId" +
275                    " AND m.source_id = mto.source_id" +
276                    " AND m.subject_id = mto.subject_id" +
277                    " AND m.property = mto.property" +
278                    " AND m.object_id = mto.object_id" +
279                    " AND mto.operation = :deleteOp" +
280                " )," +
281                " last_updated = (" +
282                    " SELECT mto.end_time" +
283                    " FROM membership_tx_operations mto" +
284                    " WHERE mto.tx_id = :txId" +
285                        " AND m.source_id = mto.source_id" +
286                        " AND m.subject_id = mto.subject_id" +
287                        " AND m.property = mto.property" +
288                        " AND m.object_id = mto.object_id" +
289                        " AND mto.operation = :deleteOp" +
290                    " )" +
291            " WHERE EXISTS (" +
292                "SELECT TRUE" +
293                " FROM membership_tx_operations mto" +
294                " WHERE mto.tx_id = :txId" +
295                    " AND mto.operation = :deleteOp" +
296                    " AND m.source_id = mto.source_id" +
297                    " AND m.subject_id = mto.subject_id" +
298                    " AND m.property = mto.property" +
299                    " AND m.object_id = mto.object_id" +
300                " )";
301
302    private static final String COMMIT_ENDS_POSTGRES =
303            "UPDATE membership" +
304            " SET end_time = mto.end_time, last_updated = mto.end_time" +
305            " FROM membership_tx_operations mto" +
306            " WHERE mto.tx_id = :txId" +
307                " AND mto.operation = :deleteOp" +
308                " AND membership.source_id = mto.source_id" +
309                " AND membership.subject_id = mto.subject_id" +
310                " AND membership.property = mto.property" +
311                " AND membership.object_id = mto.object_id";
312
313    private static final String COMMIT_ENDS_MYSQL =
314            "UPDATE membership m" +
315            " INNER JOIN membership_tx_operations mto ON" +
316                " m.source_id = mto.source_id" +
317                " AND m.subject_id = mto.subject_id" +
318                " AND m.property = mto.property" +
319                " AND m.object_id = mto.object_id" +
320            " SET m.end_time = mto.end_time, m.last_updated = mto.end_time" +
321            " WHERE mto.tx_id = :txId" +
322                " AND mto.operation = :deleteOp";
323
324    private static final Map<DbPlatform, String> COMMIT_ENDS_MAP = Map.of(
325            DbPlatform.MYSQL, COMMIT_ENDS_MYSQL,
326            DbPlatform.MARIADB, COMMIT_ENDS_MYSQL,
327            DbPlatform.POSTGRESQL, COMMIT_ENDS_POSTGRES,
328            DbPlatform.H2, COMMIT_ENDS_H2
329    );
330
331    // Transfer all "add" operations from tx to committed membership, unless the entry already exists
332    private static final String COMMIT_ADDS =
333            "INSERT INTO membership" +
334            " (subject_id, property, object_id, source_id, start_time, end_time, last_updated)" +
335            " SELECT subject_id, property, object_id, source_id, start_time, end_time, last_updated" +
336            " FROM membership_tx_operations mto" +
337            " WHERE mto.tx_id = :txId" +
338                " AND mto.operation = :addOp" +
339                " AND NOT EXISTS (" +
340                    " SELECT TRUE" +
341                    " FROM membership m" +
342                    " WHERE m.source_id = mto.source_id" +
343                        " AND m.subject_id = mto.subject_id" +
344                        " AND m.property = mto.property" +
345                        " AND m.object_id = mto.object_id" +
346                        " AND m.start_time = mto.start_time" +
347                        " AND m.end_time = mto.end_time" +
348                " )";
349
350    private static final String DELETE_TRANSACTION =
351            "DELETE FROM membership_tx_operations" +
352            " WHERE tx_id = :txId";
353
354    private static final String TRUNCATE_MEMBERSHIP = "TRUNCATE TABLE membership";
355
356    private static final String TRUNCATE_MEMBERSHIP_TX = "TRUNCATE TABLE membership_tx_operations";
357
358    @Inject
359    private DataSource dataSource;
360
361    private NamedParameterJdbcTemplate jdbcTemplate;
362
363    private DbPlatform dbPlatform;
364
365    private static final Map<DbPlatform, String> DDL_MAP = Map.of(
366            DbPlatform.MYSQL, "sql/mysql-membership.sql",
367            DbPlatform.H2, "sql/default-membership.sql",
368            DbPlatform.POSTGRESQL, "sql/default-membership.sql",
369            DbPlatform.MARIADB, "sql/mariadb-membership.sql"
370    );
371
372    private static final int MEMBERSHIP_LIMIT = 50000;
373
374    @PostConstruct
375    public void setUp() {
376        jdbcTemplate = new NamedParameterJdbcTemplate(getDataSource());
377
378        dbPlatform = DbPlatform.fromDataSource(dataSource);
379
380        Preconditions.checkArgument(DDL_MAP.containsKey(dbPlatform),
381                "Missing DDL mapping for %s", dbPlatform);
382
383        final var ddl = DDL_MAP.get(dbPlatform);
384        log.debug("Applying ddl: {}", ddl);
385        DatabasePopulatorUtils.execute(
386                new ResourceDatabasePopulator(new DefaultResourceLoader().getResource("classpath:" + ddl)),
387                dataSource);
388    }
389
390    /**
391     * End a membership entry, setting an end time if committed, or clearing from the current tx
392     * if it was newly added.
393     *
394     * @param txId transaction id
395     * @param sourceId ID of the direct/indirect container whose membership should be ended
396     * @param membership membership triple to end
397     * @param endTime the time the resource was deleted, generally its last modified
398     */
399    @Transactional
400    public void endMembership(final String txId,  final FedoraId sourceId, final Triple membership,
401            final Instant endTime) {
402        final Map<String, Object> parameterSource = Map.of(
403                TX_ID_PARAM, txId,
404                SOURCE_ID_PARAM, sourceId.getFullId(),
405                SUBJECT_ID_PARAM, membership.getSubject().getURI(),
406                PROPERTY_PARAM, membership.getPredicate().getURI(),
407                OBJECT_ID_PARAM, membership.getObject().getURI(),
408                OPERATION_PARAM, ADD_OPERATION);
409
410        final int affected = jdbcTemplate.update(CLEAR_ENTRY_IN_TX, parameterSource);
411
412        // If no rows were deleted, then assume we need to delete permanent entry
413        if (affected == 0) {
414            final Map<String, Object> parameterSource2 = Map.of(
415                    TX_ID_PARAM, txId,
416                    SOURCE_ID_PARAM, sourceId.getFullId(),
417                    SUBJECT_ID_PARAM, membership.getSubject().getURI(),
418                    PROPERTY_PARAM, membership.getPredicate().getURI(),
419                    OBJECT_ID_PARAM, membership.getObject().getURI(),
420                    END_TIME_PARAM, formatInstant(endTime),
421                    NO_END_TIME_PARAM, NO_END_TIMESTAMP,
422                    DELETE_OP_PARAM, DELETE_OPERATION);
423            jdbcTemplate.update(END_EXISTING_MEMBERSHIP, parameterSource2);
424        }
425    }
426
427    /**
428     * End all membership properties resulting from the specified source container
429     * @param txId transaction id
430     * @param sourceId ID of the direct/indirect container whose membership should be ended
431     * @param endTime the time the resource was deleted, generally its last modified
432     */
433    @Transactional
434    public void endMembershipForSource(final String txId, final FedoraId sourceId, final Instant endTime) {
435        final Map<String, Object> parameterSource = Map.of(
436                TX_ID_PARAM, txId,
437                SOURCE_ID_PARAM, sourceId.getFullId(),
438                ADD_OP_PARAM, ADD_OPERATION);
439
440        jdbcTemplate.update(CLEAR_ALL_ADDED_FOR_SOURCE_IN_TX, parameterSource);
441
442        final Map<String, Object> parameterSource2 = Map.of(
443                TX_ID_PARAM, txId,
444                SOURCE_ID_PARAM, sourceId.getFullId(),
445                END_TIME_PARAM, formatInstant(endTime),
446                NO_END_TIME_PARAM, NO_END_TIMESTAMP,
447                DELETE_OP_PARAM, DELETE_OPERATION);
448        jdbcTemplate.update(END_EXISTING_FOR_SOURCE, parameterSource2);
449    }
450
451    /**
452     * Delete membership entries that are active at or after the given timestamp for the specified source
453     * @param txId transaction id
454     * @param sourceId ID of the direct/indirect container
455     * @param afterTime time at or after which membership should be removed
456     */
457    @Transactional
458    public void deleteMembershipForSourceAfter(final String txId, final FedoraId sourceId, final Instant afterTime) {
459        // Clear all membership added in this transaction
460        final Map<String, Object> parameterSource = Map.of(
461                TX_ID_PARAM, txId,
462                SOURCE_ID_PARAM, sourceId.getFullId(),
463                ADD_OP_PARAM, ADD_OPERATION);
464
465        jdbcTemplate.update(CLEAR_ALL_ADDED_FOR_SOURCE_IN_TX, parameterSource);
466
467        final var afterTimestamp = afterTime == null ? NO_START_TIMESTAMP : formatInstant(afterTime);
468
469        // Delete all existing membership entries that start after or end after the given timestamp
470        final Map<String, Object> parameterSource2 = Map.of(
471                TX_ID_PARAM, txId,
472                SOURCE_ID_PARAM, sourceId.getFullId(),
473                START_TIME_PARAM, afterTimestamp,
474                FORCE_PARAM, FORCE_FLAG,
475                DELETE_OP_PARAM, DELETE_OPERATION);
476        jdbcTemplate.update(DELETE_EXISTING_FOR_SOURCE_AFTER, parameterSource2);
477    }
478
479    /**
480     * Clean up any references to the target id, in transactions and outside
481     * @param txId transaction id
482     * @param targetId identifier of the resource to cleanup membership references for
483     */
484    @Transactional
485    public void deleteMembershipReferences(final String txId, final FedoraId targetId) {
486        final Map<String, Object> parameterSource = Map.of(
487                TARGET_ID_PARAM, targetId.getFullId(),
488                TX_ID_PARAM, txId);
489
490        jdbcTemplate.update(PURGE_ALL_REFERENCES_TRANSACTION, parameterSource);
491        jdbcTemplate.update(PURGE_ALL_REFERENCES_MEMBERSHIP, parameterSource);
492    }
493
494    /**
495     * Add new membership property to the index, clearing any delete
496     * operations for the property if necessary.
497     * @param txId transaction id
498     * @param sourceId ID of the direct/indirect container which produced the membership
499     * @param membership membership triple
500     * @param startTime time the membership triple was added
501     */
502    @Transactional
503    public void addMembership(final String txId, final FedoraId sourceId, final Triple membership,
504            final Instant startTime) {
505        // Clear any existing delete operation for this membership
506        final Map<String, Object> parametersDelete = Map.of(
507                TX_ID_PARAM, txId,
508                SOURCE_ID_PARAM, sourceId.getFullId(),
509                SUBJECT_ID_PARAM, membership.getSubject().getURI(),
510                PROPERTY_PARAM, membership.getPredicate().getURI(),
511                OBJECT_ID_PARAM, membership.getObject().getURI(),
512                OPERATION_PARAM, DELETE_OPERATION);
513
514        jdbcTemplate.update(CLEAR_ENTRY_IN_TX, parametersDelete);
515
516        // Add the new membership operation
517        addMembership(txId, sourceId, membership, startTime, null);
518    }
519
520    /**
521     * Add new membership property to the index
522     * @param txId transaction id
523     * @param sourceId ID of the direct/indirect container which produced the membership
524     * @param membership membership triple
525     * @param startTime time the membership triple was added
526     * @param endTime time the membership triple ends, or never if not provided
527     */
528    public void addMembership(final String txId, final FedoraId sourceId, final Triple membership,
529            final Instant startTime, final Instant endTime) {
530        final Timestamp endTimestamp;
531        final Timestamp lastUpdated;
532        final Timestamp startTimestamp = formatInstant(startTime);
533        if (endTime == null) {
534            endTimestamp = NO_END_TIMESTAMP;
535            lastUpdated = startTimestamp;
536        } else {
537            endTimestamp = formatInstant(endTime);
538            lastUpdated = endTimestamp;
539        }
540        // Add the new membership operation
541        final Map<String, Object> parameterSource = Map.of(
542                SUBJECT_ID_PARAM, membership.getSubject().getURI(),
543                PROPERTY_PARAM, membership.getPredicate().getURI(),
544                TARGET_ID_PARAM, membership.getObject().getURI(),
545                SOURCE_ID_PARAM, sourceId.getFullId(),
546                START_TIME_PARAM, startTimestamp,
547                END_TIME_PARAM, endTimestamp,
548                LAST_UPDATED_PARAM, lastUpdated,
549                TX_ID_PARAM, txId,
550                OPERATION_PARAM, ADD_OPERATION);
551
552        jdbcTemplate.update(INSERT_MEMBERSHIP_IN_TX, parameterSource);
553    }
554
555    /**
556     * Get a stream of membership triples with
557     * @param txId transaction from which membership will be retrieved, or null for no transaction
558     * @param subjectId ID of the subject
559     * @return Stream of membership triples
560     */
561    public Stream<Triple> getMembership(final String txId, final FedoraId subjectId) {
562        final Node subjectNode = NodeFactory.createURI(subjectId.getBaseId());
563
564        final RowMapper<Triple> membershipMapper = (rs, rowNum) ->
565                Triple.create(subjectNode,
566                        NodeFactory.createURI(rs.getString("property")),
567                        NodeFactory.createURI(rs.getString("object_id")));
568
569        final MapSqlParameterSource parameterSource = new MapSqlParameterSource();
570        parameterSource.addValue(TX_ID_PARAM, txId);
571
572        final String query;
573        if (subjectId.isMemento()) {
574            parameterSource.addValue(SUBJECT_ID_PARAM, subjectId.getBaseId());
575            parameterSource.addValue(MEMENTO_TIME_PARAM, formatInstant(subjectId.getMementoInstant()));
576            query = SELECT_MEMBERSHIP_MEMENTO_IN_TX;
577        } else {
578            parameterSource.addValue(SUBJECT_ID_PARAM, subjectId.getFullId());
579            parameterSource.addValue(NO_END_TIME_PARAM, NO_END_TIMESTAMP);
580            query = SELECT_MEMBERSHIP_IN_TX;
581        }
582
583        return StreamSupport.stream(new MembershipIterator(query, parameterSource, membershipMapper), false);
584    }
585
586    public Instant getLastUpdated(final String txId, final FedoraId subjectId) {
587        final MapSqlParameterSource parameterSource = new MapSqlParameterSource();
588
589        parameterSource.addValue(NO_END_TIME_PARAM, NO_END_TIMESTAMP);
590        final String lastUpdatedQuery;
591        if (subjectId.isMemento()) {
592            lastUpdatedQuery = SELECT_LAST_UPDATED_MEMENTO;
593            parameterSource.addValue(SUBJECT_ID_PARAM, subjectId.getBaseId());
594            parameterSource.addValue(MEMENTO_TIME_PARAM, formatInstant(subjectId.getMementoInstant()));
595        } else if (txId == null) {
596            lastUpdatedQuery = SELECT_LAST_UPDATED;
597            parameterSource.addValue(SUBJECT_ID_PARAM, subjectId.getFullId());
598        } else {
599            lastUpdatedQuery = SELECT_LAST_UPDATED_IN_TX;
600            parameterSource.addValue(SUBJECT_ID_PARAM, subjectId.getFullId());
601            parameterSource.addValue(TX_ID_PARAM, txId);
602            parameterSource.addValue(DELETE_OP_PARAM, DELETE_OPERATION);
603        }
604
605        return jdbcTemplate.queryForObject(lastUpdatedQuery, parameterSource, Instant.class);
606    }
607
608    /**
609     * Perform a commit of operations stored in the specified transaction
610     * @param txId transaction id
611     */
612    @Transactional
613    public void commitTransaction(final String txId) {
614        final Map<String, String> parameterSource = Map.of(TX_ID_PARAM, txId,
615                ADD_OP_PARAM, ADD_OPERATION,
616                DELETE_OP_PARAM, DELETE_OPERATION,
617                FORCE_PARAM, FORCE_FLAG);
618
619        jdbcTemplate.update(COMMIT_DELETES, parameterSource);
620        final int ends = jdbcTemplate.update(COMMIT_ENDS_MAP.get(this.dbPlatform), parameterSource);
621        final int adds = jdbcTemplate.update(COMMIT_ADDS, parameterSource);
622        final int cleaned = jdbcTemplate.update(DELETE_TRANSACTION, parameterSource);
623
624        log.debug("Completed commit, {} ended, {} adds, {} operations", ends, adds, cleaned);
625    }
626
627    /**
628     * Delete all entries related to a transaction
629     * @param txId transaction id
630     */
631    public void deleteTransaction(final String txId) {
632        final Map<String, String> parameterSource = Map.of(TX_ID_PARAM, txId);
633        jdbcTemplate.update(DELETE_TRANSACTION, parameterSource);
634    }
635
636    /**
637     * Format an instant to a timestamp without milliseconds, due to precision
638     * issues with memento datetimes.
639     * @param instant
640     * @return
641     */
642    private Timestamp formatInstant(final Instant instant) {
643        final var timestamp = Timestamp.from(instant);
644        timestamp.setNanos(0);
645        return timestamp;
646    }
647
648    /**
649     * Clear all entries from the index
650     */
651    @Transactional
652    public void clearIndex() {
653        jdbcTemplate.update(TRUNCATE_MEMBERSHIP, Map.of());
654        jdbcTemplate.update(TRUNCATE_MEMBERSHIP_TX, Map.of());
655    }
656
657    /**
658     * Log all membership entries, for debugging usage only
659     */
660    public void logMembership() {
661        log.info("source_id, subject_id, property, object_id, start_time, end_time, last_updated");
662        jdbcTemplate.query(SELECT_ALL_MEMBERSHIP, new RowCallbackHandler() {
663            @Override
664            public void processRow(final ResultSet rs) throws SQLException {
665                log.info("{}, {}, {}, {}, {}, {}, {}", rs.getString("source_id"), rs.getString("subject_id"),
666                        rs.getString("property"), rs.getString("object_id"), rs.getTimestamp("start_time"),
667                        rs.getTimestamp("end_time"), rs.getTimestamp("last_updated"));
668            }
669        });
670    }
671
672    /**
673     * Log all membership operations, for debugging usage only
674     */
675    public void logOperations() {
676        log.info("source_id, subject_id, property, object_id, start_time, end_time,"
677                + " last_updated, tx_id, operation, force_flag");
678        jdbcTemplate.query(SELECT_ALL_OPERATIONS, new RowCallbackHandler() {
679            @Override
680            public void processRow(final ResultSet rs) throws SQLException {
681                log.info("{}, {}, {}, {}, {}, {}, {}, {}, {}, {}",
682                        rs.getString("source_id"), rs.getString("subject_id"), rs.getString("property"),
683                        rs.getString("object_id"), rs.getTimestamp("start_time"), rs.getTimestamp("end_time"),
684                        rs.getTimestamp("last_updated"), rs.getString("tx_id"), rs.getString("operation"),
685                        rs.getString("force_flag"));
686            }
687        });
688    }
689
690    /**
691     * Set the JDBC datastore.
692     * @param dataSource the dataStore.
693     */
694    public void setDataSource(final DataSource dataSource) {
695        this.dataSource = dataSource;
696    }
697
698    /**
699     * Get the JDBC datastore.
700     * @return the dataStore.
701     */
702    public DataSource getDataSource() {
703        return dataSource;
704    }
705
706    /**
707     * Private class to back a stream with a paged DB query.
708     *
709     * If this needs to be run in parallel we will have to override trySplit() and determine a good method to split on.
710     */
711    private class MembershipIterator extends Spliterators.AbstractSpliterator<Triple> {
712        final Queue<Triple> children = new ConcurrentLinkedQueue<>();
713        int numOffsets = 0;
714        final String queryToUse;
715        final MapSqlParameterSource parameterSource;
716        final RowMapper<Triple> rowMapper;
717
718        public MembershipIterator(final String query, final MapSqlParameterSource parameters,
719                                  final RowMapper<Triple> mapper) {
720            super(Long.MAX_VALUE, Spliterator.ORDERED);
721            queryToUse = query;
722            parameterSource = parameters;
723            rowMapper = mapper;
724            parameterSource.addValue(ADD_OP_PARAM, ADD_OPERATION);
725            parameterSource.addValue(DELETE_OP_PARAM, DELETE_OPERATION);
726            parameterSource.addValue(LIMIT_PARAM, MEMBERSHIP_LIMIT);
727        }
728
729        @Override
730        public boolean tryAdvance(final Consumer<? super Triple> action) {
731            try {
732                action.accept(children.remove());
733            } catch (final NoSuchElementException e) {
734                parameterSource.addValue(OFFSET_PARAM, numOffsets * MEMBERSHIP_LIMIT);
735                numOffsets += 1;
736                children.addAll(jdbcTemplate.query(queryToUse, parameterSource, rowMapper));
737                if (children.size() == 0) {
738                    // no more elements.
739                    return false;
740                }
741                action.accept(children.remove());
742            }
743            return true;
744        }
745    }
746}