diff --git a/Firestore/Swift/Source/Common/FirestoreLogger.swift b/Firestore/Swift/Source/Common/FirestoreLogger.swift new file mode 100644 index 00000000000..82568f29988 --- /dev/null +++ b/Firestore/Swift/Source/Common/FirestoreLogger.swift @@ -0,0 +1,62 @@ +/* + * Copyright 2023 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 OSLog + +/// Base FirestoreLogger class +/// Logging categories can be created by extending FirestoreLogger and defining a static FirestoreLogger var with your category +/// +/// ``` +/// extension FirestoreLogger { +/// static var myCategory = FirestoreLogger(category: "myCategory") +/// } +/// ``` +/// +/// To use your extension, call +/// ``` +/// FirestoreLogger.myCategory.log(msg, vars) +/// ``` +/// See ReferenceableObject.swift for the FirestoreLogger extension for an example + +@available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) +class FirestoreLogger: OSLog { + private let firestoreSubsystem = "com.google.firebase.firestore" + + init(category: String) { + super.init(subsystem: firestoreSubsystem, category: category) + } + + func debug(_ message: StaticString, _ args: CVarArg...) { + os_log(message, log: self, type: .debug, args) + } + + func info(_ message: StaticString, _ args: CVarArg...) { + os_log(message, log: self, type: .info, args) + } + + func log(_ message: StaticString, _ args: CVarArg...) { + os_log(message, log: self, type: .default, args) + } + + func error(_ message: StaticString, _ args: CVarArg) { + os_log(message, log: self, type: .error, args) + } + + func fault(_ message: StaticString, _ args: CVarArg) { + os_log(message, log: self, type: .fault, args) + } +} diff --git a/Firestore/Swift/Source/ReferenceableObject/FirestoreObjRefWrapper.swift b/Firestore/Swift/Source/ReferenceableObject/FirestoreObjRefWrapper.swift new file mode 100644 index 00000000000..ae3d8604497 --- /dev/null +++ b/Firestore/Swift/Source/ReferenceableObject/FirestoreObjRefWrapper.swift @@ -0,0 +1,148 @@ +/* + * Copyright 2023 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 FirebaseFirestore + +/// Property wrapper @FirestoreObjectReference, +/// Indicates that the specified property value should be stored by reference instead of by value +/// +/// When loading a parent object, any references are not loaded by default and can be loaded on demand +/// using the projected value of the wrapper. +/// +/// Structs that can be stored as a reference must implement the `ReferenceableObject` protocol +/// +/// Variables that are annotated with the propertyWrapper should be marked as `Optional` since they can be nil when not loaded or set +/// +/// Example: +/// We have three structs representing a simplified UserProfile model - +/// `UserProfile` +/// `Employer` - an employer who the `UserProfile` works for +/// `WorkLocation` - representing a generic location. This can be used to represent a generic location +/// +/// Since multiple Users can work for an Employer, it makes sense to have only one instance of an Employer that is referred to +/// by multiple Users. Similarly multiple users can be in a WorkLocation. Additionally, an Employer can also be located +/// in a WorkLocation (e.g. headquarters of an Employer). We can mark WorkLocation and Employer as a ReferenceableObjects. +/// +/// +/// ``` +/// struct UserProfile: ReferenceableObject { +/// var username: String +/// +/// @FirestoreObjectReference +/// var employer: Employer? +/// +/// @FirestoreObjectReference +/// var workLocation: WorkLocation? +/// +/// } +/// +/// struct Employer: ReferenceableObject { +/// var name: String +/// +/// @FirestoreObjectReference +/// var headquarters: WorkLocation? +/// } +/// +/// struct WorkLocation: ReferenceableObject { +/// var locationName: String +/// var moreInfo: String +/// +/// } +/// +/// var userProfile = ... +/// +/// // use projected value to load referenced employer +/// try await userProfile.$employer?.loadReferencedObject() +/// +/// // use projected value to load referenced workLocation +/// try await prof.$workLocation?.loadReferencedObject() +/// +/// +/// +/// ``` +/// +@available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) +@propertyWrapper +public struct FirestoreObjectReference where T: ReferenceableObject { + private var objectReference: ObjectReference? + + public init(wrappedValue initialValue: T?) { + updateInitialValue(initialValue: initialValue) + } + + private mutating func updateInitialValue(initialValue: T?) { + if let initialValue { + let objId = initialValue.id ?? { + let docRef = Firestore.firestore().collection(T.parentCollection()).document() + return docRef.documentID + }() + + objectReference = ObjectReference( + objectId: objId, + collection: T.parentCollection(), + referencedObject: initialValue + ) + } + } + + public var wrappedValue: T? { + get { + return objectReference?.referencedObject + } + + set { + if objectReference != nil { + objectReference?.referencedObject = newValue + } else { + updateInitialValue(initialValue: newValue) + } + } + } + + public var projectedValue: ObjectReference? { + get { + return objectReference + } + set { + objectReference = newValue + } + } +} + +@available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) +extension FirestoreObjectReference: Codable { + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let objRef = try container.decode(ObjectReference.self) + objectReference = objRef + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + if let objectReference { + try container.encode(objectReference) + + if let value = objectReference.referencedObject { + Task { + try await ReferenceableObjectManager.instance.save(object: value) + } + } + + } else { + try container.encodeNil() + } + } +} diff --git a/Firestore/Swift/Source/ReferenceableObject/ReferenceableObject.swift b/Firestore/Swift/Source/ReferenceableObject/ReferenceableObject.swift new file mode 100644 index 00000000000..16e0ae420d4 --- /dev/null +++ b/Firestore/Swift/Source/ReferenceableObject/ReferenceableObject.swift @@ -0,0 +1,119 @@ +/* + * Copyright 2023 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 FirebaseFirestore + +import CryptoKit +import OSLog + +/// A protocol that denotes an object that can be "stored by reference" in Firestore. +/// Structs that implement this and are contained in other Structs, are stored as a reference +/// instead of stored inline if the FirestoreObjectReference propertyWrapper is used to annotate them. +/// +@available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) +public protocol ReferenceableObject: Codable, Identifiable, Hashable, Equatable { + // The Firestore collection where objects of this type are stored. + // If no value is specified, it defaults to type name of the object + static func parentCollection() -> String + + var id: String? { get set } + + var path: String? { get } + + static func objectPath(objectId: String) -> String +} + +// default implementations +@available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) +public extension ReferenceableObject { + static func parentCollection() -> String { + let t = type(of: self) + return String(describing: t) + } + + func hash(into hasher: inout Hasher) { + hasher.combine(path) + } + + static func == (lhs: Self, rhs: Self) -> Bool { + if let lpath = lhs.path, + let rpath = rhs.path { + return lpath == rpath + } else { + return false + } + } + + var path: String? { + guard let id else { + return nil + } + + return Self.objectPath(objectId: id) + } + + // Helper function that creates a path for the object + static func objectPath(objectId: String) -> String { + let collection = Self.parentCollection() + if collection.hasSuffix("/") { + return collection + objectId + } else { + return collection + "/" + objectId + } + } +} + +// MARK: Contained Object Reference + +/// Struct used to store a reference to a ReferenceableObject. When a container struct is +/// encoded, any FirestoreObjectReference property wrappers are encoded as ObjectReference objects and the referenced +/// objects are stored (if needed) in their intended location instead of inline. +/// +/// For example: +/// When storing UserProfile, which contains an Employer referenceable object, Employer object is stored +/// in the Employer collection and a reference to Employer object is stored within the UserProfile encoded document. +/// +@available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) +public struct ObjectReference: Codable { + /// documentId of referenced object + public var objectId: String + + /// collection where the referenced object is stored + public var collection: String + + /// The referenced object. By default, it is not loaded. Apps can load this calling the loadReferencedObject() function. + public var referencedObject: T? + + enum CodingKeys: String, CodingKey { + case objectId + case collection + } + + /// Loads the referenced object from the db and assigns it to the referencedObject property + public mutating func loadReferencedObject() async throws { + let obj: T? = try await ReferenceableObjectManager.instance.getObject(objectId: objectId) + referencedObject = obj + } +} + +// MARK: Logger + +@available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) +extension FirestoreLogger { + static var objectReference = FirestoreLogger( + category: "objectReference" + ) +} diff --git a/Firestore/Swift/Source/ReferenceableObject/ReferenceableObjectManager.swift b/Firestore/Swift/Source/ReferenceableObject/ReferenceableObjectManager.swift new file mode 100644 index 00000000000..2070357419e --- /dev/null +++ b/Firestore/Swift/Source/ReferenceableObject/ReferenceableObjectManager.swift @@ -0,0 +1,273 @@ +/* + * Copyright 2023 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 FirebaseFirestore + +import CryptoKit +import OSLog + +/// Used to fetch and save ReferenceableObjects. +/// +/// To prevent refetch of the same referenced object immediately, the manager +/// also momentarily caches the referenced object. This interval is configurable. +/// +/// To prevent writes of unmodified referenced objects, the manager compares checksums for the +/// object being written. +@available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) +public class ReferenceableObjectManager { + public static var instance = ReferenceableObjectManager() + + static var settings = ReferenceableObjectManagerSettings() + + private var db = Firestore.firestore() + + private var objectCache = ReferenceableObjectCache() + + private let logPrefix = "ReferenceableObjectManager:" + + public func save(object: T) async throws { + do { + if let docId = object.id, + await objectCache.contains(for: docId) { + let encoder = Firestore.Encoder() + let json = try encoder.encode(object) + + guard let currentDigest = computeHash(obj: json), + await needsSave(object: object, currentDigest: currentDigest) else { + FirestoreLogger.objectReference.debug("%@ Object doesn't need to be saved", logPrefix) + return + } + + try await db.collection(T.parentCollection()).document(docId).setData(json) + await objectCache.add(object: object, digest: currentDigest) + } else { + let documentReference = db.collection(T.parentCollection()).document() + try documentReference.setData(from: object) + } + + FirestoreLogger.objectReference.debug("%@ save object complete", logPrefix) + } + } + + public func getObject(objectId: String) async throws -> T? { + do { + // first check cache + if let cacheEntry = await objectCache.get(for: T.objectPath(objectId: objectId)) { + return cacheEntry.object as? T + } + + // get from db + let documentReference = db.collection(T.parentCollection()).document(objectId) + let doc = try await documentReference.getDocument() + let obj = try doc.data(as: T.self) + + // cache the doc since we just fetched it from store + if let jsonData = doc.data(), + let digest = computeHash(obj: jsonData) { + await objectCache.add(object: obj, digest: digest) + } + + return obj + } + } + + public func getObjects(type: T.Type) async throws -> [T] { + var foundObjects = [T]() + do { + let collectionRef = db.collection(type.parentCollection()) + let docSnapshot = try await collectionRef.getDocuments() + + for document in docSnapshot.documents { + let refObj = try document.data(as: T.self) + foundObjects.append(refObj) + + let jsonData = document.data() + if let digest = computeHash(obj: jsonData) { + await objectCache.add(object: refObj, digest: digest) + } + } + } + + FirestoreLogger.objectReference.debug( + "%@ fetchObjects found %ld objects", + logPrefix, + foundObjects.count + ) + + return foundObjects + } + + public func getObjects(predicates: [QueryPredicate]) async throws + -> [T] { + var query: Query = db.collection(T.parentCollection()) + + query = createQuery(query: query, predicates: predicates) + + var foundObjects = [T]() + let snapshot = try await query.getDocuments() + + for document in snapshot.documents { + let refObj = try document.data(as: T.self) + foundObjects.append(refObj) + + let jsonData = document.data() + if let digest = computeHash(obj: jsonData) { + await objectCache.add(object: refObj, digest: digest) + } + } + + return foundObjects + } + + // MARK: Internal helper functions + + private func needsSave(object: T, + currentDigest: Insecure.MD5Digest) async -> Bool { + guard let objPath = object.path else { + // we don't have an object path so can't find cached value + // save object + return true + } + + guard let cacheEntry = await objectCache.get(for: objPath) else { + // we don't have a cached entry for this object. + // save object + return true + } + + guard cacheEntry.digest == currentDigest else { + // digests of cached object and current object to be saved don't match + // save object + return true + } + + return false + } + + private func computeHash(obj: [String: Any]) -> Insecure.MD5Digest? { + do { + let objData = try PropertyListSerialization.data( + fromPropertyList: obj, + format: .binary, + options: .max + ) + + var md5 = Insecure.MD5() + md5.update(data: objData) + let digest = md5.finalize() + + return digest + } catch { + // this doesn't prevent functionality so not erroring here. + FirestoreLogger.objectReference.info("Failed to compute hash") + return nil + } + } + + // logic copied from FirestoreQueryObservable.swift#createListener() + private func createQuery(query: Query, predicates: [QueryPredicate]) -> Query { + var query = query + + for predicate in predicates { + switch predicate { + case let .isEqualTo(field, value): + query = query.whereField(field, isEqualTo: value) + case let .isIn(field, values): + query = query.whereField(field, in: values) + case let .isNotIn(field, values): + query = query.whereField(field, notIn: values) + case let .arrayContains(field, value): + query = query.whereField(field, arrayContains: value) + case let .arrayContainsAny(field, values): + query = query.whereField(field, arrayContainsAny: values) + case let .isLessThan(field, value): + query = query.whereField(field, isLessThan: value) + case let .isGreaterThan(field, value): + query = query.whereField(field, isGreaterThan: value) + case let .isLessThanOrEqualTo(field, value): + query = query.whereField(field, isLessThanOrEqualTo: value) + case let .isGreaterThanOrEqualTo(field, value): + query = query.whereField(field, isGreaterThanOrEqualTo: value) + case let .orderBy(field, value): + query = query.order(by: field, descending: value) + case let .limitTo(field): + query = query.limit(to: field) + case let .limitToLast(field): + query = query.limit(toLast: field) + } + } + + return query + } +} + +@available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) +struct ReferenceableObjectManagerSettings { + // how long to cache object + // the purpose is not to cache for a long time + var cacheValidityInterval: TimeInterval = 5.0 // seconds +} + +@available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) +private struct ReferenceableObjectCacheEntry { + var digest: Insecure.MD5Digest + var object: any ReferenceableObject + var insertTime: TimeInterval +} + +@available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) +private actor ReferenceableObjectCache { + var cache = [String: ReferenceableObjectCacheEntry]() + + func add(object: T, digest: Insecure.MD5Digest) { + if let docId = object.id { + cache[docId] = ReferenceableObjectCacheEntry( + digest: digest, + object: object, + insertTime: Date().timeIntervalSince1970 + ) + FirestoreLogger.objectReference.debug("Added object to cache %@", docId) + } + } + + func get(for docId: String) -> ReferenceableObjectCacheEntry? { + guard let entry = cache[docId] else { + return nil + } + + let now = Date().timeIntervalSince1970 + let cacheTime = ReferenceableObjectManager.settings.cacheValidityInterval + guard now - entry.insertTime < cacheTime else { + // older entry - invalidate it + cache[docId] = nil + return nil + } + + return cache[docId] + } + + func contains(for docId: String) -> Bool { + guard cache[docId] != nil else { + return false + } + + return true + } + + func removeAll() { + cache.removeAll() + } +}