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

Codable records: JSON encoding and decoding of codable record properties #397

Merged
merged 38 commits into from
Aug 15, 2018
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
feeedcb
Added nested Codable support by flattening into/from JSON
Aug 7, 2018
74aae3d
implemented requested changes
Aug 8, 2018
7def60c
fixed checking for nested codable
Aug 8, 2018
2826472
sorted encoder outputFormatting for ios11+ etcl
Aug 9, 2018
6262d10
added catch
Aug 14, 2018
7f16e35
extended Codable documentation and added test for the example used in…
Aug 14, 2018
8c06e7b
Detached rows string to data and tests
gusrota Aug 14, 2018
a08974f
Added tests for arrays
gusrota Aug 14, 2018
d81475a
6262d10 continued
Aug 14, 2018
76f5adf
moved the extension to Swift.Data to follow the extension to Swift.St…
Aug 14, 2018
c52b59f
Switching to JSON encoding for nested containers.
gusrota Aug 14, 2018
4b7f17d
Merge remote-tracking branch 'origin/development' into rotacloud
groue Aug 15, 2018
05fda18
Introduce RowSingleValueDecoder
groue Aug 15, 2018
2750980
Introduce JSONDecodingRequiredError
groue Aug 15, 2018
aa8bc0f
Don't let JSONDecodingRequiredError go
groue Aug 15, 2018
942b32e
RowSingleValueDecodingContainer performance improvements
groue Aug 15, 2018
0cdc7d8
Just some cleanup, nothing new
groue Aug 15, 2018
5f41c57
amended the documentation and tests to include Dictionary support to …
Aug 15, 2018
1c66ad4
Restore GRDB/Core/Support/Foundation/Data.swift
groue Aug 15, 2018
820673b
PersistableRecord+Encodable: make it as robust as FetchableRecord+Dec…
groue Aug 15, 2018
358225b
Cleanup, documentation, and preserve coding paths
groue Aug 15, 2018
b2234b4
5f41c57c addition
Aug 15, 2018
2d6140a
Refactor value conversion tests
groue Aug 15, 2018
de4f57c
Test decoding of non-UTF8 data
groue Aug 15, 2018
0b77d0d
Better feedback for conversion errors from StatementColumnConvertible
groue Aug 15, 2018
e8dacb3
Tests for failed value conversions
groue Aug 15, 2018
b47f586
Even better feedback for conversion errors from StatementColumnConver…
groue Aug 15, 2018
eba21d3
Oh men of little faith!
groue Aug 15, 2018
4d343e8
More tests for Data -> String conversion: play with JPEG :-)
groue Aug 15, 2018
d00421a
Restore SPM: add missing Foundation import
groue Aug 15, 2018
659db4f
Safer default implementation for RowImpl.copiedRow(_:)
groue Aug 15, 2018
2eb0955
JSON encoding: choose strategies
groue Aug 15, 2018
9bb4ff8
Tests for JSON date strategy
groue Aug 15, 2018
02db2a8
Tests for JSON data strategy
groue Aug 15, 2018
dfb9421
Documentation for JSON support
groue Aug 15, 2018
2b8c838
Fix typo
groue Aug 15, 2018
44b998c
CHANGELOG.md
groue Aug 15, 2018
7ea15dc
Welcome @valexa and @gusrota to the thank you notice
groue Aug 15, 2018
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 24 additions & 2 deletions GRDB/Record/FetchableRecord+Decodable.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import Foundation

