diff --git a/Sources/BaseOperationRef.swift b/Sources/BaseOperationRef.swift index 5b8a502..b6eebe5 100644 --- a/Sources/BaseOperationRef.swift +++ b/Sources/BaseOperationRef.swift @@ -14,11 +14,6 @@ import Foundation -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public struct OperationResult: Sendable { - public var data: ResultData -} - // 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 {} @@ -31,7 +26,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/Cache/Cache.swift b/Sources/Cache/Cache.swift new file mode 100644 index 0000000..31839a0 --- /dev/null +++ b/Sources/Cache/Cache.swift @@ -0,0 +1,174 @@ +// 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 + +// FDC field name in server response that identifies a GlobalID +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, *) +actor Cache { + let config: CacheSettings + weak var dataConnect: DataConnect? + + private var cacheProvider: CacheProvider? + + // holding it to avoid dealloc + private var authChangeListenerProtocol: NSObjectProtocol? + + init(config: CacheSettings, dataConnect: DataConnect) { + self.config = config + self.dataConnect = dataConnect + + // 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() + } + } + + 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 + // we have one but its identifier is different than new one (e.g. auth uid changed) + if cacheProvider != nil, cacheProvider?.cacheIdentifier == identifier { + return + } + + do { + switch config.storage { + case .memory: + cacheProvider = try SQLiteCacheProvider(identifier, ephemeral: true) + case .persistent: + cacheProvider = try SQLiteCacheProvider(identifier, ephemeral: false) + } + } catch { + DataConnectLogger.error("Unable to initialize Persistent provider \(error)") + } + } + + 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() + } + } + + // 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 + DataConnectLogger.debug("Created Encoded 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 + 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 + ) + + let hydratedTree = ResultTree( + data: hydratedResults, + 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 + 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, + cachedAt: Date(), + lastAccessed: Date(), + rootObject: rootObj + ) + ) + + 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))") + } + } + } + } + } catch { + DataConnectLogger.warning("Error updating cache for \(queryId): \(error)") + } + } +} diff --git a/Sources/Cache/CacheProvider.swift b/Sources/Cache/CacheProvider.swift new file mode 100644 index 0000000..b5bfc1d --- /dev/null +++ b/Sources/Cache/CacheProvider.swift @@ -0,0 +1,31 @@ +// 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 + +// 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 } + + func resultTree(queryId: String) -> ResultTree? + func setResultTree(queryId: String, tree: ResultTree) + + func entityData(_ entityGuid: String) -> EntityDataObject + func updateEntityData(_ object: EntityDataObject) +} diff --git a/Sources/Cache/CacheSettings.swift b/Sources/Cache/CacheSettings.swift new file mode 100644 index 0000000..0a2a914 --- /dev/null +++ b/Sources/Cache/CacheSettings.swift @@ -0,0 +1,56 @@ +// 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 + +/// 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 + } + + /// 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 + + /// Max time interval before a queries cache is considered stale and refreshed from the server + /// This interval does not imply that cached data is evicted and it can still be accessed using + /// the `cacheOnly` fetch policy + public let maxAge: TimeInterval + + /// 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. + /// - maxAge: The max time interval before a queries cache is considered stale and refreshed + /// from the server + public init(storage: Storage = .persistent, maxSize: UInt64 = 100_000_000, + maxAge: TimeInterval = 0) { + self.storage = storage + maxSizeBytes = maxSize + self.maxAge = maxAge + } +} diff --git a/Sources/Cache/DynamicCodingKey.swift b/Sources/Cache/DynamicCodingKey.swift new file mode 100644 index 0000000..62fcda7 --- /dev/null +++ b/Sources/Cache/DynamicCodingKey.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. + +// 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 + init?(intValue: Int) { return nil } + init?(stringValue: String) { self.stringValue = stringValue } +} diff --git a/Sources/Cache/EntityDataObject.swift b/Sources/Cache/EntityDataObject.swift new file mode 100644 index 0000000..ce7ab83 --- /dev/null +++ b/Sources/Cache/EntityDataObject.swift @@ -0,0 +1,120 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import Foundation + +// 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 + + 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 guid = "_id" + case serverValues = "sval" + } + + // 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, + _ 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](referencedFrom) + return refs + } + } + + func value(forKey key: String) -> AnyCodableValue? { + accessQueue.sync { + 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 { + self.referencedFrom + } + } + + var isReferencedFromAnyQueryRef: Bool { + accessQueue.sync { + !referencedFrom.isEmpty + } + } + + // inline encodable data + // used when trying to create a hydrated tree + func encodableData() throws -> [String: AnyCodableValue] { + accessQueue.sync { + var encodingValues = [String: AnyCodableValue]() + encodingValues[GlobalIDKey] = .string(guid) + encodingValues.merge(serverValues) { _, new in new } + return encodingValues + } + } +} + +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 + } +} + +extension EntityDataObject: CustomDebugStringConvertible { + var debugDescription: String { + return description + } +} diff --git a/Sources/Cache/EntityNode.swift b/Sources/Cache/EntityNode.swift new file mode 100644 index 0000000..b4a6b40 --- /dev/null +++ b/Sources/Cache/EntityNode.swift @@ -0,0 +1,278 @@ +// 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. + +// 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 + 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 = "guid" + case objectLists + case references + case scalars + } + + 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] { + // underlying deserializer is detecting a uuid and converting it. + // TODO: Remove once server starts to send the real GlobalID + 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 node + if let st = EntityNode( + value: value, + cacheProvider: cacheProvider, + impactedRefsAccumulator: impactedRefsAccumulator + ) { + references[key] = st + } else { + DataConnectLogger.warning("Failed to convert dictionary to a reference") + } + case let .array(objs): + var refArray = [EntityNode]() + var scalarArray = [AnyCodableValue]() + for obj in objs { + if let st = EntityNode( + value: obj, + cacheProvider: cacheProvider, + impactedRefsAccumulator: impactedRefsAccumulator + ) { + 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 entityData { + let impactedRefs = entityData.updateServerValue( + key, + value, + impactedRefsAccumulator?.requestor + ) + + // accumulate any impacted QueryRefs due to this change + for r in impactedRefs { + impactedRefsAccumulator?.append(r) + } + } + } + default: + if let entityData { + let impactedRefs = entityData.updateServerValue( + key, + value, + impactedRefsAccumulator?.requestor + ) + + // accumulate any QueryRefs impacted by this change + for r in impactedRefs { + impactedRefsAccumulator?.append(r) + } + } else { + scalars[key] = value + } + } + } // for (key,value) + + if let entityData { + for refId in entityData.referencedFromRefs() { + impactedRefsAccumulator?.append(refId) + } + cacheProvider.updateEntityData(entityData) + } + } +} + +extension EntityNode: 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 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))") + } + + if let enode { + self = enode + } else { + 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) { + entityData = cacheProvider.entityData(globalID) + } + + if let refs = try container.decodeIfPresent([String: EntityNode].self, forKey: .references) { + references = refs + } + + if let lists = try container.decodeIfPresent( + [String: [EntityNode]].self, + forKey: .objectLists + ) { + objectLists = lists + } + + if let scalars = try container.decodeIfPresent( + [String: AnyCodableValue].self, + forKey: .scalars + ) { + self.scalars = scalars + } + } + } +} + +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") + } + + if resultTreeKind == .hydrated { + 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)!) + } + } + } else { + // dehydrated tree required + var container = encoder.container(keyedBy: CodingKeys.self) + 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) + } + } + } +} + +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 EntityNode: CustomDebugStringConvertible { + var debugDescription: String { + return """ + EntityNode: + \(String(describing: entityData)) + References: + \(references) + Lists: + \(objectLists) + Scalars: + \(scalars) + """ + } +} diff --git a/Sources/Cache/ResultTree.swift b/Sources/Cache/ResultTree.swift new file mode 100644 index 0000000..d1ac25b --- /dev/null +++ b/Sources/Cache/ResultTree.swift @@ -0,0 +1,40 @@ +// 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 + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +struct ResultTree: Codable { + // tree data - could be hydrated or dehydrated. + let data: String + + // 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? + + func isStale(_ ttl: TimeInterval) -> Bool { + let now = Date() + return now.timeIntervalSince(cachedAt) > ttl + } + + enum CodingKeys: String, CodingKey { + case cachedAt = "ca" // cached at + case lastAccessed = "la" // last accessed + case data = "d" // data cached + } +} diff --git a/Sources/Cache/ResultTreeProcessor.swift b/Sources/Cache/ResultTreeProcessor.swift new file mode 100644 index 0000000..61aeb9b --- /dev/null +++ b/Sources/Cache/ResultTreeProcessor.swift @@ -0,0 +1,144 @@ +// 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 + +// 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 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 +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 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 = [] + + // 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) + } + } +} + +// 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 { + /* + 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, + 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: hydratedData) + + 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) + + guard let dehydratedResultsString = String(data: jsonData, encoding: .utf8) else { + throw DataConnectCodecError.encodingFailed() + } + + DataConnectLogger + .debug( + "\(#function): \nHydrated \n \(hydratedTree) \n\nDehydrated \n \(dehydratedResultsString)" + ) + + 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 { + 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: 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) + guard let hydratedResultsString = String(data: hydratedResults, encoding: .utf8) else { + throw DataConnectCodecError + .encodingFailed(message: "Failed to convert EDO to String") + } + + 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 new file mode 100644 index 0000000..3ecb9dc --- /dev/null +++ b/Sources/Cache/SQLiteCacheProvider.swift @@ -0,0 +1,395 @@ +// 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 + +@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" + static let queryId = "query_id" + static let lastAccessed = "last_accessed" +} + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +class SQLiteCacheProvider: CacheProvider { + let cacheIdentifier: String + + private var db: OpaquePointer? + private let queue = DispatchQueue( + label: "com.google.firebase.dataconnect.sqlitecacheprovider", + autoreleaseFrequency: .workItem + ) + + init(_ cacheIdentifier: String, ephemeral: Bool = false) throws { + self.cacheIdentifier = cacheIdentifier + + try queue.sync { + var dbIdentifier = ":memory:" + if !ephemeral { + 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 { + throw + DataConnectInternalError + .sqliteError( + message: "Could not open database for identifier \(cacheIdentifier) at \(dbIdentifier)" + ) + } + + DataConnectLogger + .debug( + "Opened database with db path/id \(dbIdentifier) and cache identifier \(cacheIdentifier)" + ) + do { + try createTables() + } catch { + sqlite3_close(db) + db = nil + throw error + } + } + } + + deinit { + sqlite3_close(db) + } + + private func createTables() throws { + 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 + ); + """ + 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 + ); + """ + 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 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)) + ) + """ + if sqlite3_exec(db, createEntityDataRefs, nil, nil, nil) != SQLITE_OK { + throw DataConnectInternalError.sqliteError( + message: "Could not create entity_data_query_refs table" + ) + } + } + + private func updateLastAccessedTime(forQueryId queryId: String) { + dispatchPrecondition(condition: .onQueue(queue)) + let updateQuery = + "UPDATE \(TableName.resultTree) SET \(ColumnName.lastAccessed) = ? WHERE \(ColumnName.queryId) = ?;" + var statement: OpaquePointer? + + 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) + + if sqlite3_step(statement) != SQLITE_DONE { + DataConnectLogger.error("Error updating \(ColumnName.lastAccessed) for query \(queryId)") + } + } + + func resultTree(queryId: String) -> ResultTree? { + return queue.sync { + let query = + "SELECT \(ColumnName.data) FROM \(TableName.resultTree) WHERE \(ColumnName.queryId) = ?;" + var statement: OpaquePointer? + + 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) + + 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 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 + } + } + } + + 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 \(TableName.resultTree) (\(ColumnName.queryId), \(ColumnName.lastAccessed), \(ColumnName.data)) VALUES (?, ?, ?);" + var statement: OpaquePointer? + + guard sqlite3_prepare_v2(db, insert, -1, &statement, nil) == SQLITE_OK else { + DataConnectLogger.error("Error preparing insert statement for \(TableName.resultTree)") + return + } + defer { + sqlite3_finalize(statement) + } + + sqlite3_bind_text(statement, 1, (queryId as NSString).utf8String, -1, nil) + sqlite3_bind_double(statement, 2, Date().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)") + } + + DataConnectLogger.debug("\(#function) - query \(queryId), tree \(tree)") + } catch { + DataConnectLogger.error("Error encoding result tree for queryId \(queryId): \(error)") + } + } + } + + func entityData(_ entityGuid: String) -> EntityDataObject { + return queue.sync { + let query = + "SELECT \(ColumnName.data) FROM \(TableName.entityDataObjects) WHERE \(ColumnName.entityId) = ?;" + var statement: OpaquePointer? + + guard sqlite3_prepare_v2(db, query, -1, &statement, nil) == SQLITE_OK else { + DataConnectLogger + .error("Error preparing select statement for \(TableName.entityDataObjects)") + // 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)" + ) + } + } + } + + // 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 + } + } + + func updateEntityData(_ object: EntityDataObject) { + queue.sync { + _setObject(entityGuid: object.guid, object: object) + } + } + + // 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 (?, ?);" + var statement: OpaquePointer? + + 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 + 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)") + } + + } catch { + DataConnectLogger.error("Error encoding data object for entityGuid \(entityGuid): \(error)") + } + + // update references + _updateQueryRefs(object: object) + } + + private func _updateQueryRefs(object: EntityDataObject) { + dispatchPrecondition(condition: .onQueue(queue)) + + guard object.isReferencedFromAnyQueryRef else { + return + } + + 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 { + DataConnectLogger.error("Error preparing insert statement for entity_data_query_refs") + return + } + defer { + sqlite3_finalize(statementRefs) + } + + 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) + } + + 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) = ?" + var statementRefs: OpaquePointer? + var queryIds: [String] = [] + + 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) + } + + 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 queryIds + } +} diff --git a/Sources/ConnectorConfig.swift b/Sources/ConnectorConfig.swift index 29c98fd..530fb92 100644 --- a/Sources/ConnectorConfig.swift +++ b/Sources/ConnectorConfig.swift @@ -16,9 +16,9 @@ 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 init(serviceId: String, location: String, connector: String) { self.serviceId = serviceId diff --git a/Sources/DataConnect.swift b/Sources/DataConnect.swift index 801f77b..fcd0ba2 100644 --- a/Sources/DataConnect.swift +++ b/Sources/DataConnect.swift @@ -19,13 +19,15 @@ 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 cache: Cache? + private var callerSDKType: CallerSDKType = .base private let accessQueue = DispatchQueue( @@ -63,7 +65,8 @@ public class DataConnect { for: app, config: connectorConfig, settings: settings, - callerSDKType: callerSDKType + callerSDKType: callerSDKType, + cacheSettings: settings.cacheSettings ) } @@ -89,14 +92,21 @@ public class DataConnect { callerSDKType: callerSDKType ) - operationsManager = OperationsManager(grpcClient: grpcClient) + if let cache { + self.cache = Cache(config: cache.config, dataConnect: self) + } + + operationsManager = OperationsManager( + grpcClient: grpcClient, + cache: self.cache + ) } } // MARK: Init init(app: FirebaseApp, connectorConfig: ConnectorConfig, settings: DataConnectSettings, - callerSDKType: CallerSDKType = .base) { + 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") } @@ -112,7 +122,12 @@ public class DataConnect { connectorConfig: connectorConfig, callerSDKType: self.callerSDKType ) - operationsManager = OperationsManager(grpcClient: grpcClient) + + operationsManager = OperationsManager(grpcClient: grpcClient, cache: cache) + + if let cacheSettings { + cache = Cache(config: cacheSettings, dataConnect: self) + } } // MARK: Operations @@ -146,6 +161,15 @@ public class DataConnect { } } +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +extension DataConnect { + func queryRef(for operationId: String) -> (any QueryRef)? { + accessQueue.sync { + operationsManager.queryRef(for: operationId) + } + } +} + // This enum is public so the gen sdk can access it @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) public enum CallerSDKType: Sendable { @@ -184,7 +208,8 @@ private class InstanceStore { private var instances = [InstanceKey: DataConnect]() func instance(for app: FirebaseApp, config: ConnectorConfig, - settings: DataConnectSettings, callerSDKType: CallerSDKType) -> DataConnect { + settings: DataConnectSettings, callerSDKType: CallerSDKType, + cacheSettings: CacheSettings? = nil) -> DataConnect { accessQ.sync { let key = InstanceKey(app: app, config: config) if let inst = instances[key] { @@ -194,7 +219,8 @@ private class InstanceStore { app: app, connectorConfig: config, settings: settings, - callerSDKType: callerSDKType + callerSDKType: callerSDKType, + cacheSettings: cacheSettings ) instances[key] = dc return dc diff --git a/Sources/DataConnectError.swift b/Sources/DataConnectError.swift index 01c2a8a..9477d31 100644 --- a/Sources/DataConnectError.swift +++ b/Sources/DataConnectError.swift @@ -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/DataConnectSettings.swift b/Sources/DataConnectSettings.swift index dda66f7..0fba613 100644 --- a/Sources/DataConnectSettings.swift +++ b/Sources/DataConnectSettings.swift @@ -16,20 +16,24 @@ 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 cacheSettings: CacheSettings? - public init(host: String, port: Int, sslEnabled: Bool) { + public init(host: String, port: Int, sslEnabled: Bool, + cacheSettings: CacheSettings? = CacheSettings()) { self.host = host self.port = port self.sslEnabled = sslEnabled + self.cacheSettings = cacheSettings } public init() { host = "firebasedataconnect.googleapis.com" port = 443 sslEnabled = true + cacheSettings = CacheSettings() } public func hash(into hasher: inout Hasher) { diff --git a/Sources/DataSource.swift b/Sources/DataSource.swift new file mode 100644 index 0000000..011d90d --- /dev/null +++ b/Sources/DataSource.swift @@ -0,0 +1,23 @@ +// 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 DataSource: Sendable { + /// The query results are from server + case server + + /// Query results are from cache + case cache +} diff --git a/Sources/Internal/GrpcClient.swift b/Sources/Internal/GrpcClient.swift index bb15645..6ff817c 100644 --- a/Sources/Internal/GrpcClient.swift +++ b/Sources/Internal/GrpcClient.swift @@ -130,13 +130,13 @@ actor GrpcClient: CustomStringConvertible { VariableType: OperationVariable>(request: QueryRequest, resultType: ResultType .Type) - async throws -> OperationResult { + async throws -> ServerResponse { guard let client else { DataConnectLogger.error("When calling executeQuery(), grpc client has not been configured.") throw DataConnectInitError.grpcNotConfigured() } - let codec = Codec() + let codec = ProtoCodec() let grpcRequest = try codec.createQueryRequestProto( connectorName: connectorName, request: request @@ -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 when available + return ServerResponse(jsonResults: resultsString, maxAge: nil) + } + + // We have partial errors returned /* - if decode succeeds, errorList isEmpty = return data - if decode succeeds, errorList notEmpty = throw OperationError with decodedData @@ -165,17 +175,14 @@ actor GrpcClient: CustomStringConvertible { 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) - } + + 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 @@ -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)." ) @@ -217,7 +224,7 @@ actor GrpcClient: CustomStringConvertible { throw DataConnectInitError.grpcNotConfigured() } - let codec = Codec() + let codec = ProtoCodec() let grpcRequest = try codec.createMutationRequestProto( connectorName: connectorName, request: request @@ -256,7 +263,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/HashUtils.swift b/Sources/Internal/HashUtils.swift new file mode 100644 index 0000000..7985f3b --- /dev/null +++ b/Sources/Internal/HashUtils.swift @@ -0,0 +1,34 @@ +// 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 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) + let hashString = hashDigest.compactMap { String(format: "%02x", $0) }.joined() + return hashString + } +} + +@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)!) + let hashString = digest.compactMap { String(format: "%02x", $0) }.joined() + return hashString + } +} diff --git a/Sources/Internal/OperationsManager.swift b/Sources/Internal/OperationsManager.swift index b775857..858a372 100644 --- a/Sources/Internal/OperationsManager.swift +++ b/Sources/Internal/OperationsManager.swift @@ -18,11 +18,13 @@ import Foundation class OperationsManager { private var grpcClient: GrpcClient + private var cache: Cache? + private let queryRefAccessQueue = DispatchQueue( 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", @@ -30,8 +32,9 @@ class OperationsManager { ) private var mutationRefs = [AnyHashable: any OperationRef]() - init(grpcClient: GrpcClient) { + init(grpcClient: GrpcClient, cache: Cache? = nil) { self.grpcClient = grpcClient + self.cache = cache } func queryRef 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 } @@ -50,9 +56,10 @@ class OperationsManager { let obsRef = QueryRefObservation( request: request, dataType: resultType, - grpcClient: self.grpcClient + grpcClient: self.grpcClient, + cache: self.cache ) as (any ObservableQueryRef) - queryRefs[AnyHashable(request)] = obsRef + queryRefs[requestId] = obsRef return obsRef } } @@ -60,13 +67,20 @@ class OperationsManager { let refObsObject = QueryRefObservableObject( request: request, dataType: resultType, - grpcClient: grpcClient + 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 { + queryRefs[operationId] + } + } + func mutationRef(for request: MutationRequest, with resultType: ResultDataType 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/QueryRefInternal.swift b/Sources/Internal/QueryRefInternal.swift new file mode 100644 index 0000000..6b1bcec --- /dev/null +++ b/Sources/Internal/QueryRefInternal.swift @@ -0,0 +1,19 @@ +// 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. + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +protocol QueryRefInternal: QueryRef { + var operationId: String { get } + func publishCacheResultsToSubscribers(allowStale: Bool) async throws +} diff --git a/Sources/Internal/ServerResponse.swift b/Sources/Internal/ServerResponse.swift new file mode 100644 index 0000000..b53bb40 --- /dev/null +++ b/Sources/Internal/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 maxAge: TimeInterval? +} diff --git a/Sources/MutationRef.swift b/Sources/MutationRef.swift index af4fa05..ee100e8 100644 --- a/Sources/MutationRef.swift +++ b/Sources/MutationRef.swift @@ -31,20 +31,29 @@ 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 } + + public func hash(into hasher: inout Hasher) { + hasher.combine(request) + } + + public static func == (lhs: MutationRef, + rhs: MutationRef) -> Bool { + return lhs.request == rhs.request + } } diff --git a/Sources/OperationResult.swift b/Sources/OperationResult.swift new file mode 100644 index 0000000..d6d22b1 --- /dev/null +++ b/Sources/OperationResult.swift @@ -0,0 +1,22 @@ +// 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. + +import Foundation + +/// 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? + public let source: DataSource +} diff --git a/Sources/Queries/GenericQueryRef.swift b/Sources/Queries/GenericQueryRef.swift new file mode 100644 index 0000000..81c2b6d --- /dev/null +++ b/Sources/Queries/GenericQueryRef.swift @@ -0,0 +1,189 @@ +// 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 + +@preconcurrency import Combine +import Observation + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +actor GenericQueryRef: QueryRef { + private let resultsPublisher = PassthroughSubject< + Result, AnyDataConnectError>, + Never + >() + + private var request: QueryRequest + + private let grpcClient: GrpcClient + + private let cache: Cache? + + // maxAge received from server in query response + private var serverMaxAge: TimeInterval? = nil + + // maxAge computed based on server or cache settings + // server is given priority over cache settings + private var maxAge: TimeInterval { + if let serverMaxAge { + DataConnectLogger.debug("Using maxAge specified from server \(serverMaxAge)") + return serverMaxAge + } + + if let ma = cache?.config.maxAge { + DataConnectLogger.debug("Using maxAge specified from cache settings \(ma)") + return ma + } + + return 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. + let operationId: String + + init(request: QueryRequest, grpcClient: GrpcClient, cache: Cache? = nil) { + self.request = request + self.grpcClient = grpcClient + self.cache = cache + operationId = self.request.requestId + } + + // 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 fetchServerResults() + } catch { + resultsPublisher + .send(.failure(AnyDataConnectError(dataConnectError: DataConnectInternalError + .internalError( + message: "Failed to subscribe to query", + cause: error + )))) + } + } + 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 = .preferCache) async throws + -> OperationResult { + switch fetchPolicy { + case .preferCache: + let cachedResult = try await fetchCachedResults(allowStale: false) + if cachedResult.data != nil { + return cachedResult + } else { + let serverResults = try await fetchServerResults() + return serverResults + } + case .cacheOnly: + let cachedResult = try await fetchCachedResults(allowStale: true) + return cachedResult + case .serverOnly: + 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 { + serverMaxAge = response.maxAge + await cache.update(queryId: operationId, response: response, requestor: self) + } + } + + 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 + else { + DataConnectLogger.info("No cache provider configured") + return OperationResult(data: nil, source: .cache) + } + + if let cacheEntry = await cache.resultTree(queryId: request.requestId), + (cacheEntry.isStale(maxAge) && allowStale) || !cacheEntry.isStale(maxAge) { + let decoder = JSONDecoder() + let decodedData = try decoder.decode( + ResultData.self, + from: cacheEntry.data.data(using: .utf8)! + ) + + let result = OperationResult(data: decodedData, source: .cache) + // send to subscribers + await updateData(data: result) + + return result + } + + return OperationResult(data: nil, source: .cache) + } + + func updateData(data: OperationResult) async { + resultsPublisher.send(.success(data)) + } +} + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +extension GenericQueryRef { + nonisolated func hash(into hasher: inout Hasher) { + hasher.combine(operationId) + } + + static func == (lhs: GenericQueryRef, rhs: GenericQueryRef) -> Bool { + lhs.operationId == rhs.operationId + } +} + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +extension GenericQueryRef: CustomStringConvertible { + nonisolated var description: String { + "GenericQueryRef(\(operationId))" + } +} + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +extension GenericQueryRef: QueryRefInternal { + func publishServerResultsToSubscribers() async throws { + _ = try await fetchServerResults() + } + + func publishCacheResultsToSubscribers(allowStale: Bool) async throws { + _ = try await fetchCachedResults(allowStale: allowStale) + } +} diff --git a/Sources/QueryRef.swift b/Sources/Queries/ObservableQueryRef.swift similarity index 61% rename from Sources/QueryRef.swift rename to Sources/Queries/ObservableQueryRef.swift index 0dd2ca7..e0a6db5 100644 --- a/Sources/QueryRef.swift +++ b/Sources/Queries/ObservableQueryRef.swift @@ -17,114 +17,14 @@ import Foundation @preconcurrency import Combine import Observation -/// The type of publisher to use for the Query Ref -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -public enum ResultsPublisherType { - /// automatically determine ObservableQueryRef. - /// Tries to pick the iOS 17+ Observation but falls back to ObservableObject - case auto - - /// pre-iOS 17 ObservableObject - case observableObject - - /// iOS 17+ Observation framework - 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? - - 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> -} - -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -actor GenericQueryRef: QueryRef { - private let resultsPublisher = PassthroughSubject, - Never>() - - private let request: QueryRequest - - private let grpcClient: GrpcClient - - init(request: QueryRequest, grpcClient: GrpcClient) { - self.request = request - self.grpcClient = grpcClient - } - - // This call starts query execution and publishes data to data var - // In v0, it simply reloads query results - public func subscribe() -> AnyPublisher, Never> { - Task { - do { - _ = try await reloadResults() - } catch {} - } - return resultsPublisher.eraseToAnyPublisher() - } - - // one-shot execution. It will fetch latest data, update any caches - // and updates the published data var - public func execute() async throws -> OperationResult { - let resultData = try await reloadResults() - return OperationResult(data: resultData) - } - - private func reloadResults() async throws -> ResultData { - let results = try await grpcClient.executeQuery( - request: request, - resultType: ResultData.self - ) - await updateData(data: results.data) - return results.data - } - - func updateData(data: ResultData) 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: DataSource? { get } + // last error received. if last fetch was successful this is cleared var lastError: DataConnectError? { get } } @@ -146,15 +46,26 @@ public class QueryRefObservableObject< ResultData: Decodable & Sendable, Variable: OperationVariable >: ObservableObject, ObservableQueryRef { + var operationId: String { + return baseRef.operationId + } + private var request: QueryRequest private var baseRef: GenericQueryRef private var resultsCancellable: AnyCancellable? - init(request: QueryRequest, dataType: ResultData.Type, grpcClient: GrpcClient) { + init(request: QueryRequest, + dataType: ResultData.Type, + grpcClient: GrpcClient, + cache: Cache?) { self.request = request - baseRef = GenericQueryRef(request: request, grpcClient: grpcClient) + baseRef = GenericQueryRef( + request: request, + grpcClient: grpcClient, + cache: cache + ) setupSubscription() } @@ -164,8 +75,9 @@ public class QueryRefObservableObject< .receive(on: DispatchQueue.main) .sink(receiveValue: { result in switch result { - case let .success(resultData): - self.data = resultData + case let .success(operationResult): + self.data = operationResult.data + self.source = operationResult.source self.lastError = nil case let .failure(dcerror): self.lastError = dcerror.dataConnectError @@ -183,12 +95,16 @@ public class QueryRefObservableObject< /// error is cleared @Published public private(set) var lastError: DataConnectError? + /// Source of the query results (server, local cache, ...) + @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() async throws -> OperationResult { - let result = try await baseRef.execute() + public func execute(fetchPolicy: QueryFetchPolicy = .preferCache) async throws + -> OperationResult { + let result = try await baseRef.execute(fetchPolicy: fetchPolicy) return result } @@ -196,11 +112,33 @@ public class QueryRefObservableObject< /// 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() } } +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +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, *) +extension QueryRefObservableObject: QueryRefInternal { + func publishServerResultsToSubscribers() async throws { + try await baseRef.publishServerResultsToSubscribers() + } + + func publishCacheResultsToSubscribers(allowStale: Bool) async throws { + try await baseRef.publishCacheResultsToSubscribers(allowStale: allowStale) + } +} + /// 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 @@ -219,6 +157,10 @@ public class QueryRefObservation< ResultData: Decodable & Sendable, Variable: OperationVariable >: ObservableQueryRef { + var operationId: String { + return baseRef.operationId + } + @ObservationIgnored private var request: QueryRequest @@ -228,9 +170,14 @@ public class QueryRefObservation< @ObservationIgnored private var resultsCancellable: AnyCancellable? - init(request: QueryRequest, dataType: ResultData.Type, grpcClient: GrpcClient) { + init(request: QueryRequest, dataType: ResultData.Type, grpcClient: GrpcClient, + cache: Cache?) { self.request = request - baseRef = GenericQueryRef(request: request, grpcClient: grpcClient) + baseRef = GenericQueryRef( + request: request, + grpcClient: grpcClient, + cache: cache + ) setupSubscription() } @@ -241,7 +188,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 @@ -259,12 +207,16 @@ public class QueryRefObservation< /// error is cleared public private(set) var lastError: DataConnectError? + /// Source of the query results (server, local cache, ...) + 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() async throws -> OperationResult { - let result = try await baseRef.execute() + public func execute(fetchPolicy: QueryFetchPolicy = .preferCache) async throws + -> OperationResult { + let result = try await baseRef.execute(fetchPolicy: fetchPolicy) return result } @@ -272,7 +224,36 @@ 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() } } + +@available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) +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 14, iOS 17, tvOS 17, watchOS 10, *) +extension QueryRefObservation: CustomStringConvertible { + public nonisolated var description: String { + "QueryRefObservation(\(String(describing: baseRef)))" + } +} + +@available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) +extension QueryRefObservation: QueryRefInternal { + func publishServerResultsToSubscribers() async throws { + try await baseRef.publishServerResultsToSubscribers() + } + + func publishCacheResultsToSubscribers(allowStale: Bool) async throws { + try await baseRef.publishCacheResultsToSubscribers(allowStale: allowStale) + } +} diff --git a/Sources/Queries/QueryFetchPolicy.swift b/Sources/Queries/QueryFetchPolicy.swift new file mode 100644 index 0000000..589ecff --- /dev/null +++ b/Sources/Queries/QueryFetchPolicy.swift @@ -0,0 +1,34 @@ +// 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. + +/// 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 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 if call succeeds. + /// Throws if server call fails + case serverOnly +} diff --git a/Sources/Queries/QueryRef.swift b/Sources/Queries/QueryRef.swift new file mode 100644 index 0000000..d62ae48 --- /dev/null +++ b/Sources/Queries/QueryRef.swift @@ -0,0 +1,52 @@ +// 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 + +@preconcurrency import Combine +import Observation + +/// The type of publisher to use for the Query Ref +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +public enum ResultsPublisherType { + /// automatically determine ObservableQueryRef. + /// Tries to pick the iOS 17+ Observation but falls back to ObservableObject + case auto + + /// pre-iOS 17 ObservableObject + case observableObject + + /// iOS 17+ Observation framework + 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 + /// 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. Defaults to `preferCache` policy + func execute(fetchPolicy: QueryFetchPolicy) async throws -> OperationResult +} + +public extension QueryRef { + // default implementation for execute() + func execute() async throws -> OperationResult { + try await execute(fetchPolicy: .preferCache) + } +} diff --git a/Sources/Queries/QueryRequest.swift b/Sources/Queries/QueryRequest.swift new file mode 100644 index 0000000..cf327a8 --- /dev/null +++ b/Sources/Queries/QueryRequest.swift @@ -0,0 +1,67 @@ +// 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 + +/// 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)") + } + } + + return keyIdData.sha256String + }() + + init(operationName: String, variables: Variable? = nil) { + self.operationName = operationName + self.variables = variables + } + + // MARK: - 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 + } + + return lhs.variables == rhs.variables + } +} diff --git a/Sources/Scalars/AnyCodableValue.swift b/Sources/Scalars/AnyCodableValue.swift new file mode 100644 index 0000000..6c16f48 --- /dev/null +++ b/Sources/Scalars/AnyCodableValue.swift @@ -0,0 +1,134 @@ +// 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. + +import Foundation + +import FirebaseCore + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +enum AnyCodableValue: Codable, Equatable, CustomStringConvertible { + case string(String) + case int64(Int64) + case number(Double) + case bool(Bool) + case uuid(UUID) + case timestamp(Timestamp) + 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 (.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(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..a9fcfdb 100644 --- a/Sources/Scalars/AnyValue.swift +++ b/Sources/Scalars/AnyValue.swift @@ -102,75 +102,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/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) } } diff --git a/Tests/Unit/CacheTests.swift b/Tests/Unit/CacheTests.swift new file mode 100644 index 0000000..591e087 --- /dev/null +++ b/Tests/Unit/CacheTests.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 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,"_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", "_id":"123","price":4}} + """ + + let resultTreeJson = """ + {"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? + + 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 { + let cp = try XCTUnwrap(cacheProvider) + + let resultsProcessor = ResultTreeProcessor() + try resultsProcessor.dehydrateResults(resultTreeJson, cacheProvider: cp) + + let reused_id = "27E85023-D465-4240-82D6-0055AA122406" + + let user1 = cp.entityData(reused_id) + let user2 = cp.entityData(reused_id) + + // both user objects should be references to same db entity + XCTAssertTrue(user1 == user2) + } + } + + func testDehydrationHydration() throws { + do { + let cp = try XCTUnwrap(cacheProvider) + + let resultsProcessor = ResultTreeProcessor() + + let (dehydratedTree, do1, _) = try resultsProcessor.dehydrateResults( + resultTreeOneItemJson, + cacheProvider: cp + ) + + let (hydratedTree, do2) = try resultsProcessor.hydrateResults( + dehydratedTree, + cacheProvider: cp + ) + + XCTAssertEqual(do1, do2) + } + } +}