diff --git a/Examples/Examples.xcodeproj/project.pbxproj b/Examples/Examples.xcodeproj/project.pbxproj index 0040127d..29b9f984 100644 --- a/Examples/Examples.xcodeproj/project.pbxproj +++ b/Examples/Examples.xcodeproj/project.pbxproj @@ -1101,6 +1101,7 @@ relativePath = ..; traits = ( LazyInitializableByDefault, + StrictDecoding, ); }; /* End XCLocalSwiftPackageReference section */ diff --git a/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index ce3dfb78..8b742760 100644 --- a/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "c56e7b70de4fe8bcc798354797a8405c0eeb2e9bc68bc0f202c14a8b11a0a97f", + "originHash" : "c133bf7d10c8ce1e5d6506c3d2f080eac8b4c8c2827044d53a9b925e903564fd", "pins" : [ { "identity" : "combine-schedulers", @@ -123,8 +123,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-structured-queries", "state" : { - "revision" : "50a429884d7a6c0613df2a31a24009cc436bfb18", - "version" : "0.33.2" + "revision" : "8a733f414adc1224c5185c3a35c30ee1ef217171", + "version" : "0.36.0" } }, { diff --git a/Package.resolved b/Package.resolved index a2730804..c161271d 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "688f63393a7436307eb0f4f8a2e5bf155eb604781201924d1c58b3634e1ab647", + "originHash" : "c23a65a671a050ad4a9a14e2506d4c5d127c928aa245e735949f4e4891331401", "pins" : [ { "identity" : "combine-schedulers", @@ -42,8 +42,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-concurrency-extras", "state" : { - "revision" : "a90e2e40a7a840a853dd29e57cbef5dbb72c9d5b", - "version" : "1.4.0" + "revision" : "5fa253428866f2360c3754e88537f700ed2656b5", + "version" : "1.4.1" } }, { @@ -51,8 +51,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-custom-dump", "state" : { - "revision" : "a8cd6c976f335ed361dcecddb0dc39ebda51bc3e", - "version" : "1.6.1" + "revision" : "e9c34fd54ece006b491a0e6d23fe9a6024b9a828", + "version" : "1.7.0" } }, { @@ -114,8 +114,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-snapshot-testing", "state" : { - "revision" : "1bc16f430d8410e7f087d4c787767b26fd32fe30", - "version" : "1.19.3" + "revision" : "59a99c458de4d2dee580529b61b4f78dca7b7fa6", + "version" : "1.19.4" } }, { @@ -123,8 +123,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-structured-queries", "state" : { - "revision" : "fb25c16d0bde460ffc01fe19ab911bb26f40f26b", - "version" : "0.35.0" + "revision" : "8a733f414adc1224c5185c3a35c30ee1ef217171", + "version" : "0.36.0" } }, { diff --git a/Package.swift b/Package.swift index eead4cd9..52445928 100644 --- a/Package.swift +++ b/Package.swift @@ -37,6 +37,13 @@ let package = Package( bundled with the platform. """ ), + .trait( + name: "StrictDecoding", + description: """ + Throw an error, rather than coerce, when decoding a column whose storage type does not \ + match the expected type. + """ + ), .trait( name: "Tagged", description: "Introduce SQLiteData conformances to the swift-tagged package." @@ -58,8 +65,12 @@ let package = Package( .package(url: "https://github.com/pointfreeco/swift-snapshot-testing", from: "1.18.4"), .package( url: "https://github.com/pointfreeco/swift-structured-queries", - from: "0.35.0", + from: "0.36.0", traits: [ + .trait( + name: "LazyInitializableByDefault", + condition: .when(traits: ["LazyInitializableByDefault"]) + ), .trait(name: "CasePaths", condition: .when(traits: ["CasePaths"])), .trait( name: "LazyInitializableByDefault", diff --git a/Sources/SQLiteData/Documentation.docc/Articles/Traits.md b/Sources/SQLiteData/Documentation.docc/Articles/Traits.md new file mode 100644 index 00000000..7d184de1 --- /dev/null +++ b/Sources/SQLiteData/Documentation.docc/Articles/Traits.md @@ -0,0 +1,287 @@ +# Package traits + +Learn about the opt-in package traits that SQLiteData provides, including support for enum tables, +tagged identifiers, and more. + +## Overview + +SQLiteData uses [package traits], a Swift 6.1 feature, to provide opt-in functionality that extends +the core library. Traits allow the library to integrate with other packages, and to introduce new +behavior in a backwards-compatible manner, without imposing extra dependencies or +source changes on projects that do not need them. + +[package traits]: https://github.com/swiftlang/swift-evolution/blob/main/proposals/0450-swiftpm-package-traits.md + +The library currently provides the following traits: + + * [`CasePaths`](#CasePaths): Adds support for single-table inheritance _via_ "enum" tables. + * [`ColumnCoding`](#ColumnCoding): Aligns the `Codable` conformance of tables and selections + with their column names. + * [`LazyInitializableByDefault`](#LazyInitializableByDefault): Makes draft properties with no + default value lazy-initializable. + * [`StrictDecoding`](#StrictDecoding): Throw an error, rather than coerce, when decoding a column + whose storage type does not match the expected type. + * [`SuppressPlatformSQLiteAvailability`](#SuppressPlatformSQLiteAvailability): Suppresses + `@available` checks on APIs that depend on a newer version of SQLite than the one bundled with + the platform. + * [`Tagged`](#Tagged): Adds support for type-safe identifiers. + +To enable a trait, specify it in the `Package.swift` file that depends on SQLiteData: + +```diff + .package( + url: "https://github.com/pointfreeco/sqlite-data", + from: "1.10.0", ++ traits: ["CasePaths", "ColumnCoding", "LazyInitializableByDefault"] + ), +``` + +…or enable the trait from your Xcode project's package dependencies. + +### CasePaths + +The `CasePaths` trait unlocks the ability to use enums as a domain modeling tool for your table +schema, which can help you emulate "inheritance" for your tables without the burden of using +reference types. For example, a table of attachments, where an attachment can either be a link, a +note, or an image, can be modeled as a struct that holds onto an enum of the possible kinds: + +```swift +@Table struct Attachment { + let id: Int + let kind: Kind + + @Selection + enum Kind { + case link(URL) + case note(String) + case image(URL) + } +} +``` + +This functionality is powered by our [CasePaths] library, which enhances enumerations with key +path-like functionality, and so enabling the trait adds a dependency on that package. + +[CasePaths]: https://github.com/pointfreeco/swift-case-paths + +To enable the trait, specify it in the `Package.swift` file that depends on SQLiteData: + +```diff + .package( + url: "https://github.com/pointfreeco/sqlite-data", + from: "1.10.0", ++ traits: ["CasePaths"] + ), +``` + +> Important: On Swift toolchains earlier than 6.3, you _must_ also explicitly depend on the +> `swift-case-paths` package to work around a SwiftPM bug in which dependencies introduced by a +> trait are not resolved: +> +> ```diff +> +.package( +> + url: "https://github.com/pointfreeco/swift-case-paths", +> + from: "1.0.0" +> +), +> ``` +> +> This bug is fixed in Swift 6.3, where the explicit dependency can be omitted. + +See [Enum tables]() for more information on modeling your +schema with enums. + +### ColumnCoding + +The `ColumnCoding` trait aligns the `Codable` conformance of tables and selections with their +column names. By default, Swift synthesizes coding keys for `Codable` types from a type's property +names, which can drift from the column names the `@Table` and `@Selection` macros use when columns +are renamed: + +```swift +@Table +struct Reminder: Codable { + let id: Int + @Column("is_completed") + var isCompleted = false +} +``` + +Without the trait enabled, the above type encodes its `isCompleted` property under the +`"isCompleted"` key, even though the database column is named `"is_completed"`. With the trait +enabled, the macro generates a `CodingKeys` conformance that matches the schema: + +```swift +private enum CodingKeys: String, CodingKey { + case id + case isCompleted = "is_completed" +} +``` + +This keeps external representations of your data types, such as JSON, consistent with your schema. +Because the macro takes over `CodingKeys` generation, defining a custom `CodingKeys` type on a +table or selection is a compile-time diagnostic when the trait is enabled. + +To enable the trait, specify it in the `Package.swift` file that depends on SQLiteData: + +```diff + .package( + url: "https://github.com/pointfreeco/sqlite-data", + from: "1.10.0", ++ traits: ["ColumnCoding"] + ), +``` + +> Important: In the next major release of SQLiteData this be the default behavior. Add this trait +> and update your tables to get a head start on compatibility. + +### LazyInitializableByDefault + +The `LazyInitializableByDefault` trait makes every table property with no default value +lazy-initializable in its generated `Draft` type, matching the lazy initialization of the draft's +primary key. This means such fields become optional in the draft and can be omitted when creating +one, which is useful for fields whose values are assigned by the database, such as +created/updated timestamps and foreign keys: + +```swift +@Table +struct User { + let id: UUID + var name = "" + let createdAt: Date + let updatedAt: Date +} + +let draft = User.Draft(name: "Blob") +``` + +Without the trait enabled, the above would fail to compile because `createdAt` and `updatedAt` +must be provided, unless each property is explicitly annotated with +`@Column(lazyInitializable: true)`. + +To enable the trait, specify it in the `Package.swift` file that depends on SQLiteData: + +```diff + .package( + url: "https://github.com/pointfreeco/sqlite-data", + from: "1.10.0", ++ traits: ["LazyInitializableByDefault"] + ), +``` + +See [Lazy initialization]() for more information on +lazy-initializable draft properties. + +> Important: In a future version of SQLiteData this will become the default behavior, and you will +> be able to opt out of it for a particular property with `@Column(lazyInitializable: false)`. +> Enable the trait today to prepare for that future release. + +### StrictDecoding + +The `StrictDecoding` trait throws errors during decoding when a Swift type being decoded does not +match the affinity of the SQLite data type. This helps align the data you store with the data you +load into your application, and prevents accidentally data corruption. + +The trait is disabled by default for backwards compatibility, and instead of throwing an error, an +issue is reported to the developer. + +To enable the trait, specify it in the `Package.swift` file that depends on SQLiteData: + +```diff + .package( + url: "https://github.com/pointfreeco/sqlite-data", + from: "1.10.0", ++ traits: ["StrictDecoding"] + ), +``` + +> Important: In a future version of SQLiteData this will become the default behavior. Enable the +> trait today to prepare for that future release. + +### SuppressPlatformSQLiteAvailability + +Some of the library's APIs correspond to SQLite features that are newer than the version of SQLite +bundled with certain Apple platforms. For example, the JSONB family of functions requires SQLite +3.45, which first shipped with iOS 26, macOS 26, tvOS 26, and watchOS 26. Such APIs are annotated +with `@available` attributes so that the compiler prevents you from invoking a SQLite function that +does not exist on an older OS: + +```swift +Reminder.select { $0.title.jsonbGroupArray() } +// 🛑 'jsonbGroupArray' is only available in iOS 26 or newer +``` + +These checks only apply to the system SQLite, though. If your app links against its own copy of +SQLite instead, such as a custom build of SQLite or SQLCipher, these restrictions are unnecessary, +and you can enable the `SuppressPlatformSQLiteAvailability` trait to remove them. + +To enable the trait, specify it in the `Package.swift` file that depends on SQLiteData: + +```diff + .package( + url: "https://github.com/pointfreeco/sqlite-data", + from: "1.10.0", ++ traits: ["SuppressPlatformSQLiteAvailability"] + ), +``` + +> Warning: Only enable this trait if your app embeds a version of SQLite that supports the features +> you use. If you enable it while relying on the system SQLite, queries that use newer functions +> will compile but fail at runtime on older OS versions. + +### Tagged + +The `Tagged` trait adds support for the [Tagged] library, which provides lightweight syntax for +introducing type-safe identifiers (and more) to your models. With the trait enabled, tagged values +can be used directly in your schema: + +[Tagged]: https://github.com/pointfreeco/swift-tagged + +```swift +@Table +struct RemindersList: Identifiable { + typealias ID = Tagged + let id: ID + var title = "" +} +@Table +struct Reminder: Identifiable { + typealias ID = Tagged + let id: ID + var title = "" + var remindersListID: RemindersList.ID +} +``` + +This makes it a compile-time error to compare a `Reminder.ID` to a `RemindersList.ID`, or to pass +one where the other is expected. + +To enable the trait, specify it in the `Package.swift` file that depends on SQLiteData: + +```diff + .package( + url: "https://github.com/pointfreeco/sqlite-data", + from: "1.10.0", ++ traits: ["Tagged"] + ), +``` + +> Important: On Swift toolchains earlier than 6.3, you _must_ also explicitly depend on the +> `swift-tagged` package to work around a SwiftPM bug in which dependencies introduced by a trait +> are not resolved: +> +> ```diff +> +.package( +> + url: "https://github.com/pointfreeco/swift-tagged", +> + from: "0.1.0" +> +), +> ``` +> +> This bug is fixed in Swift 6.3, where the explicit dependency can be omitted. + +See [Tagged identifiers]() for more information on +using tagged values in your schema. + +### Deprecated traits + +The `SQLiteDataTagged` trait is a deprecated aliases to the `Tagged` trait, and will be removed in +a future version of SQLiteData. diff --git a/Sources/SQLiteData/Documentation.docc/SQLiteData.md b/Sources/SQLiteData/Documentation.docc/SQLiteData.md index 3dad6730..09bd7e35 100644 --- a/Sources/SQLiteData/Documentation.docc/SQLiteData.md +++ b/Sources/SQLiteData/Documentation.docc/SQLiteData.md @@ -287,6 +287,7 @@ with SQLite to take full advantage of GRDB and SQLiteData. - - - +- ### Database configuration and access diff --git a/Sources/SQLiteData/StructuredQueries+GRDB/CustomFunctions.swift b/Sources/SQLiteData/StructuredQueries+GRDB/CustomFunctions.swift index 45ef042b..d79b7423 100644 --- a/Sources/SQLiteData/StructuredQueries+GRDB/CustomFunctions.swift +++ b/Sources/SQLiteData/StructuredQueries+GRDB/CustomFunctions.swift @@ -20,12 +20,12 @@ extension Database { Unmanaged.passRetained(ScalarDatabaseFunctionDefinition(function)).toOpaque(), { context, argumentCount, arguments in do { - var decoder = SQLiteFunctionDecoder(argumentCount: argumentCount, arguments: arguments) - try Unmanaged + let definition = Unmanaged .fromOpaque(sqlite3_user_data(context)) .takeUnretainedValue() - .function - .invoke(&decoder) + definition.decoder.reset(argumentCount: argumentCount, arguments: arguments) + try definition.function + .invoke(&definition.decoder) .result(db: context) } catch { QueryBinding.invalid(error).result(db: context) @@ -53,10 +53,10 @@ extension Database { body, nil, { context, argumentCount, arguments in - var decoder = SQLiteFunctionDecoder(argumentCount: argumentCount, arguments: arguments) let function = AggregateDatabaseFunctionContext[context].takeUnretainedValue() + function.decoder.reset(argumentCount: argumentCount, arguments: arguments) do { - try function.iterator.step(&decoder) + try function.iterator.step(&function.decoder) } catch { sqlite3_result_error(context, error.localizedDescription, -1) } @@ -109,8 +109,10 @@ extension DatabaseFunction { private final class ScalarDatabaseFunctionDefinition { let function: any ScalarDatabaseFunction + var decoder: SQLiteFunctionDecoder init(_ function: some ScalarDatabaseFunction) { self.function = function + self.decoder = SQLiteFunctionDecoder(name: function.name) } } @@ -143,8 +145,10 @@ private final class AggregateDatabaseFunctionContext { } } let iterator: any AggregateDatabaseFunctionIteratorProtocol + var decoder: SQLiteFunctionDecoder init(_ body: some AggregateDatabaseFunction) { self.iterator = AggregateDatabaseFunctionIterator(body) + self.decoder = SQLiteFunctionDecoder(name: body.name) } } diff --git a/Sources/SQLiteData/StructuredQueries+GRDB/Decoding.swift b/Sources/SQLiteData/StructuredQueries+GRDB/Decoding.swift new file mode 100644 index 00000000..c82b117f --- /dev/null +++ b/Sources/SQLiteData/StructuredQueries+GRDB/Decoding.swift @@ -0,0 +1,13 @@ +import GRDBSQLite + +@usableFromInline +func storageClassName(_ type: Int32) -> String { + switch type { + case SQLITE_BLOB: "BLOB" + case SQLITE_FLOAT: "REAL" + case SQLITE_INTEGER: "INTEGER" + case SQLITE_TEXT: "TEXT" + case SQLITE_NULL: "NULL" + default: "unknown storage class \(type)" + } +} diff --git a/Sources/SQLiteData/StructuredQueries+GRDB/QueryCursor.swift b/Sources/SQLiteData/StructuredQueries+GRDB/QueryCursor.swift index b8829e18..eb3d1975 100644 --- a/Sources/SQLiteData/StructuredQueries+GRDB/QueryCursor.swift +++ b/Sources/SQLiteData/StructuredQueries+GRDB/QueryCursor.swift @@ -30,24 +30,51 @@ public class QueryCursor: DatabaseCursor { struct DecodingError: Error, CustomStringConvertible { let columnIndex: Int let columnName: String + let reason: String let sql: String @usableFromInline - init(columnIndex: Int, columnName: String, sql: String) { + init(columnIndex: Int, columnName: String, reason: String, sql: String) { self.columnIndex = columnIndex self.columnName = columnName + self.reason = reason self.sql = sql } @usableFromInline var description: String { """ - Expected column \(columnIndex) (\(columnName.debugDescription)) to not be NULL: … + Expected column \(columnIndex) (\(columnName.debugDescription)) \(reason): ... \(sql) """ } } + + @usableFromInline + func missingRequiredColumnError() -> DecodingError { + let columnIndex = Int(decoder.currentIndex) - 1 + return DecodingError( + columnIndex: columnIndex, + columnName: _statement.columnNames[columnIndex], + reason: "to not be NULL", + sql: _statement.sql + ) + } + + @usableFromInline + func typeMismatchError(_ columnType: Any.Type) -> DecodingError { + let columnIndex = Int(decoder.currentIndex) + let storageClass = storageClassName( + sqlite3_column_type(_statement.sqliteStatement, Int32(columnIndex)) + ) + return DecodingError( + columnIndex: columnIndex, + columnName: _statement.columnNames[columnIndex], + reason: "to decode \(columnType), but found \(storageClass)", + sql: _statement.sql + ) + } } @usableFromInline @@ -68,12 +95,9 @@ final class QueryValueCursor: QueryCursor? + var arguments: UnsafeMutablePointer? @usableFromInline var currentIndex: Int32 = 0 + #if !StrictDecoding + @usableFromInline + var reportedTypeMismatches: Set = [] + #endif + + @usableFromInline + init(name: String) { + self.name = name + } + @usableFromInline - init(argumentCount: Int32, arguments: UnsafeMutablePointer?) { + mutating func reset(argumentCount: Int32, arguments: UnsafeMutablePointer?) { self.argumentCount = argumentCount self.arguments = arguments + self.currentIndex = 0 } @inlinable @@ -25,11 +43,19 @@ struct SQLiteFunctionDecoder: QueryDecoder { } @inlinable - mutating func decode(_ columnType: [UInt8].Type) throws -> [UInt8]? { - defer { currentIndex += 1 } + mutating func decode(_ columnType: [UInt8].Type) throws(QueryDecodingError) -> [UInt8]? { precondition(argumentCount > currentIndex) let value = arguments?[Int(currentIndex)] - guard sqlite3_value_type(value) != SQLITE_NULL else { return nil } + switch sqlite3_value_type(value) { + case SQLITE_NULL: + currentIndex += 1 + return nil + case SQLITE_BLOB: + break + default: + try reportTypeMismatch([UInt8].self) + } + defer { currentIndex += 1 } if let blob = sqlite3_value_blob(value) { let count = Int(sqlite3_value_bytes(value)) let buffer = UnsafeRawBufferPointer(start: blob, count: count) @@ -40,58 +66,103 @@ struct SQLiteFunctionDecoder: QueryDecoder { } @inlinable - mutating func decode(_ columnType: Bool.Type) throws -> Bool? { + mutating func decode(_ columnType: Bool.Type) throws(QueryDecodingError) -> Bool? { try decode(Int64.self).map { $0 != 0 } } @usableFromInline - mutating func decode(_ columnType: Date.Type) throws -> Date? { + mutating func decode(_ columnType: Date.Type) throws(QueryDecodingError) -> Date? { guard let iso8601String = try decode(String.self) else { return nil } - return try Date(iso8601String: iso8601String) + do { + return try Date(iso8601String: iso8601String) + } catch { + throw .other(error) + } } @inlinable - mutating func decode(_ columnType: Double.Type) throws -> Double? { - defer { currentIndex += 1 } + mutating func decode(_ columnType: Double.Type) throws(QueryDecodingError) -> Double? { precondition(argumentCount > currentIndex) let value = arguments?[Int(currentIndex)] - guard sqlite3_value_type(value) != SQLITE_NULL else { return nil } + switch sqlite3_value_type(value) { + case SQLITE_NULL: + currentIndex += 1 + return nil + case SQLITE_FLOAT: + break + default: + try reportTypeMismatch(Double.self) + } + defer { currentIndex += 1 } return sqlite3_value_double(value) } @inlinable - mutating func decode(_ columnType: Int.Type) throws -> Int? { + mutating func decode(_ columnType: Int.Type) throws(QueryDecodingError) -> Int? { try decode(Int64.self).map(Int.init) } @inlinable - mutating func decode(_ columnType: Int64.Type) throws -> Int64? { - defer { currentIndex += 1 } + mutating func decode(_ columnType: Int64.Type) throws(QueryDecodingError) -> Int64? { precondition(argumentCount > currentIndex) let value = arguments?[Int(currentIndex)] - guard sqlite3_value_type(value) != SQLITE_NULL else { return nil } + switch sqlite3_value_type(value) { + case SQLITE_NULL: + currentIndex += 1 + return nil + case SQLITE_INTEGER: + break + default: + try reportTypeMismatch(Int64.self) + } + defer { currentIndex += 1 } return sqlite3_value_int64(value) } @inlinable - mutating func decode(_ columnType: String.Type) throws -> String? { - defer { currentIndex += 1 } + mutating func decode(_ columnType: String.Type) throws(QueryDecodingError) -> String? { precondition(argumentCount > currentIndex) let value = arguments?[Int(currentIndex)] - guard sqlite3_value_type(value) != SQLITE_NULL else { return nil } + switch sqlite3_value_type(value) { + case SQLITE_NULL: + currentIndex += 1 + return nil + case SQLITE_TEXT: + break + default: + try reportTypeMismatch(String.self) + } + defer { currentIndex += 1 } return String(cString: sqlite3_value_text(value)) } @inlinable - mutating func decode(_ columnType: UInt64.Type) throws -> UInt64? { + mutating func decode(_ columnType: UInt64.Type) throws(QueryDecodingError) -> UInt64? { guard let n = try decode(Int64.self) else { return nil } - guard n >= 0 else { throw UInt64OverflowError(signedInteger: n) } + guard n >= 0 else { throw .other(UInt64OverflowError(signedInteger: n)) } return UInt64(n) } @usableFromInline - mutating func decode(_ columnType: UUID.Type) throws -> UUID? { + mutating func decode(_ columnType: UUID.Type) throws(QueryDecodingError) -> UUID? { guard let uuidString = try decode(String.self) else { return nil } return UUID(uuidString: uuidString) } + + @usableFromInline + mutating func reportTypeMismatch(_ columnType: Any.Type) throws(QueryDecodingError) { + #if StrictDecoding + throw QueryDecodingError.typeMismatch(columnType) + #else + guard reportedTypeMismatches.insert(currentIndex).inserted + else { return } + let value = arguments?[Int(currentIndex)] + reportIssue( + """ + Expected argument \(currentIndex) of \(name.debugDescription) to decode \(columnType), \ + but found \(storageClassName(sqlite3_value_type(value))) + """ + ) + #endif + } } diff --git a/Sources/SQLiteData/StructuredQueries+GRDB/SQLiteQueryDecoder.swift b/Sources/SQLiteData/StructuredQueries+GRDB/SQLiteQueryDecoder.swift index 8e926e0f..edbdd195 100644 --- a/Sources/SQLiteData/StructuredQueries+GRDB/SQLiteQueryDecoder.swift +++ b/Sources/SQLiteData/StructuredQueries+GRDB/SQLiteQueryDecoder.swift @@ -2,6 +2,10 @@ public import Foundation public import GRDBSQLite public import StructuredQueriesCore +#if !StrictDecoding + import IssueReporting +#endif + @usableFromInline struct SQLiteQueryDecoder: QueryDecoder { @usableFromInline @@ -10,6 +14,11 @@ struct SQLiteQueryDecoder: QueryDecoder { @usableFromInline var currentIndex: Int32 = 0 + #if !StrictDecoding + @usableFromInline + var reportedTypeMismatches: Set = [] + #endif + @usableFromInline init(statement: OpaquePointer) { self.statement = statement @@ -21,9 +30,17 @@ struct SQLiteQueryDecoder: QueryDecoder { } @inlinable - mutating func decode(_ columnType: [UInt8].Type) throws -> [UInt8]? { + mutating func decode(_ columnType: [UInt8].Type) throws(QueryDecodingError) -> [UInt8]? { + switch sqlite3_column_type(statement, currentIndex) { + case SQLITE_NULL: + currentIndex += 1 + return nil + case SQLITE_BLOB: + break + default: + try reportTypeMismatch([UInt8].self) + } defer { currentIndex += 1 } - guard sqlite3_column_type(statement, currentIndex) != SQLITE_NULL else { return nil } return [UInt8]( UnsafeRawBufferPointer( start: sqlite3_column_blob(statement, currentIndex), @@ -33,54 +50,105 @@ struct SQLiteQueryDecoder: QueryDecoder { } @inlinable - mutating func decode(_ columnType: Bool.Type) throws -> Bool? { + mutating func decode(_ columnType: Bool.Type) throws(QueryDecodingError) -> Bool? { try decode(Int64.self).map { $0 != 0 } } @inlinable - mutating func decode(_ columnType: Date.Type) throws -> Date? { - try decode(String.self).map { try Date(iso8601String: $0) } + mutating func decode(_ columnType: Date.Type) throws(QueryDecodingError) -> Date? { + guard let iso8601String = try decode(String.self) else { return nil } + do { + return try Date(iso8601String: iso8601String) + } catch { + throw .other(error) + } } @inlinable - mutating func decode(_ columnType: Double.Type) throws -> Double? { + mutating func decode(_ columnType: Double.Type) throws(QueryDecodingError) -> Double? { + switch sqlite3_column_type(statement, currentIndex) { + case SQLITE_NULL: + currentIndex += 1 + return nil + case SQLITE_FLOAT: + break + default: + try reportTypeMismatch(Double.self) + } defer { currentIndex += 1 } - guard sqlite3_column_type(statement, currentIndex) != SQLITE_NULL else { return nil } return sqlite3_column_double(statement, currentIndex) } @inlinable - mutating func decode(_ columnType: Int.Type) throws -> Int? { + mutating func decode(_ columnType: Int.Type) throws(QueryDecodingError) -> Int? { try decode(Int64.self).map(Int.init) } @inlinable - mutating func decode(_ columnType: Int64.Type) throws -> Int64? { + mutating func decode(_ columnType: Int64.Type) throws(QueryDecodingError) -> Int64? { + switch sqlite3_column_type(statement, currentIndex) { + case SQLITE_NULL: + currentIndex += 1 + return nil + case SQLITE_INTEGER: + break + default: + try reportTypeMismatch(Int64.self) + } defer { currentIndex += 1 } - guard sqlite3_column_type(statement, currentIndex) != SQLITE_NULL else { return nil } return sqlite3_column_int64(statement, currentIndex) } @inlinable - mutating func decode(_ columnType: String.Type) throws -> String? { + mutating func decode(_ columnType: String.Type) throws(QueryDecodingError) -> String? { + switch sqlite3_column_type(statement, currentIndex) { + case SQLITE_NULL: + currentIndex += 1 + return nil + case SQLITE_TEXT: + break + default: + try reportTypeMismatch(String.self) + } defer { currentIndex += 1 } - guard sqlite3_column_type(statement, currentIndex) != SQLITE_NULL else { return nil } return String(cString: sqlite3_column_text(statement, currentIndex)) } @inlinable - mutating func decode(_ columnType: UInt64.Type) throws -> UInt64? { + mutating func decode(_ columnType: UInt64.Type) throws(QueryDecodingError) -> UInt64? { guard let n = try decode(Int64.self) else { return nil } - guard n >= 0 else { throw UInt64OverflowError(signedInteger: n) } + guard n >= 0 else { throw .other(UInt64OverflowError(signedInteger: n)) } return UInt64(n) } @inlinable - mutating func decode(_ columnType: UUID.Type) throws -> UUID? { + mutating func decode(_ columnType: UUID.Type) throws(QueryDecodingError) -> UUID? { guard let uuidString = try decode(String.self) else { return nil } - guard let uuid = UUID(uuidString: uuidString) else { throw InvalidUUID() } + guard let uuid = UUID(uuidString: uuidString) else { throw .other(InvalidUUID()) } return uuid } + + @usableFromInline + mutating func reportTypeMismatch(_ columnType: Any.Type) throws(QueryDecodingError) { + #if StrictDecoding + throw QueryDecodingError.typeMismatch(columnType) + #else + guard reportedTypeMismatches.insert(currentIndex).inserted + else { return } + let columnName = + sqlite3_column_name(statement, currentIndex) + .map { " (\(String(cString: $0).debugDescription))" } + ?? "" + reportIssue( + """ + Expected column \(currentIndex)\(columnName) to decode \(columnType), but found \ + \(storageClassName(sqlite3_column_type(statement, currentIndex))): ... + + \(sqlite3_sql(statement).map { String(cString: $0) } ?? "") + """ + ) + #endif + } } @usableFromInline diff --git a/Tests/SQLiteDataTests/CloudKitTests/SchemaChangeTests.swift b/Tests/SQLiteDataTests/CloudKitTests/SchemaChangeTests.swift index 98601572..c565ef58 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/SchemaChangeTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/SchemaChangeTests.swift @@ -837,7 +837,7 @@ recordType: "images", recordID: Image.recordID(for: 1) ) - imageRecord.setValue("1", forKey: "id", at: now) + imageRecord.setValue(1, forKey: "id", at: now) imageRecord.setValue("A good image", forKey: "caption", at: now) imageRecord.setValue(Data("image".utf8), forKey: "image", at: now) @@ -854,7 +854,7 @@ try #sql( """ CREATE TABLE "images" ( - "id" TEXT NOT NULL PRIMARY KEY ON CONFLICT REPLACE DEFAULT (uuid()), + "id" INTEGER PRIMARY KEY AUTOINCREMENT, "caption" TEXT NOT NULL, "image" BLOB NOT NULL ) @@ -892,7 +892,7 @@ parent: nil, share: nil, caption: "A good image", - id: "1", + id: 1, image: Data(5 bytes) ) ] diff --git a/Tests/SQLiteDataTests/CloudKitTests/SyncEngineDelegateTests.swift b/Tests/SQLiteDataTests/CloudKitTests/SyncEngineDelegateTests.swift index 395be01e..308c22c0 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/SyncEngineDelegateTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/SyncEngineDelegateTests.swift @@ -232,7 +232,7 @@ wasCalled.withValue { $0 = true } } deinit { - guard wasCalled.withValue(\.self) + guard wasCalled.withValue(\.self) || Test.current == nil else { Issue.record("Delegate method 'syncEngine(_:accountChanged:)' was not called.") return