private struct RowKeyedDecodingContainer<Key: CodingKey>: KeyedDecodingContainerProtocol {
let decoder: RowDecoder

Expand Down Expand Up @@ -79,7 +81,17 @@ private struct RowKeyedDecodingContainer<Key: CodingKey>: KeyedDecodingContainer
} else if dbValue.isNull {
return nil
} else {
return try T(from: RowDecoder(row: row, codingPath: codingPath + [key]))
do {
let natural = try T(from: RowDecoder(row: row, codingPath: codingPath + [key]))
return natural
Copy link
Owner

Choose a reason for hiding this comment

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

I think return try T(from...) is enough. That "natural" word was useful for communication between us, but has no real purpose in the code.

A comment like the following will help making the code more clear:

do {
    // This decoding will fail for types that need a keyed container,
    // because we're decoding a database value here (string, int, double, data, null).
    // Support for keyed containers is provided below (JSON decoding).
    return try T(from...)

Copy link
Contributor

Choose a reason for hiding this comment

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

Done

} catch {
Copy link
Owner

Choose a reason for hiding this comment

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

I wonder if we could catch precisely the error emitted when the decoded type asks for a keyed container, and doesn't find any. This will prevent us from switching to JSON decoding for types that throw an error when they fail decoding from the SingleValueDecodingContainer.

Copy link
Contributor

Choose a reason for hiding this comment

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

well its the "unkeyed decoding is not supported" error so yes we can add a check for that

Copy link
Owner

Choose a reason for hiding this comment

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

+1

if let data = row.dataNoCopy(named: key.stringValue), let dataString = String(data: data, encoding: .utf8), dataString.hasPrefix("[{") || dataString.hasPrefix("{"), dataString.hasSuffix("}]") || dataString.hasSuffix("}") {
Copy link
Owner

@groue groue Aug 7, 2018

Choose a reason for hiding this comment

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

Checking for the format is useless: if data contains something that is not JSON, JSONDecoder will throw anyway. And by not checking the format, we'll also avoid turning Data into String. This is the whole point of SQLite weak typing (store string, load data) 😄

// If data looks like JSON data then decode it into model (nested model as JSON)
return try JSONDecoder().decode(type.self, from: data)
} else {
fatalError("\(error)")
Copy link
Owner

Choose a reason for hiding this comment

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

throw error is enough, unless I'm mistaken.

}
}
}
}

Expand Down Expand Up @@ -116,7 +128,17 @@ private struct RowKeyedDecodingContainer<Key: CodingKey>: KeyedDecodingContainer
// This allows decoding Date from String, or DatabaseValue from NULL.
return type.fromDatabaseValue(dbValue) as! T
} else {
return try T(from: RowDecoder(row: row, codingPath: codingPath + [key]))
do {
let natural = try T(from: RowDecoder(row: row, codingPath: codingPath + [key]))
return natural
} catch {
if let data = row.dataNoCopy(named: key.stringValue), let dataString = String(data: data, encoding: .utf8), dataString.hasPrefix("[{") || dataString.hasPrefix("{"), dataString.hasSuffix("}]") || dataString.hasSuffix("}") {
// If data looks like JSON data then decode it into model (nested model as JSON)
return try JSONDecoder().decode(type.self, from: data)
} else {
fatalError("\(error)")
}
}
}
}

Expand Down
108 changes: 105 additions & 3 deletions GRDB/Record/PersistableRecord+Encodable.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import Foundation

