Skip to content
Merged
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
8 changes: 4 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@ on: push

jobs:
test:
runs-on: macos-13
runs-on: macos-15

steps:
- uses: actions/checkout@v3
- name: Select Xcode 15
run: sudo xcode-select -s /Applications/Xcode_15.0.app
- uses: actions/checkout@v4
- name: Select Xcode 16
run: sudo xcode-select -s /Applications/Xcode_16.3.app
- name: Test
run: swift test

4 changes: 2 additions & 2 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 @@ -20,7 +20,7 @@ let package = Package(
),
.package(
url: "https://github.com/groue/GRDB.swift.git",
from: "6.29.3"
from: "7.4.1"
),
.package(
url: "https://github.com/shareup/precise-iso-8601-date-formatter.git",
Expand Down
33 changes: 27 additions & 6 deletions Sources/SQLite/SQLiteDatabase.swift
Original file line number Diff line number Diff line change
Expand Up @@ -587,6 +587,33 @@ public extension SQLiteDatabase {
}
}

// MARK: - Collating sequences

public extension SQLiteDatabase {
func addCollation(
named name: String,
comparator: @escaping @Sendable (String, String) -> ComparisonResult
) throws {
let collation = DatabaseCollation(
name,
function: comparator
)
try database
.writer
.barrierWriteWithoutTransaction { $0.add(collation: collation) }
}

func removeCollation(named name: String) throws {
let collation = DatabaseCollation(
name,
function: { _, _ in .orderedSame }
)
try database
.writer
.barrierWriteWithoutTransaction { $0.remove(collation: collation) }
}
}

// MARK: - Pragmas

public extension SQLiteDatabase {
Expand Down Expand Up @@ -730,12 +757,6 @@ private extension SQLiteDatabase {

var config = Configuration()
config.journalMode = isInMemory ? .default : .wal
// NOTE: GRDB recommends `defaultTransactionKind` be set
// to `.immediate` in order to prevent `SQLITE_BUSY`
// errors.
//
// https://swiftpackageindex.com/groue/grdb.swift/v6.24.2/documentation/grdb/databasesharing#How-to-limit-the-SQLITEBUSY-error
config.defaultTransactionKind = .immediate
config.busyMode = .timeout(busyTimeout)
config.observesSuspensionNotifications = true
config.maximumReaderCount = max(
Expand Down
166 changes: 166 additions & 0 deletions Tests/SQLiteTests/SQLiteDatabaseTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,172 @@ final class SQLiteDatabaseTests: XCTestCase {
}
}

func testAddAndRemoveCollation() throws {
struct Entity: Hashable, SQLiteTransformable {
let id: String
let string: String?

init(_ id: Int, _ string: String? = nil) {
self.id = String(id)
self.string = string
}

init(row: SQLiteRow) throws {
id = try row.value(for: "id")
string = row.optionalValue(for: "string")
}

var asArguments: SQLiteArguments {
[
"id": .text(id),
"string": string.map { .text($0) } ?? .null,
]
}
}

let apple = Entity(1, "Apple")
let banana = Entity(2, "banana")
let zebra = Entity(3, "Zebra")
let null1 = Entity(4)
let null2 = Entity(5)

try database.inTransaction { db in
try db.write(_createTableWithIDAsStringAndNullableString)
try [apple, banana, zebra, null1, null2]
.forEach { entity in
try db.write(
_insertIDAndString,
arguments: entity.asArguments
)
}
}

let selectDefaultSorted: SQL = """
SELECT * FROM test ORDER BY string;
"""

let selectCustomCaseSensitiveSorted: SQL = """
SELECT * FROM test ORDER BY string COLLATE CUSTOM;
"""

let selectCustomCaseInsensitiveSorted: SQL = """
SELECT * FROM test ORDER BY string COLLATE CUSTOM_NOCASE;
"""

let defaultSorted: [Entity] = try database.read(selectDefaultSorted)
XCTAssertEqual(
defaultSorted,
[null1, null2, apple, zebra, banana]
)

XCTAssertThrowsError(
try database.read(selectCustomCaseSensitiveSorted)
) { error in
guard case SQLiteError.SQLITE_ERROR_MISSING_COLLSEQ = error else {
XCTFail("Should have thrown SQLITE_ERROR")
return
}
}

try database.addCollation(named: "CUSTOM") { $0.compare($1) }
let customSorted: [Entity] = try database.read(selectCustomCaseSensitiveSorted)
XCTAssertEqual(
customSorted,
[null1, null2, apple, zebra, banana]
)

try database.addCollation(
named: "CUSTOM_NOCASE"
) { $0.caseInsensitiveCompare($1) }

let customNoCaseSorted: [Entity] = try database
.read(selectCustomCaseInsensitiveSorted)
XCTAssertEqual(
customNoCaseSorted,
[null1, null2, apple, banana, zebra]
)

try database.removeCollation(named: "CUSTOM_NOCASE")
XCTAssertThrowsError(
try database.read(selectCustomCaseInsensitiveSorted)
) { error in
guard case SQLiteError.SQLITE_ERROR_MISSING_COLLSEQ = error else {
XCTFail("Should have thrown SQLITE_ERROR")
return
}
}
let customSortedAfterRemovingNoCase: [Entity] = try database
.read(selectCustomCaseSensitiveSorted)
XCTAssertEqual(
customSortedAfterRemovingNoCase,
[null1, null2, apple, zebra, banana]
)
}

func testCustomLocalizedCollation() throws {
try database.addCollation(named: "LOCALIZED") { lhs, rhs in
lhs.localizedStandardCompare(rhs)
}

// NOTE: ([toInsert], [binary sort], [localized sort])
let cases: [([String], [String], [String])] = [
// Basic Latin
(
["a", "A", "b", "B"],
["A", "B", "a", "b"],
["a", "A", "b", "B"]
),

// Accented Latin
(
["cafe", "café", "caffe", "caffè"],
["cafe", "caffe", "caffè", "café"],
["cafe", "café", "caffe", "caffè"]
),

// Chinese
(
["长城", "长江", "上海", "北京"],
["上海", "北京", "长城", "长江"],
["上海", "北京", "长城", "长江"]
),

// Mixed
(
["z", "中", "9", "ñ", "a"],
["9", "a", "z", "ñ", "中"],
["9", "a", "ñ", "z", "中"]
),
]

for (toInsert, binarySort, localizedSort) in cases {
try database.inTransaction { db in
try db.execute(raw: _createTableWithIDAsStringAndNullableString)
try toInsert.enumerated().forEach { id, string in
try db.write(
_insertIDAndString,
arguments: [
"id": .text(String(id)),
"string": .text(string),
]
)
}
}

let binarySorted: [String] = try database
.read("SELECT * FROM test ORDER BY string;")
.compactMap { $0["string"]?.stringValue }
XCTAssertEqual(binarySorted, binarySort)

let localizedSorted: [String] = try database
.read("SELECT * FROM test ORDER BY string COLLATE LOCALIZED;")
.compactMap { $0["string"]?.stringValue }
XCTAssertEqual(localizedSorted, localizedSort)

try database.write("DROP TABLE test;")
}
}

func testUserVersion() throws {
XCTAssertEqual(0, database.userVersion)

Expand Down