/* * Copyright © 2016, 2018 IBM Corp. All rights reserved. * * Original iOS version by Jens Alfke, ported to Android by Marty Schoch * Copyright © 2012 Couchbase, Inc. All rights reserved. * * Modifications for this distribution by Cloudant, Inc., Copyright © 2013 Cloudant, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file * except in compliance with the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software distributed under the * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, * either express or implied. See the License for the specific language governing permissions * and limitations under the License. */ package com.cloudant.sync.internal.documentstore; import com.cloudant.sync.documentstore.Attachment; import com.cloudant.sync.documentstore.AttachmentException; import com.cloudant.sync.documentstore.Changes; import com.cloudant.sync.documentstore.ConflictException; import com.cloudant.sync.documentstore.ConflictResolver; import com.cloudant.sync.documentstore.Database; import com.cloudant.sync.documentstore.DocumentBody; import com.cloudant.sync.documentstore.DocumentException; import com.cloudant.sync.documentstore.DocumentNotFoundException; import com.cloudant.sync.documentstore.DocumentRevision; import com.cloudant.sync.documentstore.DocumentStoreException; import com.cloudant.sync.documentstore.InvalidDocumentException; import com.cloudant.sync.documentstore.LocalDocument; import com.cloudant.sync.documentstore.encryption.KeyProvider; import com.cloudant.sync.event.EventBus; import com.cloudant.sync.event.notifications.DocumentCreated; import com.cloudant.sync.event.notifications.DocumentDeleted; import com.cloudant.sync.event.notifications.DocumentModified; import com.cloudant.sync.event.notifications.DocumentUpdated; import com.cloudant.sync.internal.common.CouchConstants; import com.cloudant.sync.internal.common.CouchUtils; import com.cloudant.sync.internal.common.ValueListMap; import com.cloudant.sync.internal.documentstore.callables.ChangesCallable; import com.cloudant.sync.internal.documentstore.callables.CompactCallable; import com.cloudant.sync.internal.documentstore.callables.DeleteAllRevisionsCallable; import com.cloudant.sync.internal.documentstore.callables.DeleteDocumentCallable; import com.cloudant.sync.internal.documentstore.callables.DeleteLocalDocumentCallable; import com.cloudant.sync.internal.documentstore.callables.ForceInsertCallable; import com.cloudant.sync.internal.documentstore.callables.GetAllDocumentIdsCallable; import com.cloudant.sync.internal.documentstore.callables.GetAllDocumentsCallable; import com.cloudant.sync.internal.documentstore.callables.GetAllRevisionsOfDocumentCallable; import com.cloudant.sync.internal.documentstore.callables.GetConflictedDocumentIdsCallable; import com.cloudant.sync.internal.documentstore.callables.GetDocumentCallable; import com.cloudant.sync.internal.documentstore.callables.GetDocumentCountCallable; import com.cloudant.sync.internal.documentstore.callables.GetDocumentsWithIdsCallable; import com.cloudant.sync.internal.documentstore.callables.GetLastSequenceCallable; import com.cloudant.sync.internal.documentstore.callables.GetLocalDocumentCallable; import com.cloudant.sync.internal.documentstore.callables.GetPossibleAncestorRevisionIdsCallable; import com.cloudant.sync.internal.documentstore.callables.GetPublicIdentifierCallable; import com.cloudant.sync.internal.documentstore.callables.GetSequenceCallable; import com.cloudant.sync.internal.documentstore.callables.InsertDocumentIDCallable; import com.cloudant.sync.internal.documentstore.callables.InsertLocalDocumentCallable; import com.cloudant.sync.internal.documentstore.callables.InsertRevisionCallable; import com.cloudant.sync.internal.documentstore.callables.ResolveConflictsForDocumentCallable; import com.cloudant.sync.internal.documentstore.callables.RevsDiffBatchCallable; import com.cloudant.sync.internal.documentstore.callables.SetCurrentCallable; import com.cloudant.sync.internal.documentstore.callables.UpdateDocumentFromRevisionCallable; import com.cloudant.sync.internal.documentstore.migrations.MigrateDatabase100To200; import com.cloudant.sync.internal.documentstore.migrations.MigrateDatabase6To100; import com.cloudant.sync.internal.documentstore.migrations.SchemaOnlyMigration; import com.cloudant.sync.internal.sqlite.Cursor; import com.cloudant.sync.internal.sqlite.SQLCallable; import com.cloudant.sync.internal.sqlite.SQLDatabase; import com.cloudant.sync.internal.sqlite.SQLDatabaseQueue; import com.cloudant.sync.internal.util.CollectionUtils; import com.cloudant.sync.internal.util.DatabaseUtils; import com.cloudant.sync.internal.util.JSONUtils; import com.cloudant.sync.internal.util.Misc; import java.io.File; import java.io.IOException; import java.sql.SQLException; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.concurrent.ExecutionException; import java.util.concurrent.Future; import java.util.logging.Level; import java.util.logging.Logger; public class DatabaseImpl implements Database, com.cloudant.sync.documentstore.advanced.Database { private static final Logger logger = Logger.getLogger(DatabaseImpl.class.getCanonicalName()); // Limit of parameters (placeholders) one query can have. // SQLite has limit on the number of placeholders on a single query, default 999. // http://www.sqlite.org/limits.html public static final int SQLITE_QUERY_PLACEHOLDERS_LIMIT = 500; private final EventBus eventBus; final File datastoreDir; private static final String DB_FILE_NAME = "db.sync"; /** * Stores a reference to the encryption key provider so * it can be passed to extensions. */ private final KeyProvider keyProvider; /** * Queue for all database tasks. */ private final SQLDatabaseQueue queue; /** Name used to get storage folder for attachments */ private static final String ATTACHMENTS_EXTENSION_NAME = "com.cloudant.attachments"; /** Directory where attachments are stored for this DocumentStore */ private final String attachmentsDir; /** * Creates streams used for encrypting and encoding (gzip etc.) attachments when * reading to and from disk. */ private final AttachmentStreamFactory attachmentStreamFactory; /** * Constructor for single thread SQLCipher-based DocumentStore. * @param location The location where the DocumentStore will be opened/created * @param extensionsLocation The location where the DocumentStore's extensions are stored * @param provider The key provider object that contains the user-defined SQLCipher key * @throws SQLException * @throws IOException */ public DatabaseImpl(File location, File extensionsLocation, KeyProvider provider) throws SQLException, IOException, DocumentStoreException { Misc.checkNotNull(location, "location"); Misc.checkNotNull(extensionsLocation, "extensionsLocation"); Misc.checkNotNull(provider, "Key provider"); this.keyProvider = provider; this.datastoreDir = location; this.attachmentsDir = new File(extensionsLocation, ATTACHMENTS_EXTENSION_NAME).getAbsolutePath(); final File dbFile = new File(this.datastoreDir, DB_FILE_NAME); queue = new SQLDatabaseQueue(dbFile, provider); int dbVersion = queue.getVersion(); // Increment the hundreds position if a schema change means that older // versions of the code will not be able to read the migrated database. int highestSupportedVersionExclusive = 300; if (dbVersion >= highestSupportedVersionExclusive) { throw new DocumentStoreException(String.format("Database version is higher than the " + "version supported by this library, current version %d , highest supported " + "version %d", dbVersion, highestSupportedVersionExclusive - 1)); } queue.updateSchema(new SchemaOnlyMigration(DatastoreConstants.getSchemaVersion3()), 3); queue.updateSchema(new SchemaOnlyMigration(DatastoreConstants.getSchemaVersion4()), 4); queue.updateSchema(new SchemaOnlyMigration(DatastoreConstants.getSchemaVersion5()), 5); queue.updateSchema(new SchemaOnlyMigration(DatastoreConstants.getSchemaVersion6()), 6); queue.updateSchema(new MigrateDatabase6To100(), 100); queue.updateSchema(new MigrateDatabase100To200(DatastoreConstants.getSchemaVersion200()), 200); this.eventBus = new EventBus(); this.attachmentStreamFactory = new AttachmentStreamFactory(this.getKeyProvider()); } @Override public File getPath() { return this.datastoreDir; } public KeyProvider getKeyProvider() { return this.keyProvider; } @Override public long getLastSequence() throws DocumentStoreException { Misc.checkState(this.isOpen(), "Database is closed"); try { return get(queue.submit(new GetLastSequenceCallable())); } catch (ExecutionException e) { throwCauseAs(e, IllegalStateException.class); String message = "Failed to get last Sequence"; logger.log(Level.SEVERE, message, e); throw new DocumentStoreException(message, e.getCause()); } } @Override public int getDocumentCount() throws DocumentStoreException { Misc.checkState(this.isOpen(), "Database is closed"); try { return get(queue.submit(new GetDocumentCountCallable())); } catch (ExecutionException e) { String message = "Failed to get document count"; logger.log(Level.SEVERE, message, e); throw new DocumentStoreException(message, e.getCause()); } } @Override public boolean contains(String docId, String revId) throws DocumentStoreException { Misc.checkState(this.isOpen(), "Database is closed"); try { // TODO this can be made quicker than getting the whole document read(docId, revId); return true; } catch (DocumentNotFoundException e) { return false; } } @Override public boolean contains(String docId) throws DocumentStoreException { Misc.checkState(this.isOpen(), "Database is closed"); try { // TODO this can be made quicker than getting the whole document read(docId); return true; } catch (DocumentNotFoundException e) { return false; } } @Override public InternalDocumentRevision read(String id) throws DocumentNotFoundException, DocumentStoreException { Misc.checkState(this.isOpen(), "Database is closed"); return read(id, null); } @Override public InternalDocumentRevision read(final String id, final String rev) throws DocumentNotFoundException, DocumentStoreException { Misc.checkState(this.isOpen(), "Database is closed"); Misc.checkNotNullOrEmpty(id, "Document id"); try { if (id.startsWith(CouchConstants._local_prefix)) { Misc.checkArgument(rev == null, "Local documents must have a null revision ID"); String localId = id.substring(CouchConstants._local_prefix.length()); LocalDocument ld = get(queue.submit(new GetLocalDocumentCallable(localId))); // convert to DocumentRevision, adding back "_local/" prefix which was stripped off when document was written return new DocumentRevisionBuilder().setDocId(CouchConstants._local_prefix + ld.docId).setBody(ld.body).build(); } else { return get(queue.submit(new GetDocumentCallable(id, rev, this.attachmentsDir, this.attachmentStreamFactory))); } } catch (ExecutionException e) { throwCauseAs(e, DocumentNotFoundException.class); String message = String.format(Locale.ENGLISH, "Failed to get document id %s at revision %s", id, rev); logger.log(Level.SEVERE, message, e); throw new DocumentStoreException(message, e.getCause()); } } /** * <p>Returns {@code DocumentRevisionTree} of a document.</p> * * <p>The tree contains the complete revision history of the document, * including branches for conflicts and deleted leaves.</p> * * @param docId ID of the document * @return {@code DocumentRevisionTree} of the specified document */ public DocumentRevisionTree getAllRevisionsOfDocument(final String docId) { try { return get(queue.submit(new GetAllRevisionsOfDocumentCallable(docId, this.attachmentsDir, this.attachmentStreamFactory))); } catch (ExecutionException e) { logger.log(Level.SEVERE, "Failed to get all revisions of document", e); } return null; } @Override public Changes changes(long since, final int limit) throws DocumentStoreException { Misc.checkState(this.isOpen(), "Database is closed"); Misc.checkArgument(limit > 0, "Limit must be positive number"); final long verifiedSince = since >= 0 ? since : 0; try { return get(queue.submit(new ChangesCallable(verifiedSince, limit, attachmentsDir, attachmentStreamFactory))); } catch (ExecutionException e) { String message = "Failed to get changes"; logger.log(Level.SEVERE, message, e); throw new DocumentStoreException(message, e.getCause()); } } @Override public List<DocumentRevision> read(final int offset, final int limit, final boolean descending) throws DocumentStoreException { Misc.checkState(this.isOpen(), "Database is closed"); if (offset < 0) { throw new IllegalArgumentException("offset must be >= 0"); } if (limit < 0) { throw new IllegalArgumentException("limit must be >= 0"); } try { return get(queue.submit(new GetAllDocumentsCallable(offset, limit, descending, this.attachmentsDir, this.attachmentStreamFactory))); } catch (ExecutionException e) { String message = "Failed to get all documents"; logger.log(Level.SEVERE, message, e); throw new DocumentStoreException(message, e.getCause()); } } @Override public List<String> getIds() throws DocumentStoreException { Misc.checkState(this.isOpen(), "Database is closed"); try { return get(queue.submit(new GetAllDocumentIdsCallable())); } catch (ExecutionException e) { String message = "Failed to get all document ids"; logger.log(Level.SEVERE, message, e); throw new DocumentStoreException(message, e.getCause()); } } @Override public List<DocumentRevision> read(final List<String> docIds) throws DocumentStoreException { Misc.checkState(this.isOpen(), "Database is closed"); Misc.checkNotNull(docIds, "Input document id list"); Misc.checkArgument(!docIds.isEmpty(), "Input document id list must contain document ids"); try { return get (queue.submit(new GetDocumentsWithIdsCallable(docIds, attachmentsDir, attachmentStreamFactory))); } catch (ExecutionException e) { String message = "Failed to get documents with ids"; logger.log(Level.SEVERE, message, e); throw new DocumentStoreException(message, e); } } public List<String> getPossibleAncestorRevisionIDs(final String docId, final String revId, final int limit) throws DocumentStoreException { try { return get(queue.submit(new GetPossibleAncestorRevisionIdsCallable(docId, revId, limit))); } catch (ExecutionException e) { throw new DocumentStoreException(e); } } /** * <p>Returns the current winning revision of a local document.</p> * * @param docId ID of the local document * @return {@code LocalDocument} of the document * @throws DocumentNotFoundException if the document ID doesn't exist */ public LocalDocument getLocalDocument(final String docId) throws DocumentNotFoundException { Misc.checkState(this.isOpen(), "Database is closed"); try { return get(queue.submit(new GetLocalDocumentCallable(docId))); } catch (ExecutionException e) { throw new DocumentNotFoundException(e); } } // TODO move to callable private InternalDocumentRevision createDocumentBody(SQLDatabase db, String docId, final DocumentBody body) throws AttachmentException, ConflictException, DocumentStoreException { Misc.checkState(this.isOpen(), "Database is closed"); CouchUtils.validateDocumentId(docId); Misc.checkNotNull(body, "Input document body"); this.validateDBBody(body); // check if the docid exists first: // if it does exist: // * if winning leaf deleted, root the 'created' document there // * else raise error // if it does not exist: // * normal insert logic for a new document InsertRevisionCallable callable = new InsertRevisionCallable(); InternalDocumentRevision potentialParent = null; try { potentialParent = new GetDocumentCallable(docId, null, this.attachmentsDir, this.attachmentStreamFactory).call(db); } catch (DocumentNotFoundException e) { //this is an expected exception, it just means we are // resurrecting the document } if (potentialParent != null) { if (!potentialParent.isDeleted()) { // current winner not deleted, can't insert throw new ConflictException(String.format("Cannot create doc, document with id %s" + " already exists " , docId)); } // if we got here, parent rev was deleted new SetCurrentCallable(potentialParent.getSequence(), false).call(db); callable.revId = CouchUtils.generateNextRevisionId(potentialParent.getRevision()); callable.docNumericId = potentialParent.getInternalNumericId(); callable.parentSequence = potentialParent.getSequence(); } else { // otherwise we are doing a normal create document long docNumericId = new InsertDocumentIDCallable(docId).call(db); callable.revId = CouchUtils.getFirstRevisionId(); callable.docNumericId = docNumericId; callable.parentSequence = -1l; } callable.deleted = false; callable.current = true; callable.data = body.asBytes(); callable.available = true; callable.call(db); try { InternalDocumentRevision doc = new GetDocumentCallable(docId, callable.revId, this.attachmentsDir, this.attachmentStreamFactory).call(db); logger.finer("New document created: " + doc.toString()); return doc; } catch (DocumentNotFoundException e) { throw new RuntimeException(String.format("Could not get document we just inserted " + "(id: %s); this should not happen, please file an issue with as much detail " + "as possible.", docId), e); } } public static void validateDBBody(DocumentBody body) { for (String name : body.asMap().keySet()) { if (name.startsWith("_")) { throw new InvalidDocumentException("Field name start with '_' is not allowed. "); } } } /** * <p>Inserts a local document with an ID and body. Replacing the current local document of the * same ID if one is present. </p> * * <p>Local documents are not replicated between DocumentStores.</p> * * @param docId The document ID for the document * @param body JSON body for the document * @return {@code DocumentRevision} of the newly created document * @throws DocumentException if there is an error inserting the local document into the database */ public LocalDocument insertLocalDocument(final String docId, final DocumentBody body) throws DocumentException { Misc.checkState(this.isOpen(), "Database is closed"); CouchUtils.validateDocumentId(docId); Misc.checkNotNull(body, "Input document body"); try { return get(queue.submit(new InsertLocalDocumentCallable(docId, body))); } catch (ExecutionException e) { logger.log(Level.SEVERE, "Failed to insert local document", e); throw new DocumentException("Cannot insert local document", e); } } /** * <p>Deletes a local document.</p> * * @param docId documentId of the document to be deleted * * @throws DocumentNotFoundException if the document ID doesn't exist */ public void deleteLocalDocument(final String docId) throws DocumentNotFoundException { Misc.checkState(this.isOpen(), "Database is closed"); Misc.checkNotNullOrEmpty(docId, "Input document id"); try { get(queue.submit(new DeleteLocalDocumentCallable(docId))); } catch (ExecutionException e) { throw new DocumentNotFoundException(docId, null, e); } } /** * <p>Returns the DocumentStore's unique identifier.</p> * * <p>This is used for the checkpoint document in a remote DocumentStore * during replication.</p> * * @return a unique identifier for the DocumentStore. * @throws DocumentStoreException if there was an error retrieving the unique identifier from the * database */ public String getPublicIdentifier() throws DocumentStoreException { Misc.checkState(this.isOpen(), "Database is closed"); try { return get(queue.submit(new GetPublicIdentifierCallable())); } catch (ExecutionException e) { logger.log(Level.SEVERE, "Failed to get public ID", e); throw new DocumentStoreException("Failed to get public ID", e); } } /** * <p> * Inserts one or more revisions of a document into the database. For efficiency, this is * performed as one database transaction. * </p> * <p> * Each revision is inserted at a point in the tree expressed by the path described in the * {@code revisionHistory} field. If any non-leaf revisions do not exist locally, then they are * created as "stub" revisions. * </p> * <p> * This method should only be called by the replicator. It is designed * to allow revisions from remote databases to be added to this * database during the replication process: the documents in the remote database already have * revision IDs that need to be preserved for the two databases to be in sync (otherwise it * would not be possible to tell that the two represent the same revision). This is analogous to * using the _new_edits false option in CouchDB * (see * <a target="_blank" href="https://wiki.apache.org/couchdb/HTTP_Bulk_Document_API#Posting_Existing_Revisions"> * the CouchDB wiki</a> for more detail). * <p> * If the document was successfully inserted, a * {@link com.cloudant.sync.event.notifications.DocumentCreated DocumentCreated}, * {@link com.cloudant.sync.event.notifications.DocumentModified DocumentModified}, or * {@link com.cloudant.sync.event.notifications.DocumentDeleted DocumentDeleted} * event is posted on the event bus. The event will depend on the nature * of the update made. * </p> * * * @param items one or more revisions to insert. * * @see Database#getEventBus() * @throws DocumentException if there was an error inserting the revision or its attachments * into the database */ public void forceInsert(final List<ForceInsertItem> items) throws DocumentException { Misc.checkState(this.isOpen(), "Database is closed"); for (ForceInsertItem item : items) { Misc.checkNotNull(item.rev, "Input document revision"); Misc.checkNotNull(item.revisionHistory, "Input revision history"); Misc.checkArgument(item.revisionHistory.size() > 0, "Input revision history " + "must not be empty"); Misc.checkArgument(checkCurrentRevisionIsInRevisionHistory(item.rev, item .revisionHistory), "Current revision must exist in revision history."); Misc.checkArgument(checkRevisionIsInCorrectOrder(item.revisionHistory), "Revision history must be in right order."); CouchUtils.validateDocumentId(item.rev.getId()); CouchUtils.validateRevisionId(item.rev.getRevision()); } try { // for raising events after completing database transaction List<DocumentModified> events = queue.submitTransaction(new ForceInsertCallable(items, attachmentsDir, attachmentStreamFactory)).get(); // if we got here, everything got written to the database successfully // now raise any events we stored up for (DocumentModified event : events) { eventBus.post(event); } } catch (InterruptedException e) { throw new RuntimeException(e); } catch (ExecutionException e) { throw new DocumentException(e); } } /** * <p>Inserts a revision of a document with an existing revision ID</p> * * <p>Equivalent to:</p> * * <code> * forceInsert(rev, Arrays.asList(revisionHistory), null, null, false); * </code> * * @param rev A {@code DocumentRevision} containing the information for a revision * from a remote DocumentStore. * @param revisionHistory The history of the revision being inserted, * including the rev ID of {@code rev}. This list * needs to be sorted in ascending order * @throws DocumentException if there was an error inserting the revision into the database */ public void forceInsert(InternalDocumentRevision rev, String... revisionHistory) throws DocumentException { Misc.checkState(this.isOpen(), "Database is closed"); this.forceInsert(Collections.singletonList(new ForceInsertItem(rev, Arrays.asList (revisionHistory), null, null, false))); } private boolean checkRevisionIsInCorrectOrder(List<String> revisionHistory) { for (int i = 0; i < revisionHistory.size() - 1; i++) { CouchUtils.validateRevisionId(revisionHistory.get(i)); int l = CouchUtils.generationFromRevId(revisionHistory.get(i)); int m = CouchUtils.generationFromRevId(revisionHistory.get(i + 1)); if (l >= m) { return false; } } return true; } public static boolean checkCurrentRevisionIsInRevisionHistory(InternalDocumentRevision rev, List<String> revisionHistory) { return revisionHistory.get(revisionHistory.size() - 1).equals(rev.getRevision()); } // TODO can this run async? if so no need to call get() @Override public void compact() throws DocumentStoreException { try { get(queue.submit(new CompactCallable(this.attachmentsDir))); } catch (ExecutionException e) { String message = "Failed to compact database"; logger.log(Level.SEVERE, message, e); throw new DocumentStoreException(message, e.getCause()); } } public void close() { queue.shutdown(); } boolean isOpen() { return !queue.isShutdown(); } /** * Returns the subset of given the document ID/revisions that are not stored in the database. * * The input revisions is a map, whose key is document ID, and value is a list of revisions. * An example input could be (in json format): * * { "03ee06461a12f3c288bb865b22000170": * [ * "1-b2e54331db828310f3c772d6e042ac9c", * "2-3a24009a9525bde9e4bfa8a99046b00d" * ], * "82e04f650661c9bdb88c57e044000a4b": * [ * "3-bb39f8c740c6ffb8614c7031b46ac162" * ] * } * * The output is in same format. * * @see * <a target="_blank" href="http://wiki.apache.org/couchdb/HttpPostRevsDiff">HttpPostRevsDiff documentation</a> * @param revisions a Multimap of document ID → revision ID * @return the subset of given the document ID/revisions that are already stored in the database * @throws IllegalArgumentException if {@code revisions} is empty. * @throws DocumentStoreException If it was not possible to calculate the difference between revs. */ public Map<String, List<String>> revsDiff(final Map<String, List<String>> revisions) throws DocumentStoreException { Misc.checkState(this.isOpen(), "Database is closed"); Misc.checkNotNull(revisions, "Input revisions"); Misc.checkArgument(!revisions.isEmpty(), "revisions cannot be empty"); try { ValueListMap<String, String> missingRevs = new ValueListMap<String, String>(); // Break down by docId first to avoid potential rev ID clashes between doc IDs for (Map.Entry<String, List<String>> entry : revisions.entrySet()) { String docId = entry.getKey(); List<String> revs = entry.getValue(); // Partition into batches to avoid exceeding placeholder limit // The doc ID will use one placeholder, so use limit - 1 for the number of // revs for the remaining placeholders. List<List<String>> batches = CollectionUtils.partition(revs, SQLITE_QUERY_PLACEHOLDERS_LIMIT - 1); for (List<String> revsBatch : batches) { missingRevs.addValuesToKey(docId, get(queue.submit(new RevsDiffBatchCallable(docId, revsBatch)))); } } return missingRevs; } catch (ExecutionException e) { String message = "Failed to calculate difference in revisions"; logger.log(Level.SEVERE, message, e); throw new DocumentStoreException(message, e); } } @Override public Iterable<String> getConflictedIds() throws DocumentStoreException { try { return get(queue.submit(new GetConflictedDocumentIdsCallable())); } catch (ExecutionException e) { String message = "Failed to get conflicted document ids"; logger.log(Level.SEVERE, message, e); throw new DocumentStoreException(message, e.getCause()); } } @Override public void resolveConflicts(final String docId, final ConflictResolver resolver) throws ConflictException { try { // before starting the tx, get the 'new winner' and see if we need to prepare its // attachments final DocumentRevisionTree docTree = get(queue.submit(new GetAllRevisionsOfDocumentCallable(docId, attachmentsDir, attachmentStreamFactory))); if (!docTree.hasConflicts()) { return; } DocumentRevision newWinner = null; try { newWinner = resolver.resolve(docId, docTree.leafRevisions(true)); } catch (Exception e) { logger.log(Level.SEVERE, "Exception when calling ConflictResolver", e); } if (newWinner == null) { // resolve() threw an exception or returned null, exit early return; } final String revIdKeep = newWinner.getRevision(); if (revIdKeep == null) { throw new IllegalArgumentException("Winning revision must have a revision" + " id"); } final DocumentRevision newWinnerTx = newWinner; get(queue.submitTransaction( new SQLCallable<Void>() { @Override public Void call(SQLDatabase db) throws Exception { new ResolveConflictsForDocumentCallable(docTree, revIdKeep).call(db); // is newWinnerTx a new or updated (as opposed to existing) revision? boolean isNewOrUpdatedRevision = false; if (newWinnerTx.getClass().equals(DocumentRevision.class)) { // user gave us a new DocumentRevision instance isNewOrUpdatedRevision = true; } else if (newWinnerTx instanceof InternalDocumentRevision) { // user gave us an existing InternalDocumentRevision instance - did the body or attachments change? InternalDocumentRevision newWinnerTxInternal = (InternalDocumentRevision)newWinnerTx; if (newWinnerTxInternal.isBodyModified()) { isNewOrUpdatedRevision = true; } else if (newWinnerTxInternal.getAttachments() != null) { if (newWinnerTxInternal.getAttachments().hasChanged()) { isNewOrUpdatedRevision = true; } } } // if this is a new or modified revision: graft the new revision on if (isNewOrUpdatedRevision) { // We need to work out which of the attachments for the revision are ones // we can copy over because they exist in the attachment store already and // which are new, that we need to prepare for insertion. Map<String, Attachment> attachments = newWinnerTx.getAttachments() != null ? newWinnerTx.getAttachments() : new HashMap<String, Attachment>(); final Map<String, PreparedAttachment> preparedNewAttachments = AttachmentManager.prepareAttachments(attachmentsDir, attachmentStreamFactory, AttachmentManager.findNewAttachments(attachments)); final Map<String, SavedAttachment> existingAttachments = AttachmentManager.findExistingAttachments(attachments); new UpdateDocumentFromRevisionCallable(newWinnerTx, preparedNewAttachments, existingAttachments, attachmentsDir, attachmentStreamFactory).call(db); } return null; } })); } catch (ExecutionException e) { logger.log(Level.SEVERE, "Failed to resolve Conflicts", e); Throwable cause = e.getCause(); if (cause != null) { if (cause instanceof IllegalArgumentException) { throw (IllegalArgumentException) cause; } } } } /** * <p> * Read attachment stream to a temporary location and calculate sha1, * prior to being added to the DocumentStore. * </p> * <p> * Used by replicator when receiving new/updated attachments *</p> * * @param att Attachment to be prepared, providing data either from a file or a stream * @param length Size in bytes of attachment as signalled by "length" metadata property * @param encodedLength Size in bytes of attachment, after encoding, as signalled by * "encoded_length" metadata property * @return A prepared attachment, ready to be added to the DocumentStore * @throws AttachmentException if there was an error preparing the attachment, e.g., reading * attachment data. */ public PreparedAttachment prepareAttachment(Attachment att, long length, long encodedLength) throws AttachmentException { PreparedAttachment pa = AttachmentManager.prepareAttachment(attachmentsDir, attachmentStreamFactory, att, length, encodedLength); return pa; } /** * <p>Returns attachment <code>attachmentName</code> for the revision.</p> * * <p>Used by replicator when pushing attachments</p> * * @param id The revision ID with which the attachment is associated * @param rev The document ID with which the attachment is associated * @param attachmentName Name of the attachment * @return <code>Attachment</code> or null if there is no attachment with that name. */ public Attachment getAttachment(final String id, final String rev, final String attachmentName) { try { return get(queue.submit(new SQLCallable<Attachment>() { @Override public Attachment call(SQLDatabase db) throws Exception { long sequence = new GetSequenceCallable(id, rev).call(db); return AttachmentManager.getAttachment(db, attachmentsDir, attachmentStreamFactory, sequence, attachmentName); } })); } catch (ExecutionException e) { logger.log(Level.SEVERE, "Failed to get attachment", e); } return null; } /** * <p>Returns all attachments for the revision.</p> * * <p>Used by replicator when pushing attachments</p> * * @param rev The revision with which the attachments are associated * @return List of <code>Attachment</code> * @throws AttachmentException if there was an error reading the attachment metadata from the * database */ public Map<String, ? extends Attachment> attachmentsForRevision(final InternalDocumentRevision rev) throws AttachmentException { try { return get(queue.submit(new SQLCallable<Map<String, ? extends Attachment>>() { @Override public Map<String, ? extends Attachment> call(SQLDatabase db) throws Exception { return AttachmentManager.attachmentsForRevision(db, attachmentsDir, attachmentStreamFactory, rev.getSequence()); } })); } catch (ExecutionException e) { logger.log(Level.SEVERE, "Failed to get attachments for revision"); throw new AttachmentException(e); } } @Override public EventBus getEventBus() { // Misc.checkState(this.isOpen(), "Database is closed"); return eventBus; } @Override public DocumentRevision create(final DocumentRevision rev) throws AttachmentException, InvalidDocumentException, ConflictException, DocumentStoreException { Misc.checkNotNull(rev, "DocumentRevision"); Misc.checkState(isOpen(), "Datastore is closed"); Misc.checkArgument(rev.getRevision() == null, "Revision ID must be null for new " + "DocumentRevisions"); Misc.checkArgument(rev.isFullRevision(), "Projected revisions cannot be used to " + "create documents"); final String docId; // create docid if docid is null if (rev.getId() == null) { docId = CouchUtils.generateDocumentId(); } else { docId = rev.getId(); } // check to see if we are creating a local (non-replicating) document if (docId.startsWith(CouchConstants._local_prefix)) { String localId = docId.substring(CouchConstants._local_prefix.length()); try { insertLocalDocument(localId, rev.getBody()); // we can return the input document as-is since there was no doc id or rev id to generate return rev; } catch (DocumentException e) { throw new DocumentStoreException(e.getMessage(), e.getCause()); } finally { eventBus.post(new DocumentCreated(rev)); } } // We need to work out which of the attachments for the revision are ones // we can copy over because they exist in the attachment store already and // which are new, that we need to prepare for insertion. Map<String, Attachment> attachments = rev.getAttachments() != null ? rev.getAttachments() : new HashMap<String, Attachment>(); final Map<String, PreparedAttachment> preparedNewAttachments = AttachmentManager.prepareAttachments(attachmentsDir, attachmentStreamFactory, AttachmentManager.findNewAttachments(attachments)); final Map<String, SavedAttachment> existingAttachments = AttachmentManager.findExistingAttachments(attachments); InternalDocumentRevision created = null; try { created = get(queue.submitTransaction(new SQLCallable<InternalDocumentRevision>() { @Override public InternalDocumentRevision call(SQLDatabase db) throws Exception { // Save document with new JSON body, add new attachments and copy over // existing attachments InternalDocumentRevision saved = createDocumentBody(db, docId, rev.getBody()); AttachmentManager.addAttachmentsToRevision(db, attachmentsDir, saved, preparedNewAttachments); AttachmentManager.copyAttachmentsToRevision(db, existingAttachments, saved); // now re-fetch the revision with updated attachments InternalDocumentRevision updatedWithAttachments = new GetDocumentCallable( saved.getId(), saved.getRevision(), attachmentsDir, attachmentStreamFactory).call(db); return updatedWithAttachments; } })); return created; } catch (ExecutionException e) { // invalid if eg there are keys starting with _ throwCauseAs(e, InvalidDocumentException.class); // conflictexception if doc ID already exists throwCauseAs(e, ConflictException.class); String message = "Failed to create document"; logger.log(Level.SEVERE, message, e); throw new DocumentStoreException(message, e.getCause()); } finally { if (created != null) { eventBus.post(new DocumentCreated(created)); } } } @Override public DocumentRevision update(final DocumentRevision rev) throws AttachmentException, DocumentNotFoundException, ConflictException, DocumentStoreException { Misc.checkNotNull(rev, "DocumentRevision"); Misc.checkState(isOpen(), "Datastore is closed"); Misc.checkArgument(rev.isFullRevision(), "Projected revisions cannot be used to " + "create documents"); // Shortcut if this is a create/update of local doc if (rev.getId().startsWith(CouchConstants._local_prefix)) { return create(rev); } // Shortcut if this is a deletion if (rev.isDeleted()) { return delete(rev); } // We need to work out which of the attachments for the revision are ones // we can copy over because they exist in the attachment store already and // which are new, that we need to prepare for insertion. Map<String, Attachment> attachments = rev.getAttachments() != null ? rev.getAttachments() : new HashMap<String, Attachment>(); final Map<String, PreparedAttachment> preparedNewAttachments = AttachmentManager.prepareAttachments(attachmentsDir, attachmentStreamFactory, AttachmentManager.findNewAttachments(attachments)); final Map<String, SavedAttachment> existingAttachments = AttachmentManager.findExistingAttachments(attachments); try { InternalDocumentRevision revision = get(queue.submitTransaction(new UpdateDocumentFromRevisionCallable( rev, preparedNewAttachments, existingAttachments, this.attachmentsDir, this.attachmentStreamFactory))); if (revision != null) { try { eventBus.post(new DocumentUpdated(read(rev.getId(), rev.getRevision()), revision)); } catch (DocumentStoreException de) { ; // TODO couldn't re-fetch document to post event } catch (DocumentException de) { ; // TODO couldn't re-fetch document to post event } } return revision; } catch (ExecutionException e) { // invalid if eg there are keys starting with _ throwCauseAs(e, InvalidDocumentException.class); // conflictexception if rev ID is not winning rev throwCauseAs(e, ConflictException.class); // not found if tried to update something that doesn't exist throwCauseAs(e, DocumentNotFoundException.class); String message = "Failed to update document"; logger.log(Level.SEVERE, message, e); throw new DocumentStoreException(message, e.getCause()); } } @Override public DocumentRevision delete(final DocumentRevision rev) throws ConflictException, DocumentNotFoundException, DocumentStoreException { Misc.checkNotNull(rev, "DocumentRevision"); Misc.checkState(isOpen(), "Datastore is closed"); try { // local documents if (rev.getId().startsWith(CouchConstants._local_prefix)) { Misc.checkArgument(rev.getRevision() == null, "Local documents must have a null revision ID"); String localId = rev.getId().substring(CouchConstants._local_prefix.length()); deleteLocalDocument(localId); // for local documents there is no "new document" to post on the event bus or return as // the document is removed rather than updated with a tombstone eventBus.post(new DocumentDeleted(rev, null)); return null; } else { // "normal" documents InternalDocumentRevision deletedRevision = get(queue.submit(new DeleteDocumentCallable(rev.getId(), rev.getRevision()))); if (deletedRevision != null) { eventBus.post(new DocumentDeleted(rev, deletedRevision)); } return deletedRevision; } } catch (ExecutionException e) { // conflictexception if source revision isn't current rev throwCauseAs(e, ConflictException.class); // documentnotfoundexception if it's already deleted, or was a non-existent local document throwCauseAs(e, DocumentNotFoundException.class); String message = "Failed to delete document"; logger.log(Level.SEVERE, message, e); throw new DocumentStoreException(message, e.getCause()); } } // delete all leaf nodes @Override public List<DocumentRevision> delete(final String id) throws DocumentNotFoundException, DocumentStoreException { Misc.checkNotNull(id, "ID"); try { if (id.startsWith(CouchConstants._local_prefix)) { String localId = id.substring(CouchConstants._local_prefix.length()); deleteLocalDocument(localId); // for local documents there is no "new document" to return as the document is // removed rather than updated with a tombstone return Collections.singletonList(null); } else { return get(queue.submitTransaction(new DeleteAllRevisionsCallable(id))); } } catch (ExecutionException e) { // documentnotfoundexception if it was a non-existent local document throwCauseAs(e, DocumentNotFoundException.class); String message = "Failed to delete document"; logger.log(Level.SEVERE, message, e); throw new DocumentStoreException(message, e.getCause()); } } <T> Future<T> runOnDbQueue(SQLCallable<T> callable) { return queue.submit(callable); } // helper to avoid having to catch ExecutionExceptions public static <T> T get(Future<T> future) throws ExecutionException { try { return future.get(); } catch (InterruptedException e) { logger.log(Level.SEVERE, "Re-throwing InterruptedException as ExecutionException"); throw new ExecutionException(e); } } private <T extends Throwable> void throwCauseAs(Throwable t, Class<T> type) throws T { if (t.getCause().getClass().equals(type)) { throw (T)t.getCause(); } } @Override public void createWithHistory(DocumentRevision revision, int revisionsStart, List<String> revisionsIDs) throws DocumentException { // Check the arguments for validity Misc.checkNotNull(revision, "DocumentRevision"); Misc.checkArgument(revisionsStart > 0, "revisionsStart must be greater than zero, but was" + " " + revisionsStart + "."); Misc.checkArgument(revisionsIDs != null && revisionsIDs.size() > 0, "revisionsIDs history" + " list must not be null or empty."); Map<String, Attachment> attachments = revision.getAttachments(); Collections.list(Collections.enumeration(revision.getAttachments().values())); InternalDocumentRevision internalRev = new DocumentRevisionBuilder() .setDocId(revision.getId()) .setRevId(revision.getRevision()) .setBody(revision.getBody()) .setAttachments(attachments) .setDeleted(revision.isDeleted()) .build(); Map<String[], Map<String, PreparedAttachment>> preparedAttachments = Collections .singletonMap (new String[]{revision.getId(), revision.getRevision()}, AttachmentManager .prepareAttachments(attachmentsDir, attachmentStreamFactory, attachments)); // Couch _revisions.ids are newest -> oldest without a generation, so we need to manipulate // them for forceInsert to an ascending order list with generational prefix List<String> revIDs = CouchUtils.couchStyleRevisionHistoryToFullRevisionIDs (revisionsStart, revisionsIDs); // Note attachmentsMetadata map is not used when pullAttachmentsInline=false so call with // null. See com.cloudant.sync.internal.documentstore.callables.ForceInsertCallable.call() // for reference. forceInsert(Collections.singletonList(new ForceInsertItem(internalRev, revIDs, null, preparedAttachments, false))); } }