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

Make Firestore use StructureEncoder and StructureDecoder #8858

Conversation

mortenbekditlevsen
Copy link
Contributor

@mortenbekditlevsen mortenbekditlevsen commented Oct 25, 2021

Note: this builds on upon the PR #8854 , but uses StructureEncoder and StructureDecoder for Firestore as well.

Benefits:

  • Reuse code.
  • Users can now use key coding strategies and other features that they know from JSONEncoder and JSONDecoder.

@google-cla google-cla bot added the cla: yes label Oct 25, 2021
@mortenbekditlevsen mortenbekditlevsen changed the title Firestore structureencode Make Firestore use StructureEncoder and StructureDecoder Oct 25, 2021
@mortenbekditlevsen mortenbekditlevsen changed the base branch from master to codable-refactor October 30, 2021 11:22
@mortenbekditlevsen
Copy link
Contributor Author

Note: I thought a bit about this, and there is currently an error in the implementation.
Currently I made a PassthroughType protocol that types can opt-in to. But the point is that it should only be passed through when encoding to and decoding from Firestore. For other codable situations the types should not be passed through.
This means that it would probably be better to define a list of passthrough types as an entry in the userInfo dict, so that it can be used at the correct places and not elsewhere.

@paulb777 paulb777 force-pushed the codable-refactor branch 2 times, most recently from c90e26a to b900e9c Compare November 2, 2021 23:44
@schmidt-sebastian
Copy link
Contributor

@mortenbekditlevsen Thanks for putting this together. I will spend the next couple of days looking at this and should be able to figure out whether this works for us by the end of next week.

@mortenbekditlevsen
Copy link
Contributor Author

Hi @schmidt-sebastian ,
Thank you for looking at this
As I mentioned, the pass through types really only ought to be preserved during encoding when explicitly opting in to do so (in combination with using Firestore). I've added a commit to the PR to express this.
During decoding it's ok to have the check because the structures that will be passed in to this (in combination with RTDB and Functions) will not be containing the passthrough types.

@mortenbekditlevsen
Copy link
Contributor Author

mortenbekditlevsen commented Nov 6, 2021

The latest version uses a combination of a boolean flag and protocol conformance to the encoder to signify pass through types.
Thinking about this it may be cleaner to just pass a function to the encoder/decoder that exactly matches the isFirestorePassthroughType from the existing implementation.

This, however, is not simple to do due to the generic argument of the isFirestorePassthroughType. Basically it's hard to express this generic parameter on a reassignable closure parameter. So I guess some type erasure would be needed to implement this.

Let me know if you think that the current implementation is ok or if we need something better.


