package de.otto.edison.mongo; import static java.util.Objects.isNull; import static java.util.Optional.ofNullable; import static java.util.stream.Collectors.toList; import static com.mongodb.client.model.Filters.and; import static com.mongodb.client.model.Filters.eq; import static com.mongodb.client.model.ReturnDocument.AFTER; import static de.otto.edison.mongo.UpdateIfMatchResult.CONCURRENTLY_MODIFIED; import static de.otto.edison.mongo.UpdateIfMatchResult.NOT_FOUND; import static de.otto.edison.mongo.UpdateIfMatchResult.OK; import java.util.List; import java.util.Optional; import java.util.stream.Stream; import java.util.stream.StreamSupport; import javax.annotation.PostConstruct; import org.bson.Document; import org.bson.conversions.Bson; import com.mongodb.client.MongoCollection; import com.mongodb.client.model.FindOneAndReplaceOptions; import com.mongodb.client.model.UpdateOptions; public abstract class AbstractMongoRepository<K, V> { public static final String ID = "_id"; public static final String ETAG = "etag"; private static final boolean DISABLE_PARALLEL_STREAM_PROCESSING = false; @PostConstruct public void postConstruct() { ensureIndexes(); } public Optional<V> findOne(final K key) { return ofNullable(collection() .find(byId(key)) .map(this::decode) .first()); } /** * Convert given {@link Iterable} to a standard Java8-{@link Stream}. * The {@link Stream} requests elements from the iterable in a lazy fashion as they will usually, * so p.e. passing <code>collection().find()</code> as parameter will not result in the * whole collection being read into memory. * <p> * Parallel processing of the iterable is not used. * * @param iterable any {@link Iterable} * @param <T> the type of elements returned by the iterator * @return a {@link Stream} wrapping the given {@link Iterable} */ protected static <T> Stream<T> toStream(final Iterable<T> iterable) { return StreamSupport.stream(iterable.spliterator(), DISABLE_PARALLEL_STREAM_PROCESSING); } public Stream<V> findAllAsStream() { return toStream(collection().find()) .map(this::decode); } public List<V> findAll() { return findAllAsStream().collect(toList()); } public Stream<V> findAllAsStream(final int skip, final int limit) { return toStream( collection() .find() .skip(skip) .limit(limit)) .map(this::decode); } public List<V> findAll(final int skip, final int limit) { return findAllAsStream(skip, limit).collect(toList()); } public V createOrUpdate(final V value) { final Document doc = encode(value); collection().replaceOne(byId(keyOf(value)), doc, new UpdateOptions().upsert(true)); return decode(doc); } public V create(final V value) { final K key = keyOf(value); if (key != null) { final Document doc = encode(value); collection().insertOne(doc); return decode(doc); } else { throw new NullPointerException("Key must not be null"); } } /** * Updates the document if it is already present in the repository. * * @param value the new value * @return true, if the document was updated, false otherwise. */ public boolean update(final V value) { final K key = keyOf(value); if (key != null) { return collection() .replaceOne(byId(key), encode(value)) .getModifiedCount() == 1; } else { throw new IllegalArgumentException("Key must not be null"); } } /** * Updates the document if the document's ETAG is matching the given etag (conditional put). * <p> * Using this method requires that the document contains an "etag" field that is updated if * the document is changed. * </p> * * @param value the new value * @param eTag the etag used for conditional update * @return {@link UpdateIfMatchResult} */ public UpdateIfMatchResult updateIfMatch(final V value, final String eTag) { final K key = keyOf(value); if (key != null) { final Bson query = and(eq(AbstractMongoRepository.ID, key), eq(ETAG, eTag)); final Document updatedETaggable = collection().findOneAndReplace(query, encode(value), new FindOneAndReplaceOptions().returnDocument(AFTER)); if (isNull(updatedETaggable)) { final boolean documentExists = collection().count(eq(AbstractMongoRepository.ID, key)) != 0; if (documentExists) { return CONCURRENTLY_MODIFIED; } return NOT_FOUND; } return OK; } else { throw new IllegalArgumentException("Key must not be null"); } } public long size() { return collection().count(); } /** * Deletes the document identified by key. * * @param key the identifier of the deleted document */ public void delete(final K key) { collection().deleteOne(byId(key)); } /** * Deletes all documents from this repository. */ public void deleteAll() { collection().deleteMany(matchAll()); } /** * Returns a query that is selecting documents by ID. * * @param key the document's key * @return query Document */ protected Document byId(final K key) { if (key != null) { return new Document(ID, key.toString()); } else { throw new NullPointerException("Key must not be null"); } } /** * Returns a query that is selecting all documents. * * @return query Document */ protected Document matchAll() { return new Document(); } /** * @return the MongoCollection used by this repository to store {@link Document documents} */ protected abstract MongoCollection<Document> collection(); /** * Returns the key / identifier from the given value. * <p> * The key of a document must never be null. * </p> * @param value the value * @return key */ protected abstract K keyOf(final V value); /** * Encode a value into a MongoDB {@link Document}. * * @param value the value * @return Document */ protected abstract Document encode(final V value); /** * Decode a MongoDB {@link Document} into a value. * * @param document the Document * @return V */ protected abstract V decode(final Document document); /** * Ensure that the MongoDB indexes required by the repository do exist. * <p> * This method is called once after startup of the application. * </p> */ protected abstract void ensureIndexes(); }