From 7c929213d903de3aa32ad33f780d1160d5df25d7 Mon Sep 17 00:00:00 2001 From: Aashish Patil Date: Tue, 19 Aug 2025 11:39:18 -0700 Subject: [PATCH 01/38] Initial Stub Classes --- Sources/BaseOperationRef.swift | 1 + Sources/Cache/BackingDataObject.swift | 37 ++++++++++++++++++++++ Sources/Cache/CacheConnectorConfig.swift | 27 ++++++++++++++++ Sources/Cache/CacheProvider.swift | 26 +++++++++++++++ Sources/Cache/CacheProviderType.swift | 22 +++++++++++++ Sources/Cache/EphemeralCacheProvider.swift | 28 ++++++++++++++++ Sources/Cache/ResultTreeNormalizer.swift | 20 ++++++++++++ Sources/Cache/StubDataObject.swift | 28 ++++++++++++++++ Sources/ConnectorConfig.swift | 10 +++--- Sources/Queries/QueryFetchPolicy.swift | 31 ++++++++++++++++++ Sources/{ => Queries}/QueryRef.swift | 20 +++++++++--- Sources/Queries/QueryResultSource.swift | 26 +++++++++++++++ 12 files changed, 267 insertions(+), 9 deletions(-) create mode 100644 Sources/Cache/BackingDataObject.swift create mode 100644 Sources/Cache/CacheConnectorConfig.swift create mode 100644 Sources/Cache/CacheProvider.swift create mode 100644 Sources/Cache/CacheProviderType.swift create mode 100644 Sources/Cache/EphemeralCacheProvider.swift create mode 100644 Sources/Cache/ResultTreeNormalizer.swift create mode 100644 Sources/Cache/StubDataObject.swift create mode 100644 Sources/Queries/QueryFetchPolicy.swift rename Sources/{ => Queries}/QueryRef.swift (92%) create mode 100644 Sources/Queries/QueryResultSource.swift diff --git a/Sources/BaseOperationRef.swift b/Sources/BaseOperationRef.swift index 5b8a502..c42ca10 100644 --- a/Sources/BaseOperationRef.swift +++ b/Sources/BaseOperationRef.swift @@ -17,6 +17,7 @@ import Foundation @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) public struct OperationResult: Sendable { public var data: ResultData + public let source: QueryResultSource } // notional protocol that denotes a variable. diff --git a/Sources/Cache/BackingDataObject.swift b/Sources/Cache/BackingDataObject.swift new file mode 100644 index 0000000..8c36ffb --- /dev/null +++ b/Sources/Cache/BackingDataObject.swift @@ -0,0 +1,37 @@ +// 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. + +actor BackingDataObject: Codable { + + let guid: String // globally unique id received from server + + init(guid: String) { + self.guid = guid + } + + private var serverValues: [String: AnyCodableValue] = [:] + + func updateServerValues(_ newValues: [String: AnyCodableValue]) { + self.serverValues = newValues + } + + func updateServerValue(_ key: String, _ newValue: AnyCodableValue) { + self.serverValues[key] = newValue + } + + func value(forKey key: String) -> AnyCodableValue? { + return self.serverValues[key] + } + +} diff --git a/Sources/Cache/CacheConnectorConfig.swift b/Sources/Cache/CacheConnectorConfig.swift new file mode 100644 index 0000000..e26a9a7 --- /dev/null +++ b/Sources/Cache/CacheConnectorConfig.swift @@ -0,0 +1,27 @@ +// 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. + +/// Firebase Data Connect cache is configured per Connector. +/// Specifies the cache configuration for Firebase Data Connect at a connector level +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +public struct ConnectorCacheConfig: Sendable { + public private(set) var type: CacheProviderType = .persistent // default provider is persistent type + + #if os(watchOS) + public private(set) var maxSize: Int = 40_000_000 // 40 MB + #else + public private(set) var maxSize: Int = 100_000_000 // 100 MB + #endif +} + diff --git a/Sources/Cache/CacheProvider.swift b/Sources/Cache/CacheProvider.swift new file mode 100644 index 0000000..70b5862 --- /dev/null +++ b/Sources/Cache/CacheProvider.swift @@ -0,0 +1,26 @@ +// 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. + +protocol CacheProvider: Actor { + + func resultTree(queryId: String) -> String + func setResultTree(queryId: String, data: String) + + /* + func dataObject(entityKey: String) -> BackingDataObject + func setObject(entityKey: String, object: BackingDataObject) + + func size() -> Int + */ +} diff --git a/Sources/Cache/CacheProviderType.swift b/Sources/Cache/CacheProviderType.swift new file mode 100644 index 0000000..0edbd5e --- /dev/null +++ b/Sources/Cache/CacheProviderType.swift @@ -0,0 +1,22 @@ +// 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. + + +/// Types of supported cache providers +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +public enum CacheProviderType: Sendable { + case ephemeral // cache is held in memory and not persisted to disk. + case persistent // cache is persisted to disk. This is the default type +} + diff --git a/Sources/Cache/EphemeralCacheProvider.swift b/Sources/Cache/EphemeralCacheProvider.swift new file mode 100644 index 0000000..393a955 --- /dev/null +++ b/Sources/Cache/EphemeralCacheProvider.swift @@ -0,0 +1,28 @@ +// 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. + + +actor EphemeralCacheProvider: CacheProvider { + + private var resultTreeCache: [String: String] = [:] + + func resultTree(queryId: String) -> String { + return resultTreeCache[queryId] ?? "" + } + + func setResultTree(queryId: String, data: String) { + resultTreeCache[queryId] = data + } + +} diff --git a/Sources/Cache/ResultTreeNormalizer.swift b/Sources/Cache/ResultTreeNormalizer.swift new file mode 100644 index 0000000..eccdedb --- /dev/null +++ b/Sources/Cache/ResultTreeNormalizer.swift @@ -0,0 +1,20 @@ +// 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. + +// Normalizes data in a ResultTree +struct ResultTreeNormalizer { + func normalize(_ tree: String) -> StubDataObject { + return StubDataObject() + } +} diff --git a/Sources/Cache/StubDataObject.swift b/Sources/Cache/StubDataObject.swift new file mode 100644 index 0000000..5667adf --- /dev/null +++ b/Sources/Cache/StubDataObject.swift @@ -0,0 +1,28 @@ +// 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. + +struct StubDataObject { + var backingData: BackingDataObject? + var references: [String: AnyCodableValue] = [:] +} + +extension StubDataObject: Decodable { + init (from decoder: Decoder) throws { + + } +} + +extension StubDataObject: Encodable { + +} diff --git a/Sources/ConnectorConfig.swift b/Sources/ConnectorConfig.swift index 29c98fd..5eb8062 100644 --- a/Sources/ConnectorConfig.swift +++ b/Sources/ConnectorConfig.swift @@ -16,14 +16,16 @@ import Foundation @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) public struct ConnectorConfig: Hashable, Equatable, Sendable { - public private(set) var serviceId: String - public private(set) var location: String - public private(set) var connector: String + public let serviceId: String + public let location: String + public let connector: String + public let cacheConfig: ConnectorCacheConfig? - public init(serviceId: String, location: String, connector: String) { + public init(serviceId: String, location: String, connector: String, cacheConfig: ConnectorCacheConfig? = nil) { self.serviceId = serviceId self.location = location self.connector = connector + self.cacheConfig = cacheConfig } public func hash(into hasher: inout Hasher) { diff --git a/Sources/Queries/QueryFetchPolicy.swift b/Sources/Queries/QueryFetchPolicy.swift new file mode 100644 index 0000000..b822e6e --- /dev/null +++ b/Sources/Queries/QueryFetchPolicy.swift @@ -0,0 +1,31 @@ +// 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. + +/// Policies for executing a Data Connect query. This value is passed to +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +public enum QueryFetchPolicy { + + /// default policy tries to fetch from cache if fetch is within the TTL. + /// If fetch is outside TTL it revalidates / refreshes from the server. + /// If server revalidation call fails, cached value is returned if present. + /// TTL is specified as part of the query GQL configuration. + case defaultPolicy + + /// Always attempts to return from cache. Does not reach out to server + case cache + + /// Attempts to fetch from server ignoring cache. + /// Cache is refreshed from server data after the call + case server +} diff --git a/Sources/QueryRef.swift b/Sources/Queries/QueryRef.swift similarity index 92% rename from Sources/QueryRef.swift rename to Sources/Queries/QueryRef.swift index 0dd2ca7..fa82833 100644 --- a/Sources/QueryRef.swift +++ b/Sources/Queries/QueryRef.swift @@ -72,10 +72,20 @@ struct QueryRequest: OperationRequest, Hashable, Eq public protocol QueryRef: OperationRef { // This call starts query execution and publishes data func subscribe() async throws -> AnyPublisher, Never> + + // Execute override for queries to include fetch policy + func execute(fetchPolicy: QueryFetchPolicy?) async throws -> OperationResult +} + +extension QueryRef { + public func execute() async throws -> OperationResult { + try await execute(fetchPolicy: .server) + } } @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) actor GenericQueryRef: QueryRef { + private let resultsPublisher = PassthroughSubject, Never>() @@ -101,7 +111,7 @@ actor GenericQueryRef OperationResult { + public func execute(fetchPolicy: QueryFetchPolicy? = nil) async throws -> OperationResult { let resultData = try await reloadResults() return OperationResult(data: resultData) } @@ -187,8 +197,8 @@ public class QueryRefObservableObject< /// Executes the query and returns `ResultData`. This will also update the published `data` /// variable - public func execute() async throws -> OperationResult { - let result = try await baseRef.execute() + public func execute(fetchPolicy: QueryFetchPolicy? = nil) async throws -> OperationResult { + let result = try await baseRef.execute(fetchPolicy: fetchPolicy) return result } @@ -263,8 +273,8 @@ public class QueryRefObservation< /// Executes the query and returns `ResultData`. This will also update the published `data` /// variable - public func execute() async throws -> OperationResult { - let result = try await baseRef.execute() + public func execute(fetchPolicy: QueryFetchPolicy? = nil) async throws -> OperationResult { + let result = try await baseRef.execute(fetchPolicy: fetchPolicy) return result } diff --git a/Sources/Queries/QueryResultSource.swift b/Sources/Queries/QueryResultSource.swift new file mode 100644 index 0000000..4d98b1b --- /dev/null +++ b/Sources/Queries/QueryResultSource.swift @@ -0,0 +1,26 @@ +// 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. + + +/// Indicates the source of the query results data. +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +public enum QueryResultSource: Sendable { + + /// The query results are from server + case server + + /// Query results are from cache + /// stale - indicates if cached data is within TTL or outside. + case cache(stale: Bool) +} From f56a8ccd73ab042f1565aab5a8883ef1f0a9b1df Mon Sep 17 00:00:00 2001 From: Aashish Patil Date: Wed, 20 Aug 2025 16:29:21 -0700 Subject: [PATCH 02/38] Wire up Initialization of CacheProvider --- Sources/BaseOperationRef.swift | 2 +- Sources/Cache/BackingDataObject.swift | 2 +- ...onnectorConfig.swift => CacheConfig.swift} | 27 +++-- Sources/Cache/CacheEntry.swift | 30 +++++ Sources/Cache/CacheProvider.swift | 12 +- Sources/Cache/EphemeralCacheProvider.swift | 34 +++++- Sources/Cache/StubDataObject.swift | 4 +- Sources/ConnectorConfig.swift | 4 +- Sources/DataConnect.swift | 56 ++++++++-- Sources/Internal/GrpcClient.swift | 4 +- Sources/Internal/OperationsManager.swift | 7 +- Sources/Queries/QueryRef.swift | 103 ++++++++++++++++-- 12 files changed, 244 insertions(+), 41 deletions(-) rename Sources/Cache/{CacheConnectorConfig.swift => CacheConfig.swift} (66%) create mode 100644 Sources/Cache/CacheEntry.swift diff --git a/Sources/BaseOperationRef.swift b/Sources/BaseOperationRef.swift index c42ca10..7182f68 100644 --- a/Sources/BaseOperationRef.swift +++ b/Sources/BaseOperationRef.swift @@ -16,7 +16,7 @@ import Foundation @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) public struct OperationResult: Sendable { - public var data: ResultData + public var data: ResultData? public let source: QueryResultSource } diff --git a/Sources/Cache/BackingDataObject.swift b/Sources/Cache/BackingDataObject.swift index 8c36ffb..e20858a 100644 --- a/Sources/Cache/BackingDataObject.swift +++ b/Sources/Cache/BackingDataObject.swift @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -actor BackingDataObject: Codable { +actor BackingDataObject { let guid: String // globally unique id received from server diff --git a/Sources/Cache/CacheConnectorConfig.swift b/Sources/Cache/CacheConfig.swift similarity index 66% rename from Sources/Cache/CacheConnectorConfig.swift rename to Sources/Cache/CacheConfig.swift index e26a9a7..f3ad831 100644 --- a/Sources/Cache/CacheConnectorConfig.swift +++ b/Sources/Cache/CacheConfig.swift @@ -15,13 +15,24 @@ /// Firebase Data Connect cache is configured per Connector. /// Specifies the cache configuration for Firebase Data Connect at a connector level @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public struct ConnectorCacheConfig: Sendable { - public private(set) var type: CacheProviderType = .persistent // default provider is persistent type - - #if os(watchOS) - public private(set) var maxSize: Int = 40_000_000 // 40 MB - #else - public private(set) var maxSize: Int = 100_000_000 // 100 MB - #endif +public struct CacheConfig: Sendable { + + public let type: CacheProviderType // default provider is persistent type + public let maxSize: Int + + public init(type: CacheProviderType, maxSize: Int) { + self.type = type + self.maxSize = maxSize + } + + public init() { + type = .persistent + #if os(watchOS) + maxSize = 40_000_000 + #else + maxSize = 100_000_000 + #endif + } + } diff --git a/Sources/Cache/CacheEntry.swift b/Sources/Cache/CacheEntry.swift new file mode 100644 index 0000000..db3a802 --- /dev/null +++ b/Sources/Cache/CacheEntry.swift @@ -0,0 +1,30 @@ +// 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 Foundation + +import FirebaseCore + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +struct ResultTreeEntry { + let serverTimestamp: Timestamp // Server response timestamp + let cachedAt: Date // Local time when the entry was cached / updated + let data: String // tree data + + func isStale(_ ttl: TimeInterval) -> Bool { + let now = Date() + return now.timeIntervalSince(cachedAt) > ttl + } +} + diff --git a/Sources/Cache/CacheProvider.swift b/Sources/Cache/CacheProvider.swift index 70b5862..8ceabdb 100644 --- a/Sources/Cache/CacheProvider.swift +++ b/Sources/Cache/CacheProvider.swift @@ -12,10 +12,18 @@ // See the License for the specific language governing permissions and // limitations under the License. +import Foundation + +import FirebaseCore + protocol CacheProvider: Actor { - func resultTree(queryId: String) -> String - func setResultTree(queryId: String, data: String) + var cacheConfig: CacheConfig { get } + + var cacheIdentifier: String { get } + + func resultTree(queryId: String) -> ResultTreeEntry? + func setResultTree(queryId: String, serverTimestamp: Timestamp, data: String) /* func dataObject(entityKey: String) -> BackingDataObject diff --git a/Sources/Cache/EphemeralCacheProvider.swift b/Sources/Cache/EphemeralCacheProvider.swift index 393a955..f84ab8f 100644 --- a/Sources/Cache/EphemeralCacheProvider.swift +++ b/Sources/Cache/EphemeralCacheProvider.swift @@ -12,17 +12,39 @@ // See the License for the specific language governing permissions and // limitations under the License. +import Foundation -actor EphemeralCacheProvider: CacheProvider { +import FirebaseCore + +actor EphemeralCacheProvider: CacheProvider, @preconcurrency CustomStringConvertible { + + let cacheConfig: CacheConfig - private var resultTreeCache: [String: String] = [:] + let cacheIdentifier: String - func resultTree(queryId: String) -> String { - return resultTreeCache[queryId] ?? "" + init(cacheConfig: CacheConfig, cacheIdentifier: String) { + self.cacheConfig = cacheConfig + self.cacheIdentifier = cacheIdentifier + + DataConnectLogger.debug("Initialized \(Self.Type.self) with config \(cacheConfig)") + } + + private var resultTreeCache: [String: ResultTreeEntry] = [:] + + func setResultTree(queryId: String, serverTimestamp: Timestamp, data: String) { + resultTreeCache[queryId] = .init( + serverTimestamp: serverTimestamp, + cachedAt: Date(), + data: data + ) } - func setResultTree(queryId: String, data: String) { - resultTreeCache[queryId] = data + func resultTree(queryId: String) -> ResultTreeEntry? { + return resultTreeCache[queryId] } + var description: String { + return "EphemeralCacheProvider - \(cacheIdentifier)" + } + } diff --git a/Sources/Cache/StubDataObject.swift b/Sources/Cache/StubDataObject.swift index 5667adf..02a0acc 100644 --- a/Sources/Cache/StubDataObject.swift +++ b/Sources/Cache/StubDataObject.swift @@ -24,5 +24,7 @@ extension StubDataObject: Decodable { } extension StubDataObject: Encodable { - + func encode(to encoder: Encoder) throws { + + } } diff --git a/Sources/ConnectorConfig.swift b/Sources/ConnectorConfig.swift index 5eb8062..530fb92 100644 --- a/Sources/ConnectorConfig.swift +++ b/Sources/ConnectorConfig.swift @@ -19,13 +19,11 @@ public struct ConnectorConfig: Hashable, Equatable, Sendable { public let serviceId: String public let location: String public let connector: String - public let cacheConfig: ConnectorCacheConfig? - public init(serviceId: String, location: String, connector: String, cacheConfig: ConnectorCacheConfig? = nil) { + public init(serviceId: String, location: String, connector: String) { self.serviceId = serviceId self.location = location self.connector = connector - self.cacheConfig = cacheConfig } public func hash(into hasher: inout Hasher) { diff --git a/Sources/DataConnect.swift b/Sources/DataConnect.swift index 801f77b..e7d5f54 100644 --- a/Sources/DataConnect.swift +++ b/Sources/DataConnect.swift @@ -25,6 +25,9 @@ public class DataConnect { private(set) var grpcClient: GrpcClient private var operationsManager: OperationsManager + + private(set) var cacheConfig: CacheConfig? = nil + private(set) var cacheProvider: CacheProvider? = nil private var callerSDKType: CallerSDKType = .base @@ -52,7 +55,8 @@ public class DataConnect { public class func dataConnect(app: FirebaseApp? = FirebaseApp.app(), connectorConfig: ConnectorConfig, settings: DataConnectSettings = DataConnectSettings(), - callerSDKType: CallerSDKType = .base) + callerSDKType: CallerSDKType = .base, + cacheConfig: CacheConfig? = CacheConfig()) -> DataConnect { guard let app = app else { fatalError("No Firebase App present") @@ -63,7 +67,8 @@ public class DataConnect { for: app, config: connectorConfig, settings: settings, - callerSDKType: callerSDKType + callerSDKType: callerSDKType, + cacheConfig: cacheConfig ) } @@ -88,15 +93,27 @@ public class DataConnect { connectorConfig: connectorConfig, callerSDKType: callerSDKType ) - + + if let ccfg = cacheConfig { + switch ccfg.type { + case .ephemeral, .persistent : + cacheProvider = EphemeralCacheProvider( + cacheConfig: ccfg, + cacheIdentifier: DataConnect.contructCacheIdentifier(app: app, settings: settings) + ) + } + DataConnectLogger.debug("Create cacheProvider \(self.cacheProvider)") + } + operationsManager = OperationsManager(grpcClient: grpcClient) } } + // MARK: Init init(app: FirebaseApp, connectorConfig: ConnectorConfig, settings: DataConnectSettings, - callerSDKType: CallerSDKType = .base) { + callerSDKType: CallerSDKType = .base, cacheConfig: CacheConfig? = CacheConfig()) { guard app.options.projectID != nil else { fatalError("Firebase DataConnect requires the projectID to be set in the app options") } @@ -112,7 +129,26 @@ public class DataConnect { connectorConfig: connectorConfig, callerSDKType: self.callerSDKType ) - operationsManager = OperationsManager(grpcClient: grpcClient) + + self.cacheConfig = cacheConfig + if let cacheConfig { + switch cacheConfig.type { + case .ephemeral: + self.cacheProvider = EphemeralCacheProvider( + cacheConfig: cacheConfig, + cacheIdentifier: DataConnect.contructCacheIdentifier(app: app, settings: settings) + ) + case .persistent: + // TODO: Update to SQLiteProvider once implemented + self.cacheProvider = EphemeralCacheProvider( + cacheConfig: cacheConfig, + cacheIdentifier: DataConnect.contructCacheIdentifier(app: app, settings: settings) + ) + } + DataConnectLogger.debug("Initialized cacheProvider \(self.cacheProvider)") + } + + operationsManager = OperationsManager(grpcClient: grpcClient, cacheProvider: cacheProvider) } // MARK: Operations @@ -144,6 +180,11 @@ public class DataConnect { return operationsManager.mutationRef(for: request, with: resultsDataType) } } + + // Create an identifier for the cache that the Provider will use for cache scoping + private static func contructCacheIdentifier(app: FirebaseApp, settings: DataConnectSettings) -> String { + return "\(app.name)-\(settings.host)" + } } // This enum is public so the gen sdk can access it @@ -184,7 +225,7 @@ private class InstanceStore { private var instances = [InstanceKey: DataConnect]() func instance(for app: FirebaseApp, config: ConnectorConfig, - settings: DataConnectSettings, callerSDKType: CallerSDKType) -> DataConnect { + settings: DataConnectSettings, callerSDKType: CallerSDKType, cacheConfig: CacheConfig? = nil) -> DataConnect { accessQ.sync { let key = InstanceKey(app: app, config: config) if let inst = instances[key] { @@ -194,7 +235,8 @@ private class InstanceStore { app: app, connectorConfig: config, settings: settings, - callerSDKType: callerSDKType + callerSDKType: callerSDKType, + cacheConfig: cacheConfig ) instances[key] = dc return dc diff --git a/Sources/Internal/GrpcClient.swift b/Sources/Internal/GrpcClient.swift index bb15645..192059e 100644 --- a/Sources/Internal/GrpcClient.swift +++ b/Sources/Internal/GrpcClient.swift @@ -174,7 +174,7 @@ actor GrpcClient: CustomStringConvertible { response: failureResponse ) } else { - return OperationResult(data: decodedResults) + return OperationResult(data: decodedResults, source: .server) } } catch let operationErr as DataConnectOperationError { @@ -256,7 +256,7 @@ actor GrpcClient: CustomStringConvertible { response: failureResponse ) } else { - return OperationResult(data: decodedResults) + return OperationResult(data: decodedResults, source: .server) } } catch let operationErr as DataConnectOperationError { diff --git a/Sources/Internal/OperationsManager.swift b/Sources/Internal/OperationsManager.swift index b775857..7ef8530 100644 --- a/Sources/Internal/OperationsManager.swift +++ b/Sources/Internal/OperationsManager.swift @@ -17,7 +17,9 @@ import Foundation @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) class OperationsManager { private var grpcClient: GrpcClient - + + private var cacheProvider: CacheProvider? + private let queryRefAccessQueue = DispatchQueue( label: "firebase.dataconnect.queryRef.AccessQ", autoreleaseFrequency: .workItem @@ -30,8 +32,9 @@ class OperationsManager { ) private var mutationRefs = [AnyHashable: any OperationRef]() - init(grpcClient: GrpcClient) { + init(grpcClient: GrpcClient, cacheProvider: CacheProvider? = nil) { self.grpcClient = grpcClient + self.cacheProvider = cacheProvider } func queryRef: OperationRequest, Hashable, Equatable { private(set) var operationName: String private(set) var variables: Variable? + + + // Computed requestId + lazy var requestId: String = { + var keyIdData = Data() + if let nameData = "graphql".data(using: .utf8) { + keyIdData.append(nameData) + } + + if let variables { + let encoder = JSONEncoder() + encoder.outputFormatting = .sortedKeys + do { + let jsonData = try encoder.encode(variables) + keyIdData.append(jsonData) + } catch { + DataConnectLogger.logger + .warning("Error encoding variables to compute request identifier: \(error)") + } + } + + let hashDigest = SHA256.hash(data: keyIdData) + let hashString = hashDigest.compactMap{ String(format: "%02x", $0) }.joined() + + return hashString + }() init(operationName: String, variables: Variable? = nil) { self.operationName = operationName @@ -48,6 +75,33 @@ struct QueryRequest: OperationRequest, Hashable, Eq hasher.combine(variables) } } + + private func computeRequestIdentifier() -> String { + + var keyIdData = Data() + if let nameData = "graphql".data(using: .utf8) { + keyIdData.append(nameData) + } + + if let variables { + let encoder = JSONEncoder() + encoder.outputFormatting = .sortedKeys + do { + let jsonData = try encoder.encode(variables) + keyIdData.append(jsonData) + } catch { + DataConnectLogger.logger + .warning("Error encoding variables to compute request identifier: \(error)") + } + } + + + let hashDigest = SHA256.hash(data: keyIdData) + let hashString = hashDigest.compactMap{ String(format: "%02x", $0) }.joined() + + return hashString + + } static func == (lhs: QueryRequest, rhs: QueryRequest) -> Bool { guard lhs.operationName == rhs.operationName else { @@ -89,13 +143,18 @@ actor GenericQueryRef, Never>() - private let request: QueryRequest + private var request: QueryRequest private let grpcClient: GrpcClient + + private let cacheProvider: CacheProvider? + + private var ttl: TimeInterval? = 10.0 // - init(request: QueryRequest, grpcClient: GrpcClient) { + init(request: QueryRequest, grpcClient: GrpcClient, cacheProvider: CacheProvider? = nil) { self.request = request self.grpcClient = grpcClient + self.cacheProvider = cacheProvider } // This call starts query execution and publishes data to data var @@ -103,7 +162,7 @@ actor GenericQueryRef AnyPublisher, Never> { Task { do { - _ = try await reloadResults() + _ = try await fetchServerResults() } catch {} } return resultsPublisher.eraseToAnyPublisher() @@ -112,17 +171,45 @@ actor GenericQueryRef OperationResult { - let resultData = try await reloadResults() - return OperationResult(data: resultData) + + switch fetchPolicy ?? .server { + case .defaultPolicy: + let cachedResult = try await fetchCachedResults(allowStale: false) + if cachedResult.data != nil { + return cachedResult + } else { + let serverResults = try await fetchServerResults() + return serverResults + } + case .cache: + let cachedResult = try await fetchCachedResults(allowStale: true) + return cachedResult + case .server: + let serverResults = try await fetchServerResults() + return serverResults + } } - private func reloadResults() async throws -> ResultData { + private func fetchServerResults() async throws -> OperationResult { let results = try await grpcClient.executeQuery( request: request, resultType: ResultData.self ) - await updateData(data: results.data) - return results.data + if let data = results.data { + await updateData(data: data) + } + + return results + } + + private func fetchCachedResults(allowStale: Bool) async throws -> OperationResult { + guard let cacheProvider else { + DataConnectLogger.logger.info("No cache provider configured") + return OperationResult(data: nil, source: .cache(stale: false)) + } + + return OperationResult(data: nil, source: .cache(stale: false)) + } func updateData(data: ResultData) async { From f655a157b5e8940f8f80015be08009730fc120ca Mon Sep 17 00:00:00 2001 From: Aashish Patil Date: Fri, 22 Aug 2025 15:04:57 -0700 Subject: [PATCH 03/38] query level caching TODO - needs cleanup --- Sources/Cache/EphemeralCacheProvider.swift | 1 + Sources/DataConnect.swift | 5 +- Sources/Internal/GrpcClient.swift | 39 ++++--- Sources/Internal/OperationsManager.swift | 6 +- Sources/Queries/QueryRef.swift | 120 +++++++++++++-------- 5 files changed, 106 insertions(+), 65 deletions(-) diff --git a/Sources/Cache/EphemeralCacheProvider.swift b/Sources/Cache/EphemeralCacheProvider.swift index f84ab8f..5bf4424 100644 --- a/Sources/Cache/EphemeralCacheProvider.swift +++ b/Sources/Cache/EphemeralCacheProvider.swift @@ -37,6 +37,7 @@ actor EphemeralCacheProvider: CacheProvider, @preconcurrency CustomStringConvert cachedAt: Date(), data: data ) + DataConnectLogger.debug("Update resultTreeEntry for \(queryId)") } func resultTree(queryId: String) -> ResultTreeEntry? { diff --git a/Sources/DataConnect.swift b/Sources/DataConnect.swift index e7d5f54..84476b1 100644 --- a/Sources/DataConnect.swift +++ b/Sources/DataConnect.swift @@ -105,7 +105,10 @@ public class DataConnect { DataConnectLogger.debug("Create cacheProvider \(self.cacheProvider)") } - operationsManager = OperationsManager(grpcClient: grpcClient) + operationsManager = OperationsManager( + grpcClient: grpcClient, + cacheProvider: self.cacheProvider + ) } } diff --git a/Sources/Internal/GrpcClient.swift b/Sources/Internal/GrpcClient.swift index 192059e..d968bdf 100644 --- a/Sources/Internal/GrpcClient.swift +++ b/Sources/Internal/GrpcClient.swift @@ -130,7 +130,7 @@ actor GrpcClient: CustomStringConvertible { VariableType: OperationVariable>(request: QueryRequest, resultType: ResultType .Type) - async throws -> OperationResult { + async throws -> (results: String, ttl: TimeInterval, timestamp: Timestamp) { guard let client else { DataConnectLogger.error("When calling executeQuery(), grpc client has not been configured.") throw DataConnectInitError.grpcNotConfigured() @@ -147,13 +147,23 @@ actor GrpcClient: CustomStringConvertible { DataConnectLogger .debug("executeQuery() sends grpc request: \(requestString, privacy: .private).") let results = try await client.executeQuery(grpcRequest, callOptions: createCallOptions()) - let resultsString = try results.jsonString() + let resultsString = try results.data.jsonString() DataConnectLogger .debug("executeQuery() receives response: \(resultsString, privacy: .private).") // lets decode partial errors. We need these whether we succeed or fail let errorInfoList = createErrorInfoList(errors: results.errors) + /* + If we don't have Partial Errors, we return the result string to be decoded at query level + If we have Partial Errors, we follow the partial error route below. + */ + guard !errorInfoList.isEmpty else { + // TODO: Extract ttl, server timestamp + return (results: resultsString, ttl: 10.0, timestamp: Timestamp(date: Date())) + } + + // We have partial errors returned /* - if decode succeeds, errorList isEmpty = return data - if decode succeeds, errorList notEmpty = throw OperationError with decodedData @@ -163,20 +173,17 @@ actor GrpcClient: CustomStringConvertible { */ do { let decodedResults = try codec.decode(result: results.data, asType: resultType) - + // even though decode succeeded, we may still have received partial errors - if !errorInfoList.isEmpty { - let failureResponse = OperationFailureResponse( - rawJsonData: resultsString, errors: errorInfoList, - data: decodedResults - ) - throw DataConnectOperationError.executionFailed( - response: failureResponse - ) - } else { - return OperationResult(data: decodedResults, source: .server) - } - + + let failureResponse = OperationFailureResponse( + rawJsonData: resultsString, errors: errorInfoList, + data: decodedResults + ) + throw DataConnectOperationError.executionFailed( + response: failureResponse + ) + } catch let operationErr as DataConnectOperationError { // simply rethrow to avoid wrapping error throw operationErr @@ -198,7 +205,7 @@ actor GrpcClient: CustomStringConvertible { } } } catch { - // we failed at executing the call + // we failed at executing the server call itself DataConnectLogger.error( "executeQuery(): \(requestString, privacy: .private) grpc call FAILED with \(error)." ) diff --git a/Sources/Internal/OperationsManager.swift b/Sources/Internal/OperationsManager.swift index 7ef8530..f5375b0 100644 --- a/Sources/Internal/OperationsManager.swift +++ b/Sources/Internal/OperationsManager.swift @@ -53,7 +53,8 @@ class OperationsManager { let obsRef = QueryRefObservation( request: request, dataType: resultType, - grpcClient: self.grpcClient + grpcClient: self.grpcClient, + cacheProvider: self.cacheProvider ) as (any ObservableQueryRef) queryRefs[AnyHashable(request)] = obsRef return obsRef @@ -63,7 +64,8 @@ class OperationsManager { let refObsObject = QueryRefObservableObject( request: request, dataType: resultType, - grpcClient: grpcClient + grpcClient: grpcClient, + cacheProvider: self.cacheProvider ) as (any ObservableQueryRef) queryRefs[AnyHashable(request)] = refObsObject return refObsObject diff --git a/Sources/Queries/QueryRef.swift b/Sources/Queries/QueryRef.swift index 2045fdc..e316a03 100644 --- a/Sources/Queries/QueryRef.swift +++ b/Sources/Queries/QueryRef.swift @@ -15,6 +15,8 @@ import Foundation import CryptoKit +import Firebase + @preconcurrency import Combine import Observation @@ -75,33 +77,6 @@ struct QueryRequest: OperationRequest, Hashable, Eq hasher.combine(variables) } } - - private func computeRequestIdentifier() -> String { - - var keyIdData = Data() - if let nameData = "graphql".data(using: .utf8) { - keyIdData.append(nameData) - } - - if let variables { - let encoder = JSONEncoder() - encoder.outputFormatting = .sortedKeys - do { - let jsonData = try encoder.encode(variables) - keyIdData.append(jsonData) - } catch { - DataConnectLogger.logger - .warning("Error encoding variables to compute request identifier: \(error)") - } - } - - - let hashDigest = SHA256.hash(data: keyIdData) - let hashString = hashDigest.compactMap{ String(format: "%02x", $0) }.joined() - - return hashString - - } static func == (lhs: QueryRequest, rhs: QueryRequest) -> Bool { guard lhs.operationName == rhs.operationName else { @@ -128,12 +103,12 @@ public protocol QueryRef: OperationRef { func subscribe() async throws -> AnyPublisher, Never> // Execute override for queries to include fetch policy - func execute(fetchPolicy: QueryFetchPolicy?) async throws -> OperationResult + func execute(fetchPolicy: QueryFetchPolicy) async throws -> OperationResult } extension QueryRef { public func execute() async throws -> OperationResult { - try await execute(fetchPolicy: .server) + try await execute(fetchPolicy: .defaultPolicy) } } @@ -160,26 +135,37 @@ actor GenericQueryRef AnyPublisher, Never> { + /* Task { do { - _ = try await fetchServerResults() + //_ = try await fetchServerResults() } catch {} - } + }*/ return resultsPublisher.eraseToAnyPublisher() } // one-shot execution. It will fetch latest data, update any caches // and updates the published data var - public func execute(fetchPolicy: QueryFetchPolicy? = nil) async throws -> OperationResult { + public func execute(fetchPolicy: QueryFetchPolicy = .defaultPolicy) async throws -> OperationResult { - switch fetchPolicy ?? .server { + switch fetchPolicy { case .defaultPolicy: let cachedResult = try await fetchCachedResults(allowStale: false) if cachedResult.data != nil { return cachedResult } else { - let serverResults = try await fetchServerResults() - return serverResults + do { + let serverResults = try await fetchServerResults() + return serverResults + } catch let dcerr as DataConnectOperationError { + // TODO: Catch network specific error looking for deadline exceeded + /* + if dcErr is deadlineExceeded { + try await fetchCachedResults(allowStale: true) + } else rethrow + */ + throw dcerr + } } case .cache: let cachedResult = try await fetchCachedResults(allowStale: true) @@ -195,19 +181,48 @@ actor GenericQueryRef OperationResult { - guard let cacheProvider else { - DataConnectLogger.logger.info("No cache provider configured") + guard let cacheProvider, + let ttl, + ttl > 0 else { + DataConnectLogger.info("No cache provider configured or ttl is not set \(ttl)") return OperationResult(data: nil, source: .cache(stale: false)) } + if let cacheEntry = await cacheProvider.resultTree(queryId: self.request.requestId), + (cacheEntry.isStale(ttl) && allowStale) || !cacheEntry.isStale(ttl) + { + let stale = cacheEntry.isStale(ttl) + let decoder = JSONDecoder() + let decodedData = try decoder.decode(ResultData.self, from: cacheEntry.data.data(using: .utf8)!) + return OperationResult(data: decodedData, source: .cache(stale: stale)) + } + return OperationResult(data: nil, source: .cache(stale: false)) } @@ -249,9 +264,18 @@ public class QueryRefObservableObject< private var resultsCancellable: AnyCancellable? - init(request: QueryRequest, dataType: ResultData.Type, grpcClient: GrpcClient) { + init( + request: QueryRequest, + dataType: ResultData.Type, + grpcClient: GrpcClient, + cacheProvider: CacheProvider? + ) { self.request = request - baseRef = GenericQueryRef(request: request, grpcClient: grpcClient) + baseRef = GenericQueryRef( + request: request, + grpcClient: grpcClient, + cacheProvider: cacheProvider + ) setupSubscription() } @@ -284,7 +308,7 @@ public class QueryRefObservableObject< /// Executes the query and returns `ResultData`. This will also update the published `data` /// variable - public func execute(fetchPolicy: QueryFetchPolicy? = nil) async throws -> OperationResult { + public func execute(fetchPolicy: QueryFetchPolicy = .defaultPolicy) async throws -> OperationResult { let result = try await baseRef.execute(fetchPolicy: fetchPolicy) return result } @@ -325,9 +349,13 @@ public class QueryRefObservation< @ObservationIgnored private var resultsCancellable: AnyCancellable? - init(request: QueryRequest, dataType: ResultData.Type, grpcClient: GrpcClient) { + init(request: QueryRequest, dataType: ResultData.Type, grpcClient: GrpcClient, cacheProvider: CacheProvider?) { self.request = request - baseRef = GenericQueryRef(request: request, grpcClient: grpcClient) + baseRef = GenericQueryRef( + request: request, + grpcClient: grpcClient, + cacheProvider: cacheProvider + ) setupSubscription() } @@ -360,7 +388,7 @@ public class QueryRefObservation< /// Executes the query and returns `ResultData`. This will also update the published `data` /// variable - public func execute(fetchPolicy: QueryFetchPolicy? = nil) async throws -> OperationResult { + public func execute(fetchPolicy: QueryFetchPolicy = .defaultPolicy) async throws -> OperationResult { let result = try await baseRef.execute(fetchPolicy: fetchPolicy) return result } From 90c7ae15a9f16a14ff2db2360f4a0b3fb445c991 Mon Sep 17 00:00:00 2001 From: Aashish Patil Date: Thu, 28 Aug 2025 14:57:48 -0700 Subject: [PATCH 04/38] Cache Normalization Implementation --- Sources/Cache/BackingDataObject.swift | 53 ++++- Sources/Cache/CacheEntry.swift | 2 + Sources/Cache/CacheProvider.swift | 16 +- Sources/Cache/EphemeralCacheProvider.swift | 37 ++- Sources/Cache/ResultTreeNormalizer.swift | 20 -- Sources/Cache/ResultTreeProcessor.swift | 79 ++++++ Sources/Cache/StubDataObject.swift | 224 +++++++++++++++++- Sources/Internal/GrpcClient.swift | 4 +- .../{Codec.swift => ProtoCodec.swift} | 2 +- Sources/Internal/SynchronizedDictionary.swift | 47 ++++ Sources/Queries/QueryRef.swift | 36 ++- Sources/Scalars/AnyCodableValue.swift | 135 +++++++++++ Sources/Scalars/AnyValue.swift | 74 +----- Tests/Unit/CacheTests.swift | 72 ++++++ 14 files changed, 678 insertions(+), 123 deletions(-) delete mode 100644 Sources/Cache/ResultTreeNormalizer.swift create mode 100644 Sources/Cache/ResultTreeProcessor.swift rename Sources/Internal/{Codec.swift => ProtoCodec.swift} (99%) create mode 100644 Sources/Internal/SynchronizedDictionary.swift create mode 100644 Sources/Scalars/AnyCodableValue.swift create mode 100644 Tests/Unit/CacheTests.swift diff --git a/Sources/Cache/BackingDataObject.swift b/Sources/Cache/BackingDataObject.swift index e20858a..4905c93 100644 --- a/Sources/Cache/BackingDataObject.swift +++ b/Sources/Cache/BackingDataObject.swift @@ -12,7 +12,12 @@ // See the License for the specific language governing permissions and // limitations under the License. -actor BackingDataObject { +struct ScalarField { + let name: String + let value: AnyCodableValue +} + +class BackingDataObject: CustomStringConvertible { let guid: String // globally unique id received from server @@ -20,18 +25,54 @@ actor BackingDataObject { self.guid = guid } - private var serverValues: [String: AnyCodableValue] = [:] - - func updateServerValues(_ newValues: [String: AnyCodableValue]) { - self.serverValues = newValues - } + private var serverValues = SynchronizedDictionary() func updateServerValue(_ key: String, _ newValue: AnyCodableValue) { self.serverValues[key] = newValue + DataConnectLogger.debug("BDO updateServerValue: \(key) \(newValue) for \(guid)") } func value(forKey key: String) -> AnyCodableValue? { return self.serverValues[key] } + var description: String { + return """ + BackingDataObject: + globalID: \(guid) + serverValues: + \(serverValues.rawCopy()) + """ + } + +} + +extension BackingDataObject: Encodable { + + func encodableData() throws -> [String: AnyCodableValue] { + var encodingValues = [String: AnyCodableValue]() + encodingValues[GlobalIDKey] = .string(guid) + encodingValues.merge(serverValues.rawCopy()) { (_, new) in new } + return encodingValues + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(encodableData()) + // once we have localValues, we will need to merge between the two dicts and encode + } +} + +extension BackingDataObject: Equatable { + static func == (lhs: BackingDataObject, rhs: BackingDataObject) -> Bool { + return lhs.guid == rhs.guid && lhs.serverValues.rawCopy() == rhs.serverValues.rawCopy() + } } + +extension BackingDataObject: CustomDebugStringConvertible { + var debugDescription: String { + return description + } +} + + diff --git a/Sources/Cache/CacheEntry.swift b/Sources/Cache/CacheEntry.swift index db3a802..e32c4d2 100644 --- a/Sources/Cache/CacheEntry.swift +++ b/Sources/Cache/CacheEntry.swift @@ -20,7 +20,9 @@ import FirebaseCore struct ResultTreeEntry { let serverTimestamp: Timestamp // Server response timestamp let cachedAt: Date // Local time when the entry was cached / updated + let ttl: TimeInterval // interval during which query results are considered fresh let data: String // tree data + var rootObject: StubDataObject? func isStale(_ ttl: TimeInterval) -> Bool { let now = Date() diff --git a/Sources/Cache/CacheProvider.swift b/Sources/Cache/CacheProvider.swift index 8ceabdb..cc278c9 100644 --- a/Sources/Cache/CacheProvider.swift +++ b/Sources/Cache/CacheProvider.swift @@ -16,19 +16,25 @@ import Foundation import FirebaseCore -protocol CacheProvider: Actor { +// FDC field name that identifies a GlobalID +let GlobalIDKey: String = "cacheId" + +let CacheProviderUserInfoKey = CodingUserInfoKey(rawValue: "fdc_cache_provider")! + +protocol CacheProvider { var cacheConfig: CacheConfig { get } var cacheIdentifier: String { get } func resultTree(queryId: String) -> ResultTreeEntry? - func setResultTree(queryId: String, serverTimestamp: Timestamp, data: String) + func setResultTree(queryId: String, tree: ResultTreeEntry) - /* - func dataObject(entityKey: String) -> BackingDataObject - func setObject(entityKey: String, object: BackingDataObject) + + func dataObject(entityGuid: String) -> BackingDataObject + func setObject(entityGuid: String, object: BackingDataObject) + /* func size() -> Int */ } diff --git a/Sources/Cache/EphemeralCacheProvider.swift b/Sources/Cache/EphemeralCacheProvider.swift index 5bf4424..6eab8d2 100644 --- a/Sources/Cache/EphemeralCacheProvider.swift +++ b/Sources/Cache/EphemeralCacheProvider.swift @@ -16,10 +16,9 @@ import Foundation import FirebaseCore -actor EphemeralCacheProvider: CacheProvider, @preconcurrency CustomStringConvertible { +class EphemeralCacheProvider: CacheProvider, CustomStringConvertible { let cacheConfig: CacheConfig - let cacheIdentifier: String init(cacheConfig: CacheConfig, cacheIdentifier: String) { @@ -29,14 +28,14 @@ actor EphemeralCacheProvider: CacheProvider, @preconcurrency CustomStringConvert DataConnectLogger.debug("Initialized \(Self.Type.self) with config \(cacheConfig)") } - private var resultTreeCache: [String: ResultTreeEntry] = [:] + // MARK: ResultTree + private var resultTreeCache = SynchronizedDictionary() - func setResultTree(queryId: String, serverTimestamp: Timestamp, data: String) { - resultTreeCache[queryId] = .init( - serverTimestamp: serverTimestamp, - cachedAt: Date(), - data: data - ) + func setResultTree( + queryId: String, + tree: ResultTreeEntry + ) { + resultTreeCache[queryId] = tree DataConnectLogger.debug("Update resultTreeEntry for \(queryId)") } @@ -44,6 +43,26 @@ actor EphemeralCacheProvider: CacheProvider, @preconcurrency CustomStringConvert return resultTreeCache[queryId] } + // MARK: BackingDataObjects + private var backingDataObjects = SynchronizedDictionary() + + func dataObject(entityGuid: String) -> BackingDataObject { + guard let dataObject = backingDataObjects[entityGuid] else { + let bdo = BackingDataObject(guid: entityGuid) + backingDataObjects[entityGuid] = bdo + DataConnectLogger.debug("Created BDO for \(entityGuid)") + return bdo + } + + DataConnectLogger.debug("Returning existing BDO for \(entityGuid)") + return dataObject + } + + func setObject(entityGuid: String, object: BackingDataObject) { + backingDataObjects[entityGuid] = object + } + + var description: String { return "EphemeralCacheProvider - \(cacheIdentifier)" } diff --git a/Sources/Cache/ResultTreeNormalizer.swift b/Sources/Cache/ResultTreeNormalizer.swift deleted file mode 100644 index eccdedb..0000000 --- a/Sources/Cache/ResultTreeNormalizer.swift +++ /dev/null @@ -1,20 +0,0 @@ -// 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. - -// Normalizes data in a ResultTree -struct ResultTreeNormalizer { - func normalize(_ tree: String) -> StubDataObject { - return StubDataObject() - } -} diff --git a/Sources/Cache/ResultTreeProcessor.swift b/Sources/Cache/ResultTreeProcessor.swift new file mode 100644 index 0000000..8707dcc --- /dev/null +++ b/Sources/Cache/ResultTreeProcessor.swift @@ -0,0 +1,79 @@ +// 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 Foundation + + + +// Normalization and recontruction of ResultTree +struct ResultTreeProcessor { + + /* + Go down the tree and convert them to Stubs + For each Stub + - extract primary key + - Get the BDO for the PK + - extract scalars and update BDO with scalars + - for each array + - recursively process each object (could be scalar or composite) + - for composite objects (dictionaries), create references to their stubs + - create a Stub object and init it with dictionary. + + */ + + func dehydrateResults(_ hydratedTree: String, cacheProvider: CacheProvider) throws -> (dehydratedResults: String, rootObject: StubDataObject) { + let jsonDecoder = JSONDecoder() + jsonDecoder.userInfo[CacheProviderUserInfoKey] = cacheProvider + jsonDecoder.userInfo[ResultTreeKindCodingKey] = ResultTreeKind.hydrated + let sdo = try jsonDecoder.decode(StubDataObject.self, from: hydratedTree.data(using: .utf8)!) + + let jsonEncoder = JSONEncoder() + jsonEncoder.userInfo[CacheProviderUserInfoKey] = cacheProvider + jsonEncoder.userInfo[ResultTreeKindCodingKey] = ResultTreeKind.dehydrated + let jsonData = try jsonEncoder.encode(sdo) + let dehydratedResultsString = String(data: jsonData, encoding: .utf8)! + + DataConnectLogger + .debug( + "\(#function): \nHydrated \n \(hydratedTree) \n\nDehydrated \n \(dehydratedResultsString)" + ) + + return (dehydratedResultsString, sdo) + } + + + func hydrateResults(_ dehydratedTree: String, cacheProvider: CacheProvider) throws -> + (hydratedResults: String, rootObject: StubDataObject) { + let jsonDecoder = JSONDecoder() + jsonDecoder.userInfo[CacheProviderUserInfoKey] = cacheProvider + jsonDecoder.userInfo[ResultTreeKindCodingKey] = ResultTreeKind.dehydrated + let sdo = try jsonDecoder.decode(StubDataObject.self, from: dehydratedTree.data(using: .utf8)!) + DataConnectLogger.debug("Dehydrated Tree decoded to SDO: \(sdo)") + + let jsonEncoder = JSONEncoder() + jsonEncoder.userInfo[CacheProviderUserInfoKey] = cacheProvider + jsonEncoder.userInfo[ResultTreeKindCodingKey] = ResultTreeKind.hydrated + let hydratedResults = try jsonEncoder.encode(sdo) + let hydratedResultsString = String(data: hydratedResults, encoding: .utf8)! + + DataConnectLogger + .debug( + "\(#function) Dehydrated \n \(dehydratedTree) \n\nHydrated \n \(hydratedResultsString)" + ) + + return (hydratedResultsString, sdo) + } + + //func denormalize(_ tree: String) +} diff --git a/Sources/Cache/StubDataObject.swift b/Sources/Cache/StubDataObject.swift index 02a0acc..bebbd10 100644 --- a/Sources/Cache/StubDataObject.swift +++ b/Sources/Cache/StubDataObject.swift @@ -11,20 +11,242 @@ // 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. +/* + + Init with JSON (root) + Convert to CodableValue and Init itself + Codable Value must be a dict at root level + + Dict can contain + (if BDO) + - Scalars, [Scalar] => Move this to DBO + - References, [References] => Keep with + (if no guid and therefore no BDO) + - Store CodableValue as - is + + + */ + +// Kind of result data we are encoding from or decoding to +enum ResultTreeKind { + case hydrated // JSON data is full hydrated and contains full data in the tree + case dehydrated // JSON data is dehydrated and only contains refs to actual data objects +} + +let ResultTreeKindCodingKey = CodingUserInfoKey(rawValue: "com.google.firebase.dataconnect.encodingMode")! struct StubDataObject { + + // externalized (normalized) data. + // Requires an entity globalID to be provided in selection set var backingData: BackingDataObject? - var references: [String: AnyCodableValue] = [:] + + // inline scalars are only populated if there is no BackingDataObject + // i.e. if there is no entity globalID + var scalars = [String: AnyCodableValue]() + + // entity properties that point to other stub objects + var references = [String: StubDataObject]() + + // properties that point to a list of other objects + // scalar lists are stored inline. + var objectLists = [String: [StubDataObject]]() + + enum CodingKeys: String, CodingKey { + case globalID = "cacheId" + case objectLists + case references + case scalars + } + + struct DynamicKey: CodingKey { + var intValue: Int? + let stringValue: String + init?(intValue: Int) { return nil } + init?(stringValue: String) { self.stringValue = stringValue } + } + + init?(value: AnyCodableValue, cacheProvider: CacheProvider) { + guard case let .dictionary(objectValues) = value else { + DataConnectLogger.error("StubDataObject inited with a non-dictionary type") + return nil + } + + if case let .string(guid) = objectValues[GlobalIDKey] { + backingData = cacheProvider.dataObject(entityGuid: guid) + } else if case let .uuid(guid) = objectValues[GlobalIDKey] { + // underlying deserializer is detecting a uuid and converting it. + // TODO: Remove once server starts to send the real GlobalID + backingData = cacheProvider.dataObject(entityGuid: guid.uuidString) + } + + for (key, value) in objectValues { + switch value { + case .dictionary(_): + // a dictionary is treated as a composite object + // and converted to another Stub + if let st = StubDataObject(value: value, cacheProvider: cacheProvider) { + references[key] = st + } else { + DataConnectLogger.warning("Failed to convert dictionary to a reference") + } + case .array(let objs): + var refArray = [StubDataObject]() + var scalarArray = [AnyCodableValue]() + for obj in objs { + if let st = StubDataObject(value: obj, cacheProvider: cacheProvider) { + refArray.append(st) + } else { + if obj.isScalar { + scalarArray.append(obj) + } + // Not handling the case of Array of Arrays (matrices) + } + } + if refArray.count > 0 { + objectLists[key] = refArray + } else if scalarArray.count > 0 { + if let backingData { backingData.updateServerValue(key, value)} + } + default: + if let backingData { + backingData.updateServerValue(key, value) + } else { + scalars[key] = value + } + + } + } + } } extension StubDataObject: Decodable { + init (from decoder: Decoder) throws { + guard let cacheProvider = decoder.userInfo[CacheProviderUserInfoKey] as? CacheProvider else { + throw DataConnectCodecError.decodingFailed(message: "Missing CacheProvider in decoder") + } + + guard let resultTreeKind = decoder.userInfo[ResultTreeKindCodingKey] as? ResultTreeKind else { + throw DataConnectCodecError.decodingFailed(message: "Missing ResultTreeKind in decoder") + } + + if resultTreeKind == .hydrated { + // our input is a hydrated result tree + let container = try decoder.singleValueContainer() + + let value = try container.decode(AnyCodableValue.self) + + let sdo = StubDataObject(value: value, cacheProvider: cacheProvider) + //DataConnectLogger.debug("Create SDO from JSON \(sdo?.debugDescription)") + + if let sdo { + self = sdo + } else { + throw DataConnectCodecError.decodingFailed(message: "Failed to decode into a valid StubDataObject") + } + + } else { + // our input is a dehydrated result tree + let container = try decoder.container(keyedBy: CodingKeys.self) + + if let globalID = try container.decodeIfPresent(String.self, forKey: .globalID) { + self.backingData = cacheProvider.dataObject(entityGuid: globalID) + } + + if let refs = try container.decodeIfPresent([String: StubDataObject].self, forKey: .references) { + self.references = refs + } + + if let lists = try container.decodeIfPresent([String: [StubDataObject]].self, forKey: .objectLists) { + self.objectLists = lists + } + + if let scalars = try container.decodeIfPresent([String: AnyCodableValue].self, forKey: .scalars) { + self.scalars = scalars + } + + } } } extension StubDataObject: Encodable { func encode(to encoder: Encoder) throws { + guard let resultTreeKind = encoder.userInfo[ResultTreeKindCodingKey] as? ResultTreeKind else { + throw DataConnectCodecError.decodingFailed(message: "Missing ResultTreeKind in decoder") + } + if resultTreeKind == .hydrated { + //var container = encoder.singleValueContainer() + var container = encoder.container(keyedBy: DynamicKey.self) + + if let backingData { + let encodableData = try backingData.encodableData() + for (key, value) in encodableData { + try container.encode(value, forKey: DynamicKey(stringValue: key)!) + } + } + + if references.count > 0 { + for (key, value) in references { + try container.encode(value, forKey: DynamicKey(stringValue: key)!) + } + } + + if objectLists.count > 0 { + for (key, value) in objectLists { + try container.encode(value, forKey: DynamicKey(stringValue: key)!) + } + } + + if scalars.count > 0 { + for (key, value) in scalars { + try container.encode(value, forKey: DynamicKey(stringValue: key)!) + } + } + } else { + // dehydrated tree required + var container = encoder.container(keyedBy: CodingKeys.self) + if let backingData { + try container.encode(backingData.guid, forKey: .globalID) + } + + if references.count > 0 { + try container.encode(references, forKey: .references) + } + + if objectLists.count > 0 { + try container.encode(objectLists, forKey: .objectLists) + } + + if scalars.count > 0 { + try container.encode(scalars, forKey: .scalars) + } + } + } +} + +extension StubDataObject: Equatable { + public static func == (lhs: StubDataObject, rhs: StubDataObject) -> Bool { + return lhs.backingData == rhs.backingData && + lhs.references == rhs.references && + lhs.objectLists == rhs.objectLists && + lhs.scalars == rhs.scalars + } +} + +extension StubDataObject: CustomDebugStringConvertible { + var debugDescription: String { + return """ + StubDataObject: + \(String(describing: self.backingData)) + References: + \(self.references) + Lists: + \(self.objectLists) + Scalars: + \(self.scalars) + """ } } diff --git a/Sources/Internal/GrpcClient.swift b/Sources/Internal/GrpcClient.swift index d968bdf..79eb49f 100644 --- a/Sources/Internal/GrpcClient.swift +++ b/Sources/Internal/GrpcClient.swift @@ -136,7 +136,7 @@ actor GrpcClient: CustomStringConvertible { throw DataConnectInitError.grpcNotConfigured() } - let codec = Codec() + let codec = ProtoCodec() let grpcRequest = try codec.createQueryRequestProto( connectorName: connectorName, request: request @@ -224,7 +224,7 @@ actor GrpcClient: CustomStringConvertible { throw DataConnectInitError.grpcNotConfigured() } - let codec = Codec() + let codec = ProtoCodec() let grpcRequest = try codec.createMutationRequestProto( connectorName: connectorName, request: request diff --git a/Sources/Internal/Codec.swift b/Sources/Internal/ProtoCodec.swift similarity index 99% rename from Sources/Internal/Codec.swift rename to Sources/Internal/ProtoCodec.swift index d7940e3..a49e09e 100644 --- a/Sources/Internal/Codec.swift +++ b/Sources/Internal/ProtoCodec.swift @@ -23,7 +23,7 @@ typealias FirebaseDataConnectExecuteQueryRequest = Google_Firebase_Dataconnect_V1_ExecuteQueryRequest @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -class Codec { +class ProtoCodec { // Encode Codable to Protos func encode(args: any Encodable) throws -> Google_Protobuf_Struct { do { diff --git a/Sources/Internal/SynchronizedDictionary.swift b/Sources/Internal/SynchronizedDictionary.swift new file mode 100644 index 0000000..1da6002 --- /dev/null +++ b/Sources/Internal/SynchronizedDictionary.swift @@ -0,0 +1,47 @@ +// 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 Foundation + +class SynchronizedDictionary { + private var dictionary: [Key: Value] = [:] + private let queue: DispatchQueue = DispatchQueue(label: "com.google.firebase.dataconnect.syncDictionaryQ") + + init() {} + + subscript(key: Key) -> Value? { + get { + return queue.sync { self.dictionary[key] } + } set { + queue.async(flags: .barrier) { + self.dictionary[key] = newValue + } + } + } + + func rawCopy() -> [Key: Value] { + return queue.sync { self.dictionary } + } + + +} + +extension SynchronizedDictionary: Encodable where Value: Encodable, Key: Encodable { + func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + try queue.sync { + try container.encode(dictionary) + } + } +} diff --git a/Sources/Queries/QueryRef.swift b/Sources/Queries/QueryRef.swift index e316a03..2c1cae8 100644 --- a/Sources/Queries/QueryRef.swift +++ b/Sources/Queries/QueryRef.swift @@ -43,7 +43,7 @@ struct QueryRequest: OperationRequest, Hashable, Eq // Computed requestId lazy var requestId: String = { var keyIdData = Data() - if let nameData = "graphql".data(using: .utf8) { + if let nameData = operationName.data(using: .utf8) { keyIdData.append(nameData) } @@ -185,12 +185,24 @@ actor GenericQueryRef Bool { + switch (lhs, rhs) { + case let (.string(l), .string(r)): return l == r + case let (.int64(l), .int64(r)): return l == r + case let (.number(l), .number(r)): return l == r + case let (.bool(l), .bool(r)): return l == r + case let (.uuid(l), .uuid(r)): return l == r + case let (.timestamp(l), .timestamp(r)): return l == r + case let (.dictionary(l), .dictionary(r)): return l == r + case let (.array(l), .array(r)): return l == r + case (.null, .null): return true + default: return false + } + } + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + + if let tsVal = try? container.decode(Timestamp.self) { + self = .timestamp(tsVal) + } else if let stringVal = try? container.decode(String.self) { + if let int64Val = try? Int64CodableConverter().decode(input: stringVal) { + self = .int64(int64Val) + } else if let uuidVal = try? UUIDCodableConverter().decode(input: stringVal) { + self = .uuid(uuidVal) + } else { + self = .string(stringVal) + } + } else if let doubleVal = try? container.decode(Double.self) { + self = .number(doubleVal) + } else if let boolVal = try? container.decode(Bool.self) { + self = .bool(boolVal) + } else if let dictVal = try? container.decode([String: AnyCodableValue].self) { + self = .dictionary(dictVal) + } else if let arrayVal = try? container.decode([AnyCodableValue].self) { + self = .array(arrayVal) + } else if container.decodeNil() { + self = .null + } else { + throw DataConnectCodecError + .decodingFailed(message: "Error decode AnyCodableValue from \(container)") + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case let .int64(value): + let encodedVal = try? Int64CodableConverter().encode(input: value) + try container.encode(encodedVal) + case let .string(value): + try container.encode(value) + case let .number(value): + try container.encode(value) + case let .bool(value): + try container.encode(value) + case let .uuid(value): + try container.encode(try UUIDCodableConverter().encode(input: value)) + case let .timestamp(value): + try container.encode(value) + case let .dictionary(value): + try container.encode(value) + case let .array(value): + try container.encode(value) + case .null: + try container.encodeNil() + } + } + + var isScalar: Bool { + switch self { + case .array, .dictionary: + return false // treating array itself as non-scalar till its contained types are inspected + default: + return true + // .null is an odd one. + // Since we don't know its type at base SDK + // it will be stored inline so treating as scalar + } + } + + var description: String { + switch self { + case let .int64(value): + return value.description + case let .string(value): + return value + case let .number(value): + return value.description + case let .bool(value): + return value.description + case let .uuid(value): + return value.uuidString + case let .timestamp(value): + return value.description + case let .dictionary(value): + return value.description + case let .array(value): + return value.description + case .null: + return "null" + } + } + +} diff --git a/Sources/Scalars/AnyValue.swift b/Sources/Scalars/AnyValue.swift index 44734df..2a89f6f 100644 --- a/Sources/Scalars/AnyValue.swift +++ b/Sources/Scalars/AnyValue.swift @@ -14,6 +14,8 @@ import Foundation +import FirebaseCore + /// AnyValue represents the Any graphql scalar, which represents Codable data - scalar data (Int, /// Double, String, Bool,...) or a JSON object @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) @@ -102,75 +104,3 @@ extension AnyValue: Hashable { @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) extension AnyValue: Sendable {} - -// MARK: - - -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -enum AnyCodableValue: Codable, Equatable { - case string(String) - case int64(Int64) - case number(Double) - case bool(Bool) - case dictionary([String: AnyCodableValue]) - case array([AnyCodableValue]) - case null - - static func == (lhs: AnyCodableValue, rhs: AnyCodableValue) -> Bool { - switch (lhs, rhs) { - case let (.string(l), .string(r)): return l == r - case let (.int64(l), .int64(r)): return l == r - case let (.number(l), .number(r)): return l == r - case let (.bool(l), .bool(r)): return l == r - case let (.dictionary(l), .dictionary(r)): return l == r - case let (.array(l), .array(r)): return l == r - case (.null, .null): return true - default: return false - } - } - - init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - - if let stringVal = try? container.decode(String.self) { - if let int64Val = try? Int64CodableConverter().decode(input: stringVal) { - self = .int64(int64Val) - } else { - self = .string(stringVal) - } - } else if let doubleVal = try? container.decode(Double.self) { - self = .number(doubleVal) - } else if let boolVal = try? container.decode(Bool.self) { - self = .bool(boolVal) - } else if let dictVal = try? container.decode([String: AnyCodableValue].self) { - self = .dictionary(dictVal) - } else if let arrayVal = try? container.decode([AnyCodableValue].self) { - self = .array(arrayVal) - } else if container.decodeNil() { - self = .null - } else { - throw DataConnectCodecError - .decodingFailed(message: "Error decode AnyCodableValue from \(container)") - } - } - - func encode(to encoder: Encoder) throws { - var container = encoder.singleValueContainer() - switch self { - case let .int64(value): - let encodedVal = try? Int64CodableConverter().encode(input: value) - try container.encode(encodedVal) - case let .string(value): - try container.encode(value) - case let .number(value): - try container.encode(value) - case let .bool(value): - try container.encode(value) - case let .dictionary(value): - try container.encode(value) - case let .array(value): - try container.encode(value) - case .null: - try container.encodeNil() - } - } -} diff --git a/Tests/Unit/CacheTests.swift b/Tests/Unit/CacheTests.swift new file mode 100644 index 0000000..ea6aee7 --- /dev/null +++ b/Tests/Unit/CacheTests.swift @@ -0,0 +1,72 @@ +// +// CacheTests.swift +// FirebaseDataConnect +// +// Created by Aashish Patil on 8/27/25. +// + + +import Foundation +import XCTest + +@testable import FirebaseDataConnect + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +final class CacheTests: XCTestCase { + + let resultTreeOneItemJson = """ + {"item":{"desc":null,"cacheId":"a2e64ada1771434aa3ec73f6a6d05428","price":40.456,"reviews":[{"title":"Item4 Review1 byUser3","id":"d769b8d6d4064e81948fb6b9374fba54","cacheId":"03ADC9EC-0102-4F24-BE8B-F6C0DD102EA4","user":{"name":"User3","id":"69562c9aee2f47ee8abb8181d4df53ec","cacheId":"65928AFC-22FA-422D-A2F1-85980DC682AE"}}],"id":"98e55525f20f4ee190034adcd6fb01dc","name":"Item4"}} + + """ + + let resultTreeJson = """ + {"items":[{"id":"0cadb2b93d46434db1d218d6db023b79","price":226.94024396145267,"name":"Item-24","cacheId":"78192783c32c48cd9b4146547421a6a5","userReviews_on_item":[]},{"cacheId":"fc9387b4c50a4eb28e91d7a09d108a44","id":"4a01c498dd014a29b20ac693395a2900","userReviews_on_item":[],"name":"Item-62","price":512.3027252608986},{"price":617.9690589103608,"id":"e2ed29ed3e9b42328899d49fa33fc785","userReviews_on_item":[],"cacheId":"a911561b2b904f008ab8c3a2d2a7fdbe","name":"Item-49"},{"id":"0da168e75ded479ea3b150c13b7c6ec7","price":10.456,"userReviews_on_item":[{"cacheId":"125791DB-696E-4446-8F2A-C17E7C2AF771","user":{"name":"User1","id":"2fff8099d54843a0bbbbcf905e4c3424","cacheId":"27E85023-D465-4240-82D6-0055AA122406"},"title":"Item1 Review1 byUser1","id":"1384a5173c31487c8834368348c3b89c"}],"name":"Item1","cacheId":"fcfa90f7308049a083c3131f9a7a9836"},{"id":"23311f29be09495cba198da89b8b7d0f","name":"Item2","price":20.456,"cacheId":"c565d2fb7386480c87aa804f2789d200","userReviews_on_item":[{"title":"Item2 Review1 byUser1","user":{"name":"User1","id":"2fff8099d54843a0bbbbcf905e4c3424","cacheId":"27E85023-D465-4240-82D6-0055AA122406"},"cacheId":"F652FB4E-65E0-43E0-ADB1-14582304F938","id":"7ec6b021e1654eff98b3482925fab0c9"}]},{"name":"Item3","cacheId":"c6218faf3607495aaeab752ae6d0b8a7","id":"b7d2287e94014f4fa4a1566f1b893105","price":30.456,"userReviews_on_item":[{"title":"Item3 Review1 byUser2","cacheId":"8455C788-647F-4AB3-971B-6A9C42456129","id":"9bf4d458dd204a4c8931fe952bba85b7","user":{"id":"00af97d8f274427cb5e2c691ca13521c","name":"User2","cacheId":"EB588061-7139-4D6D-9A1B-80D4150DC1B4"}}]},{"userReviews_on_item":[{"id":"d769b8d6d4064e81948fb6b9374fba54","cacheId":"03ADC9EC-0102-4F24-BE8B-F6C0DD102EA4","title":"Item4 Review1 byUser3","user":{"cacheId":"65928AFC-22FA-422D-A2F1-85980DC682AE","id":"69562c9aee2f47ee8abb8181d4df53ec","name":"User3"}}],"price":40.456,"name":"Item4","id":"98e55525f20f4ee190034adcd6fb01dc","cacheId":"a2e64ada1771434aa3ec73f6a6d05428"}]} + """ + let cacheProvider = EphemeralCacheProvider( + cacheConfig: CacheConfig(), + cacheIdentifier: UUID().uuidString + ) + + override func setUpWithError() throws { + + + + + } + + override func tearDownWithError() throws {} + + // Confirm that providing same entity cache id uses the same BackingDataObject instance + func testBDOReuse() throws { + do { + let resultsProcessor = ResultTreeProcessor() + let rootSdo = try resultsProcessor.normalize(resultTreeJson, cacheProvider: cacheProvider) + + let reusedCacheId = "27E85023-D465-4240-82D6-0055AA122406" + + let user1 = cacheProvider.dataObject(entityGuid: reusedCacheId) + let user2 = cacheProvider.dataObject(entityGuid: reusedCacheId) + + // both user objects should be references to same instance + XCTAssertTrue(user1 === user2) + } + } + + func testDehydrationHydration() throws { + do { + let resultsProcessor = ResultTreeProcessor() + + let (dehydratedTree, sdo1) = try resultsProcessor.dehydrateResults( + resultTreeOneItemJson, + cacheProvider: cacheProvider + ) + + let (hydratedTree, sdo2 ) = try resultsProcessor.hydrateResults(dehydratedTree, cacheProvider: cacheProvider) + + XCTAssertEqual(sdo1, sdo2) + + } + } + + +} From 5174e0324299297e4fada31234ee6622f2a3f2a7 Mon Sep 17 00:00:00 2001 From: Aashish Patil Date: Fri, 29 Aug 2025 11:40:26 -0700 Subject: [PATCH 05/38] Persistent Provider --- Sources/Cache/BackingDataObject.swift | 32 ++- Sources/Cache/CacheProvider.swift | 8 +- Sources/Cache/DynamicCodingKey.swift | 21 ++ Sources/Cache/EphemeralCacheProvider.swift | 12 +- .../{CacheEntry.swift => ResultTree.swift} | 30 ++- Sources/Cache/ResultTreeProcessor.swift | 1 - Sources/Cache/SQLiteCacheProvider.swift | 201 ++++++++++++++++++ Sources/Cache/StubDataObject.swift | 28 ++- Sources/DataConnect.swift | 27 ++- Sources/DataConnectError.swift | 40 +++- Sources/Internal/SynchronizedDictionary.swift | 12 +- Sources/Queries/QueryRef.swift | 122 +++-------- Sources/Queries/QueryRequest.swift | 82 +++++++ Sources/Queries/QueryResultSource.swift | 3 + 14 files changed, 475 insertions(+), 144 deletions(-) create mode 100644 Sources/Cache/DynamicCodingKey.swift rename Sources/Cache/{CacheEntry.swift => ResultTree.swift} (53%) create mode 100644 Sources/Cache/SQLiteCacheProvider.swift create mode 100644 Sources/Queries/QueryRequest.swift diff --git a/Sources/Cache/BackingDataObject.swift b/Sources/Cache/BackingDataObject.swift index 4905c93..dbd9cc2 100644 --- a/Sources/Cache/BackingDataObject.swift +++ b/Sources/Cache/BackingDataObject.swift @@ -17,16 +17,21 @@ struct ScalarField { let value: AnyCodableValue } -class BackingDataObject: CustomStringConvertible { +class BackingDataObject: CustomStringConvertible, Codable { - let guid: String // globally unique id received from server + var guid: String // globally unique id received from server - init(guid: String) { + required init(guid: String) { self.guid = guid } private var serverValues = SynchronizedDictionary() + enum CodingKeys: String, CodingKey { + case globalID = "globalID" + case serverValues = "serverValues" + } + func updateServerValue(_ key: String, _ newValue: AnyCodableValue) { self.serverValues[key] = newValue DataConnectLogger.debug("BDO updateServerValue: \(key) \(newValue) for \(guid)") @@ -45,10 +50,6 @@ class BackingDataObject: CustomStringConvertible { """ } -} - -extension BackingDataObject: Encodable { - func encodableData() throws -> [String: AnyCodableValue] { var encodingValues = [String: AnyCodableValue]() encodingValues[GlobalIDKey] = .string(guid) @@ -57,12 +58,25 @@ extension BackingDataObject: Encodable { } public func encode(to encoder: Encoder) throws { - var container = encoder.singleValueContainer() - try container.encode(encodableData()) + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(guid, forKey: .globalID) + try container.encode(serverValues.rawCopy(), forKey: .serverValues) // once we have localValues, we will need to merge between the two dicts and encode } + + required init(from decoder: Decoder) throws { + var container = try decoder.container(keyedBy: CodingKeys.self) + + let globalId = try container.decode(String.self, forKey: .globalID) + self.guid = globalId + + let rawValues = try container.decode([String: AnyCodableValue].self, forKey: .serverValues) + serverValues.updateValues(rawValues) + } + } + extension BackingDataObject: Equatable { static func == (lhs: BackingDataObject, rhs: BackingDataObject) -> Bool { return lhs.guid == rhs.guid && lhs.serverValues.rawCopy() == rhs.serverValues.rawCopy() diff --git a/Sources/Cache/CacheProvider.swift b/Sources/Cache/CacheProvider.swift index cc278c9..84122e8 100644 --- a/Sources/Cache/CacheProvider.swift +++ b/Sources/Cache/CacheProvider.swift @@ -27,12 +27,12 @@ protocol CacheProvider { var cacheIdentifier: String { get } - func resultTree(queryId: String) -> ResultTreeEntry? - func setResultTree(queryId: String, tree: ResultTreeEntry) + func resultTree(queryId: String) -> ResultTree? + func setResultTree(queryId: String, tree: ResultTree) - func dataObject(entityGuid: String) -> BackingDataObject - func setObject(entityGuid: String, object: BackingDataObject) + func backingData(_ entityGuid: String) -> BackingDataObject + func updateBackingData(_ object: BackingDataObject) /* func size() -> Int diff --git a/Sources/Cache/DynamicCodingKey.swift b/Sources/Cache/DynamicCodingKey.swift new file mode 100644 index 0000000..7fd47c2 --- /dev/null +++ b/Sources/Cache/DynamicCodingKey.swift @@ -0,0 +1,21 @@ +// 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. + + +struct DynamicCodingKey: CodingKey { + var intValue: Int? + let stringValue: String + init?(intValue: Int) { return nil } + init?(stringValue: String) { self.stringValue = stringValue } +} diff --git a/Sources/Cache/EphemeralCacheProvider.swift b/Sources/Cache/EphemeralCacheProvider.swift index 6eab8d2..2b826d5 100644 --- a/Sources/Cache/EphemeralCacheProvider.swift +++ b/Sources/Cache/EphemeralCacheProvider.swift @@ -29,24 +29,24 @@ class EphemeralCacheProvider: CacheProvider, CustomStringConvertible { } // MARK: ResultTree - private var resultTreeCache = SynchronizedDictionary() + private var resultTreeCache = SynchronizedDictionary() func setResultTree( queryId: String, - tree: ResultTreeEntry + tree: ResultTree ) { resultTreeCache[queryId] = tree DataConnectLogger.debug("Update resultTreeEntry for \(queryId)") } - func resultTree(queryId: String) -> ResultTreeEntry? { + func resultTree(queryId: String) -> ResultTree? { return resultTreeCache[queryId] } // MARK: BackingDataObjects private var backingDataObjects = SynchronizedDictionary() - func dataObject(entityGuid: String) -> BackingDataObject { + func backingData(_ entityGuid: String) -> BackingDataObject { guard let dataObject = backingDataObjects[entityGuid] else { let bdo = BackingDataObject(guid: entityGuid) backingDataObjects[entityGuid] = bdo @@ -58,8 +58,8 @@ class EphemeralCacheProvider: CacheProvider, CustomStringConvertible { return dataObject } - func setObject(entityGuid: String, object: BackingDataObject) { - backingDataObjects[entityGuid] = object + func updateBackingData(_ object: BackingDataObject) { + backingDataObjects[object.guid] = object } diff --git a/Sources/Cache/CacheEntry.swift b/Sources/Cache/ResultTree.swift similarity index 53% rename from Sources/Cache/CacheEntry.swift rename to Sources/Cache/ResultTree.swift index e32c4d2..cf04533 100644 --- a/Sources/Cache/CacheEntry.swift +++ b/Sources/Cache/ResultTree.swift @@ -17,7 +17,7 @@ import Foundation import FirebaseCore @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -struct ResultTreeEntry { +struct ResultTree { let serverTimestamp: Timestamp // Server response timestamp let cachedAt: Date // Local time when the entry was cached / updated let ttl: TimeInterval // interval during which query results are considered fresh @@ -28,5 +28,33 @@ struct ResultTreeEntry { let now = Date() return now.timeIntervalSince(cachedAt) > ttl } + + enum CodingKeys: String, CodingKey { + case serverTimestamp = "st" + case cachedAt = "ca" + case ttl = "ttl" + case data = "d" + } + + + } + +extension ResultTree: Codable { + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.serverTimestamp = try container.decode(Timestamp.self, forKey: .serverTimestamp) + self.cachedAt = try container.decode(Date.self, forKey: .cachedAt) + self.ttl = try container.decode(TimeInterval.self, forKey: .ttl) + self.data = try container.decode(String.self, forKey: .data) + } + + func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(serverTimestamp, forKey: .serverTimestamp) + try container.encode(cachedAt, forKey: .cachedAt) + try container.encode(ttl, forKey: .ttl) + try container.encode(data, forKey: .data) + } +} diff --git a/Sources/Cache/ResultTreeProcessor.swift b/Sources/Cache/ResultTreeProcessor.swift index 8707dcc..3bb3a8f 100644 --- a/Sources/Cache/ResultTreeProcessor.swift +++ b/Sources/Cache/ResultTreeProcessor.swift @@ -15,7 +15,6 @@ import Foundation - // Normalization and recontruction of ResultTree struct ResultTreeProcessor { diff --git a/Sources/Cache/SQLiteCacheProvider.swift b/Sources/Cache/SQLiteCacheProvider.swift new file mode 100644 index 0000000..ff1d044 --- /dev/null +++ b/Sources/Cache/SQLiteCacheProvider.swift @@ -0,0 +1,201 @@ + +// 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 Foundation +import SQLite3 +import FirebaseCore + +class SQLiteCacheProvider: CacheProvider { + let cacheConfig: CacheConfig + let cacheIdentifier: String + + private var db: OpaquePointer? + private let queue = DispatchQueue(label: "com.google.firebase.dataconnect.sqlitecacheprovider") + + init(cacheConfig: CacheConfig, cacheIdentifier: String) throws { + self.cacheConfig = cacheConfig + self.cacheIdentifier = cacheIdentifier + + try queue.sync { + let path = NSSearchPathForDirectoriesInDomains( + .applicationSupportDirectory, .userDomainMask, true + ).first! + let dbURL = URL(fileURLWithPath: path).appendingPathComponent("\(cacheIdentifier).sqlite3") + + if sqlite3_open(dbURL.path, &db) != SQLITE_OK { + throw DataConnectInternalError.sqliteError(message: "Could not open database") + } + + try createTables() + } + } + + deinit { + sqlite3_close(db) + } + + private func createTables() throws { + dispatchPrecondition(condition: .onQueue(queue)) + + let createResultTreeTable = """ + CREATE TABLE IF NOT EXISTS result_tree ( + query_id TEXT PRIMARY KEY NOT NULL, + tree BLOB NOT NULL + ); + """ + if sqlite3_exec(db, createResultTreeTable, nil, nil, nil) != SQLITE_OK { + throw DataConnectInternalError.sqliteError(message: "Could not create result_tree table") + } + + let createBackingDataTable = """ + CREATE TABLE IF NOT EXISTS backing_data ( + entity_guid TEXT PRIMARY KEY NOT NULL, + object BLOB NOT NULL + ); + """ + if sqlite3_exec(db, createBackingDataTable, nil, nil, nil) != SQLITE_OK { + throw DataConnectInternalError.sqliteError(message: "Could not create backing_data table") + } + } + + func resultTree(queryId: String) -> ResultTree? { + return queue.sync { + let query = "SELECT tree FROM result_tree WHERE query_id = ?;" + var statement: OpaquePointer? + + if sqlite3_prepare_v2(db, query, -1, &statement, nil) != SQLITE_OK { + DataConnectLogger.error("Error preparing select statement for result_tree") + return nil + } + + sqlite3_bind_text(statement, 1, (queryId as NSString).utf8String, -1, nil) + + if sqlite3_step(statement) == SQLITE_ROW { + if let dataBlob = sqlite3_column_blob(statement, 0) { + let dataBlobLength = sqlite3_column_bytes(statement, 0) + let data = Data(bytes: dataBlob, count: Int(dataBlobLength)) + sqlite3_finalize(statement) + do { + return try JSONDecoder().decode(ResultTree.self, from: data) + } catch { + DataConnectLogger.error("Error decoding result tree for queryId \(queryId): \(error)") + return nil + } + } + } + + sqlite3_finalize(statement) + DataConnectLogger.debug("\(#function) no result tree found for queryId \(queryId)") + return nil + } + } + + func setResultTree(queryId: String, tree: ResultTree) { + queue.sync { + do { + let data = try JSONEncoder().encode(tree) + let insert = "INSERT OR REPLACE INTO result_tree (query_id, tree) VALUES (?, ?);" + var statement: OpaquePointer? + + if sqlite3_prepare_v2(db, insert, -1, &statement, nil) != SQLITE_OK { + DataConnectLogger.error("Error preparing insert statement for result_tree") + return + } + + sqlite3_bind_text(statement, 1, (queryId as NSString).utf8String, -1, nil) + _ = data.withUnsafeBytes { (bytes: UnsafeRawBufferPointer) in + sqlite3_bind_blob(statement, 2, bytes.baseAddress, Int32(bytes.count), nil) + } + + if sqlite3_step(statement) != SQLITE_DONE { + DataConnectLogger.error("Error inserting result tree for queryId \(queryId)") + } + + sqlite3_finalize(statement) + + DataConnectLogger.debug("\(#function) - query \(queryId), tree \(tree)") + } catch { + DataConnectLogger.error("Error encoding result tree for queryId \(queryId): \(error)") + } + } + } + + func backingData(_ entityGuid: String) -> BackingDataObject { + return queue.sync { + let query = "SELECT object FROM backing_data WHERE entity_guid = ?;" + var statement: OpaquePointer? + + if sqlite3_prepare_v2(db, query, -1, &statement, nil) != SQLITE_OK { + DataConnectLogger.error("Error preparing select statement for backing_data") + } else { + sqlite3_bind_text(statement, 1, (entityGuid as NSString).utf8String, -1, nil) + + if sqlite3_step(statement) == SQLITE_ROW { + if let dataBlob = sqlite3_column_blob(statement, 0) { + let dataBlobLength = sqlite3_column_bytes(statement, 0) + let data = Data(bytes: dataBlob, count: Int(dataBlobLength)) + sqlite3_finalize(statement) + do { + let bdo = try JSONDecoder().decode(BackingDataObject.self, from: data) + DataConnectLogger.debug("Returning existing BDO for \(entityGuid)") + return bdo + } catch { + DataConnectLogger.error("Error decoding data object for entityGuid \(entityGuid): \(error)") + } + } + } + sqlite3_finalize(statement) + } + + let bdo = BackingDataObject(guid: entityGuid) + _setObject(entityGuid: entityGuid, object: bdo) + DataConnectLogger.debug("Created BDO for \(entityGuid)") + return bdo + } + } + + private func _setObject(entityGuid: String, object: BackingDataObject) { + dispatchPrecondition(condition: .onQueue(queue)) + do { + let data = try JSONEncoder().encode(object) + let insert = "INSERT OR REPLACE INTO backing_data (entity_guid, object) VALUES (?, ?);" + var statement: OpaquePointer? + + if sqlite3_prepare_v2(db, insert, -1, &statement, nil) != SQLITE_OK { + DataConnectLogger.error("Error preparing insert statement for backing_data") + return + } + + sqlite3_bind_text(statement, 1, (entityGuid as NSString).utf8String, -1, nil) + data.withUnsafeBytes { (bytes: UnsafeRawBufferPointer) in + sqlite3_bind_blob(statement, 2, bytes.baseAddress, Int32(bytes.count), nil) + } + + if sqlite3_step(statement) != SQLITE_DONE { + DataConnectLogger.error("Error inserting data object for entityGuid \(entityGuid)") + } + + sqlite3_finalize(statement) + } catch { + DataConnectLogger.error("Error encoding data object for entityGuid \(entityGuid): \(error)") + } + } + + func updateBackingData(_ object: BackingDataObject) { + queue.sync { + _setObject(entityGuid: object.guid, object: object) + } + } +} diff --git a/Sources/Cache/StubDataObject.swift b/Sources/Cache/StubDataObject.swift index bebbd10..8ae3ddb 100644 --- a/Sources/Cache/StubDataObject.swift +++ b/Sources/Cache/StubDataObject.swift @@ -58,13 +58,6 @@ struct StubDataObject { case references case scalars } - - struct DynamicKey: CodingKey { - var intValue: Int? - let stringValue: String - init?(intValue: Int) { return nil } - init?(stringValue: String) { self.stringValue = stringValue } - } init?(value: AnyCodableValue, cacheProvider: CacheProvider) { guard case let .dictionary(objectValues) = value else { @@ -73,11 +66,11 @@ struct StubDataObject { } if case let .string(guid) = objectValues[GlobalIDKey] { - backingData = cacheProvider.dataObject(entityGuid: guid) + backingData = cacheProvider.backingData(guid) } else if case let .uuid(guid) = objectValues[GlobalIDKey] { // underlying deserializer is detecting a uuid and converting it. // TODO: Remove once server starts to send the real GlobalID - backingData = cacheProvider.dataObject(entityGuid: guid.uuidString) + backingData = cacheProvider.backingData(guid.uuidString) } for (key, value) in objectValues { @@ -114,8 +107,11 @@ struct StubDataObject { } else { scalars[key] = value } - } + } //for (key,value) + + if let backingData { + cacheProvider.updateBackingData(backingData) } } } @@ -152,7 +148,7 @@ extension StubDataObject: Decodable { let container = try decoder.container(keyedBy: CodingKeys.self) if let globalID = try container.decodeIfPresent(String.self, forKey: .globalID) { - self.backingData = cacheProvider.dataObject(entityGuid: globalID) + self.backingData = cacheProvider.backingData(globalID) } if let refs = try container.decodeIfPresent([String: StubDataObject].self, forKey: .references) { @@ -179,30 +175,30 @@ extension StubDataObject: Encodable { if resultTreeKind == .hydrated { //var container = encoder.singleValueContainer() - var container = encoder.container(keyedBy: DynamicKey.self) + var container = encoder.container(keyedBy: DynamicCodingKey.self) if let backingData { let encodableData = try backingData.encodableData() for (key, value) in encodableData { - try container.encode(value, forKey: DynamicKey(stringValue: key)!) + try container.encode(value, forKey: DynamicCodingKey(stringValue: key)!) } } if references.count > 0 { for (key, value) in references { - try container.encode(value, forKey: DynamicKey(stringValue: key)!) + try container.encode(value, forKey: DynamicCodingKey(stringValue: key)!) } } if objectLists.count > 0 { for (key, value) in objectLists { - try container.encode(value, forKey: DynamicKey(stringValue: key)!) + try container.encode(value, forKey: DynamicCodingKey(stringValue: key)!) } } if scalars.count > 0 { for (key, value) in scalars { - try container.encode(value, forKey: DynamicKey(stringValue: key)!) + try container.encode(value, forKey: DynamicCodingKey(stringValue: key)!) } } } else { diff --git a/Sources/DataConnect.swift b/Sources/DataConnect.swift index 84476b1..678e6a9 100644 --- a/Sources/DataConnect.swift +++ b/Sources/DataConnect.swift @@ -96,13 +96,22 @@ public class DataConnect { if let ccfg = cacheConfig { switch ccfg.type { - case .ephemeral, .persistent : + case .ephemeral : cacheProvider = EphemeralCacheProvider( cacheConfig: ccfg, cacheIdentifier: DataConnect.contructCacheIdentifier(app: app, settings: settings) ) + case .persistent: + do { + self.cacheProvider = try SQLiteCacheProvider( + cacheConfig: ccfg, + cacheIdentifier: DataConnect.contructCacheIdentifier(app: app, settings: settings) + ) + } catch { + DataConnectLogger.error("Unable to initialize Persistent provider \(error)") + } } - DataConnectLogger.debug("Create cacheProvider \(self.cacheProvider)") + DataConnectLogger.debug("Created cacheProvider for emulator \(self.cacheProvider)") } operationsManager = OperationsManager( @@ -141,12 +150,18 @@ public class DataConnect { cacheConfig: cacheConfig, cacheIdentifier: DataConnect.contructCacheIdentifier(app: app, settings: settings) ) + DataConnectLogger.debug("Created Ephemeral Provider") case .persistent: // TODO: Update to SQLiteProvider once implemented - self.cacheProvider = EphemeralCacheProvider( - cacheConfig: cacheConfig, - cacheIdentifier: DataConnect.contructCacheIdentifier(app: app, settings: settings) - ) + do { + self.cacheProvider = try SQLiteCacheProvider( + cacheConfig: cacheConfig, + cacheIdentifier: DataConnect.contructCacheIdentifier(app: app, settings: settings) + ) + DataConnectLogger.debug("Created Persistent provider") + } catch { + DataConnectLogger.error("Unable to initialize Persistent provider \(error)") + } } DataConnectLogger.debug("Initialized cacheProvider \(self.cacheProvider)") } diff --git a/Sources/DataConnectError.swift b/Sources/DataConnectError.swift index 01c2a8a..684591d 100644 --- a/Sources/DataConnectError.swift +++ b/Sources/DataConnectError.swift @@ -68,7 +68,7 @@ public extension DataConnectDomainError { /// A type that represents an error code within an error domain. @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public protocol DataConnectErrorCode: CustomStringConvertible, Equatable, Sendable, CaseIterable {} +public protocol DataConnectErrorCode: CustomStringConvertible, Equatable, Sendable, CaseIterable {} // MARK: - Data Connect Initialization Errors @@ -271,3 +271,41 @@ public struct OperationFailureResponse: Sendable { public let path: [DataConnectPathSegment] } } + +// MARK: - Internal Errors + +public struct DataConnectInternalError: DataConnectDomainError { + public struct Code: DataConnectErrorCode { + private let code: String + private init(_ code: String) { self.code = code } + + public static let internalError = Code("internalError") + public static let sqliteError = Code("sqliteError") + + public static var allCases: [DataConnectInternalError.Code] { + return [internalError, sqliteError] + } + + public var description: String { return code } + } + + public let code: Code + public let message: String? + public let underlyingError: Error? + + private init(code: Code, message: String? = nil, cause: Error? = nil) { + self.code = code + self.message = message + underlyingError = cause + } + + static func internalError(message: String? = nil, + cause: Error? = nil) -> DataConnectInternalError { + return DataConnectInternalError(code: .internalError, message: message, cause: cause) + } + + static func sqliteError(message: String? = nil, + cause: Error? = nil) -> DataConnectInternalError { + return DataConnectInternalError(code: .sqliteError, message: message, cause: cause) + } +} diff --git a/Sources/Internal/SynchronizedDictionary.swift b/Sources/Internal/SynchronizedDictionary.swift index 1da6002..8811f3c 100644 --- a/Sources/Internal/SynchronizedDictionary.swift +++ b/Sources/Internal/SynchronizedDictionary.swift @@ -34,14 +34,10 @@ class SynchronizedDictionary { return queue.sync { self.dictionary } } - -} - -extension SynchronizedDictionary: Encodable where Value: Encodable, Key: Encodable { - func encode(to encoder: any Encoder) throws { - var container = encoder.singleValueContainer() - try queue.sync { - try container.encode(dictionary) + func updateValues(_ values: [Key: Value]) { + queue.async(flags: .barrier) { + self.dictionary.merge(values) { (_, new) in new } } } + } diff --git a/Sources/Queries/QueryRef.swift b/Sources/Queries/QueryRef.swift index 2c1cae8..ba33c49 100644 --- a/Sources/Queries/QueryRef.swift +++ b/Sources/Queries/QueryRef.swift @@ -1,16 +1,3 @@ -// 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 Foundation import CryptoKit @@ -34,73 +21,11 @@ public enum ResultsPublisherType { case observableMacro } -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -struct QueryRequest: OperationRequest, Hashable, Equatable { - private(set) var operationName: String - private(set) var variables: Variable? - - - // Computed requestId - lazy var requestId: String = { - var keyIdData = Data() - if let nameData = operationName.data(using: .utf8) { - keyIdData.append(nameData) - } - - if let variables { - let encoder = JSONEncoder() - encoder.outputFormatting = .sortedKeys - do { - let jsonData = try encoder.encode(variables) - keyIdData.append(jsonData) - } catch { - DataConnectLogger.logger - .warning("Error encoding variables to compute request identifier: \(error)") - } - } - - let hashDigest = SHA256.hash(data: keyIdData) - let hashString = hashDigest.compactMap{ String(format: "%02x", $0) }.joined() - - return hashString - }() - - init(operationName: String, variables: Variable? = nil) { - self.operationName = operationName - self.variables = variables - } - - // Hashable and Equatable implementation - func hash(into hasher: inout Hasher) { - hasher.combine(operationName) - if let variables { - hasher.combine(variables) - } - } - - static func == (lhs: QueryRequest, rhs: QueryRequest) -> Bool { - guard lhs.operationName == rhs.operationName else { - return false - } - - if lhs.variables == nil && rhs.variables == nil { - return true - } - - guard let lhsVar = lhs.variables, - let rhsVar = rhs.variables, - lhsVar == rhsVar else { - return false - } - - return true - } -} @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) public protocol QueryRef: OperationRef { // This call starts query execution and publishes data - func subscribe() async throws -> AnyPublisher, Never> + func subscribe() async throws -> AnyPublisher, AnyDataConnectError>, Never> // Execute override for queries to include fetch policy func execute(fetchPolicy: QueryFetchPolicy) async throws -> OperationResult @@ -115,7 +40,7 @@ extension QueryRef { @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) actor GenericQueryRef: QueryRef { - private let resultsPublisher = PassthroughSubject, + private let resultsPublisher = PassthroughSubject, AnyDataConnectError>, Never>() private var request: QueryRequest @@ -134,13 +59,14 @@ actor GenericQueryRef AnyPublisher, Never> { - /* + public func subscribe() -> AnyPublisher, AnyDataConnectError>, Never> { Task { do { - //_ = try await fetchServerResults() + _ = try await fetchCachedResults(allowStale: true) + try await Task.sleep(nanoseconds: 3000_000_000) //3secs + _ = try await fetchServerResults() } catch {} - }*/ + } return resultsPublisher.eraseToAnyPublisher() } @@ -190,7 +116,6 @@ actor GenericQueryRef OperationResult { @@ -240,16 +166,17 @@ actor GenericQueryRef) async { resultsPublisher.send(.success(data)) } } @@ -258,6 +185,9 @@ actor GenericQueryRef AnyPublisher, Never> { + -> AnyPublisher, AnyDataConnectError>, Never> { return await baseRef.subscribe() } } @@ -388,7 +322,8 @@ public class QueryRefObservation< .sink(receiveValue: { result in switch result { case let .success(resultData): - self.data = resultData + self.data = resultData.data + self.source = resultData.source self.lastError = nil case let .failure(dcerror): self.lastError = dcerror.dataConnectError @@ -405,6 +340,9 @@ public class QueryRefObservation< /// Error thrown if error occurs during execution of query. If the last fetch was successful the /// error is cleared public private(set) var lastError: DataConnectError? + + /// Source of the query results (server, local cache, ...) + public private(set) var source: QueryResultSource = .unknown // QueryRef implementation @@ -419,7 +357,7 @@ public class QueryRefObservation< /// Use this function ONLY if you plan to use the Query Ref outside of SwiftUI context - (UIKit, /// background updates,...) public func subscribe() async throws - -> AnyPublisher, Never> { + -> AnyPublisher, AnyDataConnectError>, Never> { return await baseRef.subscribe() } } diff --git a/Sources/Queries/QueryRequest.swift b/Sources/Queries/QueryRequest.swift new file mode 100644 index 0000000..a82ae42 --- /dev/null +++ b/Sources/Queries/QueryRequest.swift @@ -0,0 +1,82 @@ +// 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 Foundation +import CryptoKit + +import Firebase + +/// A QueryRequest is used to get a QueryRef to a Data Connect query using the specified query name +/// and input variables to the query +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +struct QueryRequest: OperationRequest, Hashable, Equatable { + private(set) var operationName: String + private(set) var variables: Variable? + + // Computed requestId + lazy var requestId: String = { + var keyIdData = Data() + if let nameData = operationName.data(using: .utf8) { + keyIdData.append(nameData) + } + + if let variables { + let encoder = JSONEncoder() + encoder.outputFormatting = .sortedKeys + do { + let jsonData = try encoder.encode(variables) + keyIdData.append(jsonData) + } catch { + DataConnectLogger.logger + .warning("Error encoding variables to compute request identifier: \(error)") + } + } + + let hashDigest = SHA256.hash(data: keyIdData) + let hashString = hashDigest.compactMap{ String(format: "%02x", $0) }.joined() + + return hashString + }() + + init(operationName: String, variables: Variable? = nil) { + self.operationName = operationName + self.variables = variables + } + + // Hashable and Equatable implementation + func hash(into hasher: inout Hasher) { + hasher.combine(operationName) + if let variables { + hasher.combine(variables) + } + } + + static func == (lhs: QueryRequest, rhs: QueryRequest) -> Bool { + guard lhs.operationName == rhs.operationName else { + return false + } + + if lhs.variables == nil && rhs.variables == nil { + return true + } + + guard let lhsVar = lhs.variables, + let rhsVar = rhs.variables, + lhsVar == rhsVar else { + return false + } + + return true + } +} diff --git a/Sources/Queries/QueryResultSource.swift b/Sources/Queries/QueryResultSource.swift index 4d98b1b..f76eb1c 100644 --- a/Sources/Queries/QueryResultSource.swift +++ b/Sources/Queries/QueryResultSource.swift @@ -17,6 +17,9 @@ @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) public enum QueryResultSource: Sendable { + /// source not known or cannot be determined + case unknown + /// The query results are from server case server From ad71e3c97f998a705b01db5ebeba3db13f8d3cf6 Mon Sep 17 00:00:00 2001 From: Aashish Patil Date: Thu, 4 Sep 2025 14:43:51 -0700 Subject: [PATCH 06/38] Add last_accessed field --- Sources/Cache/ResultTree.swift | 8 +++-- Sources/Cache/SQLiteCacheProvider.swift | 46 ++++++++++++++++++++----- Sources/Queries/QueryRef.swift | 1 + 3 files changed, 44 insertions(+), 11 deletions(-) diff --git a/Sources/Cache/ResultTree.swift b/Sources/Cache/ResultTree.swift index cf04533..00ab97c 100644 --- a/Sources/Cache/ResultTree.swift +++ b/Sources/Cache/ResultTree.swift @@ -20,6 +20,7 @@ import FirebaseCore struct ResultTree { let serverTimestamp: Timestamp // Server response timestamp let cachedAt: Date // Local time when the entry was cached / updated + var lastAccessed: Date // Local time when the entry was read or updated let ttl: TimeInterval // interval during which query results are considered fresh let data: String // tree data var rootObject: StubDataObject? @@ -32,12 +33,11 @@ struct ResultTree { enum CodingKeys: String, CodingKey { case serverTimestamp = "st" case cachedAt = "ca" + case lastAccessed = "la" case ttl = "ttl" case data = "d" } - - - + } @@ -46,6 +46,7 @@ extension ResultTree: Codable { let container = try decoder.container(keyedBy: CodingKeys.self) self.serverTimestamp = try container.decode(Timestamp.self, forKey: .serverTimestamp) self.cachedAt = try container.decode(Date.self, forKey: .cachedAt) + self.lastAccessed = try container.decode(Date.self, forKey: .lastAccessed) self.ttl = try container.decode(TimeInterval.self, forKey: .ttl) self.data = try container.decode(String.self, forKey: .data) } @@ -54,6 +55,7 @@ extension ResultTree: Codable { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(serverTimestamp, forKey: .serverTimestamp) try container.encode(cachedAt, forKey: .cachedAt) + try container.encode(lastAccessed, forKey: .lastAccessed) try container.encode(ttl, forKey: .ttl) try container.encode(data, forKey: .data) } diff --git a/Sources/Cache/SQLiteCacheProvider.swift b/Sources/Cache/SQLiteCacheProvider.swift index ff1d044..173504f 100644 --- a/Sources/Cache/SQLiteCacheProvider.swift +++ b/Sources/Cache/SQLiteCacheProvider.swift @@ -35,7 +35,8 @@ class SQLiteCacheProvider: CacheProvider { let dbURL = URL(fileURLWithPath: path).appendingPathComponent("\(cacheIdentifier).sqlite3") if sqlite3_open(dbURL.path, &db) != SQLITE_OK { - throw DataConnectInternalError.sqliteError(message: "Could not open database") + throw DataConnectInternalError + .sqliteError(message: "Could not open database for identifier \(cacheIdentifier) at \(dbURL.path)") } try createTables() @@ -52,6 +53,7 @@ class SQLiteCacheProvider: CacheProvider { let createResultTreeTable = """ CREATE TABLE IF NOT EXISTS result_tree ( query_id TEXT PRIMARY KEY NOT NULL, + last_accessed REAL NOT NULL, tree BLOB NOT NULL ); """ @@ -62,6 +64,7 @@ class SQLiteCacheProvider: CacheProvider { let createBackingDataTable = """ CREATE TABLE IF NOT EXISTS backing_data ( entity_guid TEXT PRIMARY KEY NOT NULL, + object_state INTEGER DEFAULT 10, object BLOB NOT NULL ); """ @@ -69,6 +72,27 @@ class SQLiteCacheProvider: CacheProvider { throw DataConnectInternalError.sqliteError(message: "Could not create backing_data table") } } + + private func updateLastAccessedTime(forQueryId queryId: String) { + + dispatchPrecondition(condition: .onQueue(queue)) + let updateQuery = "UPDATE result_tree SET last_accessed = ? WHERE query_id = ?;" + var statement: OpaquePointer? + + if sqlite3_prepare_v2(self.db, updateQuery, -1, &statement, nil) != SQLITE_OK { + DataConnectLogger.error("Error preparing update statement for result_tree") + return + } + + sqlite3_bind_double(statement, 1, Date().timeIntervalSince1970) + sqlite3_bind_text(statement, 2, (queryId as NSString).utf8String, -1, nil) + + if sqlite3_step(statement) != SQLITE_DONE { + DataConnectLogger.error("Error updating last_accessed for query \(queryId)") + } + sqlite3_finalize(statement) + + } func resultTree(queryId: String) -> ResultTree? { return queue.sync { @@ -88,7 +112,9 @@ class SQLiteCacheProvider: CacheProvider { let data = Data(bytes: dataBlob, count: Int(dataBlobLength)) sqlite3_finalize(statement) do { - return try JSONDecoder().decode(ResultTree.self, from: data) + let tree = try JSONDecoder().decode(ResultTree.self, from: data) + self.updateLastAccessedTime(forQueryId: queryId) + return tree } catch { DataConnectLogger.error("Error decoding result tree for queryId \(queryId): \(error)") return nil @@ -105,8 +131,9 @@ class SQLiteCacheProvider: CacheProvider { func setResultTree(queryId: String, tree: ResultTree) { queue.sync { do { + var tree = tree let data = try JSONEncoder().encode(tree) - let insert = "INSERT OR REPLACE INTO result_tree (query_id, tree) VALUES (?, ?);" + let insert = "INSERT OR REPLACE INTO result_tree (query_id, last_accessed, tree) VALUES (?, ?, ?);" var statement: OpaquePointer? if sqlite3_prepare_v2(db, insert, -1, &statement, nil) != SQLITE_OK { @@ -114,11 +141,14 @@ class SQLiteCacheProvider: CacheProvider { return } + tree.lastAccessed = Date() + sqlite3_bind_text(statement, 1, (queryId as NSString).utf8String, -1, nil) - _ = data.withUnsafeBytes { (bytes: UnsafeRawBufferPointer) in - sqlite3_bind_blob(statement, 2, bytes.baseAddress, Int32(bytes.count), nil) - } - + sqlite3_bind_double(statement, 2, tree.lastAccessed.timeIntervalSince1970) + _ = data.withUnsafeBytes { (bytes: UnsafeRawBufferPointer) in + sqlite3_bind_blob(statement, 3, bytes.baseAddress, Int32(bytes.count), nil) + } + if sqlite3_step(statement) != SQLITE_DONE { DataConnectLogger.error("Error inserting result tree for queryId \(queryId)") } @@ -179,7 +209,7 @@ class SQLiteCacheProvider: CacheProvider { } sqlite3_bind_text(statement, 1, (entityGuid as NSString).utf8String, -1, nil) - data.withUnsafeBytes { (bytes: UnsafeRawBufferPointer) in + _ = data.withUnsafeBytes { (bytes: UnsafeRawBufferPointer) in sqlite3_bind_blob(statement, 2, bytes.baseAddress, Int32(bytes.count), nil) } diff --git a/Sources/Queries/QueryRef.swift b/Sources/Queries/QueryRef.swift index ba33c49..7fd26e9 100644 --- a/Sources/Queries/QueryRef.swift +++ b/Sources/Queries/QueryRef.swift @@ -124,6 +124,7 @@ actor GenericQueryRef Date: Wed, 24 Sep 2025 12:31:18 -0700 Subject: [PATCH 07/38] Uber Cache object, support Auth uid scope and Refactor classes --- Sources/Cache/Cache.swift | 149 +++++++++ Sources/Cache/CacheProvider.swift | 7 +- Sources/Cache/EphemeralCacheProvider.swift | 7 +- Sources/Cache/ResultTree.swift | 9 +- Sources/Cache/ResultTreeProcessor.swift | 9 +- Sources/Cache/SQLiteCacheProvider.swift | 5 +- Sources/Cache/StubDataObject.swift | 7 - Sources/DataConnect.swift | 61 +--- Sources/Internal/GrpcClient.swift | 4 +- Sources/Internal/HashUtils.swift | 32 ++ Sources/Internal/OperationsManager.swift | 10 +- Sources/Internal/ServerResponse.swift | 13 + Sources/Queries/GenericQueryRef.swift | 150 +++++++++ Sources/Queries/ObservableQueryRef.swift | 203 ++++++++++++ Sources/Queries/QueryFetchPolicy.swift | 2 +- Sources/Queries/QueryRef.swift | 339 +-------------------- Sources/Queries/QueryRequest.swift | 5 +- Tests/Unit/CacheTests.swift | 30 +- 18 files changed, 613 insertions(+), 429 deletions(-) create mode 100644 Sources/Cache/Cache.swift create mode 100644 Sources/Internal/HashUtils.swift create mode 100644 Sources/Internal/ServerResponse.swift create mode 100644 Sources/Queries/GenericQueryRef.swift create mode 100644 Sources/Queries/ObservableQueryRef.swift diff --git a/Sources/Cache/Cache.swift b/Sources/Cache/Cache.swift new file mode 100644 index 0000000..22623e6 --- /dev/null +++ b/Sources/Cache/Cache.swift @@ -0,0 +1,149 @@ +// 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 FirebaseAuth + +class Cache { + + let config: CacheConfig + let dataConnect: DataConnect + + private var cacheProvider: CacheProvider? + + private let queue = DispatchQueue(label: "com.google.firebase.dataconnect.cache") + + // holding it to avoid dereference + private var authChangeListenerProtocol: NSObjectProtocol? + + init(config: CacheConfig, dataConnect: DataConnect) { + self.config = config + self.dataConnect = dataConnect + + // sync because we want the provider initialized immediately when in init + queue.sync { + self.initializeCacheProvider() + setupChangeListeners() + } + + } + + private func initializeCacheProvider() { + + dispatchPrecondition(condition: .onQueue(queue)) + + let identifier = contructCacheIdentifier() + + // Create a cacheProvider if - + // we don't have an existing cacheProvider + // we have one but its identifier is different than new one (e.g. auth uid changed) + if cacheProvider != nil && cacheProvider?.cacheIdentifier == identifier { + return + } + + switch config.type { + case .ephemeral: + self.cacheProvider = EphemeralCacheProvider(identifier) + case .persistent: + do { + self.cacheProvider = try SQLiteCacheProvider(identifier) + } catch { + DataConnectLogger.error("Unable to initialize Persistent provider \(error)") + } + } + } + + private func setupChangeListeners() { + dispatchPrecondition(condition: .onQueue(queue)) + + authChangeListenerProtocol = Auth.auth(app: dataConnect.app).addStateDidChangeListener { _, _ in + self.queue.async(flags: .barrier) { + self.initializeCacheProvider() + } + } + } + + // Create an identifier for the cache that the Provider will use for cache scoping + private func contructCacheIdentifier() -> String { + dispatchPrecondition(condition: .onQueue(queue)) + + let identifier = "\(self.config.type)-\(String(describing: dataConnect.app.options.projectID))-\(Auth.auth(app: dataConnect.app).currentUser?.uid ?? "anon")-\(dataConnect.settings.host)" + let encoded = identifier.sha256 + DataConnectLogger.debug("Created Cache Identifier \(encoded) for \(identifier)") + return encoded + } + + func resultTree(queryId: String) -> ResultTree? { + // result trees are stored dehydrated in the cache + // retrieve cache, hydrate it and then return + queue.sync { + guard let dehydratedTree = cacheProvider?.resultTree(queryId: queryId) else { + return nil + } + + do { + let resultsProcessor = ResultTreeProcessor() + let (hydratedResults, rootObj) = try resultsProcessor.hydrateResults( + dehydratedTree.data, + cacheProvider: cacheProvider! + ) + + let hydratedTree = ResultTree( + data: hydratedResults, + ttl: dehydratedTree.ttl, + cachedAt: dehydratedTree.cachedAt, + lastAccessed: dehydratedTree.lastAccessed, + rootObject: rootObj + ) + + return hydratedTree + } catch { + DataConnectLogger.warning("Error getting result tree \(error)") + return nil + } + } + } + + func update(queryId: String, response: ServerResponse) { + queue.async(flags: .barrier) { + + + guard let cacheProvider = self.cacheProvider else { + DataConnectLogger.debug("Cache provider not initialized yet. Skipping update for \(queryId)") + return + } + do { + let processor = ResultTreeProcessor() + let (dehydratedResults, rootObj) = try processor.dehydrateResults( + response.jsonResults, + cacheProvider: cacheProvider + ) + + cacheProvider + .setResultTree( + queryId: queryId, + tree: .init( + data: dehydratedResults, + ttl: response.ttl, + cachedAt: Date(), + lastAccessed: Date(), + rootObject: rootObj + ) + ) + } catch { + DataConnectLogger.warning("Error updating cache for \(queryId): \(error)") + } + } + } + +} diff --git a/Sources/Cache/CacheProvider.swift b/Sources/Cache/CacheProvider.swift index 84122e8..c99ac88 100644 --- a/Sources/Cache/CacheProvider.swift +++ b/Sources/Cache/CacheProvider.swift @@ -16,15 +16,13 @@ import Foundation import FirebaseCore -// FDC field name that identifies a GlobalID +// FDC field name in server response that identifies a GlobalID let GlobalIDKey: String = "cacheId" let CacheProviderUserInfoKey = CodingUserInfoKey(rawValue: "fdc_cache_provider")! protocol CacheProvider { - var cacheConfig: CacheConfig { get } - var cacheIdentifier: String { get } func resultTree(queryId: String) -> ResultTree? @@ -33,8 +31,9 @@ protocol CacheProvider { func backingData(_ entityGuid: String) -> BackingDataObject func updateBackingData(_ object: BackingDataObject) - + /* + func size() -> Int */ } diff --git a/Sources/Cache/EphemeralCacheProvider.swift b/Sources/Cache/EphemeralCacheProvider.swift index 2b826d5..1d8af94 100644 --- a/Sources/Cache/EphemeralCacheProvider.swift +++ b/Sources/Cache/EphemeralCacheProvider.swift @@ -18,14 +18,12 @@ import FirebaseCore class EphemeralCacheProvider: CacheProvider, CustomStringConvertible { - let cacheConfig: CacheConfig let cacheIdentifier: String - init(cacheConfig: CacheConfig, cacheIdentifier: String) { - self.cacheConfig = cacheConfig + init(_ cacheIdentifier: String) { self.cacheIdentifier = cacheIdentifier - DataConnectLogger.debug("Initialized \(Self.Type.self) with config \(cacheConfig)") + DataConnectLogger.debug("Initialized \(Self.Type.self) with identifier:\(cacheIdentifier)") } // MARK: ResultTree @@ -62,7 +60,6 @@ class EphemeralCacheProvider: CacheProvider, CustomStringConvertible { backingDataObjects[object.guid] = object } - var description: String { return "EphemeralCacheProvider - \(cacheIdentifier)" } diff --git a/Sources/Cache/ResultTree.swift b/Sources/Cache/ResultTree.swift index 00ab97c..a0151e6 100644 --- a/Sources/Cache/ResultTree.swift +++ b/Sources/Cache/ResultTree.swift @@ -18,11 +18,11 @@ import FirebaseCore @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) struct ResultTree { - let serverTimestamp: Timestamp // Server response timestamp + let data: String // tree data - could be hydrated or dehydrated. + let ttl: TimeInterval // interval during which query results are considered fresh let cachedAt: Date // Local time when the entry was cached / updated var lastAccessed: Date // Local time when the entry was read or updated - let ttl: TimeInterval // interval during which query results are considered fresh - let data: String // tree data + var rootObject: StubDataObject? func isStale(_ ttl: TimeInterval) -> Bool { @@ -31,7 +31,6 @@ struct ResultTree { } enum CodingKeys: String, CodingKey { - case serverTimestamp = "st" case cachedAt = "ca" case lastAccessed = "la" case ttl = "ttl" @@ -44,7 +43,6 @@ struct ResultTree { extension ResultTree: Codable { init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - self.serverTimestamp = try container.decode(Timestamp.self, forKey: .serverTimestamp) self.cachedAt = try container.decode(Date.self, forKey: .cachedAt) self.lastAccessed = try container.decode(Date.self, forKey: .lastAccessed) self.ttl = try container.decode(TimeInterval.self, forKey: .ttl) @@ -53,7 +51,6 @@ extension ResultTree: Codable { func encode(to encoder: any Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(serverTimestamp, forKey: .serverTimestamp) try container.encode(cachedAt, forKey: .cachedAt) try container.encode(lastAccessed, forKey: .lastAccessed) try container.encode(ttl, forKey: .ttl) diff --git a/Sources/Cache/ResultTreeProcessor.swift b/Sources/Cache/ResultTreeProcessor.swift index 3bb3a8f..33a35f6 100644 --- a/Sources/Cache/ResultTreeProcessor.swift +++ b/Sources/Cache/ResultTreeProcessor.swift @@ -14,6 +14,14 @@ import Foundation +// Key that indicates the kind of tree being coded - hydrated or dehydrated +let ResultTreeKindCodingKey = CodingUserInfoKey(rawValue: "com.google.firebase.dataconnect.encodingMode")! + +// Kind of result data we are encoding from or decoding to +enum ResultTreeKind { + case hydrated // JSON data is full hydrated and contains full data in the tree + case dehydrated // JSON data is dehydrated and only contains refs to actual data objects +} // Normalization and recontruction of ResultTree struct ResultTreeProcessor { @@ -74,5 +82,4 @@ struct ResultTreeProcessor { return (hydratedResultsString, sdo) } - //func denormalize(_ tree: String) } diff --git a/Sources/Cache/SQLiteCacheProvider.swift b/Sources/Cache/SQLiteCacheProvider.swift index 173504f..b3fdd08 100644 --- a/Sources/Cache/SQLiteCacheProvider.swift +++ b/Sources/Cache/SQLiteCacheProvider.swift @@ -18,14 +18,13 @@ import SQLite3 import FirebaseCore class SQLiteCacheProvider: CacheProvider { - let cacheConfig: CacheConfig + let cacheIdentifier: String private var db: OpaquePointer? private let queue = DispatchQueue(label: "com.google.firebase.dataconnect.sqlitecacheprovider") - init(cacheConfig: CacheConfig, cacheIdentifier: String) throws { - self.cacheConfig = cacheConfig + init(_ cacheIdentifier: String) throws { self.cacheIdentifier = cacheIdentifier try queue.sync { diff --git a/Sources/Cache/StubDataObject.swift b/Sources/Cache/StubDataObject.swift index 8ae3ddb..718d1e0 100644 --- a/Sources/Cache/StubDataObject.swift +++ b/Sources/Cache/StubDataObject.swift @@ -27,13 +27,6 @@ */ -// Kind of result data we are encoding from or decoding to -enum ResultTreeKind { - case hydrated // JSON data is full hydrated and contains full data in the tree - case dehydrated // JSON data is dehydrated and only contains refs to actual data objects -} - -let ResultTreeKindCodingKey = CodingUserInfoKey(rawValue: "com.google.firebase.dataconnect.encodingMode")! struct StubDataObject { diff --git a/Sources/DataConnect.swift b/Sources/DataConnect.swift index 678e6a9..025d99a 100644 --- a/Sources/DataConnect.swift +++ b/Sources/DataConnect.swift @@ -19,15 +19,14 @@ import Foundation @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) public class DataConnect { - private var connectorConfig: ConnectorConfig - private var app: FirebaseApp - private var settings: DataConnectSettings + private(set) var connectorConfig: ConnectorConfig + private(set) var app: FirebaseApp + private(set) var settings: DataConnectSettings private(set) var grpcClient: GrpcClient private var operationsManager: OperationsManager - private(set) var cacheConfig: CacheConfig? = nil - private(set) var cacheProvider: CacheProvider? = nil + private(set) var cache: Cache? = nil private var callerSDKType: CallerSDKType = .base @@ -94,29 +93,14 @@ public class DataConnect { callerSDKType: callerSDKType ) - if let ccfg = cacheConfig { - switch ccfg.type { - case .ephemeral : - cacheProvider = EphemeralCacheProvider( - cacheConfig: ccfg, - cacheIdentifier: DataConnect.contructCacheIdentifier(app: app, settings: settings) - ) - case .persistent: - do { - self.cacheProvider = try SQLiteCacheProvider( - cacheConfig: ccfg, - cacheIdentifier: DataConnect.contructCacheIdentifier(app: app, settings: settings) - ) - } catch { - DataConnectLogger.error("Unable to initialize Persistent provider \(error)") - } - } - DataConnectLogger.debug("Created cacheProvider for emulator \(self.cacheProvider)") + // TODO: Change this + if let cache { + self.cache = Cache(config: cache.config, dataConnect: self) } operationsManager = OperationsManager( grpcClient: grpcClient, - cacheProvider: self.cacheProvider + cache: self.cache ) } } @@ -142,31 +126,12 @@ public class DataConnect { callerSDKType: self.callerSDKType ) - self.cacheConfig = cacheConfig + operationsManager = OperationsManager(grpcClient: grpcClient, cache: cache) + if let cacheConfig { - switch cacheConfig.type { - case .ephemeral: - self.cacheProvider = EphemeralCacheProvider( - cacheConfig: cacheConfig, - cacheIdentifier: DataConnect.contructCacheIdentifier(app: app, settings: settings) - ) - DataConnectLogger.debug("Created Ephemeral Provider") - case .persistent: - // TODO: Update to SQLiteProvider once implemented - do { - self.cacheProvider = try SQLiteCacheProvider( - cacheConfig: cacheConfig, - cacheIdentifier: DataConnect.contructCacheIdentifier(app: app, settings: settings) - ) - DataConnectLogger.debug("Created Persistent provider") - } catch { - DataConnectLogger.error("Unable to initialize Persistent provider \(error)") - } - } - DataConnectLogger.debug("Initialized cacheProvider \(self.cacheProvider)") + self.cache = Cache(config: cacheConfig, dataConnect: self) } - operationsManager = OperationsManager(grpcClient: grpcClient, cacheProvider: cacheProvider) } // MARK: Operations @@ -199,10 +164,6 @@ public class DataConnect { } } - // Create an identifier for the cache that the Provider will use for cache scoping - private static func contructCacheIdentifier(app: FirebaseApp, settings: DataConnectSettings) -> String { - return "\(app.name)-\(settings.host)" - } } // This enum is public so the gen sdk can access it diff --git a/Sources/Internal/GrpcClient.swift b/Sources/Internal/GrpcClient.swift index 79eb49f..b552f3c 100644 --- a/Sources/Internal/GrpcClient.swift +++ b/Sources/Internal/GrpcClient.swift @@ -130,7 +130,7 @@ actor GrpcClient: CustomStringConvertible { VariableType: OperationVariable>(request: QueryRequest, resultType: ResultType .Type) - async throws -> (results: String, ttl: TimeInterval, timestamp: Timestamp) { + async throws -> ServerResponse { guard let client else { DataConnectLogger.error("When calling executeQuery(), grpc client has not been configured.") throw DataConnectInitError.grpcNotConfigured() @@ -160,7 +160,7 @@ actor GrpcClient: CustomStringConvertible { */ guard !errorInfoList.isEmpty else { // TODO: Extract ttl, server timestamp - return (results: resultsString, ttl: 10.0, timestamp: Timestamp(date: Date())) + return ServerResponse(jsonResults: resultsString, ttl: 10.0) } // We have partial errors returned diff --git a/Sources/Internal/HashUtils.swift b/Sources/Internal/HashUtils.swift new file mode 100644 index 0000000..c469a03 --- /dev/null +++ b/Sources/Internal/HashUtils.swift @@ -0,0 +1,32 @@ +// 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 Foundation +import CryptoKit + +extension Data { + var sha256String: String { + let hashDigest = SHA256.hash(data: self) + let hashString = hashDigest.compactMap{ String(format: "%02x", $0) }.joined() + return hashString + } +} + +extension String { + var sha256: String { + let digest = SHA256.hash(data: self.data(using: .utf8)!) + let hashString = digest.compactMap { String(format: "%20x", $0)}.joined() + return hashString + } +} diff --git a/Sources/Internal/OperationsManager.swift b/Sources/Internal/OperationsManager.swift index f5375b0..3268064 100644 --- a/Sources/Internal/OperationsManager.swift +++ b/Sources/Internal/OperationsManager.swift @@ -18,7 +18,7 @@ import Foundation class OperationsManager { private var grpcClient: GrpcClient - private var cacheProvider: CacheProvider? + private var cache: Cache? private let queryRefAccessQueue = DispatchQueue( label: "firebase.dataconnect.queryRef.AccessQ", @@ -32,9 +32,9 @@ class OperationsManager { ) private var mutationRefs = [AnyHashable: any OperationRef]() - init(grpcClient: GrpcClient, cacheProvider: CacheProvider? = nil) { + init(grpcClient: GrpcClient, cache: Cache? = nil) { self.grpcClient = grpcClient - self.cacheProvider = cacheProvider + self.cache = cache } func queryRef: QueryRef { + + private let resultsPublisher = PassthroughSubject, AnyDataConnectError>, + Never>() + + private var request: QueryRequest + + private let grpcClient: GrpcClient + + private let cache: Cache? + + private var ttl: TimeInterval? = 10.0 // + + init(request: QueryRequest, grpcClient: GrpcClient, cache: Cache? = nil) { + self.request = request + self.grpcClient = grpcClient + self.cache = cache + } + + // This call starts query execution and publishes data to data var + // In v0, it simply reloads query results + public func subscribe() -> AnyPublisher, AnyDataConnectError>, Never> { + Task { + do { + _ = try await fetchCachedResults(allowStale: true) + try await Task.sleep(nanoseconds: 3000_000_000) //3secs + _ = try await fetchServerResults() + } catch {} + } + return resultsPublisher.eraseToAnyPublisher() + } + + // one-shot execution. It will fetch latest data, update any caches + // and updates the published data var + public func execute(fetchPolicy: QueryFetchPolicy = .defaultPolicy) async throws -> OperationResult { + + switch fetchPolicy { + case .defaultPolicy: + let cachedResult = try await fetchCachedResults(allowStale: false) + if cachedResult.data != nil { + return cachedResult + } else { + do { + let serverResults = try await fetchServerResults() + return serverResults + } catch let dcerr as DataConnectOperationError { + // TODO: Catch network specific error looking for deadline exceeded + /* + if dcErr is deadlineExceeded { + try await fetchCachedResults(allowStale: true) + } else rethrow + */ + throw dcerr + } + } + case .cache: + let cachedResult = try await fetchCachedResults(allowStale: true) + return cachedResult + case .server: + let serverResults = try await fetchServerResults() + return serverResults + } + } + + private func fetchServerResults() async throws -> OperationResult { + let response = try await grpcClient.executeQuery( + request: request, + resultType: ResultData.self + ) + + do { + if let cache { + // TODO: Normalize data before saving to cache + + + // TODO: Use server timestamp when available + try cache.update(queryId: self.request.requestId, response: response) + + } + } + + let decoder = JSONDecoder() + let decodedData = try decoder.decode( + ResultData.self, + from: response.jsonResults.data(using: .utf8)! + ) + + let result = OperationResult(data: decodedData, source: .server) + // send to subscribers + await updateData(data: result) + + return result + } + + private func fetchCachedResults(allowStale: Bool) async throws -> OperationResult { + guard let cache, + let ttl, + ttl > 0 else { + DataConnectLogger.info("No cache provider configured or ttl is not set \(ttl)") + return OperationResult(data: nil, source: .cache(stale: false)) + } + + if let cacheEntry = cache.resultTree(queryId: self.request.requestId), + (cacheEntry.isStale(ttl) && allowStale) || !cacheEntry.isStale(ttl) + { + let stale = cacheEntry.isStale(ttl) + + let decoder = JSONDecoder() + let decodedData = try decoder.decode( + ResultData.self, + from: cacheEntry.data.data(using: .utf8)! + ) + + let result = OperationResult(data: decodedData, source: .cache(stale: stale)) + // send to subscribers + await updateData(data: result) + + return result + } + + return OperationResult(data: nil, source: .cache(stale: false)) + } + + func updateData(data: OperationResult) async { + resultsPublisher.send(.success(data)) + } +} diff --git a/Sources/Queries/ObservableQueryRef.swift b/Sources/Queries/ObservableQueryRef.swift new file mode 100644 index 0000000..11af6d8 --- /dev/null +++ b/Sources/Queries/ObservableQueryRef.swift @@ -0,0 +1,203 @@ +// 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 Foundation +import CryptoKit + +import Firebase + +@preconcurrency import Combine +import Observation + + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +public protocol ObservableQueryRef: QueryRef { + // results of fetch. + var data: ResultData? { get } + + // source of the query results (server, cache, ) + var source: QueryResultSource { get } + + // last error received. if last fetch was successful this is cleared + var lastError: DataConnectError? { get } +} + +/// QueryRef class compatible with ObservableObject protocol +/// +/// When the requested publisher is an ObservableObject, the returned query refs will be instances +/// of this class +/// +/// This class cannot be instantiated directly. To get an instance, call the +/// ``DataConnect/dataConnect(...)`` function +/// +/// This class publishes two vars +/// - ``data``: Published variable that contains bindable results of the query. +/// - ``lastError``: Published variable that contains ``DataConnectError`` if last fetch had error. +/// If last fetch was successful, this variable is cleared +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +public class QueryRefObservableObject< + ResultData: Decodable & Sendable, + Variable: OperationVariable +>: ObservableObject, ObservableQueryRef { + private var request: QueryRequest + + private var baseRef: GenericQueryRef + + private var resultsCancellable: AnyCancellable? + + init( + request: QueryRequest, + dataType: ResultData.Type, + grpcClient: GrpcClient, + cache: Cache? + ) { + self.request = request + baseRef = GenericQueryRef( + request: request, + grpcClient: grpcClient, + cache: cache + ) + setupSubscription() + } + + private func setupSubscription() { + Task { + resultsCancellable = await baseRef.subscribe() + .receive(on: DispatchQueue.main) + .sink(receiveValue: { result in + switch result { + case let .success(operationResult): + self.data = operationResult.data + self.source = operationResult.source + self.lastError = nil + case let .failure(dcerror): + self.lastError = dcerror.dataConnectError + } + }) + } + } + + // ObservableQueryRef implementation + + /// data published by query of type `ResultData` + @Published public private(set) var data: ResultData? + + /// Error thrown if error occurs during execution of query. If the last fetch was successful the + /// error is cleared + @Published public private(set) var lastError: DataConnectError? + + /// Source of the query results (server, local cache, ...) + @Published public private(set) var source: QueryResultSource = .unknown + + // QueryRef implementation + + /// Executes the query and returns `ResultData`. This will also update the published `data` + /// variable + public func execute(fetchPolicy: QueryFetchPolicy = .defaultPolicy) async throws -> OperationResult { + let result = try await baseRef.execute(fetchPolicy: fetchPolicy) + return result + } + + /// Returns the underlying results publisher. + /// Use this function ONLY if you plan to use the Query Ref outside of SwiftUI context - (UIKit, + /// background updates,...) + public func subscribe() async throws + -> AnyPublisher, AnyDataConnectError>, Never> { + return await baseRef.subscribe() + } +} + +/// QueryRef class compatible with the Observation framework introduced in iOS 17 +/// +/// When the requested publisher is an ObservableMacri, the returned query refs will be instances +/// of this class +/// +/// This class cannot be instantiated directly. To get an instance, call the +/// ``DataConnect/dataConnect(...)`` function +/// +/// This class publishes two vars +/// - ``data``: Published variable that contains bindable results of the query. +/// - ``lastError``: Published variable that contains ``DataConnectError`` if last fetch had error. +/// If last fetch was successful, this variable is cleared +@available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) +@Observable +public class QueryRefObservation< + ResultData: Decodable & Sendable, + Variable: OperationVariable +>: ObservableQueryRef { + @ObservationIgnored + private var request: QueryRequest + + @ObservationIgnored + private var baseRef: GenericQueryRef + + @ObservationIgnored + private var resultsCancellable: AnyCancellable? + + init(request: QueryRequest, dataType: ResultData.Type, grpcClient: GrpcClient, cache: Cache?) { + self.request = request + baseRef = GenericQueryRef( + request: request, + grpcClient: grpcClient, + cache: cache + ) + setupSubscription() + } + + private func setupSubscription() { + Task { + resultsCancellable = await baseRef.subscribe() + .receive(on: DispatchQueue.main) + .sink(receiveValue: { result in + switch result { + case let .success(resultData): + self.data = resultData.data + self.source = resultData.source + self.lastError = nil + case let .failure(dcerror): + self.lastError = dcerror.dataConnectError + } + }) + } + } + + // ObservableQueryRef implementation + + /// data published by query of type `ResultData` + public private(set) var data: ResultData? + + /// Error thrown if error occurs during execution of query. If the last fetch was successful the + /// error is cleared + public private(set) var lastError: DataConnectError? + + /// Source of the query results (server, local cache, ...) + public private(set) var source: QueryResultSource = .unknown + + // QueryRef implementation + + /// Executes the query and returns `ResultData`. This will also update the published `data` + /// variable + public func execute(fetchPolicy: QueryFetchPolicy = .defaultPolicy) async throws -> OperationResult { + let result = try await baseRef.execute(fetchPolicy: fetchPolicy) + return result + } + + /// Returns the underlying results publisher. + /// Use this function ONLY if you plan to use the Query Ref outside of SwiftUI context - (UIKit, + /// background updates,...) + public func subscribe() async throws + -> AnyPublisher, AnyDataConnectError>, Never> { + return await baseRef.subscribe() + } +} diff --git a/Sources/Queries/QueryFetchPolicy.swift b/Sources/Queries/QueryFetchPolicy.swift index b822e6e..ee9807f 100644 --- a/Sources/Queries/QueryFetchPolicy.swift +++ b/Sources/Queries/QueryFetchPolicy.swift @@ -1,4 +1,4 @@ -// Copyright 2024 Google LLC +// 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. diff --git a/Sources/Queries/QueryRef.swift b/Sources/Queries/QueryRef.swift index 7fd26e9..93cff22 100644 --- a/Sources/Queries/QueryRef.swift +++ b/Sources/Queries/QueryRef.swift @@ -1,3 +1,16 @@ +// 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 Foundation import CryptoKit @@ -32,333 +45,11 @@ public protocol QueryRef: OperationRef { } extension QueryRef { + + // default implementation for execute() public func execute() async throws -> OperationResult { try await execute(fetchPolicy: .defaultPolicy) } } -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -actor GenericQueryRef: QueryRef { - - private let resultsPublisher = PassthroughSubject, AnyDataConnectError>, - Never>() - - private var request: QueryRequest - - private let grpcClient: GrpcClient - - private let cacheProvider: CacheProvider? - - private var ttl: TimeInterval? = 10.0 // - - init(request: QueryRequest, grpcClient: GrpcClient, cacheProvider: CacheProvider? = nil) { - self.request = request - self.grpcClient = grpcClient - self.cacheProvider = cacheProvider - } - - // This call starts query execution and publishes data to data var - // In v0, it simply reloads query results - public func subscribe() -> AnyPublisher, AnyDataConnectError>, Never> { - Task { - do { - _ = try await fetchCachedResults(allowStale: true) - try await Task.sleep(nanoseconds: 3000_000_000) //3secs - _ = try await fetchServerResults() - } catch {} - } - return resultsPublisher.eraseToAnyPublisher() - } - - // one-shot execution. It will fetch latest data, update any caches - // and updates the published data var - public func execute(fetchPolicy: QueryFetchPolicy = .defaultPolicy) async throws -> OperationResult { - - switch fetchPolicy { - case .defaultPolicy: - let cachedResult = try await fetchCachedResults(allowStale: false) - if cachedResult.data != nil { - return cachedResult - } else { - do { - let serverResults = try await fetchServerResults() - return serverResults - } catch let dcerr as DataConnectOperationError { - // TODO: Catch network specific error looking for deadline exceeded - /* - if dcErr is deadlineExceeded { - try await fetchCachedResults(allowStale: true) - } else rethrow - */ - throw dcerr - } - } - case .cache: - let cachedResult = try await fetchCachedResults(allowStale: true) - return cachedResult - case .server: - let serverResults = try await fetchServerResults() - return serverResults - } - } - - private func fetchServerResults() async throws -> OperationResult { - let results = try await grpcClient.executeQuery( - request: request, - resultType: ResultData.self - ) - - do { - if let cacheProvider { - // TODO: Normalize data before saving to cache - let processor = ResultTreeProcessor() - let (dehydratedTree, sdo) = try processor.dehydrateResults( - results.results, - cacheProvider: cacheProvider - ) - - // TODO: Use server timestamp when available - cacheProvider - .setResultTree( - queryId: self.request.requestId, - tree: .init( - serverTimestamp: results.timestamp, - cachedAt: Date(), - lastAccessed: Date(), - ttl: results.ttl, - data: dehydratedTree, - rootObject: sdo - ) - ) - } - } - - print("ResultData type \(ResultData.self)") - print("Returned JSON \(results.results)") - let decoder = JSONDecoder() - let decodedData = try decoder.decode(ResultData.self, from: results.results.data(using: .utf8)!) - - let result = OperationResult(data: decodedData, source: .server) - // send to subscribers - await updateData(data: result) - - return result - } - - private func fetchCachedResults(allowStale: Bool) async throws -> OperationResult { - guard let cacheProvider, - let ttl, - ttl > 0 else { - DataConnectLogger.info("No cache provider configured or ttl is not set \(ttl)") - return OperationResult(data: nil, source: .cache(stale: false)) - } - - if let cacheEntry = cacheProvider.resultTree(queryId: self.request.requestId), - (cacheEntry.isStale(ttl) && allowStale) || !cacheEntry.isStale(ttl) - { - let stale = cacheEntry.isStale(ttl) - - let resultTreeProcessor = ResultTreeProcessor() - let (hydratedTree, _) = try resultTreeProcessor.hydrateResults( - cacheEntry.data, - cacheProvider: cacheProvider - ) - - let decoder = JSONDecoder() - let decodedData = try decoder.decode(ResultData.self, from: hydratedTree.data(using: .utf8)!) - - let result = OperationResult(data: decodedData, source: .cache(stale: stale)) - // send to subscribers - await updateData(data: result) - - return result - } - - return OperationResult(data: nil, source: .cache(stale: false)) - } - - func updateData(data: OperationResult) async { - resultsPublisher.send(.success(data)) - } -} - -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public protocol ObservableQueryRef: QueryRef { - // results of fetch. - var data: ResultData? { get } - - // source of the query results (server, cache, ) - var source: QueryResultSource { get } - // last error received. if last fetch was successful this is cleared - var lastError: DataConnectError? { get } -} - -/// QueryRef class compatible with ObservableObject protocol -/// -/// When the requested publisher is an ObservableObject, the returned query refs will be instances -/// of this class -/// -/// This class cannot be instantiated directly. To get an instance, call the -/// ``DataConnect/dataConnect(...)`` function -/// -/// This class publishes two vars -/// - ``data``: Published variable that contains bindable results of the query. -/// - ``lastError``: Published variable that contains ``DataConnectError`` if last fetch had error. -/// If last fetch was successful, this variable is cleared -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public class QueryRefObservableObject< - ResultData: Decodable & Sendable, - Variable: OperationVariable ->: ObservableObject, ObservableQueryRef { - private var request: QueryRequest - - private var baseRef: GenericQueryRef - - private var resultsCancellable: AnyCancellable? - - init( - request: QueryRequest, - dataType: ResultData.Type, - grpcClient: GrpcClient, - cacheProvider: CacheProvider? - ) { - self.request = request - baseRef = GenericQueryRef( - request: request, - grpcClient: grpcClient, - cacheProvider: cacheProvider - ) - setupSubscription() - } - - private func setupSubscription() { - Task { - resultsCancellable = await baseRef.subscribe() - .receive(on: DispatchQueue.main) - .sink(receiveValue: { result in - switch result { - case let .success(operationResult): - self.data = operationResult.data - self.source = operationResult.source - self.lastError = nil - case let .failure(dcerror): - self.lastError = dcerror.dataConnectError - } - }) - } - } - - // ObservableQueryRef implementation - - /// data published by query of type `ResultData` - @Published public private(set) var data: ResultData? - - /// Error thrown if error occurs during execution of query. If the last fetch was successful the - /// error is cleared - @Published public private(set) var lastError: DataConnectError? - - /// Source of the query results (server, local cache, ...) - @Published public private(set) var source: QueryResultSource = .unknown - - // QueryRef implementation - - /// Executes the query and returns `ResultData`. This will also update the published `data` - /// variable - public func execute(fetchPolicy: QueryFetchPolicy = .defaultPolicy) async throws -> OperationResult { - let result = try await baseRef.execute(fetchPolicy: fetchPolicy) - return result - } - - /// Returns the underlying results publisher. - /// Use this function ONLY if you plan to use the Query Ref outside of SwiftUI context - (UIKit, - /// background updates,...) - public func subscribe() async throws - -> AnyPublisher, AnyDataConnectError>, Never> { - return await baseRef.subscribe() - } -} - -/// QueryRef class compatible with the Observation framework introduced in iOS 17 -/// -/// When the requested publisher is an ObservableMacri, the returned query refs will be instances -/// of this class -/// -/// This class cannot be instantiated directly. To get an instance, call the -/// ``DataConnect/dataConnect(...)`` function -/// -/// This class publishes two vars -/// - ``data``: Published variable that contains bindable results of the query. -/// - ``lastError``: Published variable that contains ``DataConnectError`` if last fetch had error. -/// If last fetch was successful, this variable is cleared -@available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) -@Observable -public class QueryRefObservation< - ResultData: Decodable & Sendable, - Variable: OperationVariable ->: ObservableQueryRef { - @ObservationIgnored - private var request: QueryRequest - - @ObservationIgnored - private var baseRef: GenericQueryRef - - @ObservationIgnored - private var resultsCancellable: AnyCancellable? - - init(request: QueryRequest, dataType: ResultData.Type, grpcClient: GrpcClient, cacheProvider: CacheProvider?) { - self.request = request - baseRef = GenericQueryRef( - request: request, - grpcClient: grpcClient, - cacheProvider: cacheProvider - ) - setupSubscription() - } - - private func setupSubscription() { - Task { - resultsCancellable = await baseRef.subscribe() - .receive(on: DispatchQueue.main) - .sink(receiveValue: { result in - switch result { - case let .success(resultData): - self.data = resultData.data - self.source = resultData.source - self.lastError = nil - case let .failure(dcerror): - self.lastError = dcerror.dataConnectError - } - }) - } - } - - // ObservableQueryRef implementation - - /// data published by query of type `ResultData` - public private(set) var data: ResultData? - - /// Error thrown if error occurs during execution of query. If the last fetch was successful the - /// error is cleared - public private(set) var lastError: DataConnectError? - - /// Source of the query results (server, local cache, ...) - public private(set) var source: QueryResultSource = .unknown - - // QueryRef implementation - - /// Executes the query and returns `ResultData`. This will also update the published `data` - /// variable - public func execute(fetchPolicy: QueryFetchPolicy = .defaultPolicy) async throws -> OperationResult { - let result = try await baseRef.execute(fetchPolicy: fetchPolicy) - return result - } - - /// Returns the underlying results publisher. - /// Use this function ONLY if you plan to use the Query Ref outside of SwiftUI context - (UIKit, - /// background updates,...) - public func subscribe() async throws - -> AnyPublisher, AnyDataConnectError>, Never> { - return await baseRef.subscribe() - } -} diff --git a/Sources/Queries/QueryRequest.swift b/Sources/Queries/QueryRequest.swift index a82ae42..fa77eb2 100644 --- a/Sources/Queries/QueryRequest.swift +++ b/Sources/Queries/QueryRequest.swift @@ -43,10 +43,7 @@ struct QueryRequest: OperationRequest, Hashable, Eq } } - let hashDigest = SHA256.hash(data: keyIdData) - let hashString = hashDigest.compactMap{ String(format: "%02x", $0) }.joined() - - return hashString + return keyIdData.sha256String }() init(operationName: String, variables: Variable? = nil) { diff --git a/Tests/Unit/CacheTests.swift b/Tests/Unit/CacheTests.swift index ea6aee7..67a7228 100644 --- a/Tests/Unit/CacheTests.swift +++ b/Tests/Unit/CacheTests.swift @@ -19,20 +19,16 @@ final class CacheTests: XCTestCase { """ + let resultTreeOneItemSimple = """ + {"item":{"desc":"itemDesc","name":"itemsOne", "cacheId":"123","price":4}} + """ + let resultTreeJson = """ {"items":[{"id":"0cadb2b93d46434db1d218d6db023b79","price":226.94024396145267,"name":"Item-24","cacheId":"78192783c32c48cd9b4146547421a6a5","userReviews_on_item":[]},{"cacheId":"fc9387b4c50a4eb28e91d7a09d108a44","id":"4a01c498dd014a29b20ac693395a2900","userReviews_on_item":[],"name":"Item-62","price":512.3027252608986},{"price":617.9690589103608,"id":"e2ed29ed3e9b42328899d49fa33fc785","userReviews_on_item":[],"cacheId":"a911561b2b904f008ab8c3a2d2a7fdbe","name":"Item-49"},{"id":"0da168e75ded479ea3b150c13b7c6ec7","price":10.456,"userReviews_on_item":[{"cacheId":"125791DB-696E-4446-8F2A-C17E7C2AF771","user":{"name":"User1","id":"2fff8099d54843a0bbbbcf905e4c3424","cacheId":"27E85023-D465-4240-82D6-0055AA122406"},"title":"Item1 Review1 byUser1","id":"1384a5173c31487c8834368348c3b89c"}],"name":"Item1","cacheId":"fcfa90f7308049a083c3131f9a7a9836"},{"id":"23311f29be09495cba198da89b8b7d0f","name":"Item2","price":20.456,"cacheId":"c565d2fb7386480c87aa804f2789d200","userReviews_on_item":[{"title":"Item2 Review1 byUser1","user":{"name":"User1","id":"2fff8099d54843a0bbbbcf905e4c3424","cacheId":"27E85023-D465-4240-82D6-0055AA122406"},"cacheId":"F652FB4E-65E0-43E0-ADB1-14582304F938","id":"7ec6b021e1654eff98b3482925fab0c9"}]},{"name":"Item3","cacheId":"c6218faf3607495aaeab752ae6d0b8a7","id":"b7d2287e94014f4fa4a1566f1b893105","price":30.456,"userReviews_on_item":[{"title":"Item3 Review1 byUser2","cacheId":"8455C788-647F-4AB3-971B-6A9C42456129","id":"9bf4d458dd204a4c8931fe952bba85b7","user":{"id":"00af97d8f274427cb5e2c691ca13521c","name":"User2","cacheId":"EB588061-7139-4D6D-9A1B-80D4150DC1B4"}}]},{"userReviews_on_item":[{"id":"d769b8d6d4064e81948fb6b9374fba54","cacheId":"03ADC9EC-0102-4F24-BE8B-F6C0DD102EA4","title":"Item4 Review1 byUser3","user":{"cacheId":"65928AFC-22FA-422D-A2F1-85980DC682AE","id":"69562c9aee2f47ee8abb8181d4df53ec","name":"User3"}}],"price":40.456,"name":"Item4","id":"98e55525f20f4ee190034adcd6fb01dc","cacheId":"a2e64ada1771434aa3ec73f6a6d05428"}]} """ - let cacheProvider = EphemeralCacheProvider( - cacheConfig: CacheConfig(), - cacheIdentifier: UUID().uuidString - ) + let cacheProvider = EphemeralCacheProvider("testEphemeralCacheProvider") - override func setUpWithError() throws { - - - - - } + override func setUpWithError() throws {} override func tearDownWithError() throws {} @@ -40,12 +36,13 @@ final class CacheTests: XCTestCase { func testBDOReuse() throws { do { let resultsProcessor = ResultTreeProcessor() - let rootSdo = try resultsProcessor.normalize(resultTreeJson, cacheProvider: cacheProvider) + let (_, _) = try resultsProcessor.dehydrateResults(resultTreeJson, cacheProvider: cacheProvider) + let reusedCacheId = "27E85023-D465-4240-82D6-0055AA122406" - let user1 = cacheProvider.dataObject(entityGuid: reusedCacheId) - let user2 = cacheProvider.dataObject(entityGuid: reusedCacheId) + let user1 = cacheProvider.backingData(reusedCacheId) + let user2 = cacheProvider.backingData(reusedCacheId) // both user objects should be references to same instance XCTAssertTrue(user1 === user2) @@ -56,17 +53,16 @@ final class CacheTests: XCTestCase { do { let resultsProcessor = ResultTreeProcessor() - let (dehydratedTree, sdo1) = try resultsProcessor.dehydrateResults( + let (dehydratedTree, do1) = try resultsProcessor.dehydrateResults( resultTreeOneItemJson, cacheProvider: cacheProvider ) - let (hydratedTree, sdo2 ) = try resultsProcessor.hydrateResults(dehydratedTree, cacheProvider: cacheProvider) + let (hydratedTree, do2 ) = try resultsProcessor.hydrateResults(dehydratedTree, cacheProvider: cacheProvider) - XCTAssertEqual(sdo1, sdo2) + XCTAssertEqual(do1, do2) } } - } From 0bcdf091666685c9160dce39283d74050a206103 Mon Sep 17 00:00:00 2001 From: Aashish Patil Date: Thu, 25 Sep 2025 10:46:17 -0700 Subject: [PATCH 08/38] Implement operationId on QueryRef --- Sources/BaseOperationRef.swift | 2 +- Sources/Internal/ServerResponse.swift | 13 ---------- Sources/MutationRef.swift | 8 ++++++ Sources/Queries/GenericQueryRef.swift | 22 +++++++++++++---- Sources/Queries/ObservableQueryRef.swift | 31 ++++++++++++++++++++++++ Sources/Scalars/ServerResponse.swift | 20 +++++++++++++++ 6 files changed, 77 insertions(+), 19 deletions(-) delete mode 100644 Sources/Internal/ServerResponse.swift create mode 100644 Sources/Scalars/ServerResponse.swift diff --git a/Sources/BaseOperationRef.swift b/Sources/BaseOperationRef.swift index 7182f68..e4144ef 100644 --- a/Sources/BaseOperationRef.swift +++ b/Sources/BaseOperationRef.swift @@ -32,7 +32,7 @@ protocol OperationRequest: Hashable, Equatable, Sendable { } @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public protocol OperationRef { +public protocol OperationRef: Hashable, Equatable { associatedtype ResultData: Decodable & Sendable func execute() async throws -> OperationResult diff --git a/Sources/Internal/ServerResponse.swift b/Sources/Internal/ServerResponse.swift deleted file mode 100644 index a7b299a..0000000 --- a/Sources/Internal/ServerResponse.swift +++ /dev/null @@ -1,13 +0,0 @@ -// -// ServerResponse.swift -// FirebaseDataConnect -// -// Created by Aashish Patil on 9/15/25. -// - -import Foundation - -struct ServerResponse { - let jsonResults: String - let ttl: TimeInterval -} diff --git a/Sources/MutationRef.swift b/Sources/MutationRef.swift index af4fa05..8a06ced 100644 --- a/Sources/MutationRef.swift +++ b/Sources/MutationRef.swift @@ -47,4 +47,12 @@ public class MutationRef< ) return results } + + public func hash(into hasher: inout Hasher) { + hasher.combine(request) + } + + public static func == (lhs: MutationRef, rhs: MutationRef) -> Bool { + return lhs.request as? MutationRequest == rhs.request as? MutationRequest + } } diff --git a/Sources/Queries/GenericQueryRef.swift b/Sources/Queries/GenericQueryRef.swift index ca70e88..91284a7 100644 --- a/Sources/Queries/GenericQueryRef.swift +++ b/Sources/Queries/GenericQueryRef.swift @@ -33,11 +33,17 @@ actor GenericQueryRef, grpcClient: GrpcClient, cache: Cache? = nil) { self.request = request self.grpcClient = grpcClient self.cache = cache + self.operationId = self.request.requestId } // This call starts query execution and publishes data to data var @@ -93,11 +99,7 @@ actor GenericQueryRef Bool { + lhs.operationId == rhs.operationId + } +} diff --git a/Sources/Queries/ObservableQueryRef.swift b/Sources/Queries/ObservableQueryRef.swift index 11af6d8..020d6e4 100644 --- a/Sources/Queries/ObservableQueryRef.swift +++ b/Sources/Queries/ObservableQueryRef.swift @@ -50,6 +50,11 @@ public class QueryRefObservableObject< ResultData: Decodable & Sendable, Variable: OperationVariable >: ObservableObject, ObservableQueryRef { + + public var operationId: String { + return baseRef.operationId + } + private var request: QueryRequest private var baseRef: GenericQueryRef @@ -118,6 +123,16 @@ public class QueryRefObservableObject< } } +extension QueryRefObservableObject { + nonisolated public func hash(into hasher: inout Hasher) { + hasher.combine(baseRef) + } + + public static func == (lhs: QueryRefObservableObject, rhs: QueryRefObservableObject) -> Bool { + lhs.baseRef == rhs.baseRef + } +} + /// QueryRef class compatible with the Observation framework introduced in iOS 17 /// /// When the requested publisher is an ObservableMacri, the returned query refs will be instances @@ -136,6 +151,11 @@ public class QueryRefObservation< ResultData: Decodable & Sendable, Variable: OperationVariable >: ObservableQueryRef { + + public var operationId: String { + return baseRef.operationId + } + @ObservationIgnored private var request: QueryRequest @@ -201,3 +221,14 @@ public class QueryRefObservation< return await baseRef.subscribe() } } + +@available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) +extension QueryRefObservation { + nonisolated public func hash(into hasher: inout Hasher) { + hasher.combine(baseRef) + } + + public static func == (lhs: QueryRefObservation, rhs: QueryRefObservation) -> Bool { + lhs.baseRef == rhs.baseRef + } +} diff --git a/Sources/Scalars/ServerResponse.swift b/Sources/Scalars/ServerResponse.swift new file mode 100644 index 0000000..24318bb --- /dev/null +++ b/Sources/Scalars/ServerResponse.swift @@ -0,0 +1,20 @@ +// 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 Foundation + +struct ServerResponse { + let jsonResults: String + let ttl: TimeInterval +} From ad749e75ab47ab9addf3417c9b8261b3a7af36e9 Mon Sep 17 00:00:00 2001 From: Aashish Patil Date: Thu, 25 Sep 2025 10:47:28 -0700 Subject: [PATCH 09/38] Fix sha256 formatter. --- Sources/Internal/HashUtils.swift | 2 +- Sources/Queries/GenericQueryRef.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/Internal/HashUtils.swift b/Sources/Internal/HashUtils.swift index c469a03..da79dd2 100644 --- a/Sources/Internal/HashUtils.swift +++ b/Sources/Internal/HashUtils.swift @@ -26,7 +26,7 @@ extension Data { extension String { var sha256: String { let digest = SHA256.hash(data: self.data(using: .utf8)!) - let hashString = digest.compactMap { String(format: "%20x", $0)}.joined() + let hashString = digest.compactMap { String(format: "%02x", $0)}.joined() return hashString } } diff --git a/Sources/Queries/GenericQueryRef.swift b/Sources/Queries/GenericQueryRef.swift index 91284a7..10f217b 100644 --- a/Sources/Queries/GenericQueryRef.swift +++ b/Sources/Queries/GenericQueryRef.swift @@ -99,7 +99,7 @@ actor GenericQueryRef Date: Wed, 1 Oct 2025 12:32:07 -0700 Subject: [PATCH 10/38] Accumulate Impacted Refs and Reload Local --- Sources/Cache/BackingDataObject.swift | 25 +- Sources/Cache/Cache.swift | 30 +- Sources/Cache/ResultTreeProcessor.swift | 49 ++- Sources/Cache/SQLiteCacheProvider.swift | 435 ++++++++++++++--------- Sources/Cache/StubDataObject.swift | 57 ++- Sources/DataConnect.swift | 10 +- Sources/Internal/OperationsManager.swift | 17 +- Sources/Internal/QueryRefInternal.swift | 12 + Sources/Queries/GenericQueryRef.swift | 21 +- Sources/Queries/ObservableQueryRef.swift | 30 ++ Sources/Queries/QueryRef.swift | 7 +- 11 files changed, 489 insertions(+), 204 deletions(-) create mode 100644 Sources/Internal/QueryRefInternal.swift diff --git a/Sources/Cache/BackingDataObject.swift b/Sources/Cache/BackingDataObject.swift index dbd9cc2..ad6ef16 100644 --- a/Sources/Cache/BackingDataObject.swift +++ b/Sources/Cache/BackingDataObject.swift @@ -19,6 +19,9 @@ struct ScalarField { class BackingDataObject: CustomStringConvertible, Codable { + // Set of QueryRefs that reference this BDO + var referencedFrom = Set() + var guid: String // globally unique id received from server required init(guid: String) { @@ -28,13 +31,29 @@ class BackingDataObject: CustomStringConvertible, Codable { private var serverValues = SynchronizedDictionary() enum CodingKeys: String, CodingKey { - case globalID = "globalID" - case serverValues = "serverValues" + case globalID = "guid" + case serverValues = "serVal" } - func updateServerValue(_ key: String, _ newValue: AnyCodableValue) { + // Updates value received from server and returns a list of QueryRef operation ids + // referenced from this BackingDataObject + @discardableResult func updateServerValue( + _ key: String, + _ newValue: AnyCodableValue, + _ requestor: (any QueryRefInternal)? = nil + ) -> [String] { + self.serverValues[key] = newValue DataConnectLogger.debug("BDO updateServerValue: \(key) \(newValue) for \(guid)") + + if let requestor { + referencedFrom.insert(requestor.operationId) + DataConnectLogger + .debug("Inserted referencedFrom \(requestor). New count \(referencedFrom.count)") + + } + let refs: [String] = Array(referencedFrom) + return refs } func value(forKey key: String) -> AnyCodableValue? { diff --git a/Sources/Cache/Cache.swift b/Sources/Cache/Cache.swift index 22623e6..1a82e00 100644 --- a/Sources/Cache/Cache.swift +++ b/Sources/Cache/Cache.swift @@ -105,7 +105,7 @@ class Cache { lastAccessed: dehydratedTree.lastAccessed, rootObject: rootObj ) - + return hydratedTree } catch { DataConnectLogger.warning("Error getting result tree \(error)") @@ -113,22 +113,21 @@ class Cache { } } } - - func update(queryId: String, response: ServerResponse) { - queue.async(flags: .barrier) { - + func update(queryId: String, response: ServerResponse, requestor: (any QueryRefInternal)? = nil) { + queue.async(flags: .barrier) { guard let cacheProvider = self.cacheProvider else { DataConnectLogger.debug("Cache provider not initialized yet. Skipping update for \(queryId)") return - } + } do { let processor = ResultTreeProcessor() - let (dehydratedResults, rootObj) = try processor.dehydrateResults( + let (dehydratedResults, rootObj, impactedRefs) = try processor.dehydrateResults( response.jsonResults, - cacheProvider: cacheProvider + cacheProvider: cacheProvider, + requestor: requestor ) - + cacheProvider .setResultTree( queryId: queryId, @@ -140,6 +139,19 @@ class Cache { rootObject: rootObj ) ) + + impactedRefs.forEach { refId in + guard let q = self.dataConnect.queryRef(for: refId) as? (any QueryRefInternal) else { + return + } + Task { + do { + try await q.publishCacheResultsToSubscribers(allowStale: true) + } catch { + DataConnectLogger.warning("Error republishing cached results for impacted queryrefs \(error))") + } + } + } } catch { DataConnectLogger.warning("Error updating cache for \(queryId): \(error)") } diff --git a/Sources/Cache/ResultTreeProcessor.swift b/Sources/Cache/ResultTreeProcessor.swift index 33a35f6..bd86b6b 100644 --- a/Sources/Cache/ResultTreeProcessor.swift +++ b/Sources/Cache/ResultTreeProcessor.swift @@ -17,12 +17,47 @@ import Foundation // Key that indicates the kind of tree being coded - hydrated or dehydrated let ResultTreeKindCodingKey = CodingUserInfoKey(rawValue: "com.google.firebase.dataconnect.encodingMode")! +// Key that points to the QueryRef being updated in cache +let UpdatingQueryRefsCodingKey = CodingUserInfoKey(rawValue: "com.google.firebase.dataconnect.updatingQueryRef")! + +// Key pointing to container for QueryRefs. BackingDataObjects fill this +let ImpactedRefsAccumulatorCodingKey = CodingUserInfoKey(rawValue: "com.google.firebase.dataconnect.impactedQueryRefs")! + // Kind of result data we are encoding from or decoding to enum ResultTreeKind { case hydrated // JSON data is full hydrated and contains full data in the tree case dehydrated // JSON data is dehydrated and only contains refs to actual data objects } +// Class used to accumulate query refs as we dehydrate the tree and find BDOs +// BDOs contain references to other QueryRefs that reference the BDO +// We collect those QueryRefs here +class ImpactedQueryRefsAccumulator { + // operationIds of impacted QueryRefs + private(set) var queryRefIds: Set = [] + + // QueryRef requesting impacted + let requestor: (any QueryRefInternal)? + + init(requestor: (any QueryRefInternal)? = nil) { + self.requestor = requestor + } + + // appends the impacted operationId not matching requestor + func append(_ queryRefId: String) { + guard requestor != nil else { + queryRefIds.insert(queryRefId) + return + } + + if let requestor = requestor, + queryRefId != requestor.operationId { + queryRefIds.insert(queryRefId) + } + + } +} + // Normalization and recontruction of ResultTree struct ResultTreeProcessor { @@ -39,11 +74,21 @@ struct ResultTreeProcessor { */ - func dehydrateResults(_ hydratedTree: String, cacheProvider: CacheProvider) throws -> (dehydratedResults: String, rootObject: StubDataObject) { + func dehydrateResults(_ hydratedTree: String, cacheProvider: CacheProvider, requestor: (any QueryRefInternal)? = nil) throws -> ( + dehydratedResults: String, + rootObject: StubDataObject, + impactedRefIds: [String] + ) { let jsonDecoder = JSONDecoder() + let impactedRefsAccumulator = ImpactedQueryRefsAccumulator(requestor: requestor) + jsonDecoder.userInfo[CacheProviderUserInfoKey] = cacheProvider jsonDecoder.userInfo[ResultTreeKindCodingKey] = ResultTreeKind.hydrated + jsonDecoder.userInfo[ImpactedRefsAccumulatorCodingKey] = impactedRefsAccumulator let sdo = try jsonDecoder.decode(StubDataObject.self, from: hydratedTree.data(using: .utf8)!) + + DataConnectLogger.debug("Impacted QueryRefs count: \(impactedRefsAccumulator.queryRefIds.count)") + let impactedRefs = Array(impactedRefsAccumulator.queryRefIds) let jsonEncoder = JSONEncoder() jsonEncoder.userInfo[CacheProviderUserInfoKey] = cacheProvider @@ -56,7 +101,7 @@ struct ResultTreeProcessor { "\(#function): \nHydrated \n \(hydratedTree) \n\nDehydrated \n \(dehydratedResultsString)" ) - return (dehydratedResultsString, sdo) + return (dehydratedResultsString, sdo, impactedRefs) } diff --git a/Sources/Cache/SQLiteCacheProvider.swift b/Sources/Cache/SQLiteCacheProvider.swift index b3fdd08..9998834 100644 --- a/Sources/Cache/SQLiteCacheProvider.swift +++ b/Sources/Cache/SQLiteCacheProvider.swift @@ -1,4 +1,3 @@ - // Copyright 2025 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); @@ -13,218 +12,306 @@ // See the License for the specific language governing permissions and // limitations under the License. +import FirebaseCore import Foundation import SQLite3 -import FirebaseCore class SQLiteCacheProvider: CacheProvider { - - let cacheIdentifier: String - - private var db: OpaquePointer? - private let queue = DispatchQueue(label: "com.google.firebase.dataconnect.sqlitecacheprovider") - - init(_ cacheIdentifier: String) throws { - self.cacheIdentifier = cacheIdentifier - - try queue.sync { - let path = NSSearchPathForDirectoriesInDomains( - .applicationSupportDirectory, .userDomainMask, true - ).first! - let dbURL = URL(fileURLWithPath: path).appendingPathComponent("\(cacheIdentifier).sqlite3") - - if sqlite3_open(dbURL.path, &db) != SQLITE_OK { - throw DataConnectInternalError - .sqliteError(message: "Could not open database for identifier \(cacheIdentifier) at \(dbURL.path)") - } - - try createTables() + + let cacheIdentifier: String + + private var db: OpaquePointer? + private let queue = DispatchQueue(label: "com.google.firebase.dataconnect.sqlitecacheprovider") + + init(_ cacheIdentifier: String) throws { + self.cacheIdentifier = cacheIdentifier + + try queue.sync { + let path = NSSearchPathForDirectoriesInDomains( + .applicationSupportDirectory, + .userDomainMask, + true + ).first! + let dbURL = URL(fileURLWithPath: path).appendingPathComponent("\(cacheIdentifier).sqlite3") + + if sqlite3_open(dbURL.path, &db) != SQLITE_OK { + throw + DataConnectInternalError + .sqliteError( + message: "Could not open database for identifier \(cacheIdentifier) at \(dbURL.path)" + ) } + + try createTables() } + } + + deinit { + sqlite3_close(db) + } + + private func createTables() throws { + dispatchPrecondition(condition: .onQueue(queue)) - deinit { - sqlite3_close(db) + let createResultTreeTable = """ + CREATE TABLE IF NOT EXISTS result_tree ( + query_id TEXT PRIMARY KEY NOT NULL, + last_accessed REAL NOT NULL, + tree BLOB NOT NULL + ); + """ + if sqlite3_exec(db, createResultTreeTable, nil, nil, nil) != SQLITE_OK { + throw DataConnectInternalError.sqliteError(message: "Could not create result_tree table") } - private func createTables() throws { - dispatchPrecondition(condition: .onQueue(queue)) - - let createResultTreeTable = """ - CREATE TABLE IF NOT EXISTS result_tree ( - query_id TEXT PRIMARY KEY NOT NULL, - last_accessed REAL NOT NULL, - tree BLOB NOT NULL - ); - """ - if sqlite3_exec(db, createResultTreeTable, nil, nil, nil) != SQLITE_OK { - throw DataConnectInternalError.sqliteError(message: "Could not create result_tree table") - } + let createBackingDataTable = """ + CREATE TABLE IF NOT EXISTS backing_data ( + entity_guid TEXT PRIMARY KEY NOT NULL, + object_state INTEGER DEFAULT 10, + object BLOB NOT NULL + ); + """ + if sqlite3_exec(db, createBackingDataTable, nil, nil, nil) != SQLITE_OK { + throw DataConnectInternalError.sqliteError(message: "Could not create backing_data table") + } - let createBackingDataTable = """ - CREATE TABLE IF NOT EXISTS backing_data ( - entity_guid TEXT PRIMARY KEY NOT NULL, - object_state INTEGER DEFAULT 10, - object BLOB NOT NULL - ); - """ - if sqlite3_exec(db, createBackingDataTable, nil, nil, nil) != SQLITE_OK { - throw DataConnectInternalError.sqliteError(message: "Could not create backing_data table") - } + // table to store reverse mapping of BDO => queryRefs mapping + // this is to know which BDOs are still referenced + let createBackingDataRefs = """ + CREATE TABLE IF NOT EXISTS backing_data_query_refs ( + entity_guid TEXT NOT NULL REFERENCES backing_data(entity_guid), + query_id TEXT NOT NULL REFERENCES result_tree(query_id), + PRIMARY KEY (entity_guid, query_id) + ) + """ + if sqlite3_exec(db, createBackingDataRefs, nil, nil, nil) != SQLITE_OK { + throw DataConnectInternalError.sqliteError( + message: "Could not create backing_data_query_refs table" + ) } - + } + private func updateLastAccessedTime(forQueryId queryId: String) { - + dispatchPrecondition(condition: .onQueue(queue)) - let updateQuery = "UPDATE result_tree SET last_accessed = ? WHERE query_id = ?;" + let updateQuery = "UPDATE result_tree SET last_accessed = ? WHERE query_id = ?;" + var statement: OpaquePointer? + + if sqlite3_prepare_v2(self.db, updateQuery, -1, &statement, nil) != SQLITE_OK { + DataConnectLogger.error("Error preparing update statement for result_tree") + return + } + + sqlite3_bind_double(statement, 1, Date().timeIntervalSince1970) + sqlite3_bind_text(statement, 2, (queryId as NSString).utf8String, -1, nil) + + if sqlite3_step(statement) != SQLITE_DONE { + DataConnectLogger.error("Error updating last_accessed for query \(queryId)") + } + sqlite3_finalize(statement) + + } + + func resultTree(queryId: String) -> ResultTree? { + return queue.sync { + let query = "SELECT tree FROM result_tree WHERE query_id = ?;" var statement: OpaquePointer? - - if sqlite3_prepare_v2(self.db, updateQuery, -1, &statement, nil) != SQLITE_OK { - DataConnectLogger.error("Error preparing update statement for result_tree") - return + + if sqlite3_prepare_v2(db, query, -1, &statement, nil) != SQLITE_OK { + DataConnectLogger.error("Error preparing select statement for result_tree") + return nil } - - sqlite3_bind_double(statement, 1, Date().timeIntervalSince1970) - sqlite3_bind_text(statement, 2, (queryId as NSString).utf8String, -1, nil) - - if sqlite3_step(statement) != SQLITE_DONE { - DataConnectLogger.error("Error updating last_accessed for query \(queryId)") + + sqlite3_bind_text(statement, 1, (queryId as NSString).utf8String, -1, nil) + + if sqlite3_step(statement) == SQLITE_ROW { + if let dataBlob = sqlite3_column_blob(statement, 0) { + let dataBlobLength = sqlite3_column_bytes(statement, 0) + let data = Data(bytes: dataBlob, count: Int(dataBlobLength)) + sqlite3_finalize(statement) + do { + let tree = try JSONDecoder().decode(ResultTree.self, from: data) + self.updateLastAccessedTime(forQueryId: queryId) + return tree + } catch { + DataConnectLogger.error("Error decoding result tree for queryId \(queryId): \(error)") + return nil + } + } } + sqlite3_finalize(statement) - + DataConnectLogger.debug("\(#function) no result tree found for queryId \(queryId)") + return nil + } } - func resultTree(queryId: String) -> ResultTree? { - return queue.sync { - let query = "SELECT tree FROM result_tree WHERE query_id = ?;" - var statement: OpaquePointer? + func setResultTree(queryId: String, tree: ResultTree) { + queue.sync { + do { + var tree = tree + let data = try JSONEncoder().encode(tree) + let insert = + "INSERT OR REPLACE INTO result_tree (query_id, last_accessed, tree) VALUES (?, ?, ?);" + var statement: OpaquePointer? - if sqlite3_prepare_v2(db, query, -1, &statement, nil) != SQLITE_OK { - DataConnectLogger.error("Error preparing select statement for result_tree") - return nil - } + if sqlite3_prepare_v2(db, insert, -1, &statement, nil) != SQLITE_OK { + DataConnectLogger.error("Error preparing insert statement for result_tree") + return + } - sqlite3_bind_text(statement, 1, (queryId as NSString).utf8String, -1, nil) - - if sqlite3_step(statement) == SQLITE_ROW { - if let dataBlob = sqlite3_column_blob(statement, 0) { - let dataBlobLength = sqlite3_column_bytes(statement, 0) - let data = Data(bytes: dataBlob, count: Int(dataBlobLength)) - sqlite3_finalize(statement) - do { - let tree = try JSONDecoder().decode(ResultTree.self, from: data) - self.updateLastAccessedTime(forQueryId: queryId) - return tree - } catch { - DataConnectLogger.error("Error decoding result tree for queryId \(queryId): \(error)") - return nil - } - } - } + tree.lastAccessed = Date() - sqlite3_finalize(statement) - DataConnectLogger.debug("\(#function) no result tree found for queryId \(queryId)") - return nil + sqlite3_bind_text(statement, 1, (queryId as NSString).utf8String, -1, nil) + sqlite3_bind_double(statement, 2, tree.lastAccessed.timeIntervalSince1970) + _ = data.withUnsafeBytes { (bytes: UnsafeRawBufferPointer) in + sqlite3_bind_blob(statement, 3, bytes.baseAddress, Int32(bytes.count), nil) + } + + if sqlite3_step(statement) != SQLITE_DONE { + DataConnectLogger.error("Error inserting result tree for queryId \(queryId)") } + + sqlite3_finalize(statement) + + DataConnectLogger.debug("\(#function) - query \(queryId), tree \(tree)") + } catch { + DataConnectLogger.error("Error encoding result tree for queryId \(queryId): \(error)") + } } + } + + func backingData(_ entityGuid: String) -> BackingDataObject { + return queue.sync { + let query = "SELECT object FROM backing_data WHERE entity_guid = ?;" + var statement: OpaquePointer? - func setResultTree(queryId: String, tree: ResultTree) { - queue.sync { + if sqlite3_prepare_v2(db, query, -1, &statement, nil) != SQLITE_OK { + DataConnectLogger.error("Error preparing select statement for backing_data") + } else { + sqlite3_bind_text(statement, 1, (entityGuid as NSString).utf8String, -1, nil) + + if sqlite3_step(statement) == SQLITE_ROW { + if let dataBlob = sqlite3_column_blob(statement, 0) { + let dataBlobLength = sqlite3_column_bytes(statement, 0) + let data = Data(bytes: dataBlob, count: Int(dataBlobLength)) + sqlite3_finalize(statement) do { - var tree = tree - let data = try JSONEncoder().encode(tree) - let insert = "INSERT OR REPLACE INTO result_tree (query_id, last_accessed, tree) VALUES (?, ?, ?);" - var statement: OpaquePointer? - - if sqlite3_prepare_v2(db, insert, -1, &statement, nil) != SQLITE_OK { - DataConnectLogger.error("Error preparing insert statement for result_tree") - return - } - - tree.lastAccessed = Date() - - sqlite3_bind_text(statement, 1, (queryId as NSString).utf8String, -1, nil) - sqlite3_bind_double(statement, 2, tree.lastAccessed.timeIntervalSince1970) - _ = data.withUnsafeBytes { (bytes: UnsafeRawBufferPointer) in - sqlite3_bind_blob(statement, 3, bytes.baseAddress, Int32(bytes.count), nil) - } - - if sqlite3_step(statement) != SQLITE_DONE { - DataConnectLogger.error("Error inserting result tree for queryId \(queryId)") - } - - sqlite3_finalize(statement) - - DataConnectLogger.debug("\(#function) - query \(queryId), tree \(tree)") + let bdo = try JSONDecoder().decode(BackingDataObject.self, from: data) + DataConnectLogger.debug("Returning existing BDO for \(entityGuid)") + + let referencedQueryIds = _readQueryRefs(entityGuid: entityGuid) + bdo.referencedFrom = Set(referencedQueryIds) + return bdo } catch { - DataConnectLogger.error("Error encoding result tree for queryId \(queryId): \(error)") + DataConnectLogger.error( + "Error decoding data object for entityGuid \(entityGuid): \(error)" + ) } + } } - } + sqlite3_finalize(statement) + } - func backingData(_ entityGuid: String) -> BackingDataObject { - return queue.sync { - let query = "SELECT object FROM backing_data WHERE entity_guid = ?;" - var statement: OpaquePointer? - - if sqlite3_prepare_v2(db, query, -1, &statement, nil) != SQLITE_OK { - DataConnectLogger.error("Error preparing select statement for backing_data") - } else { - sqlite3_bind_text(statement, 1, (entityGuid as NSString).utf8String, -1, nil) - - if sqlite3_step(statement) == SQLITE_ROW { - if let dataBlob = sqlite3_column_blob(statement, 0) { - let dataBlobLength = sqlite3_column_bytes(statement, 0) - let data = Data(bytes: dataBlob, count: Int(dataBlobLength)) - sqlite3_finalize(statement) - do { - let bdo = try JSONDecoder().decode(BackingDataObject.self, from: data) - DataConnectLogger.debug("Returning existing BDO for \(entityGuid)") - return bdo - } catch { - DataConnectLogger.error("Error decoding data object for entityGuid \(entityGuid): \(error)") - } - } - } - sqlite3_finalize(statement) - } + // if we reach here it means we don't have a BDO in our database. + // So we create one. + let bdo = BackingDataObject(guid: entityGuid) + _setObject(entityGuid: entityGuid, object: bdo) + DataConnectLogger.debug("Created BDO for \(entityGuid)") + return bdo + } + } - let bdo = BackingDataObject(guid: entityGuid) - _setObject(entityGuid: entityGuid, object: bdo) - DataConnectLogger.debug("Created BDO for \(entityGuid)") - return bdo - } + func updateBackingData(_ object: BackingDataObject) { + queue.sync { + _setObject(entityGuid: object.guid, object: object) } + } - private func _setObject(entityGuid: String, object: BackingDataObject) { - dispatchPrecondition(condition: .onQueue(queue)) - do { - let data = try JSONEncoder().encode(object) - let insert = "INSERT OR REPLACE INTO backing_data (entity_guid, object) VALUES (?, ?);" - var statement: OpaquePointer? + // MARK: Private + // These should run on queue but not call sync otherwise we deadlock + private func _setObject(entityGuid: String, object: BackingDataObject) { + dispatchPrecondition(condition: .onQueue(queue)) + do { + let data = try JSONEncoder().encode(object) + let insert = "INSERT OR REPLACE INTO backing_data (entity_guid, object) VALUES (?, ?);" + var statement: OpaquePointer? - if sqlite3_prepare_v2(db, insert, -1, &statement, nil) != SQLITE_OK { - DataConnectLogger.error("Error preparing insert statement for backing_data") - return - } + if sqlite3_prepare_v2(db, insert, -1, &statement, nil) != SQLITE_OK { + DataConnectLogger.error("Error preparing insert statement for backing_data") + return + } - sqlite3_bind_text(statement, 1, (entityGuid as NSString).utf8String, -1, nil) - _ = data.withUnsafeBytes { (bytes: UnsafeRawBufferPointer) in - sqlite3_bind_blob(statement, 2, bytes.baseAddress, Int32(bytes.count), nil) - } + sqlite3_bind_text(statement, 1, (entityGuid as NSString).utf8String, -1, nil) + _ = data.withUnsafeBytes { (bytes: UnsafeRawBufferPointer) in + sqlite3_bind_blob(statement, 2, bytes.baseAddress, Int32(bytes.count), nil) + } - if sqlite3_step(statement) != SQLITE_DONE { - DataConnectLogger.error("Error inserting data object for entityGuid \(entityGuid)") - } + if sqlite3_step(statement) != SQLITE_DONE { + DataConnectLogger.error("Error inserting data object for entityGuid \(entityGuid)") + } - sqlite3_finalize(statement) - } catch { - DataConnectLogger.error("Error encoding data object for entityGuid \(entityGuid): \(error)") - } + sqlite3_finalize(statement) + + // update references + _updateQueryRefs(object: object) + + } catch { + DataConnectLogger.error("Error encoding data object for entityGuid \(entityGuid): \(error)") + } + } + + private func _updateQueryRefs(object: BackingDataObject) { + dispatchPrecondition(condition: .onQueue(queue)) + + guard object.referencedFrom.count > 0 else { + return + } + var insertReferences = + "INSERT OR REPLACE INTO backing_data_query_refs (entity_guid, query_id) VALUES " + for queryId in object.referencedFrom { + insertReferences += "('\(object.guid)', '\(queryId)'), " + } + insertReferences.removeLast(2) + insertReferences += ";" + + var statementRefs: OpaquePointer? + if sqlite3_prepare_v2(db, insertReferences, -1, &statementRefs, nil) != SQLITE_OK { + DataConnectLogger.error("Error preparing insert statement for backing_data_query_refs") + return } - func updateBackingData(_ object: BackingDataObject) { - queue.sync { - _setObject(entityGuid: object.guid, object: object) + if sqlite3_step(statementRefs) != SQLITE_DONE { + DataConnectLogger.error( + "Error inserting data object references for entityGuid \(object.guid)" + ) + } + + sqlite3_finalize(statementRefs) + } + + private func _readQueryRefs(entityGuid: String) -> [String] { + let readRefs = + "SELECT query_id FROM backing_data_query_refs WHERE entity_guid = '\(entityGuid)'" + var statementRefs: OpaquePointer? + var queryIds: [String] = [] + + if sqlite3_prepare_v2(db, readRefs, -1, &statementRefs, nil) == SQLITE_OK { + while sqlite3_step(statementRefs) == SQLITE_ROW { + if let cString = sqlite3_column_text(statementRefs, 0) { + queryIds.append(String(cString: cString)) } + } + + sqlite3_finalize(statementRefs) + + return queryIds + } else { + DataConnectLogger.error("Error reading query references for \(entityGuid)") } + + return [] + + } + } diff --git a/Sources/Cache/StubDataObject.swift b/Sources/Cache/StubDataObject.swift index 718d1e0..1d2528a 100644 --- a/Sources/Cache/StubDataObject.swift +++ b/Sources/Cache/StubDataObject.swift @@ -52,12 +52,17 @@ struct StubDataObject { case scalars } - init?(value: AnyCodableValue, cacheProvider: CacheProvider) { + init?( + value: AnyCodableValue, + cacheProvider: CacheProvider, + impactedRefsAccumulator: ImpactedQueryRefsAccumulator? = nil + ) { guard case let .dictionary(objectValues) = value else { DataConnectLogger.error("StubDataObject inited with a non-dictionary type") return nil } + if case let .string(guid) = objectValues[GlobalIDKey] { backingData = cacheProvider.backingData(guid) } else if case let .uuid(guid) = objectValues[GlobalIDKey] { @@ -71,7 +76,11 @@ struct StubDataObject { case .dictionary(_): // a dictionary is treated as a composite object // and converted to another Stub - if let st = StubDataObject(value: value, cacheProvider: cacheProvider) { + if let st = StubDataObject( + value: value, + cacheProvider: cacheProvider, + impactedRefsAccumulator: impactedRefsAccumulator + ) { references[key] = st } else { DataConnectLogger.warning("Failed to convert dictionary to a reference") @@ -80,7 +89,11 @@ struct StubDataObject { var refArray = [StubDataObject]() var scalarArray = [AnyCodableValue]() for obj in objs { - if let st = StubDataObject(value: obj, cacheProvider: cacheProvider) { + if let st = StubDataObject( + value: obj, + cacheProvider: cacheProvider, + impactedRefsAccumulator: impactedRefsAccumulator + ) { refArray.append(st) } else { if obj.isScalar { @@ -92,11 +105,27 @@ struct StubDataObject { if refArray.count > 0 { objectLists[key] = refArray } else if scalarArray.count > 0 { - if let backingData { backingData.updateServerValue(key, value)} + if let backingData { + let impactedRefs = backingData.updateServerValue( + key, + value, + impactedRefsAccumulator?.requestor + ) + + // accumulate any impacted QueryRefs due to this change + for r in impactedRefs { impactedRefsAccumulator?.append(r) } + } } default: if let backingData { - backingData.updateServerValue(key, value) + let impactedRefs = backingData.updateServerValue( + key, + value, + impactedRefsAccumulator?.requestor + ) + + // accumulate any QueryRefs impacted by this change + for r in impactedRefs { impactedRefsAccumulator?.append(r) } } else { scalars[key] = value } @@ -104,6 +133,7 @@ struct StubDataObject { } //for (key,value) if let backingData { + for refId in backingData.referencedFrom { impactedRefsAccumulator?.append(refId) } cacheProvider.updateBackingData(backingData) } } @@ -127,9 +157,24 @@ extension StubDataObject: Decodable { let value = try container.decode(AnyCodableValue.self) - let sdo = StubDataObject(value: value, cacheProvider: cacheProvider) + let impactedRefsAcc = decoder.userInfo[ImpactedRefsAccumulatorCodingKey] as? ImpactedQueryRefsAccumulator + + if impactedRefsAcc != nil { + DataConnectLogger + .debug("Got impactedRefs before dehydration \(String(describing: impactedRefsAcc))") + } + + let sdo = StubDataObject( + value: value, + cacheProvider: cacheProvider, + impactedRefsAccumulator: impactedRefsAcc + ) //DataConnectLogger.debug("Create SDO from JSON \(sdo?.debugDescription)") + if impactedRefsAcc != nil { + DataConnectLogger.debug("impactedRefs after dehydration \(String(describing: impactedRefsAcc))") + } + if let sdo { self = sdo } else { diff --git a/Sources/DataConnect.swift b/Sources/DataConnect.swift index 025d99a..252c36b 100644 --- a/Sources/DataConnect.swift +++ b/Sources/DataConnect.swift @@ -163,7 +163,15 @@ public class DataConnect { return operationsManager.mutationRef(for: request, with: resultsDataType) } } - +} + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +extension DataConnect { + internal func queryRef(for operationId: String) -> (any QueryRef)? { + accessQueue.sync { + return operationsManager.queryRef(for: operationId) + } + } } // This enum is public so the gen sdk can access it diff --git a/Sources/Internal/OperationsManager.swift b/Sources/Internal/OperationsManager.swift index 3268064..aac812b 100644 --- a/Sources/Internal/OperationsManager.swift +++ b/Sources/Internal/OperationsManager.swift @@ -24,7 +24,7 @@ class OperationsManager { label: "firebase.dataconnect.queryRef.AccessQ", autoreleaseFrequency: .workItem ) - private var queryRefs = [AnyHashable: any ObservableQueryRef]() + private var queryRefs = [String: any ObservableQueryRef]() private let mutationRefAccessQueue = DispatchQueue( label: "firebase.dataconnect.mutRef.AccessQ", @@ -44,7 +44,10 @@ class OperationsManager { publisher: ResultsPublisherType = .auto) -> any ObservableQueryRef { queryRefAccessQueue.sync { - if let ref = queryRefs[AnyHashable(request)] { + var req = request // requestId is a mutating call. + let requestId = req.requestId + + if let ref = queryRefs[requestId] { return ref } @@ -56,7 +59,7 @@ class OperationsManager { grpcClient: self.grpcClient, cache: self.cache ) as (any ObservableQueryRef) - queryRefs[AnyHashable(request)] = obsRef + queryRefs[requestId] = obsRef return obsRef } } @@ -67,10 +70,16 @@ class OperationsManager { grpcClient: grpcClient, cache: self.cache ) as (any ObservableQueryRef) - queryRefs[AnyHashable(request)] = refObsObject + queryRefs[requestId] = refObsObject return refObsObject } // accessQueue.sync } + + func queryRef(for operationId: String) -> (any ObservableQueryRef)? { + queryRefAccessQueue.sync { + return queryRefs[operationId] + } + } func mutationRef(for request: MutationRequest, diff --git a/Sources/Internal/QueryRefInternal.swift b/Sources/Internal/QueryRefInternal.swift new file mode 100644 index 0000000..b0725ea --- /dev/null +++ b/Sources/Internal/QueryRefInternal.swift @@ -0,0 +1,12 @@ +// +// QueryRefInternal.swift +// FirebaseDataConnect +// +// Created by Aashish Patil on 9/29/25. +// + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +internal protocol QueryRefInternal: QueryRef { + var operationId: String { get } + func publishCacheResultsToSubscribers(allowStale: Bool) async throws +} diff --git a/Sources/Queries/GenericQueryRef.swift b/Sources/Queries/GenericQueryRef.swift index 10f217b..ef8eb06 100644 --- a/Sources/Queries/GenericQueryRef.swift +++ b/Sources/Queries/GenericQueryRef.swift @@ -99,7 +99,7 @@ actor GenericQueryRef AnyPublisher, AnyDataConnectError>, Never> // Execute override for queries to include fetch policy func execute(fetchPolicy: QueryFetchPolicy) async throws -> OperationResult + + //func execute(fetchPolicy: QueryFetchPolicy) async throws } extension QueryRef { - // default implementation for execute() public func execute() async throws -> OperationResult { try await execute(fetchPolicy: .defaultPolicy) } } - - From 6cd60ef76391a33af1580e1bae36a51e798286dc Mon Sep 17 00:00:00 2001 From: Aashish Patil Date: Wed, 1 Oct 2025 14:28:24 -0700 Subject: [PATCH 11/38] Update construction of cacheIdentifier to include connector and location info --- Sources/Cache/Cache.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Cache/Cache.swift b/Sources/Cache/Cache.swift index 1a82e00..c576485 100644 --- a/Sources/Cache/Cache.swift +++ b/Sources/Cache/Cache.swift @@ -77,7 +77,7 @@ class Cache { private func contructCacheIdentifier() -> String { dispatchPrecondition(condition: .onQueue(queue)) - let identifier = "\(self.config.type)-\(String(describing: dataConnect.app.options.projectID))-\(Auth.auth(app: dataConnect.app).currentUser?.uid ?? "anon")-\(dataConnect.settings.host)" + let identifier = "\(self.config.type)-\(String(describing: dataConnect.app.options.projectID))-\(dataConnect.app.name)-\(dataConnect.connectorConfig.serviceId)-\(dataConnect.connectorConfig.connector)-\(dataConnect.connectorConfig.location)-\(Auth.auth(app: dataConnect.app).currentUser?.uid ?? "anon")-\(dataConnect.settings.host)" let encoded = identifier.sha256 DataConnectLogger.debug("Created Cache Identifier \(encoded) for \(identifier)") return encoded From 824a41eb9e0646bbd3da763c9956907a1d6b9867 Mon Sep 17 00:00:00 2001 From: Aashish Patil Date: Wed, 1 Oct 2025 23:03:12 -0700 Subject: [PATCH 12/38] Use inmemory SQLite for Ephemeral cache provider --- Sources/Cache/Cache.swift | 16 +++--- Sources/Cache/EphemeralCacheProvider.swift | 67 ---------------------- Sources/Cache/SQLiteCacheProvider.swift | 24 ++++---- 3 files changed, 22 insertions(+), 85 deletions(-) delete mode 100644 Sources/Cache/EphemeralCacheProvider.swift diff --git a/Sources/Cache/Cache.swift b/Sources/Cache/Cache.swift index c576485..c4a309e 100644 --- a/Sources/Cache/Cache.swift +++ b/Sources/Cache/Cache.swift @@ -51,15 +51,15 @@ class Cache { return } - switch config.type { - case .ephemeral: - self.cacheProvider = EphemeralCacheProvider(identifier) - case .persistent: - do { - self.cacheProvider = try SQLiteCacheProvider(identifier) - } catch { - DataConnectLogger.error("Unable to initialize Persistent provider \(error)") + do { + switch config.type { + case .ephemeral: + self.cacheProvider = try SQLiteCacheProvider(identifier, ephemeral: true) + case .persistent: + self.cacheProvider = try SQLiteCacheProvider(identifier, ephemeral: false) } + } catch { + DataConnectLogger.error("Unable to initialize Persistent provider \(error)") } } diff --git a/Sources/Cache/EphemeralCacheProvider.swift b/Sources/Cache/EphemeralCacheProvider.swift deleted file mode 100644 index 1d8af94..0000000 --- a/Sources/Cache/EphemeralCacheProvider.swift +++ /dev/null @@ -1,67 +0,0 @@ -// 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 Foundation - -import FirebaseCore - -class EphemeralCacheProvider: CacheProvider, CustomStringConvertible { - - let cacheIdentifier: String - - init(_ cacheIdentifier: String) { - self.cacheIdentifier = cacheIdentifier - - DataConnectLogger.debug("Initialized \(Self.Type.self) with identifier:\(cacheIdentifier)") - } - - // MARK: ResultTree - private var resultTreeCache = SynchronizedDictionary() - - func setResultTree( - queryId: String, - tree: ResultTree - ) { - resultTreeCache[queryId] = tree - DataConnectLogger.debug("Update resultTreeEntry for \(queryId)") - } - - func resultTree(queryId: String) -> ResultTree? { - return resultTreeCache[queryId] - } - - // MARK: BackingDataObjects - private var backingDataObjects = SynchronizedDictionary() - - func backingData(_ entityGuid: String) -> BackingDataObject { - guard let dataObject = backingDataObjects[entityGuid] else { - let bdo = BackingDataObject(guid: entityGuid) - backingDataObjects[entityGuid] = bdo - DataConnectLogger.debug("Created BDO for \(entityGuid)") - return bdo - } - - DataConnectLogger.debug("Returning existing BDO for \(entityGuid)") - return dataObject - } - - func updateBackingData(_ object: BackingDataObject) { - backingDataObjects[object.guid] = object - } - - var description: String { - return "EphemeralCacheProvider - \(cacheIdentifier)" - } - -} diff --git a/Sources/Cache/SQLiteCacheProvider.swift b/Sources/Cache/SQLiteCacheProvider.swift index 9998834..0acc40b 100644 --- a/Sources/Cache/SQLiteCacheProvider.swift +++ b/Sources/Cache/SQLiteCacheProvider.swift @@ -23,25 +23,29 @@ class SQLiteCacheProvider: CacheProvider { private var db: OpaquePointer? private let queue = DispatchQueue(label: "com.google.firebase.dataconnect.sqlitecacheprovider") - init(_ cacheIdentifier: String) throws { + init(_ cacheIdentifier: String, ephemeral: Bool = false) throws { self.cacheIdentifier = cacheIdentifier try queue.sync { - let path = NSSearchPathForDirectoriesInDomains( - .applicationSupportDirectory, - .userDomainMask, - true - ).first! - let dbURL = URL(fileURLWithPath: path).appendingPathComponent("\(cacheIdentifier).sqlite3") - - if sqlite3_open(dbURL.path, &db) != SQLITE_OK { + var dbIdentifier = ":memory:" + if !ephemeral { + let path = NSSearchPathForDirectoriesInDomains( + .applicationSupportDirectory, + .userDomainMask, + true + ).first! + let dbURL = URL(fileURLWithPath: path).appendingPathComponent("\(cacheIdentifier).sqlite3") + dbIdentifier = dbURL.path + } + if sqlite3_open(dbIdentifier, &db) != SQLITE_OK { throw DataConnectInternalError .sqliteError( - message: "Could not open database for identifier \(cacheIdentifier) at \(dbURL.path)" + message: "Could not open database for identifier \(cacheIdentifier) at \(dbIdentifier)" ) } + DataConnectLogger.debug("Opened database with db path/id \(dbIdentifier) and cache identifier \(cacheIdentifier)") try createTables() } } From f503f5eaf3b5744170edb8df599404ac7dab0cda Mon Sep 17 00:00:00 2001 From: Aashish Patil Date: Tue, 7 Oct 2025 15:42:57 -0700 Subject: [PATCH 13/38] Minor updates to API --- Sources/BaseOperationRef.swift | 2 +- Sources/Cache/CacheConfig.swift | 11 +---------- Sources/Queries/ObservableQueryRef.swift | 18 +++++++++--------- ...ryResultSource.swift => ResultSource.swift} | 2 +- 4 files changed, 12 insertions(+), 21 deletions(-) rename Sources/{Queries/QueryResultSource.swift => ResultSource.swift} (95%) diff --git a/Sources/BaseOperationRef.swift b/Sources/BaseOperationRef.swift index e4144ef..e8c6dbd 100644 --- a/Sources/BaseOperationRef.swift +++ b/Sources/BaseOperationRef.swift @@ -17,7 +17,7 @@ import Foundation @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) public struct OperationResult: Sendable { public var data: ResultData? - public let source: QueryResultSource + public let source: ResultSource } // notional protocol that denotes a variable. diff --git a/Sources/Cache/CacheConfig.swift b/Sources/Cache/CacheConfig.swift index f3ad831..603a776 100644 --- a/Sources/Cache/CacheConfig.swift +++ b/Sources/Cache/CacheConfig.swift @@ -20,19 +20,10 @@ public struct CacheConfig: Sendable { public let type: CacheProviderType // default provider is persistent type public let maxSize: Int - public init(type: CacheProviderType, maxSize: Int) { + public init(type: CacheProviderType = .persistent, maxSize: Int = 100_000_000) { self.type = type self.maxSize = maxSize } - public init() { - type = .persistent - #if os(watchOS) - maxSize = 40_000_000 - #else - maxSize = 100_000_000 - #endif - } - } diff --git a/Sources/Queries/ObservableQueryRef.swift b/Sources/Queries/ObservableQueryRef.swift index 329a09a..2e7a370 100644 --- a/Sources/Queries/ObservableQueryRef.swift +++ b/Sources/Queries/ObservableQueryRef.swift @@ -27,7 +27,7 @@ public protocol ObservableQueryRef: QueryRef { var data: ResultData? { get } // source of the query results (server, cache, ) - var source: QueryResultSource { get } + var source: ResultSource { get } // last error received. if last fetch was successful this is cleared var lastError: DataConnectError? { get } @@ -51,7 +51,7 @@ public class QueryRefObservableObject< Variable: OperationVariable >: ObservableObject, ObservableQueryRef { - public var operationId: String { + var operationId: String { return baseRef.operationId } @@ -103,7 +103,7 @@ public class QueryRefObservableObject< @Published public private(set) var lastError: DataConnectError? /// Source of the query results (server, local cache, ...) - @Published public private(set) var source: QueryResultSource = .unknown + @Published public private(set) var source: ResultSource = .unknown // QueryRef implementation @@ -157,14 +157,14 @@ extension QueryRefObservableObject: QueryRefInternal { /// - ``data``: Published variable that contains bindable results of the query. /// - ``lastError``: Published variable that contains ``DataConnectError`` if last fetch had error. /// If last fetch was successful, this variable is cleared -@available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) +@available(macOS 15, iOS 17, tvOS 17, watchOS 10, *) @Observable public class QueryRefObservation< ResultData: Decodable & Sendable, Variable: OperationVariable >: ObservableQueryRef { - public var operationId: String { + var operationId: String { return baseRef.operationId } @@ -214,7 +214,7 @@ public class QueryRefObservation< public private(set) var lastError: DataConnectError? /// Source of the query results (server, local cache, ...) - public private(set) var source: QueryResultSource = .unknown + public private(set) var source: ResultSource = .unknown // QueryRef implementation @@ -234,7 +234,7 @@ public class QueryRefObservation< } } -@available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) +@available(macOS 15, iOS 17, tvOS 17, watchOS 10, *) extension QueryRefObservation { nonisolated public func hash(into hasher: inout Hasher) { hasher.combine(baseRef) @@ -245,14 +245,14 @@ extension QueryRefObservation { } } -@available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) +@available(macOS 15, iOS 17, tvOS 17, watchOS 10, *) extension QueryRefObservation: CustomStringConvertible { nonisolated public var description: String { "QueryRefObservation(\(String(describing: baseRef)))" } } -@available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) +@available(macOS 15, iOS 17, tvOS 17, watchOS 10, *) extension QueryRefObservation: QueryRefInternal { func publishServerResultsToSubscribers() async throws { try await baseRef.publishServerResultsToSubscribers() diff --git a/Sources/Queries/QueryResultSource.swift b/Sources/ResultSource.swift similarity index 95% rename from Sources/Queries/QueryResultSource.swift rename to Sources/ResultSource.swift index f76eb1c..ab0ef3c 100644 --- a/Sources/Queries/QueryResultSource.swift +++ b/Sources/ResultSource.swift @@ -15,7 +15,7 @@ /// Indicates the source of the query results data. @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public enum QueryResultSource: Sendable { +public enum ResultSource: Sendable { /// source not known or cannot be determined case unknown From e57bbb4efc0598e2f2d4996335c9dd8fe5c1ef84 Mon Sep 17 00:00:00 2001 From: Aashish Patil Date: Tue, 7 Oct 2025 22:57:50 -0700 Subject: [PATCH 14/38] Refactor BackingDataObject, STubDataObject names to EntityDataObject and EntityNode --- Sources/Cache/CacheProvider.swift | 4 +- ...ataObject.swift => EntityDataObject.swift} | 20 ++--- ...{StubDataObject.swift => EntityNode.swift} | 83 +++++++++---------- Sources/Cache/ResultTree.swift | 2 +- Sources/Cache/ResultTreeProcessor.swift | 36 ++++---- Sources/Cache/SQLiteCacheProvider.swift | 62 +++++++------- Tests/Unit/CacheTests.swift | 35 ++++++-- 7 files changed, 129 insertions(+), 113 deletions(-) rename Sources/Cache/{BackingDataObject.swift => EntityDataObject.swift} (84%) rename Sources/Cache/{StubDataObject.swift => EntityNode.swift} (77%) diff --git a/Sources/Cache/CacheProvider.swift b/Sources/Cache/CacheProvider.swift index c99ac88..16a396a 100644 --- a/Sources/Cache/CacheProvider.swift +++ b/Sources/Cache/CacheProvider.swift @@ -29,8 +29,8 @@ protocol CacheProvider { func setResultTree(queryId: String, tree: ResultTree) - func backingData(_ entityGuid: String) -> BackingDataObject - func updateBackingData(_ object: BackingDataObject) + func entityData(_ entityGuid: String) -> EntityDataObject + func updateEntityData(_ object: EntityDataObject) /* diff --git a/Sources/Cache/BackingDataObject.swift b/Sources/Cache/EntityDataObject.swift similarity index 84% rename from Sources/Cache/BackingDataObject.swift rename to Sources/Cache/EntityDataObject.swift index ad6ef16..3f3244e 100644 --- a/Sources/Cache/BackingDataObject.swift +++ b/Sources/Cache/EntityDataObject.swift @@ -17,12 +17,12 @@ struct ScalarField { let value: AnyCodableValue } -class BackingDataObject: CustomStringConvertible, Codable { +class EntityDataObject: CustomStringConvertible, Codable { - // Set of QueryRefs that reference this BDO + // Set of QueryRefs that reference this EDO var referencedFrom = Set() - var guid: String // globally unique id received from server + let guid: String // globally unique id received from server required init(guid: String) { self.guid = guid @@ -36,7 +36,7 @@ class BackingDataObject: CustomStringConvertible, Codable { } // Updates value received from server and returns a list of QueryRef operation ids - // referenced from this BackingDataObject + // referenced from this EntityDataObject @discardableResult func updateServerValue( _ key: String, _ newValue: AnyCodableValue, @@ -44,7 +44,7 @@ class BackingDataObject: CustomStringConvertible, Codable { ) -> [String] { self.serverValues[key] = newValue - DataConnectLogger.debug("BDO updateServerValue: \(key) \(newValue) for \(guid)") + DataConnectLogger.debug("EDO updateServerValue: \(key) \(newValue) for \(guid)") if let requestor { referencedFrom.insert(requestor.operationId) @@ -62,7 +62,7 @@ class BackingDataObject: CustomStringConvertible, Codable { var description: String { return """ - BackingDataObject: + EntityDataObject: globalID: \(guid) serverValues: \(serverValues.rawCopy()) @@ -84,7 +84,7 @@ class BackingDataObject: CustomStringConvertible, Codable { } required init(from decoder: Decoder) throws { - var container = try decoder.container(keyedBy: CodingKeys.self) + let container = try decoder.container(keyedBy: CodingKeys.self) let globalId = try container.decode(String.self, forKey: .globalID) self.guid = globalId @@ -96,13 +96,13 @@ class BackingDataObject: CustomStringConvertible, Codable { } -extension BackingDataObject: Equatable { - static func == (lhs: BackingDataObject, rhs: BackingDataObject) -> Bool { +extension EntityDataObject: Equatable { + static func == (lhs: EntityDataObject, rhs: EntityDataObject) -> Bool { return lhs.guid == rhs.guid && lhs.serverValues.rawCopy() == rhs.serverValues.rawCopy() } } -extension BackingDataObject: CustomDebugStringConvertible { +extension EntityDataObject: CustomDebugStringConvertible { var debugDescription: String { return description } diff --git a/Sources/Cache/StubDataObject.swift b/Sources/Cache/EntityNode.swift similarity index 77% rename from Sources/Cache/StubDataObject.swift rename to Sources/Cache/EntityNode.swift index 1d2528a..1aac8b4 100644 --- a/Sources/Cache/StubDataObject.swift +++ b/Sources/Cache/EntityNode.swift @@ -18,32 +18,32 @@ Codable Value must be a dict at root level Dict can contain - (if BDO) + (if EDO) - Scalars, [Scalar] => Move this to DBO - References, [References] => Keep with - (if no guid and therefore no BDO) + (if no guid and therefore no EDO) - Store CodableValue as - is */ -struct StubDataObject { +struct EntityNode { // externalized (normalized) data. // Requires an entity globalID to be provided in selection set - var backingData: BackingDataObject? + var entityData: EntityDataObject? - // inline scalars are only populated if there is no BackingDataObject + // inline scalars are only populated if there is no EntityDataObject // i.e. if there is no entity globalID var scalars = [String: AnyCodableValue]() - // entity properties that point to other stub objects - var references = [String: StubDataObject]() + // entity properties that point to other entity nodes + var references = [String: EntityNode]() // properties that point to a list of other objects // scalar lists are stored inline. - var objectLists = [String: [StubDataObject]]() + var objectLists = [String: [EntityNode]]() enum CodingKeys: String, CodingKey { case globalID = "cacheId" @@ -58,25 +58,25 @@ struct StubDataObject { impactedRefsAccumulator: ImpactedQueryRefsAccumulator? = nil ) { guard case let .dictionary(objectValues) = value else { - DataConnectLogger.error("StubDataObject inited with a non-dictionary type") + DataConnectLogger.error("EntityNode inited with a non-dictionary type") return nil } if case let .string(guid) = objectValues[GlobalIDKey] { - backingData = cacheProvider.backingData(guid) + entityData = cacheProvider.entityData(guid) } else if case let .uuid(guid) = objectValues[GlobalIDKey] { // underlying deserializer is detecting a uuid and converting it. // TODO: Remove once server starts to send the real GlobalID - backingData = cacheProvider.backingData(guid.uuidString) + entityData = cacheProvider.entityData(guid.uuidString) } for (key, value) in objectValues { switch value { case .dictionary(_): // a dictionary is treated as a composite object - // and converted to another Stub - if let st = StubDataObject( + // and converted to another node + if let st = EntityNode( value: value, cacheProvider: cacheProvider, impactedRefsAccumulator: impactedRefsAccumulator @@ -86,10 +86,10 @@ struct StubDataObject { DataConnectLogger.warning("Failed to convert dictionary to a reference") } case .array(let objs): - var refArray = [StubDataObject]() + var refArray = [EntityNode]() var scalarArray = [AnyCodableValue]() for obj in objs { - if let st = StubDataObject( + if let st = EntityNode( value: obj, cacheProvider: cacheProvider, impactedRefsAccumulator: impactedRefsAccumulator @@ -105,8 +105,8 @@ struct StubDataObject { if refArray.count > 0 { objectLists[key] = refArray } else if scalarArray.count > 0 { - if let backingData { - let impactedRefs = backingData.updateServerValue( + if let entityData { + let impactedRefs = entityData.updateServerValue( key, value, impactedRefsAccumulator?.requestor @@ -117,8 +117,8 @@ struct StubDataObject { } } default: - if let backingData { - let impactedRefs = backingData.updateServerValue( + if let entityData { + let impactedRefs = entityData.updateServerValue( key, value, impactedRefsAccumulator?.requestor @@ -132,14 +132,14 @@ struct StubDataObject { } } //for (key,value) - if let backingData { - for refId in backingData.referencedFrom { impactedRefsAccumulator?.append(refId) } - cacheProvider.updateBackingData(backingData) + if let entityData { + for refId in entityData.referencedFrom { impactedRefsAccumulator?.append(refId) } + cacheProvider.updateEntityData(entityData) } } } -extension StubDataObject: Decodable { +extension EntityNode: Decodable { init (from decoder: Decoder) throws { @@ -164,21 +164,20 @@ extension StubDataObject: Decodable { .debug("Got impactedRefs before dehydration \(String(describing: impactedRefsAcc))") } - let sdo = StubDataObject( + let enode = EntityNode( value: value, cacheProvider: cacheProvider, impactedRefsAccumulator: impactedRefsAcc ) - //DataConnectLogger.debug("Create SDO from JSON \(sdo?.debugDescription)") if impactedRefsAcc != nil { DataConnectLogger.debug("impactedRefs after dehydration \(String(describing: impactedRefsAcc))") } - if let sdo { - self = sdo + if let enode { + self = enode } else { - throw DataConnectCodecError.decodingFailed(message: "Failed to decode into a valid StubDataObject") + throw DataConnectCodecError.decodingFailed(message: "Failed to decode into a valid EntityNode") } } else { @@ -186,14 +185,14 @@ extension StubDataObject: Decodable { let container = try decoder.container(keyedBy: CodingKeys.self) if let globalID = try container.decodeIfPresent(String.self, forKey: .globalID) { - self.backingData = cacheProvider.backingData(globalID) + self.entityData = cacheProvider.entityData(globalID) } - if let refs = try container.decodeIfPresent([String: StubDataObject].self, forKey: .references) { + if let refs = try container.decodeIfPresent([String: EntityNode].self, forKey: .references) { self.references = refs } - if let lists = try container.decodeIfPresent([String: [StubDataObject]].self, forKey: .objectLists) { + if let lists = try container.decodeIfPresent([String: [EntityNode]].self, forKey: .objectLists) { self.objectLists = lists } @@ -205,7 +204,7 @@ extension StubDataObject: Decodable { } } -extension StubDataObject: Encodable { +extension EntityNode: Encodable { func encode(to encoder: Encoder) throws { guard let resultTreeKind = encoder.userInfo[ResultTreeKindCodingKey] as? ResultTreeKind else { throw DataConnectCodecError.decodingFailed(message: "Missing ResultTreeKind in decoder") @@ -215,8 +214,8 @@ extension StubDataObject: Encodable { //var container = encoder.singleValueContainer() var container = encoder.container(keyedBy: DynamicCodingKey.self) - if let backingData { - let encodableData = try backingData.encodableData() + if let entityData { + let encodableData = try entityData.encodableData() for (key, value) in encodableData { try container.encode(value, forKey: DynamicCodingKey(stringValue: key)!) } @@ -242,8 +241,8 @@ extension StubDataObject: Encodable { } else { // dehydrated tree required var container = encoder.container(keyedBy: CodingKeys.self) - if let backingData { - try container.encode(backingData.guid, forKey: .globalID) + if let entityData { + try container.encode(entityData.guid, forKey: .globalID) } if references.count > 0 { @@ -261,20 +260,20 @@ extension StubDataObject: Encodable { } } -extension StubDataObject: Equatable { - public static func == (lhs: StubDataObject, rhs: StubDataObject) -> Bool { - return lhs.backingData == rhs.backingData && +extension EntityNode: Equatable { + public static func == (lhs: EntityNode, rhs: EntityNode) -> Bool { + return lhs.entityData == rhs.entityData && lhs.references == rhs.references && lhs.objectLists == rhs.objectLists && lhs.scalars == rhs.scalars } } -extension StubDataObject: CustomDebugStringConvertible { +extension EntityNode: CustomDebugStringConvertible { var debugDescription: String { return """ - StubDataObject: - \(String(describing: self.backingData)) + EntityNode: + \(String(describing: self.entityData)) References: \(self.references) Lists: diff --git a/Sources/Cache/ResultTree.swift b/Sources/Cache/ResultTree.swift index a0151e6..11ffaec 100644 --- a/Sources/Cache/ResultTree.swift +++ b/Sources/Cache/ResultTree.swift @@ -23,7 +23,7 @@ struct ResultTree { let cachedAt: Date // Local time when the entry was cached / updated var lastAccessed: Date // Local time when the entry was read or updated - var rootObject: StubDataObject? + var rootObject: EntityNode? func isStale(_ ttl: TimeInterval) -> Bool { let now = Date() diff --git a/Sources/Cache/ResultTreeProcessor.swift b/Sources/Cache/ResultTreeProcessor.swift index bd86b6b..4fe63c7 100644 --- a/Sources/Cache/ResultTreeProcessor.swift +++ b/Sources/Cache/ResultTreeProcessor.swift @@ -20,7 +20,7 @@ let ResultTreeKindCodingKey = CodingUserInfoKey(rawValue: "com.google.firebase.d // Key that points to the QueryRef being updated in cache let UpdatingQueryRefsCodingKey = CodingUserInfoKey(rawValue: "com.google.firebase.dataconnect.updatingQueryRef")! -// Key pointing to container for QueryRefs. BackingDataObjects fill this +// Key pointing to container for QueryRefs. EntityDataObjects fill this let ImpactedRefsAccumulatorCodingKey = CodingUserInfoKey(rawValue: "com.google.firebase.dataconnect.impactedQueryRefs")! // Kind of result data we are encoding from or decoding to @@ -29,8 +29,8 @@ enum ResultTreeKind { case dehydrated // JSON data is dehydrated and only contains refs to actual data objects } -// Class used to accumulate query refs as we dehydrate the tree and find BDOs -// BDOs contain references to other QueryRefs that reference the BDO +// Class used to accumulate query refs as we dehydrate the tree and find EDOs +// EDOs contain references to other QueryRefs that reference the EDO // We collect those QueryRefs here class ImpactedQueryRefsAccumulator { // operationIds of impacted QueryRefs @@ -62,21 +62,21 @@ class ImpactedQueryRefsAccumulator { struct ResultTreeProcessor { /* - Go down the tree and convert them to Stubs - For each Stub + Go down the tree and convert them to nodes + For each Node - extract primary key - - Get the BDO for the PK - - extract scalars and update BDO with scalars + - Get the EDO for the PK + - extract scalars and update EDO with scalars - for each array - recursively process each object (could be scalar or composite) - - for composite objects (dictionaries), create references to their stubs - - create a Stub object and init it with dictionary. + - for composite objects (dictionaries), create references to their node + - create a Node and init it with dictionary. */ func dehydrateResults(_ hydratedTree: String, cacheProvider: CacheProvider, requestor: (any QueryRefInternal)? = nil) throws -> ( dehydratedResults: String, - rootObject: StubDataObject, + rootObject: EntityNode, impactedRefIds: [String] ) { let jsonDecoder = JSONDecoder() @@ -85,7 +85,7 @@ struct ResultTreeProcessor { jsonDecoder.userInfo[CacheProviderUserInfoKey] = cacheProvider jsonDecoder.userInfo[ResultTreeKindCodingKey] = ResultTreeKind.hydrated jsonDecoder.userInfo[ImpactedRefsAccumulatorCodingKey] = impactedRefsAccumulator - let sdo = try jsonDecoder.decode(StubDataObject.self, from: hydratedTree.data(using: .utf8)!) + let enode = try jsonDecoder.decode(EntityNode.self, from: hydratedTree.data(using: .utf8)!) DataConnectLogger.debug("Impacted QueryRefs count: \(impactedRefsAccumulator.queryRefIds.count)") let impactedRefs = Array(impactedRefsAccumulator.queryRefIds) @@ -93,7 +93,7 @@ struct ResultTreeProcessor { let jsonEncoder = JSONEncoder() jsonEncoder.userInfo[CacheProviderUserInfoKey] = cacheProvider jsonEncoder.userInfo[ResultTreeKindCodingKey] = ResultTreeKind.dehydrated - let jsonData = try jsonEncoder.encode(sdo) + let jsonData = try jsonEncoder.encode(enode) let dehydratedResultsString = String(data: jsonData, encoding: .utf8)! DataConnectLogger @@ -101,22 +101,22 @@ struct ResultTreeProcessor { "\(#function): \nHydrated \n \(hydratedTree) \n\nDehydrated \n \(dehydratedResultsString)" ) - return (dehydratedResultsString, sdo, impactedRefs) + return (dehydratedResultsString, enode, impactedRefs) } func hydrateResults(_ dehydratedTree: String, cacheProvider: CacheProvider) throws -> - (hydratedResults: String, rootObject: StubDataObject) { + (hydratedResults: String, rootObject: EntityNode) { let jsonDecoder = JSONDecoder() jsonDecoder.userInfo[CacheProviderUserInfoKey] = cacheProvider jsonDecoder.userInfo[ResultTreeKindCodingKey] = ResultTreeKind.dehydrated - let sdo = try jsonDecoder.decode(StubDataObject.self, from: dehydratedTree.data(using: .utf8)!) - DataConnectLogger.debug("Dehydrated Tree decoded to SDO: \(sdo)") + let enode = try jsonDecoder.decode(EntityNode.self, from: dehydratedTree.data(using: .utf8)!) + DataConnectLogger.debug("Dehydrated Tree decoded to EDO: \(enode)") let jsonEncoder = JSONEncoder() jsonEncoder.userInfo[CacheProviderUserInfoKey] = cacheProvider jsonEncoder.userInfo[ResultTreeKindCodingKey] = ResultTreeKind.hydrated - let hydratedResults = try jsonEncoder.encode(sdo) + let hydratedResults = try jsonEncoder.encode(enode) let hydratedResultsString = String(data: hydratedResults, encoding: .utf8)! DataConnectLogger @@ -124,7 +124,7 @@ struct ResultTreeProcessor { "\(#function) Dehydrated \n \(dehydratedTree) \n\nHydrated \n \(hydratedResultsString)" ) - return (hydratedResultsString, sdo) + return (hydratedResultsString, enode) } } diff --git a/Sources/Cache/SQLiteCacheProvider.swift b/Sources/Cache/SQLiteCacheProvider.swift index 0acc40b..c6e0a89 100644 --- a/Sources/Cache/SQLiteCacheProvider.swift +++ b/Sources/Cache/SQLiteCacheProvider.swift @@ -68,29 +68,29 @@ class SQLiteCacheProvider: CacheProvider { throw DataConnectInternalError.sqliteError(message: "Could not create result_tree table") } - let createBackingDataTable = """ - CREATE TABLE IF NOT EXISTS backing_data ( + let createEntityDataTable = """ + CREATE TABLE IF NOT EXISTS entity_data ( entity_guid TEXT PRIMARY KEY NOT NULL, object_state INTEGER DEFAULT 10, object BLOB NOT NULL ); """ - if sqlite3_exec(db, createBackingDataTable, nil, nil, nil) != SQLITE_OK { - throw DataConnectInternalError.sqliteError(message: "Could not create backing_data table") + if sqlite3_exec(db, createEntityDataTable, nil, nil, nil) != SQLITE_OK { + throw DataConnectInternalError.sqliteError(message: "Could not create entity_data table") } - // table to store reverse mapping of BDO => queryRefs mapping - // this is to know which BDOs are still referenced - let createBackingDataRefs = """ - CREATE TABLE IF NOT EXISTS backing_data_query_refs ( - entity_guid TEXT NOT NULL REFERENCES backing_data(entity_guid), + // table to store reverse mapping of EDO => queryRefs mapping + // this is to know which EDOs are still referenced + let createEntityDataRefs = """ + CREATE TABLE IF NOT EXISTS entity_data_query_refs ( + entity_guid TEXT NOT NULL REFERENCES entity_data(entity_guid), query_id TEXT NOT NULL REFERENCES result_tree(query_id), PRIMARY KEY (entity_guid, query_id) ) """ - if sqlite3_exec(db, createBackingDataRefs, nil, nil, nil) != SQLITE_OK { + if sqlite3_exec(db, createEntityDataRefs, nil, nil, nil) != SQLITE_OK { throw DataConnectInternalError.sqliteError( - message: "Could not create backing_data_query_refs table" + message: "Could not create entity_data_query_refs table" ) } } @@ -185,13 +185,13 @@ class SQLiteCacheProvider: CacheProvider { } } - func backingData(_ entityGuid: String) -> BackingDataObject { + func entityData(_ entityGuid: String) -> EntityDataObject { return queue.sync { - let query = "SELECT object FROM backing_data WHERE entity_guid = ?;" + let query = "SELECT object FROM entity_data WHERE entity_guid = ?;" var statement: OpaquePointer? if sqlite3_prepare_v2(db, query, -1, &statement, nil) != SQLITE_OK { - DataConnectLogger.error("Error preparing select statement for backing_data") + DataConnectLogger.error("Error preparing select statement for entity_data") } else { sqlite3_bind_text(statement, 1, (entityGuid as NSString).utf8String, -1, nil) @@ -201,12 +201,12 @@ class SQLiteCacheProvider: CacheProvider { let data = Data(bytes: dataBlob, count: Int(dataBlobLength)) sqlite3_finalize(statement) do { - let bdo = try JSONDecoder().decode(BackingDataObject.self, from: data) - DataConnectLogger.debug("Returning existing BDO for \(entityGuid)") + let edo = try JSONDecoder().decode(EntityDataObject.self, from: data) + DataConnectLogger.debug("Returning existing EDO for \(entityGuid)") let referencedQueryIds = _readQueryRefs(entityGuid: entityGuid) - bdo.referencedFrom = Set(referencedQueryIds) - return bdo + edo.referencedFrom = Set(referencedQueryIds) + return edo } catch { DataConnectLogger.error( "Error decoding data object for entityGuid \(entityGuid): \(error)" @@ -217,16 +217,16 @@ class SQLiteCacheProvider: CacheProvider { sqlite3_finalize(statement) } - // if we reach here it means we don't have a BDO in our database. + // if we reach here it means we don't have a EDO in our database. // So we create one. - let bdo = BackingDataObject(guid: entityGuid) - _setObject(entityGuid: entityGuid, object: bdo) - DataConnectLogger.debug("Created BDO for \(entityGuid)") - return bdo + let edo = EntityDataObject(guid: entityGuid) + _setObject(entityGuid: entityGuid, object: edo) + DataConnectLogger.debug("Created EDO for \(entityGuid)") + return edo } } - func updateBackingData(_ object: BackingDataObject) { + func updateEntityData(_ object: EntityDataObject) { queue.sync { _setObject(entityGuid: object.guid, object: object) } @@ -234,15 +234,15 @@ class SQLiteCacheProvider: CacheProvider { // MARK: Private // These should run on queue but not call sync otherwise we deadlock - private func _setObject(entityGuid: String, object: BackingDataObject) { + private func _setObject(entityGuid: String, object: EntityDataObject) { dispatchPrecondition(condition: .onQueue(queue)) do { let data = try JSONEncoder().encode(object) - let insert = "INSERT OR REPLACE INTO backing_data (entity_guid, object) VALUES (?, ?);" + let insert = "INSERT OR REPLACE INTO entity_data (entity_guid, object) VALUES (?, ?);" var statement: OpaquePointer? if sqlite3_prepare_v2(db, insert, -1, &statement, nil) != SQLITE_OK { - DataConnectLogger.error("Error preparing insert statement for backing_data") + DataConnectLogger.error("Error preparing insert statement for entity_data") return } @@ -265,14 +265,14 @@ class SQLiteCacheProvider: CacheProvider { } } - private func _updateQueryRefs(object: BackingDataObject) { + private func _updateQueryRefs(object: EntityDataObject) { dispatchPrecondition(condition: .onQueue(queue)) guard object.referencedFrom.count > 0 else { return } var insertReferences = - "INSERT OR REPLACE INTO backing_data_query_refs (entity_guid, query_id) VALUES " + "INSERT OR REPLACE INTO entity_data_query_refs (entity_guid, query_id) VALUES " for queryId in object.referencedFrom { insertReferences += "('\(object.guid)', '\(queryId)'), " } @@ -281,7 +281,7 @@ class SQLiteCacheProvider: CacheProvider { var statementRefs: OpaquePointer? if sqlite3_prepare_v2(db, insertReferences, -1, &statementRefs, nil) != SQLITE_OK { - DataConnectLogger.error("Error preparing insert statement for backing_data_query_refs") + DataConnectLogger.error("Error preparing insert statement for entity_data_query_refs") return } @@ -296,7 +296,7 @@ class SQLiteCacheProvider: CacheProvider { private func _readQueryRefs(entityGuid: String) -> [String] { let readRefs = - "SELECT query_id FROM backing_data_query_refs WHERE entity_guid = '\(entityGuid)'" + "SELECT query_id FROM entity_data_query_refs WHERE entity_guid = '\(entityGuid)'" var statementRefs: OpaquePointer? var queryIds: [String] = [] diff --git a/Tests/Unit/CacheTests.swift b/Tests/Unit/CacheTests.swift index 67a7228..50d5ac3 100644 --- a/Tests/Unit/CacheTests.swift +++ b/Tests/Unit/CacheTests.swift @@ -26,34 +26,51 @@ final class CacheTests: XCTestCase { let resultTreeJson = """ {"items":[{"id":"0cadb2b93d46434db1d218d6db023b79","price":226.94024396145267,"name":"Item-24","cacheId":"78192783c32c48cd9b4146547421a6a5","userReviews_on_item":[]},{"cacheId":"fc9387b4c50a4eb28e91d7a09d108a44","id":"4a01c498dd014a29b20ac693395a2900","userReviews_on_item":[],"name":"Item-62","price":512.3027252608986},{"price":617.9690589103608,"id":"e2ed29ed3e9b42328899d49fa33fc785","userReviews_on_item":[],"cacheId":"a911561b2b904f008ab8c3a2d2a7fdbe","name":"Item-49"},{"id":"0da168e75ded479ea3b150c13b7c6ec7","price":10.456,"userReviews_on_item":[{"cacheId":"125791DB-696E-4446-8F2A-C17E7C2AF771","user":{"name":"User1","id":"2fff8099d54843a0bbbbcf905e4c3424","cacheId":"27E85023-D465-4240-82D6-0055AA122406"},"title":"Item1 Review1 byUser1","id":"1384a5173c31487c8834368348c3b89c"}],"name":"Item1","cacheId":"fcfa90f7308049a083c3131f9a7a9836"},{"id":"23311f29be09495cba198da89b8b7d0f","name":"Item2","price":20.456,"cacheId":"c565d2fb7386480c87aa804f2789d200","userReviews_on_item":[{"title":"Item2 Review1 byUser1","user":{"name":"User1","id":"2fff8099d54843a0bbbbcf905e4c3424","cacheId":"27E85023-D465-4240-82D6-0055AA122406"},"cacheId":"F652FB4E-65E0-43E0-ADB1-14582304F938","id":"7ec6b021e1654eff98b3482925fab0c9"}]},{"name":"Item3","cacheId":"c6218faf3607495aaeab752ae6d0b8a7","id":"b7d2287e94014f4fa4a1566f1b893105","price":30.456,"userReviews_on_item":[{"title":"Item3 Review1 byUser2","cacheId":"8455C788-647F-4AB3-971B-6A9C42456129","id":"9bf4d458dd204a4c8931fe952bba85b7","user":{"id":"00af97d8f274427cb5e2c691ca13521c","name":"User2","cacheId":"EB588061-7139-4D6D-9A1B-80D4150DC1B4"}}]},{"userReviews_on_item":[{"id":"d769b8d6d4064e81948fb6b9374fba54","cacheId":"03ADC9EC-0102-4F24-BE8B-F6C0DD102EA4","title":"Item4 Review1 byUser3","user":{"cacheId":"65928AFC-22FA-422D-A2F1-85980DC682AE","id":"69562c9aee2f47ee8abb8181d4df53ec","name":"User3"}}],"price":40.456,"name":"Item4","id":"98e55525f20f4ee190034adcd6fb01dc","cacheId":"a2e64ada1771434aa3ec73f6a6d05428"}]} """ - let cacheProvider = EphemeralCacheProvider("testEphemeralCacheProvider") + var cacheProvider: SQLiteCacheProvider? - override func setUpWithError() throws {} + override func setUpWithError() throws { + cacheProvider = try SQLiteCacheProvider( + "testEphemeralCacheProvider", + ephemeral: true + ) + } override func tearDownWithError() throws {} - // Confirm that providing same entity cache id uses the same BackingDataObject instance - func testBDOReuse() throws { + // Confirm that providing same entity cache id uses the same EntityDataObject instance + func testEntityDataObjectReuse() throws { do { + + guard let cacheProvider else { + XCTFail("cacheProvider is nil") + return + } + let resultsProcessor = ResultTreeProcessor() - let (_, _) = try resultsProcessor.dehydrateResults(resultTreeJson, cacheProvider: cacheProvider) + try resultsProcessor.dehydrateResults(resultTreeJson, cacheProvider: cacheProvider) let reusedCacheId = "27E85023-D465-4240-82D6-0055AA122406" - let user1 = cacheProvider.backingData(reusedCacheId) - let user2 = cacheProvider.backingData(reusedCacheId) + let user1 = cacheProvider.entityData(reusedCacheId) + let user2 = cacheProvider.entityData(reusedCacheId) // both user objects should be references to same instance - XCTAssertTrue(user1 === user2) + XCTAssertTrue(user1 == user2) } } func testDehydrationHydration() throws { do { + + guard let cacheProvider else { + XCTFail("cacheProvider is nil") + return + } + let resultsProcessor = ResultTreeProcessor() - let (dehydratedTree, do1) = try resultsProcessor.dehydrateResults( + let (dehydratedTree, do1, _) = try resultsProcessor.dehydrateResults( resultTreeOneItemJson, cacheProvider: cacheProvider ) From 353caf51ff777f94b08ea79bc5f32ccd60efde9b Mon Sep 17 00:00:00 2001 From: Aashish Patil Date: Thu, 9 Oct 2025 09:15:28 -0700 Subject: [PATCH 15/38] DispatchQueue for EntityDataObject access --- Sources/BaseOperationRef.swift | 2 +- Sources/Cache/EntityDataObject.swift | 71 ++++++++++++++++++------- Sources/Cache/EntityNode.swift | 2 +- Sources/Cache/SQLiteCacheProvider.swift | 23 +++++--- 4 files changed, 69 insertions(+), 29 deletions(-) diff --git a/Sources/BaseOperationRef.swift b/Sources/BaseOperationRef.swift index e8c6dbd..d0b821d 100644 --- a/Sources/BaseOperationRef.swift +++ b/Sources/BaseOperationRef.swift @@ -16,7 +16,7 @@ import Foundation @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) public struct OperationResult: Sendable { - public var data: ResultData? + public let data: ResultData? public let source: ResultSource } diff --git a/Sources/Cache/EntityDataObject.swift b/Sources/Cache/EntityDataObject.swift index 3f3244e..3f32fa8 100644 --- a/Sources/Cache/EntityDataObject.swift +++ b/Sources/Cache/EntityDataObject.swift @@ -12,6 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. +import Foundation + struct ScalarField { let name: String let value: AnyCodableValue @@ -19,16 +21,18 @@ struct ScalarField { class EntityDataObject: CustomStringConvertible, Codable { - // Set of QueryRefs that reference this EDO - var referencedFrom = Set() - let guid: String // globally unique id received from server + private let accessQueue = DispatchQueue( + label: "com.google.firebase.dataconnect.entityData", + autoreleaseFrequency: .workItem + ) + required init(guid: String) { self.guid = guid } - private var serverValues = SynchronizedDictionary() + private var serverValues = [String: AnyCodableValue]() enum CodingKeys: String, CodingKey { case globalID = "guid" @@ -43,43 +47,71 @@ class EntityDataObject: CustomStringConvertible, Codable { _ requestor: (any QueryRefInternal)? = nil ) -> [String] { - self.serverValues[key] = newValue - DataConnectLogger.debug("EDO updateServerValue: \(key) \(newValue) for \(guid)") - - if let requestor { - referencedFrom.insert(requestor.operationId) - DataConnectLogger - .debug("Inserted referencedFrom \(requestor). New count \(referencedFrom.count)") + accessQueue.sync { + self.serverValues[key] = newValue + DataConnectLogger.debug("EDO updateServerValue: \(key) \(newValue) for \(guid)") + if let requestor { + referencedFrom.insert(requestor.operationId) + DataConnectLogger + .debug("Inserted referencedFrom \(requestor). New count \(referencedFrom.count)") + + } + let refs: [String] = Array(referencedFrom) + return refs } - let refs: [String] = Array(referencedFrom) - return refs } func value(forKey key: String) -> AnyCodableValue? { - return self.serverValues[key] + accessQueue.sync { + return self.serverValues[key] + } } + // MARK: Track referenced QueryRefs + + // Set of QueryRefs that reference this EDO + private var referencedFrom = Set() + + func updateReferencedFrom(_ refs: Set) { + accessQueue.sync { + self.referencedFrom = refs + } + } + + func referencedFromRefs() -> Set { + accessQueue.sync { + return self.referencedFrom + } + } + + var isReferencedFromAnyQueryRef: Bool { + accessQueue.sync { + return !referencedFrom.isEmpty + } + } + + var description: String { return """ EntityDataObject: globalID: \(guid) serverValues: - \(serverValues.rawCopy()) + \(serverValues) """ } func encodableData() throws -> [String: AnyCodableValue] { var encodingValues = [String: AnyCodableValue]() encodingValues[GlobalIDKey] = .string(guid) - encodingValues.merge(serverValues.rawCopy()) { (_, new) in new } + encodingValues.merge(serverValues) { (_, new) in new } return encodingValues } public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(guid, forKey: .globalID) - try container.encode(serverValues.rawCopy(), forKey: .serverValues) + try container.encode(serverValues, forKey: .serverValues) // once we have localValues, we will need to merge between the two dicts and encode } @@ -89,8 +121,7 @@ class EntityDataObject: CustomStringConvertible, Codable { let globalId = try container.decode(String.self, forKey: .globalID) self.guid = globalId - let rawValues = try container.decode([String: AnyCodableValue].self, forKey: .serverValues) - serverValues.updateValues(rawValues) + serverValues = try container.decode([String: AnyCodableValue].self, forKey: .serverValues) } } @@ -98,7 +129,7 @@ class EntityDataObject: CustomStringConvertible, Codable { extension EntityDataObject: Equatable { static func == (lhs: EntityDataObject, rhs: EntityDataObject) -> Bool { - return lhs.guid == rhs.guid && lhs.serverValues.rawCopy() == rhs.serverValues.rawCopy() + return lhs.guid == rhs.guid && lhs.serverValues == rhs.serverValues } } diff --git a/Sources/Cache/EntityNode.swift b/Sources/Cache/EntityNode.swift index 1aac8b4..b90cd6c 100644 --- a/Sources/Cache/EntityNode.swift +++ b/Sources/Cache/EntityNode.swift @@ -133,7 +133,7 @@ struct EntityNode { } //for (key,value) if let entityData { - for refId in entityData.referencedFrom { impactedRefsAccumulator?.append(refId) } + for refId in entityData.referencedFromRefs() { impactedRefsAccumulator?.append(refId) } cacheProvider.updateEntityData(entityData) } } diff --git a/Sources/Cache/SQLiteCacheProvider.swift b/Sources/Cache/SQLiteCacheProvider.swift index c6e0a89..f8c056b 100644 --- a/Sources/Cache/SQLiteCacheProvider.swift +++ b/Sources/Cache/SQLiteCacheProvider.swift @@ -16,12 +16,21 @@ import FirebaseCore import Foundation import SQLite3 +fileprivate enum TableName { + static let entityDataObjects = "entity_data" + static let resultTree = "result_tree" + static let entityDataQueryRefs = "entity_data_query_refs" +} + class SQLiteCacheProvider: CacheProvider { let cacheIdentifier: String private var db: OpaquePointer? - private let queue = DispatchQueue(label: "com.google.firebase.dataconnect.sqlitecacheprovider") + private let queue = DispatchQueue( + label: "com.google.firebase.dataconnect.sqlitecacheprovider", + autoreleaseFrequency: .workItem + ) init(_ cacheIdentifier: String, ephemeral: Bool = false) throws { self.cacheIdentifier = cacheIdentifier @@ -58,7 +67,7 @@ class SQLiteCacheProvider: CacheProvider { dispatchPrecondition(condition: .onQueue(queue)) let createResultTreeTable = """ - CREATE TABLE IF NOT EXISTS result_tree ( + CREATE TABLE IF NOT EXISTS \(TableName.resultTree) ( query_id TEXT PRIMARY KEY NOT NULL, last_accessed REAL NOT NULL, tree BLOB NOT NULL @@ -69,7 +78,7 @@ class SQLiteCacheProvider: CacheProvider { } let createEntityDataTable = """ - CREATE TABLE IF NOT EXISTS entity_data ( + CREATE TABLE IF NOT EXISTS \(TableName.entityDataObjects) ( entity_guid TEXT PRIMARY KEY NOT NULL, object_state INTEGER DEFAULT 10, object BLOB NOT NULL @@ -82,7 +91,7 @@ class SQLiteCacheProvider: CacheProvider { // table to store reverse mapping of EDO => queryRefs mapping // this is to know which EDOs are still referenced let createEntityDataRefs = """ - CREATE TABLE IF NOT EXISTS entity_data_query_refs ( + CREATE TABLE IF NOT EXISTS \(TableName.entityDataQueryRefs) ( entity_guid TEXT NOT NULL REFERENCES entity_data(entity_guid), query_id TEXT NOT NULL REFERENCES result_tree(query_id), PRIMARY KEY (entity_guid, query_id) @@ -205,7 +214,7 @@ class SQLiteCacheProvider: CacheProvider { DataConnectLogger.debug("Returning existing EDO for \(entityGuid)") let referencedQueryIds = _readQueryRefs(entityGuid: entityGuid) - edo.referencedFrom = Set(referencedQueryIds) + edo.updateReferencedFrom(Set(referencedQueryIds)) return edo } catch { DataConnectLogger.error( @@ -268,12 +277,12 @@ class SQLiteCacheProvider: CacheProvider { private func _updateQueryRefs(object: EntityDataObject) { dispatchPrecondition(condition: .onQueue(queue)) - guard object.referencedFrom.count > 0 else { + guard object.isReferencedFromAnyQueryRef else { return } var insertReferences = "INSERT OR REPLACE INTO entity_data_query_refs (entity_guid, query_id) VALUES " - for queryId in object.referencedFrom { + for queryId in object.referencedFromRefs() { insertReferences += "('\(object.guid)', '\(queryId)'), " } insertReferences.removeLast(2) From 78599a3e78aace6c9ab5ffcd6389fb17ee90a252 Mon Sep 17 00:00:00 2001 From: Aashish Patil Date: Fri, 10 Oct 2025 14:27:31 -0700 Subject: [PATCH 16/38] Externalize table and column name strings into constants --- Sources/Cache/SQLiteCacheProvider.swift | 52 ++++++++++++++----------- 1 file changed, 30 insertions(+), 22 deletions(-) diff --git a/Sources/Cache/SQLiteCacheProvider.swift b/Sources/Cache/SQLiteCacheProvider.swift index f8c056b..01a6eec 100644 --- a/Sources/Cache/SQLiteCacheProvider.swift +++ b/Sources/Cache/SQLiteCacheProvider.swift @@ -18,10 +18,17 @@ import SQLite3 fileprivate enum TableName { static let entityDataObjects = "entity_data" - static let resultTree = "result_tree" + static let resultTree = "query_results" static let entityDataQueryRefs = "entity_data_query_refs" } +fileprivate enum ColumnName { + static let entityId = "entity_guid" + static let data = "data" + static let queryId = "query_id" + static let lastAccessed = "last_accessed" +} + class SQLiteCacheProvider: CacheProvider { let cacheIdentifier: String @@ -68,20 +75,20 @@ class SQLiteCacheProvider: CacheProvider { let createResultTreeTable = """ CREATE TABLE IF NOT EXISTS \(TableName.resultTree) ( - query_id TEXT PRIMARY KEY NOT NULL, - last_accessed REAL NOT NULL, - tree BLOB NOT NULL + \(ColumnName.queryId) TEXT PRIMARY KEY NOT NULL, + \(ColumnName.lastAccessed) REAL NOT NULL, + \(ColumnName.data) BLOB NOT NULL ); """ if sqlite3_exec(db, createResultTreeTable, nil, nil, nil) != SQLITE_OK { - throw DataConnectInternalError.sqliteError(message: "Could not create result_tree table") + throw DataConnectInternalError + .sqliteError(message: "Could not create \(TableName.resultTree) table") } let createEntityDataTable = """ CREATE TABLE IF NOT EXISTS \(TableName.entityDataObjects) ( - entity_guid TEXT PRIMARY KEY NOT NULL, - object_state INTEGER DEFAULT 10, - object BLOB NOT NULL + \(ColumnName.entityId) TEXT PRIMARY KEY NOT NULL, + \(ColumnName.data) BLOB NOT NULL ); """ if sqlite3_exec(db, createEntityDataTable, nil, nil, nil) != SQLITE_OK { @@ -92,9 +99,9 @@ class SQLiteCacheProvider: CacheProvider { // this is to know which EDOs are still referenced let createEntityDataRefs = """ CREATE TABLE IF NOT EXISTS \(TableName.entityDataQueryRefs) ( - entity_guid TEXT NOT NULL REFERENCES entity_data(entity_guid), - query_id TEXT NOT NULL REFERENCES result_tree(query_id), - PRIMARY KEY (entity_guid, query_id) + \(ColumnName.entityId) TEXT NOT NULL REFERENCES \(TableName.entityDataObjects)(\(ColumnName.entityId)), + \(ColumnName.queryId) TEXT NOT NULL REFERENCES \(TableName.resultTree)(\(ColumnName.queryId)), + PRIMARY KEY (\(ColumnName.entityId), \(ColumnName.queryId)) ) """ if sqlite3_exec(db, createEntityDataRefs, nil, nil, nil) != SQLITE_OK { @@ -107,7 +114,7 @@ class SQLiteCacheProvider: CacheProvider { private func updateLastAccessedTime(forQueryId queryId: String) { dispatchPrecondition(condition: .onQueue(queue)) - let updateQuery = "UPDATE result_tree SET last_accessed = ? WHERE query_id = ?;" + let updateQuery = "UPDATE \(TableName.resultTree) SET \(ColumnName.lastAccessed) = ? WHERE \(ColumnName.queryId) = ?;" var statement: OpaquePointer? if sqlite3_prepare_v2(self.db, updateQuery, -1, &statement, nil) != SQLITE_OK { @@ -119,7 +126,7 @@ class SQLiteCacheProvider: CacheProvider { sqlite3_bind_text(statement, 2, (queryId as NSString).utf8String, -1, nil) if sqlite3_step(statement) != SQLITE_DONE { - DataConnectLogger.error("Error updating last_accessed for query \(queryId)") + DataConnectLogger.error("Error updating \(ColumnName.lastAccessed) for query \(queryId)") } sqlite3_finalize(statement) @@ -127,11 +134,11 @@ class SQLiteCacheProvider: CacheProvider { func resultTree(queryId: String) -> ResultTree? { return queue.sync { - let query = "SELECT tree FROM result_tree WHERE query_id = ?;" + let query = "SELECT \(ColumnName.data) FROM \(TableName.resultTree) WHERE \(ColumnName.queryId) = ?;" var statement: OpaquePointer? if sqlite3_prepare_v2(db, query, -1, &statement, nil) != SQLITE_OK { - DataConnectLogger.error("Error preparing select statement for result_tree") + DataConnectLogger.error("Error preparing select statement for \(TableName.resultTree)") return nil } @@ -165,11 +172,11 @@ class SQLiteCacheProvider: CacheProvider { var tree = tree let data = try JSONEncoder().encode(tree) let insert = - "INSERT OR REPLACE INTO result_tree (query_id, last_accessed, tree) VALUES (?, ?, ?);" + "INSERT OR REPLACE INTO \(TableName.resultTree) (\(ColumnName.queryId), \(ColumnName.lastAccessed), \(ColumnName.data)) VALUES (?, ?, ?);" var statement: OpaquePointer? if sqlite3_prepare_v2(db, insert, -1, &statement, nil) != SQLITE_OK { - DataConnectLogger.error("Error preparing insert statement for result_tree") + DataConnectLogger.error("Error preparing insert statement for \(TableName.resultTree)") return } @@ -196,11 +203,12 @@ class SQLiteCacheProvider: CacheProvider { func entityData(_ entityGuid: String) -> EntityDataObject { return queue.sync { - let query = "SELECT object FROM entity_data WHERE entity_guid = ?;" + let query = "SELECT \(ColumnName.data) FROM \(TableName.entityDataObjects) WHERE \(ColumnName.entityId) = ?;" var statement: OpaquePointer? if sqlite3_prepare_v2(db, query, -1, &statement, nil) != SQLITE_OK { - DataConnectLogger.error("Error preparing select statement for entity_data") + DataConnectLogger + .error("Error preparing select statement for \(TableName.entityDataObjects)") } else { sqlite3_bind_text(statement, 1, (entityGuid as NSString).utf8String, -1, nil) @@ -247,7 +255,7 @@ class SQLiteCacheProvider: CacheProvider { dispatchPrecondition(condition: .onQueue(queue)) do { let data = try JSONEncoder().encode(object) - let insert = "INSERT OR REPLACE INTO entity_data (entity_guid, object) VALUES (?, ?);" + let insert = "INSERT OR REPLACE INTO \(TableName.entityDataObjects) (\(ColumnName.entityId), \(ColumnName.data)) VALUES (?, ?);" var statement: OpaquePointer? if sqlite3_prepare_v2(db, insert, -1, &statement, nil) != SQLITE_OK { @@ -281,7 +289,7 @@ class SQLiteCacheProvider: CacheProvider { return } var insertReferences = - "INSERT OR REPLACE INTO entity_data_query_refs (entity_guid, query_id) VALUES " + "INSERT OR REPLACE INTO \(TableName.entityDataQueryRefs) (\(ColumnName.entityId), \(ColumnName.queryId)) VALUES " for queryId in object.referencedFromRefs() { insertReferences += "('\(object.guid)', '\(queryId)'), " } @@ -305,7 +313,7 @@ class SQLiteCacheProvider: CacheProvider { private func _readQueryRefs(entityGuid: String) -> [String] { let readRefs = - "SELECT query_id FROM entity_data_query_refs WHERE entity_guid = '\(entityGuid)'" + "SELECT \(ColumnName.queryId) FROM \(TableName.entityDataQueryRefs) WHERE \(ColumnName.entityId) = '\(entityGuid)'" var statementRefs: OpaquePointer? var queryIds: [String] = [] From 250e90518eaaf6bb1707ed3e767be9a8f2765a93 Mon Sep 17 00:00:00 2001 From: Aashish Patil Date: Fri, 10 Oct 2025 15:22:01 -0700 Subject: [PATCH 17/38] API Review feedback --- Sources/BaseOperationRef.swift | 6 ------ Sources/Cache/Cache.swift | 4 ++-- Sources/Cache/CacheConfig.swift | 13 +++++++++---- Sources/DataConnect.swift | 5 ++--- Sources/DataConnectSettings.swift | 11 +++++++---- ...acheProviderType.swift => OperationResult.swift} | 12 ++++++------ 6 files changed, 26 insertions(+), 25 deletions(-) rename Sources/{Cache/CacheProviderType.swift => OperationResult.swift} (70%) diff --git a/Sources/BaseOperationRef.swift b/Sources/BaseOperationRef.swift index d0b821d..b6eebe5 100644 --- a/Sources/BaseOperationRef.swift +++ b/Sources/BaseOperationRef.swift @@ -14,12 +14,6 @@ import Foundation -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public struct OperationResult: Sendable { - public let data: ResultData? - public let source: ResultSource -} - // notional protocol that denotes a variable. @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) public protocol OperationVariable: Encodable, Hashable, Equatable, Sendable {} diff --git a/Sources/Cache/Cache.swift b/Sources/Cache/Cache.swift index c4a309e..131185c 100644 --- a/Sources/Cache/Cache.swift +++ b/Sources/Cache/Cache.swift @@ -52,7 +52,7 @@ class Cache { } do { - switch config.type { + switch config.storage { case .ephemeral: self.cacheProvider = try SQLiteCacheProvider(identifier, ephemeral: true) case .persistent: @@ -77,7 +77,7 @@ class Cache { private func contructCacheIdentifier() -> String { dispatchPrecondition(condition: .onQueue(queue)) - let identifier = "\(self.config.type)-\(String(describing: dataConnect.app.options.projectID))-\(dataConnect.app.name)-\(dataConnect.connectorConfig.serviceId)-\(dataConnect.connectorConfig.connector)-\(dataConnect.connectorConfig.location)-\(Auth.auth(app: dataConnect.app).currentUser?.uid ?? "anon")-\(dataConnect.settings.host)" + let identifier = "\(self.config.storage)-\(String(describing: dataConnect.app.options.projectID))-\(dataConnect.app.name)-\(dataConnect.connectorConfig.serviceId)-\(dataConnect.connectorConfig.connector)-\(dataConnect.connectorConfig.location)-\(Auth.auth(app: dataConnect.app).currentUser?.uid ?? "anon")-\(dataConnect.settings.host)" let encoded = identifier.sha256 DataConnectLogger.debug("Created Cache Identifier \(encoded) for \(identifier)") return encoded diff --git a/Sources/Cache/CacheConfig.swift b/Sources/Cache/CacheConfig.swift index 603a776..b78ed3d 100644 --- a/Sources/Cache/CacheConfig.swift +++ b/Sources/Cache/CacheConfig.swift @@ -17,11 +17,16 @@ @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) public struct CacheConfig: Sendable { - public let type: CacheProviderType // default provider is persistent type - public let maxSize: Int + public enum Storage: Sendable { + case persistent + case ephemeral + } + + public let storage: Storage // default provider is persistent type + public let maxSize: UInt64 - public init(type: CacheProviderType = .persistent, maxSize: Int = 100_000_000) { - self.type = type + public init(storage: Storage = .persistent, maxSize: UInt64 = 100_000_000) { + self.storage = storage self.maxSize = maxSize } diff --git a/Sources/DataConnect.swift b/Sources/DataConnect.swift index 252c36b..1405fda 100644 --- a/Sources/DataConnect.swift +++ b/Sources/DataConnect.swift @@ -54,8 +54,7 @@ public class DataConnect { public class func dataConnect(app: FirebaseApp? = FirebaseApp.app(), connectorConfig: ConnectorConfig, settings: DataConnectSettings = DataConnectSettings(), - callerSDKType: CallerSDKType = .base, - cacheConfig: CacheConfig? = CacheConfig()) + callerSDKType: CallerSDKType = .base) -> DataConnect { guard let app = app else { fatalError("No Firebase App present") @@ -67,7 +66,7 @@ public class DataConnect { config: connectorConfig, settings: settings, callerSDKType: callerSDKType, - cacheConfig: cacheConfig + cacheConfig: settings.cacheConfig ) } diff --git a/Sources/DataConnectSettings.swift b/Sources/DataConnectSettings.swift index dda66f7..0fcff28 100644 --- a/Sources/DataConnectSettings.swift +++ b/Sources/DataConnectSettings.swift @@ -16,20 +16,23 @@ import Foundation @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) public struct DataConnectSettings: Hashable, Equatable, Sendable { - public private(set) var host: String - public private(set) var port: Int - public private(set) var sslEnabled: Bool + public let host: String + public let port: Int + public let sslEnabled: Bool + public let cacheConfig: CacheConfig? - public init(host: String, port: Int, sslEnabled: Bool) { + public init(host: String, port: Int, sslEnabled: Bool, cacheConfig: CacheConfig? = CacheConfig()) { self.host = host self.port = port self.sslEnabled = sslEnabled + self.cacheConfig = cacheConfig } public init() { host = "firebasedataconnect.googleapis.com" port = 443 sslEnabled = true + cacheConfig = CacheConfig() } public func hash(into hasher: inout Hasher) { diff --git a/Sources/Cache/CacheProviderType.swift b/Sources/OperationResult.swift similarity index 70% rename from Sources/Cache/CacheProviderType.swift rename to Sources/OperationResult.swift index 0edbd5e..3c9b28e 100644 --- a/Sources/Cache/CacheProviderType.swift +++ b/Sources/OperationResult.swift @@ -1,4 +1,4 @@ -// Copyright 2025 Google LLC +// 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. @@ -12,11 +12,11 @@ // See the License for the specific language governing permissions and // limitations under the License. +import Foundation -/// Types of supported cache providers @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public enum CacheProviderType: Sendable { - case ephemeral // cache is held in memory and not persisted to disk. - case persistent // cache is persisted to disk. This is the default type -} +public struct OperationResult: Sendable { + public let data: ResultData? + public let source: ResultSource +} From f1aa7171bfbb919bb3bf8bc759d4d60b1f5cb8df Mon Sep 17 00:00:00 2001 From: Aashish Patil Date: Fri, 10 Oct 2025 17:53:47 -0700 Subject: [PATCH 18/38] Code formatting updates --- Sources/Cache/Cache.swift | 59 ++++--- Sources/Cache/CacheConfig.swift | 9 +- Sources/Cache/CacheProvider.swift | 12 +- Sources/Cache/DynamicCodingKey.swift | 1 - Sources/Cache/EntityDataObject.swift | 75 ++++----- Sources/Cache/EntityNode.swift | 154 +++++++++--------- Sources/Cache/ResultTree.swift | 20 +-- Sources/Cache/ResultTreeProcessor.swift | 75 ++++----- Sources/Cache/SQLiteCacheProvider.swift | 71 ++++---- Sources/DataConnect.swift | 23 ++- Sources/DataConnectError.swift | 6 +- Sources/DataConnectSettings.swift | 3 +- Sources/Internal/GrpcClient.swift | 12 +- Sources/Internal/HashUtils.swift | 8 +- Sources/Internal/OperationsManager.swift | 10 +- Sources/Internal/QueryRefInternal.swift | 2 +- Sources/Internal/SynchronizedDictionary.swift | 13 +- Sources/MutationRef.swift | 7 +- Sources/OperationResult.swift | 1 - Sources/Queries/GenericQueryRef.swift | 60 +++---- Sources/Queries/ObservableQueryRef.swift | 62 ++++--- Sources/Queries/QueryFetchPolicy.swift | 5 +- Sources/Queries/QueryRef.swift | 18 +- Sources/Queries/QueryRequest.swift | 8 +- Sources/ResultSource.swift | 6 +- Sources/Scalars/AnyCodableValue.swift | 7 +- Tests/Unit/CacheTests.swift | 46 +++--- 27 files changed, 379 insertions(+), 394 deletions(-) diff --git a/Sources/Cache/Cache.swift b/Sources/Cache/Cache.swift index 131185c..8dd4d6b 100644 --- a/Sources/Cache/Cache.swift +++ b/Sources/Cache/Cache.swift @@ -15,74 +15,72 @@ import FirebaseAuth class Cache { - let config: CacheConfig let dataConnect: DataConnect - + private var cacheProvider: CacheProvider? - + private let queue = DispatchQueue(label: "com.google.firebase.dataconnect.cache") - + // holding it to avoid dereference private var authChangeListenerProtocol: NSObjectProtocol? - + init(config: CacheConfig, dataConnect: DataConnect) { self.config = config self.dataConnect = dataConnect - + // sync because we want the provider initialized immediately when in init queue.sync { self.initializeCacheProvider() setupChangeListeners() } - } - + private func initializeCacheProvider() { - dispatchPrecondition(condition: .onQueue(queue)) - + let identifier = contructCacheIdentifier() - + // Create a cacheProvider if - // we don't have an existing cacheProvider // we have one but its identifier is different than new one (e.g. auth uid changed) - if cacheProvider != nil && cacheProvider?.cacheIdentifier == identifier { + if cacheProvider != nil, cacheProvider?.cacheIdentifier == identifier { return } - + do { switch config.storage { case .ephemeral: - self.cacheProvider = try SQLiteCacheProvider(identifier, ephemeral: true) + cacheProvider = try SQLiteCacheProvider(identifier, ephemeral: true) case .persistent: - self.cacheProvider = try SQLiteCacheProvider(identifier, ephemeral: false) + cacheProvider = try SQLiteCacheProvider(identifier, ephemeral: false) } } catch { DataConnectLogger.error("Unable to initialize Persistent provider \(error)") } } - + private func setupChangeListeners() { dispatchPrecondition(condition: .onQueue(queue)) - + authChangeListenerProtocol = Auth.auth(app: dataConnect.app).addStateDidChangeListener { _, _ in self.queue.async(flags: .barrier) { self.initializeCacheProvider() } } } - + // Create an identifier for the cache that the Provider will use for cache scoping private func contructCacheIdentifier() -> String { dispatchPrecondition(condition: .onQueue(queue)) - - let identifier = "\(self.config.storage)-\(String(describing: dataConnect.app.options.projectID))-\(dataConnect.app.name)-\(dataConnect.connectorConfig.serviceId)-\(dataConnect.connectorConfig.connector)-\(dataConnect.connectorConfig.location)-\(Auth.auth(app: dataConnect.app).currentUser?.uid ?? "anon")-\(dataConnect.settings.host)" + + let identifier = + "\(config.storage)-\(String(describing: dataConnect.app.options.projectID))-\(dataConnect.app.name)-\(dataConnect.connectorConfig.serviceId)-\(dataConnect.connectorConfig.connector)-\(dataConnect.connectorConfig.location)-\(Auth.auth(app: dataConnect.app).currentUser?.uid ?? "anon")-\(dataConnect.settings.host)" let encoded = identifier.sha256 DataConnectLogger.debug("Created Cache Identifier \(encoded) for \(identifier)") return encoded } - + func resultTree(queryId: String) -> ResultTree? { // result trees are stored dehydrated in the cache // retrieve cache, hydrate it and then return @@ -90,14 +88,14 @@ class Cache { guard let dehydratedTree = cacheProvider?.resultTree(queryId: queryId) else { return nil } - + do { let resultsProcessor = ResultTreeProcessor() let (hydratedResults, rootObj) = try resultsProcessor.hydrateResults( dehydratedTree.data, cacheProvider: cacheProvider! ) - + let hydratedTree = ResultTree( data: hydratedResults, ttl: dehydratedTree.ttl, @@ -117,8 +115,9 @@ class Cache { func update(queryId: String, response: ServerResponse, requestor: (any QueryRefInternal)? = nil) { queue.async(flags: .barrier) { guard let cacheProvider = self.cacheProvider else { - DataConnectLogger.debug("Cache provider not initialized yet. Skipping update for \(queryId)") - return + DataConnectLogger + .debug("Cache provider not initialized yet. Skipping update for \(queryId)") + return } do { let processor = ResultTreeProcessor() @@ -139,16 +138,17 @@ class Cache { rootObject: rootObj ) ) - - impactedRefs.forEach { refId in + + for refId in impactedRefs { guard let q = self.dataConnect.queryRef(for: refId) as? (any QueryRefInternal) else { - return + continue } Task { do { try await q.publishCacheResultsToSubscribers(allowStale: true) } catch { - DataConnectLogger.warning("Error republishing cached results for impacted queryrefs \(error))") + DataConnectLogger + .warning("Error republishing cached results for impacted queryrefs \(error))") } } } @@ -157,5 +157,4 @@ class Cache { } } } - } diff --git a/Sources/Cache/CacheConfig.swift b/Sources/Cache/CacheConfig.swift index b78ed3d..de43302 100644 --- a/Sources/Cache/CacheConfig.swift +++ b/Sources/Cache/CacheConfig.swift @@ -13,22 +13,19 @@ // limitations under the License. /// Firebase Data Connect cache is configured per Connector. -/// Specifies the cache configuration for Firebase Data Connect at a connector level +/// Specifies the cache configuration for Firebase Data Connect at a connector level @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) public struct CacheConfig: Sendable { - public enum Storage: Sendable { case persistent case ephemeral } - + public let storage: Storage // default provider is persistent type public let maxSize: UInt64 - + public init(storage: Storage = .persistent, maxSize: UInt64 = 100_000_000) { self.storage = storage self.maxSize = maxSize } - } - diff --git a/Sources/Cache/CacheProvider.swift b/Sources/Cache/CacheProvider.swift index 16a396a..9baec62 100644 --- a/Sources/Cache/CacheProvider.swift +++ b/Sources/Cache/CacheProvider.swift @@ -22,18 +22,16 @@ let GlobalIDKey: String = "cacheId" let CacheProviderUserInfoKey = CodingUserInfoKey(rawValue: "fdc_cache_provider")! protocol CacheProvider { - var cacheIdentifier: String { get } - + func resultTree(queryId: String) -> ResultTree? func setResultTree(queryId: String, tree: ResultTree) - func entityData(_ entityGuid: String) -> EntityDataObject func updateEntityData(_ object: EntityDataObject) - + /* - - func size() -> Int - */ + + func size() -> Int + */ } diff --git a/Sources/Cache/DynamicCodingKey.swift b/Sources/Cache/DynamicCodingKey.swift index 7fd47c2..52cc6bd 100644 --- a/Sources/Cache/DynamicCodingKey.swift +++ b/Sources/Cache/DynamicCodingKey.swift @@ -12,7 +12,6 @@ // See the License for the specific language governing permissions and // limitations under the License. - struct DynamicCodingKey: CodingKey { var intValue: Int? let stringValue: String diff --git a/Sources/Cache/EntityDataObject.swift b/Sources/Cache/EntityDataObject.swift index 3f32fa8..f3e9256 100644 --- a/Sources/Cache/EntityDataObject.swift +++ b/Sources/Cache/EntityDataObject.swift @@ -20,113 +20,106 @@ struct ScalarField { } class EntityDataObject: CustomStringConvertible, Codable { - let guid: String // globally unique id received from server - + private let accessQueue = DispatchQueue( label: "com.google.firebase.dataconnect.entityData", autoreleaseFrequency: .workItem ) - + required init(guid: String) { self.guid = guid } - + private var serverValues = [String: AnyCodableValue]() - + enum CodingKeys: String, CodingKey { case globalID = "guid" case serverValues = "serVal" } - + // Updates value received from server and returns a list of QueryRef operation ids // referenced from this EntityDataObject - @discardableResult func updateServerValue( - _ key: String, - _ newValue: AnyCodableValue, - _ requestor: (any QueryRefInternal)? = nil - ) -> [String] { - + @discardableResult func updateServerValue(_ key: String, + _ newValue: AnyCodableValue, + _ requestor: (any QueryRefInternal)? = nil) + -> [String] { accessQueue.sync { self.serverValues[key] = newValue DataConnectLogger.debug("EDO updateServerValue: \(key) \(newValue) for \(guid)") - + if let requestor { referencedFrom.insert(requestor.operationId) DataConnectLogger .debug("Inserted referencedFrom \(requestor). New count \(referencedFrom.count)") - } - let refs: [String] = Array(referencedFrom) + let refs = [String](referencedFrom) return refs } } - + func value(forKey key: String) -> AnyCodableValue? { accessQueue.sync { - return self.serverValues[key] + self.serverValues[key] } } - + // MARK: Track referenced QueryRefs - + // Set of QueryRefs that reference this EDO private var referencedFrom = Set() - + func updateReferencedFrom(_ refs: Set) { accessQueue.sync { self.referencedFrom = refs } } - + func referencedFromRefs() -> Set { accessQueue.sync { - return self.referencedFrom + self.referencedFrom } } - + var isReferencedFromAnyQueryRef: Bool { accessQueue.sync { - return !referencedFrom.isEmpty + !referencedFrom.isEmpty } } - - + var description: String { return """ - EntityDataObject: - globalID: \(guid) - serverValues: - \(serverValues) - """ + EntityDataObject: + globalID: \(guid) + serverValues: + \(serverValues) + """ } - + func encodableData() throws -> [String: AnyCodableValue] { var encodingValues = [String: AnyCodableValue]() encodingValues[GlobalIDKey] = .string(guid) - encodingValues.merge(serverValues) { (_, new) in new } + encodingValues.merge(serverValues) { _, new in new } return encodingValues } - + public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(guid, forKey: .globalID) try container.encode(serverValues, forKey: .serverValues) // once we have localValues, we will need to merge between the two dicts and encode } - + required init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - + let globalId = try container.decode(String.self, forKey: .globalID) - self.guid = globalId - + guid = globalId + serverValues = try container.decode([String: AnyCodableValue].self, forKey: .serverValues) } - } - extension EntityDataObject: Equatable { static func == (lhs: EntityDataObject, rhs: EntityDataObject) -> Bool { return lhs.guid == rhs.guid && lhs.serverValues == rhs.serverValues @@ -138,5 +131,3 @@ extension EntityDataObject: CustomDebugStringConvertible { return description } } - - diff --git a/Sources/Cache/EntityNode.swift b/Sources/Cache/EntityNode.swift index b90cd6c..1187937 100644 --- a/Sources/Cache/EntityNode.swift +++ b/Sources/Cache/EntityNode.swift @@ -12,57 +12,51 @@ // See the License for the specific language governing permissions and // limitations under the License. /* - + Init with JSON (root) Convert to CodableValue and Init itself Codable Value must be a dict at root level - + Dict can contain (if EDO) - Scalars, [Scalar] => Move this to DBO - References, [References] => Keep with (if no guid and therefore no EDO) - Store CodableValue as - is - - - */ + */ struct EntityNode { - // externalized (normalized) data. // Requires an entity globalID to be provided in selection set var entityData: EntityDataObject? - + // inline scalars are only populated if there is no EntityDataObject // i.e. if there is no entity globalID var scalars = [String: AnyCodableValue]() - + // entity properties that point to other entity nodes var references = [String: EntityNode]() - + // properties that point to a list of other objects // scalar lists are stored inline. var objectLists = [String: [EntityNode]]() - + enum CodingKeys: String, CodingKey { case globalID = "cacheId" case objectLists case references case scalars } - - init?( - value: AnyCodableValue, - cacheProvider: CacheProvider, - impactedRefsAccumulator: ImpactedQueryRefsAccumulator? = nil - ) { + + init?(value: AnyCodableValue, + cacheProvider: CacheProvider, + impactedRefsAccumulator: ImpactedQueryRefsAccumulator? = nil) { guard case let .dictionary(objectValues) = value else { DataConnectLogger.error("EntityNode inited with a non-dictionary type") return nil } - - + if case let .string(guid) = objectValues[GlobalIDKey] { entityData = cacheProvider.entityData(guid) } else if case let .uuid(guid) = objectValues[GlobalIDKey] { @@ -70,10 +64,10 @@ struct EntityNode { // TODO: Remove once server starts to send the real GlobalID entityData = cacheProvider.entityData(guid.uuidString) } - + for (key, value) in objectValues { switch value { - case .dictionary(_): + case .dictionary: // a dictionary is treated as a composite object // and converted to another node if let st = EntityNode( @@ -85,7 +79,7 @@ struct EntityNode { } else { DataConnectLogger.warning("Failed to convert dictionary to a reference") } - case .array(let objs): + case let .array(objs): var refArray = [EntityNode]() var scalarArray = [AnyCodableValue]() for obj in objs { @@ -111,9 +105,11 @@ struct EntityNode { value, impactedRefsAccumulator?.requestor ) - + // accumulate any impacted QueryRefs due to this change - for r in impactedRefs { impactedRefsAccumulator?.append(r) } + for r in impactedRefs { + impactedRefsAccumulator?.append(r) + } } } default: @@ -123,83 +119,93 @@ struct EntityNode { value, impactedRefsAccumulator?.requestor ) - + // accumulate any QueryRefs impacted by this change - for r in impactedRefs { impactedRefsAccumulator?.append(r) } + for r in impactedRefs { + impactedRefsAccumulator?.append(r) + } } else { scalars[key] = value } } - } //for (key,value) - + } // for (key,value) + if let entityData { - for refId in entityData.referencedFromRefs() { impactedRefsAccumulator?.append(refId) } + for refId in entityData.referencedFromRefs() { + impactedRefsAccumulator?.append(refId) + } cacheProvider.updateEntityData(entityData) } } } extension EntityNode: Decodable { - - init (from decoder: Decoder) throws { - + init(from decoder: Decoder) throws { guard let cacheProvider = decoder.userInfo[CacheProviderUserInfoKey] as? CacheProvider else { throw DataConnectCodecError.decodingFailed(message: "Missing CacheProvider in decoder") } - + guard let resultTreeKind = decoder.userInfo[ResultTreeKindCodingKey] as? ResultTreeKind else { throw DataConnectCodecError.decodingFailed(message: "Missing ResultTreeKind in decoder") } - + if resultTreeKind == .hydrated { // our input is a hydrated result tree let container = try decoder.singleValueContainer() - + let value = try container.decode(AnyCodableValue.self) - - let impactedRefsAcc = decoder.userInfo[ImpactedRefsAccumulatorCodingKey] as? ImpactedQueryRefsAccumulator - + + let impactedRefsAcc = decoder + .userInfo[ImpactedRefsAccumulatorCodingKey] as? ImpactedQueryRefsAccumulator + if impactedRefsAcc != nil { DataConnectLogger .debug("Got impactedRefs before dehydration \(String(describing: impactedRefsAcc))") } - + let enode = EntityNode( value: value, cacheProvider: cacheProvider, impactedRefsAccumulator: impactedRefsAcc ) - + if impactedRefsAcc != nil { - DataConnectLogger.debug("impactedRefs after dehydration \(String(describing: impactedRefsAcc))") + DataConnectLogger + .debug("impactedRefs after dehydration \(String(describing: impactedRefsAcc))") } - + if let enode { self = enode } else { - throw DataConnectCodecError.decodingFailed(message: "Failed to decode into a valid EntityNode") + throw DataConnectCodecError + .decodingFailed(message: "Failed to decode into a valid EntityNode") } - + } else { // our input is a dehydrated result tree let container = try decoder.container(keyedBy: CodingKeys.self) - + if let globalID = try container.decodeIfPresent(String.self, forKey: .globalID) { - self.entityData = cacheProvider.entityData(globalID) + entityData = cacheProvider.entityData(globalID) } - + if let refs = try container.decodeIfPresent([String: EntityNode].self, forKey: .references) { - self.references = refs + references = refs } - - if let lists = try container.decodeIfPresent([String: [EntityNode]].self, forKey: .objectLists) { - self.objectLists = lists + + if let lists = try container.decodeIfPresent( + [String: [EntityNode]].self, + forKey: .objectLists + ) { + objectLists = lists } - - if let scalars = try container.decodeIfPresent([String: AnyCodableValue].self, forKey: .scalars) { + + if let scalars = try container.decodeIfPresent( + [String: AnyCodableValue].self, + forKey: .scalars + ) { self.scalars = scalars } - } } } @@ -209,30 +215,30 @@ extension EntityNode: Encodable { guard let resultTreeKind = encoder.userInfo[ResultTreeKindCodingKey] as? ResultTreeKind else { throw DataConnectCodecError.decodingFailed(message: "Missing ResultTreeKind in decoder") } - + if resultTreeKind == .hydrated { - //var container = encoder.singleValueContainer() + // var container = encoder.singleValueContainer() var container = encoder.container(keyedBy: DynamicCodingKey.self) - + if let entityData { let encodableData = try entityData.encodableData() for (key, value) in encodableData { try container.encode(value, forKey: DynamicCodingKey(stringValue: key)!) } } - + if references.count > 0 { for (key, value) in references { try container.encode(value, forKey: DynamicCodingKey(stringValue: key)!) } } - + if objectLists.count > 0 { for (key, value) in objectLists { try container.encode(value, forKey: DynamicCodingKey(stringValue: key)!) } } - + if scalars.count > 0 { for (key, value) in scalars { try container.encode(value, forKey: DynamicCodingKey(stringValue: key)!) @@ -244,15 +250,15 @@ extension EntityNode: Encodable { if let entityData { try container.encode(entityData.guid, forKey: .globalID) } - + if references.count > 0 { try container.encode(references, forKey: .references) } - + if objectLists.count > 0 { try container.encode(objectLists, forKey: .objectLists) } - + if scalars.count > 0 { try container.encode(scalars, forKey: .scalars) } @@ -263,23 +269,23 @@ extension EntityNode: Encodable { extension EntityNode: Equatable { public static func == (lhs: EntityNode, rhs: EntityNode) -> Bool { return lhs.entityData == rhs.entityData && - lhs.references == rhs.references && - lhs.objectLists == rhs.objectLists && - lhs.scalars == rhs.scalars + lhs.references == rhs.references && + lhs.objectLists == rhs.objectLists && + lhs.scalars == rhs.scalars } } extension EntityNode: CustomDebugStringConvertible { var debugDescription: String { return """ - EntityNode: - \(String(describing: self.entityData)) - References: - \(self.references) - Lists: - \(self.objectLists) - Scalars: - \(self.scalars) - """ + EntityNode: + \(String(describing: entityData)) + References: + \(references) + Lists: + \(objectLists) + Scalars: + \(scalars) + """ } } diff --git a/Sources/Cache/ResultTree.swift b/Sources/Cache/ResultTree.swift index 11ffaec..8e3ceb7 100644 --- a/Sources/Cache/ResultTree.swift +++ b/Sources/Cache/ResultTree.swift @@ -22,33 +22,31 @@ struct ResultTree { let ttl: TimeInterval // interval during which query results are considered fresh let cachedAt: Date // Local time when the entry was cached / updated var lastAccessed: Date // Local time when the entry was read or updated - + var rootObject: EntityNode? - + func isStale(_ ttl: TimeInterval) -> Bool { let now = Date() return now.timeIntervalSince(cachedAt) > ttl } - + enum CodingKeys: String, CodingKey { case cachedAt = "ca" case lastAccessed = "la" - case ttl = "ttl" + case ttl case data = "d" } - } - extension ResultTree: Codable { init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - self.cachedAt = try container.decode(Date.self, forKey: .cachedAt) - self.lastAccessed = try container.decode(Date.self, forKey: .lastAccessed) - self.ttl = try container.decode(TimeInterval.self, forKey: .ttl) - self.data = try container.decode(String.self, forKey: .data) + cachedAt = try container.decode(Date.self, forKey: .cachedAt) + lastAccessed = try container.decode(Date.self, forKey: .lastAccessed) + ttl = try container.decode(TimeInterval.self, forKey: .ttl) + data = try container.decode(String.self, forKey: .data) } - + func encode(to encoder: any Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(cachedAt, forKey: .cachedAt) diff --git a/Sources/Cache/ResultTreeProcessor.swift b/Sources/Cache/ResultTreeProcessor.swift index 4fe63c7..9b0e21e 100644 --- a/Sources/Cache/ResultTreeProcessor.swift +++ b/Sources/Cache/ResultTreeProcessor.swift @@ -15,13 +15,16 @@ import Foundation // Key that indicates the kind of tree being coded - hydrated or dehydrated -let ResultTreeKindCodingKey = CodingUserInfoKey(rawValue: "com.google.firebase.dataconnect.encodingMode")! +let ResultTreeKindCodingKey = + CodingUserInfoKey(rawValue: "com.google.firebase.dataconnect.encodingMode")! // Key that points to the QueryRef being updated in cache -let UpdatingQueryRefsCodingKey = CodingUserInfoKey(rawValue: "com.google.firebase.dataconnect.updatingQueryRef")! +let UpdatingQueryRefsCodingKey = + CodingUserInfoKey(rawValue: "com.google.firebase.dataconnect.updatingQueryRef")! // Key pointing to container for QueryRefs. EntityDataObjects fill this -let ImpactedRefsAccumulatorCodingKey = CodingUserInfoKey(rawValue: "com.google.firebase.dataconnect.impactedQueryRefs")! +let ImpactedRefsAccumulatorCodingKey = + CodingUserInfoKey(rawValue: "com.google.firebase.dataconnect.impactedQueryRefs")! // Kind of result data we are encoding from or decoding to enum ResultTreeKind { @@ -35,32 +38,30 @@ enum ResultTreeKind { class ImpactedQueryRefsAccumulator { // operationIds of impacted QueryRefs private(set) var queryRefIds: Set = [] - + // QueryRef requesting impacted let requestor: (any QueryRefInternal)? - + init(requestor: (any QueryRefInternal)? = nil) { self.requestor = requestor } - + // appends the impacted operationId not matching requestor func append(_ queryRefId: String) { guard requestor != nil else { queryRefIds.insert(queryRefId) return } - + if let requestor = requestor, queryRefId != requestor.operationId { queryRefIds.insert(queryRefId) } - } } // Normalization and recontruction of ResultTree struct ResultTreeProcessor { - /* Go down the tree and convert them to nodes For each Node @@ -71,60 +72,60 @@ struct ResultTreeProcessor { - recursively process each object (could be scalar or composite) - for composite objects (dictionaries), create references to their node - create a Node and init it with dictionary. - + */ - - func dehydrateResults(_ hydratedTree: String, cacheProvider: CacheProvider, requestor: (any QueryRefInternal)? = nil) throws -> ( + + func dehydrateResults(_ hydratedTree: String, cacheProvider: CacheProvider, + requestor: (any QueryRefInternal)? = nil) throws -> ( dehydratedResults: String, rootObject: EntityNode, impactedRefIds: [String] ) { let jsonDecoder = JSONDecoder() let impactedRefsAccumulator = ImpactedQueryRefsAccumulator(requestor: requestor) - + jsonDecoder.userInfo[CacheProviderUserInfoKey] = cacheProvider jsonDecoder.userInfo[ResultTreeKindCodingKey] = ResultTreeKind.hydrated jsonDecoder.userInfo[ImpactedRefsAccumulatorCodingKey] = impactedRefsAccumulator let enode = try jsonDecoder.decode(EntityNode.self, from: hydratedTree.data(using: .utf8)!) - - DataConnectLogger.debug("Impacted QueryRefs count: \(impactedRefsAccumulator.queryRefIds.count)") + + DataConnectLogger + .debug("Impacted QueryRefs count: \(impactedRefsAccumulator.queryRefIds.count)") let impactedRefs = Array(impactedRefsAccumulator.queryRefIds) - + let jsonEncoder = JSONEncoder() jsonEncoder.userInfo[CacheProviderUserInfoKey] = cacheProvider jsonEncoder.userInfo[ResultTreeKindCodingKey] = ResultTreeKind.dehydrated let jsonData = try jsonEncoder.encode(enode) let dehydratedResultsString = String(data: jsonData, encoding: .utf8)! - + DataConnectLogger .debug( "\(#function): \nHydrated \n \(hydratedTree) \n\nDehydrated \n \(dehydratedResultsString)" ) - + return (dehydratedResultsString, enode, impactedRefs) } - - + func hydrateResults(_ dehydratedTree: String, cacheProvider: CacheProvider) throws -> - (hydratedResults: String, rootObject: EntityNode) { - let jsonDecoder = JSONDecoder() - jsonDecoder.userInfo[CacheProviderUserInfoKey] = cacheProvider - jsonDecoder.userInfo[ResultTreeKindCodingKey] = ResultTreeKind.dehydrated - let enode = try jsonDecoder.decode(EntityNode.self, from: dehydratedTree.data(using: .utf8)!) + (hydratedResults: String, rootObject: EntityNode) { + let jsonDecoder = JSONDecoder() + jsonDecoder.userInfo[CacheProviderUserInfoKey] = cacheProvider + jsonDecoder.userInfo[ResultTreeKindCodingKey] = ResultTreeKind.dehydrated + let enode = try jsonDecoder.decode(EntityNode.self, from: dehydratedTree.data(using: .utf8)!) DataConnectLogger.debug("Dehydrated Tree decoded to EDO: \(enode)") - - let jsonEncoder = JSONEncoder() - jsonEncoder.userInfo[CacheProviderUserInfoKey] = cacheProvider - jsonEncoder.userInfo[ResultTreeKindCodingKey] = ResultTreeKind.hydrated - let hydratedResults = try jsonEncoder.encode(enode) - let hydratedResultsString = String(data: hydratedResults, encoding: .utf8)! - - DataConnectLogger - .debug( + + let jsonEncoder = JSONEncoder() + jsonEncoder.userInfo[CacheProviderUserInfoKey] = cacheProvider + jsonEncoder.userInfo[ResultTreeKindCodingKey] = ResultTreeKind.hydrated + let hydratedResults = try jsonEncoder.encode(enode) + let hydratedResultsString = String(data: hydratedResults, encoding: .utf8)! + + DataConnectLogger + .debug( "\(#function) Dehydrated \n \(dehydratedTree) \n\nHydrated \n \(hydratedResultsString)" - ) - + ) + return (hydratedResultsString, enode) } - } diff --git a/Sources/Cache/SQLiteCacheProvider.swift b/Sources/Cache/SQLiteCacheProvider.swift index 01a6eec..27c3689 100644 --- a/Sources/Cache/SQLiteCacheProvider.swift +++ b/Sources/Cache/SQLiteCacheProvider.swift @@ -16,13 +16,13 @@ import FirebaseCore import Foundation import SQLite3 -fileprivate enum TableName { +private enum TableName { static let entityDataObjects = "entity_data" static let resultTree = "query_results" static let entityDataQueryRefs = "entity_data_query_refs" } -fileprivate enum ColumnName { +private enum ColumnName { static let entityId = "entity_guid" static let data = "data" static let queryId = "query_id" @@ -30,7 +30,6 @@ fileprivate enum ColumnName { } class SQLiteCacheProvider: CacheProvider { - let cacheIdentifier: String private var db: OpaquePointer? @@ -61,7 +60,10 @@ class SQLiteCacheProvider: CacheProvider { ) } - DataConnectLogger.debug("Opened database with db path/id \(dbIdentifier) and cache identifier \(cacheIdentifier)") + DataConnectLogger + .debug( + "Opened database with db path/id \(dbIdentifier) and cache identifier \(cacheIdentifier)" + ) try createTables() } } @@ -74,23 +76,23 @@ class SQLiteCacheProvider: CacheProvider { dispatchPrecondition(condition: .onQueue(queue)) let createResultTreeTable = """ - CREATE TABLE IF NOT EXISTS \(TableName.resultTree) ( - \(ColumnName.queryId) TEXT PRIMARY KEY NOT NULL, - \(ColumnName.lastAccessed) REAL NOT NULL, - \(ColumnName.data) BLOB NOT NULL - ); - """ + CREATE TABLE IF NOT EXISTS \(TableName.resultTree) ( + \(ColumnName.queryId) TEXT PRIMARY KEY NOT NULL, + \(ColumnName.lastAccessed) REAL NOT NULL, + \(ColumnName.data) BLOB NOT NULL + ); + """ if sqlite3_exec(db, createResultTreeTable, nil, nil, nil) != SQLITE_OK { throw DataConnectInternalError .sqliteError(message: "Could not create \(TableName.resultTree) table") } let createEntityDataTable = """ - CREATE TABLE IF NOT EXISTS \(TableName.entityDataObjects) ( - \(ColumnName.entityId) TEXT PRIMARY KEY NOT NULL, - \(ColumnName.data) BLOB NOT NULL - ); - """ + CREATE TABLE IF NOT EXISTS \(TableName.entityDataObjects) ( + \(ColumnName.entityId) TEXT PRIMARY KEY NOT NULL, + \(ColumnName.data) BLOB NOT NULL + ); + """ if sqlite3_exec(db, createEntityDataTable, nil, nil, nil) != SQLITE_OK { throw DataConnectInternalError.sqliteError(message: "Could not create entity_data table") } @@ -98,12 +100,14 @@ class SQLiteCacheProvider: CacheProvider { // table to store reverse mapping of EDO => queryRefs mapping // this is to know which EDOs are still referenced let createEntityDataRefs = """ - CREATE TABLE IF NOT EXISTS \(TableName.entityDataQueryRefs) ( - \(ColumnName.entityId) TEXT NOT NULL REFERENCES \(TableName.entityDataObjects)(\(ColumnName.entityId)), - \(ColumnName.queryId) TEXT NOT NULL REFERENCES \(TableName.resultTree)(\(ColumnName.queryId)), - PRIMARY KEY (\(ColumnName.entityId), \(ColumnName.queryId)) - ) - """ + CREATE TABLE IF NOT EXISTS \(TableName.entityDataQueryRefs) ( + \(ColumnName.entityId) TEXT NOT NULL REFERENCES \(TableName.entityDataObjects)(\(ColumnName + .entityId)), + \(ColumnName.queryId) TEXT NOT NULL REFERENCES \(TableName.resultTree)(\(ColumnName + .queryId)), + PRIMARY KEY (\(ColumnName.entityId), \(ColumnName.queryId)) + ) + """ if sqlite3_exec(db, createEntityDataRefs, nil, nil, nil) != SQLITE_OK { throw DataConnectInternalError.sqliteError( message: "Could not create entity_data_query_refs table" @@ -112,12 +116,12 @@ class SQLiteCacheProvider: CacheProvider { } private func updateLastAccessedTime(forQueryId queryId: String) { - dispatchPrecondition(condition: .onQueue(queue)) - let updateQuery = "UPDATE \(TableName.resultTree) SET \(ColumnName.lastAccessed) = ? WHERE \(ColumnName.queryId) = ?;" + let updateQuery = + "UPDATE \(TableName.resultTree) SET \(ColumnName.lastAccessed) = ? WHERE \(ColumnName.queryId) = ?;" var statement: OpaquePointer? - if sqlite3_prepare_v2(self.db, updateQuery, -1, &statement, nil) != SQLITE_OK { + if sqlite3_prepare_v2(db, updateQuery, -1, &statement, nil) != SQLITE_OK { DataConnectLogger.error("Error preparing update statement for result_tree") return } @@ -129,12 +133,12 @@ class SQLiteCacheProvider: CacheProvider { DataConnectLogger.error("Error updating \(ColumnName.lastAccessed) for query \(queryId)") } sqlite3_finalize(statement) - } func resultTree(queryId: String) -> ResultTree? { return queue.sync { - let query = "SELECT \(ColumnName.data) FROM \(TableName.resultTree) WHERE \(ColumnName.queryId) = ?;" + let query = + "SELECT \(ColumnName.data) FROM \(TableName.resultTree) WHERE \(ColumnName.queryId) = ?;" var statement: OpaquePointer? if sqlite3_prepare_v2(db, query, -1, &statement, nil) != SQLITE_OK { @@ -172,7 +176,7 @@ class SQLiteCacheProvider: CacheProvider { var tree = tree let data = try JSONEncoder().encode(tree) let insert = - "INSERT OR REPLACE INTO \(TableName.resultTree) (\(ColumnName.queryId), \(ColumnName.lastAccessed), \(ColumnName.data)) VALUES (?, ?, ?);" + "INSERT OR REPLACE INTO \(TableName.resultTree) (\(ColumnName.queryId), \(ColumnName.lastAccessed), \(ColumnName.data)) VALUES (?, ?, ?);" var statement: OpaquePointer? if sqlite3_prepare_v2(db, insert, -1, &statement, nil) != SQLITE_OK { @@ -203,7 +207,8 @@ class SQLiteCacheProvider: CacheProvider { func entityData(_ entityGuid: String) -> EntityDataObject { return queue.sync { - let query = "SELECT \(ColumnName.data) FROM \(TableName.entityDataObjects) WHERE \(ColumnName.entityId) = ?;" + let query = + "SELECT \(ColumnName.data) FROM \(TableName.entityDataObjects) WHERE \(ColumnName.entityId) = ?;" var statement: OpaquePointer? if sqlite3_prepare_v2(db, query, -1, &statement, nil) != SQLITE_OK { @@ -250,12 +255,14 @@ class SQLiteCacheProvider: CacheProvider { } // MARK: Private + // These should run on queue but not call sync otherwise we deadlock private func _setObject(entityGuid: String, object: EntityDataObject) { dispatchPrecondition(condition: .onQueue(queue)) do { let data = try JSONEncoder().encode(object) - let insert = "INSERT OR REPLACE INTO \(TableName.entityDataObjects) (\(ColumnName.entityId), \(ColumnName.data)) VALUES (?, ?);" + let insert = + "INSERT OR REPLACE INTO \(TableName.entityDataObjects) (\(ColumnName.entityId), \(ColumnName.data)) VALUES (?, ?);" var statement: OpaquePointer? if sqlite3_prepare_v2(db, insert, -1, &statement, nil) != SQLITE_OK { @@ -289,7 +296,7 @@ class SQLiteCacheProvider: CacheProvider { return } var insertReferences = - "INSERT OR REPLACE INTO \(TableName.entityDataQueryRefs) (\(ColumnName.entityId), \(ColumnName.queryId)) VALUES " + "INSERT OR REPLACE INTO \(TableName.entityDataQueryRefs) (\(ColumnName.entityId), \(ColumnName.queryId)) VALUES " for queryId in object.referencedFromRefs() { insertReferences += "('\(object.guid)', '\(queryId)'), " } @@ -313,7 +320,7 @@ class SQLiteCacheProvider: CacheProvider { private func _readQueryRefs(entityGuid: String) -> [String] { let readRefs = - "SELECT \(ColumnName.queryId) FROM \(TableName.entityDataQueryRefs) WHERE \(ColumnName.entityId) = '\(entityGuid)'" + "SELECT \(ColumnName.queryId) FROM \(TableName.entityDataQueryRefs) WHERE \(ColumnName.entityId) = '\(entityGuid)'" var statementRefs: OpaquePointer? var queryIds: [String] = [] @@ -332,7 +339,5 @@ class SQLiteCacheProvider: CacheProvider { } return [] - } - } diff --git a/Sources/DataConnect.swift b/Sources/DataConnect.swift index 1405fda..34093fc 100644 --- a/Sources/DataConnect.swift +++ b/Sources/DataConnect.swift @@ -25,8 +25,8 @@ public class DataConnect { private(set) var grpcClient: GrpcClient private var operationsManager: OperationsManager - - private(set) var cache: Cache? = nil + + private(set) var cache: Cache? private var callerSDKType: CallerSDKType = .base @@ -91,19 +91,18 @@ public class DataConnect { connectorConfig: connectorConfig, callerSDKType: callerSDKType ) - + // TODO: Change this if let cache { self.cache = Cache(config: cache.config, dataConnect: self) } - + operationsManager = OperationsManager( grpcClient: grpcClient, cache: self.cache ) } } - // MARK: Init @@ -124,13 +123,12 @@ public class DataConnect { connectorConfig: connectorConfig, callerSDKType: self.callerSDKType ) - + operationsManager = OperationsManager(grpcClient: grpcClient, cache: cache) - + if let cacheConfig { - self.cache = Cache(config: cacheConfig, dataConnect: self) + cache = Cache(config: cacheConfig, dataConnect: self) } - } // MARK: Operations @@ -166,9 +164,9 @@ public class DataConnect { @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) extension DataConnect { - internal func queryRef(for operationId: String) -> (any QueryRef)? { + func queryRef(for operationId: String) -> (any QueryRef)? { accessQueue.sync { - return operationsManager.queryRef(for: operationId) + operationsManager.queryRef(for: operationId) } } } @@ -211,7 +209,8 @@ private class InstanceStore { private var instances = [InstanceKey: DataConnect]() func instance(for app: FirebaseApp, config: ConnectorConfig, - settings: DataConnectSettings, callerSDKType: CallerSDKType, cacheConfig: CacheConfig? = nil) -> DataConnect { + settings: DataConnectSettings, callerSDKType: CallerSDKType, + cacheConfig: CacheConfig? = nil) -> DataConnect { accessQ.sync { let key = InstanceKey(app: app, config: config) if let inst = instances[key] { diff --git a/Sources/DataConnectError.swift b/Sources/DataConnectError.swift index 684591d..9477d31 100644 --- a/Sources/DataConnectError.swift +++ b/Sources/DataConnectError.swift @@ -68,7 +68,7 @@ public extension DataConnectDomainError { /// A type that represents an error code within an error domain. @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public protocol DataConnectErrorCode: CustomStringConvertible, Equatable, Sendable, CaseIterable {} +public protocol DataConnectErrorCode: CustomStringConvertible, Equatable, Sendable, CaseIterable {} // MARK: - Data Connect Initialization Errors @@ -300,12 +300,12 @@ public struct DataConnectInternalError: DataConnectDomainError { } static func internalError(message: String? = nil, - cause: Error? = nil) -> DataConnectInternalError { + cause: Error? = nil) -> DataConnectInternalError { return DataConnectInternalError(code: .internalError, message: message, cause: cause) } static func sqliteError(message: String? = nil, - cause: Error? = nil) -> DataConnectInternalError { + cause: Error? = nil) -> DataConnectInternalError { return DataConnectInternalError(code: .sqliteError, message: message, cause: cause) } } diff --git a/Sources/DataConnectSettings.swift b/Sources/DataConnectSettings.swift index 0fcff28..e24f4bd 100644 --- a/Sources/DataConnectSettings.swift +++ b/Sources/DataConnectSettings.swift @@ -21,7 +21,8 @@ public struct DataConnectSettings: Hashable, Equatable, Sendable { public let sslEnabled: Bool public let cacheConfig: CacheConfig? - public init(host: String, port: Int, sslEnabled: Bool, cacheConfig: CacheConfig? = CacheConfig()) { + public init(host: String, port: Int, sslEnabled: Bool, + cacheConfig: CacheConfig? = CacheConfig()) { self.host = host self.port = port self.sslEnabled = sslEnabled diff --git a/Sources/Internal/GrpcClient.swift b/Sources/Internal/GrpcClient.swift index b552f3c..f19c20e 100644 --- a/Sources/Internal/GrpcClient.swift +++ b/Sources/Internal/GrpcClient.swift @@ -130,7 +130,7 @@ actor GrpcClient: CustomStringConvertible { VariableType: OperationVariable>(request: QueryRequest, resultType: ResultType .Type) - async throws -> ServerResponse { + async throws -> ServerResponse { guard let client else { DataConnectLogger.error("When calling executeQuery(), grpc client has not been configured.") throw DataConnectInitError.grpcNotConfigured() @@ -159,10 +159,10 @@ actor GrpcClient: CustomStringConvertible { If we have Partial Errors, we follow the partial error route below. */ guard !errorInfoList.isEmpty else { - // TODO: Extract ttl, server timestamp + // TODO: Extract ttl, server timestamp return ServerResponse(jsonResults: resultsString, ttl: 10.0) } - + // We have partial errors returned /* - if decode succeeds, errorList isEmpty = return data @@ -173,9 +173,9 @@ actor GrpcClient: CustomStringConvertible { */ do { let decodedResults = try codec.decode(result: results.data, asType: resultType) - + // even though decode succeeded, we may still have received partial errors - + let failureResponse = OperationFailureResponse( rawJsonData: resultsString, errors: errorInfoList, data: decodedResults @@ -183,7 +183,7 @@ actor GrpcClient: CustomStringConvertible { throw DataConnectOperationError.executionFailed( response: failureResponse ) - + } catch let operationErr as DataConnectOperationError { // simply rethrow to avoid wrapping error throw operationErr diff --git a/Sources/Internal/HashUtils.swift b/Sources/Internal/HashUtils.swift index da79dd2..8df3fb5 100644 --- a/Sources/Internal/HashUtils.swift +++ b/Sources/Internal/HashUtils.swift @@ -12,21 +12,21 @@ // See the License for the specific language governing permissions and // limitations under the License. -import Foundation import CryptoKit +import Foundation extension Data { var sha256String: String { let hashDigest = SHA256.hash(data: self) - let hashString = hashDigest.compactMap{ String(format: "%02x", $0) }.joined() + let hashString = hashDigest.compactMap { String(format: "%02x", $0) }.joined() return hashString } } extension String { var sha256: String { - let digest = SHA256.hash(data: self.data(using: .utf8)!) - let hashString = digest.compactMap { String(format: "%02x", $0)}.joined() + let digest = SHA256.hash(data: data(using: .utf8)!) + let hashString = digest.compactMap { String(format: "%02x", $0) }.joined() return hashString } } diff --git a/Sources/Internal/OperationsManager.swift b/Sources/Internal/OperationsManager.swift index aac812b..858a372 100644 --- a/Sources/Internal/OperationsManager.swift +++ b/Sources/Internal/OperationsManager.swift @@ -17,9 +17,9 @@ import Foundation @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) class OperationsManager { private var grpcClient: GrpcClient - + private var cache: Cache? - + private let queryRefAccessQueue = DispatchQueue( label: "firebase.dataconnect.queryRef.AccessQ", autoreleaseFrequency: .workItem @@ -46,7 +46,7 @@ class OperationsManager { queryRefAccessQueue.sync { var req = request // requestId is a mutating call. let requestId = req.requestId - + if let ref = queryRefs[requestId] { return ref } @@ -74,10 +74,10 @@ class OperationsManager { return refObsObject } // accessQueue.sync } - + func queryRef(for operationId: String) -> (any ObservableQueryRef)? { queryRefAccessQueue.sync { - return queryRefs[operationId] + queryRefs[operationId] } } diff --git a/Sources/Internal/QueryRefInternal.swift b/Sources/Internal/QueryRefInternal.swift index b0725ea..1570868 100644 --- a/Sources/Internal/QueryRefInternal.swift +++ b/Sources/Internal/QueryRefInternal.swift @@ -6,7 +6,7 @@ // @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -internal protocol QueryRefInternal: QueryRef { +protocol QueryRefInternal: QueryRef { var operationId: String { get } func publishCacheResultsToSubscribers(allowStale: Bool) async throws } diff --git a/Sources/Internal/SynchronizedDictionary.swift b/Sources/Internal/SynchronizedDictionary.swift index 8811f3c..f53c65e 100644 --- a/Sources/Internal/SynchronizedDictionary.swift +++ b/Sources/Internal/SynchronizedDictionary.swift @@ -16,10 +16,10 @@ import Foundation class SynchronizedDictionary { private var dictionary: [Key: Value] = [:] - private let queue: DispatchQueue = DispatchQueue(label: "com.google.firebase.dataconnect.syncDictionaryQ") - + private let queue: DispatchQueue = .init(label: "com.google.firebase.dataconnect.syncDictionaryQ") + init() {} - + subscript(key: Key) -> Value? { get { return queue.sync { self.dictionary[key] } @@ -29,15 +29,14 @@ class SynchronizedDictionary { } } } - + func rawCopy() -> [Key: Value] { return queue.sync { self.dictionary } } - + func updateValues(_ values: [Key: Value]) { queue.async(flags: .barrier) { - self.dictionary.merge(values) { (_, new) in new } + self.dictionary.merge(values) { _, new in new } } } - } diff --git a/Sources/MutationRef.swift b/Sources/MutationRef.swift index 8a06ced..d00e38e 100644 --- a/Sources/MutationRef.swift +++ b/Sources/MutationRef.swift @@ -47,12 +47,13 @@ public class MutationRef< ) return results } - + public func hash(into hasher: inout Hasher) { hasher.combine(request) } - - public static func == (lhs: MutationRef, rhs: MutationRef) -> Bool { + + public static func == (lhs: MutationRef, + rhs: MutationRef) -> Bool { return lhs.request as? MutationRequest == rhs.request as? MutationRequest } } diff --git a/Sources/OperationResult.swift b/Sources/OperationResult.swift index 3c9b28e..e0ec07e 100644 --- a/Sources/OperationResult.swift +++ b/Sources/OperationResult.swift @@ -16,7 +16,6 @@ import Foundation @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) public struct OperationResult: Sendable { - public let data: ResultData? public let source: ResultSource } diff --git a/Sources/Queries/GenericQueryRef.swift b/Sources/Queries/GenericQueryRef.swift index ef8eb06..e5384cc 100644 --- a/Sources/Queries/GenericQueryRef.swift +++ b/Sources/Queries/GenericQueryRef.swift @@ -12,8 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. -import Foundation import CryptoKit +import Foundation import Firebase @@ -22,18 +22,19 @@ import Observation @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) actor GenericQueryRef: QueryRef { - - private let resultsPublisher = PassthroughSubject, AnyDataConnectError>, - Never>() + private let resultsPublisher = PassthroughSubject< + Result, AnyDataConnectError>, + Never + >() private var request: QueryRequest private let grpcClient: GrpcClient - + private let cache: Cache? - + private var ttl: TimeInterval? = 10.0 // - + // Ideally we would like this to be part of the QueryRef protocol // Not adding for now since the protocol is Public // This property is for now an internal property. @@ -43,16 +44,17 @@ actor GenericQueryRef AnyPublisher, AnyDataConnectError>, Never> { + public func subscribe() + -> AnyPublisher, AnyDataConnectError>, Never> { Task { do { _ = try await fetchCachedResults(allowStale: true) - try await Task.sleep(nanoseconds: 3000_000_000) //3secs + try await Task.sleep(nanoseconds: 3_000_000_000) // 3secs _ = try await fetchServerResults() } catch {} } @@ -61,8 +63,8 @@ actor GenericQueryRef OperationResult { - + public func execute(fetchPolicy: QueryFetchPolicy = .defaultPolicy) async throws + -> OperationResult { switch fetchPolicy { case .defaultPolicy: let cachedResult = try await fetchCachedResults(allowStale: false) @@ -96,27 +98,26 @@ actor GenericQueryRef OperationResult { guard let cache, let ttl, @@ -124,25 +125,24 @@ actor GenericQueryRef Bool { lhs.operationId == rhs.operationId } @@ -172,10 +172,10 @@ extension GenericQueryRef: CustomStringConvertible { @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) extension GenericQueryRef: QueryRefInternal { func publishServerResultsToSubscribers() async throws { - _ = try await self.fetchServerResults() + _ = try await fetchServerResults() } func publishCacheResultsToSubscribers(allowStale: Bool) async throws { - _ = try await self.fetchCachedResults(allowStale: allowStale) + _ = try await fetchCachedResults(allowStale: allowStale) } } diff --git a/Sources/Queries/ObservableQueryRef.swift b/Sources/Queries/ObservableQueryRef.swift index 2e7a370..c82a2fd 100644 --- a/Sources/Queries/ObservableQueryRef.swift +++ b/Sources/Queries/ObservableQueryRef.swift @@ -12,20 +12,19 @@ // See the License for the specific language governing permissions and // limitations under the License. -import Foundation import CryptoKit +import Foundation import Firebase @preconcurrency import Combine import Observation - @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) public protocol ObservableQueryRef: QueryRef { // results of fetch. var data: ResultData? { get } - + // source of the query results (server, cache, ) var source: ResultSource { get } @@ -50,7 +49,6 @@ public class QueryRefObservableObject< ResultData: Decodable & Sendable, Variable: OperationVariable >: ObservableObject, ObservableQueryRef { - var operationId: String { return baseRef.operationId } @@ -61,12 +59,10 @@ public class QueryRefObservableObject< private var resultsCancellable: AnyCancellable? - init( - request: QueryRequest, - dataType: ResultData.Type, - grpcClient: GrpcClient, - cache: Cache? - ) { + init(request: QueryRequest, + dataType: ResultData.Type, + grpcClient: GrpcClient, + cache: Cache?) { self.request = request baseRef = GenericQueryRef( request: request, @@ -101,7 +97,7 @@ public class QueryRefObservableObject< /// Error thrown if error occurs during execution of query. If the last fetch was successful the /// error is cleared @Published public private(set) var lastError: DataConnectError? - + /// Source of the query results (server, local cache, ...) @Published public private(set) var source: ResultSource = .unknown @@ -109,7 +105,8 @@ public class QueryRefObservableObject< /// Executes the query and returns `ResultData`. This will also update the published `data` /// variable - public func execute(fetchPolicy: QueryFetchPolicy = .defaultPolicy) async throws -> OperationResult { + public func execute(fetchPolicy: QueryFetchPolicy = .defaultPolicy) async throws + -> OperationResult { let result = try await baseRef.execute(fetchPolicy: fetchPolicy) return result } @@ -124,14 +121,14 @@ public class QueryRefObservableObject< } @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -extension QueryRefObservableObject { - nonisolated public func hash(into hasher: inout Hasher) { - hasher.combine(baseRef) - } - - public static func == (lhs: QueryRefObservableObject, rhs: QueryRefObservableObject) -> Bool { +public extension QueryRefObservableObject { + nonisolated func hash(into hasher: inout Hasher) { + hasher.combine(baseRef) + } + + static func == (lhs: QueryRefObservableObject, rhs: QueryRefObservableObject) -> Bool { lhs.baseRef == rhs.baseRef - } + } } @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) @@ -163,7 +160,6 @@ public class QueryRefObservation< ResultData: Decodable & Sendable, Variable: OperationVariable >: ObservableQueryRef { - var operationId: String { return baseRef.operationId } @@ -177,7 +173,8 @@ public class QueryRefObservation< @ObservationIgnored private var resultsCancellable: AnyCancellable? - init(request: QueryRequest, dataType: ResultData.Type, grpcClient: GrpcClient, cache: Cache?) { + init(request: QueryRequest, dataType: ResultData.Type, grpcClient: GrpcClient, + cache: Cache?) { self.request = request baseRef = GenericQueryRef( request: request, @@ -212,7 +209,7 @@ public class QueryRefObservation< /// Error thrown if error occurs during execution of query. If the last fetch was successful the /// error is cleared public private(set) var lastError: DataConnectError? - + /// Source of the query results (server, local cache, ...) public private(set) var source: ResultSource = .unknown @@ -220,7 +217,8 @@ public class QueryRefObservation< /// Executes the query and returns `ResultData`. This will also update the published `data` /// variable - public func execute(fetchPolicy: QueryFetchPolicy = .defaultPolicy) async throws -> OperationResult { + public func execute(fetchPolicy: QueryFetchPolicy = .defaultPolicy) async throws + -> OperationResult { let result = try await baseRef.execute(fetchPolicy: fetchPolicy) return result } @@ -229,25 +227,25 @@ public class QueryRefObservation< /// Use this function ONLY if you plan to use the Query Ref outside of SwiftUI context - (UIKit, /// background updates,...) public func subscribe() async throws - -> AnyPublisher, AnyDataConnectError>, Never> { + -> AnyPublisher, AnyDataConnectError>, Never> { return await baseRef.subscribe() } } @available(macOS 15, iOS 17, tvOS 17, watchOS 10, *) -extension QueryRefObservation { - nonisolated public func hash(into hasher: inout Hasher) { - hasher.combine(baseRef) - } - - public static func == (lhs: QueryRefObservation, rhs: QueryRefObservation) -> Bool { +public extension QueryRefObservation { + nonisolated func hash(into hasher: inout Hasher) { + hasher.combine(baseRef) + } + + static func == (lhs: QueryRefObservation, rhs: QueryRefObservation) -> Bool { lhs.baseRef == rhs.baseRef - } + } } @available(macOS 15, iOS 17, tvOS 17, watchOS 10, *) extension QueryRefObservation: CustomStringConvertible { - nonisolated public var description: String { + public nonisolated var description: String { "QueryRefObservation(\(String(describing: baseRef)))" } } diff --git a/Sources/Queries/QueryFetchPolicy.swift b/Sources/Queries/QueryFetchPolicy.swift index ee9807f..09fefd8 100644 --- a/Sources/Queries/QueryFetchPolicy.swift +++ b/Sources/Queries/QueryFetchPolicy.swift @@ -15,16 +15,15 @@ /// Policies for executing a Data Connect query. This value is passed to @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) public enum QueryFetchPolicy { - /// default policy tries to fetch from cache if fetch is within the TTL. /// If fetch is outside TTL it revalidates / refreshes from the server. /// If server revalidation call fails, cached value is returned if present. /// TTL is specified as part of the query GQL configuration. case defaultPolicy - + /// Always attempts to return from cache. Does not reach out to server case cache - + /// Attempts to fetch from server ignoring cache. /// Cache is refreshed from server data after the call case server diff --git a/Sources/Queries/QueryRef.swift b/Sources/Queries/QueryRef.swift index 38c3cb8..bd4eca3 100644 --- a/Sources/Queries/QueryRef.swift +++ b/Sources/Queries/QueryRef.swift @@ -12,8 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. -import Foundation import CryptoKit +import Foundation import Firebase @@ -34,21 +34,23 @@ public enum ResultsPublisherType { case observableMacro } - @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) public protocol QueryRef: OperationRef, Equatable, Hashable { // This call starts query execution and publishes data - func subscribe() async throws -> AnyPublisher, AnyDataConnectError>, Never> - + func subscribe() async throws -> AnyPublisher, + AnyDataConnectError + >, Never> + // Execute override for queries to include fetch policy func execute(fetchPolicy: QueryFetchPolicy) async throws -> OperationResult - - //func execute(fetchPolicy: QueryFetchPolicy) async throws + + // func execute(fetchPolicy: QueryFetchPolicy) async throws } -extension QueryRef { +public extension QueryRef { // default implementation for execute() - public func execute() async throws -> OperationResult { + func execute() async throws -> OperationResult { try await execute(fetchPolicy: .defaultPolicy) } } diff --git a/Sources/Queries/QueryRequest.swift b/Sources/Queries/QueryRequest.swift index fa77eb2..f61cf18 100644 --- a/Sources/Queries/QueryRequest.swift +++ b/Sources/Queries/QueryRequest.swift @@ -12,8 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. -import Foundation import CryptoKit +import Foundation import Firebase @@ -23,14 +23,14 @@ import Firebase struct QueryRequest: OperationRequest, Hashable, Equatable { private(set) var operationName: String private(set) var variables: Variable? - + // Computed requestId lazy var requestId: String = { var keyIdData = Data() if let nameData = operationName.data(using: .utf8) { keyIdData.append(nameData) } - + if let variables { let encoder = JSONEncoder() encoder.outputFormatting = .sortedKeys @@ -42,7 +42,7 @@ struct QueryRequest: OperationRequest, Hashable, Eq .warning("Error encoding variables to compute request identifier: \(error)") } } - + return keyIdData.sha256String }() diff --git a/Sources/ResultSource.swift b/Sources/ResultSource.swift index ab0ef3c..874bad5 100644 --- a/Sources/ResultSource.swift +++ b/Sources/ResultSource.swift @@ -12,17 +12,15 @@ // See the License for the specific language governing permissions and // limitations under the License. - /// Indicates the source of the query results data. @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) public enum ResultSource: Sendable { - /// source not known or cannot be determined case unknown - + /// The query results are from server case server - + /// Query results are from cache /// stale - indicates if cached data is within TTL or outside. case cache(stale: Bool) diff --git a/Sources/Scalars/AnyCodableValue.swift b/Sources/Scalars/AnyCodableValue.swift index 8dec877..6c16f48 100644 --- a/Sources/Scalars/AnyCodableValue.swift +++ b/Sources/Scalars/AnyCodableValue.swift @@ -85,7 +85,7 @@ enum AnyCodableValue: Codable, Equatable, CustomStringConvertible { case let .bool(value): try container.encode(value) case let .uuid(value): - try container.encode(try UUIDCodableConverter().encode(input: value)) + try container.encode(UUIDCodableConverter().encode(input: value)) case let .timestamp(value): try container.encode(value) case let .dictionary(value): @@ -96,7 +96,7 @@ enum AnyCodableValue: Codable, Equatable, CustomStringConvertible { try container.encodeNil() } } - + var isScalar: Bool { switch self { case .array, .dictionary: @@ -108,7 +108,7 @@ enum AnyCodableValue: Codable, Equatable, CustomStringConvertible { // it will be stored inline so treating as scalar } } - + var description: String { switch self { case let .int64(value): @@ -131,5 +131,4 @@ enum AnyCodableValue: Codable, Equatable, CustomStringConvertible { return "null" } } - } diff --git a/Tests/Unit/CacheTests.swift b/Tests/Unit/CacheTests.swift index 50d5ac3..2b8e1e1 100644 --- a/Tests/Unit/CacheTests.swift +++ b/Tests/Unit/CacheTests.swift @@ -5,7 +5,6 @@ // Created by Aashish Patil on 8/27/25. // - import Foundation import XCTest @@ -13,73 +12,70 @@ import XCTest @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) final class CacheTests: XCTestCase { - let resultTreeOneItemJson = """ {"item":{"desc":null,"cacheId":"a2e64ada1771434aa3ec73f6a6d05428","price":40.456,"reviews":[{"title":"Item4 Review1 byUser3","id":"d769b8d6d4064e81948fb6b9374fba54","cacheId":"03ADC9EC-0102-4F24-BE8B-F6C0DD102EA4","user":{"name":"User3","id":"69562c9aee2f47ee8abb8181d4df53ec","cacheId":"65928AFC-22FA-422D-A2F1-85980DC682AE"}}],"id":"98e55525f20f4ee190034adcd6fb01dc","name":"Item4"}} - + """ - + let resultTreeOneItemSimple = """ {"item":{"desc":"itemDesc","name":"itemsOne", "cacheId":"123","price":4}} """ - + let resultTreeJson = """ - {"items":[{"id":"0cadb2b93d46434db1d218d6db023b79","price":226.94024396145267,"name":"Item-24","cacheId":"78192783c32c48cd9b4146547421a6a5","userReviews_on_item":[]},{"cacheId":"fc9387b4c50a4eb28e91d7a09d108a44","id":"4a01c498dd014a29b20ac693395a2900","userReviews_on_item":[],"name":"Item-62","price":512.3027252608986},{"price":617.9690589103608,"id":"e2ed29ed3e9b42328899d49fa33fc785","userReviews_on_item":[],"cacheId":"a911561b2b904f008ab8c3a2d2a7fdbe","name":"Item-49"},{"id":"0da168e75ded479ea3b150c13b7c6ec7","price":10.456,"userReviews_on_item":[{"cacheId":"125791DB-696E-4446-8F2A-C17E7C2AF771","user":{"name":"User1","id":"2fff8099d54843a0bbbbcf905e4c3424","cacheId":"27E85023-D465-4240-82D6-0055AA122406"},"title":"Item1 Review1 byUser1","id":"1384a5173c31487c8834368348c3b89c"}],"name":"Item1","cacheId":"fcfa90f7308049a083c3131f9a7a9836"},{"id":"23311f29be09495cba198da89b8b7d0f","name":"Item2","price":20.456,"cacheId":"c565d2fb7386480c87aa804f2789d200","userReviews_on_item":[{"title":"Item2 Review1 byUser1","user":{"name":"User1","id":"2fff8099d54843a0bbbbcf905e4c3424","cacheId":"27E85023-D465-4240-82D6-0055AA122406"},"cacheId":"F652FB4E-65E0-43E0-ADB1-14582304F938","id":"7ec6b021e1654eff98b3482925fab0c9"}]},{"name":"Item3","cacheId":"c6218faf3607495aaeab752ae6d0b8a7","id":"b7d2287e94014f4fa4a1566f1b893105","price":30.456,"userReviews_on_item":[{"title":"Item3 Review1 byUser2","cacheId":"8455C788-647F-4AB3-971B-6A9C42456129","id":"9bf4d458dd204a4c8931fe952bba85b7","user":{"id":"00af97d8f274427cb5e2c691ca13521c","name":"User2","cacheId":"EB588061-7139-4D6D-9A1B-80D4150DC1B4"}}]},{"userReviews_on_item":[{"id":"d769b8d6d4064e81948fb6b9374fba54","cacheId":"03ADC9EC-0102-4F24-BE8B-F6C0DD102EA4","title":"Item4 Review1 byUser3","user":{"cacheId":"65928AFC-22FA-422D-A2F1-85980DC682AE","id":"69562c9aee2f47ee8abb8181d4df53ec","name":"User3"}}],"price":40.456,"name":"Item4","id":"98e55525f20f4ee190034adcd6fb01dc","cacheId":"a2e64ada1771434aa3ec73f6a6d05428"}]} - """ + {"items":[{"id":"0cadb2b93d46434db1d218d6db023b79","price":226.94024396145267,"name":"Item-24","cacheId":"78192783c32c48cd9b4146547421a6a5","userReviews_on_item":[]},{"cacheId":"fc9387b4c50a4eb28e91d7a09d108a44","id":"4a01c498dd014a29b20ac693395a2900","userReviews_on_item":[],"name":"Item-62","price":512.3027252608986},{"price":617.9690589103608,"id":"e2ed29ed3e9b42328899d49fa33fc785","userReviews_on_item":[],"cacheId":"a911561b2b904f008ab8c3a2d2a7fdbe","name":"Item-49"},{"id":"0da168e75ded479ea3b150c13b7c6ec7","price":10.456,"userReviews_on_item":[{"cacheId":"125791DB-696E-4446-8F2A-C17E7C2AF771","user":{"name":"User1","id":"2fff8099d54843a0bbbbcf905e4c3424","cacheId":"27E85023-D465-4240-82D6-0055AA122406"},"title":"Item1 Review1 byUser1","id":"1384a5173c31487c8834368348c3b89c"}],"name":"Item1","cacheId":"fcfa90f7308049a083c3131f9a7a9836"},{"id":"23311f29be09495cba198da89b8b7d0f","name":"Item2","price":20.456,"cacheId":"c565d2fb7386480c87aa804f2789d200","userReviews_on_item":[{"title":"Item2 Review1 byUser1","user":{"name":"User1","id":"2fff8099d54843a0bbbbcf905e4c3424","cacheId":"27E85023-D465-4240-82D6-0055AA122406"},"cacheId":"F652FB4E-65E0-43E0-ADB1-14582304F938","id":"7ec6b021e1654eff98b3482925fab0c9"}]},{"name":"Item3","cacheId":"c6218faf3607495aaeab752ae6d0b8a7","id":"b7d2287e94014f4fa4a1566f1b893105","price":30.456,"userReviews_on_item":[{"title":"Item3 Review1 byUser2","cacheId":"8455C788-647F-4AB3-971B-6A9C42456129","id":"9bf4d458dd204a4c8931fe952bba85b7","user":{"id":"00af97d8f274427cb5e2c691ca13521c","name":"User2","cacheId":"EB588061-7139-4D6D-9A1B-80D4150DC1B4"}}]},{"userReviews_on_item":[{"id":"d769b8d6d4064e81948fb6b9374fba54","cacheId":"03ADC9EC-0102-4F24-BE8B-F6C0DD102EA4","title":"Item4 Review1 byUser3","user":{"cacheId":"65928AFC-22FA-422D-A2F1-85980DC682AE","id":"69562c9aee2f47ee8abb8181d4df53ec","name":"User3"}}],"price":40.456,"name":"Item4","id":"98e55525f20f4ee190034adcd6fb01dc","cacheId":"a2e64ada1771434aa3ec73f6a6d05428"}]} + """ var cacheProvider: SQLiteCacheProvider? - + override func setUpWithError() throws { cacheProvider = try SQLiteCacheProvider( "testEphemeralCacheProvider", ephemeral: true ) } - + override func tearDownWithError() throws {} - + // Confirm that providing same entity cache id uses the same EntityDataObject instance func testEntityDataObjectReuse() throws { do { - guard let cacheProvider else { XCTFail("cacheProvider is nil") return } - + let resultsProcessor = ResultTreeProcessor() try resultsProcessor.dehydrateResults(resultTreeJson, cacheProvider: cacheProvider) - - + let reusedCacheId = "27E85023-D465-4240-82D6-0055AA122406" - + let user1 = cacheProvider.entityData(reusedCacheId) let user2 = cacheProvider.entityData(reusedCacheId) - + // both user objects should be references to same instance XCTAssertTrue(user1 == user2) } } - + func testDehydrationHydration() throws { do { - guard let cacheProvider else { XCTFail("cacheProvider is nil") return } - + let resultsProcessor = ResultTreeProcessor() - + let (dehydratedTree, do1, _) = try resultsProcessor.dehydrateResults( resultTreeOneItemJson, cacheProvider: cacheProvider ) - - let (hydratedTree, do2 ) = try resultsProcessor.hydrateResults(dehydratedTree, cacheProvider: cacheProvider) - + + let (hydratedTree, do2) = try resultsProcessor.hydrateResults( + dehydratedTree, + cacheProvider: cacheProvider + ) + XCTAssertEqual(do1, do2) - } } - } From d9b4807b0eb63195dd2cf32d9284b1f4dde45b33 Mon Sep 17 00:00:00 2001 From: Aashish Patil Date: Mon, 13 Oct 2025 11:30:30 -0700 Subject: [PATCH 19/38] Refactor name of OperationResultSource --- Sources/OperationResult.swift | 2 +- Sources/{ResultSource.swift => OperationResultSource.swift} | 2 +- Sources/Queries/ObservableQueryRef.swift | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) rename Sources/{ResultSource.swift => OperationResultSource.swift} (95%) diff --git a/Sources/OperationResult.swift b/Sources/OperationResult.swift index e0ec07e..a972445 100644 --- a/Sources/OperationResult.swift +++ b/Sources/OperationResult.swift @@ -17,5 +17,5 @@ import Foundation @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) public struct OperationResult: Sendable { public let data: ResultData? - public let source: ResultSource + public let source: OperationResultSource } diff --git a/Sources/ResultSource.swift b/Sources/OperationResultSource.swift similarity index 95% rename from Sources/ResultSource.swift rename to Sources/OperationResultSource.swift index 874bad5..9f4b25b 100644 --- a/Sources/ResultSource.swift +++ b/Sources/OperationResultSource.swift @@ -14,7 +14,7 @@ /// Indicates the source of the query results data. @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public enum ResultSource: Sendable { +public enum OperationResultSource: Sendable { /// source not known or cannot be determined case unknown diff --git a/Sources/Queries/ObservableQueryRef.swift b/Sources/Queries/ObservableQueryRef.swift index c82a2fd..612d3ec 100644 --- a/Sources/Queries/ObservableQueryRef.swift +++ b/Sources/Queries/ObservableQueryRef.swift @@ -26,7 +26,7 @@ public protocol ObservableQueryRef: QueryRef { var data: ResultData? { get } // source of the query results (server, cache, ) - var source: ResultSource { get } + var source: OperationResultSource { get } // last error received. if last fetch was successful this is cleared var lastError: DataConnectError? { get } @@ -99,7 +99,7 @@ public class QueryRefObservableObject< @Published public private(set) var lastError: DataConnectError? /// Source of the query results (server, local cache, ...) - @Published public private(set) var source: ResultSource = .unknown + @Published public private(set) var source: OperationResultSource = .unknown // QueryRef implementation @@ -211,7 +211,7 @@ public class QueryRefObservation< public private(set) var lastError: DataConnectError? /// Source of the query results (server, local cache, ...) - public private(set) var source: ResultSource = .unknown + public private(set) var source: OperationResultSource = .unknown // QueryRef implementation From 1c666ec9721f5d6131cdd76cc6cef73978d91cf0 Mon Sep 17 00:00:00 2001 From: Aashish Patil Date: Mon, 13 Oct 2025 13:14:27 -0700 Subject: [PATCH 20/38] API feedback - rename maxSize => maxSizeBytes --- Sources/Cache/CacheConfig.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/Cache/CacheConfig.swift b/Sources/Cache/CacheConfig.swift index de43302..72a6767 100644 --- a/Sources/Cache/CacheConfig.swift +++ b/Sources/Cache/CacheConfig.swift @@ -22,10 +22,10 @@ public struct CacheConfig: Sendable { } public let storage: Storage // default provider is persistent type - public let maxSize: UInt64 + public let maxSizeBytes: UInt64 public init(storage: Storage = .persistent, maxSize: UInt64 = 100_000_000) { self.storage = storage - self.maxSize = maxSize + self.maxSizeBytes = maxSize } } From 8ea6762bed8403b7ede15d85be414c15f7ff05f9 Mon Sep 17 00:00:00 2001 From: Aashish Patil Date: Wed, 15 Oct 2025 12:11:43 -0700 Subject: [PATCH 21/38] API Council Review Feedback --- Sources/Cache/Cache.swift | 4 +-- Sources/Cache/CacheConfig.swift | 2 +- Sources/Cache/CacheProvider.swift | 1 + Sources/Cache/ResultTree.swift | 8 ++--- Sources/DataConnect.swift | 12 ++++---- Sources/DataConnectSettings.swift | 8 ++--- ...ionResultSource.swift => DataSource.swift} | 8 ++--- Sources/OperationResult.swift | 2 +- Sources/Queries/GenericQueryRef.swift | 29 ++++++------------- Sources/Queries/ObservableQueryRef.swift | 10 +++---- Sources/Queries/QueryFetchPolicy.swift | 6 ++-- Sources/Queries/QueryRef.swift | 2 +- 12 files changed, 39 insertions(+), 53 deletions(-) rename Sources/{OperationResultSource.swift => DataSource.swift} (79%) diff --git a/Sources/Cache/Cache.swift b/Sources/Cache/Cache.swift index 8dd4d6b..12ed1bb 100644 --- a/Sources/Cache/Cache.swift +++ b/Sources/Cache/Cache.swift @@ -15,7 +15,7 @@ import FirebaseAuth class Cache { - let config: CacheConfig + let config: CacheSettings let dataConnect: DataConnect private var cacheProvider: CacheProvider? @@ -25,7 +25,7 @@ class Cache { // holding it to avoid dereference private var authChangeListenerProtocol: NSObjectProtocol? - init(config: CacheConfig, dataConnect: DataConnect) { + init(config: CacheSettings, dataConnect: DataConnect) { self.config = config self.dataConnect = dataConnect diff --git a/Sources/Cache/CacheConfig.swift b/Sources/Cache/CacheConfig.swift index 72a6767..c04d6b8 100644 --- a/Sources/Cache/CacheConfig.swift +++ b/Sources/Cache/CacheConfig.swift @@ -15,7 +15,7 @@ /// Firebase Data Connect cache is configured per Connector. /// Specifies the cache configuration for Firebase Data Connect at a connector level @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public struct CacheConfig: Sendable { +public struct CacheSettings: Sendable { public enum Storage: Sendable { case persistent case ephemeral diff --git a/Sources/Cache/CacheProvider.swift b/Sources/Cache/CacheProvider.swift index 9baec62..770b1a6 100644 --- a/Sources/Cache/CacheProvider.swift +++ b/Sources/Cache/CacheProvider.swift @@ -19,6 +19,7 @@ import FirebaseCore // FDC field name in server response that identifies a GlobalID let GlobalIDKey: String = "cacheId" +// Key to store cache provider in Codables userInfo object. let CacheProviderUserInfoKey = CodingUserInfoKey(rawValue: "fdc_cache_provider")! protocol CacheProvider { diff --git a/Sources/Cache/ResultTree.swift b/Sources/Cache/ResultTree.swift index 8e3ceb7..5b70ba5 100644 --- a/Sources/Cache/ResultTree.swift +++ b/Sources/Cache/ResultTree.swift @@ -31,10 +31,10 @@ struct ResultTree { } enum CodingKeys: String, CodingKey { - case cachedAt = "ca" - case lastAccessed = "la" - case ttl - case data = "d" + case cachedAt = "ca" // cached at + case lastAccessed = "la" // last accessed + case ttl = "ri" // revalidation interval + case data = "d" // data cached } } diff --git a/Sources/DataConnect.swift b/Sources/DataConnect.swift index 34093fc..26b3455 100644 --- a/Sources/DataConnect.swift +++ b/Sources/DataConnect.swift @@ -66,7 +66,7 @@ public class DataConnect { config: connectorConfig, settings: settings, callerSDKType: callerSDKType, - cacheConfig: settings.cacheConfig + cacheSettings: settings.cacheSettings ) } @@ -107,7 +107,7 @@ public class DataConnect { // MARK: Init init(app: FirebaseApp, connectorConfig: ConnectorConfig, settings: DataConnectSettings, - callerSDKType: CallerSDKType = .base, cacheConfig: CacheConfig? = CacheConfig()) { + callerSDKType: CallerSDKType = .base, cacheSettings: CacheSettings? = CacheSettings()) { guard app.options.projectID != nil else { fatalError("Firebase DataConnect requires the projectID to be set in the app options") } @@ -126,8 +126,8 @@ public class DataConnect { operationsManager = OperationsManager(grpcClient: grpcClient, cache: cache) - if let cacheConfig { - cache = Cache(config: cacheConfig, dataConnect: self) + if let cacheSettings { + cache = Cache(config: cacheSettings, dataConnect: self) } } @@ -210,7 +210,7 @@ private class InstanceStore { func instance(for app: FirebaseApp, config: ConnectorConfig, settings: DataConnectSettings, callerSDKType: CallerSDKType, - cacheConfig: CacheConfig? = nil) -> DataConnect { + cacheSettings: CacheSettings? = nil) -> DataConnect { accessQ.sync { let key = InstanceKey(app: app, config: config) if let inst = instances[key] { @@ -221,7 +221,7 @@ private class InstanceStore { connectorConfig: config, settings: settings, callerSDKType: callerSDKType, - cacheConfig: cacheConfig + cacheSettings: cacheSettings ) instances[key] = dc return dc diff --git a/Sources/DataConnectSettings.swift b/Sources/DataConnectSettings.swift index e24f4bd..0fba613 100644 --- a/Sources/DataConnectSettings.swift +++ b/Sources/DataConnectSettings.swift @@ -19,21 +19,21 @@ public struct DataConnectSettings: Hashable, Equatable, Sendable { public let host: String public let port: Int public let sslEnabled: Bool - public let cacheConfig: CacheConfig? + public let cacheSettings: CacheSettings? public init(host: String, port: Int, sslEnabled: Bool, - cacheConfig: CacheConfig? = CacheConfig()) { + cacheSettings: CacheSettings? = CacheSettings()) { self.host = host self.port = port self.sslEnabled = sslEnabled - self.cacheConfig = cacheConfig + self.cacheSettings = cacheSettings } public init() { host = "firebasedataconnect.googleapis.com" port = 443 sslEnabled = true - cacheConfig = CacheConfig() + cacheSettings = CacheSettings() } public func hash(into hasher: inout Hasher) { diff --git a/Sources/OperationResultSource.swift b/Sources/DataSource.swift similarity index 79% rename from Sources/OperationResultSource.swift rename to Sources/DataSource.swift index 9f4b25b..011d90d 100644 --- a/Sources/OperationResultSource.swift +++ b/Sources/DataSource.swift @@ -14,14 +14,10 @@ /// Indicates the source of the query results data. @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public enum OperationResultSource: Sendable { - /// source not known or cannot be determined - case unknown - +public enum DataSource: Sendable { /// The query results are from server case server /// Query results are from cache - /// stale - indicates if cached data is within TTL or outside. - case cache(stale: Bool) + case cache } diff --git a/Sources/OperationResult.swift b/Sources/OperationResult.swift index a972445..b7787e7 100644 --- a/Sources/OperationResult.swift +++ b/Sources/OperationResult.swift @@ -17,5 +17,5 @@ import Foundation @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) public struct OperationResult: Sendable { public let data: ResultData? - public let source: OperationResultSource + public let source: DataSource } diff --git a/Sources/Queries/GenericQueryRef.swift b/Sources/Queries/GenericQueryRef.swift index e5384cc..fff0d09 100644 --- a/Sources/Queries/GenericQueryRef.swift +++ b/Sources/Queries/GenericQueryRef.swift @@ -63,31 +63,21 @@ actor GenericQueryRef OperationResult { switch fetchPolicy { - case .defaultPolicy: + case .preferCache: let cachedResult = try await fetchCachedResults(allowStale: false) if cachedResult.data != nil { return cachedResult } else { - do { - let serverResults = try await fetchServerResults() - return serverResults - } catch let dcerr as DataConnectOperationError { - // TODO: Catch network specific error looking for deadline exceeded - /* - if dcErr is deadlineExceeded { - try await fetchCachedResults(allowStale: true) - } else rethrow - */ - throw dcerr - } + let serverResults = try await fetchServerResults() + return serverResults } - case .cache: + case .cacheOnly: let cachedResult = try await fetchCachedResults(allowStale: true) return cachedResult - case .server: + case .serverOnly: let serverResults = try await fetchServerResults() return serverResults } @@ -123,12 +113,11 @@ actor GenericQueryRef 0 else { DataConnectLogger.info("No cache provider configured or ttl is not set \(ttl)") - return OperationResult(data: nil, source: .cache(stale: false)) + return OperationResult(data: nil, source: .cache) } if let cacheEntry = cache.resultTree(queryId: request.requestId), (cacheEntry.isStale(ttl) && allowStale) || !cacheEntry.isStale(ttl) { - let stale = cacheEntry.isStale(ttl) let decoder = JSONDecoder() let decodedData = try decoder.decode( @@ -136,14 +125,14 @@ actor GenericQueryRef) async { diff --git a/Sources/Queries/ObservableQueryRef.swift b/Sources/Queries/ObservableQueryRef.swift index 612d3ec..86ebea9 100644 --- a/Sources/Queries/ObservableQueryRef.swift +++ b/Sources/Queries/ObservableQueryRef.swift @@ -26,7 +26,7 @@ public protocol ObservableQueryRef: QueryRef { var data: ResultData? { get } // source of the query results (server, cache, ) - var source: OperationResultSource { get } + var source: DataSource? { get } // last error received. if last fetch was successful this is cleared var lastError: DataConnectError? { get } @@ -99,13 +99,13 @@ public class QueryRefObservableObject< @Published public private(set) var lastError: DataConnectError? /// Source of the query results (server, local cache, ...) - @Published public private(set) var source: OperationResultSource = .unknown + @Published public private(set) var source: DataSource? // QueryRef implementation /// Executes the query and returns `ResultData`. This will also update the published `data` /// variable - public func execute(fetchPolicy: QueryFetchPolicy = .defaultPolicy) async throws + public func execute(fetchPolicy: QueryFetchPolicy = .preferCache) async throws -> OperationResult { let result = try await baseRef.execute(fetchPolicy: fetchPolicy) return result @@ -211,13 +211,13 @@ public class QueryRefObservation< public private(set) var lastError: DataConnectError? /// Source of the query results (server, local cache, ...) - public private(set) var source: OperationResultSource = .unknown + public private(set) var source: DataSource? // QueryRef implementation /// Executes the query and returns `ResultData`. This will also update the published `data` /// variable - public func execute(fetchPolicy: QueryFetchPolicy = .defaultPolicy) async throws + public func execute(fetchPolicy: QueryFetchPolicy = .preferCache) async throws -> OperationResult { let result = try await baseRef.execute(fetchPolicy: fetchPolicy) return result diff --git a/Sources/Queries/QueryFetchPolicy.swift b/Sources/Queries/QueryFetchPolicy.swift index 09fefd8..30c7f07 100644 --- a/Sources/Queries/QueryFetchPolicy.swift +++ b/Sources/Queries/QueryFetchPolicy.swift @@ -19,12 +19,12 @@ public enum QueryFetchPolicy { /// If fetch is outside TTL it revalidates / refreshes from the server. /// If server revalidation call fails, cached value is returned if present. /// TTL is specified as part of the query GQL configuration. - case defaultPolicy + case preferCache /// Always attempts to return from cache. Does not reach out to server - case cache + case cacheOnly /// Attempts to fetch from server ignoring cache. /// Cache is refreshed from server data after the call - case server + case serverOnly } diff --git a/Sources/Queries/QueryRef.swift b/Sources/Queries/QueryRef.swift index bd4eca3..6a1e3eb 100644 --- a/Sources/Queries/QueryRef.swift +++ b/Sources/Queries/QueryRef.swift @@ -51,6 +51,6 @@ public protocol QueryRef: OperationRef, Equatable, Hashable { public extension QueryRef { // default implementation for execute() func execute() async throws -> OperationResult { - try await execute(fetchPolicy: .defaultPolicy) + try await execute(fetchPolicy: .preferCache) } } From a807d9f5ecae8dccd58454790dbc8b8f4b385ee3 Mon Sep 17 00:00:00 2001 From: Aashish Patil Date: Fri, 24 Oct 2025 15:42:00 -0700 Subject: [PATCH 22/38] Move ServerResponse --- Sources/{Scalars => Internal}/ServerResponse.swift | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename Sources/{Scalars => Internal}/ServerResponse.swift (100%) diff --git a/Sources/Scalars/ServerResponse.swift b/Sources/Internal/ServerResponse.swift similarity index 100% rename from Sources/Scalars/ServerResponse.swift rename to Sources/Internal/ServerResponse.swift From 336e41b95527740fe35613813993071d197340d4 Mon Sep 17 00:00:00 2001 From: Aashish Patil Date: Wed, 5 Nov 2025 12:01:57 -0800 Subject: [PATCH 23/38] Update cache type to match API review feedback --- Sources/Cache/Cache.swift | 2 +- Sources/Cache/{CacheConfig.swift => CacheSettings.swift} | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) rename Sources/Cache/{CacheConfig.swift => CacheSettings.swift} (98%) diff --git a/Sources/Cache/Cache.swift b/Sources/Cache/Cache.swift index 12ed1bb..06316c2 100644 --- a/Sources/Cache/Cache.swift +++ b/Sources/Cache/Cache.swift @@ -50,7 +50,7 @@ class Cache { do { switch config.storage { - case .ephemeral: + case .memory: cacheProvider = try SQLiteCacheProvider(identifier, ephemeral: true) case .persistent: cacheProvider = try SQLiteCacheProvider(identifier, ephemeral: false) diff --git a/Sources/Cache/CacheConfig.swift b/Sources/Cache/CacheSettings.swift similarity index 98% rename from Sources/Cache/CacheConfig.swift rename to Sources/Cache/CacheSettings.swift index c04d6b8..aef0c7d 100644 --- a/Sources/Cache/CacheConfig.swift +++ b/Sources/Cache/CacheSettings.swift @@ -18,7 +18,7 @@ public struct CacheSettings: Sendable { public enum Storage: Sendable { case persistent - case ephemeral + case memory } public let storage: Storage // default provider is persistent type From 37493c494aaad6e4017fc0e332f3384b0876ab78 Mon Sep 17 00:00:00 2001 From: Aashish Patil Date: Tue, 11 Nov 2025 11:28:58 -0800 Subject: [PATCH 24/38] Cleanup and minor fixes to docs --- Sources/Cache/Cache.swift | 9 ++-- Sources/Cache/CacheProvider.swift | 4 -- Sources/Cache/CacheSettings.swift | 3 +- Sources/Cache/DynamicCodingKey.swift | 1 + Sources/Cache/EntityDataObject.swift | 30 +++++++------ Sources/Cache/EntityNode.swift | 17 +------- Sources/Cache/ResultTreeProcessor.swift | 14 +++---- Sources/Internal/SynchronizedDictionary.swift | 42 ------------------- Sources/OperationResult.swift | 1 + Sources/Queries/QueryFetchPolicy.swift | 14 ++++--- Sources/Queries/QueryRef.swift | 6 +-- 11 files changed, 46 insertions(+), 95 deletions(-) delete mode 100644 Sources/Internal/SynchronizedDictionary.swift diff --git a/Sources/Cache/Cache.swift b/Sources/Cache/Cache.swift index 06316c2..92c1674 100644 --- a/Sources/Cache/Cache.swift +++ b/Sources/Cache/Cache.swift @@ -14,6 +14,7 @@ import FirebaseAuth +// Client cache that internally uses a CacheProvider to store content. class Cache { let config: CacheSettings let dataConnect: DataConnect @@ -22,7 +23,7 @@ class Cache { private let queue = DispatchQueue(label: "com.google.firebase.dataconnect.cache") - // holding it to avoid dereference + // holding it to avoid dealloc private var authChangeListenerProtocol: NSObjectProtocol? init(config: CacheSettings, dataConnect: DataConnect) { @@ -77,7 +78,7 @@ class Cache { let identifier = "\(config.storage)-\(String(describing: dataConnect.app.options.projectID))-\(dataConnect.app.name)-\(dataConnect.connectorConfig.serviceId)-\(dataConnect.connectorConfig.connector)-\(dataConnect.connectorConfig.location)-\(Auth.auth(app: dataConnect.app).currentUser?.uid ?? "anon")-\(dataConnect.settings.host)" let encoded = identifier.sha256 - DataConnectLogger.debug("Created Cache Identifier \(encoded) for \(identifier)") + DataConnectLogger.debug("Created Encoded Cache Identifier \(encoded) for \(identifier)") return encoded } @@ -106,13 +107,15 @@ class Cache { return hydratedTree } catch { - DataConnectLogger.warning("Error getting result tree \(error)") + DataConnectLogger.warning("Error decoding result tree \(error)") return nil } } } func update(queryId: String, response: ServerResponse, requestor: (any QueryRefInternal)? = nil) { + // server response contains hydrated trees + // dehydrate (normalize) the results and store dehydrated trees queue.async(flags: .barrier) { guard let cacheProvider = self.cacheProvider else { DataConnectLogger diff --git a/Sources/Cache/CacheProvider.swift b/Sources/Cache/CacheProvider.swift index 770b1a6..a40ace3 100644 --- a/Sources/Cache/CacheProvider.swift +++ b/Sources/Cache/CacheProvider.swift @@ -31,8 +31,4 @@ protocol CacheProvider { func entityData(_ entityGuid: String) -> EntityDataObject func updateEntityData(_ object: EntityDataObject) - /* - - func size() -> Int - */ } diff --git a/Sources/Cache/CacheSettings.swift b/Sources/Cache/CacheSettings.swift index aef0c7d..6bb34c5 100644 --- a/Sources/Cache/CacheSettings.swift +++ b/Sources/Cache/CacheSettings.swift @@ -12,8 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -/// Firebase Data Connect cache is configured per Connector. -/// Specifies the cache configuration for Firebase Data Connect at a connector level +/// Specifies the cache configuration for a Firebase Data Connect instance @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) public struct CacheSettings: Sendable { public enum Storage: Sendable { diff --git a/Sources/Cache/DynamicCodingKey.swift b/Sources/Cache/DynamicCodingKey.swift index 52cc6bd..4b32b2c 100644 --- a/Sources/Cache/DynamicCodingKey.swift +++ b/Sources/Cache/DynamicCodingKey.swift @@ -12,6 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. +// Used for inline inline hydration of entity values struct DynamicCodingKey: CodingKey { var intValue: Int? let stringValue: String diff --git a/Sources/Cache/EntityDataObject.swift b/Sources/Cache/EntityDataObject.swift index f3e9256..4a8f8eb 100644 --- a/Sources/Cache/EntityDataObject.swift +++ b/Sources/Cache/EntityDataObject.swift @@ -19,7 +19,8 @@ struct ScalarField { let value: AnyCodableValue } -class EntityDataObject: CustomStringConvertible, Codable { +// Represents a normalized entity shared amongst queries. +class EntityDataObject: Codable { let guid: String // globally unique id received from server private let accessQueue = DispatchQueue( @@ -35,10 +36,10 @@ class EntityDataObject: CustomStringConvertible, Codable { enum CodingKeys: String, CodingKey { case globalID = "guid" - case serverValues = "serVal" + case serverValues = "sval" } - // Updates value received from server and returns a list of QueryRef operation ids + // Updates the value received from server and returns a list of QueryRef operation ids // referenced from this EntityDataObject @discardableResult func updateServerValue(_ key: String, _ newValue: AnyCodableValue, @@ -87,15 +88,8 @@ class EntityDataObject: CustomStringConvertible, Codable { } } - var description: String { - return """ - EntityDataObject: - globalID: \(guid) - serverValues: - \(serverValues) - """ - } - + // MARK: Encoding / Decoding support + func encodableData() throws -> [String: AnyCodableValue] { var encodingValues = [String: AnyCodableValue]() encodingValues[GlobalIDKey] = .string(guid) @@ -107,7 +101,6 @@ class EntityDataObject: CustomStringConvertible, Codable { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(guid, forKey: .globalID) try container.encode(serverValues, forKey: .serverValues) - // once we have localValues, we will need to merge between the two dicts and encode } required init(from decoder: Decoder) throws { @@ -120,6 +113,17 @@ class EntityDataObject: CustomStringConvertible, Codable { } } +extension EntityDataObject: CustomStringConvertible { + var description: String { + return """ + EntityDataObject: + globalID: \(guid) + serverValues: + \(serverValues) + """ + } +} + extension EntityDataObject: Equatable { static func == (lhs: EntityDataObject, rhs: EntityDataObject) -> Bool { return lhs.guid == rhs.guid && lhs.serverValues == rhs.serverValues diff --git a/Sources/Cache/EntityNode.swift b/Sources/Cache/EntityNode.swift index 1187937..5ec6ae1 100644 --- a/Sources/Cache/EntityNode.swift +++ b/Sources/Cache/EntityNode.swift @@ -11,21 +11,8 @@ // 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. -/* - - Init with JSON (root) - Convert to CodableValue and Init itself - Codable Value must be a dict at root level - - Dict can contain - (if EDO) - - Scalars, [Scalar] => Move this to DBO - - References, [References] => Keep with - (if no guid and therefore no EDO) - - Store CodableValue as - is - - */ +// Represents an object node in the ResultTree. struct EntityNode { // externalized (normalized) data. // Requires an entity globalID to be provided in selection set @@ -43,7 +30,7 @@ struct EntityNode { var objectLists = [String: [EntityNode]]() enum CodingKeys: String, CodingKey { - case globalID = "cacheId" + case globalID = "guid" case objectLists case references case scalars diff --git a/Sources/Cache/ResultTreeProcessor.swift b/Sources/Cache/ResultTreeProcessor.swift index 9b0e21e..0d31f7f 100644 --- a/Sources/Cache/ResultTreeProcessor.swift +++ b/Sources/Cache/ResultTreeProcessor.swift @@ -22,11 +22,11 @@ let ResultTreeKindCodingKey = let UpdatingQueryRefsCodingKey = CodingUserInfoKey(rawValue: "com.google.firebase.dataconnect.updatingQueryRef")! -// Key pointing to container for QueryRefs. EntityDataObjects fill this +// Key pointing to accumulator for referenced QueryRefs. EntityDataObjects fill this let ImpactedRefsAccumulatorCodingKey = CodingUserInfoKey(rawValue: "com.google.firebase.dataconnect.impactedQueryRefs")! -// Kind of result data we are encoding from or decoding to +// Kind-of result data we are encoding from or decoding to enum ResultTreeKind { case hydrated // JSON data is full hydrated and contains full data in the tree case dehydrated // JSON data is dehydrated and only contains refs to actual data objects @@ -60,19 +60,19 @@ class ImpactedQueryRefsAccumulator { } } -// Normalization and recontruction of ResultTree +// Dehydration (normalization) and hydration of the data +// Hooks into the Codable process with userInfo flags driving what data gets encoded / decoded struct ResultTreeProcessor { /* - Go down the tree and convert them to nodes + Go down the tree and convert them to entity nodes For each Node - - extract primary key - - Get the EDO for the PK + - extract globalID + - Get the EDO for the globalID - extract scalars and update EDO with scalars - for each array - recursively process each object (could be scalar or composite) - for composite objects (dictionaries), create references to their node - create a Node and init it with dictionary. - */ func dehydrateResults(_ hydratedTree: String, cacheProvider: CacheProvider, diff --git a/Sources/Internal/SynchronizedDictionary.swift b/Sources/Internal/SynchronizedDictionary.swift deleted file mode 100644 index f53c65e..0000000 --- a/Sources/Internal/SynchronizedDictionary.swift +++ /dev/null @@ -1,42 +0,0 @@ -// 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 Foundation - -class SynchronizedDictionary { - private var dictionary: [Key: Value] = [:] - private let queue: DispatchQueue = .init(label: "com.google.firebase.dataconnect.syncDictionaryQ") - - init() {} - - subscript(key: Key) -> Value? { - get { - return queue.sync { self.dictionary[key] } - } set { - queue.async(flags: .barrier) { - self.dictionary[key] = newValue - } - } - } - - func rawCopy() -> [Key: Value] { - return queue.sync { self.dictionary } - } - - func updateValues(_ values: [Key: Value]) { - queue.async(flags: .barrier) { - self.dictionary.merge(values) { _, new in new } - } - } -} diff --git a/Sources/OperationResult.swift b/Sources/OperationResult.swift index b7787e7..550447e 100644 --- a/Sources/OperationResult.swift +++ b/Sources/OperationResult.swift @@ -14,6 +14,7 @@ import Foundation +/// Struct returned by operation calls - query or mutation @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) public struct OperationResult: Sendable { public let data: ResultData? diff --git a/Sources/Queries/QueryFetchPolicy.swift b/Sources/Queries/QueryFetchPolicy.swift index 30c7f07..dd393bd 100644 --- a/Sources/Queries/QueryFetchPolicy.swift +++ b/Sources/Queries/QueryFetchPolicy.swift @@ -12,19 +12,21 @@ // See the License for the specific language governing permissions and // limitations under the License. -/// Policies for executing a Data Connect query. This value is passed to +/// Policies for executing a Data Connect query. This value is optionally passed to `execute()` @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) public enum QueryFetchPolicy { - /// default policy tries to fetch from cache if fetch is within the TTL. - /// If fetch is outside TTL it revalidates / refreshes from the server. - /// If server revalidation call fails, cached value is returned if present. - /// TTL is specified as part of the query GQL configuration. + /// default policy tries to fetch from cache if fetch is within the revalidationInterval. + /// If fetch is outside revalidationInterval it revalidates / refreshes from the server. + /// Throws if server revalidation fails + /// Callers may call with `cacheOnly` policy to fetch data (if present) outside revalidationInterval from cache. + /// revalidationInterval is specified as part of the query YAML config using `client-cache.revalidateAfter` key case preferCache /// Always attempts to return from cache. Does not reach out to server case cacheOnly /// Attempts to fetch from server ignoring cache. - /// Cache is refreshed from server data after the call + /// Cache is refreshed from server data if call succeeds. + /// Throws if server call fails case serverOnly } diff --git a/Sources/Queries/QueryRef.swift b/Sources/Queries/QueryRef.swift index 6a1e3eb..1f69952 100644 --- a/Sources/Queries/QueryRef.swift +++ b/Sources/Queries/QueryRef.swift @@ -36,16 +36,16 @@ public enum ResultsPublisherType { @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) public protocol QueryRef: OperationRef, Equatable, Hashable { - // This call starts query execution and publishes data + /// This call starts query execution and publishes data + /// Subscribe always returns cache data first while it attempts to fetch from the server func subscribe() async throws -> AnyPublisher, AnyDataConnectError >, Never> - // Execute override for queries to include fetch policy + /// Execute override for queries to include fetch policy. Defaults to `preferCache` policy func execute(fetchPolicy: QueryFetchPolicy) async throws -> OperationResult - // func execute(fetchPolicy: QueryFetchPolicy) async throws } public extension QueryRef { From dd8569c74ea34a45056878d7163f6f7425f97c44 Mon Sep 17 00:00:00 2001 From: Aashish Patil Date: Tue, 11 Nov 2025 11:33:48 -0800 Subject: [PATCH 25/38] Update globalID value --- Sources/Cache/Cache.swift | 3 +++ Sources/Cache/CacheProvider.swift | 3 --- Tests/Unit/CacheTests.swift | 12 ++++++------ 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/Sources/Cache/Cache.swift b/Sources/Cache/Cache.swift index 92c1674..2b08771 100644 --- a/Sources/Cache/Cache.swift +++ b/Sources/Cache/Cache.swift @@ -14,6 +14,9 @@ import FirebaseAuth +// FDC field name in server response that identifies a GlobalID +let GlobalIDKey: String = "_id" + // Client cache that internally uses a CacheProvider to store content. class Cache { let config: CacheSettings diff --git a/Sources/Cache/CacheProvider.swift b/Sources/Cache/CacheProvider.swift index a40ace3..51b200d 100644 --- a/Sources/Cache/CacheProvider.swift +++ b/Sources/Cache/CacheProvider.swift @@ -16,9 +16,6 @@ import Foundation import FirebaseCore -// FDC field name in server response that identifies a GlobalID -let GlobalIDKey: String = "cacheId" - // Key to store cache provider in Codables userInfo object. let CacheProviderUserInfoKey = CodingUserInfoKey(rawValue: "fdc_cache_provider")! diff --git a/Tests/Unit/CacheTests.swift b/Tests/Unit/CacheTests.swift index 2b8e1e1..5fceebb 100644 --- a/Tests/Unit/CacheTests.swift +++ b/Tests/Unit/CacheTests.swift @@ -13,16 +13,16 @@ import XCTest @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) final class CacheTests: XCTestCase { let resultTreeOneItemJson = """ - {"item":{"desc":null,"cacheId":"a2e64ada1771434aa3ec73f6a6d05428","price":40.456,"reviews":[{"title":"Item4 Review1 byUser3","id":"d769b8d6d4064e81948fb6b9374fba54","cacheId":"03ADC9EC-0102-4F24-BE8B-F6C0DD102EA4","user":{"name":"User3","id":"69562c9aee2f47ee8abb8181d4df53ec","cacheId":"65928AFC-22FA-422D-A2F1-85980DC682AE"}}],"id":"98e55525f20f4ee190034adcd6fb01dc","name":"Item4"}} + {"item":{"desc":null,"_id":"a2e64ada1771434aa3ec73f6a6d05428","price":40.456,"reviews":[{"title":"Item4 Review1 byUser3","id":"d769b8d6d4064e81948fb6b9374fba54","_id":"03ADC9EC-0102-4F24-BE8B-F6C0DD102EA4","user":{"name":"User3","id":"69562c9aee2f47ee8abb8181d4df53ec","_id":"65928AFC-22FA-422D-A2F1-85980DC682AE"}}],"id":"98e55525f20f4ee190034adcd6fb01dc","name":"Item4"}} """ let resultTreeOneItemSimple = """ - {"item":{"desc":"itemDesc","name":"itemsOne", "cacheId":"123","price":4}} + {"item":{"desc":"itemDesc","name":"itemsOne", "_id":"123","price":4}} """ let resultTreeJson = """ - {"items":[{"id":"0cadb2b93d46434db1d218d6db023b79","price":226.94024396145267,"name":"Item-24","cacheId":"78192783c32c48cd9b4146547421a6a5","userReviews_on_item":[]},{"cacheId":"fc9387b4c50a4eb28e91d7a09d108a44","id":"4a01c498dd014a29b20ac693395a2900","userReviews_on_item":[],"name":"Item-62","price":512.3027252608986},{"price":617.9690589103608,"id":"e2ed29ed3e9b42328899d49fa33fc785","userReviews_on_item":[],"cacheId":"a911561b2b904f008ab8c3a2d2a7fdbe","name":"Item-49"},{"id":"0da168e75ded479ea3b150c13b7c6ec7","price":10.456,"userReviews_on_item":[{"cacheId":"125791DB-696E-4446-8F2A-C17E7C2AF771","user":{"name":"User1","id":"2fff8099d54843a0bbbbcf905e4c3424","cacheId":"27E85023-D465-4240-82D6-0055AA122406"},"title":"Item1 Review1 byUser1","id":"1384a5173c31487c8834368348c3b89c"}],"name":"Item1","cacheId":"fcfa90f7308049a083c3131f9a7a9836"},{"id":"23311f29be09495cba198da89b8b7d0f","name":"Item2","price":20.456,"cacheId":"c565d2fb7386480c87aa804f2789d200","userReviews_on_item":[{"title":"Item2 Review1 byUser1","user":{"name":"User1","id":"2fff8099d54843a0bbbbcf905e4c3424","cacheId":"27E85023-D465-4240-82D6-0055AA122406"},"cacheId":"F652FB4E-65E0-43E0-ADB1-14582304F938","id":"7ec6b021e1654eff98b3482925fab0c9"}]},{"name":"Item3","cacheId":"c6218faf3607495aaeab752ae6d0b8a7","id":"b7d2287e94014f4fa4a1566f1b893105","price":30.456,"userReviews_on_item":[{"title":"Item3 Review1 byUser2","cacheId":"8455C788-647F-4AB3-971B-6A9C42456129","id":"9bf4d458dd204a4c8931fe952bba85b7","user":{"id":"00af97d8f274427cb5e2c691ca13521c","name":"User2","cacheId":"EB588061-7139-4D6D-9A1B-80D4150DC1B4"}}]},{"userReviews_on_item":[{"id":"d769b8d6d4064e81948fb6b9374fba54","cacheId":"03ADC9EC-0102-4F24-BE8B-F6C0DD102EA4","title":"Item4 Review1 byUser3","user":{"cacheId":"65928AFC-22FA-422D-A2F1-85980DC682AE","id":"69562c9aee2f47ee8abb8181d4df53ec","name":"User3"}}],"price":40.456,"name":"Item4","id":"98e55525f20f4ee190034adcd6fb01dc","cacheId":"a2e64ada1771434aa3ec73f6a6d05428"}]} + {"items":[{"id":"0cadb2b93d46434db1d218d6db023b79","price":226.94024396145267,"name":"Item-24","_id":"78192783c32c48cd9b4146547421a6a5","userReviews_on_item":[]},{"_id":"fc9387b4c50a4eb28e91d7a09d108a44","id":"4a01c498dd014a29b20ac693395a2900","userReviews_on_item":[],"name":"Item-62","price":512.3027252608986},{"price":617.9690589103608,"id":"e2ed29ed3e9b42328899d49fa33fc785","userReviews_on_item":[],"_id":"a911561b2b904f008ab8c3a2d2a7fdbe","name":"Item-49"},{"id":"0da168e75ded479ea3b150c13b7c6ec7","price":10.456,"userReviews_on_item":[{"_id":"125791DB-696E-4446-8F2A-C17E7C2AF771","user":{"name":"User1","id":"2fff8099d54843a0bbbbcf905e4c3424","_id":"27E85023-D465-4240-82D6-0055AA122406"},"title":"Item1 Review1 byUser1","id":"1384a5173c31487c8834368348c3b89c"}],"name":"Item1","_id":"fcfa90f7308049a083c3131f9a7a9836"},{"id":"23311f29be09495cba198da89b8b7d0f","name":"Item2","price":20.456,"_id":"c565d2fb7386480c87aa804f2789d200","userReviews_on_item":[{"title":"Item2 Review1 byUser1","user":{"name":"User1","id":"2fff8099d54843a0bbbbcf905e4c3424","_id":"27E85023-D465-4240-82D6-0055AA122406"},"_id":"F652FB4E-65E0-43E0-ADB1-14582304F938","id":"7ec6b021e1654eff98b3482925fab0c9"}]},{"name":"Item3","_id":"c6218faf3607495aaeab752ae6d0b8a7","id":"b7d2287e94014f4fa4a1566f1b893105","price":30.456,"userReviews_on_item":[{"title":"Item3 Review1 byUser2","_id":"8455C788-647F-4AB3-971B-6A9C42456129","id":"9bf4d458dd204a4c8931fe952bba85b7","user":{"id":"00af97d8f274427cb5e2c691ca13521c","name":"User2","_id":"EB588061-7139-4D6D-9A1B-80D4150DC1B4"}}]},{"userReviews_on_item":[{"id":"d769b8d6d4064e81948fb6b9374fba54","_id":"03ADC9EC-0102-4F24-BE8B-F6C0DD102EA4","title":"Item4 Review1 byUser3","user":{"_id":"65928AFC-22FA-422D-A2F1-85980DC682AE","id":"69562c9aee2f47ee8abb8181d4df53ec","name":"User3"}}],"price":40.456,"name":"Item4","id":"98e55525f20f4ee190034adcd6fb01dc","_id":"a2e64ada1771434aa3ec73f6a6d05428"}]} """ var cacheProvider: SQLiteCacheProvider? @@ -46,10 +46,10 @@ final class CacheTests: XCTestCase { let resultsProcessor = ResultTreeProcessor() try resultsProcessor.dehydrateResults(resultTreeJson, cacheProvider: cacheProvider) - let reusedCacheId = "27E85023-D465-4240-82D6-0055AA122406" + let reused_id = "27E85023-D465-4240-82D6-0055AA122406" - let user1 = cacheProvider.entityData(reusedCacheId) - let user2 = cacheProvider.entityData(reusedCacheId) + let user1 = cacheProvider.entityData(reused_id) + let user2 = cacheProvider.entityData(reused_id) // both user objects should be references to same instance XCTAssertTrue(user1 == user2) From fad7e2f57db9edccd45982e494a7fe5641cfb049 Mon Sep 17 00:00:00 2001 From: Aashish Patil Date: Wed, 12 Nov 2025 11:59:54 -0800 Subject: [PATCH 26/38] Fix format --- Sources/Cache/CacheProvider.swift | 3 +-- Sources/Cache/CacheSettings.swift | 2 +- Sources/Cache/EntityDataObject.swift | 2 +- Sources/Queries/GenericQueryRef.swift | 1 - Sources/Queries/QueryFetchPolicy.swift | 6 ++++-- Sources/Queries/QueryRef.swift | 1 - 6 files changed, 7 insertions(+), 8 deletions(-) diff --git a/Sources/Cache/CacheProvider.swift b/Sources/Cache/CacheProvider.swift index 51b200d..6a2dc05 100644 --- a/Sources/Cache/CacheProvider.swift +++ b/Sources/Cache/CacheProvider.swift @@ -16,7 +16,7 @@ import Foundation import FirebaseCore -// Key to store cache provider in Codables userInfo object. +// Key to store cache provider in Codables userInfo object. let CacheProviderUserInfoKey = CodingUserInfoKey(rawValue: "fdc_cache_provider")! protocol CacheProvider { @@ -27,5 +27,4 @@ protocol CacheProvider { func entityData(_ entityGuid: String) -> EntityDataObject func updateEntityData(_ object: EntityDataObject) - } diff --git a/Sources/Cache/CacheSettings.swift b/Sources/Cache/CacheSettings.swift index 6bb34c5..b9f9b03 100644 --- a/Sources/Cache/CacheSettings.swift +++ b/Sources/Cache/CacheSettings.swift @@ -25,6 +25,6 @@ public struct CacheSettings: Sendable { public init(storage: Storage = .persistent, maxSize: UInt64 = 100_000_000) { self.storage = storage - self.maxSizeBytes = maxSize + maxSizeBytes = maxSize } } diff --git a/Sources/Cache/EntityDataObject.swift b/Sources/Cache/EntityDataObject.swift index 4a8f8eb..90f954f 100644 --- a/Sources/Cache/EntityDataObject.swift +++ b/Sources/Cache/EntityDataObject.swift @@ -89,7 +89,7 @@ class EntityDataObject: Codable { } // MARK: Encoding / Decoding support - + func encodableData() throws -> [String: AnyCodableValue] { var encodingValues = [String: AnyCodableValue]() encodingValues[GlobalIDKey] = .string(guid) diff --git a/Sources/Queries/GenericQueryRef.swift b/Sources/Queries/GenericQueryRef.swift index fff0d09..07cb3c7 100644 --- a/Sources/Queries/GenericQueryRef.swift +++ b/Sources/Queries/GenericQueryRef.swift @@ -118,7 +118,6 @@ actor GenericQueryRef OperationResult - } public extension QueryRef { From a62c16b0ba8ae0f433034f49c0149018d9784a31 Mon Sep 17 00:00:00 2001 From: Aashish Patil Date: Wed, 12 Nov 2025 12:06:55 -0800 Subject: [PATCH 27/38] Fix copyright notices --- Sources/Internal/QueryRefInternal.swift | 13 ++++++++++--- Tests/Unit/CacheTests.swift | 13 ++++++++++--- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/Sources/Internal/QueryRefInternal.swift b/Sources/Internal/QueryRefInternal.swift index 1570868..6b1bcec 100644 --- a/Sources/Internal/QueryRefInternal.swift +++ b/Sources/Internal/QueryRefInternal.swift @@ -1,9 +1,16 @@ +// Copyright 2025 Google LLC // -// QueryRefInternal.swift -// FirebaseDataConnect +// 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 // -// Created by Aashish Patil on 9/29/25. +// 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. @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) protocol QueryRefInternal: QueryRef { diff --git a/Tests/Unit/CacheTests.swift b/Tests/Unit/CacheTests.swift index 5fceebb..e1c55bf 100644 --- a/Tests/Unit/CacheTests.swift +++ b/Tests/Unit/CacheTests.swift @@ -1,9 +1,16 @@ +// Copyright 2025 Google LLC // -// CacheTests.swift -// FirebaseDataConnect +// 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 // -// Created by Aashish Patil on 8/27/25. +// 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 Foundation import XCTest From 30940a68b133401dae698094a7002f770a400a61 Mon Sep 17 00:00:00 2001 From: Aashish Patil Date: Wed, 12 Nov 2025 15:24:05 -0800 Subject: [PATCH 28/38] Fix @available --- Sources/Cache/Cache.swift | 1 + Sources/Cache/CacheProvider.swift | 1 + Sources/Cache/DynamicCodingKey.swift | 1 + Sources/Cache/EntityDataObject.swift | 1 + Sources/Cache/EntityNode.swift | 1 + Sources/Cache/ResultTreeProcessor.swift | 2 ++ Sources/Cache/SQLiteCacheProvider.swift | 3 +++ Sources/Internal/HashUtils.swift | 2 ++ Sources/Internal/OperationsManager.swift | 2 +- 9 files changed, 13 insertions(+), 1 deletion(-) diff --git a/Sources/Cache/Cache.swift b/Sources/Cache/Cache.swift index 2b08771..065933c 100644 --- a/Sources/Cache/Cache.swift +++ b/Sources/Cache/Cache.swift @@ -18,6 +18,7 @@ import FirebaseAuth let GlobalIDKey: String = "_id" // Client cache that internally uses a CacheProvider to store content. +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) class Cache { let config: CacheSettings let dataConnect: DataConnect diff --git a/Sources/Cache/CacheProvider.swift b/Sources/Cache/CacheProvider.swift index 6a2dc05..b5bfc1d 100644 --- a/Sources/Cache/CacheProvider.swift +++ b/Sources/Cache/CacheProvider.swift @@ -19,6 +19,7 @@ import FirebaseCore // Key to store cache provider in Codables userInfo object. let CacheProviderUserInfoKey = CodingUserInfoKey(rawValue: "fdc_cache_provider")! +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) protocol CacheProvider { var cacheIdentifier: String { get } diff --git a/Sources/Cache/DynamicCodingKey.swift b/Sources/Cache/DynamicCodingKey.swift index 4b32b2c..62fcda7 100644 --- a/Sources/Cache/DynamicCodingKey.swift +++ b/Sources/Cache/DynamicCodingKey.swift @@ -13,6 +13,7 @@ // limitations under the License. // Used for inline inline hydration of entity values +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) struct DynamicCodingKey: CodingKey { var intValue: Int? let stringValue: String diff --git a/Sources/Cache/EntityDataObject.swift b/Sources/Cache/EntityDataObject.swift index 90f954f..c6907c0 100644 --- a/Sources/Cache/EntityDataObject.swift +++ b/Sources/Cache/EntityDataObject.swift @@ -20,6 +20,7 @@ struct ScalarField { } // Represents a normalized entity shared amongst queries. +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) class EntityDataObject: Codable { let guid: String // globally unique id received from server diff --git a/Sources/Cache/EntityNode.swift b/Sources/Cache/EntityNode.swift index 5ec6ae1..8dd5663 100644 --- a/Sources/Cache/EntityNode.swift +++ b/Sources/Cache/EntityNode.swift @@ -13,6 +13,7 @@ // limitations under the License. // Represents an object node in the ResultTree. +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) struct EntityNode { // externalized (normalized) data. // Requires an entity globalID to be provided in selection set diff --git a/Sources/Cache/ResultTreeProcessor.swift b/Sources/Cache/ResultTreeProcessor.swift index 0d31f7f..30f35a3 100644 --- a/Sources/Cache/ResultTreeProcessor.swift +++ b/Sources/Cache/ResultTreeProcessor.swift @@ -35,6 +35,7 @@ enum ResultTreeKind { // Class used to accumulate query refs as we dehydrate the tree and find EDOs // EDOs contain references to other QueryRefs that reference the EDO // We collect those QueryRefs here +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) class ImpactedQueryRefsAccumulator { // operationIds of impacted QueryRefs private(set) var queryRefIds: Set = [] @@ -62,6 +63,7 @@ class ImpactedQueryRefsAccumulator { // Dehydration (normalization) and hydration of the data // Hooks into the Codable process with userInfo flags driving what data gets encoded / decoded +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) struct ResultTreeProcessor { /* Go down the tree and convert them to entity nodes diff --git a/Sources/Cache/SQLiteCacheProvider.swift b/Sources/Cache/SQLiteCacheProvider.swift index 27c3689..96917b4 100644 --- a/Sources/Cache/SQLiteCacheProvider.swift +++ b/Sources/Cache/SQLiteCacheProvider.swift @@ -16,12 +16,14 @@ import FirebaseCore import Foundation import SQLite3 +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) private enum TableName { static let entityDataObjects = "entity_data" static let resultTree = "query_results" static let entityDataQueryRefs = "entity_data_query_refs" } +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) private enum ColumnName { static let entityId = "entity_guid" static let data = "data" @@ -29,6 +31,7 @@ private enum ColumnName { static let lastAccessed = "last_accessed" } +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) class SQLiteCacheProvider: CacheProvider { let cacheIdentifier: String diff --git a/Sources/Internal/HashUtils.swift b/Sources/Internal/HashUtils.swift index 8df3fb5..7985f3b 100644 --- a/Sources/Internal/HashUtils.swift +++ b/Sources/Internal/HashUtils.swift @@ -15,6 +15,7 @@ import CryptoKit import Foundation +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) extension Data { var sha256String: String { let hashDigest = SHA256.hash(data: self) @@ -23,6 +24,7 @@ extension Data { } } +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) extension String { var sha256: String { let digest = SHA256.hash(data: data(using: .utf8)!) diff --git a/Sources/Internal/OperationsManager.swift b/Sources/Internal/OperationsManager.swift index 858a372..de29973 100644 --- a/Sources/Internal/OperationsManager.swift +++ b/Sources/Internal/OperationsManager.swift @@ -52,7 +52,7 @@ class OperationsManager { } if publisher == .auto || publisher == .observableMacro { - if #available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) { + if #available(iOS 17, macOS 15, tvOS 17, watchOS 10, *) { let obsRef = QueryRefObservation( request: request, dataType: resultType, From 548298744d4d561cd26cd44cf401083ac01179af Mon Sep 17 00:00:00 2001 From: Aashish Patil Date: Wed, 12 Nov 2025 15:50:48 -0800 Subject: [PATCH 29/38] fix integration tests --- Tests/Integration/AnyScalarTests.swift | 22 +++++++++---------- Tests/Integration/IntegrationTests.swift | 28 ++++++++++++------------ 2 files changed, 25 insertions(+), 25 deletions(-) diff --git a/Tests/Integration/AnyScalarTests.swift b/Tests/Integration/AnyScalarTests.swift index 0759091..041d2f0 100644 --- a/Tests/Integration/AnyScalarTests.swift +++ b/Tests/Integration/AnyScalarTests.swift @@ -42,7 +42,7 @@ final class AnyScalarTests: IntegrationTestBase { let result = try await DataConnect.kitchenSinkConnector.getAnyValueTypeQuery.ref(id: anyValueId) .execute() - let anyValueResult = result.data.anyValueType?.props + let anyValueResult = result.data?.anyValueType?.props let decodedResult = try anyValueResult?.decodeValue(String.self) XCTAssertEqual(testData, decodedResult) @@ -60,7 +60,7 @@ final class AnyScalarTests: IntegrationTestBase { let result = try await DataConnect.kitchenSinkConnector.getAnyValueTypeQuery.ref(id: anyValueId) .execute() - let anyValueResult = result.data.anyValueType?.props + let anyValueResult = result.data?.anyValueType?.props let decodedResult = try anyValueResult?.decodeValue(Int.self) XCTAssertEqual(testNumber, decodedResult) @@ -79,7 +79,7 @@ final class AnyScalarTests: IntegrationTestBase { let result = try await DataConnect.kitchenSinkConnector.getAnyValueTypeQuery.ref(id: anyValueId) .execute() - let anyValueResult = result.data.anyValueType?.props + let anyValueResult = result.data?.anyValueType?.props let decodedResult = try anyValueResult?.decodeValue(Double.self) XCTAssertEqual(testDouble, decodedResult) @@ -97,7 +97,7 @@ final class AnyScalarTests: IntegrationTestBase { let result = try await DataConnect.kitchenSinkConnector.getAnyValueTypeQuery.ref(id: anyValueId) .execute() - let anyValueResult = result.data.anyValueType?.props + let anyValueResult = result.data?.anyValueType?.props let decodedResult = try anyValueResult?.decodeValue(Int64.self) XCTAssertEqual(testInt64, decodedResult) @@ -113,7 +113,7 @@ final class AnyScalarTests: IntegrationTestBase { let result = try await DataConnect.kitchenSinkConnector.getAnyValueTypeQuery.ref(id: anyValueId) .execute() - let anyValueResult = result.data.anyValueType?.props + let anyValueResult = result.data?.anyValueType?.props let decodedResult = try anyValueResult?.decodeValue(Int64.self) XCTAssertEqual(int64Max, decodedResult) @@ -129,7 +129,7 @@ final class AnyScalarTests: IntegrationTestBase { let result = try await DataConnect.kitchenSinkConnector.getAnyValueTypeQuery.ref(id: anyValueId) .execute() - let anyValueResult = result.data.anyValueType?.props + let anyValueResult = result.data?.anyValueType?.props let decodedResult = try anyValueResult?.decodeValue(Int64.self) XCTAssertEqual(int64Min, decodedResult) @@ -147,7 +147,7 @@ final class AnyScalarTests: IntegrationTestBase { let result = try await DataConnect.kitchenSinkConnector.getAnyValueTypeQuery.ref(id: anyValueId) .execute() - let anyValueResult = result.data.anyValueType?.props + let anyValueResult = result.data?.anyValueType?.props let decodedResult = try anyValueResult?.decodeValue(Double.self) XCTAssertEqual(testDouble, decodedResult) @@ -162,7 +162,7 @@ final class AnyScalarTests: IntegrationTestBase { let result = try await DataConnect.kitchenSinkConnector.getAnyValueTypeQuery.ref(id: anyValueId) .execute() - let anyValueResult = result.data.anyValueType?.props + let anyValueResult = result.data?.anyValueType?.props let decodedResult = try anyValueResult?.decodeValue(UUID.self) XCTAssertEqual(anyValueId, decodedResult) @@ -180,7 +180,7 @@ final class AnyScalarTests: IntegrationTestBase { let result = try await DataConnect.kitchenSinkConnector.getAnyValueTypeQuery.ref(id: anyValueId) .execute() - let anyValueResult = result.data.anyValueType?.props + let anyValueResult = result.data?.anyValueType?.props let decodedResult = try anyValueResult?.decodeValue(Double.self) XCTAssertEqual(testDouble, decodedResult) @@ -233,7 +233,7 @@ final class AnyScalarTests: IntegrationTestBase { let result = try await DataConnect.kitchenSinkConnector.getAnyValueTypeQuery.ref(id: anyValueId) .execute() - let anyValueResult = result.data.anyValueType?.props + let anyValueResult = result.data?.anyValueType?.props let decodedResult = try anyValueResult?.decodeValue(AnyValueTestStruct.self) XCTAssertEqual(structVal, decodedResult) @@ -255,7 +255,7 @@ final class AnyScalarTests: IntegrationTestBase { let result = try await DataConnect.kitchenSinkConnector.getAnyValueTypeQuery.execute( id: anyValueId ) - let anyValueResult = result.data.anyValueType?.props + let anyValueResult = result.data?.anyValueType?.props let decodedResult = try anyValueResult?.decodeValue([Double].self) XCTAssertEqual(intArray, decodedResult) diff --git a/Tests/Integration/IntegrationTests.swift b/Tests/Integration/IntegrationTests.swift index 90c40f5..e022de9 100644 --- a/Tests/Integration/IntegrationTests.swift +++ b/Tests/Integration/IntegrationTests.swift @@ -36,13 +36,13 @@ final class IntegrationTests: IntegrationTestBase { let result = try await DataConnect.kitchenSinkConnector.createTestIdMutation.execute( id: specifiedUUID ) - XCTAssertEqual(result.data.testId_insert.id, specifiedUUID) + XCTAssertEqual(result.data?.testId_insert.id, specifiedUUID) } // test for an auto generated UUID assignment func testAutoId() async throws { let result = try await DataConnect.kitchenSinkConnector.createTestAutoIdMutation.execute() - _ = result.data.testAutoId_insert.id + _ = result.data?.testAutoId_insert.id // if we get till here - we have successfully got a UUID and decoded it. So test is successful XCTAssert(true) } @@ -68,7 +68,7 @@ final class IntegrationTests: IntegrationTestBase { let dc = DataConnect.kitchenSinkConnector.dataConnect XCTAssertEqual( - executeResult.data.standardScalars_insert.id, + executeResult.data?.standardScalars_insert.id, standardScalarUUID, "UUID mismatch between specified and returned" ) @@ -76,21 +76,21 @@ final class IntegrationTests: IntegrationTestBase { let queryResult = try await DataConnect.kitchenSinkConnector .getStandardScalarQuery.execute(id: standardScalarUUID) - let returnedDecimal = queryResult.data.standardScalars?.decimal + let returnedDecimal = queryResult.data?.standardScalars?.decimal XCTAssertEqual( returnedDecimal, testDecimal, "Decimal value mismatch between sent \(testDecimal) and received \(String(describing: returnedDecimal))" ) - let returnedNumber = queryResult.data.standardScalars?.number + let returnedNumber = queryResult.data?.standardScalars?.number XCTAssertEqual( returnedNumber, testInt, "Int value mismatch between sent \(testInt) and received \(String(describing: returnedNumber))" ) - let returnedText = queryResult.data.standardScalars?.text + let returnedText = queryResult.data?.standardScalars?.text XCTAssertEqual( returnedText, testText, @@ -117,28 +117,28 @@ final class IntegrationTests: IntegrationTestBase { let queryResult = try await DataConnect.kitchenSinkConnector .getScalarBoundaryQuery.ref(id: scalaryBoundaryUUID).execute() - let returnedMaxInt = queryResult.data.scalarBoundary?.maxNumber + let returnedMaxInt = queryResult.data?.scalarBoundary?.maxNumber XCTAssertEqual( returnedMaxInt, maxInt, "Returned maxInt \(String(describing: returnedMaxInt)) is not same as sent \(maxInt)" ) - let returnedMinInt = queryResult.data.scalarBoundary?.minNumber + let returnedMinInt = queryResult.data?.scalarBoundary?.minNumber XCTAssertEqual( returnedMinInt, minInt, "Returned minInt \(minInt) is not same as sent \(minInt)" ) - let returnedMaxFloat = queryResult.data.scalarBoundary?.maxDecimal + let returnedMaxFloat = queryResult.data?.scalarBoundary?.maxDecimal XCTAssertEqual( returnedMaxFloat, maxFloat, "Returned maxFloat \(String(describing: returnedMaxFloat)) is not same as sent \(maxFloat)" ) - let returnedMinFloat = queryResult.data.scalarBoundary?.minDecimal + let returnedMinFloat = queryResult.data?.scalarBoundary?.minDecimal XCTAssertEqual( returnedMinFloat, minFloat, @@ -163,21 +163,21 @@ final class IntegrationTests: IntegrationTestBase { id: largeNumUUID ) - let returnedLargeNum = result.data.largeIntType?.num + let returnedLargeNum = result.data?.largeIntType?.num XCTAssertEqual( returnedLargeNum, largeNum, "Int64 returned \(String(describing: returnedLargeNum)) does not match sent \(largeNum)" ) - let returnedMax = result.data.largeIntType?.maxNum + let returnedMax = result.data?.largeIntType?.maxNum XCTAssertEqual( returnedMax, largeNumMax, "Int64 max returned \(String(describing: returnedMax)) does not match sent \(largeNumMax)" ) - let returnedMin = result.data.largeIntType?.minNum + let returnedMin = result.data?.largeIntType?.minNum XCTAssertEqual( returnedMin, largeNumMin, @@ -198,7 +198,7 @@ final class IntegrationTests: IntegrationTestBase { id: localDateUUID ) .execute() - let returnedLd = result.data.localDateType?.localDate + let returnedLd = result.data?.localDateType?.localDate XCTAssertEqual(ld, returnedLd) } } From 1a9aeb98a9b5d991f8ea65fab5c290d19fb80a7e Mon Sep 17 00:00:00 2001 From: Aashish Patil Date: Wed, 12 Nov 2025 22:37:01 -0800 Subject: [PATCH 30/38] Gemini Review part 1 --- Sources/Cache/Cache.swift | 12 +- Sources/Cache/EntityDataObject.swift | 5 - Sources/Cache/EntityNode.swift | 1 - Sources/Cache/ResultTreeProcessor.swift | 25 ++- Sources/Cache/SQLiteCacheProvider.swift | 194 +++++++++++++++--------- Sources/DataConnect.swift | 1 - Sources/Queries/GenericQueryRef.swift | 5 +- 7 files changed, 154 insertions(+), 89 deletions(-) diff --git a/Sources/Cache/Cache.swift b/Sources/Cache/Cache.swift index 065933c..88d8f73 100644 --- a/Sources/Cache/Cache.swift +++ b/Sources/Cache/Cache.swift @@ -80,7 +80,7 @@ class Cache { dispatchPrecondition(condition: .onQueue(queue)) let identifier = - "\(config.storage)-\(String(describing: dataConnect.app.options.projectID))-\(dataConnect.app.name)-\(dataConnect.connectorConfig.serviceId)-\(dataConnect.connectorConfig.connector)-\(dataConnect.connectorConfig.location)-\(Auth.auth(app: dataConnect.app).currentUser?.uid ?? "anon")-\(dataConnect.settings.host)" + "\(config.storage)-\( dataConnect.app.options.projectID!)-\(dataConnect.app.name)-\(dataConnect.connectorConfig.serviceId)-\(dataConnect.connectorConfig.connector)-\(dataConnect.connectorConfig.location)-\(Auth.auth(app: dataConnect.app).currentUser?.uid ?? "anon")-\(dataConnect.settings.host)" let encoded = identifier.sha256 DataConnectLogger.debug("Created Encoded Cache Identifier \(encoded) for \(identifier)") return encoded @@ -90,7 +90,13 @@ class Cache { // result trees are stored dehydrated in the cache // retrieve cache, hydrate it and then return queue.sync { - guard let dehydratedTree = cacheProvider?.resultTree(queryId: queryId) else { + + guard let cacheProvider else { + DataConnectLogger.error("CacheProvider is nil in the Cache") + return nil + } + + guard let dehydratedTree = cacheProvider.resultTree(queryId: queryId) else { return nil } @@ -98,7 +104,7 @@ class Cache { let resultsProcessor = ResultTreeProcessor() let (hydratedResults, rootObj) = try resultsProcessor.hydrateResults( dehydratedTree.data, - cacheProvider: cacheProvider! + cacheProvider: cacheProvider ) let hydratedTree = ResultTree( diff --git a/Sources/Cache/EntityDataObject.swift b/Sources/Cache/EntityDataObject.swift index c6907c0..bf9c8b5 100644 --- a/Sources/Cache/EntityDataObject.swift +++ b/Sources/Cache/EntityDataObject.swift @@ -14,11 +14,6 @@ import Foundation -struct ScalarField { - let name: String - let value: AnyCodableValue -} - // Represents a normalized entity shared amongst queries. @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) class EntityDataObject: Codable { diff --git a/Sources/Cache/EntityNode.swift b/Sources/Cache/EntityNode.swift index 8dd5663..b4a6b40 100644 --- a/Sources/Cache/EntityNode.swift +++ b/Sources/Cache/EntityNode.swift @@ -205,7 +205,6 @@ extension EntityNode: Encodable { } if resultTreeKind == .hydrated { - // var container = encoder.singleValueContainer() var container = encoder.container(keyedBy: DynamicCodingKey.self) if let entityData { diff --git a/Sources/Cache/ResultTreeProcessor.swift b/Sources/Cache/ResultTreeProcessor.swift index 30f35a3..e7fab55 100644 --- a/Sources/Cache/ResultTreeProcessor.swift +++ b/Sources/Cache/ResultTreeProcessor.swift @@ -83,13 +83,17 @@ struct ResultTreeProcessor { rootObject: EntityNode, impactedRefIds: [String] ) { + guard let hydratedData = hydratedTree.data(using: .utf8) else { + throw DataConnectCodecError.encodingFailed() + } + let jsonDecoder = JSONDecoder() let impactedRefsAccumulator = ImpactedQueryRefsAccumulator(requestor: requestor) jsonDecoder.userInfo[CacheProviderUserInfoKey] = cacheProvider jsonDecoder.userInfo[ResultTreeKindCodingKey] = ResultTreeKind.hydrated jsonDecoder.userInfo[ImpactedRefsAccumulatorCodingKey] = impactedRefsAccumulator - let enode = try jsonDecoder.decode(EntityNode.self, from: hydratedTree.data(using: .utf8)!) + let enode = try jsonDecoder.decode(EntityNode.self, from: hydratedData) DataConnectLogger .debug("Impacted QueryRefs count: \(impactedRefsAccumulator.queryRefIds.count)") @@ -99,7 +103,10 @@ struct ResultTreeProcessor { jsonEncoder.userInfo[CacheProviderUserInfoKey] = cacheProvider jsonEncoder.userInfo[ResultTreeKindCodingKey] = ResultTreeKind.dehydrated let jsonData = try jsonEncoder.encode(enode) - let dehydratedResultsString = String(data: jsonData, encoding: .utf8)! + + guard let dehydratedResultsString = String(data: jsonData, encoding: .utf8) else { + throw DataConnectCodecError.encodingFailed() + } DataConnectLogger .debug( @@ -111,17 +118,27 @@ struct ResultTreeProcessor { func hydrateResults(_ dehydratedTree: String, cacheProvider: CacheProvider) throws -> (hydratedResults: String, rootObject: EntityNode) { + + guard let dehydratedData = dehydratedTree.data(using: .utf8) else { + throw DataConnectCodecError + .decodingFailed(message: "Failed to convert dehydratedTree to Data") + } + let jsonDecoder = JSONDecoder() jsonDecoder.userInfo[CacheProviderUserInfoKey] = cacheProvider jsonDecoder.userInfo[ResultTreeKindCodingKey] = ResultTreeKind.dehydrated - let enode = try jsonDecoder.decode(EntityNode.self, from: dehydratedTree.data(using: .utf8)!) + let enode = try jsonDecoder.decode(EntityNode.self, from: dehydratedData) DataConnectLogger.debug("Dehydrated Tree decoded to EDO: \(enode)") let jsonEncoder = JSONEncoder() jsonEncoder.userInfo[CacheProviderUserInfoKey] = cacheProvider jsonEncoder.userInfo[ResultTreeKindCodingKey] = ResultTreeKind.hydrated + let hydratedResults = try jsonEncoder.encode(enode) - let hydratedResultsString = String(data: hydratedResults, encoding: .utf8)! + guard let hydratedResultsString = String(data: hydratedResults, encoding: .utf8) else { + throw DataConnectCodecError + .encodingFailed(message: "Failed to convert EDO to String") + } DataConnectLogger .debug( diff --git a/Sources/Cache/SQLiteCacheProvider.swift b/Sources/Cache/SQLiteCacheProvider.swift index 96917b4..c76e71e 100644 --- a/Sources/Cache/SQLiteCacheProvider.swift +++ b/Sources/Cache/SQLiteCacheProvider.swift @@ -47,12 +47,16 @@ class SQLiteCacheProvider: CacheProvider { try queue.sync { var dbIdentifier = ":memory:" if !ephemeral { - let path = NSSearchPathForDirectoriesInDomains( - .applicationSupportDirectory, - .userDomainMask, - true - ).first! - let dbURL = URL(fileURLWithPath: path).appendingPathComponent("\(cacheIdentifier).sqlite3") + guard let path = FileManager.default.urls( + for: .documentDirectory, + in: .userDomainMask + ) + .first else { + throw DataConnectInternalError.sqliteError( + message: "Could not find document directory." + ) + } + let dbURL = path.appendingPathComponent("\(cacheIdentifier).sqlite3") dbIdentifier = dbURL.path } if sqlite3_open(dbIdentifier, &db) != SQLITE_OK { @@ -67,7 +71,13 @@ class SQLiteCacheProvider: CacheProvider { .debug( "Opened database with db path/id \(dbIdentifier) and cache identifier \(cacheIdentifier)" ) - try createTables() + do { + try createTables() + } catch { + sqlite3_close(db) + db = nil + throw error + } } } @@ -124,10 +134,13 @@ class SQLiteCacheProvider: CacheProvider { "UPDATE \(TableName.resultTree) SET \(ColumnName.lastAccessed) = ? WHERE \(ColumnName.queryId) = ?;" var statement: OpaquePointer? - if sqlite3_prepare_v2(db, updateQuery, -1, &statement, nil) != SQLITE_OK { + guard sqlite3_prepare_v2(db, updateQuery, -1, &statement, nil) == SQLITE_OK else { DataConnectLogger.error("Error preparing update statement for result_tree") return } + defer { + sqlite3_finalize(statement) + } sqlite3_bind_double(statement, 1, Date().timeIntervalSince1970) sqlite3_bind_text(statement, 2, (queryId as NSString).utf8String, -1, nil) @@ -135,7 +148,6 @@ class SQLiteCacheProvider: CacheProvider { if sqlite3_step(statement) != SQLITE_DONE { DataConnectLogger.error("Error updating \(ColumnName.lastAccessed) for query \(queryId)") } - sqlite3_finalize(statement) } func resultTree(queryId: String) -> ResultTree? { @@ -144,10 +156,13 @@ class SQLiteCacheProvider: CacheProvider { "SELECT \(ColumnName.data) FROM \(TableName.resultTree) WHERE \(ColumnName.queryId) = ?;" var statement: OpaquePointer? - if sqlite3_prepare_v2(db, query, -1, &statement, nil) != SQLITE_OK { + guard sqlite3_prepare_v2(db, query, -1, &statement, nil) == SQLITE_OK else { DataConnectLogger.error("Error preparing select statement for \(TableName.resultTree)") return nil } + defer { + sqlite3_finalize(statement) + } sqlite3_bind_text(statement, 1, (queryId as NSString).utf8String, -1, nil) @@ -155,7 +170,6 @@ class SQLiteCacheProvider: CacheProvider { if let dataBlob = sqlite3_column_blob(statement, 0) { let dataBlobLength = sqlite3_column_bytes(statement, 0) let data = Data(bytes: dataBlob, count: Int(dataBlobLength)) - sqlite3_finalize(statement) do { let tree = try JSONDecoder().decode(ResultTree.self, from: data) self.updateLastAccessedTime(forQueryId: queryId) @@ -167,7 +181,6 @@ class SQLiteCacheProvider: CacheProvider { } } - sqlite3_finalize(statement) DataConnectLogger.debug("\(#function) no result tree found for queryId \(queryId)") return nil } @@ -176,21 +189,21 @@ class SQLiteCacheProvider: CacheProvider { func setResultTree(queryId: String, tree: ResultTree) { queue.sync { do { - var tree = tree let data = try JSONEncoder().encode(tree) let insert = "INSERT OR REPLACE INTO \(TableName.resultTree) (\(ColumnName.queryId), \(ColumnName.lastAccessed), \(ColumnName.data)) VALUES (?, ?, ?);" var statement: OpaquePointer? - if sqlite3_prepare_v2(db, insert, -1, &statement, nil) != SQLITE_OK { + guard sqlite3_prepare_v2(db, insert, -1, &statement, nil) == SQLITE_OK else { DataConnectLogger.error("Error preparing insert statement for \(TableName.resultTree)") return } - - tree.lastAccessed = Date() + defer { + sqlite3_finalize(statement) + } sqlite3_bind_text(statement, 1, (queryId as NSString).utf8String, -1, nil) - sqlite3_bind_double(statement, 2, tree.lastAccessed.timeIntervalSince1970) + sqlite3_bind_double(statement, 2, Date().timeIntervalSince1970) _ = data.withUnsafeBytes { (bytes: UnsafeRawBufferPointer) in sqlite3_bind_blob(statement, 3, bytes.baseAddress, Int32(bytes.count), nil) } @@ -199,8 +212,6 @@ class SQLiteCacheProvider: CacheProvider { DataConnectLogger.error("Error inserting result tree for queryId \(queryId)") } - sqlite3_finalize(statement) - DataConnectLogger.debug("\(#function) - query \(queryId), tree \(tree)") } catch { DataConnectLogger.error("Error encoding result tree for queryId \(queryId): \(error)") @@ -214,32 +225,39 @@ class SQLiteCacheProvider: CacheProvider { "SELECT \(ColumnName.data) FROM \(TableName.entityDataObjects) WHERE \(ColumnName.entityId) = ?;" var statement: OpaquePointer? - if sqlite3_prepare_v2(db, query, -1, &statement, nil) != SQLITE_OK { + guard sqlite3_prepare_v2(db, query, -1, &statement, nil) == SQLITE_OK else { DataConnectLogger .error("Error preparing select statement for \(TableName.entityDataObjects)") - } else { - sqlite3_bind_text(statement, 1, (entityGuid as NSString).utf8String, -1, nil) - - if sqlite3_step(statement) == SQLITE_ROW { - if let dataBlob = sqlite3_column_blob(statement, 0) { - let dataBlobLength = sqlite3_column_bytes(statement, 0) - let data = Data(bytes: dataBlob, count: Int(dataBlobLength)) - sqlite3_finalize(statement) - do { - let edo = try JSONDecoder().decode(EntityDataObject.self, from: data) - DataConnectLogger.debug("Returning existing EDO for \(entityGuid)") - - let referencedQueryIds = _readQueryRefs(entityGuid: entityGuid) - edo.updateReferencedFrom(Set(referencedQueryIds)) - return edo - } catch { - DataConnectLogger.error( - "Error decoding data object for entityGuid \(entityGuid): \(error)" - ) - } + // if we reach here it means we don't have a EDO in our database. + // So we create one. + let edo = EntityDataObject(guid: entityGuid) + _setObject(entityGuid: entityGuid, object: edo) + DataConnectLogger.debug("Created EDO for \(entityGuid)") + return edo + } + defer { + sqlite3_finalize(statement) + } + + sqlite3_bind_text(statement, 1, (entityGuid as NSString).utf8String, -1, nil) + + if sqlite3_step(statement) == SQLITE_ROW { + if let dataBlob = sqlite3_column_blob(statement, 0) { + let dataBlobLength = sqlite3_column_bytes(statement, 0) + let data = Data(bytes: dataBlob, count: Int(dataBlobLength)) + do { + let edo = try JSONDecoder().decode(EntityDataObject.self, from: data) + DataConnectLogger.debug("Returning existing EDO for \(entityGuid)") + + let referencedQueryIds = _readQueryRefs(entityGuid: entityGuid) + edo.updateReferencedFrom(Set(referencedQueryIds)) + return edo + } catch { + DataConnectLogger.error( + "Error decoding data object for entityGuid \(entityGuid): \(error)" + ) } } - sqlite3_finalize(statement) } // if we reach here it means we don't have a EDO in our database. @@ -268,10 +286,13 @@ class SQLiteCacheProvider: CacheProvider { "INSERT OR REPLACE INTO \(TableName.entityDataObjects) (\(ColumnName.entityId), \(ColumnName.data)) VALUES (?, ?);" var statement: OpaquePointer? - if sqlite3_prepare_v2(db, insert, -1, &statement, nil) != SQLITE_OK { + guard sqlite3_prepare_v2(db, insert, -1, &statement, nil) == SQLITE_OK else { DataConnectLogger.error("Error preparing insert statement for entity_data") return } + defer { + sqlite3_finalize(statement) + } sqlite3_bind_text(statement, 1, (entityGuid as NSString).utf8String, -1, nil) _ = data.withUnsafeBytes { (bytes: UnsafeRawBufferPointer) in @@ -282,14 +303,12 @@ class SQLiteCacheProvider: CacheProvider { DataConnectLogger.error("Error inserting data object for entityGuid \(entityGuid)") } - sqlite3_finalize(statement) - - // update references - _updateQueryRefs(object: object) - } catch { DataConnectLogger.error("Error encoding data object for entityGuid \(entityGuid): \(error)") } + + // update references + _updateQueryRefs(object: object) } private func _updateQueryRefs(object: EntityDataObject) { @@ -298,49 +317,78 @@ class SQLiteCacheProvider: CacheProvider { guard object.isReferencedFromAnyQueryRef else { return } - var insertReferences = - "INSERT OR REPLACE INTO \(TableName.entityDataQueryRefs) (\(ColumnName.entityId), \(ColumnName.queryId)) VALUES " - for queryId in object.referencedFromRefs() { - insertReferences += "('\(object.guid)', '\(queryId)'), " - } - insertReferences.removeLast(2) - insertReferences += ";" - + + let sql = "INSERT OR REPLACE INTO \(TableName.entityDataQueryRefs) (\(ColumnName.entityId), \(ColumnName.queryId)) VALUES (?, ?);" var statementRefs: OpaquePointer? - if sqlite3_prepare_v2(db, insertReferences, -1, &statementRefs, nil) != SQLITE_OK { + + guard sqlite3_prepare_v2(db, sql, -1, &statementRefs, nil) == SQLITE_OK else { DataConnectLogger.error("Error preparing insert statement for entity_data_query_refs") return } + defer { + sqlite3_finalize(statementRefs) + } - if sqlite3_step(statementRefs) != SQLITE_DONE { - DataConnectLogger.error( - "Error inserting data object references for entityGuid \(object.guid)" - ) + let SQLITE_TRANSIENT = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + + sqlite3_exec(db, "BEGIN TRANSACTION", nil, nil, nil) + + var success = true + let entityGuid = object.guid + for queryId in object.referencedFromRefs() { + guard sqlite3_bind_text( + statementRefs, + 1, + entityGuid, + -1, + SQLITE_TRANSIENT + ) == SQLITE_OK, + sqlite3_bind_text(statementRefs, 2, queryId, -1, SQLITE_TRANSIENT) == SQLITE_OK + else { + DataConnectLogger.error("Error binding parameters for entity_data_query_refs") + success = false + break + } + + if sqlite3_step(statementRefs) != SQLITE_DONE { + DataConnectLogger + .error("Error inserting data object references for entityGuid \(entityGuid)") + success = false + break + } + + sqlite3_reset(statementRefs) } - sqlite3_finalize(statementRefs) + if success { + sqlite3_exec(db, "COMMIT TRANSACTION", nil, nil, nil) + } else { + sqlite3_exec(db, "ROLLBACK TRANSACTION", nil, nil, nil) + } } private func _readQueryRefs(entityGuid: String) -> [String] { let readRefs = - "SELECT \(ColumnName.queryId) FROM \(TableName.entityDataQueryRefs) WHERE \(ColumnName.entityId) = '\(entityGuid)'" + "SELECT \(ColumnName.queryId) FROM \(TableName.entityDataQueryRefs) WHERE \(ColumnName.entityId) = ?" var statementRefs: OpaquePointer? var queryIds: [String] = [] - if sqlite3_prepare_v2(db, readRefs, -1, &statementRefs, nil) == SQLITE_OK { - while sqlite3_step(statementRefs) == SQLITE_ROW { - if let cString = sqlite3_column_text(statementRefs, 0) { - queryIds.append(String(cString: cString)) - } - } - + guard sqlite3_prepare_v2(db, readRefs, -1, &statementRefs, nil) == SQLITE_OK else { + DataConnectLogger.error("Error preparing select statement for \(TableName.entityDataQueryRefs)") + return [] + } + defer { sqlite3_finalize(statementRefs) + } - return queryIds - } else { - DataConnectLogger.error("Error reading query references for \(entityGuid)") + sqlite3_bind_text(statementRefs, 1, (entityGuid as NSString).utf8String, -1, nil) + + while sqlite3_step(statementRefs) == SQLITE_ROW { + if let cString = sqlite3_column_text(statementRefs, 0) { + queryIds.append(String(cString: cString)) + } } - return [] + return queryIds } } diff --git a/Sources/DataConnect.swift b/Sources/DataConnect.swift index 26b3455..fcd0ba2 100644 --- a/Sources/DataConnect.swift +++ b/Sources/DataConnect.swift @@ -92,7 +92,6 @@ public class DataConnect { callerSDKType: callerSDKType ) - // TODO: Change this if let cache { self.cache = Cache(config: cache.config, dataConnect: self) } diff --git a/Sources/Queries/GenericQueryRef.swift b/Sources/Queries/GenericQueryRef.swift index 07cb3c7..5ee0853 100644 --- a/Sources/Queries/GenericQueryRef.swift +++ b/Sources/Queries/GenericQueryRef.swift @@ -54,9 +54,10 @@ actor GenericQueryRef Date: Wed, 12 Nov 2025 22:39:25 -0800 Subject: [PATCH 31/38] formatting fixes --- Sources/Cache/Cache.swift | 5 ++--- Sources/Cache/ResultTreeProcessor.swift | 23 +++++++++++------------ Sources/Cache/SQLiteCacheProvider.swift | 14 ++++++++------ Sources/Queries/GenericQueryRef.swift | 7 ++++++- 4 files changed, 27 insertions(+), 22 deletions(-) diff --git a/Sources/Cache/Cache.swift b/Sources/Cache/Cache.swift index 88d8f73..e9fc2dd 100644 --- a/Sources/Cache/Cache.swift +++ b/Sources/Cache/Cache.swift @@ -80,7 +80,7 @@ class Cache { dispatchPrecondition(condition: .onQueue(queue)) let identifier = - "\(config.storage)-\( dataConnect.app.options.projectID!)-\(dataConnect.app.name)-\(dataConnect.connectorConfig.serviceId)-\(dataConnect.connectorConfig.connector)-\(dataConnect.connectorConfig.location)-\(Auth.auth(app: dataConnect.app).currentUser?.uid ?? "anon")-\(dataConnect.settings.host)" + "\(config.storage)-\(dataConnect.app.options.projectID!)-\(dataConnect.app.name)-\(dataConnect.connectorConfig.serviceId)-\(dataConnect.connectorConfig.connector)-\(dataConnect.connectorConfig.location)-\(Auth.auth(app: dataConnect.app).currentUser?.uid ?? "anon")-\(dataConnect.settings.host)" let encoded = identifier.sha256 DataConnectLogger.debug("Created Encoded Cache Identifier \(encoded) for \(identifier)") return encoded @@ -90,12 +90,11 @@ class Cache { // result trees are stored dehydrated in the cache // retrieve cache, hydrate it and then return queue.sync { - guard let cacheProvider else { DataConnectLogger.error("CacheProvider is nil in the Cache") return nil } - + guard let dehydratedTree = cacheProvider.resultTree(queryId: queryId) else { return nil } diff --git a/Sources/Cache/ResultTreeProcessor.swift b/Sources/Cache/ResultTreeProcessor.swift index e7fab55..c587a64 100644 --- a/Sources/Cache/ResultTreeProcessor.swift +++ b/Sources/Cache/ResultTreeProcessor.swift @@ -86,7 +86,7 @@ struct ResultTreeProcessor { guard let hydratedData = hydratedTree.data(using: .utf8) else { throw DataConnectCodecError.encodingFailed() } - + let jsonDecoder = JSONDecoder() let impactedRefsAccumulator = ImpactedQueryRefsAccumulator(requestor: requestor) @@ -103,7 +103,7 @@ struct ResultTreeProcessor { jsonEncoder.userInfo[CacheProviderUserInfoKey] = cacheProvider jsonEncoder.userInfo[ResultTreeKindCodingKey] = ResultTreeKind.dehydrated let jsonData = try jsonEncoder.encode(enode) - + guard let dehydratedResultsString = String(data: jsonData, encoding: .utf8) else { throw DataConnectCodecError.encodingFailed() } @@ -118,12 +118,11 @@ struct ResultTreeProcessor { func hydrateResults(_ dehydratedTree: String, cacheProvider: CacheProvider) throws -> (hydratedResults: String, rootObject: EntityNode) { - - guard let dehydratedData = dehydratedTree.data(using: .utf8) else { - throw DataConnectCodecError - .decodingFailed(message: "Failed to convert dehydratedTree to Data") - } - + guard let dehydratedData = dehydratedTree.data(using: .utf8) else { + throw DataConnectCodecError + .decodingFailed(message: "Failed to convert dehydratedTree to Data") + } + let jsonDecoder = JSONDecoder() jsonDecoder.userInfo[CacheProviderUserInfoKey] = cacheProvider jsonDecoder.userInfo[ResultTreeKindCodingKey] = ResultTreeKind.dehydrated @@ -135,10 +134,10 @@ struct ResultTreeProcessor { jsonEncoder.userInfo[ResultTreeKindCodingKey] = ResultTreeKind.hydrated let hydratedResults = try jsonEncoder.encode(enode) - guard let hydratedResultsString = String(data: hydratedResults, encoding: .utf8) else { - throw DataConnectCodecError - .encodingFailed(message: "Failed to convert EDO to String") - } + guard let hydratedResultsString = String(data: hydratedResults, encoding: .utf8) else { + throw DataConnectCodecError + .encodingFailed(message: "Failed to convert EDO to String") + } DataConnectLogger .debug( diff --git a/Sources/Cache/SQLiteCacheProvider.swift b/Sources/Cache/SQLiteCacheProvider.swift index c76e71e..ee2f777 100644 --- a/Sources/Cache/SQLiteCacheProvider.swift +++ b/Sources/Cache/SQLiteCacheProvider.swift @@ -306,7 +306,7 @@ class SQLiteCacheProvider: CacheProvider { } catch { DataConnectLogger.error("Error encoding data object for entityGuid \(entityGuid): \(error)") } - + // update references _updateQueryRefs(object: object) } @@ -317,8 +317,9 @@ class SQLiteCacheProvider: CacheProvider { guard object.isReferencedFromAnyQueryRef else { return } - - let sql = "INSERT OR REPLACE INTO \(TableName.entityDataQueryRefs) (\(ColumnName.entityId), \(ColumnName.queryId)) VALUES (?, ?);" + + let sql = + "INSERT OR REPLACE INTO \(TableName.entityDataQueryRefs) (\(ColumnName.entityId), \(ColumnName.queryId)) VALUES (?, ?);" var statementRefs: OpaquePointer? guard sqlite3_prepare_v2(db, sql, -1, &statementRefs, nil) == SQLITE_OK else { @@ -330,7 +331,7 @@ class SQLiteCacheProvider: CacheProvider { } let SQLITE_TRANSIENT = unsafeBitCast(-1, to: sqlite3_destructor_type.self) - + sqlite3_exec(db, "BEGIN TRANSACTION", nil, nil, nil) var success = true @@ -356,7 +357,7 @@ class SQLiteCacheProvider: CacheProvider { success = false break } - + sqlite3_reset(statementRefs) } @@ -374,7 +375,8 @@ class SQLiteCacheProvider: CacheProvider { var queryIds: [String] = [] guard sqlite3_prepare_v2(db, readRefs, -1, &statementRefs, nil) == SQLITE_OK else { - DataConnectLogger.error("Error preparing select statement for \(TableName.entityDataQueryRefs)") + DataConnectLogger + .error("Error preparing select statement for \(TableName.entityDataQueryRefs)") return [] } defer { diff --git a/Sources/Queries/GenericQueryRef.swift b/Sources/Queries/GenericQueryRef.swift index 5ee0853..8fdfaf3 100644 --- a/Sources/Queries/GenericQueryRef.swift +++ b/Sources/Queries/GenericQueryRef.swift @@ -56,7 +56,12 @@ actor GenericQueryRef Date: Wed, 12 Nov 2025 22:57:05 -0800 Subject: [PATCH 32/38] Convert sync queue calls to actor --- Sources/Cache/Cache.swift | 146 ++++++++++++-------------- Sources/Queries/GenericQueryRef.swift | 4 +- 2 files changed, 69 insertions(+), 81 deletions(-) diff --git a/Sources/Cache/Cache.swift b/Sources/Cache/Cache.swift index e9fc2dd..29a4eee 100644 --- a/Sources/Cache/Cache.swift +++ b/Sources/Cache/Cache.swift @@ -19,14 +19,12 @@ let GlobalIDKey: String = "_id" // Client cache that internally uses a CacheProvider to store content. @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -class Cache { +actor Cache { let config: CacheSettings let dataConnect: DataConnect private var cacheProvider: CacheProvider? - private let queue = DispatchQueue(label: "com.google.firebase.dataconnect.cache") - // holding it to avoid dealloc private var authChangeListenerProtocol: NSObjectProtocol? @@ -35,14 +33,14 @@ class Cache { self.dataConnect = dataConnect // sync because we want the provider initialized immediately when in init - queue.sync { - self.initializeCacheProvider() - setupChangeListeners() + Task { + await initializeCacheProvider() + await setupChangeListeners() } } private func initializeCacheProvider() { - dispatchPrecondition(condition: .onQueue(queue)) + // dispatchPrecondition(condition: .onQueue(queue)) let identifier = contructCacheIdentifier() @@ -66,19 +64,13 @@ class Cache { } private func setupChangeListeners() { - dispatchPrecondition(condition: .onQueue(queue)) - authChangeListenerProtocol = Auth.auth(app: dataConnect.app).addStateDidChangeListener { _, _ in - self.queue.async(flags: .barrier) { - self.initializeCacheProvider() - } + self.initializeCacheProvider() } } // Create an identifier for the cache that the Provider will use for cache scoping private func contructCacheIdentifier() -> String { - dispatchPrecondition(condition: .onQueue(queue)) - let identifier = "\(config.storage)-\(dataConnect.app.options.projectID!)-\(dataConnect.app.name)-\(dataConnect.connectorConfig.serviceId)-\(dataConnect.connectorConfig.connector)-\(dataConnect.connectorConfig.location)-\(Auth.auth(app: dataConnect.app).currentUser?.uid ?? "anon")-\(dataConnect.settings.host)" let encoded = identifier.sha256 @@ -89,84 +81,80 @@ class Cache { func resultTree(queryId: String) -> ResultTree? { // result trees are stored dehydrated in the cache // retrieve cache, hydrate it and then return - queue.sync { - guard let cacheProvider else { - DataConnectLogger.error("CacheProvider is nil in the Cache") - return nil - } - - guard let dehydratedTree = cacheProvider.resultTree(queryId: queryId) else { - return nil - } - - do { - let resultsProcessor = ResultTreeProcessor() - let (hydratedResults, rootObj) = try resultsProcessor.hydrateResults( - dehydratedTree.data, - cacheProvider: cacheProvider - ) + guard let cacheProvider else { + DataConnectLogger.error("CacheProvider is nil in the Cache") + return nil + } - let hydratedTree = ResultTree( - data: hydratedResults, - ttl: dehydratedTree.ttl, - cachedAt: dehydratedTree.cachedAt, - lastAccessed: dehydratedTree.lastAccessed, - rootObject: rootObj - ) + guard let dehydratedTree = cacheProvider.resultTree(queryId: queryId) else { + return nil + } - return hydratedTree - } catch { - DataConnectLogger.warning("Error decoding result tree \(error)") - return nil - } + do { + let resultsProcessor = ResultTreeProcessor() + let (hydratedResults, rootObj) = try resultsProcessor.hydrateResults( + dehydratedTree.data, + cacheProvider: cacheProvider + ) + + let hydratedTree = ResultTree( + data: hydratedResults, + ttl: dehydratedTree.ttl, + cachedAt: dehydratedTree.cachedAt, + lastAccessed: dehydratedTree.lastAccessed, + rootObject: rootObj + ) + + return hydratedTree + } catch { + DataConnectLogger.warning("Error decoding result tree \(error)") + return nil } } func update(queryId: String, response: ServerResponse, requestor: (any QueryRefInternal)? = nil) { // server response contains hydrated trees // dehydrate (normalize) the results and store dehydrated trees - queue.async(flags: .barrier) { - guard let cacheProvider = self.cacheProvider else { - DataConnectLogger - .debug("Cache provider not initialized yet. Skipping update for \(queryId)") - return - } - do { - let processor = ResultTreeProcessor() - let (dehydratedResults, rootObj, impactedRefs) = try processor.dehydrateResults( - response.jsonResults, - cacheProvider: cacheProvider, - requestor: requestor - ) - - cacheProvider - .setResultTree( - queryId: queryId, - tree: .init( - data: dehydratedResults, - ttl: response.ttl, - cachedAt: Date(), - lastAccessed: Date(), - rootObject: rootObj - ) + guard let cacheProvider = cacheProvider else { + DataConnectLogger + .debug("Cache provider not initialized yet. Skipping update for \(queryId)") + return + } + do { + let processor = ResultTreeProcessor() + let (dehydratedResults, rootObj, impactedRefs) = try processor.dehydrateResults( + response.jsonResults, + cacheProvider: cacheProvider, + requestor: requestor + ) + + cacheProvider + .setResultTree( + queryId: queryId, + tree: .init( + data: dehydratedResults, + ttl: response.ttl, + cachedAt: Date(), + lastAccessed: Date(), + rootObject: rootObj ) + ) - for refId in impactedRefs { - guard let q = self.dataConnect.queryRef(for: refId) as? (any QueryRefInternal) else { - continue - } - Task { - do { - try await q.publishCacheResultsToSubscribers(allowStale: true) - } catch { - DataConnectLogger - .warning("Error republishing cached results for impacted queryrefs \(error))") - } + for refId in impactedRefs { + guard let q = dataConnect.queryRef(for: refId) as? (any QueryRefInternal) else { + continue + } + Task { + do { + try await q.publishCacheResultsToSubscribers(allowStale: true) + } catch { + DataConnectLogger + .warning("Error republishing cached results for impacted queryrefs \(error))") } } - } catch { - DataConnectLogger.warning("Error updating cache for \(queryId): \(error)") } + } catch { + DataConnectLogger.warning("Error updating cache for \(queryId): \(error)") } } } diff --git a/Sources/Queries/GenericQueryRef.swift b/Sources/Queries/GenericQueryRef.swift index 8fdfaf3..6de8ac8 100644 --- a/Sources/Queries/GenericQueryRef.swift +++ b/Sources/Queries/GenericQueryRef.swift @@ -97,7 +97,7 @@ actor GenericQueryRef Date: Thu, 13 Nov 2025 00:21:45 -0800 Subject: [PATCH 33/38] comments --- Sources/Cache/Cache.swift | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/Sources/Cache/Cache.swift b/Sources/Cache/Cache.swift index 29a4eee..90523aa 100644 --- a/Sources/Cache/Cache.swift +++ b/Sources/Cache/Cache.swift @@ -32,7 +32,8 @@ actor Cache { self.config = config self.dataConnect = dataConnect - // sync because we want the provider initialized immediately when in init + // this is a potential race since update or get could get scheduled before initialize + // workarounds are complex since caller DataConnect APIs aren't async Task { await initializeCacheProvider() await setupChangeListeners() @@ -40,8 +41,6 @@ actor Cache { } private func initializeCacheProvider() { - // dispatchPrecondition(condition: .onQueue(queue)) - let identifier = contructCacheIdentifier() // Create a cacheProvider if - From 517fdcdf80998e072cf04da2925882aa9420182d Mon Sep 17 00:00:00 2001 From: Aashish Patil Date: Tue, 18 Nov 2025 15:52:10 -0800 Subject: [PATCH 34/38] Nick feedback --- Sources/Cache/ResultTree.swift | 37 ++++++++---------------- Sources/Cache/ResultTreeProcessor.swift | 15 ++++------ Sources/Cache/SQLiteCacheProvider.swift | 1 - Sources/Internal/OperationsManager.swift | 2 +- Sources/MutationRef.swift | 8 ++--- Sources/OperationResult.swift | 2 +- Sources/Queries/GenericQueryRef.swift | 3 -- Sources/Queries/ObservableQueryRef.swift | 11 +++---- Sources/Queries/QueryRef.swift | 3 -- Sources/Queries/QueryRequest.swift | 18 ++---------- Sources/Scalars/AnyValue.swift | 2 -- Tests/Unit/CacheTests.swift | 22 +++++--------- 12 files changed, 38 insertions(+), 86 deletions(-) diff --git a/Sources/Cache/ResultTree.swift b/Sources/Cache/ResultTree.swift index 5b70ba5..9e6286f 100644 --- a/Sources/Cache/ResultTree.swift +++ b/Sources/Cache/ResultTree.swift @@ -14,14 +14,19 @@ import Foundation -import FirebaseCore - @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -struct ResultTree { - let data: String // tree data - could be hydrated or dehydrated. - let ttl: TimeInterval // interval during which query results are considered fresh - let cachedAt: Date // Local time when the entry was cached / updated - var lastAccessed: Date // Local time when the entry was read or updated +struct ResultTree: Codable { + // tree data - could be hydrated or dehydrated. + let data: String + + // interval during which query results are considered fresh + let ttl: TimeInterval + + // Local time when the entry was cached / updated + let cachedAt: Date + + // Local time when the entry was read or updated + var lastAccessed: Date var rootObject: EntityNode? @@ -37,21 +42,3 @@ struct ResultTree { case data = "d" // data cached } } - -extension ResultTree: Codable { - init(from decoder: any Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - cachedAt = try container.decode(Date.self, forKey: .cachedAt) - lastAccessed = try container.decode(Date.self, forKey: .lastAccessed) - ttl = try container.decode(TimeInterval.self, forKey: .ttl) - data = try container.decode(String.self, forKey: .data) - } - - func encode(to encoder: any Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(cachedAt, forKey: .cachedAt) - try container.encode(lastAccessed, forKey: .lastAccessed) - try container.encode(ttl, forKey: .ttl) - try container.encode(data, forKey: .data) - } -} diff --git a/Sources/Cache/ResultTreeProcessor.swift b/Sources/Cache/ResultTreeProcessor.swift index c587a64..61aeb9b 100644 --- a/Sources/Cache/ResultTreeProcessor.swift +++ b/Sources/Cache/ResultTreeProcessor.swift @@ -66,17 +66,9 @@ class ImpactedQueryRefsAccumulator { @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) struct ResultTreeProcessor { /* - Go down the tree and convert them to entity nodes - For each Node - - extract globalID - - Get the EDO for the globalID - - extract scalars and update EDO with scalars - - for each array - - recursively process each object (could be scalar or composite) - - for composite objects (dictionaries), create references to their node - - create a Node and init it with dictionary. + Takes a JSON tree with data and normalizes the entities contained in it, + creating a resultant JSON tree with references to entities. */ - func dehydrateResults(_ hydratedTree: String, cacheProvider: CacheProvider, requestor: (any QueryRefInternal)? = nil) throws -> ( dehydratedResults: String, @@ -116,6 +108,9 @@ struct ResultTreeProcessor { return (dehydratedResultsString, enode, impactedRefs) } + /* + Takes a dehydrated tree and fills it up with Entity data from referenced entities. + */ func hydrateResults(_ dehydratedTree: String, cacheProvider: CacheProvider) throws -> (hydratedResults: String, rootObject: EntityNode) { guard let dehydratedData = dehydratedTree.data(using: .utf8) else { diff --git a/Sources/Cache/SQLiteCacheProvider.swift b/Sources/Cache/SQLiteCacheProvider.swift index ee2f777..3ecb9dc 100644 --- a/Sources/Cache/SQLiteCacheProvider.swift +++ b/Sources/Cache/SQLiteCacheProvider.swift @@ -12,7 +12,6 @@ // See the License for the specific language governing permissions and // limitations under the License. -import FirebaseCore import Foundation import SQLite3 diff --git a/Sources/Internal/OperationsManager.swift b/Sources/Internal/OperationsManager.swift index de29973..858a372 100644 --- a/Sources/Internal/OperationsManager.swift +++ b/Sources/Internal/OperationsManager.swift @@ -52,7 +52,7 @@ class OperationsManager { } if publisher == .auto || publisher == .observableMacro { - if #available(iOS 17, macOS 15, tvOS 17, watchOS 10, *) { + if #available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) { let obsRef = QueryRefObservation( request: request, dataType: resultType, diff --git a/Sources/MutationRef.swift b/Sources/MutationRef.swift index d00e38e..ee100e8 100644 --- a/Sources/MutationRef.swift +++ b/Sources/MutationRef.swift @@ -31,18 +31,18 @@ public class MutationRef< ResultData: Decodable & Sendable, Variable: OperationVariable >: OperationRef { - private var request: any OperationRequest + private var request: MutationRequest private var grpcClient: GrpcClient - init(request: any OperationRequest, grpcClient: GrpcClient) { + init(request: MutationRequest, grpcClient: GrpcClient) { self.request = request self.grpcClient = grpcClient } public func execute() async throws -> OperationResult { let results = try await grpcClient.executeMutation( - request: request as! MutationRequest, + request: request, resultType: ResultData.self ) return results @@ -54,6 +54,6 @@ public class MutationRef< public static func == (lhs: MutationRef, rhs: MutationRef) -> Bool { - return lhs.request as? MutationRequest == rhs.request as? MutationRequest + return lhs.request == rhs.request } } diff --git a/Sources/OperationResult.swift b/Sources/OperationResult.swift index 550447e..d6d22b1 100644 --- a/Sources/OperationResult.swift +++ b/Sources/OperationResult.swift @@ -14,7 +14,7 @@ import Foundation -/// Struct returned by operation calls - query or mutation +/// Structure representing the value returned by operation calls - query or mutation @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) public struct OperationResult: Sendable { public let data: ResultData? diff --git a/Sources/Queries/GenericQueryRef.swift b/Sources/Queries/GenericQueryRef.swift index 6de8ac8..3fe207a 100644 --- a/Sources/Queries/GenericQueryRef.swift +++ b/Sources/Queries/GenericQueryRef.swift @@ -12,11 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. -import CryptoKit import Foundation -import Firebase - @preconcurrency import Combine import Observation diff --git a/Sources/Queries/ObservableQueryRef.swift b/Sources/Queries/ObservableQueryRef.swift index 86ebea9..e0a6db5 100644 --- a/Sources/Queries/ObservableQueryRef.swift +++ b/Sources/Queries/ObservableQueryRef.swift @@ -12,11 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. -import CryptoKit import Foundation -import Firebase - @preconcurrency import Combine import Observation @@ -154,7 +151,7 @@ extension QueryRefObservableObject: QueryRefInternal { /// - ``data``: Published variable that contains bindable results of the query. /// - ``lastError``: Published variable that contains ``DataConnectError`` if last fetch had error. /// If last fetch was successful, this variable is cleared -@available(macOS 15, iOS 17, tvOS 17, watchOS 10, *) +@available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) @Observable public class QueryRefObservation< ResultData: Decodable & Sendable, @@ -232,7 +229,7 @@ public class QueryRefObservation< } } -@available(macOS 15, iOS 17, tvOS 17, watchOS 10, *) +@available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) public extension QueryRefObservation { nonisolated func hash(into hasher: inout Hasher) { hasher.combine(baseRef) @@ -243,14 +240,14 @@ public extension QueryRefObservation { } } -@available(macOS 15, iOS 17, tvOS 17, watchOS 10, *) +@available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) extension QueryRefObservation: CustomStringConvertible { public nonisolated var description: String { "QueryRefObservation(\(String(describing: baseRef)))" } } -@available(macOS 15, iOS 17, tvOS 17, watchOS 10, *) +@available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) extension QueryRefObservation: QueryRefInternal { func publishServerResultsToSubscribers() async throws { try await baseRef.publishServerResultsToSubscribers() diff --git a/Sources/Queries/QueryRef.swift b/Sources/Queries/QueryRef.swift index f07e597..d62ae48 100644 --- a/Sources/Queries/QueryRef.swift +++ b/Sources/Queries/QueryRef.swift @@ -12,11 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. -import CryptoKit import Foundation -import Firebase - @preconcurrency import Combine import Observation diff --git a/Sources/Queries/QueryRequest.swift b/Sources/Queries/QueryRequest.swift index f61cf18..cf327a8 100644 --- a/Sources/Queries/QueryRequest.swift +++ b/Sources/Queries/QueryRequest.swift @@ -12,11 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. -import CryptoKit import Foundation -import Firebase - /// A QueryRequest is used to get a QueryRef to a Data Connect query using the specified query name /// and input variables to the query @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) @@ -51,7 +48,8 @@ struct QueryRequest: OperationRequest, Hashable, Eq self.variables = variables } - // Hashable and Equatable implementation + // MARK: - Hashable and Equatable implementation + func hash(into hasher: inout Hasher) { hasher.combine(operationName) if let variables { @@ -64,16 +62,6 @@ struct QueryRequest: OperationRequest, Hashable, Eq return false } - if lhs.variables == nil && rhs.variables == nil { - return true - } - - guard let lhsVar = lhs.variables, - let rhsVar = rhs.variables, - lhsVar == rhsVar else { - return false - } - - return true + return lhs.variables == rhs.variables } } diff --git a/Sources/Scalars/AnyValue.swift b/Sources/Scalars/AnyValue.swift index 2a89f6f..a9fcfdb 100644 --- a/Sources/Scalars/AnyValue.swift +++ b/Sources/Scalars/AnyValue.swift @@ -14,8 +14,6 @@ import Foundation -import FirebaseCore - /// AnyValue represents the Any graphql scalar, which represents Codable data - scalar data (Int, /// Double, String, Bool,...) or a JSON object @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) diff --git a/Tests/Unit/CacheTests.swift b/Tests/Unit/CacheTests.swift index e1c55bf..591e087 100644 --- a/Tests/Unit/CacheTests.swift +++ b/Tests/Unit/CacheTests.swift @@ -45,41 +45,35 @@ final class CacheTests: XCTestCase { // Confirm that providing same entity cache id uses the same EntityDataObject instance func testEntityDataObjectReuse() throws { do { - guard let cacheProvider else { - XCTFail("cacheProvider is nil") - return - } + let cp = try XCTUnwrap(cacheProvider) let resultsProcessor = ResultTreeProcessor() - try resultsProcessor.dehydrateResults(resultTreeJson, cacheProvider: cacheProvider) + try resultsProcessor.dehydrateResults(resultTreeJson, cacheProvider: cp) let reused_id = "27E85023-D465-4240-82D6-0055AA122406" - let user1 = cacheProvider.entityData(reused_id) - let user2 = cacheProvider.entityData(reused_id) + let user1 = cp.entityData(reused_id) + let user2 = cp.entityData(reused_id) - // both user objects should be references to same instance + // both user objects should be references to same db entity XCTAssertTrue(user1 == user2) } } func testDehydrationHydration() throws { do { - guard let cacheProvider else { - XCTFail("cacheProvider is nil") - return - } + let cp = try XCTUnwrap(cacheProvider) let resultsProcessor = ResultTreeProcessor() let (dehydratedTree, do1, _) = try resultsProcessor.dehydrateResults( resultTreeOneItemJson, - cacheProvider: cacheProvider + cacheProvider: cp ) let (hydratedTree, do2) = try resultsProcessor.hydrateResults( dehydratedTree, - cacheProvider: cacheProvider + cacheProvider: cp ) XCTAssertEqual(do1, do2) From b9265dc246d97865ef2f2729d9951e7cdcd9dfb1 Mon Sep 17 00:00:00 2001 From: Aashish Patil Date: Tue, 18 Nov 2025 16:52:38 -0800 Subject: [PATCH 35/38] Remove unnecessary custom codable in EDO --- Sources/Cache/EntityDataObject.swift | 31 ++++++++-------------------- 1 file changed, 9 insertions(+), 22 deletions(-) diff --git a/Sources/Cache/EntityDataObject.swift b/Sources/Cache/EntityDataObject.swift index bf9c8b5..ce7ab83 100644 --- a/Sources/Cache/EntityDataObject.swift +++ b/Sources/Cache/EntityDataObject.swift @@ -31,7 +31,7 @@ class EntityDataObject: Codable { private var serverValues = [String: AnyCodableValue]() enum CodingKeys: String, CodingKey { - case globalID = "guid" + case guid = "_id" case serverValues = "sval" } @@ -84,28 +84,15 @@ class EntityDataObject: Codable { } } - // MARK: Encoding / Decoding support - + // inline encodable data + // used when trying to create a hydrated tree func encodableData() throws -> [String: AnyCodableValue] { - var encodingValues = [String: AnyCodableValue]() - encodingValues[GlobalIDKey] = .string(guid) - encodingValues.merge(serverValues) { _, new in new } - return encodingValues - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(guid, forKey: .globalID) - try container.encode(serverValues, forKey: .serverValues) - } - - required init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - - let globalId = try container.decode(String.self, forKey: .globalID) - guid = globalId - - serverValues = try container.decode([String: AnyCodableValue].self, forKey: .serverValues) + accessQueue.sync { + var encodingValues = [String: AnyCodableValue]() + encodingValues[GlobalIDKey] = .string(guid) + encodingValues.merge(serverValues) { _, new in new } + return encodingValues + } } } From 6689c9cf2e7662617ab9778c0d35f770da24de2e Mon Sep 17 00:00:00 2001 From: Aashish Patil Date: Tue, 18 Nov 2025 23:29:58 -0800 Subject: [PATCH 36/38] Convert data connect to a weak reference in cache --- Sources/Cache/Cache.swift | 39 ++++++++++++++++++++++++++++----------- 1 file changed, 28 insertions(+), 11 deletions(-) diff --git a/Sources/Cache/Cache.swift b/Sources/Cache/Cache.swift index 90523aa..507d34b 100644 --- a/Sources/Cache/Cache.swift +++ b/Sources/Cache/Cache.swift @@ -21,7 +21,7 @@ let GlobalIDKey: String = "_id" @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) actor Cache { let config: CacheSettings - let dataConnect: DataConnect + weak var dataConnect: DataConnect? private var cacheProvider: CacheProvider? @@ -42,6 +42,11 @@ actor Cache { private func initializeCacheProvider() { let identifier = contructCacheIdentifier() + + guard identifier.isEmpty == false else { + DataConnectLogger.error("CacheIdentifier is empty. Caching is disabled") + return + } // Create a cacheProvider if - // we don't have an existing cacheProvider @@ -63,6 +68,11 @@ actor Cache { } private func setupChangeListeners() { + guard let dataConnect else { + DataConnectLogger.error("Unable to setup auth change listeners since DataConnect is nil") + return + } + authChangeListenerProtocol = Auth.auth(app: dataConnect.app).addStateDidChangeListener { _, _ in self.initializeCacheProvider() } @@ -70,6 +80,11 @@ actor Cache { // Create an identifier for the cache that the Provider will use for cache scoping private func contructCacheIdentifier() -> String { + guard let dataConnect else { + DataConnectLogger.error("Unable to construct a cache identifier since DataConnect is nil") + return "" + } + let identifier = "\(config.storage)-\(dataConnect.app.options.projectID!)-\(dataConnect.app.name)-\(dataConnect.connectorConfig.serviceId)-\(dataConnect.connectorConfig.connector)-\(dataConnect.connectorConfig.location)-\(Auth.auth(app: dataConnect.app).currentUser?.uid ?? "anon")-\(dataConnect.settings.host)" let encoded = identifier.sha256 @@ -139,16 +154,18 @@ actor Cache { ) ) - for refId in impactedRefs { - guard let q = dataConnect.queryRef(for: refId) as? (any QueryRefInternal) else { - continue - } - Task { - do { - try await q.publishCacheResultsToSubscribers(allowStale: true) - } catch { - DataConnectLogger - .warning("Error republishing cached results for impacted queryrefs \(error))") + if let dataConnect { + for refId in impactedRefs { + guard let q = dataConnect.queryRef(for: refId) as? (any QueryRefInternal) else { + continue + } + Task { + do { + try await q.publishCacheResultsToSubscribers(allowStale: true) + } catch { + DataConnectLogger + .warning("Error republishing cached results for impacted queryrefs \(error))") + } } } } From 2b90b52463b7594a89a41a0abe0891e402b07ef8 Mon Sep 17 00:00:00 2001 From: Aashish Patil Date: Tue, 18 Nov 2025 23:50:11 -0800 Subject: [PATCH 37/38] formatting fixes --- Sources/Cache/Cache.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/Cache/Cache.swift b/Sources/Cache/Cache.swift index 507d34b..eb5e74d 100644 --- a/Sources/Cache/Cache.swift +++ b/Sources/Cache/Cache.swift @@ -42,7 +42,7 @@ actor Cache { private func initializeCacheProvider() { let identifier = contructCacheIdentifier() - + guard identifier.isEmpty == false else { DataConnectLogger.error("CacheIdentifier is empty. Caching is disabled") return @@ -72,7 +72,7 @@ actor Cache { DataConnectLogger.error("Unable to setup auth change listeners since DataConnect is nil") return } - + authChangeListenerProtocol = Auth.auth(app: dataConnect.app).addStateDidChangeListener { _, _ in self.initializeCacheProvider() } @@ -84,7 +84,7 @@ actor Cache { DataConnectLogger.error("Unable to construct a cache identifier since DataConnect is nil") return "" } - + let identifier = "\(config.storage)-\(dataConnect.app.options.projectID!)-\(dataConnect.app.name)-\(dataConnect.connectorConfig.serviceId)-\(dataConnect.connectorConfig.connector)-\(dataConnect.connectorConfig.location)-\(Auth.auth(app: dataConnect.app).currentUser?.uid ?? "anon")-\(dataConnect.settings.host)" let encoded = identifier.sha256 From edfcd83eaff871477304bc443dc57800be62df74 Mon Sep 17 00:00:00 2001 From: Aashish Patil Date: Wed, 19 Nov 2025 10:22:18 -0800 Subject: [PATCH 38/38] Doc comments for Settings --- Sources/Cache/CacheSettings.swift | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/Sources/Cache/CacheSettings.swift b/Sources/Cache/CacheSettings.swift index b9f9b03..eef15d1 100644 --- a/Sources/Cache/CacheSettings.swift +++ b/Sources/Cache/CacheSettings.swift @@ -12,17 +12,32 @@ // See the License for the specific language governing permissions and // limitations under the License. -/// Specifies the cache configuration for a Firebase Data Connect instance +/// Specifies the cache configuration for a `DataConnect` instance. +/// +/// You can configure the cache's storage policy and its maximum size. @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) public struct CacheSettings: Sendable { + /// Defines the storage mechanism for the cache. public enum Storage: Sendable { + /// The cache will be written to disk, persisting data across application launches. case persistent + /// The cache will only be stored in memory and will be cleared when the application terminates. case memory } - public let storage: Storage // default provider is persistent type + /// The storage mechanism to be used for caching. The default is `.persistent`. + public let storage: Storage + /// The maximum size of the cache in bytes. + /// + /// This size is not strictly enforced but is used as a guideline by the cache + /// to trigger cleanup procedures. The default is 100MB (100,000,000 bytes). public let maxSizeBytes: UInt64 + /// Creates a new cache settings configuration. + /// + /// - Parameters: + /// - storage: The storage mechanism to use. Defaults to `.persistent`. + /// - maxSize: The maximum desired size of the cache in bytes. Defaults to 100MB. public init(storage: Storage = .persistent, maxSize: UInt64 = 100_000_000) { self.storage = storage maxSizeBytes = maxSize