extension StructureDecoder.DateDecodingStrategy {
public static func timestamp(fallback: StructureDecoder
.DateDecodingStrategy = .deferredToDate) -> StructureDecoder.DateDecodingStrategy {
Copy link
Contributor

Choose a reason for hiding this comment

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

Can you quickly explain when this would be used in Firestore since we do not use JSON-formatted dates?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Sure - no other reason than: someone might have a different preference then encoding to / decoding from the Timestamp type.

If I wish to represent my date as the number of seconds since epoch or as a string specifying week-year and week of the date: "2021-44" or as ISO 8601 or anything else, then I don't think that the API should force me to use Timestamp.

If you disagree then this can of course just be removed.

Copy link
Contributor

Choose a reason for hiding this comment

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

If I understand this correctly, this allows user to use String-based dates in their Codable objects, but we will store them as Timestamps? If so, then this makes sense.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

As this is in the direction of decoding it's more like:
If you have a Date in you encodable model and a Timestamp in you Firestore document, then the decoding will map the Timestamp to the Date. This step is to be API compatible with functionality that is already a part of the existing Firestore.Decoder. But it also lets you express a date decoding strategy to use when the Firestore document is not a Timestamp.
Perhaps you might always store iso8601 encoded date strings in your Firestore document - or you might be storing seconds or miliseconds since epoch. For whatever reasons - historical, compatibility with another framework, etc., etc.
So this just allows the exact same configurability that users may be used to from JSONDecoder while still being compatible with the existing internals of Firestore.Decoder that would always automatically decode a Timestamp to a Date if that was requested by the user.

This strategy should however be internal instead of public since it's used internally and is just there to ensure backwards compatibility with the existing Firestore.Decoder.

@@ -233,6 +236,9 @@ public class StructureEncoder {
/// The strategy to use for encoding keys. Defaults to `.useDefaultKeys`.
open var keyEncodingStrategy: KeyEncodingStrategy = .useDefaultKeys

/// If `usePassthroughTypes` is set to `true`, then any value of a type conforming to StructureCodingPassthroughType will not be encoded, but left as is in the resulting structure
open var usePassthroughTypes: Bool = false
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 reason why this would not be turned by default (or always)? The only issue I see is that some of the Firestore types might be treated as Passthrough types when used in RTDB and Functions. This however is already an error condition.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, exactly so that you could use the same models with Firestore, RTDB and callable functions:

struct Model: Codable {
   var coord: GeoPoint
}

Would encode as a native GeoPoint in Firestore, but use the perfectly valid Codable implementation of GeoPoint for RTDB and functions.

Copy link
Contributor

@schmidt-sebastian schmidt-sebastian left a comment

Choose a reason for hiding this comment

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

Some quick notes. I need to do more testing to see if this supports all existing use cases.

@@ -37,8 +38,10 @@ extension DocumentSnapshot {
with serverTimestampBehavior: ServerTimestampBehavior = .none,
decoder: Firestore.Decoder? = nil) throws -> T? {
let d = decoder ?? Firestore.Decoder()
d.userInfo[documentRefUserInfoKey] = reference
Copy link
Contributor

Choose a reason for hiding this comment

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

Do you think there is a different way we could handle this? This does not seem very discoverable.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

My hopes were that the end user does not have any need for discovering this, as it would be an implementation detail of the Firestore Codable additions.

Don't you think that it's realistic that the Codable overlays for swift provided by the SDK were sufficient, so that end users would never directly decode structures from Firestore 'manually'?

As a side note, I really think that the @DocumentID property wrapper (which the above aims to support) is an anti-pattern, as it ties your model to a very specific use case - but of course it must be supported here as it's already part of the API.

T.self == FieldValue.self ||
T.self == DocumentReference.self
}
extension GeoPoint: StructureCodingPassthroughType {}
Copy link
Contributor

Choose a reason for hiding this comment

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

FWIW, I think the updated protocol-based implementation is more maintainable than what we had before.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thanks.
The only thing that bothers me is that conforming to a protocol states globally that you are a thing that needs to be left alone during structure encoding and decoding.
In actuality it's probably more likely that it's tied to the specific underlying framework (Firestore) - and thus not really a global opt-in. This is of course why I added the boolean flag to opt-in to this behavior in general.

I think it would be a cleaner solution to be able to have separate passthrough types for separate use cases. Thus going back to something like the previous function instead of conforming to a type.
As closures in Swift can't be generic (or at least you need to know the generic parameter when assigning the closure to a property), then it's not possible to pass a 'passthroughTypes()' function directly to the encoder/decoder, but I think you could create a protocol with a requirement of a generic function and then wrap the function in a struct conforming to the closure.

I haven't tried it, but something like:

protocol StructureCodingPassthroughTypeResolver {
   func isPassthroughType<T: Any>(_ t: T) -> Bool
}
struct FirestorePassthroughTypeResolver: StructureCodingPassthroughTypeResolver {
 ...
}

Then again: perhaps such a design is overkill since the only use case for the passthrough types is currently Firestore...

What do you think?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The function requirement of the protocol could even be static, so that no actual instance of a resolver needs to be passed around, just the type...

Copy link
Contributor

Choose a reason for hiding this comment

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

Do you think we might be able to expand on the id of the StructureCodingPassthroughTypeResolver and make it handle both the @DcoumentId annotation as well as the Passthrough types? We could call the new TypeResolver for every type and can optionally implement custom encoding.

protocol TypeResolver {
  func encode<T : Encodable>(_ value: T, forKey key: Key) : Optional<Data>;
  func decode<T : Decodable>(_ type: T.Type, forKey key: Key) : Decode<T>;
}

struct FirestoreTypeResolver: TypeResolver {
  func encode<T : Encodable>(_ value: T, forKey key: Key) : Optional<Data> {
    // Handles DocumenID, GeoPoint, Timestamp, DocumentReference. Returns Optional.none for all other types
   }

  ...
}

The DocumentReference can then be passes to FirestoreTypeResolver, which means that we no longer need to add it to userInfo.

I am not sure how feasible this is without implementing it first, but you might some more expertise here.

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've tried looking at it, and I am not certain that the two concepts can be bridged.

The passthrough type checking is being used in the box/unbox methods of the encoder/decoder and thus don't have access to any keys.

Keys are actually also ignored in the documentid decoding (since the point is that there is no data at the currently parsed level in the input, that corresponds to the id - it comes from somewhere else).

So the signatures should perhaps rather be encode<T>(_ value: T): Optional<T> and similar for decode - to allow passing types through. So basically the existing boolean function check converted to an optional of the input...

But for decoding DocumentID this type signature is not really a fit since there's no 'DocumentID' value to pass along to the decoding...


I did try implementing a passthrough type resolver - and I enhanced discoverability a little by introducing a public func decode<T: Decodable>(_ t: T.Type, from data: Any, in reference: DocumentReference)
overload on Firestore.Decoder (aka StructureDecoder).

I updated the PR branch with the new refactor. Let me know what you think.

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 just had a different thought:
Instead of Firestore.Decoder being a typealias for StructureDecoder (and ditto encoder), it could be a really thin wrapper, so that the correct configuration is always guaranteed.
What do you think about that idea?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

In the latest version of the PR I tried implementing the lightweight wrapper types around the encoder and decoder, and that does indeed give a much nicer API at the use point.


import Foundation
import FirebaseFunctions
import FirebaseSharedSwift
Copy link
Contributor

Choose a reason for hiding this comment

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

Can this be FirebaseEncoderSwift? Reason I ask is because we already have FirebaseCommon, so in some ways it would make sense to rename this to FirebaseCommonSwift... with the exception that we do not want to include this code for users that do not use RTDB, Functions or Firestore. So maybe the naming should indicate that this is Encoding specific rather than a shared Swift library.

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 also not able to build from your branch as this module cannot be found. Do you have a PR that includes a Podspec?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Sure, I don't have any preference for the naming. Should I go ahead and rename?

With regards to building, I am not familiar with the CocoaPods setup, so initially I've just added support for SwiftPM. I think @paulb777 mentioned that the podspec could perhaps be added later by one more knowledgeable than me in this area. :-)

Copy link
Contributor

Choose a reason for hiding this comment

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

Yeah, I think we should rename. I will see if I can put together a Podspec.

Copy link
Contributor

Choose a reason for hiding this comment

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

I created a diff with the Podspec: https://gist.github.com/schmidt-sebastian/a3df8aac27067ef572351627664ba175

Let me know if you can apply this, otherwise I will fork your repo and create a PR.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thanks - it's now applied and pushed. :-)

Copy link
Contributor

Choose a reason for hiding this comment

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

I also need the Podspec for FirebaseFunctionsSwift: https://gist.github.com/schmidt-sebastian/8b726a52540c972a2e083d460a51f954

Copy link
Contributor

@schmidt-sebastian schmidt-sebastian left a comment

Choose a reason for hiding this comment

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

Some quick notes. I need to do more testing to see if this supports all existing use cases.

…nformance - needs cleanup in StructureEncoder
@schmidt-sebastian schmidt-sebastian self-assigned this Nov 10, 2021
@mortenbekditlevsen
Copy link
Contributor Author

I have a small question: In the existing Firestore encoding tests, I can see that a Date always roundtrips to Date after encode and decode.
Now that date encoding strategies are available, wouldn't it be nice to have Date encode to Timestamp using such a strategy?
This would be a new, opt-in feature.

…o ensure proper configuration of the underlying encoder/decoder
@mortenbekditlevsen
Copy link
Contributor Author

I have a small question: In the existing Firestore encoding tests, I can see that a Date always roundtrips to Date after encode and decode.
Now that date encoding strategies are available, wouldn't it be nice to have Date encode to Timestamp using such a strategy?
This would be a new, opt-in feature.

I took the liberty to implement this strategy in the latest version of this PR.

@schmidt-sebastian
Copy link
Contributor

I have a small question: In the existing Firestore encoding tests, I can see that a Date always roundtrips to Date after encode and decode.
Now that date encoding strategies are available, wouldn't it be nice to have Date encode to Timestamp using such a strategy?
This would be a new, opt-in feature.

I took the liberty to implement this strategy in the latest version of this PR.

Are we not able to to determine what type we need to convert to based on what we find in the Codable object?

I will probably add more tests to this PR to make sure that we do not break anything.

@mortenbekditlevsen
Copy link
Contributor Author

Yes, but if you models in general contains Date entities (like for instance if you don't want to tie your models to Firestore) - but you'd still prefer that those dates were represented as Timestamps when used with Firestore, then you can now just use the .timestamp date encoding strategy.

}

public func call<T: Encodable, U: Decodable>(_ data: T,
resultAs: U.Type,
Copy link
Contributor

Choose a reason for hiding this comment

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

Can we drop resultAs? The compiler should be able to infer the argument from the Result type.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

So all of the functions-relate code is already part of PR #8854 - apologies for including the same code here too, but I developed it in three steps - first the refactor to a shared module, then the functions part and finally the firestore part.

But you're completely right - the compiler is indeed able to infer the type. However, there is precedence for including the type used for decoding - in both Apple's JSONDecoder and also in the existing Firestore swift overlays:

https://developer.apple.com/documentation/foundation/jsondecoder/2895189-decode

Meaning that the API lets you do:

let a = try decoder.decode(GroceryProduct.self, from: json)

rather than:

let a: GroceryProduct = try decoder.decode(from: json)

And similar in the Firestore overlays:
https://github.com/firebase/firebase-ios-sdk/blob/master/Firestore/Swift/Source/Codable/DocumentSnapshot%2BReadDecodable.swift

  public func data<T: Decodable>(as type: T.Type,
                                 with serverTimestampBehavior: ServerTimestampBehavior = .none,
                                 decoder: Firestore.Decoder? = nil) throws -> T? {
...
  }

which is used as:

let a = try snapshot.data(as: GroceryProduct.self)

So even in the very direct API there's precedence for including the type - and when using a callback that has even more value, because otherwise the end user would be forced to spell out the type inside of the callback closure, which makes them quite verbose.

Note also, that in the latest commit on PR #8854, this is where I moved the types into the function that returns the callable - meaning that once you have defined your callable, the types are directly represented by that callable, meaning that you neither have to spell out the request or response types at the call site, and you get compiler validation that you only use the callable with input data of the request type.

@paulb777
Copy link
Member

Closing in favor of #9465

@paulb777 paulb777 closed this Apr 13, 2022
@firebase firebase locked and limited conversation to collaborators May 14, 2022
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.

None yet

3 participants