Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[WIP] - Referenceable Object Swift PropertyWrapper - @FirestoreObjectReference #11330

Closed
wants to merge 4 commits into from

Conversation

aashishpatil-g
Copy link
Contributor

@aashishpatil-g aashishpatil-g commented May 25, 2023

Discussion

This PR proposes a Swift property wrapper (@FirestoreObjectReference) to store object (non-scalar) properties (vars) by reference in Firestore instead with the document blob. It automates storing the object in a specified location and saves the reference id in the parent document instead of the actual object. This is useful for scenarios where a common object must be used / referenced across multiple parent objects.

When reading/loading the parent document (object) from Firestore, the propertyWrapper provides a way to load the referenced object via the projected value of the wrapper. The loadReference() call on the projected value is async.

For objects to participate in the propertyWrapper, they must implement the ReferenceableObject Protocol.

Here is a sample data model and how the propertyWrapper is used.

We have three structs representing a simplified UserProfile model -
UserProfile
Employer - an employer who the UserProfile works for
WorkLocation - representing 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?.loadObject()

// use projected value to load referenced workLocation
try await prof.$workLocation?.loadObject()

TODO

The following items are still to be done.

  • Provide an example app. Have to clean up what I have
  • Unit Tests

@google-oss-bot
Copy link

1 Warning
⚠️ Did you forget to add a changelog entry? (Add #no-changelog to the PR description to silence this warning.)

Generated by 🚫 Danger

@github-actions
Copy link
Contributor

github-actions bot commented May 25, 2023

Apple API Diff Report

Commit: 6e3475f
Last updated: Sun May 28 22:14 PDT 2023
View workflow logs & download artifacts


FirebaseFirestoreSwift

Structures

[ADDED] FirestoreObjectReference
[ADDED] FirestoreObjectReference
Swift:
+  @available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) @propertyWrapper public struct FirestoreObjectReference < T > where T : ReferenceableObject extension FirestoreObjectReference : Codable
+    public init ( wrappedValue initialValue : T ?)
+    public var wrappedValue : T ? { get set }
+    public var projectedValue : ObjectReference < T > ? { get set }
+    public init ( from decoder : Decoder ) throws
+    public func encode ( to encoder : Encoder ) throws
[ADDED] ObjectReference
[ADDED] ObjectReference
Swift:
+    public var objectId : String
+    public var collection : String
+    public var referencedObject : T ?
+    public mutating func loadReferencedObject () async throws
[ADDED] Classes
Swift:
+    @available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) public class ReferenceableObjectManager
+      public static var instance : ReferenceableObjectManager
+      public func save < T > ( object : T ) async throws where T : ReferenceableObject
+      public func getObject < T > ( objectId : String ) async throws -> T ? where T : ReferenceableObject
+      public func getObjects < T > ( type : T . Type ) async throws -> [ T ] where T : ReferenceableObject
+      public func getObjects < T : ReferenceableObject > ( predicates : [ QueryPredicate ]) async throws -> [ T ]

Protocols

[ADDED] ReferenceableObject
[ADDED] ReferenceableObject
Swift:
+  @available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) public protocol ReferenceableObject : Decodable , Encodable , Hashable , Identifiable
+    static func parentCollection () -> String
+    var id : String ? { get set }
+    var path : String ? { get }
+    static func objectPath ( objectId : String ) -> String
+    func hash ( into hasher : inout Hasher )
+    static func == ( lhs : Self , rhs : Self ) -> Bool

Copy link
Contributor

