-
Notifications
You must be signed in to change notification settings - Fork 1.5k
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
Continue Firestore Codable 3 #2229
Conversation
Any updates on this @ryanwilson @wilhuff ? |
@felixsolorzano Next steps:
I have some other priorities continuing into this week and hope to get back to this PR in the later part of the week. In the meantime, feel free to work on any of the above by sending a PR to this |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Reviewing tests now.
do { | ||
setData(try Firestore.Encoder().encode(value)) | ||
} catch let error { | ||
fatalError("TODO \(error)") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
"Unable to encode data with Firestore encoder: \(error)"
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Here and a few places below where we also throw.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Doesn't throwing mess up the signature and usability? Is there a better option than fatalError
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Crossed my terms up a bit here, I meant "throwing the fatal error" - should have said "call fatalError" or something 😄
} | ||
let dict = ["x": 42] | ||
let model = Model(x: 42, opt: nil) | ||
XCTAssertEqual(model.x, roundTrip(input: model, expected: dict).x) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can we split the round trip out and also test model.opt
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Isn't it being tested on the last line?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This would be less easy to miss if Model
were Equatable
.
assertRoundTrip(model: Model(x: 42, opt: nil), encoded: ["x": 42])
assertRoundTrip(model: Model(x: 42, opt: 7), encoded: ["x": 42, "opt": 7])
Also, more cases that don't round trip are worth testing:
assertDecodes(["x": 42, "opt": nil], to: Model(x: 42, opt: nil))
Also cases that are invalid are worth testing too:
assertDecodingFails(Model.self, from: ["x": 42, "opt": true])
Firestore/Swift/Source/Codable/third_party/FirestoreEncoder.swift
Outdated
Show resolved
Hide resolved
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This LGTM , please wait for @wilhuff's review.
Thanks @ryanwilson , the PR still needs to ensure the original, overridden APIs work correctly and merge master. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
First pass.
import Foundation | ||
import FirebaseFirestore | ||
|
||
extension CollectionReference { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These extensions are adding API surface sugar and don't have anything to do with the FirestoreEncoder.
Given the separate provenance of this stuff and the complexity of the encoder proper, we should try to keep the sugar separate from the FirestoreEncoder
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Separated.
} | ||
} | ||
|
||
extension DocumentReference { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should these things be in DocumentReference+Codable.swift? Similarly should the other extensions here go in Foo+Codable for all Foos? (Note that I think this would be on the DocumentReference
extension rather than the CodableDocumentReference
extension.
One point of concern is that these methods aren't strictly required for Codable--rather they're Firestore-specific extensions to the API to make encoding simple. Then again, these are intimately related to our Codable support so it seems reasonable to just have one file per type we're extending.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done.
extension CollectionReference { | ||
public func addDocument<T: Encodable>(_ item: T) -> DocumentReference { | ||
do { | ||
return addDocument(data: try Firestore.Encoder().encode(item)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This Firestore.Encoder().encode()
incantation is cumbersome.
Secondarily, nesting the Encoder
and Decoder
within Firestore
seems inconsistent with other similar types. For example, JSONEncoder
and JSONDecoder
aren't both nested in some common outer type. Within Firestore
, our "settings" object is FirestoreSettings
not Firestore.Settings
. I think this argues for marking the encoder type FirestoreEncoder
.
However, do we even need to expose the FirestoreEncoder
type? Could we just have a static method hanging off Firestore, and call it like Firestore.encode()
? JSONEncoder
doesn't implement some common supertype so it doesn't seem like you could write generic code that uses different encoder types.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Changed to public static Firestore.encode and Firestore.decode
let encoded = try Firestore.Encoder().encode(item) | ||
return addDocument(data: encoded, completion: completion) | ||
} catch let error { | ||
Firestore.firestore().settings.dispatchQueue.sync { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The dispatch queue associated with the default settings may not be the same as the dispatch queue for this instance of Firestore. This should be self.firestore.settings.dispatchQueue
to refer through the settings for this instance.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
N/A because of switch to fatalError
|
||
extension CollectionReference { | ||
public func addDocument<T: Encodable>(_ item: T) -> DocumentReference { | ||
do { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we could greatly reduce the amount of boilerplate in here if we had some common code like this:
internal func encodeOrDie<T: Encodable>(_ value: T) -> [String : Any] {
do {
return try Firestore.encode(value)
} catch let error {
fatalError("Unable to encode data with Firestore encoder: \(error)")
}
}
Then each type that exposes these kinds of methods could be implemented like this:
extension CollectionReference {
public func addDocument<T: Encodable>(_ item: T) -> DocumentReference {
let encoded = encodeOrDie(item)
return addDocument(data: encoded)
}
}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done in EncodeOrDie.swift
Firestore.firestore().settings.dispatchQueue.sync { | ||
completion!(error) | ||
} | ||
return document() // Is there something better to return after the error? |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This particular wart exposes a major problem. As I'm messing around with this I think the inconsistent failure behavior between completion-accepting and non-completion-accepting variants of these methods is wrong. It makes it difficult to describe what our expectations are on conforming types, and makes it difficult to concisely describe the API guarantee we're making.
I'd like us to say simply that encoding errors on any of these encodable-accepting types are fatalErrors, and then we don't need to synthesize fake instances to return like this.
Users that want to handle the error programmatically can use the encode function (or encoder type) directly.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Changed to always fatalError
} | ||
} | ||
|
||
fileprivate class _FirestoreEncoder: Encoder { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I can't see what you're changing in here. I'd like us to do a separate PR for moving the FirestoreEncoder into third_party so that it's more obvious we're changing.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is a new file to master and mostly unchanged from the original PR. Perhaps, best to look at commit by commit history?
} | ||
} | ||
|
||
fileprivate class _FirestoreEncoder: Encoder { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is a new file to master and mostly unchanged from the original PR. Perhaps, best to look at commit by commit history?
@@ -198,7 +198,7 @@ func writeDocuments(at docRef: DocumentReference, database db: Firestore) { | |||
} | |||
|
|||
func addDocument(to collectionRef: CollectionReference) { | |||
collectionRef.addDocument(data: ["foo": 42]) | |||
_ = collectionRef.addDocument(data: ["foo": 42]) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Prevents warning if collectionRef is a Swift API.
@paulb777 thanks for the work done. Any updates on this issue? |
@cabarique The last batch of changes is running CI on travis as we speak. The next step will be to continue the review with @wilhuff, @ryanwilson, and anyone else with input. Does the current implementation satisfy your requirements? |
@paulb777 It does. Looking forward its completion. |
HEADER_SEARCH_PATHS = ( | ||
"$(inherited)", | ||
"\"${PODS_ROOT}/../../..\"", | ||
"\"${PODS_ROOT}/../../../Firestore/third_party/abseil-cpp\"", |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This should not be required and is likely harmful. We don't want any C++ when compiling Swift.
Same for the Release configuration below.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Here's what happens without the abseil include building the swift tests:
In file included from /Users/paulbeusterien/gh/firebase-ios-sdk/Firestore/Example/Tests/API/FSTAPIHelpers.mm:25:
In file included from /Users/paulbeusterien/gh/firebase-ios-sdk/Firestore/Example/Tests/Util/FSTHelpers.h:22:
In file included from /Users/paulbeusterien/gh/firebase-ios-sdk/Firestore/Source/Model/FSTDocument.h:19:
In file included from /Users/paulbeusterien/gh/firebase-ios-sdk/Firestore/core/src/firebase/firestore/model/document_key.h:26:
In file included from /Users/paulbeusterien/gh/firebase-ios-sdk/Firestore/Source/Model/FSTDocumentKey.h:22:
In file included from /Users/paulbeusterien/gh/firebase-ios-sdk/Firestore/core/src/firebase/firestore/model/resource_path.h:24:
In file included from /Users/paulbeusterien/gh/firebase-ios-sdk/Firestore/core/src/firebase/firestore/model/base_path.h:27:
In file included from /Users/paulbeusterien/gh/firebase-ios-sdk/Firestore/core/src/firebase/firestore/util/hard_assert.h:23:
In file included from /Users/paulbeusterien/gh/firebase-ios-sdk/Firestore/core/src/firebase/firestore/util/string_format.h:24:
/Users/paulbeusterien/gh/firebase-ios-sdk/Firestore/core/src/firebase/firestore/util/string_apple.h:29:10: fatal error: 'absl/strings/string_view.h' file not found
#include "absl/strings/string_view.h"
^~~~~~~~~~~~~~~~~~~~~~~~~~~~
1 error generated.
* limitations under the License. | ||
*/ | ||
|
||
enum FirestoreDecodingError: Error { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These need comments if they're going to be public.
We can ignore this for now though.
import Foundation | ||
import FirebaseFirestore | ||
|
||
func isCodablePassThroughType<T: Any>(_ value: T) -> Bool { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A few thoughts:
- This could be
internal
, no? T
could likely be constrained toCodable
.- The name is slightly off, see below
The issue with the name is that these aren't pass-through for Codable generally so much as they need special handling in the Firestore encoder and decoder. I'd suggest either calling this just isPassThroughType
or isFirestorePassthroughType
to make it clearer that we're doing this for some Firestore-specific reason.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If it's Codable
, then both the Encodable and Decodable calls will fail to match because of not conforming to the other. Making changes 1 and 3.
/Users/paulbeusterien/gh/firebase-ios-sdk/Firestore/Swift/Source/Codable/third_party/FirestoreEncoder.swift:345:42: error: argument type 'T' does not conform to expected type 'Decodable'
/Users/paulbeusterien/gh/firebase-ios-sdk/Firestore/Swift/Source/Codable/third_party/FirestoreDecoder.swift:1008:43: error: argument type 'T' does not conform to expected type 'Encodable'
Firestore/Swift/Source/Codable/CollectionReference+Codable.swift
Outdated
Show resolved
Hide resolved
} | ||
|
||
extension DocumentReference: CodableDocumentReference { | ||
public func setData<T: Encodable>(encoderInput: T) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Similar to above, from encodable: T
. Unlike CollectionReference.addDocument
the underlying setData
does not have a label, but I think it's OK.
let model: Codable = ...
collection.setData(from: model)
vs
let values: [String : Any] = ...
collection.setData(values)
struct Model: Codable {} | ||
let model = Model() | ||
let dict = [String: Any]() as NSDictionary | ||
let encoded = try! Firestore.encode(model) as NSDictionary |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
With the above changes all these tests would now have the same form:
struct Model: Codable, Equatable {
// ...
}
let model = Model(...)
let dict = [...] // without as NSDictionary
assertRoundTrip(model: model, encoded: dict)
} | ||
let dict = ["x": 42] | ||
let model = Model(x: 42, opt: nil) | ||
XCTAssertEqual(model.x, roundTrip(input: model, expected: dict).x) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This would be less easy to miss if Model
were Equatable
.
assertRoundTrip(model: Model(x: 42, opt: nil), encoded: ["x": 42])
assertRoundTrip(model: Model(x: 42, opt: 7), encoded: ["x": 42, "opt": 7])
Also, more cases that don't round trip are worth testing:
assertDecodes(["x": 42, "opt": nil], to: Model(x: 42, opt: nil))
Also cases that are invalid are worth testing too:
assertDecodingFails(Model.self, from: ["x": 42, "opt": true])
} | ||
|
||
func testEnum() { | ||
enum MyEnum: Codable, Equatable { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As implemented here it appears this enumeration may be suitable for encoding both as a top-level type and when nested. Pull this out and assert both round-trips work?
Are enums ever directly Codable (i.e. without writing this boilerplate)? If so we should have a test for that too.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not that I could find.
let x: Int | ||
} | ||
let dict = ["x": "abc"] // Wrong type; | ||
XCTAssertThrowsError(try Firestore.decode(Model.self, from: dict)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For readability, consider extract this to something like assertFailsDecoding(_:from:)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done
} | ||
class FirestoreDummy { | ||
var visited = 0 | ||
func setObject<T: Codable>(_ object: T, fieldValues: [PartialKeyPath<T>: FieldValue] = [:]) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I can't understand what this test is doing at all. It needs some comments describing what's going on here.
For example, is setObject
some hook that's part of Codable
generally? Are we simulating something an end user might write? Or are we using this to assert the behavior of the encoder? It seems like if the latter we can just assert that a completed object serializes one way or another.
In any case, it appears that fieldValues
is completely ignored here which doesn't seem right.
Also, asserting the visited count doesn't seem useful. It seems as if it's asserting that the test itself called setObject twice?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Deleted the test.
let model = Model(x: nil) | ||
let dict = ["x": nil] as [String: Int?] | ||
let encodedDict = try! Firestore.encode(model) | ||
XCTAssertNil(encodedDict["x"]) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So I just checked the Android SDK and I think this assertion is ambiguous. It doesn't distinguish between an "x" that's present and has a value of nil or an "x" that's absent. We really care that it's the former not the latter.
On Android, given a class like this:
private static class StringBean {
private String value;
public String getValue() {
return value;
}
}
This test holds true:
@Test
public void primitiveSerializeString() {
StringBean bean = new StringBean();
assertJson("{'value': null}", serialize(bean));
}
This is slightly different from Swift, because nullability is implicit and default for Object-types in Java. Nevertheless it's important to preserve that the key exists with a null value in the output because null has an explicit representation in Firestore and you can't query for missing fields.
This means that we have to be very careful:
XCTAssertNotEqualObjects([:], ["x": nil]);
assertRoundTrip(model: model, encoded: dict)
assertDecodes([:], to: model)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Handling nil properly looks challenging and is not currently supported by the JSON encoder. See https://stackoverflow.com/questions/47266862/encode-nil-value-as-null-with-jsonencoder and https://bugs.swift.org/browse/SR-9232.
Presumably we don't handle this now in Objective C because NSDictionary's don't allow nil values.
Any suggestions other than deferring nil
support?
@wilhuff Any chance this will be seeing the light of day soon? It would be a greatly appreciated feature! Thanks! |
Closing in favor of #3198 |
Continue Firestore codable after squashing #838 and #2178
Companion QuickStart PR is firebase/quickstart-ios#609
Final merge to master plan is to create another PR with two commits - one with #838 and one with everything after, verify they match this branch, then do a merge (unsquashed) of those two commits to master.