Skip to content

Commit fed585f

Browse files
refactor(fdc): Support for entityId path extensions and hardening (#17988)
Support for reading entity ids from paths in response extension Hardening SQLite to use transactions for edits Setup schema versioning to handle future schema updates. Handle edge cases - multi-dimensional arrays, mixed type arrays, scalar arrays
1 parent fd07be0 commit fed585f

35 files changed

Lines changed: 1257 additions & 333 deletions
Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
11
#!/bin/bash
2+
3+
# Uses dart protoc_plugin version 21.1.2. There are compilation issues with newer plugin versions.
4+
# https://github.com/google/protobuf.dart/releases/tag/protoc_plugin-v21.1.2
5+
# Run `pub global activate protoc_plugin 21.1.2`
6+
27
rm -rf lib/src/generated
38
mkdir lib/src/generated
4-
protoc --dart_out=grpc:lib/src/generated -I./protos/firebase -I./protos/google connector_service.proto google/protobuf/struct.proto graphql_error.proto --proto_path=./protos
9+
protoc --dart_out=grpc:lib/src/generated -I./protos/firebase -I./protos/google connector_service.proto google/protobuf/struct.proto google/protobuf/duration.proto graphql_error.proto graphql_response_extensions.proto --proto_path=./protos

packages/firebase_data_connect/firebase_data_connect/lib/src/cache/cache_manager.dart renamed to packages/firebase_data_connect/firebase_data_connect/lib/src/cache/cache.dart

Lines changed: 27 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,13 @@ class Cache {
5050
Stream<Set<String>> get impactedQueries => _impactedQueryController.stream;
5151

5252
String _constructCacheIdentifier() {
53-
final rawIdentifier =
54-
'${_settings.storage}-${dataConnect.app.options.projectId}-${dataConnect.app.name}-${dataConnect.connectorConfig.serviceId}-${dataConnect.connectorConfig.connector}-${dataConnect.connectorConfig.location}-${dataConnect.auth?.currentUser?.uid ?? 'anon'}-${dataConnect.transport.transportOptions.host}';
55-
return convertToSha256(rawIdentifier);
53+
final rawPrefix =
54+
'${_settings.storage}-${dataConnect.app.options.projectId}-${dataConnect.app.name}-${dataConnect.connectorConfig.serviceId}-${dataConnect.connectorConfig.connector}-${dataConnect.connectorConfig.location}-${dataConnect.transport.transportOptions.host}';
55+
final prefixSha = convertToSha256(rawPrefix);
56+
final rawSuffix = dataConnect.auth?.currentUser?.uid ?? 'anon';
57+
final suffixSha = convertToSha256(rawSuffix);
58+
59+
return '$prefixSha-$suffixSha';
5660
}
5761

5862
void _initializeProvider() {
@@ -92,31 +96,41 @@ class Cache {
9296
return;
9397
}
9498

95-
final dehydrationResult = await _resultTreeProcessor.dehydrate(
96-
queryId, serverResponse.data, _cacheProvider!);
99+
final Map<DataConnectPath, PathMetadata> paths =
100+
serverResponse.extensions != null
101+
? ExtensionResponse.fromJson(serverResponse.extensions!)
102+
.flattenPathMetadata()
103+
: {};
104+
105+
final dehydrationResult = await _resultTreeProcessor.dehydrateResults(
106+
queryId, serverResponse.data, _cacheProvider!, paths);
97107

98108
EntityNode rootNode = dehydrationResult.dehydratedTree;
99109
Map<String, dynamic> dehydratedMap =
100110
rootNode.toJson(mode: EncodingMode.dehydrated);
101111

102112
// if we have server ttl, that overrides maxAge from cacheSettings
103-
Duration ttl =
104-
serverResponse.ttl != null ? serverResponse.ttl! : _settings.maxAge;
113+
Duration ttl = serverResponse.extensions != null &&
114+
serverResponse.extensions!['ttl'] != null
115+
? Duration(seconds: serverResponse.extensions!['ttl'] as int)
116+
: (serverResponse.ttl ?? _settings.maxAge);
117+
105118
final resultTree = ResultTree(
106119
data: dehydratedMap,
107120
ttl: ttl,
108121
cachedAt: DateTime.now(),
109122
lastAccessed: DateTime.now());
110123

111-
_cacheProvider!.saveResultTree(queryId, resultTree);
124+
_cacheProvider!.setResultTree(queryId, resultTree);
112125

113126
Set<String> impactedQueryIds = dehydrationResult.impactedQueryIds;
114127
impactedQueryIds.remove(queryId); // remove query being cached
115128
_impactedQueryController.add(impactedQueryIds);
116129
}
117130

118131
/// Fetches a cached result.
119-
Future<Map<String, dynamic>?> get(String queryId, bool allowStale) async {
132+
Future<Map<String, dynamic>?> resultTree(
133+
String queryId, bool allowStale) async {
120134
if (_cacheProvider == null) {
121135
return null;
122136
}
@@ -137,23 +151,20 @@ class Cache {
137151
}
138152

139153
resultTree.lastAccessed = DateTime.now();
140-
_cacheProvider!.saveResultTree(queryId, resultTree);
154+
_cacheProvider!.setResultTree(queryId, resultTree);
141155

142156
EntityNode rootNode =
143157
EntityNode.fromJson(resultTree.data, _cacheProvider!);
158+
144159
Map<String, dynamic> hydratedJson =
145-
rootNode.toJson(); //default mode for toJson is hydrate
160+
await _resultTreeProcessor.hydrateResults(rootNode, _cacheProvider!);
161+
146162
return hydratedJson;
147163
}
148164

149165
return null;
150166
}
151167

152-
/// Invalidates the cache.
153-
Future<void> invalidate() async {
154-
_cacheProvider?.clear();
155-
}
156-
157168
void dispose() {
158169
_impactedQueryController.close();
159170
}

packages/firebase_data_connect/firebase_data_connect/lib/src/cache/cache_data_types.dart

Lines changed: 112 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,41 +15,144 @@
1515
import 'dart:convert';
1616

1717
import 'package:firebase_data_connect/src/cache/cache_provider.dart';
18-
import 'package:flutter/foundation.dart' show kIsWeb;
18+
import 'package:firebase_data_connect/src/common/common_library.dart';
19+
import 'package:flutter/cupertino.dart';
20+
import 'package:flutter/foundation.dart' show kIsWeb, listEquals;
1921

2022
/// Type of storage to use for the cache
2123
enum CacheStorage { persistent, memory }
2224

23-
const String kGlobalIDKey = 'cacheId';
25+
const String kGlobalIDKey = 'guid';
26+
27+
@immutable
28+
class DataConnectPath {
29+
final List<DataConnectPathSegment> components;
30+
31+
DataConnectPath([List<DataConnectPathSegment>? components])
32+
: components = components ?? [];
33+
34+
DataConnectPath appending(DataConnectPathSegment segment) {
35+
return DataConnectPath([...components, segment]);
36+
}
37+
38+
@override
39+
bool operator ==(Object other) =>
40+
identical(this, other) ||
41+
other is DataConnectPath &&
42+
runtimeType == other.runtimeType &&
43+
listEquals(components, other.components);
44+
45+
@override
46+
int get hashCode => Object.hashAll(components);
47+
48+
@override
49+
String toString() => 'DataConnectPath($components)';
50+
}
51+
52+
/// Additional information about object / field identified by a path
53+
class PathMetadata {
54+
final DataConnectPath path;
55+
final String? entityId;
56+
57+
PathMetadata({required this.path, this.entityId});
58+
59+
@override
60+
String toString() {
61+
return '$path : ${entityId ?? "null"}';
62+
}
63+
}
64+
65+
/// Represents the server response contained within the extension response
66+
class PathMetadataResponse {
67+
final List<DataConnectPathSegment> path;
68+
final String? entityId;
69+
final List<String>? entityIds;
70+
71+
PathMetadataResponse({required this.path, this.entityId, this.entityIds});
72+
73+
factory PathMetadataResponse.fromJson(Map<String, dynamic> json) {
74+
return PathMetadataResponse(
75+
path: (json['path'] as List).map(_parsePathSegment).toList(),
76+
entityId: json['entityId'] as String?,
77+
entityIds: (json['entityIds'] as List?)?.cast<String>(),
78+
);
79+
}
80+
}
81+
82+
DataConnectPathSegment _parsePathSegment(dynamic segment) {
83+
if (segment is String) {
84+
return DataConnectFieldPathSegment(segment);
85+
} else if (segment is double || segment is int) {
86+
int index = (segment is double) ? segment.toInt() : segment;
87+
return DataConnectListIndexPathSegment(index);
88+
}
89+
throw ArgumentError('Invalid path segment type: ${segment.runtimeType}');
90+
}
91+
92+
/// Represents the extension section within the server response
93+
class ExtensionResponse {
94+
final Duration? maxAge;
95+
final List<PathMetadataResponse> dataConnect;
96+
97+
ExtensionResponse({this.maxAge, required this.dataConnect});
98+
99+
factory ExtensionResponse.fromJson(Map<String, dynamic> json) {
100+
return ExtensionResponse(
101+
maxAge:
102+
json['ttl'] != null ? Duration(seconds: json['ttl'] as int) : null,
103+
dataConnect: (json['dataConnect'] as List?)
104+
?.map((e) =>
105+
PathMetadataResponse.fromJson(e as Map<String, dynamic>))
106+
.toList() ??
107+
[],
108+
);
109+
}
110+
111+
Map<DataConnectPath, PathMetadata> flattenPathMetadata() {
112+
final Map<DataConnectPath, PathMetadata> result = {};
113+
for (final pmr in dataConnect) {
114+
if (pmr.entityId != null) {
115+
final pm = PathMetadata(
116+
path: DataConnectPath(pmr.path), entityId: pmr.entityId);
117+
result[pm.path] = pm;
118+
}
119+
120+
if (pmr.entityIds != null) {
121+
for (var i = 0; i < pmr.entityIds!.length; i++) {
122+
final entityId = pmr.entityIds![i];
123+
final indexPath = DataConnectPath(pmr.path)
124+
.appending(DataConnectListIndexPathSegment(i));
125+
final pm = PathMetadata(path: indexPath, entityId: entityId);
126+
result[pm.path] = pm;
127+
}
128+
}
129+
}
130+
return result;
131+
}
132+
}
24133

25134
/// Configuration for the cache
26135
class CacheSettings {
27136
/// The type of storage to use (e.g., "persistent", "memory")
28137
final CacheStorage storage;
29138

30-
/// The maximum size of the cache in bytes
31-
final int maxSizeBytes;
32-
33139
/// Duration for which cache is used before revalidation with server
34140
final Duration maxAge;
35141

36142
// Internal const constructor
37143
const CacheSettings._internal({
38144
required this.storage,
39-
required this.maxSizeBytes,
40145
required this.maxAge,
41146
});
42147

43148
// Factory constructor to handle the logic
44149
factory CacheSettings({
45150
CacheStorage? storage,
46-
int? maxSizeBytes,
47151
Duration maxAge = Duration.zero,
48152
}) {
49153
return CacheSettings._internal(
50154
storage:
51155
storage ?? (kIsWeb ? CacheStorage.memory : CacheStorage.persistent),
52-
maxSizeBytes: maxSizeBytes ?? (kIsWeb ? 40000000 : 100000000),
53156
maxAge: maxAge,
54157
);
55158
}
@@ -203,7 +306,7 @@ class EntityNode {
203306
Map<String, dynamic> json, CacheProvider cacheProvider) {
204307
EntityDataObject? entity;
205308
if (json[kGlobalIDKey] != null) {
206-
entity = cacheProvider.getEntityDataObject(json[kGlobalIDKey]);
309+
entity = cacheProvider.getEntityData(json[kGlobalIDKey]);
207310
}
208311

209312
Map<String, dynamic>? scalars;

packages/firebase_data_connect/firebase_data_connect/lib/src/cache/cache_provider.dart

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,19 +25,16 @@ abstract class CacheProvider {
2525
Future<bool> initialize();
2626

2727
/// Stores a `ResultTree` object.
28-
void saveResultTree(String queryId, ResultTree resultTree);
28+
void setResultTree(String queryId, ResultTree resultTree);
2929

3030
/// Retrieves a `ResultTree` object.
3131
ResultTree? getResultTree(String queryId);
3232

3333
/// Stores an `EntityDataObject` object.
34-
void saveEntityDataObject(EntityDataObject edo);
34+
void updateEntityData(EntityDataObject edo);
3535

3636
/// Retrieves an `EntityDataObject` object.
37-
EntityDataObject getEntityDataObject(String guid);
38-
39-
/// Manages the cache size and eviction policies.
40-
void manageCacheSize();
37+
EntityDataObject getEntityData(String guid);
4138

4239
/// Clears all data from the cache.
4340
void clear();

packages/firebase_data_connect/firebase_data_connect/lib/src/cache/in_memory_cache_provider.dart

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import 'cache_data_types.dart';
1616
import 'cache_provider.dart';
1717

1818
/// An in-memory implementation of the `CacheProvider`.
19+
/// This is used for the web platform
1920
class InMemoryCacheProvider implements CacheProvider {
2021
final Map<String, ResultTree> _resultTrees = {};
2122
final Map<String, EntityDataObject> _edos = {};
@@ -31,12 +32,12 @@ class InMemoryCacheProvider implements CacheProvider {
3132

3233
@override
3334
Future<bool> initialize() async {
34-
// nothing to be intialized.
35+
// nothing to be intialized
3536
return true;
3637
}
3738

3839
@override
39-
void saveResultTree(String queryId, ResultTree resultTree) {
40+
void setResultTree(String queryId, ResultTree resultTree) {
4041
_resultTrees[queryId] = resultTree;
4142
}
4243

@@ -46,20 +47,15 @@ class InMemoryCacheProvider implements CacheProvider {
4647
}
4748

4849
@override
49-
void saveEntityDataObject(EntityDataObject edo) {
50+
void updateEntityData(EntityDataObject edo) {
5051
_edos[edo.guid] = edo;
5152
}
5253

5354
@override
54-
EntityDataObject getEntityDataObject(String guid) {
55+
EntityDataObject getEntityData(String guid) {
5556
return _edos.putIfAbsent(guid, () => EntityDataObject(guid: guid));
5657
}
5758

58-
@override
59-
void manageCacheSize() {
60-
// In-memory cache doesn't have a size limit in this implementation.
61-
}
62-
6359
@override
6460
void clear() {
6561
_resultTrees.clear();

0 commit comments

Comments
 (0)