private struct PersistableRecordKeyedEncodingContainer<Key: CodingKey> : KeyedEncodingContainerProtocol {
let encode: (_ value: DatabaseValueConvertible?, _ key: String) -> Void

Expand Down Expand Up @@ -40,7 +42,21 @@ private struct PersistableRecordKeyedEncodingContainer<Key: CodingKey> : KeyedEn
// This allows us to encode Date as String, for example.
encode((value as! DatabaseValueConvertible), key.stringValue)
} else {
try value.encode(to: PersistableRecordEncoder(codingPath: [key], encode: encode))
do {
try value.encode(to: PersistableRecordEncoder(codingPath: [key], encode: encode))
} catch {
Copy link
Owner

Choose a reason for hiding this comment

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

Why do we carefully check for the error type before we switch to JSON decoding, but not when we switch to JSON encoding? I'd like some consistency, and the same level of care to be displayed in both places.

Copy link
Contributor

Choose a reason for hiding this comment

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

This was a oversight, fixed now that you pointed it out.

Copy link
Owner

Choose a reason for hiding this comment

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

You mean in 6262d10? It still needs a little extra effort: just like we did for decoding, we have to check for a particular encoding error before switching to JSON.

Copy link
Contributor

Choose a reason for hiding this comment

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

Updated in d81475a

// If value.encode does not work e.g. "unkeyed encoding is not supported" then see if model can be stored as JSON
let encodeError = error
do {
let json = try JSONEncoder().encode(value)
Copy link
Owner

@groue groue Aug 7, 2018

Choose a reason for hiding this comment

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

Please set encoder's outputFormatting to .sortedKeys. This will guarantee stable JSON output, and will ease record comparison with databaseEquals and similar methods.

Copy link
Owner

@groue groue Aug 7, 2018

Choose a reason for hiding this comment

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

I'd also like that we think about which dateEncodingStrategy we should use. I don't quite like that we use the default format, which is not portable at all to other platforms (intervalSinceReferenceDate is specific to Foundation as far as I know). I'm thinking about people that share databases with Android: I don't want the JSON produced by GRDB to be painful for them to decode.

Copy link
Contributor

Choose a reason for hiding this comment

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

We were thinking of the same thing but .sortedKeys is iOS 11 only

Copy link
Owner

Choose a reason for hiding this comment

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

Then we'll sort keys on iOS11 only.

Copy link
Owner

Choose a reason for hiding this comment

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

And make this point clear in the documentation (that record comparison may give odd results for records stored before iOS 11)

Copy link
Owner

Choose a reason for hiding this comment

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

Precisely speaking: iOS 11.0+, macOS 10.13+, watchOS 4.0+

guard let modelAsString = String(data: json, encoding: .utf8) else {
Copy link
Owner

Choose a reason for hiding this comment

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

I think String(data: json, encoding: .utf8)! (force unwrap) is OK here: JSONEncoder is not supposed to output Data that can't be turned into a String (you can add this as a comment). JsonStringError is purposeless, and can be removed.

throw JsonStringError.covertStringError("Error, could not make string out of JSON data")
}
return encode(modelAsString, key.stringValue)
} catch {
fatalError("Encode error: \(encodeError), tried to encode to Json, got error: \(error)")
Copy link
Owner

Choose a reason for hiding this comment

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

We should throw, and not fatalError here.

I understand that we hesitate between rethrowing encodeError or error. What do you think?

}
}
}
}

