From 27c83e097213afb011fda35ea8ed070f2a5adedb Mon Sep 17 00:00:00 2001 From: Anthony Drendel Date: Mon, 14 Apr 2025 18:06:27 +0200 Subject: [PATCH 1/3] Add SQLiteDatabase.addCollation() and removeCollation() --- Sources/SQLite/SQLiteDatabase.swift | 27 ++++ Tests/SQLiteTests/SQLiteDatabaseTests.swift | 166 ++++++++++++++++++++ 2 files changed, 193 insertions(+) diff --git a/Sources/SQLite/SQLiteDatabase.swift b/Sources/SQLite/SQLiteDatabase.swift index 21a77e9..2d982e1 100644 --- a/Sources/SQLite/SQLiteDatabase.swift +++ b/Sources/SQLite/SQLiteDatabase.swift @@ -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 { diff --git a/Tests/SQLiteTests/SQLiteDatabaseTests.swift b/Tests/SQLiteTests/SQLiteDatabaseTests.swift index 67b3651..1137442 100644 --- a/Tests/SQLiteTests/SQLiteDatabaseTests.swift +++ b/Tests/SQLiteTests/SQLiteDatabaseTests.swift @@ -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) From c6b944e7f192d99fbde2fbc018f970b8c3595747 Mon Sep 17 00:00:00 2001 From: Anthony Drendel Date: Mon, 14 Apr 2025 18:09:06 +0200 Subject: [PATCH 2/3] Update CI --- .github/workflows/ci.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7129d66..f0777c6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 From 9c2333998c47488412f7862fec07623b3d00421d Mon Sep 17 00:00:00 2001 From: Anthony Drendel Date: Mon, 14 Apr 2025 18:12:49 +0200 Subject: [PATCH 3/3] Update GRDB to version 7 --- Package.resolved | 4 ++-- Package.swift | 2 +- Sources/SQLite/SQLiteDatabase.swift | 6 ------ 3 files changed, 3 insertions(+), 9 deletions(-) diff --git a/Package.resolved b/Package.resolved index 0552d30..3cbe36f 100644 --- a/Package.resolved +++ b/Package.resolved @@ -14,8 +14,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/groue/GRDB.swift.git", "state" : { - "revision" : "2cf6c756e1e5ef6901ebae16576a7e4e4b834622", - "version" : "6.29.3" + "revision" : "04e73c26c4ce8218ab85aaf791942bb0b204f330", + "version" : "7.4.1" } }, { diff --git a/Package.swift b/Package.swift index 9ade0c8..aca5826 100644 --- a/Package.swift +++ b/Package.swift @@ -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", diff --git a/Sources/SQLite/SQLiteDatabase.swift b/Sources/SQLite/SQLiteDatabase.swift index 2d982e1..69eb535 100644 --- a/Sources/SQLite/SQLiteDatabase.swift +++ b/Sources/SQLite/SQLiteDatabase.swift @@ -757,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(