Skip to content

Commit 9b0d5a3

Browse files
authored
Add @documentid equivalent to Swift Codable (#3762)
* Commit with failed optional test case. * Integration Test and cleanup * swiftformat * better doc formatting * addressing comments * rename to SelfDocumentID * Addressing Comments * fix comment
1 parent c8c2c21 commit 9b0d5a3

File tree

7 files changed

+202
-4
lines changed

7 files changed

+202
-4
lines changed

Firestore/Swift/Source/Codable/CodableErrors.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
public enum FirestoreDecodingError: Error {
1818
case decodingIsNotSupported
19+
case fieldNameConfict(String)
1920
}
2021

2122
public enum FirestoreEncodingError: Error {

Firestore/Swift/Source/Codable/DocumentSnapshot+ReadDecodable.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ extension DocumentSnapshot {
3434
d = Firestore.Decoder()
3535
}
3636
if let data = data() {
37-
return try d?.decode(T.self, from: data)
37+
return try d?.decode(T.self, from: data, in: reference)
3838
}
3939
return nil
4040
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
/*
2+
* Copyright 2019 Google
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import FirebaseFirestore
18+
19+
/// A value that is populated in Codable objects with a `DocumentReference` by
20+
/// the FirestoreDecoder when a document is read.
21+
///
22+
/// Note that limitations in Swift compiler-generated Codable implementations
23+
/// prevent using this type wrapped in an Optional. Optional SelfDocumentIDs
24+
/// are possible if you write a custom `init(from: Decoder)` method.
25+
///
26+
/// If the field name used for this type conflicts with a read document field,
27+
/// an error is thrown. For example, if a custom object has a field `firstName`
28+
/// with type `SelfDocumentID`, and there is a property from the document named
29+
/// `firstName` as well, an error is thrown when you try to read the document.
30+
///
31+
/// When writing a Codable object containing a `SelfDocumentID`, its value is
32+
/// ignored. This allows you to read a document from one path and write it into
33+
/// another without adjusting the value here.
34+
///
35+
/// NOTE: Trying to encode/decode this type using encoders/decoders other than
36+
/// FirestoreEncoder leads to an error.
37+
public final class SelfDocumentID: Equatable, Codable {
38+
// MARK: - Initializers
39+
40+
public init() {
41+
reference = nil
42+
}
43+
44+
public init(from ref: DocumentReference?) {
45+
reference = ref
46+
}
47+
48+
// MARK: - `Codable` implemention.
49+
50+
public init(from decoder: Decoder) throws {
51+
throw FirestoreDecodingError.decodingIsNotSupported
52+
}
53+
54+
public func encode(to encoder: Encoder) throws {
55+
throw FirestoreEncodingError.encodingIsNotSupported
56+
}
57+
58+
// MARK: - Properties
59+
60+
public var id: String? {
61+
return reference?.documentID
62+
}
63+
64+
public let reference: DocumentReference?
65+
66+
// MARK: - `Equatable` implementation
67+
68+
public static func == (lhs: SelfDocumentID,
69+
rhs: SelfDocumentID) -> Bool {
70+
return lhs.reference == rhs.reference
71+
}
72+
}

Firestore/Swift/Source/Codable/third_party/FirestoreDecoder.swift

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ import Foundation
1818

1919
extension Firestore {
2020
public struct Decoder {
21+
fileprivate static let documentRefUserInfoKey = CodingUserInfoKey(rawValue: "DocumentRefUserInfoKey")
22+
2123
public init() {}
2224
/// Returns an instance of specified type from a Firestore document.
2325
///
@@ -31,9 +33,17 @@ extension Firestore {
3133
/// - Parameters:
3234
/// - A type to decode a document to.
3335
/// - container: A Map keyed of String representing a Firestore document.
36+
/// - document: A reference to the Firestore Document that is being
37+
/// decoded.
3438
/// - Returns: An instance of specified type by the first parameter.
35-
public func decode<T: Decodable>(_: T.Type, from container: [String: Any]) throws -> T {
39+
public func decode<T: Decodable>(_: T.Type,
40+
from container: [String: Any],
41+
in document: DocumentReference? = nil) throws -> T {
3642
let decoder = _FirestoreDecoder(referencing: container)
43+
if let doc = document {
44+
decoder.userInfo[Firestore.Decoder.documentRefUserInfoKey!] = doc
45+
}
46+
3747
guard let value = try decoder.unbox(container, as: T.self) else {
3848
throw DecodingError.valueNotFound(
3949
T.self,
@@ -323,6 +333,24 @@ private struct _FirestoreKeyedDecodingContainer<K: CodingKey>: KeyedDecodingCont
323333
}
324334

325335
public func decode<T: Decodable>(_: T.Type, forKey key: Key) throws -> T {
336+
if T.self == SelfDocumentID.self {
337+
let docRef = decoder.userInfo[
338+
Firestore.Decoder.documentRefUserInfoKey!
339+
] as! DocumentReference?
340+
341+
if contains(key) {
342+
let docPath = (docRef != nil) ? docRef!.path : "nil"
343+
var codingPathCopy = codingPath.map { key in key.stringValue }
344+
codingPathCopy.append(key.stringValue)
345+
346+
throw FirestoreDecodingError.fieldNameConfict("Field name " +
347+
"\(codingPathCopy) was found from document \"\(docPath)\", " +
348+
"cannot assign the document reference to this field.")
349+
}
350+
351+
return SelfDocumentID(from: docRef) as! T
352+
}
353+
326354
let entry = try require(key: key)
327355

328356
decoder.codingPath.append(key)

Firestore/Swift/Source/Codable/third_party/FirestoreEncoder.swift

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,11 @@ extension Firestore {
3636
/// - Returns: A Map keyed by String representing a document Firestore
3737
/// API can work with.
3838
public func encode<T: Encodable>(_ value: T) throws -> [String: Any] {
39-
// DocumentReference and FieldValue cannot be encoded directly.
40-
guard T.self != DocumentReference.self, T.self != FieldValue.self else {
39+
// SelfDocumentID, DocumentReference and FieldValue cannot be
40+
// encoded directly.
41+
guard T.self != SelfDocumentID.self,
42+
T.self != DocumentReference.self,
43+
T.self != FieldValue.self else {
4144
throw FirestoreEncodingError.encodingIsNotSupported
4245
}
4346
guard let topLevel = try _FirestoreEncoder().box_(value) else {
@@ -212,6 +215,11 @@ private struct _FirestoreKeyedEncodingContainer<K: CodingKey>: KeyedEncodingCont
212215
public mutating func encode(_ value: Double, forKey key: Key) throws { container[key.stringValue] = encoder.box(value) }
213216

214217
public mutating func encode<T: Encodable>(_ value: T, forKey key: Key) throws {
218+
// `SelfDocumentID` is ignored during encoding.
219+
if T.self == SelfDocumentID.self {
220+
return
221+
}
222+
215223
encoder.codingPath.append(key)
216224
defer {
217225
encoder.codingPath.removeLast()

Firestore/Swift/Tests/Codable/FirestoreEncoderTests.swift

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -605,4 +605,69 @@ class FirestoreEncoderTests: XCTestCase {
605605
decoded = try Firestore.Decoder().decode(Model.self, from: encoded)
606606
XCTAssertEqual(decoded, fieldIsNotNull)
607607
}
608+
609+
func testAutomaticallyPopulatesSelfDocumentIDField() throws {
610+
struct Model: Codable, Equatable {
611+
var name: String
612+
var docId: SelfDocumentID
613+
}
614+
615+
let decoded = try Firestore.Decoder().decode(Model.self, from: ["name": "abc"], in: FSTTestDocRef("abc/123"))
616+
XCTAssertEqual(decoded, Model(name: "abc", docId: SelfDocumentID(from: FSTTestDocRef("abc/123"))))
617+
}
618+
619+
func testSelfDocumentIDIgnoredInEncoding() throws {
620+
struct Model: Codable, Equatable {
621+
var name: String
622+
var docId: SelfDocumentID
623+
}
624+
625+
let model = Model(name: "abc", docId: SelfDocumentID(from: FSTTestDocRef("abc/123")))
626+
_ = assertEncodes(model, to: ["name": "abc"])
627+
}
628+
629+
func testEncodingSelfDocumentIDNotEmbeddedThrows() {
630+
let doc = SelfDocumentID(from: FSTTestDocRef("abc/xyz"))
631+
do {
632+
_ = try Firestore.Encoder().encode(doc)
633+
XCTFail("Failed to throw")
634+
} catch FirestoreEncodingError.encodingIsNotSupported {
635+
return
636+
} catch {
637+
XCTFail("Unrecognized error: \(error)")
638+
}
639+
}
640+
641+
func testSelfDocumentIDWithJsonEncoderThrows() {
642+
let doc = SelfDocumentID(from: FSTTestDocRef("abc/xyz"))
643+
do {
644+
_ = try JSONEncoder().encode(doc)
645+
XCTFail("Failed to throw")
646+
} catch FirestoreEncodingError.encodingIsNotSupported {
647+
return
648+
} catch {
649+
XCTFail("Unrecognized error: \(error)")
650+
}
651+
}
652+
653+
func testDecodingSelfDocumentIDWithConfictingFieldsThrows() throws {
654+
struct Model: Codable, Equatable {
655+
var name: String
656+
var docId: SelfDocumentID
657+
}
658+
659+
do {
660+
_ = try Firestore.Decoder().decode(
661+
Model.self,
662+
from: ["name": "abc", "docId": "Causing conflict"],
663+
in: FSTTestDocRef("abc/123")
664+
)
665+
XCTFail("Failed to throw")
666+
} catch let FirestoreDecodingError.fieldNameConfict(msg) {
667+
XCTAssertEqual(msg, "Field name [\"docId\"] was found from document \"abc/123\"," + " cannot assign the document reference to this field.")
668+
return
669+
} catch {
670+
XCTFail("Unrecognized error: \(error)")
671+
}
672+
}
608673
}

Firestore/Swift/Tests/Integration/CodableIntegrationTests.swift

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,30 @@ class CodableIntegrationTests: FSTIntegrationTestCase {
169169
}
170170
}
171171

172+
func testSelfDocumentID() throws {
173+
struct Model: Codable, Equatable {
174+
var name: String
175+
var docId: SelfDocumentID
176+
}
177+
178+
let docToWrite = documentRef()
179+
let model = Model(
180+
name: "name",
181+
docId: SelfDocumentID()
182+
)
183+
184+
try setData(from: model, forDocument: docToWrite, withFlavor: .docRef)
185+
let data = readDocument(forRef: docToWrite).data()
186+
187+
// "docId" is ignored during encoding
188+
XCTAssertEqual(data! as! [String: String], ["name": "name"])
189+
190+
// Decoded result has "docId" auto-populated.
191+
let decoded = try readDocument(forRef: docToWrite).data(as: Model.self)
192+
XCTAssertEqual(decoded!, Model(name: "name",
193+
docId: SelfDocumentID(from: docToWrite)))
194+
}
195+
172196
func testSetThenMerge() throws {
173197
struct Model: Codable, Equatable {
174198
var name: String? = nil

0 commit comments

Comments
 (0)