Skip to content

Commit 5b0328a

Browse files
authored
CBL-8389 : Add Kotlin serialization-based APIs (#475)
* Implemented Kotlin Serialization for Fleece * Implemented serialization-based API extensions - Collection.getDocumentAs(), save() - Result.data() - ResultSet.data() * Implemented Collection.delete(DocumentModel) * Allow null docID when saving new DocumentModel * Fixes from code review (not all fixed) - serializeToFleece() now returns a FLSliceResult not a ByteArray. - Callers of the above use `use` to release the memory when done. - Use `use` to release Fleece iterators. - Collection.save(DocumentModel) interprets a null conflict handler as last-write-wins. This fixes a NPE bug too. * FLValue.asString() should be marked @nullable per Jim. Fixed FleeceDeserialization to handle null by returning "" instead. * Ensure MutableDocument.setContentFromModel doesn't lose backing store
1 parent 88fb34c commit 5b0328a

11 files changed

Lines changed: 1334 additions & 13 deletions

File tree

common/main/java/com/couchbase/lite/Document.java

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
import com.couchbase.lite.internal.fleece.FLDict;
3434
import com.couchbase.lite.internal.fleece.FLEncoder;
3535
import com.couchbase.lite.internal.fleece.FLSliceResult;
36+
import com.couchbase.lite.internal.fleece.FLValue;
3637
import com.couchbase.lite.internal.fleece.JSONEncodable;
3738
import com.couchbase.lite.internal.fleece.MRoot;
3839
import com.couchbase.lite.internal.utils.ClassUtils;
@@ -133,6 +134,12 @@ static Document getDocumentWithRevisions(@NonNull Collection collection, @NonNul
133134
@Nullable
134135
private String revId;
135136

137+
// Set by setData(FLSliceResult,boolean) to keep the Fleece backing store from being GC'd.
138+
// (This is kind of a hack, and it's only used ephemerally by Kotlin serialization.)
139+
@GuardedBy("lock")
140+
@Nullable
141+
private FLSliceResult extraBackingStore;
142+
136143
//---------------------------------------------
137144
// Constructors
138145
//---------------------------------------------
@@ -622,6 +629,15 @@ private void setC4Document(@Nullable C4Document c4doc, boolean mutable) {
622629
}
623630
}
624631

632+
// for use by CollectionExtensions.kt
633+
void setContent(@NonNull FLSliceResult fleeceData, boolean mutable) {
634+
synchronized (lock) {
635+
var data = FLValue.fromData(fleeceData).asFLDict();
636+
extraBackingStore = fleeceData;
637+
setContentLocked(data, mutable);
638+
}
639+
}
640+
625641
@GuardedBy("lock")
626642
private void updateC4DocumentLocked(@Nullable C4Document c4Doc) {
627643
if (c4Document == c4Doc) { return; }

common/main/java/com/couchbase/lite/Result.java

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -523,13 +523,27 @@ public String toJSON() throws CouchbaseLiteException {
523523
public Iterator<String> iterator() { return getKeys().iterator(); }
524524

525525
//---------------------------------------------
526-
// private access
526+
// package access -- for use by QueryExtensions.kt
527527
//---------------------------------------------
528528

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

531532
@NonNull
532-
private List<String> getColumnNames() { return context.getResultSet().getColumnNames(); }
533+
List<String> getColumnNames() { return context.getResultSet().getColumnNames(); }
534+
535+
int getIndexForKey(String key) {
536+
final int index = context.getResultSet().getColumnIndex(Preconditions.assertNotNull(key, "key"));
537+
if (index < 0) { return -1; }
538+
if ((missingColumns & (1L << index)) != 0) { return -1; }
539+
return (!isInBounds(index)) ? -1 : index;
540+
}
541+
542+
//---------------------------------------------
543+
// private access
544+
//---------------------------------------------
545+
546+
private int getColumnCount() { return context.getResultSet().getColumnCount(); }
533547

534548
@Nullable
535549
private Object getFleeceAt(int index) {
@@ -546,13 +560,6 @@ private FLValue getFLValueAt(int index) {
546560
return values.get(index);
547561
}
548562

549-
private int getIndexForKey(@Nullable String key) {
550-
final int index = context.getResultSet().getColumnIndex(Preconditions.assertNotNull(key, "key"));
551-
if (index < 0) { return -1; }
552-
if ((missingColumns & (1L << index)) != 0) { return -1; }
553-
return (!isInBounds(index)) ? -1 : index;
554-
}
555-
556563
@NonNull
557564
private List<FLValue> extractColumns(@NonNull FLArrayIterator columns) {
558565
final int n = getColumnCount();

common/main/java/com/couchbase/lite/internal/fleece/FLValue.java

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ public interface NativeImpl {
7373
long nAsInt(long value);
7474
float nAsFloat(long value);
7575
double nAsDouble(long value);
76-
@NonNull
76+
@Nullable
7777
String nAsString(long value);
7878
long nAsArray(long value);
7979
long nAsDict(long value);
@@ -269,7 +269,7 @@ public FLValue(@NonNull NativeImpl impl, long peer) {
269269
*
270270
* @return String
271271
*/
272-
@NonNull
272+
@Nullable
273273
public String asString() { return impl.nAsString(peer); }
274274

275275
@NonNull
@@ -325,6 +325,6 @@ public Object toJava() {
325325
<T> T withContent(@NonNull Fn.NonNullFunction<Long, T> fn) { return fn.apply(peer); }
326326

327327
@NonNull
328-
FLArray asFLArray() { return FLArray.create(impl.nAsArray(peer)); }
328+
public FLArray asFLArray() { return FLArray.create(impl.nAsArray(peer)); }
329329
}
330330

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
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

Comments
 (0)