diff --git a/packages/firebase_data_connect/firebase_data_connect/lib/firebase_data_connect.dart b/packages/firebase_data_connect/firebase_data_connect/lib/firebase_data_connect.dart index 835caf1b690f..a957eaa9a84d 100644 --- a/packages/firebase_data_connect/firebase_data_connect/lib/firebase_data_connect.dart +++ b/packages/firebase_data_connect/firebase_data_connect/lib/firebase_data_connect.dart @@ -39,3 +39,7 @@ export 'src/optional.dart' listDeserializer, listSerializer; export 'src/timestamp.dart' show Timestamp; +export 'src/cache/cache_data_types.dart' show CacheSettings, QueryFetchPolicy; +export 'src/cache/cache_manager.dart' show Cache; +export 'src/cache/cache_provider.dart' show CacheProvider; +export 'src/cache/sqlite_cache_provider.dart' show SQLite3CacheProvider; diff --git a/packages/firebase_data_connect/firebase_data_connect/lib/src/cache/cache_data_types.dart b/packages/firebase_data_connect/firebase_data_connect/lib/src/cache/cache_data_types.dart new file mode 100644 index 000000000000..a707790bfcd7 --- /dev/null +++ b/packages/firebase_data_connect/firebase_data_connect/lib/src/cache/cache_data_types.dart @@ -0,0 +1,302 @@ +// Copyright 2025 Google LLC +// +// 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. + +import 'dart:convert'; + +import 'package:firebase_data_connect/src/cache/cache_provider.dart'; +import 'package:flutter/foundation.dart' show kIsWeb; + +/// Type of storage to use for the cache +enum CacheStorage { persistent, memory } + +const String GlobalIDKey = 'cacheId'; + +/// Configuration for the cache +class CacheSettings { + /// The type of storage to use (e.g., "persistent", "memory") + final CacheStorage storage; + + /// The maximum size of the cache in bytes + final int maxSizeBytes; + + /// Duration for which cache is used before revalidation with server + final Duration maxAge; + + const CacheSettings( + {this.storage = kIsWeb ? CacheStorage.memory : CacheStorage.persistent, + this.maxSizeBytes = kIsWeb ? 40000000 : 100000000, + this.maxAge = Duration.zero}); +} + +/// Enum to control the fetch policy for a query +enum QueryFetchPolicy { + /// Prefer the cache, but fetch from the server if the cached data is stale + preferCache, + + /// Only fetch from the cache + cacheOnly, + + /// Only fetch from the server + serverOnly, +} + +/// Represents a cached query result. +class ResultTree { + /// The dehydrated query result, typically in a serialized format like JSON. + final Map data; + + /// The time-to-live for the cached result, indicating how long it is considered "fresh". + final Duration ttl; + + /// The timestamp when the result was cached. + final DateTime cachedAt; + + /// The timestamp when the result was last accessed. + DateTime lastAccessed; + + /// A reference to the root `EntityNode` of the dehydrated tree. + //final EntityNode rootObject; + + /// Checks if cached data is stale + bool isStale() { + if (DateTime.now().difference(cachedAt) > ttl) { + return true; // stale + } else { + return false; + } + } + + ResultTree( + {required this.data, + required this.ttl, + required this.cachedAt, + required this.lastAccessed}); + + factory ResultTree.fromJson(Map json) => ResultTree( + data: Map.from(json['data'] as Map), + ttl: Duration(microseconds: json['ttl'] as int), + cachedAt: DateTime.parse(json['cachedAt'] as String), + lastAccessed: DateTime.parse(json['lastAccessed'] as String), + ); + + Map toJson() => { + 'data': data, + 'ttl': ttl.inMicroseconds, + 'cachedAt': cachedAt.toIso8601String(), + 'lastAccessed': lastAccessed.toIso8601String(), + }; + + factory ResultTree.fromRawJson(String source) => + ResultTree.fromJson(json.decode(source) as Map); + + String toRawJson() => json.encode(toJson()); +} + +/// Target encoding mode +enum EncodingMode { hydrated, dehydrated } + +/// Represents a normalized data entity. +class EntityDataObject { + /// A globally unique identifier for the entity, provided by the server. + final String guid; + + /// A dictionary of the scalar values of the entity. + Map _serverValues = {}; + + /// A set of identifiers for the `QueryRef`s that reference this EDO. + final Set referencedFrom = {}; + + void updateServerValue(String prop, dynamic value) { + _serverValues[prop] = value; + } + + void setServerValues(Map values) { + _serverValues = values; + } + + /// Dictionary of prop-values contained in this EDO + Map fields() { + return _serverValues; + } + + EntityDataObject({required this.guid}); + + factory EntityDataObject.fromRawJson(String source) => + EntityDataObject.fromJson(json.decode(source) as Map); + + String toRawJson() => json.encode(toJson()); + + factory EntityDataObject.fromJson(Map json) => + EntityDataObject( + guid: json[GlobalIDKey] as String, + ).._serverValues = + Map.from(json['_serverValues'] as Map); + + Map toJson() => { + GlobalIDKey: guid, + '_serverValues': _serverValues, + }; +} + +/// A tree-like data structure that represents the dehydrated or hydrated query result. +class EntityNode { + /// A reference to an `EntityDataObject`. + final EntityDataObject? entity; + + /// A dictionary of scalar values (if the node does not represent a normalized entity). + final Map? scalarValues; + static const String scalarsKey = 'scalars'; + + /// A dictionary of references to other `EntityNode`s (for nested objects). + final Map? nestedObjects; + static const String objectsKey = 'objects'; + + /// A dictionary of lists of other `EntityNode`s (for arrays of objects). + final Map>? nestedObjectLists; + static const String listsKey = 'lists'; + + EntityNode( + {this.entity, + this.scalarValues, + this.nestedObjects, + this.nestedObjectLists}); + + factory EntityNode.fromJson( + Map json, CacheProvider cacheProvider) { + EntityDataObject? entity = null; + if (json[GlobalIDKey] != null) { + entity = cacheProvider.getEntityDataObject(json[GlobalIDKey]); + } + + Map? scalars = null; + if (json[scalarsKey] != null) { + scalars = json[scalarsKey]; + } + + Map? objects; + if (json[objectsKey] != null) { + Map srcObjMap = json[objectsKey] as Map; + objects = {}; + srcObjMap.forEach((key, value) { + Map objValue = value as Map; + EntityNode node = EntityNode.fromJson(objValue, cacheProvider); + objects?[key] = node; + }); + } + + Map>? objLists; + if (json[listsKey] != null) { + Map srcListMap = json[listsKey] as Map; + objLists = {}; + srcListMap.forEach((key, value) { + List enodeList = []; + List jsonList = value as List; + jsonList.forEach((jsonObj) { + Map jmap = jsonObj as Map; + EntityNode en = EntityNode.fromJson(jmap, cacheProvider); + enodeList.add(en); + }); + objLists?[key] = enodeList; + }); + } + return EntityNode( + entity: entity, + scalarValues: scalars, + nestedObjects: objects, + nestedObjectLists: objLists); + } + +/* + factory EntityNode.fromJson(Map json, CacheProvider cacheProvider) => EntityNode( + entity: json[GlobalIDKey] == null + ? null + : cacheProvider.getEntityDataObject(json[GlobalIDKey]), + scalarValues: json['scalars'] == null + ? null + : Map.from(json['scalars'] as Map), + nestedObjects: json['objects'] == null + ? null + : (json['objects'] as Map).map( + (k, e) => MapEntry( + k, EntityNode.fromJson(e as Map, cacheProvider))), + nestedObjectLists: json['lists'] == null + ? null + : (json['lists'] as Map).map((k, e) => + MapEntry( + k, + List.from((e as List).map((x) => + EntityNode.fromJson(x as Map, cacheProvider))))), + ); + */ + + Map toJson({EncodingMode mode = EncodingMode.hydrated}) { + Map jsonData = {}; + if (mode == EncodingMode.hydrated) { + if (entity != null) { + jsonData.addAll(entity!.fields()); + } + + if (scalarValues != null) { + jsonData.addAll(scalarValues!); + } + + if (nestedObjects != null) { + nestedObjects!.forEach((key, edo) { + jsonData[key] = edo.toJson(mode: mode); + }); + } + + if (nestedObjectLists != null) { + nestedObjectLists!.forEach((key, edoList) { + List> jsonList = []; + edoList.forEach((edo) { + jsonList.add(edo.toJson(mode: mode)); + }); + jsonData[key] = jsonList; + }); + } + } // if hydrated + else if (mode == EncodingMode.dehydrated) { + // encode the guid so we can extract the EntityDataObject + if (entity != null) { + jsonData[GlobalIDKey] = entity!.guid; + } + + if (scalarValues != null) { + jsonData[scalarsKey] = scalarValues; + } + + if (nestedObjects != null) { + Map nestedObjectsJson = {}; + nestedObjects!.forEach((key, edo) { + nestedObjectsJson[key] = edo.toJson(mode: mode); + }); + jsonData[objectsKey] = nestedObjectsJson; + } + + if (nestedObjectLists != null) { + Map nestedObjectListsJson = {}; + nestedObjectLists!.forEach((key, edoList) { + List> jsonList = []; + edoList.forEach((edo) { + jsonList.add(edo.toJson(mode: mode)); + }); + nestedObjectListsJson[key] = jsonList; + }); + jsonData[listsKey] = nestedObjectListsJson; + } + } + return jsonData; + } +} diff --git a/packages/firebase_data_connect/firebase_data_connect/lib/src/cache/cache_manager.dart b/packages/firebase_data_connect/firebase_data_connect/lib/src/cache/cache_manager.dart new file mode 100644 index 000000000000..966d4e9174a7 --- /dev/null +++ b/packages/firebase_data_connect/firebase_data_connect/lib/src/cache/cache_manager.dart @@ -0,0 +1,172 @@ +// Copyright 2025 Google LLC +// +// 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. + +import 'dart:async'; +import 'dart:convert'; +import 'dart:developer' as developer; + +import 'package:firebase_auth/firebase_auth.dart'; +import 'package:firebase_data_connect/firebase_data_connect.dart'; +import 'package:firebase_data_connect/src/cache/in_memory_cache_provider.dart'; +import 'package:firebase_data_connect/src/cache/sqlite_cache_provider.dart'; +import 'package:flutter/foundation.dart'; + +import '../common/common_library.dart'; + +import 'cache_data_types.dart'; +import 'result_tree_processor.dart'; + +/// The central component of the caching system. +class Cache { + CacheSettings _settings; + CacheProvider? _cacheProvider; + FirebaseDataConnect dataConnect; + final ResultTreeProcessor _resultTreeProcessor = ResultTreeProcessor(); + final _impactedQueryController = StreamController>.broadcast(); + Future? providerInitialization; + + factory Cache(CacheSettings settings, FirebaseDataConnect dataConnect) { + Cache c = Cache._internal(settings, dataConnect); + + c._initializeProvider(); + c._listenForAuthChanges(); + + return c; + } + + Cache._internal(this._settings, this.dataConnect); + + /// Stream of impacted query IDs. + Stream> get impactedQueries => _impactedQueryController.stream; + + String _contructCacheIdentifier() { + return '${_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}'; + } + + void _initializeProvider() { + String identifier = _contructCacheIdentifier(); + if (_cacheProvider != null && _cacheProvider?.identifier() == identifier) { + return; + } + + if (kIsWeb) { + // change this once we support persistence for web + _cacheProvider = InMemoryCacheProvider(identifier); + providerInitialization = _cacheProvider?.initialize(); + return; + } + + switch (_settings.storage) { + case CacheStorage.memory: + _cacheProvider = SQLite3CacheProvider(identifier, memory: true); + case CacheStorage.persistent: + _cacheProvider = SQLite3CacheProvider(identifier); + } + providerInitialization = _cacheProvider?.initialize(); + } + + void _listenForAuthChanges() { + if (dataConnect.auth == null) { + developer.log('Not listening for auth changes since no auth instance in data connect'); + return; + } + + dataConnect.auth! + .authStateChanges() + .listen((User? user) { + _initializeProvider(); + }); + } + + /// Caches a server response. + Future update(String queryId, ServerResponse serverResponse) async { + if (_cacheProvider == null) { + developer.log('cache update: no provider available'); + return; + } + + // we have a provider lets ensure its initialized + bool? initialized = await providerInitialization; + if (initialized == null || initialized == false) { + developer.log('CacheProvider not initialized. Cache not functional'); + return; + } + + final dehydrationResult = await _resultTreeProcessor.dehydrate( + queryId, serverResponse.data, _cacheProvider!); + + EntityNode rootNode = dehydrationResult.dehydratedTree; + String dehydratedJson = + jsonEncode(rootNode.toJson(mode: EncodingMode.dehydrated)); + + Duration ttl = serverResponse.ttl != null + ? serverResponse.ttl! + : Duration(seconds: 10); + final resultTree = ResultTree( + data: rootNode.toJson( + mode: EncodingMode + .dehydrated), // Storing the original response for now + ttl: ttl, // Default TTL + cachedAt: DateTime.now(), + lastAccessed: DateTime.now()); + + _cacheProvider!.saveResultTree(queryId, resultTree); + + _impactedQueryController.add(dehydrationResult.impactedQueryIds); + } + + /// Fetches a cached result. + Future?> get(String queryId, bool allowStale) async { + if (_cacheProvider == null) { + return null; + } + + // we have a provider lets ensure its initialized + bool? initialized = await providerInitialization; + if (initialized == null || initialized == false) { + developer.log('CacheProvider not initialized. Cache not functional'); + return null; + } + + final resultTree = _cacheProvider!.getResultTree(queryId); + + if (resultTree != null) { + // Simple TTL check + if (resultTree.isStale() && !allowStale) { + developer.log('getCache result is stale and allowStale is false'); + return null; + } + + resultTree.lastAccessed = DateTime.now(); + _cacheProvider!.saveResultTree(queryId, resultTree); + + EntityNode rootNode = + EntityNode.fromJson(resultTree.data, _cacheProvider!); + Map hydratedJson = + rootNode.toJson(); //default mode for toJson is hydrate + return hydratedJson; + } + + return null; + } + + /// Invalidates the cache. + Future invalidate() async { + _cacheProvider?.clear(); + } + + void dispose() { + _impactedQueryController.close(); + } +} diff --git a/packages/firebase_data_connect/firebase_data_connect/lib/src/cache/cache_provider.dart b/packages/firebase_data_connect/firebase_data_connect/lib/src/cache/cache_provider.dart new file mode 100644 index 000000000000..9f65aa127820 --- /dev/null +++ b/packages/firebase_data_connect/firebase_data_connect/lib/src/cache/cache_provider.dart @@ -0,0 +1,44 @@ +// Copyright 2025 Google LLC +// +// 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. + +import 'cache_data_types.dart'; + +/// An interface that defines the contract for the underlying storage mechanism. +/// +/// This allows for different storage implementations to be used (e.g., in-memory, SQLite, IndexedDB). +abstract class CacheProvider { + /// Identifier for this provider + String identifier(); + + /// Initialize the provider async + Future initialize(); + + /// Stores a `ResultTree` object. + void saveResultTree(String queryId, ResultTree resultTree); + + /// Retrieves a `ResultTree` object. + ResultTree? getResultTree(String queryId); + + /// Stores an `EntityDataObject` object. + void saveEntityDataObject(EntityDataObject edo); + + /// Retrieves an `EntityDataObject` object. + EntityDataObject getEntityDataObject(String guid); + + /// Manages the cache size and eviction policies. + void manageCacheSize(); + + /// Clears all data from the cache. + void clear(); +} diff --git a/packages/firebase_data_connect/firebase_data_connect/lib/src/cache/in_memory_cache_provider.dart b/packages/firebase_data_connect/firebase_data_connect/lib/src/cache/in_memory_cache_provider.dart new file mode 100644 index 000000000000..7a593da9bd9f --- /dev/null +++ b/packages/firebase_data_connect/firebase_data_connect/lib/src/cache/in_memory_cache_provider.dart @@ -0,0 +1,75 @@ +// Copyright 2025 Google LLC +// +// 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. + +import 'cache_data_types.dart'; +import 'cache_provider.dart'; + +/// An in-memory implementation of the `CacheProvider`. +class InMemoryCacheProvider implements CacheProvider { + final Map _resultTrees = {}; + final Map _edos = {}; + + final String cacheIdentifier; + + InMemoryCacheProvider(this.cacheIdentifier); + + @override + String identifier() { + return cacheIdentifier; + } + + @override + Future initialize() async { + // nothing to be intialized + return true; + } + + @override + void saveResultTree(String queryId, ResultTree resultTree) { + _resultTrees[queryId] = resultTree; + } + + @override + ResultTree? getResultTree(String queryId) { + return _resultTrees[queryId]; + } + + @override + void saveEntityDataObject(EntityDataObject edo) { + _edos[edo.guid] = edo; + } + + @override + EntityDataObject getEntityDataObject(String guid) { + EntityDataObject? edo = _edos[guid]; + if (edo != null) { + return edo; + } else { + edo = EntityDataObject(guid: guid); + _edos[guid] = edo; + return edo; + } + } + + @override + void manageCacheSize() { + // In-memory cache doesn't have a size limit in this implementation. + } + + @override + void clear() { + _resultTrees.clear(); + _edos.clear(); + } +} diff --git a/packages/firebase_data_connect/firebase_data_connect/lib/src/cache/result_tree_processor.dart b/packages/firebase_data_connect/firebase_data_connect/lib/src/cache/result_tree_processor.dart new file mode 100644 index 000000000000..33220e8893f6 --- /dev/null +++ b/packages/firebase_data_connect/firebase_data_connect/lib/src/cache/result_tree_processor.dart @@ -0,0 +1,175 @@ +// Copyright 2025 Google LLC +// +// 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. + +import '../common/common_library.dart'; +import 'cache_data_types.dart'; +import 'cache_provider.dart'; + +import 'dart:convert'; + +class DehydrationResult { + final EntityNode dehydratedTree; + final Set impactedQueryIds; + + DehydrationResult(this.dehydratedTree, this.impactedQueryIds); +} + +/// Responsible for the "dehydration" and "hydration" processes. +class ResultTreeProcessor { + /// Takes a server response, traverses the data, creates or updates `EntityDataObject`s, + /// and builds a dehydrated `EntityNode` tree. + Future dehydrate(String queryId, + Map serverResponse, CacheProvider cacheProvider) async { + final impactedQueryIds = {}; + + Map jsonData = serverResponse; + if (serverResponse.containsKey('data')) { + jsonData = serverResponse['data']; + } + final rootNode = + _dehydrateNode(queryId, jsonData, cacheProvider, impactedQueryIds); + + return DehydrationResult(rootNode, impactedQueryIds); + } + + EntityNode _dehydrateNode(String queryId, dynamic data, + CacheProvider cacheProvider, Set impactedQueryIds) { + if (data is Map) { + if (data.containsKey(GlobalIDKey)) { + final guid = data[GlobalIDKey] as String; + + final serverValues = {}; + final nestedObjects = {}; + final nestedObjectLists = >{}; + + for (final entry in data.entries) { + final key = entry.key; + final value = entry.value; + + if (value is Map) { + EntityNode en = + _dehydrateNode(queryId, value, cacheProvider, impactedQueryIds); + nestedObjects[key] = en; + } else if (value is List) { + final nodeList = []; + for (final item in value) { + nodeList.add(_dehydrateNode( + queryId, item, cacheProvider, impactedQueryIds)); + } + nestedObjectLists[key] = nodeList; + } else { + serverValues[key] = value; + } + } + + final existingEdo = cacheProvider.getEntityDataObject(guid); + existingEdo.referencedFrom.add(queryId); + impactedQueryIds.addAll(existingEdo.referencedFrom); + existingEdo.setServerValues(serverValues); + cacheProvider.saveEntityDataObject(existingEdo); + + return EntityNode( + entity: existingEdo, + nestedObjects: nestedObjects, + nestedObjectLists: nestedObjectLists); + } else { + // GlobalID check + final scalarValues = {}; + final nestedObjects = {}; + final nestedObjectLists = >{}; + + for (final entry in data.entries) { + final key = entry.key; + final value = entry.value; + + if (value is Map) { + nestedObjects[key] = + _dehydrateNode(queryId, value, cacheProvider, impactedQueryIds); + } else if (value is List) { + final nodeList = []; + for (final item in value) { + nodeList.add(_dehydrateNode( + queryId, item, cacheProvider, impactedQueryIds)); + } + nestedObjectLists[key] = nodeList; + } else { + scalarValues[key] = value; + } + } + + return EntityNode( + scalarValues: scalarValues, + nestedObjects: nestedObjects, + nestedObjectLists: nestedObjectLists); + } + } else { + throw DataConnectError(DataConnectErrorCode.codecFailed, + 'Unexpected object type while caching'); + } + } + + /// Takes a dehydrated `EntityNode` tree, fetches the corresponding `EntityDataObject`s + /// from the `CacheProvider`, and reconstructs the original data structure. + Future> hydrate( + EntityNode dehydratedTree, CacheProvider cacheProvider) async { + return await _hydrateNode(dehydratedTree, cacheProvider) + as Map; + } + + Future _hydrateNode( + EntityNode node, CacheProvider cacheProvider) async { + if (node.entity != null) { + final edo = cacheProvider.getEntityDataObject(node.entity!.guid); + final data = Map.from(edo.fields()); + + if (node.nestedObjects != null) { + for (final entry in node.nestedObjects!.entries) { + data[entry.key] = await _hydrateNode(entry.value, cacheProvider); + } + } + + if (node.nestedObjectLists != null) { + for (final entry in node.nestedObjectLists!.entries) { + final list = []; + for (final item in entry.value) { + list.add(await _hydrateNode(item, cacheProvider)); + } + data[entry.key] = list; + } + } + + return data; + } else if (node.scalarValues != null) { + if (node.scalarValues!.containsKey('value')) { + return node.scalarValues!['value']; + } + return node.scalarValues; + } else if (node.nestedObjects != null) { + final data = {}; + for (final entry in node.nestedObjects!.entries) { + data[entry.key] = await _hydrateNode(entry.value, cacheProvider); + } + return data; + } else if (node.nestedObjectLists != null && + node.nestedObjectLists!.containsKey('list')) { + final list = []; + for (final item in node.nestedObjectLists!['list']!) { + list.add(await _hydrateNode(item, cacheProvider)); + } + return list; + } else { + return {}; + } + } +} diff --git a/packages/firebase_data_connect/firebase_data_connect/lib/src/cache/sqlite_cache_provider.dart b/packages/firebase_data_connect/firebase_data_connect/lib/src/cache/sqlite_cache_provider.dart new file mode 100644 index 000000000000..f11eb9f097ab --- /dev/null +++ b/packages/firebase_data_connect/firebase_data_connect/lib/src/cache/sqlite_cache_provider.dart @@ -0,0 +1,120 @@ +// Copyright 2025 Google LLC +// +// 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. + +import 'package:firebase_data_connect/src/cache/cache_provider.dart'; +import 'package:firebase_data_connect/src/cache/cache_data_types.dart'; +import 'package:path/path.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:sqlite3/sqlite3.dart'; +import 'dart:developer' as developer; + +class SQLite3CacheProvider implements CacheProvider { + late final Database _db; + final String _identifier; + final bool memory; + + SQLite3CacheProvider(this._identifier, {this.memory = false}); + + @override + Future initialize() async { + try { + if (memory) { + _db = sqlite3.open(':memory:'); + } else { + final dbPath = await getApplicationDocumentsDirectory(); + final path = join(dbPath.path, '{$_identifier}.db'); + _db = sqlite3.open(path); + } + _createTables(); + return true; + } catch (e) { + developer.log('Error initializing SQLiteProvider $e'); + return false; + } + } + + void _createTables() { + _db.execute(''' + CREATE TABLE IF NOT EXISTS result_trees ( + query_id TEXT PRIMARY KEY, + result_tree TEXT + ); + '''); + _db.execute(''' + CREATE TABLE IF NOT EXISTS entity_data_objects ( + guid TEXT PRIMARY KEY, + entity_data_object TEXT + ); + '''); + } + + @override + String identifier() { + return _identifier; + } + + @override + void clear() { + _db.execute('DELETE FROM result_trees'); + _db.execute('DELETE FROM entity_data_objects'); + } + + @override + EntityDataObject getEntityDataObject(String guid) { + final resultSet = _db.select( + 'SELECT entity_data_object FROM entity_data_objects WHERE guid = ?', + [guid], + ); + if (resultSet.isEmpty) { + // not found lets create an empty one. + EntityDataObject edo = EntityDataObject(guid: guid); + return edo; + } + return EntityDataObject.fromRawJson( + resultSet.first['entity_data_object'] as String); + } + + @override + ResultTree? getResultTree(String queryId) { + final resultSet = _db.select( + 'SELECT result_tree FROM result_trees WHERE query_id = ?', + [queryId], + ); + if (resultSet.isEmpty) { + return null; + } + return ResultTree.fromRawJson(resultSet.first['result_tree'] as String); + } + + @override + void manageCacheSize() { + // TODO: implement manageCacheSize + } + + @override + void saveEntityDataObject(EntityDataObject edo) { + _db.execute( + 'INSERT OR REPLACE INTO entity_data_objects (guid, entity_data_object) VALUES (?, ?)', + [edo.guid, edo.toRawJson()], + ); + } + + @override + void saveResultTree(String queryId, ResultTree resultTree) { + _db.execute( + 'INSERT OR REPLACE INTO result_trees (query_id, result_tree) VALUES (?, ?)', + [queryId, resultTree.toRawJson()], + ); + } +} diff --git a/packages/firebase_data_connect/firebase_data_connect/lib/src/common/common_library.dart b/packages/firebase_data_connect/firebase_data_connect/lib/src/common/common_library.dart index 72d30afc87c0..a4c69dedc909 100644 --- a/packages/firebase_data_connect/firebase_data_connect/lib/src/common/common_library.dart +++ b/packages/firebase_data_connect/firebase_data_connect/lib/src/common/common_library.dart @@ -13,6 +13,7 @@ // limitations under the License. import 'dart:convert'; +import 'package:crypto/crypto.dart'; import 'package:firebase_app_check/firebase_app_check.dart'; @@ -37,6 +38,14 @@ String getFirebaseClientVal(String packageVersion) { return 'flutter-fire-dc/$packageVersion'; } +String convertToSha256(String inputString) { + List bytes = utf8.encode(inputString); + Digest digest = sha256.convert(bytes); + String sha256Hash = digest.toString(); + + return sha256Hash; +} + /// Transport Options for connecting to a specific host. class TransportOptions { /// Constructor @@ -52,6 +61,15 @@ class TransportOptions { bool? isSecure; } +class ServerResponse { + final Map data; + Duration? ttl; + + ServerResponse(this.data, + {this.ttl = + const Duration(seconds: 5)}); // TODO reduce to zero after testing +} + /// Interface for transports connecting to the DataConnect backend. abstract class DataConnectTransport { /// Constructor. @@ -78,7 +96,7 @@ abstract class DataConnectTransport { String appId; /// Invokes corresponding query endpoint. - Future invokeQuery( + Future invokeQuery( String queryName, Deserializer deserializer, Serializer serializer, @@ -87,7 +105,7 @@ abstract class DataConnectTransport { ); /// Invokes corresponding mutation endpoint. - Future invokeMutation( + Future invokeMutation( String queryName, Deserializer deserializer, Serializer serializer, diff --git a/packages/firebase_data_connect/firebase_data_connect/lib/src/common/dataconnect_error.dart b/packages/firebase_data_connect/firebase_data_connect/lib/src/common/dataconnect_error.dart index bf29ba112b74..3928a9706536 100644 --- a/packages/firebase_data_connect/firebase_data_connect/lib/src/common/dataconnect_error.dart +++ b/packages/firebase_data_connect/firebase_data_connect/lib/src/common/dataconnect_error.dart @@ -15,7 +15,13 @@ part of 'common_library.dart'; /// Types of DataConnect errors that can occur. -enum DataConnectErrorCode { unavailable, unauthorized, other } +enum DataConnectErrorCode { + unavailable, + unauthorized, + cacheMiss, + codecFailed, + other +} /// Error thrown when DataConnect encounters an error. class DataConnectError extends FirebaseException { diff --git a/packages/firebase_data_connect/firebase_data_connect/lib/src/core/ref.dart b/packages/firebase_data_connect/firebase_data_connect/lib/src/core/ref.dart index 2565521caaf8..071594c21de8 100644 --- a/packages/firebase_data_connect/firebase_data_connect/lib/src/core/ref.dart +++ b/packages/firebase_data_connect/firebase_data_connect/lib/src/core/ref.dart @@ -13,22 +13,30 @@ // limitations under the License. import 'dart:async'; +import 'dart:convert'; import 'dart:developer'; import '../../firebase_data_connect.dart'; import '../common/common_library.dart'; +/// Result data source +enum DataSource { + cache, // results come from cache + server // results come from server +} + /// Result of an Operation Request (query/mutation). class OperationResult { - OperationResult(this.dataConnect, this.data, this.ref); + OperationResult(this.dataConnect, this.data, this.source, this.ref); Data data; + DataSource source; OperationRef ref; FirebaseDataConnect dataConnect; } /// Result of a query request. Created to hold extra variables in the future. class QueryResult extends OperationResult { - QueryResult(super.dataConnect, super.data, super.ref); + QueryResult(super.dataConnect, super.data, super.source, super.ref); } /// Reference to a specific query. @@ -52,7 +60,9 @@ abstract class OperationRef { FirebaseDataConnect dataConnect; - Future> execute(); + Future> execute( + {QueryFetchPolicy fetchPolicy = QueryFetchPolicy.preferCache}); + Future _shouldRetry() async { String? newToken; try { @@ -65,17 +75,89 @@ abstract class OperationRef { _lastToken = newToken; return shouldRetry; } + + // Converts a hydrated Json tree to Typed Data + Data _convertBodyJsonToData(Map bodyJson) { + List errors = bodyJson['errors'] ?? []; + final data = bodyJson['data'] ?? bodyJson; + List suberrors = errors + .map((e) => switch (e) { + {'path': List? path, 'message': String? message} => + DataConnectOperationFailureResponseErrorInfo( + (path ?? []) + .map((val) => switch (val) { + String() => DataConnectFieldPathSegment(val), + int() => DataConnectListIndexPathSegment(val), + _ => throw DataConnectError( + DataConnectErrorCode.other, + 'Incorrect type for $val') + }) + .toList(), + message ?? + (throw DataConnectError( + DataConnectErrorCode.other, 'Missing message'))), + _ => throw DataConnectError( + DataConnectErrorCode.other, 'Unable to parse JSON: $e') + }) + .toList(); + Data? decodedData; + Object? decodeError; + try { + /// The response we get is in the data field of the response + /// Once we get the data back, it's not quite json-encoded, + /// so we have to encode it and then send it to the user's deserializer. + decodedData = deserializer(jsonEncode(data)); + } catch (e) { + decodeError = e; + } + if (suberrors.isNotEmpty) { + final response = + DataConnectOperationFailureResponse(suberrors, data, decodedData); + + throw DataConnectOperationError( + DataConnectErrorCode.other, 'Failed to invoke operation: ', response); + } else { + if (decodeError != null) { + throw DataConnectError( + DataConnectErrorCode.other, 'Unable to decode data: $decodeError'); + } + if (decodedData is! Data) { + throw DataConnectError( + DataConnectErrorCode.other, + "Decoded data wasn't parsed properly. Expected $Data, got $decodedData", + ); + } + return decodedData; + } + } } -/// Tracks currently active queries, and emits events when a new query is executed. class QueryManager { - QueryManager(this.dataConnect); + QueryManager(this.dataConnect) { + if (dataConnect.cacheManager != null) { + _impactedQueriesSubscription = + dataConnect.cacheManager!.impactedQueries.listen((impactedQueryIds) { + for (final queryId in impactedQueryIds) { + final queryParts = queryId.split('-'); + final queryName = queryParts[0]; + final varsAsStr = queryParts.sublist(1).join('-'); + if (trackedQueries[queryName] != null && + trackedQueries[queryName]![varsAsStr] != null) { + final queryRef = trackedQueries[queryName]![varsAsStr]!; + queryRef.execute(fetchPolicy: QueryFetchPolicy.cacheOnly); + } + } + }); + } + } /// FirebaseDataConnect instance; FirebaseDataConnect dataConnect; + StreamSubscription? _impactedQueriesSubscription; + /// Keeps track of what queries are currently active. - Map>> trackedQueries = {}; + Map> trackedQueries = {}; bool containsQuery( String queryName, Variables variables, @@ -86,45 +168,30 @@ class QueryManager { trackedQueries[queryName]![key] != null; } - Stream addQuery( - String queryName, - Variables variables, - String varsAsStr, + Stream addQuery( + QueryRef ref, ) { - // TODO(mtewani): Replace with more stable encoder - String key = varsAsStr; + final queryName = ref.operationName; + final varsAsStr = ref.serializer(ref.variables as Variables); if (trackedQueries[queryName] == null) { - trackedQueries[queryName] = {}; + trackedQueries[queryName] = {}; } - if (trackedQueries[queryName]![key] == null) { - trackedQueries[queryName]![key] = StreamController.broadcast(); + if (trackedQueries[queryName]![varsAsStr] == null) { + trackedQueries[queryName]![varsAsStr] = ref; } - return trackedQueries[queryName]![key]!.stream; - } - Future triggerCallback( - String operationName, - String varsAsStr, - QueryRef ref, - Data? data, - Exception? error, - ) async { - String key = varsAsStr; - if (trackedQueries[operationName] == null || - trackedQueries[operationName]![key] == null) { - return; - } - // ignore: close_sinks - StreamController stream = trackedQueries[operationName]![key]!; + final streamController = + StreamController>.broadcast(); + ref + .execute() + .then((value) => streamController.add(value)) + .catchError((error) => streamController.addError(error)); - if (!stream.isClosed) { - if (error != null) { - stream.addError(error); - } else { - stream - .add(QueryResult(dataConnect, data as Data, ref)); - } - } + return streamController.stream; + } + + void dispose() { + _impactedQueriesSubscription?.cancel(); } } @@ -148,66 +215,87 @@ class QueryRef extends OperationRef { QueryManager _queryManager; - @override - Future> execute() async { - bool shouldRetry = await _shouldRetry(); - try { - QueryResult r = await _executeOperation(_lastToken); - return r; - } on DataConnectError catch (e) { - if (shouldRetry && - e.code == DataConnectErrorCode.unauthorized.toString()) { - return execute(); + Future> execute( + {QueryFetchPolicy fetchPolicy = QueryFetchPolicy.preferCache}) async { + if (dataConnect.cacheManager != null) { + switch (fetchPolicy) { + case QueryFetchPolicy.cacheOnly: + return _executeFromCache(fetchPolicy); + case QueryFetchPolicy.preferCache: + try { + return await _executeFromCache(fetchPolicy); + } catch (e) { + return _executeFromServer(); + } + case QueryFetchPolicy.serverOnly: + return _executeFromServer(); + } + } else { + return _executeFromServer(); + } + } + + String get _queryId => '$operationName-${serializer(variables as Variables)}'; + + Future> _executeFromCache( + QueryFetchPolicy fetchPolicy) async { + if (dataConnect.cacheManager == null) { + throw DataConnectError(DataConnectErrorCode.cacheMiss, 'Cache miss. No configured cache'); + } + final cacheManager = dataConnect.cacheManager!; + bool allowStale = fetchPolicy == + QueryFetchPolicy.cacheOnly; //if its cache only, we always allow stale + final cachedData = await cacheManager.get(_queryId, allowStale); + + if (cachedData != null) { + final result = QueryResult( + dataConnect, + deserializer(jsonEncode(cachedData['data'] ?? cachedData)), + DataSource.cache, + this); + return result; + } else { + if (fetchPolicy == QueryFetchPolicy.cacheOnly) { + throw DataConnectError(DataConnectErrorCode.cacheMiss, 'Cache miss'); } else { - rethrow; + throw DataConnectError( + DataConnectErrorCode.cacheMiss, 'Possible stale cache miss'); } } } - Future> _executeOperation(String? token) async { + Future> _executeFromServer() async { + bool shouldRetry = await _shouldRetry(); try { - Data data = await _transport.invokeQuery( + ServerResponse serverResponse = + await _transport.invokeQuery( operationName, deserializer, serializer, variables, - token, - ); - QueryResult res = QueryResult(dataConnect, data, this); - await _queryManager.triggerCallback( - operationName, - serializer(variables as Variables), - this, - res.data, - null, + _lastToken, ); + + if (dataConnect.cacheManager != null) { + await dataConnect.cacheManager!.update(_queryId, serverResponse); + } + Data typedData = _convertBodyJsonToData(serverResponse.data); + + QueryResult res = + QueryResult(dataConnect, typedData, DataSource.server, this); return res; - } on Exception catch (e) { - await _queryManager.triggerCallback( - operationName, - serializer(variables as Variables), - this, - null, - e, - ); - rethrow; + } on DataConnectError catch (e) { + if (shouldRetry && + e.code == DataConnectErrorCode.unauthorized.toString()) { + return _executeFromServer(); + } else { + rethrow; + } } } Stream> subscribe() { - String varsSerialized = serializer(variables as Variables); - Stream> res = _queryManager - .addQuery(operationName, variables, varsSerialized) - .cast>(); - if (_queryManager.containsQuery(operationName, variables, varsSerialized)) { - try { - execute(); - } catch (_) { - // Call to `execute` should properly pass the error to the Stream. - log('Error thrown by execute. The error will propagate via onError.'); - } - } - return res; + return _queryManager.addQuery(this).cast>(); } } @@ -229,7 +317,8 @@ class MutationRef extends OperationRef { ); @override - Future> execute() async { + Future> execute( + {QueryFetchPolicy fetchPolicy = QueryFetchPolicy.serverOnly}) async { bool shouldRetry = await _shouldRetry(); try { // Logic below is duplicated due to the fact that `executeOperation` returns @@ -239,7 +328,7 @@ class MutationRef extends OperationRef { } on DataConnectError catch (e) { if (shouldRetry && e.code == DataConnectErrorCode.unauthorized.toString()) { - return execute(); + return _executeOperation(_lastToken); } else { rethrow; } @@ -249,13 +338,17 @@ class MutationRef extends OperationRef { Future> _executeOperation( String? token, ) async { - Data data = await _transport.invokeMutation( + ServerResponse serverResponse = + await _transport.invokeMutation( operationName, deserializer, serializer, variables, token, ); - return OperationResult(dataConnect, data, this); + + Data typedData = _convertBodyJsonToData(serverResponse.data); + + return OperationResult(dataConnect, typedData, DataSource.server, this); } } diff --git a/packages/firebase_data_connect/firebase_data_connect/lib/src/firebase_data_connect.dart b/packages/firebase_data_connect/firebase_data_connect/lib/src/firebase_data_connect.dart index 11f9114e40ba..80f52e14c3e7 100644 --- a/packages/firebase_data_connect/firebase_data_connect/lib/src/firebase_data_connect.dart +++ b/packages/firebase_data_connect/firebase_data_connect/lib/src/firebase_data_connect.dart @@ -24,6 +24,9 @@ import './network/transport_library.dart' if (dart.library.io) './network/grpc_library.dart' if (dart.library.html) './network/rest_library.dart'; +import 'cache/cache_data_types.dart'; +import 'cache/cache_manager.dart'; + /// DataConnect class class FirebaseDataConnect extends FirebasePluginPlatform { /// Constructor for initializing Data Connect @@ -34,6 +37,7 @@ class FirebaseDataConnect extends FirebasePluginPlatform { this.auth, this.appCheck, CallerSDKType? sdkType, + this.cacheSettings }) : options = DataConnectOptions( app.options.projectId, connectorConfig.location, @@ -47,6 +51,9 @@ class FirebaseDataConnect extends FirebasePluginPlatform { } } + /// CacheManager + Cache? cacheManager; + /// QueryManager manages ongoing queries, and their subscriptions. late QueryManager _queryManager; @@ -73,6 +80,9 @@ class FirebaseDataConnect extends FirebasePluginPlatform { /// Data Connect specific config information ConnectorConfig connectorConfig; + /// Cache settings + CacheSettings? cacheSettings; + /// Custom transport options for connecting to the Data Connect service. @visibleForTesting TransportOptions? transportOptions; @@ -91,6 +101,13 @@ class FirebaseDataConnect extends FirebasePluginPlatform { ); } + @visibleForTesting + void checkAndInitializeCache() { + if (cacheSettings != null) { + cacheManager = Cache(cacheSettings!, this); + } + } + /// Returns a [QueryRef] object. QueryRef query( String operationName, @@ -99,6 +116,7 @@ class FirebaseDataConnect extends FirebasePluginPlatform { Variables? vars, ) { checkTransport(); + checkAndInitializeCache(); return QueryRef( this, operationName, @@ -137,6 +155,12 @@ class FirebaseDataConnect extends FirebasePluginPlatform { }) { String mappedHost = automaticHostMapping ? getMappedHost(host) : host; transportOptions = TransportOptions(mappedHost, port, isSecure); + + if (cacheManager != null) { + // dispose and clean this up. it will get reinitialized for newer QueryRefs that target the emulator. + cacheManager?.dispose(); + cacheManager = null; + } } /// Currently cached DataConnect instances. Maps from app name to ConnectorConfigStr, DataConnect. @@ -154,6 +178,7 @@ class FirebaseDataConnect extends FirebasePluginPlatform { FirebaseAppCheck? appCheck, CallerSDKType? sdkType, required ConnectorConfig connectorConfig, + CacheSettings? cacheSettings = const CacheSettings() }) { app ??= Firebase.app(); auth ??= FirebaseAuth.instanceFor(app: app); @@ -170,6 +195,7 @@ class FirebaseDataConnect extends FirebasePluginPlatform { appCheck: appCheck, connectorConfig: connectorConfig, sdkType: sdkType, + cacheSettings: cacheSettings, ); if (cachedInstances[app.name] == null) { cachedInstances[app.name] = {}; diff --git a/packages/firebase_data_connect/firebase_data_connect/lib/src/network/grpc_transport.dart b/packages/firebase_data_connect/firebase_data_connect/lib/src/network/grpc_transport.dart index 44e3ae38a713..de8217942153 100644 --- a/packages/firebase_data_connect/firebase_data_connect/lib/src/network/grpc_transport.dart +++ b/packages/firebase_data_connect/firebase_data_connect/lib/src/network/grpc_transport.dart @@ -92,7 +92,7 @@ class GRPCTransport implements DataConnectTransport { /// Invokes GPRC query endpoint. @override - Future invokeQuery( + Future invokeQuery( String queryName, Deserializer deserializer, Serializer? serializer, @@ -136,7 +136,7 @@ class GRPCTransport implements DataConnectTransport { /// Invokes GPRC mutation endpoint. @override - Future invokeMutation( + Future invokeMutation( String queryName, Deserializer deserializer, Serializer? serializer, @@ -169,8 +169,12 @@ class GRPCTransport implements DataConnectTransport { } } -Data handleResponse(CommonResponse commonResponse) { +ServerResponse handleResponse(CommonResponse commonResponse) { + log('handleResponse type ${commonResponse.data.runtimeType}'); + Map? jsond = commonResponse.data as Map?; + log('handleResponse got json data $jsond'); String jsonEncoded = jsonEncode(commonResponse.data); + if (commonResponse.errors.isNotEmpty) { Map? data = jsonDecode(jsonEncoded) as Map?; @@ -197,7 +201,13 @@ Data handleResponse(CommonResponse commonResponse) { throw DataConnectOperationError(DataConnectErrorCode.other, 'failed to invoke operation: ${response.errors}', response); } - return commonResponse.deserializer(jsonEncoded); + + // no errors - return a standard response + if (jsond != null) { + return ServerResponse(jsond!); + } else { + return ServerResponse({}); + } } /// Initializes GRPC transport for Data Connect. diff --git a/packages/firebase_data_connect/firebase_data_connect/lib/src/network/rest_transport.dart b/packages/firebase_data_connect/firebase_data_connect/lib/src/network/rest_transport.dart index bf39d32283b3..0ee037744ffd 100644 --- a/packages/firebase_data_connect/firebase_data_connect/lib/src/network/rest_transport.dart +++ b/packages/firebase_data_connect/firebase_data_connect/lib/src/network/rest_transport.dart @@ -69,7 +69,7 @@ class RestTransport implements DataConnectTransport { String appId; /// Invokes the current operation, whether its a query or mutation. - Future invokeOperation( + Future invokeOperation( String queryName, String endpoint, Deserializer deserializer, @@ -127,58 +127,7 @@ class RestTransport implements DataConnectTransport { "Received a status code of ${r.statusCode} with a message '$message'", ); } - - List errors = bodyJson['errors'] ?? []; - final data = bodyJson['data']; - List suberrors = errors - .map((e) => switch (e) { - {'path': List? path, 'message': String? message} => - DataConnectOperationFailureResponseErrorInfo( - (path ?? []) - .map((val) => switch (val) { - String() => DataConnectFieldPathSegment(val), - int() => DataConnectListIndexPathSegment(val), - _ => throw DataConnectError( - DataConnectErrorCode.other, - 'Incorrect type for $val') - }) - .toList(), - message ?? - (throw DataConnectError( - DataConnectErrorCode.other, 'Missing message'))), - _ => throw DataConnectError( - DataConnectErrorCode.other, 'Unable to parse JSON: $e') - }) - .toList(); - Data? decodedData; - Object? decodeError; - try { - /// The response we get is in the data field of the response - /// Once we get the data back, it's not quite json-encoded, - /// so we have to encode it and then send it to the user's deserializer. - decodedData = deserializer(jsonEncode(bodyJson['data'])); - } catch (e) { - decodeError = e; - } - if (suberrors.isNotEmpty) { - final response = - DataConnectOperationFailureResponse(suberrors, data, decodedData); - - throw DataConnectOperationError(DataConnectErrorCode.other, - 'Failed to invoke operation: ', response); - } else { - if (decodeError != null) { - throw DataConnectError(DataConnectErrorCode.other, - 'Unable to decode data: $decodeError'); - } - if (decodedData is! Data) { - throw DataConnectError( - DataConnectErrorCode.other, - "Decoded data wasn't parsed properly. Expected $Data, got $decodedData", - ); - } - return decodedData; - } + return ServerResponse(bodyJson); } on Exception catch (e) { if (e is DataConnectError) { rethrow; @@ -192,7 +141,7 @@ class RestTransport implements DataConnectTransport { /// Invokes query REST endpoint. @override - Future invokeQuery( + Future invokeQuery( String queryName, Deserializer deserializer, Serializer? serializer, @@ -211,7 +160,7 @@ class RestTransport implements DataConnectTransport { /// Invokes mutation REST endpoint. @override - Future invokeMutation( + Future invokeMutation( String queryName, Deserializer deserializer, Serializer? serializer, diff --git a/packages/firebase_data_connect/firebase_data_connect/lib/src/network/transport_stub.dart b/packages/firebase_data_connect/firebase_data_connect/lib/src/network/transport_stub.dart index bf3ee7b91bc7..e00c53b5bac1 100644 --- a/packages/firebase_data_connect/firebase_data_connect/lib/src/network/transport_stub.dart +++ b/packages/firebase_data_connect/firebase_data_connect/lib/src/network/transport_stub.dart @@ -49,7 +49,7 @@ class TransportStub implements DataConnectTransport { /// Stub for invoking a mutation. @override - Future invokeMutation( + Future invokeMutation( String queryName, Deserializer deserializer, Serializer? serializer, @@ -62,7 +62,7 @@ class TransportStub implements DataConnectTransport { /// Stub for invoking a query. @override - Future invokeQuery( + Future invokeQuery( String queryName, Deserializer deserializer, Serializer? serialize, diff --git a/packages/firebase_data_connect/firebase_data_connect/pubspec.yaml b/packages/firebase_data_connect/firebase_data_connect/pubspec.yaml index 8d45db10c1ab..0712a9d5973c 100644 --- a/packages/firebase_data_connect/firebase_data_connect/pubspec.yaml +++ b/packages/firebase_data_connect/firebase_data_connect/pubspec.yaml @@ -11,6 +11,7 @@ environment: flutter: '>=3.3.0' dependencies: + crypto: any firebase_app_check: ^0.4.1+2 firebase_auth: ^6.1.2 firebase_core: ^4.2.1 @@ -21,6 +22,10 @@ dependencies: http: ^1.2.1 intl: ^0.20.2 protobuf: ^3.1.0 + sqlite3: ^2.9.0 + sqlite3_flutter_libs: ^0.5.40 + path_provider: ^2.0.0 + path: ^1.9.0 dev_dependencies: build_runner: ^2.4.12 diff --git a/packages/firebase_data_connect/firebase_data_connect/test/src/cache/cache_manager_test.dart b/packages/firebase_data_connect/firebase_data_connect/test/src/cache/cache_manager_test.dart new file mode 100644 index 000000000000..7a8401fb3389 --- /dev/null +++ b/packages/firebase_data_connect/firebase_data_connect/test/src/cache/cache_manager_test.dart @@ -0,0 +1,190 @@ +// Copyright 2025 Google LLC +// +// 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. + +import 'package:firebase_core/firebase_core.dart'; +import 'package:firebase_data_connect/firebase_data_connect.dart'; +import 'package:firebase_data_connect/src/common/common_library.dart'; +import 'package:firebase_data_connect/src/cache/cache_data_types.dart'; +import 'package:firebase_data_connect/src/cache/in_memory_cache_provider.dart'; +import 'package:flutter/foundation.dart'; + +import 'package:flutter_test/flutter_test.dart'; +import 'dart:convert'; + +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; + +import '../core/ref_test.dart'; +@GenerateNiceMocks([MockSpec(), MockSpec()]) +import '../firebase_data_connect_test.mocks.dart'; + +class MockTransportOptions extends Mock implements TransportOptions {} + +class MockDataConnectTransport extends Mock implements DataConnectTransport {} + +void main() { + late MockFirebaseApp mockApp; + late MockFirebaseAuth mockAuth; + late MockConnectorConfig mockConnectorConfig; + late FirebaseDataConnect dataConnect; + + const String entityObject = ''' + {"desc":"itemDesc1","name":"itemOne", "cacheId":"123","price":4} + '''; + + const String simpleQueryResponse = ''' + {"data": {"items":[ + + {"desc":"itemDesc1","name":"itemOne", "cacheId":"123","price":4}, + {"desc":"itemDesc2","name":"itemTwo", "cacheId":"345","price":7} + + ]}} + '''; + + // query that updates the price for cacheId 123 to 11 + const String simpleQueryResponseUpdate = ''' + {"data": {"items":[ + + {"desc":"itemDesc1","name":"itemOne", "cacheId":"123","price":11}, + {"desc":"itemDesc2","name":"itemTwo", "cacheId":"345","price":7} + + ]}} + '''; + + // query two has same object as query one so should refer to same Entity. + const String simpleQueryTwoResponse = ''' + {"data": { + "item": { "desc":"itemDesc1","name":"itemOne", "cacheId":"123","price":4 } + }} + '''; + + group('Cache Provider Tests', () { + setUp(() async { + mockApp = MockFirebaseApp(); + //mockAuth = MockFirebaseAuth(); + mockConnectorConfig = MockConnectorConfig(); + + when(mockApp.options).thenReturn( + const FirebaseOptions( + apiKey: 'fake_api_key', + appId: 'fake_app_id', + messagingSenderId: 'fake_messaging_sender_id', + projectId: 'fake_project_id', + ), + ); + when(mockConnectorConfig.location).thenReturn('us-central1'); + when(mockConnectorConfig.connector).thenReturn('connector'); + when(mockConnectorConfig.serviceId).thenReturn('serviceId'); + + dataConnect = FirebaseDataConnect( + app: mockApp, + connectorConfig: mockConnectorConfig, + cacheSettings: const CacheSettings(storage: CacheStorage.memory)); + dataConnect.checkTransport(); + dataConnect.checkAndInitializeCache(); + }); + + test('Test Cache set get', () async { + if (dataConnect.cacheManager == null) { + fail('No cache available'); + } + + Cache cache = dataConnect.cacheManager!; + + Map jsonData = + jsonDecode(simpleQueryResponse) as Map; + await cache.update('itemsSimple', ServerResponse(jsonData)); + + Map? cachedData = await cache.get('itemsSimple', true); + + expect(jsonData['data'], cachedData); + }); // test set get + + test('EntityDataObject set get', () async { + CacheProvider cp = InMemoryCacheProvider('inmemprov'); + if (!kIsWeb) { + cp = SQLite3CacheProvider('testDb', memory: true); + } + await cp.initialize(); + + EntityDataObject edo = EntityDataObject(guid: '1234'); + edo.updateServerValue('name', 'test'); + edo.updateServerValue('desc', 'testDesc'); + + cp.saveEntityDataObject(edo); + EntityDataObject edo2 = cp.getEntityDataObject('1234'); + + expect(edo.fields().length, edo2.fields().length); + expect(edo.fields()['name'], edo2.fields()['name']); + }); + + test('Update shared EntityDataObject', () async { + if (dataConnect.cacheManager == null) { + fail('No cache available'); + } + Cache cache = dataConnect.cacheManager!; + + String queryOneId = 'itemsSimple'; + String queryTwoId = 'itemSimple'; + + Map jsonDataOne = + jsonDecode(simpleQueryResponse) as Map; + await cache.update(queryOneId, ServerResponse(jsonDataOne)); + + Map jsonDataTwo = + jsonDecode(simpleQueryTwoResponse) as Map; + await cache.update(queryTwoId, ServerResponse(jsonDataTwo)); + + Map jsonDataOneUpdate = + jsonDecode(simpleQueryResponseUpdate) as Map; + await cache.update(queryOneId, ServerResponse(jsonDataOneUpdate)); + // shared object should be updated. + // now reload query two from cache and check object value. + // it should be updated + + Map? jsonDataTwoUpdated = + await cache.get(queryTwoId, true); + if (jsonDataTwoUpdated == null) { + fail('No query two found in cache'); + } + + int price = jsonDataTwoUpdated['item']?['price'] as int; + + expect(price, 11); + }); // test shared EDO + + test('SQLiteProvider EntityDataObject persist', () async { + CacheProvider cp = InMemoryCacheProvider('inmemprov'); + if (!kIsWeb) { + cp = SQLite3CacheProvider('testDb', memory: true); + } + await cp.initialize(); + + String oid = '1234'; + EntityDataObject edo = cp.getEntityDataObject(oid); + + String testValue = 'testValue'; + String testProp = 'testProp'; + + edo.updateServerValue(testProp, testValue); + + cp.saveEntityDataObject(edo); + + EntityDataObject edo2 = cp.getEntityDataObject(oid); + String value = edo2.fields()[testProp]; + + expect(testValue, value); + }); + }); // test group +} //main diff --git a/packages/firebase_data_connect/firebase_data_connect/test/src/cache/cache_manager_test.mocks.dart b/packages/firebase_data_connect/firebase_data_connect/test/src/cache/cache_manager_test.mocks.dart new file mode 100644 index 000000000000..9dd8e6ef2263 --- /dev/null +++ b/packages/firebase_data_connect/firebase_data_connect/test/src/cache/cache_manager_test.mocks.dart @@ -0,0 +1,202 @@ +// Mocks generated by Mockito 5.4.6 from annotations +// in firebase_data_connect/test/src/cache/cache_manager_test.dart. +// Do not manually edit this file. + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'dart:async' as _i5; + +import 'package:firebase_core/firebase_core.dart' as _i3; +import 'package:firebase_core_platform_interface/firebase_core_platform_interface.dart' + as _i2; +import 'package:firebase_data_connect/src/common/common_library.dart' as _i6; +import 'package:mockito/mockito.dart' as _i1; +import 'package:mockito/src/dummies.dart' as _i4; + +// ignore_for_file: type=lint +// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_setters_without_getters +// ignore_for_file: comment_references +// ignore_for_file: deprecated_member_use +// ignore_for_file: deprecated_member_use_from_same_package +// ignore_for_file: implementation_imports +// ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: must_be_immutable +// ignore_for_file: prefer_const_constructors +// ignore_for_file: unnecessary_parenthesis +// ignore_for_file: camel_case_types +// ignore_for_file: subtype_of_sealed_class +// ignore_for_file: invalid_use_of_internal_member + +class _FakeFirebaseOptions_0 extends _i1.SmartFake + implements _i2.FirebaseOptions { + _FakeFirebaseOptions_0( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +/// A class which mocks [FirebaseApp]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockFirebaseApp extends _i1.Mock implements _i3.FirebaseApp { + @override + String get name => (super.noSuchMethod( + Invocation.getter(#name), + returnValue: _i4.dummyValue( + this, + Invocation.getter(#name), + ), + returnValueForMissingStub: _i4.dummyValue( + this, + Invocation.getter(#name), + ), + ) as String); + + @override + _i2.FirebaseOptions get options => (super.noSuchMethod( + Invocation.getter(#options), + returnValue: _FakeFirebaseOptions_0( + this, + Invocation.getter(#options), + ), + returnValueForMissingStub: _FakeFirebaseOptions_0( + this, + Invocation.getter(#options), + ), + ) as _i2.FirebaseOptions); + + @override + bool get isAutomaticDataCollectionEnabled => (super.noSuchMethod( + Invocation.getter(#isAutomaticDataCollectionEnabled), + returnValue: false, + returnValueForMissingStub: false, + ) as bool); + + @override + _i5.Future delete() => (super.noSuchMethod( + Invocation.method( + #delete, + [], + ), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); + + @override + _i5.Future setAutomaticDataCollectionEnabled(bool? enabled) => + (super.noSuchMethod( + Invocation.method( + #setAutomaticDataCollectionEnabled, + [enabled], + ), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); + + @override + _i5.Future setAutomaticResourceManagementEnabled(bool? enabled) => + (super.noSuchMethod( + Invocation.method( + #setAutomaticResourceManagementEnabled, + [enabled], + ), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); +} + +/// A class which mocks [ConnectorConfig]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockConnectorConfig extends _i1.Mock implements _i6.ConnectorConfig { + @override + String get location => (super.noSuchMethod( + Invocation.getter(#location), + returnValue: _i4.dummyValue( + this, + Invocation.getter(#location), + ), + returnValueForMissingStub: _i4.dummyValue( + this, + Invocation.getter(#location), + ), + ) as String); + + @override + String get connector => (super.noSuchMethod( + Invocation.getter(#connector), + returnValue: _i4.dummyValue( + this, + Invocation.getter(#connector), + ), + returnValueForMissingStub: _i4.dummyValue( + this, + Invocation.getter(#connector), + ), + ) as String); + + @override + String get serviceId => (super.noSuchMethod( + Invocation.getter(#serviceId), + returnValue: _i4.dummyValue( + this, + Invocation.getter(#serviceId), + ), + returnValueForMissingStub: _i4.dummyValue( + this, + Invocation.getter(#serviceId), + ), + ) as String); + + @override + set location(String? value) => super.noSuchMethod( + Invocation.setter( + #location, + value, + ), + returnValueForMissingStub: null, + ); + + @override + set connector(String? value) => super.noSuchMethod( + Invocation.setter( + #connector, + value, + ), + returnValueForMissingStub: null, + ); + + @override + set serviceId(String? value) => super.noSuchMethod( + Invocation.setter( + #serviceId, + value, + ), + returnValueForMissingStub: null, + ); + + @override + String toJson() => (super.noSuchMethod( + Invocation.method( + #toJson, + [], + ), + returnValue: _i4.dummyValue( + this, + Invocation.method( + #toJson, + [], + ), + ), + returnValueForMissingStub: _i4.dummyValue( + this, + Invocation.method( + #toJson, + [], + ), + ), + ) as String); +} diff --git a/packages/firebase_data_connect/firebase_data_connect/test/src/cache/result_tree_processor_test.dart b/packages/firebase_data_connect/firebase_data_connect/test/src/cache/result_tree_processor_test.dart new file mode 100644 index 000000000000..ef658323c72b --- /dev/null +++ b/packages/firebase_data_connect/firebase_data_connect/test/src/cache/result_tree_processor_test.dart @@ -0,0 +1,83 @@ +// Copyright 2025 Google LLC +// +// 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. + +import 'package:firebase_data_connect/firebase_data_connect.dart'; +import 'package:firebase_data_connect/src/cache/cache_data_types.dart'; +import 'package:firebase_data_connect/src/cache/result_tree_processor.dart'; +import 'package:firebase_data_connect/src/common/common_library.dart'; + +import 'package:flutter_test/flutter_test.dart'; +import 'dart:convert'; +import 'dart:collection'; + +import 'package:firebase_data_connect/src/cache/in_memory_cache_provider.dart'; + +void main() { + const String simpleQueryResponse = ''' + {"data": {"items":[ + + {"desc":"itemDesc1","name":"itemOne", "cacheId":"123","price":4}, + {"desc":"itemDesc2","name":"itemTwo", "cacheId":"345","price":7} + + ]}} + '''; + + // query two has same object as query one so should refer to same Entity. + const String simpleQueryResponseTwo = ''' + {"data": { + "item": { "desc":"itemDesc1","name":"itemOne", "cacheId":"123","price":4 } + }} + '''; + + group('CacheProviderTests', () { + // Dehydrate two queries sharing a single object. + // Confirm that same EntityDataObject is present in both the dehydrated queries + test('Test Dehydration - compare common GlobalIDs', () async { + ResultTreeProcessor rp = ResultTreeProcessor(); + InMemoryCacheProvider cp = InMemoryCacheProvider('inmemprovider'); + + Map jsonData = + jsonDecode(simpleQueryResponse) as Map; + DehydrationResult result = + await rp.dehydrate('itemsSimple', jsonData['data'], cp); + expect(result.dehydratedTree.nestedObjectLists?.length, 1); + expect(result.dehydratedTree.nestedObjectLists?['items']?.length, 2); + expect(result.dehydratedTree.nestedObjectLists?['items']?.first.entity, + isNotNull); + + Map jsonDataTwo = + jsonDecode(simpleQueryResponseTwo) as Map; + DehydrationResult resultTwo = + await rp.dehydrate('itemsSimpleTwo', jsonDataTwo, cp); + + List? guids = result.dehydratedTree.nestedObjectLists?['items'] + ?.map((item) => item.entity?.guid) + .where((guid) => guid != null) + .cast() + .toList(); + if (guids == null) { + fail('DehydratedTree has no GlobalIDs'); + } + + String? guidTwo = + resultTwo.dehydratedTree.nestedObjects?['item']?.entity?.guid; + if (guidTwo == null) { + fail('Second DehydratedTree has no GlobalID'); + } + + bool containsGuid = guids.contains(guidTwo); + expect(containsGuid, isTrue); + }); + }); //test group +} //main diff --git a/packages/firebase_data_connect/firebase_data_connect/test/src/common/common_library_test.dart b/packages/firebase_data_connect/firebase_data_connect/test/src/common/common_library_test.dart index 5ab20c284909..90b18aef22e8 100644 --- a/packages/firebase_data_connect/firebase_data_connect/test/src/common/common_library_test.dart +++ b/packages/firebase_data_connect/firebase_data_connect/test/src/common/common_library_test.dart @@ -12,6 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. +import 'dart:nativewrappers/_internal/vm/bin/vmservice_io.dart'; + import 'package:firebase_app_check/firebase_app_check.dart'; import 'package:firebase_data_connect/firebase_data_connect.dart'; import 'package:firebase_data_connect/src/common/common_library.dart'; @@ -144,7 +146,7 @@ class TestDataConnectTransport extends DataConnectTransport { } @override - Future invokeQuery( + Future invokeQuery( String queryName, Deserializer deserializer, Serializer? serializer, @@ -152,11 +154,11 @@ class TestDataConnectTransport extends DataConnectTransport { String? authToken, ) async { // Simulate query invocation logic here - return deserializer('{}'); + return ServerResponse({}); } @override - Future invokeMutation( + Future invokeMutation( String queryName, Deserializer deserializer, Serializer? serializer, @@ -164,6 +166,6 @@ class TestDataConnectTransport extends DataConnectTransport { String? authToken, ) async { // Simulate mutation invocation logic here - return deserializer('{}'); + return ServerResponse({}); } } diff --git a/packages/firebase_data_connect/firebase_data_connect/test/src/core/ref_test.dart b/packages/firebase_data_connect/firebase_data_connect/test/src/core/ref_test.dart index 2e871c52d85b..829c3a5683b2 100644 --- a/packages/firebase_data_connect/firebase_data_connect/test/src/core/ref_test.dart +++ b/packages/firebase_data_connect/firebase_data_connect/test/src/core/ref_test.dart @@ -45,8 +45,8 @@ void main() { final mockRef = MockOperationRef(); final mockFirebaseDataConnect = MockFirebaseDataConnect(); - final result = - OperationResult(mockFirebaseDataConnect, mockData, mockRef); + final result = OperationResult( + mockFirebaseDataConnect, mockData, DataSource.server, mockRef); expect(result.data, mockData); expect(result.ref, mockRef); @@ -60,8 +60,8 @@ void main() { final mockRef = MockOperationRef(); final mockFirebaseDataConnect = MockFirebaseDataConnect(); - final queryResult = - QueryResult(mockFirebaseDataConnect, mockData, mockRef); + final queryResult = QueryResult( + mockFirebaseDataConnect, mockData, DataSource.server, mockRef); expect(queryResult.data, mockData); expect(queryResult.ref, mockRef); @@ -81,8 +81,21 @@ void main() { test( 'addQuery should create a new StreamController if query does not exist', () { - final stream = - queryManager.addQuery('testQuery', 'variables', 'varsAsStr'); + final deserializer = (String data) => 'Deserialized Data'; + String varSerializer(Object? _) { + return 'varsAsStr'; + } + + QueryRef ref = QueryRef( + mockDataConnect, + 'testQuery', + MockDataConnectTransport(), + deserializer, + QueryManager(mockDataConnect), + varSerializer, + 'variables', + ); + final stream = queryManager.addQuery(ref); expect(queryManager.trackedQueries['testQuery'], isNotNull); expect(queryManager.trackedQueries['testQuery']!['varsAsStr'], isNotNull); diff --git a/packages/firebase_data_connect/firebase_data_connect/test/src/firebase_data_connect_test.mocks.dart b/packages/firebase_data_connect/firebase_data_connect/test/src/firebase_data_connect_test.mocks.dart index 228dc815abe0..70980f8dc04d 100644 --- a/packages/firebase_data_connect/firebase_data_connect/test/src/firebase_data_connect_test.mocks.dart +++ b/packages/firebase_data_connect/firebase_data_connect/test/src/firebase_data_connect_test.mocks.dart @@ -1,18 +1,4 @@ -// Copyright 2024 Google LLC -// -// 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. - -// Mocks generated by Mockito 5.4.4 from annotations +// Mocks generated by Mockito 5.4.6 from annotations // in firebase_data_connect/test/src/firebase_data_connect_test.dart. // Do not manually edit this file. @@ -34,10 +20,12 @@ import 'package:mockito/src/dummies.dart' as _i4; // ignore_for_file: deprecated_member_use_from_same_package // ignore_for_file: implementation_imports // ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: must_be_immutable // ignore_for_file: prefer_const_constructors // ignore_for_file: unnecessary_parenthesis // ignore_for_file: camel_case_types // ignore_for_file: subtype_of_sealed_class +// ignore_for_file: invalid_use_of_internal_member class _FakeFirebaseOptions_0 extends _i1.SmartFake implements _i2.FirebaseOptions { @@ -137,15 +125,6 @@ class MockConnectorConfig extends _i1.Mock implements _i6.ConnectorConfig { ), ) as String); - @override - set location(String? _location) => super.noSuchMethod( - Invocation.setter( - #location, - _location, - ), - returnValueForMissingStub: null, - ); - @override String get connector => (super.noSuchMethod( Invocation.getter(#connector), @@ -159,15 +138,6 @@ class MockConnectorConfig extends _i1.Mock implements _i6.ConnectorConfig { ), ) as String); - @override - set connector(String? _connector) => super.noSuchMethod( - Invocation.setter( - #connector, - _connector, - ), - returnValueForMissingStub: null, - ); - @override String get serviceId => (super.noSuchMethod( Invocation.getter(#serviceId), @@ -182,10 +152,28 @@ class MockConnectorConfig extends _i1.Mock implements _i6.ConnectorConfig { ) as String); @override - set serviceId(String? _serviceId) => super.noSuchMethod( + set location(String? value) => super.noSuchMethod( + Invocation.setter( + #location, + value, + ), + returnValueForMissingStub: null, + ); + + @override + set connector(String? value) => super.noSuchMethod( + Invocation.setter( + #connector, + value, + ), + returnValueForMissingStub: null, + ); + + @override + set serviceId(String? value) => super.noSuchMethod( Invocation.setter( #serviceId, - _serviceId, + value, ), returnValueForMissingStub: null, ); diff --git a/packages/firebase_data_connect/firebase_data_connect/test/src/network/rest_transport_test.mocks.dart b/packages/firebase_data_connect/firebase_data_connect/test/src/network/rest_transport_test.mocks.dart index 490a28637eaf..a558635344b4 100644 --- a/packages/firebase_data_connect/firebase_data_connect/test/src/network/rest_transport_test.mocks.dart +++ b/packages/firebase_data_connect/firebase_data_connect/test/src/network/rest_transport_test.mocks.dart @@ -1,17 +1,3 @@ -// Copyright 2024 Google LLC -// -// 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. - // Mocks generated by Mockito 5.4.6 from annotations // in firebase_data_connect/test/src/network/rest_transport_test.dart. // Do not manually edit this file. @@ -45,6 +31,7 @@ import 'package:mockito/src/dummies.dart' as _i8; // ignore_for_file: unnecessary_parenthesis // ignore_for_file: camel_case_types // ignore_for_file: subtype_of_sealed_class +// ignore_for_file: invalid_use_of_internal_member class _FakeResponse_0 extends _i1.SmartFake implements _i2.Response { _FakeResponse_0( @@ -753,10 +740,10 @@ class MockFirebaseAppCheck extends _i1.Mock implements _i10.FirebaseAppCheck { ) as _i6.Stream); @override - set app(_i5.FirebaseApp? _app) => super.noSuchMethod( + set app(_i5.FirebaseApp? value) => super.noSuchMethod( Invocation.setter( #app, - _app, + value, ), returnValueForMissingStub: null, ); @@ -784,6 +771,7 @@ class MockFirebaseAppCheck extends _i1.Mock implements _i10.FirebaseAppCheck { [], { #webProvider: webProvider, + #providerWeb: providerWeb, #androidProvider: androidProvider, #appleProvider: appleProvider, #providerAndroid: providerAndroid,