diff --git a/Examples/CaseStudies/DynamicQuery.swift b/Examples/CaseStudies/DynamicQuery.swift index 9d388edd..b4c25163 100644 --- a/Examples/CaseStudies/DynamicQuery.swift +++ b/Examples/CaseStudies/DynamicQuery.swift @@ -89,7 +89,7 @@ struct DynamicQueryDemo: SwiftUICaseStudy { func fetch(_ db: Database) throws -> Value { let search = Fact - .where { $0.body.contains(query) } + .where { $0.body.like("%\(query)%") } .order { $0.id.desc() } return try Value( facts: search.fetchAll(db), diff --git a/Examples/Examples.xcodeproj/project.pbxproj b/Examples/Examples.xcodeproj/project.pbxproj index d21c4881..29b9f984 100644 --- a/Examples/Examples.xcodeproj/project.pbxproj +++ b/Examples/Examples.xcodeproj/project.pbxproj @@ -1100,6 +1100,8 @@ isa = XCLocalSwiftPackageReference; 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 b3c5cab9..099b2798 100644 --- a/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -123,8 +123,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-structured-queries", "state" : { - "revision" : "859958c7cf76918abf5aa5d6ce8b068e00a77eb2", - "version" : "0.32.0" + "branch" : "strict-decoding", + "revision" : "eb47155b76011b4f1c77637e9efb51c69ef0083e" } }, { diff --git a/Examples/Reminders/Schema.swift b/Examples/Reminders/Schema.swift index 50c7746f..54dc8f4f 100644 --- a/Examples/Reminders/Schema.swift +++ b/Examples/Reminders/Schema.swift @@ -36,6 +36,7 @@ nonisolated struct Reminder: Hashable, Identifiable { var notes = "" var position = 0 var priority: Priority? + @Column(lazyInitializable: false) var remindersListID: RemindersList.ID var status: Status = .incomplete var title = "" diff --git a/Examples/Reminders/SearchReminders.swift b/Examples/Reminders/SearchReminders.swift index 7faf8f51..9c33dfca 100644 --- a/Examples/Reminders/SearchReminders.swift +++ b/Examples/Reminders/SearchReminders.swift @@ -96,7 +96,7 @@ class SearchRemindersModel { let existingTags = searchTokens.compactMap { $0.kind == .tag ? $0.rawValue : nil } try await $tags.load( Tag - .where { $0.title.hasPrefix(searchText.dropFirst()) && !$0.title.in(existingTags) } + .where { $0.title.like("\(searchText.dropFirst())%") && !$0.title.in(existingTags) } .order(by: \.title) ) } else { diff --git a/Examples/SyncUps/Dependencies/SpeechClient.swift b/Examples/SyncUps/Dependencies/SpeechClient.swift index f6725781..66debf07 100644 --- a/Examples/SyncUps/Dependencies/SpeechClient.swift +++ b/Examples/SyncUps/Dependencies/SpeechClient.swift @@ -37,7 +37,7 @@ nonisolated struct SpeechClient: DependencyKey { requestAuthorization: { .authorized }, startTask: { AsyncThrowingStream { continuation in - Task { @MainActor in + _ = Task { @MainActor in isRecording.setValue(true) var finalText = """ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor \ diff --git a/Examples/SyncUps/SyncUpDetail.swift b/Examples/SyncUps/SyncUpDetail.swift index e5b305c1..9818ff3e 100644 --- a/Examples/SyncUps/SyncUpDetail.swift +++ b/Examples/SyncUps/SyncUpDetail.swift @@ -44,7 +44,7 @@ final class SyncUpDetailModel: HashableObject { withErrorReporting { try database.write { db in let ids = indices.map { meetings[$0].id } - try Meeting.where { ids.contains($0.id) }.delete().execute(db) + try Meeting.where { $0.id.in(ids) }.delete().execute(db) } } } diff --git a/Package.resolved b/Package.resolved index b0538ea6..fda925a4 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "e2cc6c054d5cb45b58ddae48858b3338ce4fc9283457edb40b284849c3914ed5", + "originHash" : "208b608351bdaad231682c75942344a5e93cbd7d4e2d03ef90c0a0623cb0bf68", "pins" : [ { "identity" : "combine-schedulers", @@ -15,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/groue/GRDB.swift", "state" : { - "revision" : "9ed8c8457e00ff9c7aedb3bf213f20a2cfdf509e", - "version" : "7.11.0" + "revision" : "b83108d10f42680d78f23fe4d4d80fc88dab3212", + "version" : "7.11.1" } }, { @@ -51,8 +51,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-custom-dump", "state" : { - "revision" : "b9b59eb58c946236d6f16305c576ad194c36444e", - "version" : "1.6.0" + "revision" : "a8cd6c976f335ed361dcecddb0dc39ebda51bc3e", + "version" : "1.6.1" } }, { @@ -60,8 +60,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-dependencies", "state" : { - "revision" : "f80552807ec92f72fe3fe4543d71879182b0bfd5", - "version" : "1.13.0" + "revision" : "8dc1fbf2f6255a73dec53b4648164884898db4c5", + "version" : "1.14.1" } }, { @@ -105,8 +105,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-sharing", "state" : { - "revision" : "e47a2f545bafa3c0c702600f3e6ce02b3d566b6f", - "version" : "2.8.2" + "revision" : "8244fe63bf43e58188ab13851ad693eecf6a9e90", + "version" : "2.9.1" } }, { @@ -123,8 +123,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-structured-queries", "state" : { - "revision" : "859958c7cf76918abf5aa5d6ce8b068e00a77eb2", - "version" : "0.32.0" + "branch" : "strict-decoding", + "revision" : "eb47155b76011b4f1c77637e9efb51c69ef0083e" } }, { @@ -141,8 +141,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", "state" : { - "revision" : "cb281f343fd953280336dcbd3822cdf47c182f5b", - "version" : "1.10.0" + "revision" : "401bf70d95bfe8db2a1dc619f9e175a85c089321", + "version" : "1.10.1" } } ], diff --git a/Package.swift b/Package.swift index 837ac528..a6595638 100644 --- a/Package.swift +++ b/Package.swift @@ -22,6 +22,17 @@ let package = Package( ), ], traits: [ + .trait( + name: "LazyInitializableByDefault", + description: "Optionalize draft properties that have no default." + ), + .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: "CasePaths", description: "Introduce support for enum tables." @@ -47,8 +58,13 @@ 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.32.0", + branch: "strict-decoding", +// from: "0.32.0", traits: [ + .trait( + name: "LazyInitializableByDefault", + condition: .when(traits: ["LazyInitializableByDefault"]) + ), .trait(name: "CasePaths", condition: .when(traits: ["CasePaths"])), .trait(name: "Tagged", condition: .when(traits: ["Tagged"])), ] diff --git a/Sources/SQLiteData/StructuredQueries+GRDB/CustomFunctions.swift b/Sources/SQLiteData/StructuredQueries+GRDB/CustomFunctions.swift index 45ef042b..81ceece1 100644 --- a/Sources/SQLiteData/StructuredQueries+GRDB/CustomFunctions.swift +++ b/Sources/SQLiteData/StructuredQueries+GRDB/CustomFunctions.swift @@ -20,11 +20,16 @@ extension Database { Unmanaged.passRetained(ScalarDatabaseFunctionDefinition(function)).toOpaque(), { context, argumentCount, arguments in do { - var decoder = SQLiteFunctionDecoder(argumentCount: argumentCount, arguments: arguments) - try Unmanaged + let function = Unmanaged .fromOpaque(sqlite3_user_data(context)) .takeUnretainedValue() .function + var decoder = SQLiteFunctionDecoder( + name: function.name, + argumentCount: argumentCount, + arguments: arguments + ) + try function .invoke(&decoder) .result(db: context) } catch { @@ -53,8 +58,12 @@ extension Database { body, nil, { context, argumentCount, arguments in - var decoder = SQLiteFunctionDecoder(argumentCount: argumentCount, arguments: arguments) let function = AggregateDatabaseFunctionContext[context].takeUnretainedValue() + var decoder = SQLiteFunctionDecoder( + name: function.iterator.body.name, + argumentCount: argumentCount, + arguments: arguments + ) do { try function.iterator.step(&decoder) } catch { diff --git a/Sources/SQLiteData/StructuredQueries+GRDB/Decoding.swift b/Sources/SQLiteData/StructuredQueries+GRDB/Decoding.swift new file mode 100644 index 00000000..6d1ca564 --- /dev/null +++ b/Sources/SQLiteData/StructuredQueries+GRDB/Decoding.swift @@ -0,0 +1,19 @@ +import GRDBSQLite + +#if !StrictDecoding + import ConcurrencyExtras + + let reportedTypeMismatches = LockIsolated>([]) +#endif + +@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 587365dc..e195b410 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?) { + init(name: String, argumentCount: Int32, arguments: UnsafeMutablePointer?) { + self.name = name self.argumentCount = argumentCount self.arguments = arguments } @@ -26,10 +35,18 @@ struct SQLiteFunctionDecoder: QueryDecoder { @inlinable mutating func decode(_ columnType: [UInt8].Type) throws -> [UInt8]? { - defer { currentIndex += 1 } 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) @@ -52,10 +69,18 @@ struct SQLiteFunctionDecoder: QueryDecoder { @inlinable mutating func decode(_ columnType: Double.Type) throws -> Double? { - defer { currentIndex += 1 } 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) } @@ -66,19 +91,35 @@ struct SQLiteFunctionDecoder: QueryDecoder { @inlinable mutating func decode(_ columnType: Int64.Type) throws -> Int64? { - defer { currentIndex += 1 } 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 } 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)) } @@ -94,4 +135,22 @@ struct SQLiteFunctionDecoder: QueryDecoder { guard let uuidString = try decode(String.self) else { return nil } return UUID(uuidString: uuidString) } + + @usableFromInline + func reportTypeMismatch(_ columnType: Any.Type) throws { + #if StrictDecoding + throw QueryDecodingError.typeMismatch(columnType) + #else + let key = "\(currentIndex)|\(name)" + guard reportedTypeMismatches.withValue({ $0.insert(key).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..c8de563d 100644 --- a/Sources/SQLiteData/StructuredQueries+GRDB/SQLiteQueryDecoder.swift +++ b/Sources/SQLiteData/StructuredQueries+GRDB/SQLiteQueryDecoder.swift @@ -2,6 +2,11 @@ public import Foundation public import GRDBSQLite public import StructuredQueriesCore +#if !StrictDecoding + import ConcurrencyExtras + import IssueReporting +#endif + @usableFromInline struct SQLiteQueryDecoder: QueryDecoder { @usableFromInline @@ -22,8 +27,16 @@ struct SQLiteQueryDecoder: QueryDecoder { @inlinable mutating func decode(_ columnType: [UInt8].Type) throws -> [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), @@ -44,8 +57,16 @@ struct SQLiteQueryDecoder: QueryDecoder { @inlinable mutating func decode(_ columnType: Double.Type) throws -> 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) } @@ -56,15 +77,31 @@ struct SQLiteQueryDecoder: QueryDecoder { @inlinable mutating func decode(_ columnType: Int64.Type) throws -> 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? { + 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)) } @@ -81,6 +118,28 @@ struct SQLiteQueryDecoder: QueryDecoder { guard let uuid = UUID(uuidString: uuidString) else { throw InvalidUUID() } return uuid } + + @usableFromInline + func reportTypeMismatch(_ columnType: Any.Type) throws { + #if StrictDecoding + throw QueryDecodingError.typeMismatch(columnType) + #else + let sql = sqlite3_sql(statement).map { String(cString: $0) } ?? "" + let key = "\(currentIndex)|\(sql)" + guard reportedTypeMismatches.withValue({ $0.insert(key).inserted }) + else { return } + let columnName = sqlite3_column_name(statement, currentIndex).map { String(cString: $0) } + reportIssue( + """ + Expected column \(currentIndex) (\((columnName ?? "").debugDescription)) to decode \ + \(columnType), but found \ + \(storageClassName(sqlite3_column_type(statement, currentIndex))): ... + + \(sql) + """ + ) + #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