diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cbe67147..26a544c4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,7 +14,7 @@ jobs: name: macOS strategy: matrix: - xcode: ["26.2"] + xcode: ["26.6"] config: ["debug", "release"] runs-on: macos-26 steps: @@ -32,7 +32,7 @@ jobs: name: Examples strategy: matrix: - xcode: ["26.2"] + xcode: ["26.6"] config: ["debug"] scheme: ["Reminders", "CaseStudies", "SyncUps"] runs-on: macos-26 diff --git a/Examples/Examples.xcodeproj/project.pbxproj b/Examples/Examples.xcodeproj/project.pbxproj index 2c6f2d6c..f11a0ded 100644 --- a/Examples/Examples.xcodeproj/project.pbxproj +++ b/Examples/Examples.xcodeproj/project.pbxproj @@ -51,7 +51,6 @@ /* Begin PBXFileReference section */ CA2BDD9D2E71C30B000974D3 /* CloudKitDemo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CloudKitDemo.app; sourceTree = BUILT_PRODUCTS_DIR; }; - CA2BDE272E71C42B000974D3 /* sqlite-data */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = "sqlite-data"; path = "/Users/brandon/projects/sqlite-data"; sourceTree = ""; }; CA5E46962DEBFE410069E0F8 /* RemindersTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RemindersTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; CA5F37542D5AFBBC002E1A9E /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; CAD0017D2D874E6F00FA977A /* SyncUpTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SyncUpTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -257,7 +256,6 @@ isa = PBXGroup; children = ( DC55FADE30363BB000F2D8D3 /* sqlite-data */, - CA2BDE272E71C42B000974D3 /* sqlite-data */, ); name = Frameworks; sourceTree = ""; @@ -1256,7 +1254,7 @@ repositoryURL = "https://github.com/pointfreeco/swift-dependencies"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 1.7.0; + minimumVersion = 1.17.1; }; }; DCBE8A122D4842BF0071F499 /* XCRemoteSwiftPackageReference "swift-case-paths" */ = { @@ -1264,7 +1262,7 @@ repositoryURL = "https://github.com/pointfreeco/swift-case-paths"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 1.6.0; + minimumVersion = 1.10.0; }; }; DCF267372D48437300B680BE /* XCRemoteSwiftPackageReference "swift-navigation" */ = { @@ -1272,7 +1270,7 @@ repositoryURL = "https://github.com/pointfreeco/swift-navigation"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 2.2.3; + minimumVersion = 2.11.2; }; }; /* End XCRemoteSwiftPackageReference section */ diff --git a/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved deleted file mode 100644 index 8bdb0fcc..00000000 --- a/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ /dev/null @@ -1,150 +0,0 @@ -{ - "originHash" : "c56e7b70de4fe8bcc798354797a8405c0eeb2e9bc68bc0f202c14a8b11a0a97f", - "pins" : [ - { - "identity" : "combine-schedulers", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/combine-schedulers", - "state" : { - "revision" : "dcccb979a2183b8df3334237e3dc1ae2b4116a86", - "version" : "1.2.0" - } - }, - { - "identity" : "grdb.swift", - "kind" : "remoteSourceControl", - "location" : "https://github.com/groue/GRDB.swift", - "state" : { - "revision" : "9ed8c8457e00ff9c7aedb3bf213f20a2cfdf509e", - "version" : "7.11.0" - } - }, - { - "identity" : "swift-case-paths", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-case-paths", - "state" : { - "revision" : "794f4b0a9cf32042592388d014f6a1ea987d323a", - "version" : "1.9.1" - } - }, - { - "identity" : "swift-clocks", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-clocks", - "state" : { - "revision" : "72d749bf341b78851203066ab421869b783ec42a", - "version" : "1.1.0" - } - }, - { - "identity" : "swift-collections", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-collections", - "state" : { - "revision" : "a0cb0954ecb21e4e31b0070e6ed5674e8556685a", - "version" : "1.6.0" - } - }, - { - "identity" : "swift-concurrency-extras", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-concurrency-extras", - "state" : { - "revision" : "a90e2e40a7a840a853dd29e57cbef5dbb72c9d5b", - "version" : "1.4.0" - } - }, - { - "identity" : "swift-custom-dump", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-custom-dump", - "state" : { - "revision" : "b9b59eb58c946236d6f16305c576ad194c36444e", - "version" : "1.6.0" - } - }, - { - "identity" : "swift-dependencies", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-dependencies", - "state" : { - "revision" : "16f7dd14ee28d04617090f2a73198b8b316ffa12", - "version" : "1.13.1" - } - }, - { - "identity" : "swift-identified-collections", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-identified-collections", - "state" : { - "revision" : "322d9ffeeba85c9f7c4984b39422ec7cc3c56597", - "version" : "1.1.1" - } - }, - { - "identity" : "swift-navigation", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-navigation", - "state" : { - "revision" : "32f35241b8be0719c4c7f00eb27713b1cadb6248", - "version" : "2.8.0" - } - }, - { - "identity" : "swift-perception", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-perception", - "state" : { - "revision" : "25ac73741c3436605d61eceb5207e896973918e7", - "version" : "2.0.10" - } - }, - { - "identity" : "swift-sharing", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-sharing", - "state" : { - "revision" : "e47a2f545bafa3c0c702600f3e6ce02b3d566b6f", - "version" : "2.8.2" - } - }, - { - "identity" : "swift-snapshot-testing", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-snapshot-testing.git", - "state" : { - "revision" : "ad5e3190cc63dc288f28546f9c6827efc1e9d495", - "version" : "1.19.2" - } - }, - { - "identity" : "swift-structured-queries", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-structured-queries", - "state" : { - "revision" : "8a733f414adc1224c5185c3a35c30ee1ef217171", - "version" : "0.36.0" - } - }, - { - "identity" : "swift-syntax", - "kind" : "remoteSourceControl", - "location" : "https://github.com/swiftlang/swift-syntax", - "state" : { - "revision" : "79e4b74a295b6eb74a8b585e3a39d29e70c1dbd1", - "version" : "603.0.2" - } - }, - { - "identity" : "xctest-dynamic-overlay", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", - "state" : { - "revision" : "cb281f343fd953280336dcbd3822cdf47c182f5b", - "version" : "1.10.0" - } - } - ], - "version" : 3 -} diff --git a/Package.resolved b/Package.resolved index 96d9694c..52f071db 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,13 +1,13 @@ { - "originHash" : "dc5ab367dcce25a198b7a95115b35fa5f0c8e9dab4ee45a8fb4fce1f000db38e", + "originHash" : "3e318606e30fd7507b3905998ae154d62963a3667c482b7e92aac0fbf624db2c", "pins" : [ { "identity" : "combine-schedulers", "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/combine-schedulers", "state" : { - "revision" : "dcccb979a2183b8df3334237e3dc1ae2b4116a86", - "version" : "1.2.0" + "revision" : "c96fdbf1223e22fa4566ac5f40513506dcf5dd41", + "version" : "1.2.1" } }, { @@ -19,13 +19,22 @@ "version" : "7.11.1" } }, + { + "identity" : "swift-case-paths", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-case-paths", + "state" : { + "revision" : "cb4cba6a8a46a86db0d37dbe09935ead6b24c91a", + "version" : "1.10.0" + } + }, { "identity" : "swift-clocks", "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-clocks", "state" : { - "revision" : "72d749bf341b78851203066ab421869b783ec42a", - "version" : "1.1.0" + "revision" : "82440fa0a8b1c381a6d1e8e0fc7bbba53ec63204", + "version" : "1.1.1" } }, { @@ -51,8 +60,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-custom-dump", "state" : { - "revision" : "b46dda2b036983389d451d8b877c19378d4610df", - "version" : "1.7.1" + "revision" : "4fb9e7cbb4b8f5a7354005db39447f570d6b3e19", + "version" : "1.7.3" } }, { @@ -60,8 +69,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-dependencies", "state" : { - "revision" : "5e0815746534ccaa9e20588ea35d3bb2cb27bab8", - "version" : "1.17.0" + "revision" : "b476cc5761057d8d857de063fe7ac8daecf5c06b", + "version" : "1.17.1" } }, { @@ -100,13 +109,22 @@ "version" : "2.1.0" } }, + { + "identity" : "swift-navigation", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-navigation", + "state" : { + "revision" : "7e95e5e9ff0a64f6ad8bca59083f8d6ba381b6ff", + "version" : "2.11.2" + } + }, { "identity" : "swift-perception", "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-perception", "state" : { - "revision" : "de219a1cf34e958134e75a9ebb134cf09bf52fc6", - "version" : "2.0.11" + "revision" : "597afd46249f4a71885b95d26b26cc5b893daf32", + "version" : "2.0.12" } }, { @@ -114,14 +132,14 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-sharing", "state" : { - "revision" : "8244fe63bf43e58188ab13851ad693eecf6a9e90", - "version" : "2.9.1" + "revision" : "3552faf8a6c18ce896ec5a72bd8c34755f0f03e0", + "version" : "2.10.1" } }, { "identity" : "swift-snapshot-testing", "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-snapshot-testing", + "location" : "https://github.com/pointfreeco/swift-snapshot-testing.git", "state" : { "revision" : "59a99c458de4d2dee580529b61b4f78dca7b7fa6", "version" : "1.19.4" @@ -132,8 +150,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-structured-queries", "state" : { - "revision" : "d5931a31ae462427b3ef0067b1652f2426ffcc9e", - "version" : "0.38.1" + "revision" : "8df0088a09acd1e889ef303e34b3625978cd8dd0", + "version" : "0.39.1" } }, { @@ -144,15 +162,6 @@ "revision" : "79e4b74a295b6eb74a8b585e3a39d29e70c1dbd1", "version" : "603.0.2" } - }, - { - "identity" : "xctest-dynamic-overlay", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", - "state" : { - "revision" : "7bbd97af585672a3dcdf6575ad37b339ad41b579", - "version" : "1.13.0" - } } ], "version" : 3 diff --git a/Package.swift b/Package.swift index e58ba155..bcd84fb3 100644 --- a/Package.swift +++ b/Package.swift @@ -70,7 +70,7 @@ 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.38.1", + from: "0.39.1", traits: [ .trait(name: "CasePaths", condition: .when(traits: ["CasePaths"])), .trait(name: "ColumnCoding", condition: .when(traits: ["ColumnCoding"])), diff --git a/Package@swift-6.0.swift b/Package@swift-6.0.swift index b7246de3..9fa29c55 100644 --- a/Package@swift-6.0.swift +++ b/Package@swift-6.0.swift @@ -28,7 +28,7 @@ let package = Package( .package(url: "https://github.com/pointfreeco/swift-dependencies", from: "1.9.0"), .package(url: "https://github.com/pointfreeco/swift-sharing", from: "2.3.0"), .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"), + .package(url: "https://github.com/pointfreeco/swift-structured-queries", from: "0.39.1"), .package(url: "https://github.com/pointfreeco/xctest-dynamic-overlay", from: "1.13.0"), ], targets: [ diff --git a/Package@swift-6.1.swift b/Package@swift-6.1.swift index 744badc0..c0f8345f 100644 --- a/Package@swift-6.1.swift +++ b/Package@swift-6.1.swift @@ -26,6 +26,28 @@ let package = Package( name: "CasePaths", description: "Introduce support for enum tables." ), + .trait( + name: "ColumnCoding", + description: "Align the Codable coding of tables and selections with their column names." + ), + .trait( + name: "LazyInitializableByDefault", + description: "Optionalize draft properties that have no default." + ), + .trait( + name: "SuppressPlatformSQLiteAvailability", + description: """ + Suppress '@available' checks on APIs that depend on a newer version of SQLite than the one \ + 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." @@ -47,7 +69,7 @@ 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", + from: "0.39.1", traits: [ .trait(name: "CasePaths", condition: .when(traits: ["CasePaths"])), .trait(name: "Tagged", condition: .when(traits: ["Tagged"])), diff --git a/Sources/SQLiteData/Documentation.docc/Extensions/Database.md b/Sources/SQLiteData/Documentation.docc/Extensions/Database.md index 8c8f2316..7563b65d 100644 --- a/Sources/SQLiteData/Documentation.docc/Extensions/Database.md +++ b/Sources/SQLiteData/Documentation.docc/Extensions/Database.md @@ -11,6 +11,13 @@ - ``add(function:)`` - ``remove(function:)`` +### User-defined collations + +- ``add(collation:)`` +- ``remove(collation:)`` +- ``StructuredQueriesCore/Collation/canonical`` +- ``CanonicalCollation`` + ### Querying CloudKit metadata - ``attachMetadatabase(containerIdentifier:)`` diff --git a/Sources/SQLiteData/StructuredQueries+GRDB/CustomCollations.swift b/Sources/SQLiteData/StructuredQueries+GRDB/CustomCollations.swift new file mode 100644 index 00000000..6ca475c8 --- /dev/null +++ b/Sources/SQLiteData/StructuredQueries+GRDB/CustomCollations.swift @@ -0,0 +1,106 @@ +import GRDBSQLite +public import StructuredQueriesSQLiteCore + +#if EXCLUDE_EXPORTS + // NB: This 'public import' breaks the '@_exported import'. + public import class GRDB.Database +#endif + +extension Database { + /// Adds a user-defined `@DatabaseCollation` to a connection. + /// + /// - Parameter collation: A database collation to add. + public func add(collation: some DatabaseCollation) { + sqlite3_create_collation_v2( + sqliteConnection, + collation.name, + SQLITE_UTF8, + Unmanaged.passRetained(DatabaseCollationDefinition(collation)).toOpaque(), + { context, lhsCount, lhs, rhsCount, rhs in + switch Unmanaged + .fromOpaque(context!) + .takeUnretainedValue() + .collation + .compare( + UnsafeRawBufferPointer(start: lhs, count: Int(lhsCount)), + UnsafeRawBufferPointer(start: rhs, count: Int(rhsCount)) + ) + { + case .ascending: return -1 + case .same: return 0 + case .descending: return 1 + } + }, + { context in + guard let context else { return } + Unmanaged.fromOpaque(context).release() + } + ) + } + + /// Deletes a user-defined `@DatabaseCollation` from a connection. + /// + /// - Parameter collation: A database collation to delete. + public func remove(collation: some DatabaseCollation) { + sqlite3_create_collation_v2( + sqliteConnection, + collation.name, + SQLITE_UTF8, + nil, + nil, + nil + ) + } +} + +extension Collation where Self == CanonicalCollation { + /// Orders text by Unicode Canonical Equivalence. + /// + /// This collating sequence orders text the same way as Swift's String type. + /// + /// > Tip: This collating sequence is automatically installed by + /// > ``defaultDatabase(path:configuration:)``. To manually install it, use + /// > ``GRDB/Database/add(collation:)``: + /// > + /// > ```swift + /// > configuration.prepareDatabase { db in + /// > db.add(collation: .canonical) + /// > } + /// > ``` + public static var canonical: Self { Self() } +} + +/// A collating sequence that orders text by Unicode Canonical Equivalence. +public nonisolated struct CanonicalCollation: DatabaseCollation, Sendable { + public var name: String { "canonical" } + public init() {} + public func compare( + _ lhs: UnsafeRawBufferPointer, _ rhs: UnsafeRawBufferPointer + ) -> CollationOrder { + #if compiler(>=6.2) + if #available(iOS 26, macOS 26, tvOS 26, watchOS 26, *) { + do { + let lhsSpan = try UTF8Span(validating: lhs.assumingMemoryBound(to: UInt8.self).span) + let rhsSpan = try UTF8Span(validating: rhs.assumingMemoryBound(to: UInt8.self).span) + if lhsSpan.isCanonicallyLessThan(rhsSpan) { return .ascending } + if rhsSpan.isCanonicallyLessThan(lhsSpan) { return .descending } + return .same + } catch { + return lhs.elementsEqual(rhs) + ? .same + : lhs.lexicographicallyPrecedes(rhs) ? .ascending : .descending + } + } + #endif + return CollationOrder( + String(decoding: lhs, as: UTF8.self), String(decoding: rhs, as: UTF8.self) + ) + } +} + +private final class DatabaseCollationDefinition { + let collation: any DatabaseCollation + init(_ collation: some DatabaseCollation) { + self.collation = collation + } +} diff --git a/Sources/SQLiteData/StructuredQueries+GRDB/DefaultDatabase.swift b/Sources/SQLiteData/StructuredQueries+GRDB/DefaultDatabase.swift index 1a6ee3ca..59283de5 100644 --- a/Sources/SQLiteData/StructuredQueries+GRDB/DefaultDatabase.swift +++ b/Sources/SQLiteData/StructuredQueries+GRDB/DefaultDatabase.swift @@ -19,6 +19,10 @@ public func defaultDatabase( path: String? = nil, configuration: Configuration = Configuration() ) throws -> any DatabaseWriter { + var configuration = configuration + configuration.prepareDatabase { db in + db.add(collation: .canonical) + } let database: any DatabaseWriter @Dependency(\.context) var context switch context { diff --git a/Tests/SQLiteDataTests/CustomCollationTests.swift b/Tests/SQLiteDataTests/CustomCollationTests.swift new file mode 100644 index 00000000..d1e3bc75 --- /dev/null +++ b/Tests/SQLiteDataTests/CustomCollationTests.swift @@ -0,0 +1,92 @@ +import Foundation +import SQLiteData +import Testing + +@Suite struct CustomCollationsTests { + @Table struct Item { + var title: String + } + + @DatabaseCollation func reversed(_ lhs: String, _ rhs: String) -> CollationOrder { + CollationOrder(rhs, lhs) + } + + @Test func basics() throws { + var configuration = Configuration() + configuration.prepareDatabase { db in + db.add(collation: $reversed) + } + let database = try DatabaseQueue(configuration: configuration) + let titles = try database.write { db in + try db.execute(sql: "CREATE TABLE items (title TEXT NOT NULL)") + try db.execute(sql: "INSERT INTO items VALUES ('a'), ('c'), ('b')") + return try Item.order { $0.title.collate($reversed) }.fetchAll(db).map(\.title) + } + #expect(titles == ["c", "b", "a"]) + + try database.write { db in + db.remove(collation: $reversed) + } + #expect(throws: (any Error).self) { + try database.read { db in + _ = try Item.order { $0.title.collate($reversed) }.fetchAll(db) + } + } + } + + @Suite(.dependency(\.defaultDatabase, try .database())) + struct CanonicalCollationTests { + @Dependency(\.defaultDatabase) var database + + @Table struct Item { + var title: String + } + + @Test func ordering() throws { + let titles = ["cafe\u{0301}z", "CAFE", "caf\u{00E9}", "caff", "cafe"] + try database.write { db in + try Item.insert { + for title in titles { + Item(title: title) + } + } + .execute(db) + } + let ordered = try database.read { db in + try Item.order { $0.title.collate(.canonical) }.fetchAll(db).map(\.title) + } + #expect(ordered == titles.sorted()) + } + + @Test func equality() throws { + try database.write { db in + try Item.insert { + for title in ["caf\u{00E9}", "cafe\u{0301}", "cafe"] { + Item(title: title) + } + } + .execute(db) + } + let matches = try database.read { db in + try Item.where { $0.title.collate(.canonical).eq("caf\u{00E9}") }.fetchAll(db) + } + #expect(matches.count == 2) + } + + } +} + +extension DatabaseWriter where Self == DatabaseQueue { + fileprivate static func database() throws -> any DatabaseWriter { + let database = try SQLiteData.defaultDatabase() + try database.write { db in + try #sql( + """ + CREATE TABLE "items" ("title" TEXT NOT NULL) + """ + ) + .execute(db) + } + return database + } +}