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

Closed
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
6df778d
Call a callable with encoded entity and decode the response. Also add…
mortenbekditlevsen Oct 23, 2021
819c37b
Add generic additions to StructureEncoder and StructureDecoder in ord…
mortenbekditlevsen Oct 25, 2021
cb6a165
Deleted previous Firestore coding
mortenbekditlevsen Oct 25, 2021
743c11c
Remove custom Firestore encoder and decoder and use StructureEncoder …
mortenbekditlevsen Oct 25, 2021
9f27bce
Merge branch 'codable-refactor' into firebasefunctionsswift
mortenbekditlevsen Oct 30, 2021
ec86d61
Style
mortenbekditlevsen Oct 30, 2021
bfb1353
Style
mortenbekditlevsen Oct 30, 2021
765f3a6
Merge branch 'firebasefunctionsswift' into firestore-structureencode
mortenbekditlevsen Nov 5, 2021
deec46c
Ran style.sh
mortenbekditlevsen Nov 5, 2021
33f4379
Only consider passthrough types during encoding if explicitly opting …
mortenbekditlevsen Nov 5, 2021
77acaae
Fix copyright headers
mortenbekditlevsen Nov 8, 2021
728446c
Use a passthrough type resolver instead of boolean flag + protocol co…
mortenbekditlevsen Nov 10, 2021
7a78dd0
Applied diff from Sebastian Schmidt, renaming framework and adding po…
mortenbekditlevsen Nov 11, 2021
f3b7279
Merge branch 'firestore-structureencode' into firestore-structureenco…
mortenbekditlevsen Nov 11, 2021
34718bb
Increase ergonomics by adding overloads to Firestore.Encoder and Fire…
mortenbekditlevsen Nov 11, 2021
aca0271
Update Package.swift with module rename
mortenbekditlevsen Nov 11, 2021
b10e1d2
Merge branch 'firestore-structureencode' into firestore-structureenco…
mortenbekditlevsen Nov 11, 2021
41820f0
Fix typo introduced at refactor
mortenbekditlevsen Nov 11, 2021
9c4b9a0
Created lightweight wrapper types for Firestore Encoder and Decoder t…
mortenbekditlevsen Nov 12, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
61 changes: 61 additions & 0 deletions FirebaseFunctionsSwift/Sources/Codable/Callable+Codable.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/*
* Copyright 2020 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 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


extension HTTPSCallable {
enum CallableError: Error {
case internalError
}

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.

encoder: StructureEncoder = StructureEncoder(),
decoder: StructureDecoder = StructureDecoder(),
completion: @escaping (Result<U, Error>)
-> Void) throws {
let encoded = try encoder.encode(data)
call(encoded) { result, error in
do {
if let result = result {
let decoded = try decoder.decode(U.self, from: result.data)
completion(.success(decoded))
} else if let error = error {
completion(.failure(error))
} else {
completion(.failure(CallableError.internalError))
}
} catch {
completion(.failure(error))
}
}
}

#if compiler(>=5.5) && canImport(_Concurrency)
@available(iOS 15, tvOS 15, macOS 12, watchOS 8, *)
public func call<T: Encodable, U: Decodable>(_ data: T,
resultAs: U.Type,
encoder: StructureEncoder = StructureEncoder(),
decoder: StructureDecoder =
StructureDecoder()) async throws -> U {
let encoded = try encoder.encode(data)
let result = try await call(encoded)
return try decoder.decode(U.self, from: result.data)
}
#endif
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@

import Foundation

public protocol StructureCodingPassthroughType: NSObject { }
public protocol StructureCodingUncodedUnkeyed {}

extension DecodingError {
/// Returns a `.typeMismatch` error describing the expected type.
///
Expand Down Expand Up @@ -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.


/// Contextual user-provided information for use during encoding.
open var userInfo: [CodingUserInfoKey : Any] = [:]

Expand All @@ -242,6 +248,7 @@ public class StructureEncoder {
let dataEncodingStrategy: DataEncodingStrategy
let nonConformingFloatEncodingStrategy: NonConformingFloatEncodingStrategy
let keyEncodingStrategy: KeyEncodingStrategy
let usePassthroughTypes: Bool
let userInfo: [CodingUserInfoKey : Any]
}

Expand All @@ -251,6 +258,7 @@ public class StructureEncoder {
dataEncodingStrategy: dataEncodingStrategy,
nonConformingFloatEncodingStrategy: nonConformingFloatEncodingStrategy,
keyEncodingStrategy: keyEncodingStrategy,
usePassthroughTypes: usePassthroughTypes,
userInfo: userInfo)
}

Expand Down Expand Up @@ -501,6 +509,7 @@ fileprivate struct _JSONKeyedEncodingContainer<K : CodingKey> : KeyedEncodingCon
}

public mutating func encode<T : Encodable>(_ value: T, forKey key: Key) throws {
if T.self is StructureCodingUncodedUnkeyed.Type { return }
self.encoder.codingPath.append(key)
defer { self.encoder.codingPath.removeLast() }
self.container[_converted(key).stringValue] = try self.encoder.box(value)
Expand Down Expand Up @@ -928,6 +937,8 @@ extension __JSONEncoder {
return (value as! NSDecimalNumber)
} else if value is _JSONStringDictionaryEncodableMarker {
return try self.box(value as! [String : Encodable])
} else if let passthrough = value as? StructureCodingPassthroughType, self.options.usePassthroughTypes {
return passthrough
}

// The value should request a container from the __JSONEncoder.
Expand Down Expand Up @@ -1601,6 +1612,11 @@ fileprivate struct _JSONKeyedDecodingContainer<K : CodingKey> : KeyedDecodingCon
}

public func decode<T : Decodable>(_ type: T.Type, forKey key: Key) throws -> T {
if T.self is StructureCodingUncodedUnkeyed.Type {
// Note: not pushing and popping key to codingPath since the key is
// not part of the decoded structure.
return try T.init(from: self.decoder)
}
guard let entry = self.container[key.stringValue] else {
throw DecodingError.keyNotFound(key, DecodingError.Context(codingPath: self.decoder.codingPath, debugDescription: "No value associated with key \(_errorDescription(of: key))."))
}
Expand Down Expand Up @@ -2505,6 +2521,9 @@ extension __JSONDecoder {
} else {
self.storage.push(container: value)
defer { self.storage.popContainer() }
if let passthrough = value as? StructureCodingPassthroughType, Swift.type(of: value) == type {
return passthrough
}
return try type.init(from: self)
}
}
Expand Down
1 change: 1 addition & 0 deletions Firestore/Swift/Source/Codable/CodableErrors.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,5 @@ public enum FirestoreDecodingError: Error {

public enum FirestoreEncodingError: Error {
case encodingIsNotSupported(String)
case topLevelTypesAreNotSupported
}
12 changes: 5 additions & 7 deletions Firestore/Swift/Source/Codable/CodablePassThroughTypes.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,9 @@

import Foundation
import FirebaseFirestore
import FirebaseSharedSwift

internal func isFirestorePassthroughType<T: Any>(_ value: T) -> Bool {
return
T.self == GeoPoint.self ||
T.self == Timestamp.self ||
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.

extension Timestamp: StructureCodingPassthroughType {}
extension FieldValue: StructureCodingPassthroughType {}
extension DocumentReference: StructureCodingPassthroughType {}
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ extension CollectionReference {
encoder: Firestore.Encoder = Firestore.Encoder(),
completion: ((Error?) -> Void)? = nil) throws
-> DocumentReference {
return addDocument(data: try encoder.encode(value), completion: completion)
encoder.usePassthroughTypes = true
let encoded = try encoder.encode(value)
guard let dictionaryValue = encoded as? [String: Any] else {
throw FirestoreEncodingError.topLevelTypesAreNotSupported
}
return addDocument(data: dictionaryValue, completion: completion)
}
}
30 changes: 11 additions & 19 deletions Firestore/Swift/Source/Codable/DocumentID.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@
*/

import FirebaseFirestore
import FirebaseSharedSwift

let documentRefUserInfoKey =
CodingUserInfoKey(rawValue: "DocumentRefUserInfoKey")!

/// A type that can initialize itself from a Firestore `DocumentReference`,
/// which makes it suitable for use with the `@DocumentID` property wrapper.
Expand Down Expand Up @@ -44,21 +48,6 @@ extension DocumentReference: DocumentIDWrappable {
}
}

/// An internal protocol that allows Firestore.Decoder to test if a type is a
/// DocumentID of some kind without knowing the specific generic parameter that
/// the user actually used.
///
/// This is required because Swift does not define an existential type for all
/// instances of a generic class--that is, it has no wildcard or raw type that
/// matches a generic without any specific parameter. Swift does define an
/// existential type for protocols though, so this protocol (to which DocumentID
/// conforms) indirectly makes it possible to test for and act on any
/// `DocumentID<Value>`.
internal protocol DocumentIDProtocol {
/// Initializes the DocumentID from a DocumentReference.
init(from documentReference: DocumentReference?) throws
}

/// A value that is populated in Codable objects with the `DocumentReference`
/// of the current document by the Firestore.Decoder when a document is read.
///
Expand All @@ -76,7 +65,7 @@ internal protocol DocumentIDProtocol {
/// Firestore.Encoder leads to an error.
@propertyWrapper
public struct DocumentID<Value: DocumentIDWrappable & Codable>:
DocumentIDProtocol, Codable {
Codable, StructureCodingUncodedUnkeyed {
var value: Value?

public init(wrappedValue value: Value?) {
Expand All @@ -101,9 +90,12 @@ public struct DocumentID<Value: DocumentIDWrappable & Codable>:
// MARK: - `Codable` implementation.

public init(from decoder: Decoder) throws {
throw FirestoreDecodingError.decodingIsNotSupported(
"DocumentID values can only be decoded with Firestore.Decoder"
)
guard let reference = decoder.userInfo[documentRefUserInfoKey] as? DocumentReference else {
throw FirestoreDecodingError.decodingIsNotSupported(
"Could not find DocumentReference for user info key: \(documentRefUserInfoKey)"
)
}
try self.init(from: reference)
}

public func encode(to encoder: Encoder) throws {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,11 @@ extension DocumentReference {
public func setData<T: Encodable>(from value: T,
encoder: Firestore.Encoder = Firestore.Encoder(),
completion: ((Error?) -> Void)? = nil) throws {
setData(try encoder.encode(value), completion: completion)
let encoded = try encoder.encode(value)
guard let dictionaryValue = encoded as? [String: Any] else {
throw FirestoreEncodingError.topLevelTypesAreNotSupported
}
setData(dictionaryValue, completion: completion)
}

/// Encodes an instance of `Encodable` and overwrites the encoded data
Expand All @@ -57,7 +61,11 @@ extension DocumentReference {
merge: Bool,
encoder: Firestore.Encoder = Firestore.Encoder(),
completion: ((Error?) -> Void)? = nil) throws {
setData(try encoder.encode(value), merge: merge, completion: completion)
let encoded = try encoder.encode(value)
guard let dictionaryValue = encoded as? [String: Any] else {
throw FirestoreEncodingError.topLevelTypesAreNotSupported
}
setData(dictionaryValue, merge: merge, completion: completion)
}

/// Encodes an instance of `Encodable` and writes the encoded data to the document referred
Expand All @@ -84,6 +92,11 @@ extension DocumentReference {
mergeFields: [Any],
encoder: Firestore.Encoder = Firestore.Encoder(),
completion: ((Error?) -> Void)? = nil) throws {
setData(try encoder.encode(value), mergeFields: mergeFields, completion: completion)
encoder.usePassthroughTypes = true
let encoded = try encoder.encode(value)
guard let dictionary = encoded as? [String: Any] else {
throw FirestoreEncodingError.topLevelTypesAreNotSupported
}
setData(dictionary, mergeFields: mergeFields, completion: completion)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

import Foundation
import FirebaseFirestore
import FirebaseSharedSwift

extension DocumentSnapshot {
/// Retrieves all fields in a document and converts them to an instance of
Expand All @@ -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.

d.dateDecodingStrategy = .timestamp(fallback: d.dateDecodingStrategy)
if let data = data(with: serverTimestampBehavior) {
return try d.decode(T.self, from: data, in: reference)
return try d.decode(T.self, from: data)
}
return nil
}
Expand Down
24 changes: 24 additions & 0 deletions Firestore/Swift/Source/Codable/EncoderDecoder.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/*
* Copyright 2021 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 FirebaseSharedSwift
import Foundation

extension Firestore {
public typealias Encoder = StructureEncoder
public typealias Decoder = StructureDecoder
}
81 changes: 81 additions & 0 deletions Firestore/Swift/Source/Codable/TimestampDecodingStrategy.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/*
* Copyright 2021 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 FirebaseFirestore
import FirebaseSharedSwift

@available(macOS 10.12, iOS 10.0, watchOS 3.0, tvOS 10.0, *)
private var _iso8601Formatter: ISO8601DateFormatter = {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = .withInternetDateTime
return formatter
}()

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.

return .custom { decoder in
let container = try decoder.singleValueContainer()
do {
let value = try container.decode(Timestamp.self)
return value.dateValue()
} catch {
switch fallback {
case .deferredToDate:
return try Date(from: decoder)

case .secondsSince1970:
let double = try container.decode(Double.self)
return Date(timeIntervalSince1970: double)

case .millisecondsSince1970:
let double = try container.decode(Double.self)
return Date(timeIntervalSince1970: double / 1000.0)

case .iso8601:
if #available(macOS 10.12, iOS 10.0, watchOS 3.0, tvOS 10.0, *) {
let string = try container.decode(String.self)
guard let date = _iso8601Formatter.date(from: string) else {
throw DecodingError.dataCorrupted(DecodingError.Context(
codingPath: decoder.codingPath,
debugDescription: "Expected date string to be ISO8601-formatted."
))
}

return date
} else {
fatalError("ISO8601DateFormatter is unavailable on this platform.")
}

case let .formatted(formatter):
let string = try container.decode(String.self)
guard let date = formatter.date(from: string) else {
throw DecodingError.dataCorrupted(DecodingError.Context(
codingPath: decoder.codingPath,
debugDescription: "Date string does not match format expected by formatter."
))
}

return date

case let .custom(closure):
return try closure(decoder)
}
}
}
}
}