Skip to content

Commit 9fc310b

Browse files
authored
dataconnect: caching: add logic to handle query result dehydration and rehydration (#7714)
1 parent 506716b commit 9fc310b

File tree

30 files changed

+5709
-22
lines changed

30 files changed

+5709
-22
lines changed

firebase-dataconnect/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
- [changed] Added public APIs for offline caching.
44
[#7716](https://github.com/firebase/firebase-android-sdk/pull/7716))
5+
- [changed] Added hydration and dehydration logic for use in offline caching.
6+
[#7714](https://github.com/firebase/firebase-android-sdk/pull/7714))
57

68
# 17.1.3
79

firebase-dataconnect/src/main/kotlin/com/google/firebase/dataconnect/DataConnectPathSegment.kt

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@
1616

1717
package com.google.firebase.dataconnect
1818

19+
import google.firebase.dataconnect.proto.kotlinsdk.EntityPath as EntityPathProto
20+
import google.firebase.dataconnect.proto.kotlinsdk.FieldOrListIndex as FieldOrListIndexProto
21+
1922
/** The "segment" of a path to a field in the response data. */
2023
public sealed interface DataConnectPathSegment {
2124

@@ -59,6 +62,10 @@ internal typealias DataConnectPath = List<DataConnectPathSegment>
5962

6063
internal typealias MutableDataConnectPath = MutableList<DataConnectPathSegment>
6164

65+
internal fun emptyDataConnectPath(): DataConnectPath = emptyList()
66+
67+
internal fun emptyMutableDataConnectPath(): MutableDataConnectPath = mutableListOf()
68+
6269
internal fun <T : DataConnectPathSegment> List<T>.toPathString(): String = buildString {
6370
appendPathStringTo(this)
6471
}
@@ -128,3 +135,60 @@ internal fun List<DataConnectPathSegment>.withAddedPathSegment(
128135
addAll(this@withAddedPathSegment)
129136
add(pathSegment)
130137
}
138+
139+
internal object DataConnectPathComparator : Comparator<DataConnectPath> {
140+
override fun compare(o1: DataConnectPath, o2: DataConnectPath): Int {
141+
val size = o1.size.coerceAtMost(o2.size)
142+
repeat(size) {
143+
val segmentComparisonResult = DataConnectPathSegmentComparator.compare(o1[it], o2[it])
144+
if (segmentComparisonResult != 0) {
145+
return segmentComparisonResult
146+
}
147+
}
148+
return o1.size.compareTo(o2.size)
149+
}
150+
}
151+
152+
internal object DataConnectPathSegmentComparator : Comparator<DataConnectPathSegment> {
153+
override fun compare(o1: DataConnectPathSegment, o2: DataConnectPathSegment): Int =
154+
when (o1) {
155+
is DataConnectPathSegment.Field ->
156+
when (o2) {
157+
is DataConnectPathSegment.Field -> o1.field.compareTo(o2.field)
158+
is DataConnectPathSegment.ListIndex -> -1
159+
}
160+
is DataConnectPathSegment.ListIndex ->
161+
when (o2) {
162+
is DataConnectPathSegment.Field -> 1
163+
is DataConnectPathSegment.ListIndex -> o1.index.compareTo(o2.index)
164+
}
165+
}
166+
}
167+
168+
internal fun DataConnectPath.toEntityPathProto(): EntityPathProto {
169+
val builder = EntityPathProto.newBuilder()
170+
forEach { pathSegment -> builder.addSegments(pathSegment.toFieldOrListIndexProto()) }
171+
return builder.build()
172+
}
173+
174+
internal fun EntityPathProto.toDataConnectPath(): DataConnectPath =
175+
List(segmentsCount) { getSegments(it).toDataConnectPathSegment() }
176+
177+
internal fun DataConnectPathSegment.toFieldOrListIndexProto(): FieldOrListIndexProto {
178+
val builder = FieldOrListIndexProto.newBuilder()
179+
when (this) {
180+
is DataConnectPathSegment.Field -> builder.setField(field)
181+
is DataConnectPathSegment.ListIndex -> builder.setListIndex(index)
182+
}
183+
return builder.build()
184+
}
185+
186+
internal fun FieldOrListIndexProto.toDataConnectPathSegment(): DataConnectPathSegment =
187+
when (kindCase) {
188+
FieldOrListIndexProto.KindCase.FIELD -> DataConnectPathSegment.Field(field)
189+
FieldOrListIndexProto.KindCase.LIST_INDEX -> DataConnectPathSegment.ListIndex(listIndex)
190+
FieldOrListIndexProto.KindCase.KIND_NOT_SET ->
191+
throw IllegalArgumentException(
192+
"KIND_NOT_SET cannot be converted to DataConnectPathSegment [dp2pgjhkh3]"
193+
)
194+
}
Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
/*
2+
* Copyright 2026 Google LLC
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+
package com.google.firebase.dataconnect.sqlite
18+
19+
import com.google.firebase.dataconnect.DataConnectPath
20+
import com.google.firebase.dataconnect.toEntityPathProto
21+
import com.google.firebase.dataconnect.toPathString
22+
import com.google.firebase.dataconnect.util.ProtoPrune
23+
import com.google.firebase.dataconnect.util.ProtoPrune.withPrunedDescendants
24+
import com.google.firebase.dataconnect.util.ProtoUtil.toCompactString
25+
import com.google.firebase.dataconnect.withAddedListIndex
26+
import com.google.protobuf.Struct
27+
import google.firebase.dataconnect.proto.kotlinsdk.Entity as EntityProto
28+
import google.firebase.dataconnect.proto.kotlinsdk.EntityList as EntityListProto
29+
import google.firebase.dataconnect.proto.kotlinsdk.EntityOrEntityList as EntityOrEntityListProto
30+
import google.firebase.dataconnect.proto.kotlinsdk.QueryResult as QueryResultProto
31+
32+
internal typealias GetEntityIdForPathFunction = (DataConnectPath) -> String?
33+
34+
internal data class DehydratedQueryResult(
35+
val proto: QueryResultProto,
36+
val entityStructById: Map<String, Struct>,
37+
)
38+
39+
internal fun dehydrateQueryResult(
40+
queryResult: Struct,
41+
getEntityIdForPath: GetEntityIdForPathFunction? = null
42+
): DehydratedQueryResult {
43+
val protoBuilder = QueryResultProto.newBuilder()
44+
val entityById = protoBuilder.initialize(queryResult, getEntityIdForPath)
45+
return DehydratedQueryResult(protoBuilder.build(), entityById)
46+
}
47+
48+
private fun QueryResultProto.Builder.initialize(
49+
queryResult: Struct,
50+
getEntityIdForPath: GetEntityIdForPathFunction?
51+
): Map<String, Struct> {
52+
val pruneResult =
53+
if (getEntityIdForPath === null) {
54+
null
55+
} else {
56+
pruneEntities(queryResult, EntityIdMemoizer(getEntityIdForPath))
57+
}
58+
59+
if (pruneResult === null) {
60+
setStruct(queryResult)
61+
return emptyMap()
62+
}
63+
64+
setStruct(pruneResult.prunedStruct)
65+
66+
val entityStructById = mutableMapOf<String, Struct>()
67+
fun updateEntityStructByIdWithEntity(entity: EntityIdStructPair) {
68+
val (entityId, struct) = entity
69+
70+
val newStruct =
71+
if (!entityStructById.containsKey(entityId)) {
72+
struct
73+
} else {
74+
val oldStruct = entityStructById[entityId]!!
75+
if (oldStruct.fieldsMap.keys == struct.fieldsMap.keys) {
76+
struct
77+
} else {
78+
oldStruct.toBuilder().putAllFields(struct.fieldsMap).build()
79+
}
80+
}
81+
82+
entityStructById[entityId] = newStruct
83+
}
84+
85+
pruneResult.entityByPath.entries.forEach { (path, entity) ->
86+
addEntities(entity.toEntityOrEntityListProto(path))
87+
updateEntityStructByIdWithEntity(entity)
88+
}
89+
pruneResult.entityListByPath.entries.forEach { (path, entityList) ->
90+
addEntities(entityList.toEntityOrEntityListProto(path))
91+
entityList.forEach(::updateEntityStructByIdWithEntity)
92+
}
93+
94+
return entityStructById.toMap()
95+
}
96+
97+
private fun EntityIdStructPair.toEntityOrEntityListProto(
98+
path: DataConnectPath
99+
): EntityOrEntityListProto =
100+
EntityOrEntityListProto.newBuilder().run {
101+
setPath(path.toEntityPathProto())
102+
setEntity(toEntityProto())
103+
build()
104+
}
105+
106+
private fun EntityIdStructPair.toEntityProto(): EntityProto =
107+
EntityProto.newBuilder().run {
108+
setEntityId(this@toEntityProto.entityId)
109+
addAllFields(this@toEntityProto.struct.fieldsMap.keys)
110+
build()
111+
}
112+
113+
private fun List<EntityIdStructPair>.toEntityOrEntityListProto(
114+
path: DataConnectPath
115+
): EntityOrEntityListProto =
116+
EntityOrEntityListProto.newBuilder().run {
117+
setPath(path.toEntityPathProto())
118+
setEntityList(toEntityListProto())
119+
build()
120+
}
121+
122+
private fun List<EntityIdStructPair>.toEntityListProto(): EntityListProto =
123+
EntityListProto.newBuilder().run {
124+
forEach { addEntities(it.toEntityProto()) }
125+
build()
126+
}
127+
128+
internal data class EntityIdStructPair(
129+
val entityId: String,
130+
val struct: Struct,
131+
) {
132+
override fun toString() =
133+
"EntityIdStructPair(entityId=$entityId, struct=${struct.toCompactString()})"
134+
}
135+
136+
private data class PruneEntitiesResult(
137+
val prunedStruct: Struct,
138+
val entityByPath: Map<DataConnectPath, EntityIdStructPair>,
139+
val entityListByPath: Map<DataConnectPath, List<EntityIdStructPair>>,
140+
)
141+
142+
private fun pruneEntities(
143+
queryResult: Struct,
144+
entityIdByPath: EntityIdMemoizer,
145+
): PruneEntitiesResult? {
146+
val dehydratedQueryResult =
147+
queryResult.withPrunedDescendants { path, listSize ->
148+
if (listSize === null) {
149+
entityIdByPath.isEntity(path)
150+
} else {
151+
var entityCount = 0
152+
var nonEntityCount = 0
153+
repeat(listSize) {
154+
val listElementPath = path.withAddedListIndex(it)
155+
if (entityIdByPath.isEntity(listElementPath)) {
156+
entityCount++
157+
} else {
158+
nonEntityCount++
159+
}
160+
}
161+
entityCount > 0 && nonEntityCount == 0
162+
}
163+
}
164+
165+
if (dehydratedQueryResult === null) {
166+
return null
167+
}
168+
169+
val entityByPath: Map<DataConnectPath, EntityIdStructPair>
170+
val entityListByPath: Map<DataConnectPath, List<EntityIdStructPair>>
171+
172+
run {
173+
val entityByPathBuilder = mutableMapOf<DataConnectPath, EntityIdStructPair>()
174+
val entityListByPathBuilder = mutableMapOf<DataConnectPath, List<EntityIdStructPair>>()
175+
176+
dehydratedQueryResult.prunedValueByPath.entries.forEach { (path, prunedValue) ->
177+
when (prunedValue) {
178+
is ProtoPrune.PrunedStruct -> {
179+
val entityId = entityIdByPath.getInitializedEntityId(path)
180+
val entity = EntityIdStructPair(entityId, prunedValue.struct)
181+
entityByPathBuilder[path] = entity
182+
}
183+
is ProtoPrune.PrunedListValue -> {
184+
val entities =
185+
prunedValue.structs.mapIndexed { index, struct ->
186+
val entityPath = path.withAddedListIndex(index)
187+
val entityId = entityIdByPath.getInitializedEntityId(entityPath)
188+
EntityIdStructPair(entityId, struct)
189+
}
190+
entityListByPathBuilder[path] = entities
191+
}
192+
}
193+
}
194+
195+
entityByPath = entityByPathBuilder.toMap()
196+
entityListByPath = entityListByPathBuilder.toMap()
197+
}
198+
199+
return PruneEntitiesResult(dehydratedQueryResult.prunedStruct, entityByPath, entityListByPath)
200+
}
201+
202+
private class EntityIdMemoizer(private val getEntityIdForPath: GetEntityIdForPathFunction) {
203+
204+
sealed interface EntityId {
205+
206+
val entityIdOrNull: String?
207+
208+
object NotAnEntity : EntityId {
209+
override val entityIdOrNull: Nothing? = null
210+
}
211+
212+
@JvmInline
213+
value class IsAnEntity(val entityId: String) : EntityId {
214+
override val entityIdOrNull: String
215+
get() = entityId
216+
}
217+
}
218+
219+
private val entityIdByPath = mutableMapOf<DataConnectPath, EntityId>()
220+
221+
private fun ensureInitialized(path: DataConnectPath): EntityId =
222+
entityIdByPath.getOrPut(path) {
223+
getEntityIdForPath(path)?.let(EntityId::IsAnEntity) ?: EntityId.NotAnEntity
224+
}
225+
226+
fun isEntity(path: DataConnectPath): Boolean =
227+
when (ensureInitialized(path)) {
228+
is EntityId.IsAnEntity -> true
229+
EntityId.NotAnEntity -> false
230+
}
231+
232+
fun getInitializedEntityId(path: DataConnectPath): String {
233+
val memoizedValue =
234+
entityIdByPath[path]
235+
?: throw IllegalArgumentException(
236+
"entity ID for path=${path.toPathString()} is not initialized [d8a8k6bkzh]"
237+
)
238+
return when (memoizedValue) {
239+
is EntityId.IsAnEntity -> memoizedValue.entityId
240+
EntityId.NotAnEntity ->
241+
throw IllegalArgumentException("path=${path.toPathString()} is not an entity [jserv86jpk]")
242+
}
243+
}
244+
}

0 commit comments

Comments
 (0)