Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions common/main/java/com/couchbase/lite/Document.java
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down
27 changes: 17 additions & 10 deletions common/main/java/com/couchbase/lite/Result.java
Original file line number Diff line number Diff line change
Expand Up @@ -523,13 +523,27 @@ public String toJSON() throws CouchbaseLiteException {
public Iterator<String> iterator() { return getKeys().iterator(); }

//---------------------------------------------
// private access
// package access -- for use by QueryExtensions.kt
//---------------------------------------------

private int getColumnCount() { return context.getResultSet().getColumnCount(); }
@NonNull
List<FLValue> getFLValues() { return values; }

@NonNull
private List<String> getColumnNames() { return context.getResultSet().getColumnNames(); }
List<String> 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) {
Expand All @@ -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<FLValue> extractColumns(@NonNull FLArrayIterator columns) {
final int n = getColumnCount();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,6 @@ public Object toJava() {
<T> T withContent(@NonNull Fn.NonNullFunction<Long, T> fn) { return fn.apply(peer); }

@NonNull
FLArray asFLArray() { return FLArray.create(impl.nAsArray(peer)); }
public FLArray asFLArray() { return FLArray.create(impl.nAsArray(peer)); }
}

153 changes: 153 additions & 0 deletions common/main/kotlin/com/couchbase/lite/CollectionExtensions.kt
Original file line number Diff line number Diff line change
@@ -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 <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)
} 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, T?)-> Boolean


/** 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)
}
129 changes: 129 additions & 0 deletions common/main/kotlin/com/couchbase/lite/QueryExtensions.kt
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 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<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 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>("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 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 <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 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)
}
Loading
Loading