|
| 1 | +// |
| 2 | +// Copyright (c) 2026 Couchbase, Inc All rights reserved. |
| 3 | +// |
| 4 | +// Licensed under the Apache License, Version 2.0 (the "License"); |
| 5 | +// you may not use this file except in compliance with the License. |
| 6 | +// You may obtain a copy of the License at |
| 7 | +// |
| 8 | +// http://www.apache.org/licenses/LICENSE-2.0 |
| 9 | +// |
| 10 | +// Unless required by applicable law or agreed to in writing, software |
| 11 | +// distributed under the License is distributed on an "AS IS" BASIS, |
| 12 | +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 13 | +// See the License for the specific language governing permissions and |
| 14 | +// limitations under the License. |
| 15 | +// |
| 16 | + |
| 17 | +@file:OptIn(ExperimentalSerializationApi::class) |
| 18 | + |
| 19 | +package com.couchbase.lite |
| 20 | + |
| 21 | +import com.couchbase.lite.internal.core.C4Document |
| 22 | +import com.couchbase.lite.internal.fleece.* |
| 23 | +import kotlinx.serialization.* |
| 24 | + |
| 25 | + |
| 26 | +/** Document model classes must implement this interface. |
| 27 | + * It adds a [documentMeta] property that's used by Couchbase Lite. */ |
| 28 | +interface DocumentModel { |
| 29 | + /** This tags the model instance with the document ID and revision it was read from, |
| 30 | + * which enables conflict detection when it's later saved. |
| 31 | + * You may read this property, but DO NOT alter it. |
| 32 | + * It should be implemented as a stored property defaulting to `null`, for example: |
| 33 | + * `@Transient override var documentMeta: DocumentMeta? = null` */ |
| 34 | + @Transient var documentMeta: DocumentMeta? |
| 35 | +} |
| 36 | + |
| 37 | +/** Stores the Couchbase Lite metadata of a document. Used by the [DocumentModel] interface. */ |
| 38 | +class DocumentMeta internal constructor(val collection: Collection?, // [Result] leaves it null |
| 39 | + val id: String, |
| 40 | + val revisionID: String) |
| 41 | + |
| 42 | + |
| 43 | +/** Gets an existing document with the given ID, and uses Kotlin Serialization to create an |
| 44 | + * instance of class [T] from it. [T] must implement [DocumentModel]. |
| 45 | + * If a document with the given ID doesn't exist in the collection, returns null. */ |
| 46 | +@ExperimentalSerializationApi |
| 47 | +inline fun <reified T: DocumentModel> Collection.getDocumentAs(id: String): T? = |
| 48 | + getDocumentAs(id, serializer()) |
| 49 | + |
| 50 | +@ExperimentalSerializationApi |
| 51 | +fun <T: DocumentModel> Collection.getDocumentAs(id: String, deserializer: DeserializationStrategy<T>): T? = |
| 52 | + modelFromC4Doc(this, id, getC4Document(id), deserializer) |
| 53 | + |
| 54 | + |
| 55 | +/** Saves a [DocumentModel] instance as a document in the collection. |
| 56 | + * After a successful save, its [DocumentModel.documentMeta] property is updated to the current state. |
| 57 | + * @param model The [DocumentModel] instance to save. |
| 58 | + * @param docID The document ID to save a new unsaved model as, or `null` to generate a unique ID. |
| 59 | + * If the model has already been saved, this should be omitted or left `null`. |
| 60 | + * @param conflictHandler A callback to resolve conflicts between the model and a more recently |
| 61 | + * saved document revision. If `null` (the default), the last-writer-wins |
| 62 | + * strategy is used: the save always succeeds, overwriting any |
| 63 | + * conflicting revision. |
| 64 | + * @return True on success, false on an unresolved conflict. |
| 65 | + * */ |
| 66 | +@ExperimentalSerializationApi |
| 67 | +inline fun <reified T: DocumentModel> Collection.save(model: T, |
| 68 | + docID: String? = null, |
| 69 | + noinline conflictHandler: ModelConflictHandler<T>? = null) = |
| 70 | + save(model, serializer(), serializer(), docID, conflictHandler) |
| 71 | + |
| 72 | +/** Saves a [DocumentModel] instance as a document in the collection, explicitly passing the |
| 73 | + * serialization strategy. (This overload is rarely needed.) */ |
| 74 | +@ExperimentalSerializationApi |
| 75 | +fun <T: DocumentModel> Collection.save(model: T, |
| 76 | + serializer: SerializationStrategy<T>, |
| 77 | + deserializer: DeserializationStrategy<T>, |
| 78 | + docID: String? = null, |
| 79 | + conflictHandler: ModelConflictHandler<T>? = null): Boolean |
| 80 | +{ |
| 81 | + // Get or create the Document: |
| 82 | + val meta = model.documentMeta |
| 83 | + val doc: MutableDocument |
| 84 | + if (meta == null) { |
| 85 | + doc = MutableDocument(docID) |
| 86 | + } else { |
| 87 | + require(meta.collection == this || meta.collection == null) {"saving document to wrong collection"} |
| 88 | + require(docID == null || docID == meta.id) {"docID parameter does not match documentMeta.id"} |
| 89 | + doc = getDocument(meta.id)?.toMutable() ?: MutableDocument(meta.id) |
| 90 | + } |
| 91 | + |
| 92 | + // Subroutine that calls the ModelConflictHandler & updates the model accordingly: |
| 93 | + fun handleConflict(doc: MutableDocument?, curDoc: Document?): Boolean { |
| 94 | + if (conflictHandler == null) |
| 95 | + return true // conflict handler defaults to last-write-wins |
| 96 | + val curModel = curDoc?.let {modelFromC4Doc(this, it.id, it.c4doc, deserializer)} |
| 97 | + val ok = conflictHandler(model, curModel) |
| 98 | + if (ok) |
| 99 | + doc?.setContentFromModel(model, serializer) |
| 100 | + return ok |
| 101 | + } |
| 102 | + |
| 103 | + if (doc.revisionID != meta?.revisionID) { |
| 104 | + // Model is out of date -- have to resolve the conflict |
| 105 | + if (!handleConflict(null, doc)) |
| 106 | + return false |
| 107 | + } |
| 108 | + |
| 109 | + // Replace the document's content with the serialized model: |
| 110 | + if (doc.collection == null) { |
| 111 | + doc.collection = this |
| 112 | + } |
| 113 | + doc.setContentFromModel(model, serializer) |
| 114 | + |
| 115 | + // Save: |
| 116 | + val ok = if (conflictHandler != null) { |
| 117 | + save(doc) {savingDoc, curDoc -> handleConflict(savingDoc, curDoc) } |
| 118 | + } else { |
| 119 | + save(doc) |
| 120 | + true |
| 121 | + } |
| 122 | + if (ok) |
| 123 | + model.documentMeta = DocumentMeta(this, doc.id, doc.revisionID!!) |
| 124 | + return ok |
| 125 | +} |
| 126 | + |
| 127 | + |
| 128 | +/** Model-based conflict handler callback, used by [Collection.save] with [DocumentModel] objects. |
| 129 | + * The first parameter is the [DocumentModel] you are saving. |
| 130 | + * The second parameter is a [DocumentModel] deserialized from the conflicting revision in the |
| 131 | + * collection, or `null` if the document has been deleted. |
| 132 | + * |
| 133 | + * The callback may modify the first [DocumentModel] -- the one being saved -- to incorporate |
| 134 | + * changes from the other [DocumentModel] (the revision in the database), then return true. |
| 135 | + * (But it must NOT modify its [DocumentModel.documentMeta] property.) |
| 136 | + * |
| 137 | + * Or it may return false to signal that it can't handle the conflict, in which case the |
| 138 | + * [Collection.save] method will return false without saving anything. */ |
| 139 | +typealias ModelConflictHandler<T> = (T, T?)-> Boolean |
| 140 | + |
| 141 | + |
| 142 | +/** Deletes a model's document from the collection. |
| 143 | + * @throws CouchbaseLiteException if the [DocumentModel.documentMeta] property is null. */ |
| 144 | +fun Collection.delete(model: DocumentModel, concurrencyControl: ConcurrencyControl = ConcurrencyControl.LAST_WRITE_WINS): Boolean { |
| 145 | + val meta = model.documentMeta ?: throw CouchbaseLiteException("DocumentModel has no document ID") |
| 146 | + require(meta.collection == this || meta.collection == null) {"deleting document from wrong collection"} |
| 147 | + val doc = getDocument(meta.id) ?: return true |
| 148 | + if (doc.revisionID != meta.revisionID && concurrencyControl == ConcurrencyControl.FAIL_ON_CONFLICT) |
| 149 | + return false |
| 150 | + if (!delete(doc, concurrencyControl)) |
| 151 | + return false |
| 152 | + model.documentMeta = null |
| 153 | + return true |
| 154 | +} |
| 155 | + |
| 156 | + |
| 157 | +/** Purges a model's document from the collection. |
| 158 | + * @throws CouchbaseLiteException if the [DocumentModel.documentMeta] property is null, |
| 159 | + * or the document doesn't exist in the collection. */ |
| 160 | +fun Collection.purge(model: DocumentModel) { |
| 161 | + val id = model.documentMeta?.id ?: throw CouchbaseLiteException("DocumentModel has no document ID") |
| 162 | + purge(id) |
| 163 | + model.documentMeta = null |
| 164 | +} |
| 165 | + |
| 166 | + |
| 167 | +/** Creates a [DocumentModel] instance from a [C4Document]. */ |
| 168 | +private fun <T:DocumentModel> modelFromC4Doc(collection: Collection, |
| 169 | + docID: String, |
| 170 | + c4doc: C4Document?, |
| 171 | + deserializer: DeserializationStrategy<T>): T? |
| 172 | +{ |
| 173 | + if (c4doc == null || c4doc.isDocDeleted) return null |
| 174 | + val properties = c4doc.selectedBody2 ?: return null |
| 175 | + val model = deserializeFromFleece(properties.toFLValue(), deserializer) |
| 176 | + model.documentMeta = DocumentMeta(collection, docID, c4doc.revID!!) |
| 177 | + return model |
| 178 | +} |
| 179 | + |
| 180 | + |
| 181 | +/** Extension of [MutableDocument], that updates its content from a [DocumentModel] object. */ |
| 182 | +private fun <T:DocumentModel> MutableDocument.setContentFromModel(model: T, serializer: SerializationStrategy<T>) { |
| 183 | + setContent(serializeToFleece(serializer, model), false) |
| 184 | +} |
0 commit comments