diff --git a/Package.resolved b/Package.resolved index c161271d..70ec96cd 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "c23a65a671a050ad4a9a14e2506d4c5d127c928aa245e735949f4e4891331401", + "originHash" : "df330bfa630916a4bace49aef1473ae4f7471ce413a497590e789c4c8e96937a", "pins" : [ { "identity" : "combine-schedulers", @@ -123,8 +123,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-structured-queries", "state" : { - "revision" : "8a733f414adc1224c5185c3a35c30ee1ef217171", - "version" : "0.36.0" + "branch" : "custom-collations", + "revision" : "45b057400ecae8599e6ceb4a9f1b113a7170a20e" } }, { diff --git a/Package.swift b/Package.swift index 50115d26..1593dcbc 100644 --- a/Package.swift +++ b/Package.swift @@ -69,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.36.0", + branch: "custom-collations", traits: [ .trait(name: "CasePaths", condition: .when(traits: ["CasePaths"])), .trait(name: "ColumnCoding", condition: .when(traits: ["ColumnCoding"])), 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 2f70021d..63681c69 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 + } +}