Expand Down Expand Up @@ -169,7 +185,7 @@ private struct PersistableRecordEncoder : Encoder {
func container<Key>(keyedBy type: Key.Type) -> KeyedEncodingContainer<Key> {
// Asked for a keyed type: top level required
guard codingPath.isEmpty else {
fatalError("unkeyed encoding is not supported")
return KeyedEncodingContainer(ThrowingKeyedContainer(error: EncodingError.invalidValue(codingPath.isEmpty, EncodingError.Context(codingPath: codingPath, debugDescription: "unkeyed encoding is not supported"))))
Copy link
Owner

@groue groue Aug 7, 2018

Choose a reason for hiding this comment

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

We should immediately throw an error, instead of creating another level through ThrowingKeyedContainer. This error will be caught during "natural" encoding, and trigger JSON encoding.

Ideally, this error should be a standard EncodingError.

And it will allow us to apply my comment on https://github.com/groue/GRDB.swift/pull/397/files#diff-e9cae7906e97e4aadddcbc90115862c3R87.

Eventually we discover that we don't need ThrowingKeyedContainer at all.

Copy link
Contributor

Choose a reason for hiding this comment

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

If we change func container(keyedBy type: Key.Type) -> KeyedEncodingContainer to throw we lose conformity to the Encoder protocol

Copy link
Owner

@groue groue Aug 8, 2018

Choose a reason for hiding this comment

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

Ha, I understand: container(keyedBy:) can not throw. Thanks for fixing my mistake.

}
return KeyedEncodingContainer(PersistableRecordKeyedEncodingContainer<Key>(encode: encode))
}
Expand All @@ -180,7 +196,7 @@ private struct PersistableRecordEncoder : Encoder {
/// - precondition: May not be called after a prior `self.container(keyedBy:)` call.
/// - precondition: May not be called after a value has been encoded through a previous `self.singleValueContainer()` call.
func unkeyedContainer() -> UnkeyedEncodingContainer {
fatalError("unkeyed encoding is not supported")
return ThrowingUnkeyedContainer(error: EncodingError.invalidValue(encode, EncodingError.Context(codingPath: [], debugDescription: "unkeyed encoding is not supported")))
Copy link
Owner

Choose a reason for hiding this comment

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

Same as container(keyedBy:) above: we should throw an error instead.

}

/// Returns an encoding container appropriate for holding a single primitive value.
Expand All @@ -194,6 +210,92 @@ private struct PersistableRecordEncoder : Encoder {
}
}

class ThrowingKeyedContainer<KeyType: CodingKey>: KeyedEncodingContainerProtocol {
let errorMessage: Error
var codingPath: [CodingKey] = []

init(error: Error) {
errorMessage = error
}

func encodeNil(forKey key: KeyType) throws { throw errorMessage }
func encode(_ value: Bool, forKey key: KeyType) throws { throw errorMessage }
func encode(_ value: Int, forKey key: KeyType) throws { throw errorMessage }
func encode(_ value: Int8, forKey key: KeyType) throws { throw errorMessage }
func encode(_ value: Int16, forKey key: KeyType) throws { throw errorMessage }
func encode(_ value: Int32, forKey key: KeyType) throws { throw errorMessage }
func encode(_ value: Int64, forKey key: KeyType) throws { throw errorMessage }
func encode(_ value: UInt, forKey key: KeyType) throws { throw errorMessage }
func encode(_ value: UInt8, forKey key: KeyType) throws { throw errorMessage }
func encode(_ value: UInt16, forKey key: KeyType) throws { throw errorMessage }
func encode(_ value: UInt32, forKey key: KeyType) throws { throw errorMessage }
func encode(_ value: UInt64, forKey key: KeyType) throws { throw errorMessage }
func encode(_ value: Float, forKey key: KeyType) throws { throw errorMessage }
func encode(_ value: Double, forKey key: KeyType) throws { throw errorMessage }
func encode(_ value: String, forKey key: KeyType) throws { throw errorMessage }
func encode<T>(_ value: T, forKey key: KeyType) throws where T : Encodable { throw errorMessage }

func nestedContainer<NestedKey>(keyedBy keyType: NestedKey.Type, forKey key: KeyType) -> KeyedEncodingContainer<NestedKey> where NestedKey : CodingKey {
fatalError("Not implemented")
Copy link
Owner

@groue groue Aug 13, 2018

Choose a reason for hiding this comment

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

I think we should return another throwing container here. Imagine a codable type that tries to access a nested container before it stores its properties and triggers one of our throwing encode methods above. Program will crash instead of switching to JSON encoding. This type should never fatalError, so that we can always switch to JSON. That's the whole point :-)

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 @groue good point :-), commit c52b59f now returns another throwing container.

}
func nestedUnkeyedContainer(forKey key: KeyType) -> UnkeyedEncodingContainer {
fatalError("Not implemented")
}
func superEncoder() -> Encoder {
fatalError("Not implemented")
}

func superEncoder(forKey key: KeyType) -> Encoder {
fatalError("Not implemented")
}
}

