Skip to content
Merged
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
16 changes: 16 additions & 0 deletions common/main/java/com/couchbase/lite/Document.java
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
import com.couchbase.lite.internal.fleece.FLDict;
import com.couchbase.lite.internal.fleece.FLEncoder;
import com.couchbase.lite.internal.fleece.FLSliceResult;
import com.couchbase.lite.internal.fleece.FLValue;
import com.couchbase.lite.internal.fleece.JSONEncodable;
import com.couchbase.lite.internal.fleece.MRoot;
import com.couchbase.lite.internal.utils.ClassUtils;
Expand Down Expand Up @@ -133,6 +134,12 @@ static Document getDocumentWithRevisions(@NonNull Collection collection, @NonNul
@Nullable
private String revId;

// Set by setData(FLSliceResult,boolean) to keep the Fleece backing store from being GC'd.
// (This is kind of a hack, and it's only used ephemerally by Kotlin serialization.)
@GuardedBy("lock")
@Nullable
private FLSliceResult extraBackingStore;

//---------------------------------------------
// Constructors
//---------------------------------------------
Expand Down Expand Up @@ -622,6 +629,15 @@ private void setC4Document(@Nullable C4Document c4doc, boolean mutable) {
}
}

// for use by CollectionExtensions.kt
void setContent(@NonNull FLSliceResult fleeceData, boolean mutable) {
synchronized (lock) {
var data = FLValue.fromData(fleeceData).asFLDict();
extraBackingStore = fleeceData;
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 @@ -73,7 +73,7 @@ public interface NativeImpl {
long nAsInt(long value);
float nAsFloat(long value);
double nAsDouble(long value);
@NonNull
@Nullable
String nAsString(long value);
long nAsArray(long value);
long nAsDict(long value);
Expand Down Expand Up @@ -269,7 +269,7 @@ public FLValue(@NonNull NativeImpl impl, long peer) {
*
* @return String
*/
@NonNull
@Nullable
public String asString() { return impl.nAsString(peer); }

@NonNull
Expand Down 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)); }
}

184 changes: 184 additions & 0 deletions common/main/kotlin/com/couchbase/lite/CollectionExtensions.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
//
// 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.
* After a successful save, its [DocumentModel.documentMeta] property is updated to the current state.
* @param model The [DocumentModel] instance to save.
* @param docID The document ID to save a new unsaved model as, or `null` to generate a unique ID.
* If the model has already been saved, this should be omitted or left `null`.
* @param conflictHandler A callback to resolve conflicts between the model and a more recently
* saved document revision. If `null` (the default), the last-writer-wins
* strategy is used: the save always succeeds, overwriting any
* conflicting revision.
* @return True on success, false on an unresolved conflict.
* */
@ExperimentalSerializationApi
inline fun <reified T: DocumentModel> Collection.save(model: T,
docID: String? = null,
noinline conflictHandler: ModelConflictHandler<T>? = null) =
save(model, serializer(), serializer(), docID, conflictHandler)

/** Saves a [DocumentModel] instance as a document in the collection, explicitly passing the
* serialization strategy. (This overload is rarely needed.) */
@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) {
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 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 {
if (conflictHandler == null)
return true // conflict handler defaults to last-write-wins
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 callback 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 must NOT modify its [DocumentModel.documentMeta] property.)
*
* Or it may return false to signal that it can't handle the conflict, in which case the
* [Collection.save] method will return false without saving anything. */
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>) {
setContent(serializeToFleece(serializer, model), false)
}
Loading
Loading