diff --git a/common/main/java/com/couchbase/lite/Document.java b/common/main/java/com/couchbase/lite/Document.java index 1d8c63244..c958c74d3 100644 --- a/common/main/java/com/couchbase/lite/Document.java +++ b/common/main/java/com/couchbase/lite/Document.java @@ -622,6 +622,13 @@ private void setC4Document(@Nullable C4Document c4doc, boolean mutable) { } } + // for use by CollectionExtensions.kt + void setContent(@NonNull FLDict data, boolean mutable) { + synchronized (lock) { + setContentLocked(data, mutable); + } + } + @GuardedBy("lock") private void updateC4DocumentLocked(@Nullable C4Document c4Doc) { if (c4Document == c4Doc) { return; } diff --git a/common/main/java/com/couchbase/lite/Result.java b/common/main/java/com/couchbase/lite/Result.java index dad37f50d..50cb0b0ed 100644 --- a/common/main/java/com/couchbase/lite/Result.java +++ b/common/main/java/com/couchbase/lite/Result.java @@ -523,13 +523,27 @@ public String toJSON() throws CouchbaseLiteException { public Iterator iterator() { return getKeys().iterator(); } //--------------------------------------------- - // private access + // package access -- for use by QueryExtensions.kt //--------------------------------------------- - private int getColumnCount() { return context.getResultSet().getColumnCount(); } + @NonNull + List getFLValues() { return values; } @NonNull - private List getColumnNames() { return context.getResultSet().getColumnNames(); } + List getColumnNames() { return context.getResultSet().getColumnNames(); } + + int getIndexForKey(String key) { + final int index = context.getResultSet().getColumnIndex(Preconditions.assertNotNull(key, "key")); + if (index < 0) { return -1; } + if ((missingColumns & (1L << index)) != 0) { return -1; } + return (!isInBounds(index)) ? -1 : index; + } + + //--------------------------------------------- + // private access + //--------------------------------------------- + + private int getColumnCount() { return context.getResultSet().getColumnCount(); } @Nullable private Object getFleeceAt(int index) { @@ -546,13 +560,6 @@ private FLValue getFLValueAt(int index) { return values.get(index); } - private int getIndexForKey(@Nullable String key) { - final int index = context.getResultSet().getColumnIndex(Preconditions.assertNotNull(key, "key")); - if (index < 0) { return -1; } - if ((missingColumns & (1L << index)) != 0) { return -1; } - return (!isInBounds(index)) ? -1 : index; - } - @NonNull private List extractColumns(@NonNull FLArrayIterator columns) { final int n = getColumnCount(); diff --git a/common/main/java/com/couchbase/lite/internal/fleece/FLValue.java b/common/main/java/com/couchbase/lite/internal/fleece/FLValue.java index 01e2f5d2f..bcff3e12b 100644 --- a/common/main/java/com/couchbase/lite/internal/fleece/FLValue.java +++ b/common/main/java/com/couchbase/lite/internal/fleece/FLValue.java @@ -325,6 +325,6 @@ public Object toJava() { T withContent(@NonNull Fn.NonNullFunction fn) { return fn.apply(peer); } @NonNull - FLArray asFLArray() { return FLArray.create(impl.nAsArray(peer)); } + public FLArray asFLArray() { return FLArray.create(impl.nAsArray(peer)); } } diff --git a/common/main/kotlin/com/couchbase/lite/CollectionExtensions.kt b/common/main/kotlin/com/couchbase/lite/CollectionExtensions.kt new file mode 100644 index 000000000..5fb27baed --- /dev/null +++ b/common/main/kotlin/com/couchbase/lite/CollectionExtensions.kt @@ -0,0 +1,153 @@ +// +// 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 Collection.getDocumentAs(id: String): T? = + getDocumentAs(id, serializer()) + +@ExperimentalSerializationApi +fun Collection.getDocumentAs(id: String, deserializer: DeserializationStrategy): 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 Collection.save(model: T, + docID: String? = null, + noinline conflictHandler: ModelConflictHandler? = null) = + save(model, serializer(), serializer(), docID, conflictHandler) + +@ExperimentalSerializationApi +fun Collection.save(model: T, + serializer: SerializationStrategy, + deserializer: DeserializationStrategy, + docID: String? = null, + conflictHandler: ModelConflictHandler? = 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) + } else { + require(meta.collection == this || meta.collection == null) {"saving document to wrong collection"} + require(docID == null || docID == meta.id) {"docID parameter does not match meta.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?)-> Boolean + + +/** Creates a [DocumentModel] instance from a [C4Document]. */ +private fun modelFromC4Doc(collection: Collection, + docID: String, + c4doc: C4Document?, + deserializer: DeserializationStrategy): 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 MutableDocument.setContentFromModel(model: T, serializer: SerializationStrategy) { + val body = serializeToFleece(serializer, model) + val root = FLValue.fromData(body).asFLDict() + setContent(root, false) +} diff --git a/common/main/kotlin/com/couchbase/lite/QueryExtensions.kt b/common/main/kotlin/com/couchbase/lite/QueryExtensions.kt new file mode 100644 index 000000000..0a50c6272 --- /dev/null +++ b/common/main/kotlin/com/couchbase/lite/QueryExtensions.kt @@ -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 the query row. + * If the [key] parameter is non-null, it uses the row's value for that key as the source, + * instead of the entire row. + * + * 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()` + * + * Or you could use the query `SELECT * as person FROM people` and create the Person with + * `val person = result.data("person")`. + * */ +@ExperimentalSerializationApi +inline fun Result.data(key: String? = null): T = + data(serializer(), key) + +/** Uses Kotlin Serialization to create a [DocumentModel] instance of type [T] from the query row. + * 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", "meta")`. + */ +@ExperimentalSerializationApi +inline fun Result.data(key: String? = null, + metaKey: String? = null): T = + data(serializer(), key, metaKey) + +@ExperimentalSerializationApi +fun Result.data(deserializer: DeserializationStrategy, + 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> { + var i = 0 + override fun hasNext() = i < size + override fun next(): Map.Entry { + 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 row 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 ResultSet.data(key: String? = null): Sequence = + data(serializer(), key) + +@ExperimentalSerializationApi +inline fun ResultSet.data(key: String? = null, + metaKey: String? = null): Sequence = + data(serializer(), key, metaKey) + +/** Returns the query rows transformed by Kotlin Serialization into instances of [T]. */ +@ExperimentalSerializationApi +fun ResultSet.data(deserializer: DeserializationStrategy, + key: String? = null, + metaKey: String? = null): Sequence = + 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 + + +// 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 row 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) +} \ No newline at end of file diff --git a/common/main/kotlin/com/couchbase/lite/internal/fleece/FLValueExtensions.kt b/common/main/kotlin/com/couchbase/lite/internal/fleece/FLValueExtensions.kt new file mode 100644 index 000000000..d3a9e08d6 --- /dev/null +++ b/common/main/kotlin/com/couchbase/lite/internal/fleece/FLValueExtensions.kt @@ -0,0 +1,107 @@ +// +// 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.internal.fleece + + +/** Returns a []Collection] with the same contents as this FLArray. */ +val FLArray.asCollection: Collection get() = FLArrayAsCollection(this) + + +private class FLArrayAsCollection(val array: FLArray): Collection { + override val size: Int = array.count().toInt() + + override fun isEmpty(): Boolean = (size == 0) + + override fun contains(element: FLValue): Boolean { + for (i in 0L ..< size.toLong()) + if (array[i] == element) return true + return false + } + + override fun containsAll(elements: Collection): Boolean = + elements.all { this.contains(it) } + + override fun iterator(): Iterator { + return object: Iterator { + var iter = array.iterator() + + override fun hasNext(): Boolean = iter.value != null + + override fun next(): FLValue { + val value = iter.value ?: throw IndexOutOfBoundsException() + iter.next() + return value + } + } + } +} + + +/** Returns a [Map] with the same contents as this FLDict. */ +val FLDict.asMap: Map get() = FLDictAsMap(this) + +private class FLDictAsMap(val dict: FLDict): Map { + override val size: Int = dict.count().toInt() + + override fun isEmpty(): Boolean = (dict.count() == 0L) + + override fun get(key: String): FLValue? = dict.get(key) + + override fun containsKey(key: String): Boolean = (dict.get(key) != null) + + override fun containsValue(value: FLValue): Boolean { + val iter = dict.iterator() + while (iter.key != null) { + if (iter.value == value) return true + iter.next() + } + return false + } + + class Entry(override val key: String, override val value: FLValue) : Map.Entry + + override val entries: Set> get() { + val iter = dict.iterator() + return buildSet { + while (true) { + val key = iter.key ?: break + add(Entry(key, iter.value)) + iter.next() + } + } + } + + override val keys: Set get() { + val iter = dict.iterator() + return buildSet { + while (true) { + add(iter.key ?: break) + iter.next() + } + } + } + + override val values: Collection get() { + val iter = dict.iterator() + return buildList { + while (iter.key != null) { + add(iter.value) + iter.next() + } + } + } +} diff --git a/common/main/kotlin/com/couchbase/lite/internal/fleece/FleeceDeserialization.kt b/common/main/kotlin/com/couchbase/lite/internal/fleece/FleeceDeserialization.kt new file mode 100644 index 000000000..02aeb39d3 --- /dev/null +++ b/common/main/kotlin/com/couchbase/lite/internal/fleece/FleeceDeserialization.kt @@ -0,0 +1,300 @@ +// +// 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.internal.fleece + +import kotlinx.serialization.* +import kotlinx.serialization.descriptors.* +import kotlinx.serialization.encoding.* +import kotlinx.serialization.modules.* + + +/** Decodes an object from a Fleece [FLValue] using Kotlin Serialization. */ +@ExperimentalSerializationApi +fun deserializeFromFleece(root: FLValue, deserializer: DeserializationStrategy): T = + ValueDecoder(root).decodeSerializableValue(deserializer) + +/** Decodes an object from a Fleece [FLValue] using Kotlin Serialization. */ +@ExperimentalSerializationApi +inline fun deserializeFromFleece(root: FLValue): T = + deserializeFromFleece(root, serializer()) + +/** Decodes an object from a [Collection] using Kotlin Serialization. */ +@ExperimentalSerializationApi +fun deserializeFromFleece(root: Collection, deserializer: DeserializationStrategy): T = + CollectionRootDecoder(root).decodeSerializableValue(deserializer) + + +/** Decodes an object from a [Collection] using Kotlin Serialization. */ +@ExperimentalSerializationApi +inline fun deserializeFromFleece(root: Collection): T = + deserializeFromFleece(root, serializer()) + + +/** Decodes an object from a [Map] iterator using Kotlin Serialization. */ +@ExperimentalSerializationApi +fun deserializeFromFleece(iterator: Iterator>, + size: Int, + deserializer: DeserializationStrategy): T = + MapRootDecoder(iterator, size).decodeSerializableValue(deserializer) + +/** Decodes an object from a [Map] iterator using Kotlin Serialization. */ +@ExperimentalSerializationApi +inline fun deserializeFromFleece(iterator: Iterator>, + size: Int): T = + deserializeFromFleece(iterator, size, serializer()) + + +/** Root decoder for a `Collection` */ +private class CollectionRootDecoder(val collection: Collection): AbstractDecoder() { + override val serializersModule: SerializersModule = EmptySerializersModule() + + override fun decodeElementIndex(descriptor: SerialDescriptor): Int { + throw FleeceSerializationException("not a scalar value") + } + + override fun beginStructure(descriptor: SerialDescriptor): CompositeDecoder { + return when (descriptor.kind) { + StructureKind.LIST -> ArrayDecoder(collection) + else -> throw FleeceSerializationException("unsupported SerialDescriptor kind ${descriptor.kind}") + } + } +} + + +/** Root decoder for a `Map` */ +private class MapRootDecoder(val iter: Iterator>, + val size: Int): AbstractDecoder() +{ + override val serializersModule: SerializersModule = EmptySerializersModule() + + override fun decodeElementIndex(descriptor: SerialDescriptor): Int { + throw FleeceSerializationException("not a scalar value") + } + + override fun beginStructure(descriptor: SerialDescriptor): CompositeDecoder { + return when (descriptor.kind) { + StructureKind.MAP -> { + val keyDescriptor = descriptor.getElementDescriptor(0) + if (keyDescriptor.kind != PrimitiveKind.STRING) + throw FleeceSerializationException("Map keys must be Strings, not ${keyDescriptor.serialName}") + MapDecoder(iter, size) + } + StructureKind.CLASS -> { + ClassDecoder(iter) + } + else -> throw FleeceSerializationException("unsupported SerialDescriptor kind ${descriptor.kind}") + } + } +} + + +/** Base class of ValueDecoder and ArrayDecoder. */ +private abstract class AbstractValueDecoder(val size: Int): AbstractDecoder() { + protected var index = -1 + + override val serializersModule: SerializersModule = EmptySerializersModule() + + override fun decodeSequentially(): Boolean = true + + override fun decodeCollectionSize(descriptor: SerialDescriptor): Int = size + + override fun decodeElementIndex(descriptor: SerialDescriptor): Int = + if (++index >= size) CompositeDecoder.DECODE_DONE else index + + abstract val value: FLValue + + override fun decodeBoolean(): Boolean = value.asBool() + override fun decodeByte(): Byte = decodeLong().toByte() + override fun decodeShort(): Short = decodeLong().toShort() + override fun decodeChar(): Char = Char(decodeInt()) + override fun decodeInt(): Int = decodeLong().toInt() + override fun decodeLong(): Long = value.asInt() + override fun decodeFloat(): Float = value.asFloat() + override fun decodeDouble(): Double = value.asDouble() + override fun decodeString(): String = value.asString() + + override fun decodeInline(descriptor: SerialDescriptor): Decoder = this + + override fun decodeEnum(enumDescriptor: SerialDescriptor): Int = decodeInt() + + override fun beginStructure(descriptor: SerialDescriptor): CompositeDecoder = + decoderForStructure(value, descriptor) +} + + +/** Root decoder for a [FLValue]. + * Decodes scalars, and delegates collections to [ArrayDecoder] or [MapDecoder]. */ +private class ValueDecoder(override val value: FLValue): AbstractValueDecoder(1) + + +/** Decodes a Kotlin [List] from a Collection of FLValues, usually an FLArray. */ +private class ArrayDecoder(array: Collection): AbstractValueDecoder(array.size) { + val iter = array.iterator() + override val value get() = iter.next() +} + + +/** Decodes a Kotlin [Map] from a Map iterator. */ +private class MapDecoder(val iter: Iterator>, + val size: Int): AbstractDecoder() +{ + constructor(map: Map) :this(map.iterator(), map.size) + + private var index = 0 + private var curKey: String? = null + private var curValue: FLValue? = null + + override val serializersModule: SerializersModule = EmptySerializersModule() + + override fun decodeSequentially() = true + + override fun decodeCollectionSize(descriptor: SerialDescriptor): Int = size + + override fun decodeElementIndex(descriptor: SerialDescriptor): Int = + if (index < size) index else CompositeDecoder.DECODE_DONE + + // Advances the iterator and returns the key. + private fun nextKey(): String { + require(curKey == null) + val (key, value) = iter.next() + curKey = key + curValue = value + index++ + return key + } + + // Returns the current value without advancing. + private fun nextValue(): FLValue { + val value = curValue + require(value != null) {"expected a map key to be decoded next"} + curKey = null + curValue = null + index++ + return value + } + + // A decoder for [StructureKind.MAP] is expected to provide alternating keys and values. + // And keys are always strings (already preflighted.) So [decodeString] may be called for either + // a key or a value. It checks if we've read the next entry, and if not advances the iterator + // and returns the key. Otherwise it returns the current value. + override fun decodeString(): String = + if (curValue == null) nextKey() else nextValue().asString() + + // The other decode methods are only called for values. + override fun decodeBoolean(): Boolean = nextValue().asBool() + override fun decodeByte(): Byte = decodeLong().toByte() + override fun decodeShort(): Short = decodeLong().toShort() + override fun decodeInt(): Int = decodeLong().toInt() + override fun decodeLong(): Long = nextValue().asInt() + override fun decodeFloat(): Float = nextValue().asFloat() + override fun decodeDouble(): Double = nextValue().asDouble() + override fun decodeChar(): Char = Char(decodeInt()) +} + + +/** Decodes a Kotlin class instance from a Map iterator. */ +private class ClassDecoder(val iter: Iterator>): CompositeDecoder { + constructor(map: Map) :this(map.iterator()) + + private var curKey: String? = null + private var curValue: FLValue? = null + private var elementIndex = -1 + + override val serializersModule: SerializersModule = EmptySerializersModule() + + override fun decodeElementIndex(descriptor: SerialDescriptor): Int { + // "Decodes the index of the next element to be decoded. Index represents a position of the + // current element in the serial descriptor element that can be found with + // SerialDescriptor.getElementIndex." -- Kotlin API docs + if (!iter.hasNext()) return CompositeDecoder.DECODE_DONE + val (key, value) = iter.next() + curKey = key + curValue = value + elementIndex = descriptor.getElementIndex(key) + return elementIndex + } + + private fun nextValue(index: Int): FLValue { + require(index == elementIndex) {"unexpected element index"} + elementIndex = -1 + return curValue!! + } + + private fun decodeLong(index: Int) = nextValue(index).asInt() + + override fun decodeBooleanElement(descriptor: SerialDescriptor, index: Int): Boolean = nextValue(index).asBool() + override fun decodeByteElement(descriptor: SerialDescriptor, index: Int): Byte = decodeLong(index).toByte() + override fun decodeShortElement(descriptor: SerialDescriptor, index: Int): Short = decodeLong(index).toShort() + override fun decodeIntElement(descriptor: SerialDescriptor, index: Int): Int = decodeLong(index).toInt() + override fun decodeLongElement(descriptor: SerialDescriptor, index: Int): Long = decodeLong(index) + override fun decodeFloatElement(descriptor: SerialDescriptor, index: Int): Float = nextValue(index).asFloat() + override fun decodeDoubleElement(descriptor: SerialDescriptor, index: Int): Double = nextValue(index).asDouble() + override fun decodeCharElement(descriptor: SerialDescriptor, index: Int): Char = Char(decodeLong(index).toInt()) + override fun decodeStringElement(descriptor: SerialDescriptor, index: Int): String = nextValue(index).asString() + + override fun decodeInlineElement(descriptor: SerialDescriptor, index: Int): Decoder = + ValueDecoder(nextValue(index)) + + override fun decodeSerializableElement(descriptor: SerialDescriptor, + index: Int, + deserializer: DeserializationStrategy, + previousValue: T?): T + { + return deserializer.deserialize(ValueDecoder(nextValue(index))) + } + + override fun decodeNullableSerializableElement(descriptor: SerialDescriptor, + index: Int, + deserializer: DeserializationStrategy, + previousValue: T?): T? + { + val value = nextValue(index) + if (value.type == FLValue.NULL) + return null + return deserializer.deserialize(ValueDecoder(value)) + } + + override fun endStructure(descriptor: SerialDescriptor) { /*no-op*/ } +} + + +// Creates an appropriate CompositeDecoder based on a SerialDescriptor. +@ExperimentalSerializationApi +private fun decoderForStructure(item: FLValue, descriptor: SerialDescriptor): CompositeDecoder { + return when (descriptor.kind) { + StructureKind.LIST -> { + if (item.type != FLValue.ARRAY) {throw FleeceSerializationException("expected an array") } + ArrayDecoder(item.asFLArray().asCollection) + } + StructureKind.MAP -> { + val keyDescriptor = descriptor.getElementDescriptor(0) + if (keyDescriptor.kind != PrimitiveKind.STRING) + throw FleeceSerializationException("Map keys must be Strings, not ${keyDescriptor.serialName}") + if (item.type != FLValue.DICT) {throw FleeceSerializationException("expected a dict") } + MapDecoder(item.asFLDict().asMap) + } + StructureKind.CLASS -> { + if (item.type != FLValue.DICT) {throw FleeceSerializationException("expected a dict") } + ClassDecoder(item.asFLDict().asMap) + } + else -> { + throw FleeceSerializationException("unsupported SerialDescriptor kind ${descriptor.kind}") + } + } +} diff --git a/common/main/kotlin/com/couchbase/lite/internal/fleece/FleeceSerialization.kt b/common/main/kotlin/com/couchbase/lite/internal/fleece/FleeceSerialization.kt new file mode 100644 index 000000000..fe03ce4ea --- /dev/null +++ b/common/main/kotlin/com/couchbase/lite/internal/fleece/FleeceSerialization.kt @@ -0,0 +1,245 @@ +// +// 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.internal.fleece + +import kotlinx.serialization.* +import kotlinx.serialization.descriptors.* +import kotlinx.serialization.encoding.* +import kotlinx.serialization.modules.* + + +/** Uses Kotlin Serialization to encode an arbitrary object or collection to Fleece. + * @param serializer The SerializationStrategy to use. + * @param value The object to encode. + * @param encoder A Fleece [FLEncoder] to use; defaults to a fresh instance. + * @return The encoded Fleece data. */ +@ExperimentalSerializationApi +fun serializeToFleece(serializer: SerializationStrategy, + value: T, + encoder: FLEncoder? = null): ByteArray +{ + val encoder = FleeceRootEncoder(encoder) + encoder.encodeSerializableValue(serializer, value) + return encoder.container!! +} + +/** Uses Kotlin Serialization to encode an arbitrary object or collection to Fleece. + * @param value The object to encode. + * @param encoder A Fleece [FLEncoder] to use; defaults to a fresh instance. + * @return The encoded Fleece data. */ +@ExperimentalSerializationApi +inline fun serializeToFleece(value: T, encoder: FLEncoder? = null): ByteArray = + serializeToFleece(serializer(), value, encoder) + + +class FleeceSerializationException(message: String): Exception(message) + + +/** Common interface of [FleeceRootEncoder] and [FleeceCollectionEncoder]. */ +private interface FleeceParentEncoder { + val flEncoder: FLEncoder + + fun childFinished() + + fun beginChild(descriptor: SerialDescriptor, collectionSize: Long = 0): CompositeEncoder { + require(descriptor.kind is StructureKind) + var isDict = false + if (descriptor.kind == StructureKind.LIST) { + flEncoder.beginArray(collectionSize) + } else { + if (descriptor.kind == StructureKind.MAP) { + val keyDescriptor = descriptor.getElementDescriptor(0) + if (keyDescriptor.kind != PrimitiveKind.STRING) + throw FleeceSerializationException("Map keys must be Strings, not ${keyDescriptor.serialName}") + } + isDict = true + flEncoder.beginDict(collectionSize) + } + return FleeceCollectionEncoder(this, isDict = isDict) + } +} + + +/** The root-level Encoder. It just expects a [beginCollection] or [beginStructure] call. */ +private class FleeceRootEncoder(encoder: FLEncoder?) : AbstractEncoder(), FleeceParentEncoder { + override val flEncoder = encoder ?: FLEncoder.getManagedEncoder() + var container: ByteArray? = null + + override val serializersModule: SerializersModule = EmptySerializersModule() + + override fun encodeValue(value: Any) = + throw FleeceSerializationException("Fleece cannot serialize primitive types, only classes and collections.") + + override fun beginCollection(descriptor: SerialDescriptor, collectionSize: Int): CompositeEncoder = + beginChild(descriptor, collectionSize.toLong()) + + override fun beginStructure(descriptor: SerialDescriptor): CompositeEncoder = + beginChild(descriptor) + + override fun childFinished() { + require(container == null) + container = flEncoder.finish() + } +} + + +// https://kotlinlang.org/api/kotlinx.serialization/kotlinx-serialization-core/kotlinx.serialization.encoding/-composite-encoder/ + +/** Child Encoder that does the real work. */ +private class FleeceCollectionEncoder (private val parent: FleeceParentEncoder, + private val isDict: Boolean) + : Encoder, CompositeEncoder, FleeceParentEncoder { + + override val flEncoder = parent.flEncoder + + override val serializersModule: SerializersModule = EmptySerializersModule() + + // True when the current value being encoded is actually a Dict key. + private var valueIsKey: Boolean = false + + // Always called before adding a value to the Fleece encoder. + private fun writeKey(descriptor: SerialDescriptor, index: Int, isString: Boolean = false) { + require(!valueIsKey) + when (descriptor.kind) { + StructureKind.CLASS -> { + // If encoding a class instance, then the key is available from the descriptor. + valueIsKey = true + actuallyWriteKey(descriptor.getElementName(index)) + } + StructureKind.MAP -> { + // ...but if encoding a map, it's passed to us as alternating keys and values, so + // we have to keep track of which are the keys by setting [valueIsKey] appropriately. + valueIsKey = (index % 2 == 0) + if (valueIsKey && !isString) + throw FleeceSerializationException("Map keys must be Strings to encode to Fleece") + } + else -> { } + } + } + + // Actually writes a key to the DictEncoder. + private fun actuallyWriteKey(key: String) { + assert(valueIsKey) + flEncoder.writeKey(key) + valueIsKey = false + } + + override fun childFinished() { /*no-op*/ } + + //---- CompositeEncoder API: + + override fun encodeBooleanElement(descriptor: SerialDescriptor, index: Int, value: Boolean) { + writeKey(descriptor, index) + encodeBoolean(value) + } + + override fun encodeByteElement(descriptor: SerialDescriptor, index: Int, value: Byte) = + encodeIntElement(descriptor, index, value.toInt()) + + override fun encodeShortElement(descriptor: SerialDescriptor, index: Int, value: Short) = + encodeIntElement(descriptor, index, value.toInt()) + + override fun encodeCharElement(descriptor: SerialDescriptor, index: Int, value: Char) = + encodeIntElement(descriptor, index, value.code) + + override fun encodeIntElement(descriptor: SerialDescriptor, index: Int, value: Int) { + writeKey(descriptor, index) + encodeInt(value) + } + + override fun encodeLongElement(descriptor: SerialDescriptor, index: Int, value: Long) { + writeKey(descriptor, index) + encodeLong(value) + } + + override fun encodeFloatElement(descriptor: SerialDescriptor, index: Int, value: Float) { + writeKey(descriptor, index) + encodeFloat(value) + } + + override fun encodeDoubleElement(descriptor: SerialDescriptor, index: Int, value: Double) { + writeKey(descriptor, index) + encodeDouble(value) + } + + override fun encodeStringElement(descriptor: SerialDescriptor, index: Int, value: String) { + writeKey(descriptor, index, isString = true) + encodeString(value) + } + + override fun encodeInlineElement(descriptor: SerialDescriptor, index: Int): Encoder = this + + override fun encodeSerializableElement(descriptor: SerialDescriptor, + index: Int, + serializer: SerializationStrategy, + value: T) + { + writeKey(descriptor, index, isString = true) + serializer.serialize(this, value) + } + + override fun encodeNullableSerializableElement(descriptor: SerialDescriptor, + index: Int, + serializer: SerializationStrategy, + value: T?) + { + if (value != null) { + encodeSerializableElement(descriptor, index, serializer, value) + } else if (!descriptor.isElementOptional(index)) { + writeKey(descriptor, index) + encodeNull() + } + } + + override fun endStructure(descriptor: SerialDescriptor) { + if (isDict) flEncoder.endDict() else flEncoder.endArray() + parent.childFinished() + } + + //---- Encoder API: + + override fun encodeNull() {flEncoder.writeNull()} + override fun encodeBoolean(value: Boolean) {flEncoder.writeValue(value)} + override fun encodeByte(value: Byte) {flEncoder.writeValue(value.toInt())} + override fun encodeShort(value: Short) {flEncoder.writeValue(value.toInt())} + override fun encodeChar(value: Char) {flEncoder.writeValue(value.code)} + override fun encodeInt(value: Int) {flEncoder.writeValue(value)} + override fun encodeLong(value: Long) {flEncoder.writeValue(value)} + override fun encodeFloat(value: Float) {flEncoder.writeValue(value)} + override fun encodeDouble(value: Double) {flEncoder.writeValue(value)} + + override fun encodeString(value: String) { + if (valueIsKey) + actuallyWriteKey(value) + else + flEncoder.writeString(value) + } + + override fun encodeEnum(enumDescriptor: SerialDescriptor, index: Int) { + flEncoder.writeValue(index) + } + + override fun encodeInline(descriptor: SerialDescriptor): Encoder = this + + override fun beginCollection(descriptor: SerialDescriptor, collectionSize: Int) = + beginChild(descriptor, collectionSize.toLong()) + + override fun beginStructure(descriptor: SerialDescriptor) = + beginChild(descriptor) +} diff --git a/common/test/java/com/couchbase/lite/CollectionTest.kt b/common/test/java/com/couchbase/lite/CollectionTest.kt index 7bc7c80cd..3073bf558 100644 --- a/common/test/java/com/couchbase/lite/CollectionTest.kt +++ b/common/test/java/com/couchbase/lite/CollectionTest.kt @@ -17,6 +17,8 @@ package com.couchbase.lite import com.couchbase.lite.internal.utils.SlowTest +import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient import org.junit.Assert import org.junit.Test @@ -1213,4 +1215,48 @@ class CollectionTest : BaseDbTest() { Assert.assertEquals(scope, testDatabase.getScope(Scope.DEFAULT_NAME)) } + + //--------------------------------------------- + // Model-based API + //--------------------------------------------- + + @Test + fun saveNewDocInCollectionFromModel() { + val id = getUniqueName("test_doc") + + // Create a new model and save it: + val model = TestModel("Nigel", 12) + testCollection.save(model, id) + Assert.assertEquals(testCollection, model.documentMeta?.collection) + Assert.assertEquals(id, model.documentMeta?.id) + + // Read it back: + Assert.assertEquals(1, testCollection.count) + val gotModel = testCollection.getDocumentAs(id)!! + Assert.assertEquals(model, gotModel) + + // Modify and save again: + gotModel.favorites = listOf("XTC", "Elvis Costello") + Assert.assertTrue(testCollection.save(gotModel)) + Assert.assertNotEquals(model.documentMeta?.revisionID, gotModel.documentMeta?.revisionID) + + // Get it as a regular Document and verify the contents: + val doc = testCollection.getDocument(id)!! + Assert.assertEquals(gotModel.documentMeta?.revisionID, doc.revisionID) + Assert.assertEquals("Nigel", doc.getString("name")) + Assert.assertEquals(12, doc.getInt("age")) + val faves = doc.getArray("favorites")!! + Assert.assertEquals(2, faves.count()) + Assert.assertEquals("XTC", faves.getString(0)) + Assert.assertEquals("Elvis Costello", faves.getString(1)) + } +} + + +// Simple Model class for tests +@Serializable +data class TestModel(var name: String, + var age: Int, + var favorites: List? = null): DocumentModel { + @Transient override var documentMeta: DocumentMeta? = null } diff --git a/common/test/java/com/couchbase/lite/ResultTest.kt b/common/test/java/com/couchbase/lite/ResultTest.kt new file mode 100644 index 000000000..ecc1f92d3 --- /dev/null +++ b/common/test/java/com/couchbase/lite/ResultTest.kt @@ -0,0 +1,70 @@ +// +// Copyright (c) 2026 Couchbase, 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.couchbase.lite + +import kotlinx.serialization.ExperimentalSerializationApi +import org.junit.Assert.* +import org.junit.Test + +/** Tests serialization-based Result and ResultSet accessors implemented in QueryExtensions.kt. */ +class ResultSerializationTest : BaseQueryTest() { + @Test @OptIn(ExperimentalSerializationApi::class) + fun testResultToModel() { + // Note: For simplicity this uses a class that implements DocumentModel, but it isn't necessary. + val doc = MutableDocument("nigel") + doc.setString("name", "Nigel") + doc.setInt("age", 12) + testCollection.save(doc) + + val query = testDatabase.createQuery("SELECT name, age FROM " + testCollection.fullName) + val rows = query.execute().data() + val iter = rows.iterator() + + assertTrue(iter.hasNext()) + val nigel: TestModel = iter.next() + assertEquals("Nigel", nigel.name) + assertEquals(12, nigel.age) + assertNull(nigel.favorites) + assertNull(nigel.documentMeta) // query doesn't return `meta` + + assertFalse(iter.hasNext()) + } + + @Test @OptIn(ExperimentalSerializationApi::class) + fun testResultToModelWithMeta() { + val doc = MutableDocument("nigel") + doc.setString("name", "Nigel") + doc.setInt("age", 12) + testCollection.save(doc) + + val query = testDatabase.createQuery("SELECT * as doc, meta() as meta FROM " + testCollection.fullName) + val resultSet = query.execute() + val rows = resultSet.data("doc", "meta") + val iter = rows.iterator() + + assertTrue(iter.hasNext()) + val nigel: TestModel = iter.next() + assertEquals("Nigel", nigel.name) + assertEquals(12, nigel.age) + assertNull(nigel.favorites) + // Verify the documentMeta got set: + assertEquals(doc.id, nigel.documentMeta?.id) + assertEquals(doc.revisionID, nigel.documentMeta?.revisionID) + + assertFalse(iter.hasNext()) + } +} \ No newline at end of file diff --git a/common/test/java/com/couchbase/lite/internal/fleece/FleeceSerializationTest.kt b/common/test/java/com/couchbase/lite/internal/fleece/FleeceSerializationTest.kt new file mode 100644 index 000000000..173cd2abf --- /dev/null +++ b/common/test/java/com/couchbase/lite/internal/fleece/FleeceSerializationTest.kt @@ -0,0 +1,199 @@ +package com.couchbase.lite.internal.fleece + +import com.couchbase.lite.BaseTest +import kotlinx.serialization.* +import org.junit.Assert +import org.junit.Test + + +@Serializable +data class Project(val name: String, val owner: User?, val votes: Int) + +@Serializable +data class ProjectOpt(val name: String, val owner: User? = null, val votes: Int) + +@Serializable +data class User(val name: String, val age: Age) + +@Serializable @JvmInline +value class Age(val years: UInt) + + +private fun fleeceToJSON(container: ByteArray): String = + FLValue.fromData(container).toJSON()!! + +private fun encode(fn: FLEncoder.()->Boolean): FLValue { + val encoder = FLEncoder.getManagedEncoder() + encoder.fn() + return FLValue.fromData(encoder.finish()) +} + + +@OptIn(ExperimentalSerializationApi::class) +class SerializationTests: BaseTest() { + @Test + fun encodeList() { + val container = serializeToFleece(listOf("hello", "there")) + Assert.assertEquals("[\"hello\",\"there\"]", fleeceToJSON(container)) + } + + @Test + fun encodeMap() { + val container = serializeToFleece(mapOf("key" to "value", "foo" to "bar")) + val json = fleeceToJSON(container) + Assert.assertEquals("""{"foo":"bar","key":"value"}""", json) + } + + @Test + fun encodeInvalidTypes() { + Assert.assertThrows(FleeceSerializationException::class.java) { + serializeToFleece("hello") // Can't encode just a scalar + } + Assert.assertThrows(FleeceSerializationException::class.java) { + serializeToFleece(Age(14u)) // ...even if it's wrapped in a value class + } + Assert.assertThrows(FleeceSerializationException::class.java) { + serializeToFleece(mapOf(99 to "value", 30 to "bar")) // Map keys must be Strings + } + } + + @Test + fun encodeSimpleClass() { + val data = User("kotlin", Age(17u)) + val container = serializeToFleece(data) + val json = fleeceToJSON(container) + Assert.assertEquals("""{"age":17,"name":"kotlin"}""", json) + } + + @Test + fun encodeNestedClasses() { + val data = Project("kotlinx.serialization", User("kotlin", Age(17u)), 9000) + val container = serializeToFleece(data) + val json = fleeceToJSON(container) + Assert.assertEquals( + """{"name":"kotlinx.serialization","owner":{"age":17,"name":"kotlin"},"votes":9000}""", + json + ) + } + + @Test + fun encodeNestedClassesWithNull() { + val data = Project("kotlinx.serialization", null, 9000) + val container = serializeToFleece(data) + val json = fleeceToJSON(container) + // "owner" appears with a null value because Project.owner doesn't default to null. + Assert.assertEquals("""{"name":"kotlinx.serialization","owner":null,"votes":9000}""", json) + } + + @Test + fun encodeNestedClassesWithOptionalNull() { + val data = ProjectOpt("kotlinx.serialization", null, 9000) + val container = serializeToFleece(data) + val json = fleeceToJSON(container) + // "owner" is omitted because ProjectOpt.owner has a default value of null. + Assert.assertEquals("""{"name":"kotlinx.serialization","votes":9000}""", json) + } + + + //---- DECODING: + + + @Test fun decodeList() { + val encoded = encode { + beginArray(0) + writeString("hi") + writeString("there") + endArray() + } + val list = deserializeFromFleece>(encoded) + Assert.assertEquals(listOf("hi", "there"), list) + } + + @Test fun decodeMap() { + val encoded = encode { + beginDict(0) + writeKey("hi") + writeValue(19) + writeKey("bye") + writeValue(-1) + endDict() + } + val map = deserializeFromFleece>(encoded) + Assert.assertEquals(mapOf("hi" to 19, "bye" to -1), map) + } + + @Test fun decodeClass() { + val encoded = encode { + beginDict(0) + writeKey("name") + writeValue("pupshaw") + writeKey("age") + writeValue(29) + endDict() + } + val user = deserializeFromFleece(encoded) + Assert.assertEquals(User("pupshaw", Age(29u)), user) + } + + @Test fun decodeNestedClasses() { + val encoded = encode { + beginDict(0) + writeKey("name") + writeValue("Fleece") + writeKey("votes") + writeValue(9000) + writeKey("owner") + + beginDict(0) + writeKey("name") + writeValue("pupshaw") + writeKey("age") + writeValue(29) + endDict() + + endDict() + } + + val user = deserializeFromFleece(encoded) + Assert.assertEquals(Project("Fleece", User("pupshaw", Age(29u)), 9000), user) + } + + @Test fun decodeNestedClassesWithNull() { + val encoded = encode { + beginDict(0) + writeKey("name") + writeValue("Fleece") + writeKey("votes") + writeValue(9000) + writeKey("owner") + writeNull() + endDict() + } + + val user = deserializeFromFleece(encoded) + Assert.assertEquals(Project("Fleece", null, 9000), user) + } + + @Test fun decodeNestedClassesWithOptionalNull() { + val encoded = encode { + beginDict(0) + writeKey("name") + writeValue("Fleece") + writeKey("votes") + writeValue(9000) + endDict() + } + + val user = deserializeFromFleece(encoded) + Assert.assertEquals(ProjectOpt("Fleece", null, 9000), user) + } + + @Test fun roundTripNestedClassesWithNull() { + val data = Project("Fleece", null, 9000) + val encoded = serializeToFleece(data) + print(fleeceToJSON(encoded)) + val user = deserializeFromFleece(FLValue.fromData(encoded)) + Assert.assertEquals(Project("Fleece", null, 9000), user) + + } +}