Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 3 additions & 3 deletions Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"])),
Expand Down
106 changes: 106 additions & 0 deletions Sources/SQLiteData/StructuredQueries+GRDB/CustomCollations.swift
Original file line number Diff line number Diff line change
@@ -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<DatabaseCollationDefinition>
.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<DatabaseCollationDefinition>.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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
92 changes: 92 additions & 0 deletions Tests/SQLiteDataTests/CustomCollationTests.swift
Original file line number Diff line number Diff line change
@@ -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
}
}