@peterfriese peterfriese left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I generally like the idea (object references are a feature of Firestore, but we haven't had client side support for them so far), however, I would love to hear the Firestore team's opinion on whether we should expose this to the client SDKs.

Sample app: I started a sample at in https://github.com/firebase/firebase-ios-sdk/tree/master/Example/FirestoreSample, which contains a menu structure that should make it easy to add a sample for this property wrapper.


/// Property wrapper @FirestoreObjectReference,
/// Indicates that the specified property value should be stored by reference instead of by value, inline
/// with the parent object.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not sure what "inline with the parent object" refers to.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can't find a good way to describe this. Checked both these videos but not finding a good naming.

Firestore data structure
https://www.youtube.com/watch?v=haMOUb3KVSo

Firestore Data Modeling I/O 2019
https://www.youtube.com/watch?v=lW7DWV2jST0

/// 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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Uppercase?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed.

///
/// 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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Uppercase

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed

///
/// ```
/// struct UserProfile: ReferenceableObject {
/// var username: String
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Indentation 2 spaces?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed

///
@available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *)
@propertyWrapper
public struct FirestoreObjRef<T> where T: ReferenceableObject {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know it's quite a mouthful, but I think I might prefer FirestoreObjectReference over the shortened version. Code completion is your friend.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense. Made the change. Tx!

try await db.collection(T.parentCollection()).document().setData(json)
}

print("Save complete")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might want to use the logger.

Copy link
Contributor Author

@aashishpatil-g aashishpatil-g May 29, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. I also refactored the Logger class so it could be reused across FirestoreSwift if need be.

do {
// first check cache
if let cacheEntry = await objectCache.get(for: T.objectPath(objectId: objId)) {
return cacheEntry.object as! T
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a way how we can cast the type without coercing it?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I haven't been able to figure this one out. Its odd. 'object' is a ReferenceableObject and so is 'T'. Not sure why compiler is not letting me just return object without a cast.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok optional casting removes the warning and its not a forced cast anymore. In reality if cacheEntry exists, object will not be nil since its not an optional but optional casting makes compiler happy.

}

// get from db
let docRef = db.collection(T.parentCollection()).document(objId)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

documentReference / objectId

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed

return foundObjects
}

public func fetchObjects<T: ReferenceableObject>(predicates: [QueryPredicate]) async throws
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Our other APIs use get instead of fetch and Document instead of Object, (see https://firebase.google.com/docs/firestore/query-data/get-data#get_a_document)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed the function name to use get. Kept object to stay consistent with the PRs nomenclature.


var foundObjects = [T]()
let snapshot = try await query.getDocuments()
for document in snapshot.documents {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe use a compactMap instead (like we do in most of our samples for mapping collections).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't seem to work. I think the issue is that its within an async method.

@google-oss-bot
Copy link

google-oss-bot commented May 25, 2023

Coverage Report 1

Affected Products

  • FirebaseFirestore-iOS-FirebaseFirestore.framework

    Overall coverage changed from 88.07% (9f009df) to 88.14% (6e3475f) by +0.07%.

    FilenameBase (9f009df)Merge (6e3475f)Diff
    exception.cc23.68%84.21%+60.53%
    leveldb_key.cc98.63%98.14%-0.49%
  • FirebaseFirestore-iOS-FirebaseFirestoreSwift.framework

    Overall coverage changed from 46.92% (9f009df) to 33.87% (6e3475f) by -13.05%.

    FilenameBase (9f009df)Merge (6e3475f)Diff
    FirestoreLogger.swift?0.00%?
    FirestoreObjRefWrapper.swift?0.00%?
    ReferenceableObject.swift?0.00%?
    ReferenceableObjectManager.swift?0.00%?

Test Logs

  1. https://storage.googleapis.com/firebase-sdk-metric-reports/n9BCBF4Epo.html

@aashishpatil-g aashishpatil-g changed the title [WIP] - Referenceable Object Swift PropertyWrapper - @FirestoreObjRef [WIP] - Referenceable Object Swift PropertyWrapper - @FirestoreObjectReference May 29, 2023
@aashishpatil-g
Copy link
Contributor Author

@peterfriese

Thanks for the detailed review. I've addressed the comments except

  • Not sure whats the right way to describe the "inline storage" vs "by reference"
  • Will do the sample app in a separate commit.

@paulb777
Copy link
Member

Please close, merge, or comment. We plan to close stale PRs on November 28, 2023.

@aashishpatil-g
Copy link
Contributor Author

aashishpatil-g commented Nov 14, 2023

Going to close this for now as I haven't made progress on sample app. Will reopen later if still viable.

@firebase firebase locked and limited conversation to collaborators Dec 14, 2023
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants