-
Notifications
You must be signed in to change notification settings - Fork 6
Add Kotlin serialization-based APIs #475
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,177 @@ | ||
| // | ||
| // Copyright (c) 2026 Couchbase, Inc All rights reserved. | ||
| // | ||
| // 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. | ||
| // | ||
|
|
||
| @file:OptIn(ExperimentalSerializationApi::class) | ||
|
|
||
| package com.couchbase.lite | ||
|
|
||
| import com.couchbase.lite.internal.core.C4Document | ||
| import com.couchbase.lite.internal.fleece.* | ||
| import kotlinx.serialization.* | ||
|
|
||
|
|
||
| /** Document model classes must implement this interface. | ||
| * It adds a [documentMeta] property that's used by Couchbase Lite. */ | ||
| interface DocumentModel { | ||
| /** This tags the model instance with the document ID and revision it was read from, | ||
| * which enables conflict detection when it's later saved. | ||
| * You may read this property, but DO NOT alter it. | ||
| * It should be implemented as a stored property defaulting to `null`, for example: | ||
| * `@Transient override var documentMeta: DocumentMeta? = null` */ | ||
| @Transient var documentMeta: DocumentMeta? | ||
| } | ||
|
|
||
| /** Stores the Couchbase Lite metadata of a document. Used by the [DocumentModel] interface. */ | ||
| class DocumentMeta internal constructor(val collection: Collection?, // [Result] leaves it null | ||
| val id: String, | ||
| val revisionID: String) | ||
|
|
||
|
|
||
| /** Gets an existing document with the given ID, and uses Kotlin Serialization to create an | ||
| * instance of class [T] from it. [T] must implement [DocumentModel]. | ||
| * If a document with the given ID doesn't exist in the collection, returns null. */ | ||
| @ExperimentalSerializationApi | ||
| inline fun <reified T: DocumentModel> Collection.getDocumentAs(id: String): T? = | ||
| getDocumentAs(id, serializer()) | ||
|
|
||
| @ExperimentalSerializationApi | ||
| fun <T: DocumentModel> Collection.getDocumentAs(id: String, deserializer: DeserializationStrategy<T>): T? = | ||
| modelFromC4Doc(this, id, getC4Document(id), deserializer) | ||
|
|
||
|
|
||
| /** Saves a [DocumentModel] instance as a document in the collection, with a specified conflict handler. | ||
| * If the model's [DocumentModel.documentMeta] property is null, it will be saved as a new document with the | ||
| * given [docID], which must not be null. | ||
| * Otherwise the [DocumentModel.documentMeta] property determines the document ID and prior revision ID, and the | ||
| * [docID] parameter should be null. | ||
| * After a successful save, the [DocumentModel.documentMeta] property is updated to the current state. */ | ||
| @ExperimentalSerializationApi | ||
| inline fun <reified T: DocumentModel> Collection.save(model: T, | ||
| docID: String? = null, | ||
| noinline conflictHandler: ModelConflictHandler<T>? = null) = | ||
| save(model, serializer(), serializer(), docID, conflictHandler) | ||
|
|
||
| @ExperimentalSerializationApi | ||
| fun <T: DocumentModel> Collection.save(model: T, | ||
| serializer: SerializationStrategy<T>, | ||
| deserializer: DeserializationStrategy<T>, | ||
| docID: String? = null, | ||
| conflictHandler: ModelConflictHandler<T>? = null): Boolean | ||
| { | ||
| // Get or create the Document: | ||
| val meta = model.documentMeta | ||
| val doc: MutableDocument | ||
| if (meta == null) { | ||
| require(docID != null) { "docID argument must be given when saving a new document" } | ||
| doc = MutableDocument(docID) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can we also allow null docID here (an auto id (uuid) will be generated)? |
||
| } else { | ||
| require(meta.collection == this || meta.collection == null) {"saving document to wrong collection"} | ||
| require(docID == null || docID == meta.id) {"docID parameter does not match documentMeta.id"} | ||
| doc = getDocument(meta.id)?.toMutable() ?: MutableDocument(meta.id) | ||
| } | ||
|
|
||
| // Subroutine that calls the ModelConflictHandler & updates the model accordingly: | ||
| fun handleConflict(doc: MutableDocument?, curDoc: Document?): Boolean { | ||
| val curModel = curDoc?.let {modelFromC4Doc(this, it.id, it.c4doc, deserializer)} | ||
| val ok = conflictHandler!!(model, curModel) | ||
| if (ok) | ||
| doc?.setContentFromModel(model, serializer) | ||
| return ok | ||
| } | ||
|
|
||
| if (doc.revisionID != meta?.revisionID) { | ||
| // Model is out of date -- have to resolve the conflict | ||
| if (!handleConflict(null, doc)) | ||
| return false | ||
| } | ||
|
|
||
| // Replace the document's content with the serialized model: | ||
| if (doc.collection == null) { | ||
| doc.collection = this | ||
| } | ||
| doc.setContentFromModel(model, serializer) | ||
|
|
||
| // Save: | ||
| val ok = if (conflictHandler != null) { | ||
| save(doc) {savingDoc, curDoc -> handleConflict(savingDoc, curDoc) } | ||
| } else { | ||
| save(doc) | ||
| true | ||
| } | ||
| if (ok) | ||
| model.documentMeta = DocumentMeta(this, doc.id, doc.revisionID!!) | ||
| return ok | ||
| } | ||
|
|
||
|
|
||
| /** Model-based conflict handler callback, used by [Collection.save] with [DocumentModel] objects. | ||
| * The first parameter is the [DocumentModel] you are saving. | ||
| * The second parameter is a [DocumentModel] deserialized from the conflicting revision in the collection, | ||
| * or null if the document has been deleted. | ||
| * | ||
| * The function may modify the first [DocumentModel] -- the one being saved -- to incorporate changes from | ||
| * the other [DocumentModel] (the revision in the database), then return true. (But it should NOT modify | ||
| * its [DocumentModel.documentMeta] property.) | ||
| * | ||
| * Or it may return false to signal that it can't handle the conflict. */ | ||
| typealias ModelConflictHandler<T> = (T, T?)-> Boolean | ||
|
|
||
|
|
||
| /** Deletes a model's document from the collection. | ||
| * @throws CouchbaseLiteException if the [DocumentModel.documentMeta] property is null. */ | ||
| fun Collection.delete(model: DocumentModel, concurrencyControl: ConcurrencyControl = ConcurrencyControl.LAST_WRITE_WINS): Boolean { | ||
| val meta = model.documentMeta ?: throw CouchbaseLiteException("DocumentModel has no document ID") | ||
| require(meta.collection == this || meta.collection == null) {"deleting document from wrong collection"} | ||
| val doc = getDocument(meta.id) ?: return true | ||
| if (doc.revisionID != meta.revisionID && concurrencyControl == ConcurrencyControl.FAIL_ON_CONFLICT) | ||
| return false | ||
| if (!delete(doc, concurrencyControl)) | ||
| return false | ||
| model.documentMeta = null | ||
| return true | ||
| } | ||
|
|
||
|
|
||
| /** Purges a model's document from the collection. | ||
| * @throws CouchbaseLiteException if the [DocumentModel.documentMeta] property is null, | ||
| * or the document doesn't exist in the collection. */ | ||
| fun Collection.purge(model: DocumentModel) { | ||
| val id = model.documentMeta?.id ?: throw CouchbaseLiteException("DocumentModel has no document ID") | ||
| purge(id) | ||
| model.documentMeta = null | ||
| } | ||
|
|
||
|
|
||
| /** Creates a [DocumentModel] instance from a [C4Document]. */ | ||
| private fun <T:DocumentModel> modelFromC4Doc(collection: Collection, | ||
| docID: String, | ||
| c4doc: C4Document?, | ||
| deserializer: DeserializationStrategy<T>): T? | ||
| { | ||
| if (c4doc == null || c4doc.isDocDeleted) return null | ||
| val properties = c4doc.selectedBody2 ?: return null | ||
| val model = deserializeFromFleece(properties.toFLValue(), deserializer) | ||
| model.documentMeta = DocumentMeta(collection, docID, c4doc.revID!!) | ||
| return model | ||
| } | ||
|
|
||
|
|
||
| /** Extension of [MutableDocument], that updates its content from a [DocumentModel] object. */ | ||
| private fun <T:DocumentModel> MutableDocument.setContentFromModel(model: T, serializer: SerializationStrategy<T>) { | ||
| val body = serializeToFleece(serializer, model) | ||
| val root = FLValue.fromData(body).asFLDict() | ||
| setContent(root, false) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Does the body need to be retained as well? I read iOS code, and iOS is doing something similar. If I read the code correctly, Swift's DocumentEncoder calls FLEncoder_FinishDoc that returns an FLDoc, get the dict from the FLDoc, set the dict to the CBL doc, and abandon the FLDoc. So maybe the data is retained by the dict? |
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,129 @@ | ||
| // | ||
| // Copyright (c) 2026 Couchbase, Inc All rights reserved. | ||
| // | ||
| // 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.couchbase.lite | ||
|
|
||
| import com.couchbase.lite.internal.fleece.* | ||
| import kotlinx.serialization.DeserializationStrategy | ||
| import kotlinx.serialization.ExperimentalSerializationApi | ||
| import kotlinx.serialization.descriptors.StructureKind | ||
| import kotlinx.serialization.serializer | ||
|
|
||
|
|
||
| /** Uses Kotlin Serialization to create an object of type [T] from a query result. | ||
| * If the [key] parameter is non-null, it uses the result's value for that key as the source, | ||
| * instead of the entire result. | ||
| * | ||
| * For example, if your query is `SELECT name, age, shoeSize FROM people` and you have a | ||
| * serializable `Person` class with properties `name`, `age` and `shoeSize`, you can call: | ||
| * `val person = result.data<Person>()` | ||
| * | ||
| * Or you could use the query `SELECT * as person FROM people` and create the `Person` with | ||
| * `val person = result.data<Person>("person")`. | ||
| * */ | ||
| @ExperimentalSerializationApi | ||
| inline fun <reified T> Result.data(key: String? = null): T = | ||
| data(serializer(), key) | ||
|
|
||
| /** Uses Kotlin Serialization to create a [DocumentModel] instance of type [T] from the query result. | ||
| * This is a specialization of the one-parameter [data] method that adds a [metaKey] parameter. | ||
| * | ||
| * The [metaKey] parameter is the name of the result property whose value comes from the N1QL | ||
| * `meta()` function; this is used to set the [DocumentModel.documentMeta] property of the result. | ||
| * | ||
| * For example, if your query is `SELECT * as person, meta() as meta FROM people`, you would call | ||
| * `result.data<Person>("person", "meta")`. | ||
| */ | ||
| @ExperimentalSerializationApi | ||
| inline fun <reified T: DocumentModel> Result.data(key: String? = null, | ||
| metaKey: String? = null): T = | ||
| data(serializer(), key, metaKey) | ||
|
|
||
| @ExperimentalSerializationApi | ||
| fun <T> Result.data(deserializer: DeserializationStrategy<T>, | ||
| key: String? = null, | ||
| metaKey: String? = null): T | ||
| { | ||
| val columns = flValues | ||
| val result = if (key == null) { | ||
| if (deserializer.descriptor as? StructureKind == StructureKind.LIST) { | ||
| // Deserializer wants a List, so pass the column values directly: | ||
| deserializeFromFleece(columns, deserializer) | ||
| } else { | ||
| // Deserializer wants a Map, so smush the column names and values together: | ||
| val size = columns.size | ||
| val names = columnNames | ||
| val iter = object: Iterator<Map.Entry<String,FLValue>> { | ||
| var i = 0 | ||
| override fun hasNext() = i < size | ||
| override fun next(): Map.Entry<String,FLValue> { | ||
| val entry = Entry(names[i], columns[i]) | ||
| i++ | ||
| return entry | ||
| } | ||
| } | ||
| deserializeFromFleece(iter, size, deserializer) | ||
| } | ||
| } else { | ||
| // Deserializing a single value from the result: | ||
| val i = getIndexForKey(key) | ||
| if (i < 0) throw CouchbaseLiteError("Query result has no property '$key'") | ||
| deserializeFromFleece(columns[i], deserializer) | ||
| } | ||
| if (result is DocumentModel && metaKey != null) | ||
| result.documentMeta = getDocumentMeta(metaKey) | ||
| return result | ||
| } | ||
|
|
||
|
|
||
| /** Returns the query rows transformed by Kotlin Serialization into instances of [T]. */ | ||
| @ExperimentalSerializationApi | ||
| inline fun <reified T> ResultSet.data(key: String? = null): Sequence<T> = | ||
| data(serializer(), key) | ||
|
|
||
| @ExperimentalSerializationApi | ||
| inline fun <reified T: DocumentModel> ResultSet.data(key: String? = null, | ||
| metaKey: String? = null): Sequence<T> = | ||
| data(serializer(), key, metaKey) | ||
|
|
||
| /** Returns the query rows transformed by Kotlin Serialization into instances of [T]. */ | ||
| @ExperimentalSerializationApi | ||
| fun <T> ResultSet.data(deserializer: DeserializationStrategy<T>, | ||
| key: String? = null, | ||
| metaKey: String? = null): Sequence<T> = | ||
| sequence { | ||
| while (true) { | ||
| val result = next() ?: break | ||
| yield(result.data(deserializer, key, metaKey)) | ||
| } | ||
| } | ||
|
|
||
|
|
||
| // A trivial implementation of [Map.Entry]. | ||
| private class Entry(override val key: String, override val value: FLValue) : Map.Entry<String,FLValue> | ||
|
|
||
|
|
||
| // Creates a [DocumentMeta] from the "meta" column of a Result. (Note: It can't set the `collection`.) | ||
| private fun Result.getDocumentMeta(key: String): DocumentMeta? { | ||
| val i = getIndexForKey(key) | ||
| if (i < 0) throw CouchbaseLiteError("Query result has no property '$key'") | ||
| val col = flValues[i] | ||
| if (col.type != FLValue.DICT) return null | ||
| val meta = col.asFLDict() | ||
| val id = meta["id"]?.asString() ?: return null | ||
| val revID = meta["revisionID"]?.asString() ?: return null | ||
| return DocumentMeta(null, id, revID) | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In Swift, we also support id == nil as an auto generated id for a newly created doc. Can we allow that here as well?