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

Continue Firestore Codable 3 #2229

Closed
wants to merge 25 commits into from
Closed

Continue Firestore Codable 3 #2229

wants to merge 25 commits into from

Conversation

paulb777
Copy link
Member

@paulb777 paulb777 commented Jan 3, 2019

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.

@jonathansolorzn
Copy link

Any updates on this @ryanwilson @wilhuff ?

@paulb777
Copy link
Member Author

@felixsolorzano Next steps:

  • Remove throws from APIs in the extensions
  • Make sure the new extension APIs don't mess up the non-codable original versions of the APIs
  • Fix the error handling to occur on the correct queue

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 pb-codable3 branch.

Copy link
Member

@ryanwilson ryanwilson left a comment

Choose a reason for hiding this comment

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

Reviewing tests now.

Firestore/Example/Tests/API/FIRQueryTests.mm Show resolved Hide resolved
Firestore/Swift/Source/Codable/FieldValue+Codable.swift Outdated Show resolved Hide resolved
Firestore/Swift/Source/Codable/FieldValue+Codable.swift Outdated Show resolved Hide resolved
Firestore/Swift/Source/Codable/FirestoreDecoder.swift Outdated Show resolved Hide resolved
Firestore/Swift/Source/Codable/FirestoreEncoder.swift Outdated Show resolved Hide resolved
do {
setData(try Firestore.Encoder().encode(value))
} catch let error {
fatalError("TODO \(error)")
Copy link
Member

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)"?

Copy link
Member

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.

Copy link
Member Author

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?

Copy link
Member

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 😄

Firestore/Swift/Source/Codable/FirestoreEncoder.swift Outdated Show resolved Hide resolved
Firestore/Swift/Source/Codable/FirestoreEncoder.swift Outdated Show resolved Hide resolved
Firestore/Swift/Tests/Codable/CodableDocumentTests.swift Outdated Show resolved Hide resolved
}
let dict = ["x": 42]
let model = Model(x: 42, opt: nil)
XCTAssertEqual(model.x, roundTrip(input: model, expected: dict).x)
Copy link
Member

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?

Copy link
Member Author

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?

Copy link
Contributor

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/Tests/Codable/CodableDocumentTests.swift Outdated Show resolved Hide resolved
Firestore/Swift/Tests/Codable/CodableDocumentTests.swift Outdated Show resolved Hide resolved
Firestore/Swift/Tests/Codable/CodableDocumentTests.swift Outdated Show resolved Hide resolved
Firestore/Swift/Tests/Codable/CodableDocumentTests.swift Outdated Show resolved Hide resolved
Firestore/Swift/Tests/Codable/CodableDocumentTests.swift Outdated Show resolved Hide resolved
Firestore/Swift/Tests/Codable/CodableDocumentTests.swift Outdated Show resolved Hide resolved
Copy link
Member

@ryanwilson ryanwilson left a 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.

@paulb777
Copy link
Member Author

Thanks @ryanwilson , the PR still needs to ensure the original, overridden APIs work correctly and merge master.

Copy link
Contributor

@wilhuff wilhuff left a comment

Choose a reason for hiding this comment

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

First pass.

Firestore/Swift/Source/Codable/FieldValue+Codable.swift Outdated Show resolved Hide resolved
Firestore/Swift/Source/Codable/FieldValue+Codable.swift Outdated Show resolved Hide resolved
Firestore/Swift/Source/Codable/Timestamp+Codable.swift Outdated Show resolved Hide resolved
Firestore/Swift/Source/Codable/Timestamp+Codable.swift Outdated Show resolved Hide resolved
Firestore/Swift/Source/Codable/CodableErrors.swift Outdated Show resolved Hide resolved
import Foundation
import FirebaseFirestore

extension CollectionReference {
Copy link
Contributor

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

Copy link
Member Author

Choose a reason for hiding this comment

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

Separated.

}
}

extension DocumentReference {
Copy link
Contributor

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.

Copy link
Member Author

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

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.

Copy link
Member Author

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

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.

Copy link
Member Author

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

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)
  }
}

Copy link
Member Author

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

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.

Copy link
Member Author

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

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.

Copy link
Member Author

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 {
Copy link
Member Author

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])
Copy link
Member Author

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.

@cabarique
Copy link

@paulb777 thanks for the work done. Any updates on this issue?

@paulb777
Copy link
Member Author

@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?

@cabarique
Copy link

@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\"",
Copy link
Contributor

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.

Copy link
Member Author

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

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

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 to Codable.
  • 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.

Copy link
Member Author

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'

}

extension DocumentReference: CodableDocumentReference {
public func setData<T: Encodable>(encoderInput: T) {
Copy link
Contributor

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

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

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

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.

Copy link
Member Author

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

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:)

Copy link
Member Author

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] = [:]) {
Copy link
Contributor

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?

Copy link
Member Author

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"])
Copy link
Contributor

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)

Copy link
Member Author

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?

@ghost
Copy link

ghost commented Apr 14, 2019

@wilhuff Any chance this will be seeing the light of day soon? It would be a greatly appreciated feature! Thanks!

@paulb777
Copy link
Member Author

Closing in favor of #3198

@paulb777 paulb777 closed this Jun 17, 2019
@paulb777 paulb777 deleted the pb-codable3 branch October 2, 2019 19:19
@firebase firebase locked and limited conversation to collaborators Oct 23, 2019
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.

6 participants