class ThrowingUnkeyedContainer: UnkeyedEncodingContainer {
let errorMessage: Error
var codingPath: [CodingKey] = []
var count: Int = 0

init(error: Error) {
errorMessage = error
}

func encode(_ value: Int) throws { throw errorMessage }
func encode(_ value: Int8) throws { throw errorMessage }
func encode(_ value: Int16) throws { throw errorMessage }
func encode(_ value: Int32) throws { throw errorMessage }
func encode(_ value: Int64) throws { throw errorMessage }
func encode(_ value: UInt) throws { throw errorMessage }
func encode(_ value: UInt8) throws { throw errorMessage }
func encode(_ value: UInt16) throws { throw errorMessage }
func encode(_ value: UInt32) throws { throw errorMessage }
func encode(_ value: UInt64) throws { throw errorMessage }
func encode(_ value: Float) throws { throw errorMessage }
func encode(_ value: Double) throws { throw errorMessage }
func encode(_ value: String) throws { throw errorMessage }
func encode<T>(_ value: T) throws where T : Encodable { throw errorMessage }
func encode(_ value: Bool) throws { throw errorMessage }
func encodeNil() throws { throw errorMessage }

func nestedContainer<NestedKey>(keyedBy keyType: NestedKey.Type) -> KeyedEncodingContainer<NestedKey> where NestedKey : CodingKey {
fatalError("Not implemented")
Copy link
Owner

Choose a reason for hiding this comment

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

Same comment as above: there should be no fatalError in this 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.

commit c52b59f also returns another throwing container here


}

func nestedUnkeyedContainer() -> UnkeyedEncodingContainer {
fatalError("Not implemented")

}

func superEncoder() -> Encoder {
fatalError("Not implemented")

}
}

enum JsonStringError: Error {
case covertStringError(String)
}

extension MutablePersistableRecord where Self: Encodable {
public func encode(to container: inout PersistenceContainer) {
// The inout container parameter won't enter an escaping closure since
Expand Down
194 changes: 194 additions & 0 deletions Tests/GRDBTests/FetchableRecordDecodableTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -301,3 +301,197 @@ extension FetchableRecordDecodableTests {
XCTAssertEqual(value.uuid, uuid)
}
}

// MARK: - Custom nested Decodable types - nested saved as JSON

extension FetchableRecordDecodableTests {
Copy link
Owner

Choose a reason for hiding this comment

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

I still don't see any test for detached rows. Context and guidance.

Copy link
Contributor

@valexa valexa Aug 13, 2018

Choose a reason for hiding this comment

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

Hi, we are having issues to even begin imagining how to test for detached rows, are there any existing non Codable detached rows tests we could look at for guidance ?

Copy link
Owner

@groue groue Aug 13, 2018

Choose a reason for hiding this comment

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

Start by clicking my "context and guIdance" link: it starts with a sample code.

@gusrota, @valexa, it's totally OK if you stop working on this PR. You don't have to "obey" my requests. But please be totally clear about our common expectations.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@groue please be reassured that both I and @valexa very much wish to continue on this PR and finish on the open points to so that it is ready to be merged into master branch.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@groue have just committed changes to add test for detached rows. Thank you for the example code and the description you gave to help explain detached rows. I have added JSON checking in Data.fromDatabaseValue to allow the reading of Data that is of JSON content. I did not need to add any additions to String.fromDatabaseValue as seems to work fine.

Copy link
Owner

Choose a reason for hiding this comment

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

I have just committed changes to add test for detached rows. Thank you for the example code and the description you gave to help explain detached rows.

Great

I have added JSON checking in Data.fromDatabaseValue to allow the reading of Data that is of JSON content.

Something was not understood at all, here. See this comment for context.

I did not need to add any additions to String.fromDatabaseValue as seems to work fine.

I understand, you want to remain focused on your use case and your convenience. But please also understand that this is not enough. I manage this open source library not only for you. If GRDB had been built for my personal use cases only, you would have never heard of it. I repeat: If we support String to Data conversion, we have to support Data to String conversion. It is a matter of consistency. And it was explicitly requested in this comment.

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 be done now in 76f5adf

func testOptionalNestedStruct() throws {
struct NestedStruct : PersistableRecord, FetchableRecord, Codable {
let firstName = "Bob"
let lastName = "Dylan"
Copy link
Owner

Choose a reason for hiding this comment

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

I'm glad a little music is entering the tests 👍

Copy link
Owner

Choose a reason for hiding this comment

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

BTW... When I read let firstName = "Bob", I wonder how NestedStruct could hold different values. This makes the XCTAssertEqual(nestedModel.firstName, "Bob") test less obvious.

I'm not saying there is a bug here. I'm saying the test would be clearer if NestedStruct was declared as:

struct NestedStruct : PersistableRecord, FetchableRecord, Codable {
    var firstName: String
    var lastName: String
}

and the insertion below written as:

let value = StructWithNestedType(nested: NestedStruct(firstName: "Bob", lastName: "Dylan"))

This comment applies to other testing structs with let properties.

Copy link
Owner

Choose a reason for hiding this comment

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

Another point: NestedStruct does not need to adopt PersistableRecord and FetchableRecord. Same for all other nested structs.

}

struct StructWithNestedType : PersistableRecord, FetchableRecord, Codable {
static let databaseTableName = "t1"
let nested: NestedStruct?
}

let dbQueue = try makeDatabaseQueue()
try dbQueue.inDatabase { db in
try db.create(table: "t1") { t in
t.column("nested", .text)
}

let value = StructWithNestedType(nested: NestedStruct())
try value.insert(db)

let parentModel = try StructWithNestedType.fetchAll(db)

guard let nestedModel = parentModel.first?.nested else {
XCTFail()
return
}

// Check the nested model contains the expected values of first and last name
XCTAssertEqual(nestedModel.firstName, "Bob")
XCTAssertEqual(nestedModel.lastName, "Dylan")
Copy link
Owner

Choose a reason for hiding this comment

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

That's a good test. Checking for detached rows can be done with something like:

let row: Row = ["nested": """
    { "firstName": "Bob", "lastName": "Dylan" }
    """]
let model = StructWithNestedType(row: row)
XCTAssertEqual(model.nestedModel.firstName, "Bob")
XCTAssertEqual(model.nestedModel.lastName, "Dylan")

Copy link
Contributor

Choose a reason for hiding this comment

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

Thats interesting, where if at all would we test for detached rows ? It is still not clear to us what the implications of a "detached" row are.

Copy link
Owner

@groue groue Aug 8, 2018

Choose a reason for hiding this comment

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

"Raw rows" and "detached rows" are two different types of rows, which don't access values in the same way. Both share the same Swift type Row. But they don't have the same implementation, and their behavior is different in some corner cases. Raw rows load values straight from SQLite, using SQLite C apis. Detached rows don't have access to SQLite, and contains regular Swift values.

Since they don't access values in the same way, both code paths have to be tested. This is especially true in our case, because we store JSON strings, but load JSON data. We rely on automatic conversion from string to data.

This conversion is done by SQLite when a raw row is involved. It works, as you can see.

But this conversion is not done by SQLite for detached rows: it has to be done by GRDB itself. I know very well that this is not done today: the test above will fail if you run it (try, you will see - if the test pass, then there is something wrong in our code).

To make the test pass with detached rows, we need GRDB to provide the same automatic conversion as SQLite from string to data (and from data to string because I don't like half-baked features). This has to be done in the conversion methods I gave above: String.fromDatabaseValue and Data.fromDatabaseValue.

}
}

func testOptionalNestedStructNil() throws {
struct NestedStruct : PersistableRecord, FetchableRecord, Codable {
let firstName = "Bob"
let lastName = "Dylan"
}

struct StructWithNestedType : PersistableRecord, FetchableRecord, Codable {
static let databaseTableName = "t1"
let nested: NestedStruct?
}

let dbQueue = try makeDatabaseQueue()
try dbQueue.inDatabase { db in
try db.create(table: "t1") { t in
t.column("nested", .text)
}

let value = StructWithNestedType(nested: nil)
try value.insert(db)

let parentModel = try StructWithNestedType.fetchAll(db)

XCTAssertNil(parentModel.first?.nested)
}
}

func testOptionalNestedArrayStruct() throws {
struct NestedStruct : PersistableRecord, FetchableRecord, Codable {
let firstName = "Bob"
let lastName = "Dylan"
}

struct StructWithNestedType : PersistableRecord, FetchableRecord, Codable {
static let databaseTableName = "t1"
let nested: [NestedStruct]?
}

let dbQueue = try makeDatabaseQueue()
try dbQueue.inDatabase { db in
try db.create(table: "t1") { t in
t.column("nested", .text)
}

let value = StructWithNestedType(nested: [NestedStruct(), NestedStruct()])
try value.insert(db)

let parentModel = try StructWithNestedType.fetchAll(db)

guard let arrayOfNestedModel = parentModel.first?.nested, let firstNestedModelInArray = arrayOfNestedModel.first else {
XCTFail()
return
}

// Check there are two models in array
XCTAssertTrue(arrayOfNestedModel.count == 2)

// Check the nested model contains the expected values of first and last name
XCTAssertEqual(firstNestedModelInArray.firstName, "Bob")
XCTAssertEqual(firstNestedModelInArray.lastName, "Dylan")
}
}

func testOptionalNestedArrayStructNil() throws {
struct NestedStruct : PersistableRecord, FetchableRecord, Codable {
let firstName = "Bob"
let lastName = "Dylan"
}

struct StructWithNestedType : PersistableRecord, FetchableRecord, Codable {
static let databaseTableName = "t1"
let nested: [NestedStruct]?
}

let dbQueue = try makeDatabaseQueue()
try dbQueue.inDatabase { db in
try db.create(table: "t1") { t in
t.column("nested", .text)
}

let value = StructWithNestedType(nested: nil)
try value.insert(db)

let parentModel = try StructWithNestedType.fetchAll(db)

XCTAssertNil(parentModel.first?.nested)
}
}

func testNonOptionalNestedStruct() throws {
struct NestedStruct : PersistableRecord, FetchableRecord, Codable {
let firstName = "Bob"
let lastName = "Dylan"
}

struct StructWithNestedType : PersistableRecord, FetchableRecord, Codable {
static let databaseTableName = "t1"
let nested: NestedStruct
}

let dbQueue = try makeDatabaseQueue()
try dbQueue.inDatabase { db in
try db.create(table: "t1") { t in
t.column("nested", .text)
}

let value = StructWithNestedType(nested: NestedStruct())
try value.insert(db)

let parentModel = try StructWithNestedType.fetchAll(db)

guard let nestedModel = parentModel.first?.nested else {
XCTFail()
return
}

// Check the nested model contains the expected values of first and last name
XCTAssertEqual(nestedModel.firstName, "Bob")
XCTAssertEqual(nestedModel.lastName, "Dylan")
}
}

func testNonOptionalNestedArrayStruct() throws {
struct NestedStruct : PersistableRecord, FetchableRecord, Codable {
let firstName = "Bob"
let lastName = "Dylan"
}

struct StructWithNestedType : PersistableRecord, FetchableRecord, Codable {
static let databaseTableName = "t1"
let nested: [NestedStruct]
}

let dbQueue = try makeDatabaseQueue()
try dbQueue.inDatabase { db in
try db.create(table: "t1") { t in
t.column("nested", .text)
}

let value = StructWithNestedType(nested: [NestedStruct(), NestedStruct()])
try value.insert(db)

let parentModel = try StructWithNestedType.fetchAll(db)

guard let arrayOfNestedModel = parentModel.first?.nested, let firstNestedModelInArray = arrayOfNestedModel.first else {
XCTFail()
return
}

// Check there are two models in array
XCTAssertTrue(arrayOfNestedModel.count == 2)

// Check the nested model contains the expected values of first and last name
XCTAssertEqual(firstNestedModelInArray.firstName, "Bob")
XCTAssertEqual(firstNestedModelInArray.lastName, "Dylan")
}
}
}
Loading