diff --git a/CHANGELOG.md b/CHANGELOG.md index 6dd540058e..878a36eef4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ GRDB adheres to [Semantic Versioning](https://semver.org/), with one exception: #### 7.x Releases -- `7.0.0` Betas - [7.0.0-beta](#700-beta) - [7.0.0-beta.2](#700-beta2) +- `7.0.0` Betas - [7.0.0-beta](#700-beta) - [7.0.0-beta.2](#700-beta2) - [7.0.0-beta.3](#700-beta3) #### 6.x Releases @@ -131,11 +131,19 @@ GRDB adheres to [Semantic Versioning](https://semver.org/), with one exception: --- +## 7.0.0-beta.3 + +Released October 6, 2024 + +- **Fix**: use #if directives to conditionally @preconcurrency import the Dispatch module to enable building the package on linux by [@tayloraswift](https://github.com/tayloraswift) in [#1644](https://github.com/groue/GRDB.swift/pull/1644) +- **New**: Add coalesce free function and Row method by [@philmitchell](https://github.com/philmitchell) in [#1645](https://github.com/groue/GRDB.swift/pull/1645) +- **Documentation Update**: Add `DatabaseValueConvertible` tip for JSON columns by [@bok-](https://github.com/bok-) in [#1649](https://github.com/groue/GRDB.swift/pull/1649) + ## 7.0.0-beta.2 Released September 29, 2024 -- **Fix** Update .spi.yml by [@finestructure](https://github.com/finestructure) in [#1643](https://github.com/groue/GRDB.swift/pull/1643) +- **Fix**: Update .spi.yml by [@finestructure](https://github.com/finestructure) in [#1643](https://github.com/groue/GRDB.swift/pull/1643) ## 7.0.0-beta @@ -145,7 +153,7 @@ Released September 29, 2024 [Migrating From GRDB 6 to GRDB 7](Documentation/GRDB7MigrationGuide.md) describes in detail how to bump the GRDB version in your application. -The new [Swift Concurrency and GRDB](https://swiftpackageindex.com/groue/grdb.swift/v7.0.0-beta.2/documentation/grdb/swiftconcurrency) guide explains how to best integrate GRDB and Swift Concurrency. +The new [Swift Concurrency and GRDB](https://swiftpackageindex.com/groue/grdb.swift/v7.0.0-beta.3/documentation/grdb/swiftconcurrency) guide explains how to best integrate GRDB and Swift Concurrency. The [demo app](Documentation/DemoApps/) was rewritten from scratch in a brand new Xcode 16 project. diff --git a/Documentation/GRDB7MigrationGuide.md b/Documentation/GRDB7MigrationGuide.md index 81eb576f70..a13d442f01 100644 --- a/Documentation/GRDB7MigrationGuide.md +++ b/Documentation/GRDB7MigrationGuide.md @@ -228,7 +228,7 @@ Do not miss [Swift Concurrency and GRDB], for more recommendations regarding non - The async sequence returned by [`ValueObservation.values`](https://swiftpackageindex.com/groue/grdb.swiftdocumentation/grdb/valueobservation/values(in:scheduling:bufferingpolicy:)) now iterates on the cooperative thread pool by default. Use .mainActor as the scheduler if you need the previous behavior. [Migrating to Swift 6]: https://www.swift.org/migration/documentation/migrationguide -[Sharing a Database]: https://swiftpackageindex.com/groue/grdb.swift/v7.0.0-beta.2/documentation/grdb/databasesharing -[Transaction Kinds]: https://swiftpackageindex.com/groue/grdb.swift/v7.0.0-beta.2/documentation/grdb/transactions#Transaction-Kinds -[Swift Concurrency and GRDB]: https://swiftpackageindex.com/groue/grdb.swift/v7.0.0-beta.2/documentation/grdb/swiftconcurrency -[Record]: https://swiftpackageindex.com/groue/grdb.swift/v7.0.0-beta.2/documentation/grdb/record +[Sharing a Database]: https://swiftpackageindex.com/groue/grdb.swift/v7.0.0-beta.3/documentation/grdb/databasesharing +[Transaction Kinds]: https://swiftpackageindex.com/groue/grdb.swift/v7.0.0-beta.3/documentation/grdb/transactions#Transaction-Kinds +[Swift Concurrency and GRDB]: https://swiftpackageindex.com/groue/grdb.swift/v7.0.0-beta.3/documentation/grdb/swiftconcurrency +[Record]: https://swiftpackageindex.com/groue/grdb.swift/v7.0.0-beta.3/documentation/grdb/record diff --git a/GRDB.swift.podspec b/GRDB.swift.podspec index e5fb0391e6..a56a6a97a0 100644 --- a/GRDB.swift.podspec +++ b/GRDB.swift.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'GRDB.swift' - s.version = '7.0.0-beta.2' + s.version = '7.0.0-beta.3' s.license = { :type => 'MIT', :file => 'LICENSE' } s.summary = 'A toolkit for SQLite databases, with a focus on application development.' diff --git a/GRDB/Core/Configuration.swift b/GRDB/Core/Configuration.swift index 059bbb86f9..9e4e165010 100644 --- a/GRDB/Core/Configuration.swift +++ b/GRDB/Core/Configuration.swift @@ -7,6 +7,9 @@ import SQLCipher import SQLite3 #endif +#if !canImport(Darwin) +@preconcurrency +#endif import Dispatch import Foundation diff --git a/GRDB/Core/Row.swift b/GRDB/Core/Row.swift index f1e49e5f67..8d39236bdc 100644 --- a/GRDB/Core/Row.swift +++ b/GRDB/Core/Row.swift @@ -60,6 +60,8 @@ import Foundation /// - ``subscript(_:)-3tp8o`` /// - ``subscript(_:)-4k8od`` /// - ``subscript(_:)-9rbo7`` +/// - ``coalesce(_:)-359k7`` +/// - ``coalesce(_:)-6nbah`` /// - ``withUnsafeData(named:_:)`` /// - ``dataNoCopy(named:)`` /// @@ -671,6 +673,53 @@ extension Row { public func dataNoCopy(_ column: some ColumnExpression) -> Data? { dataNoCopy(named: column.name) } + + /// Returns the first non-null value, if any. Identical to SQL `COALESCE` function. + /// + /// For example: + /// + /// ```swift + /// let name: String? = row.coalesce(["nickname", "name"]) + /// ``` + /// + /// Prefer `coalesce` to nil-coalescing row values, which does not + /// return the expected value: + /// + /// ```swift + /// // INCORRECT + /// let name: String? = row["nickname"] ?? row["name"] + /// ``` + public func coalesce( + _ columns: some Collection + ) -> T? { + for column in columns { + if let value = self[column] as T? { + return value + } + } + return nil + } + + /// Returns the first non-null value, if any. Identical to SQL `COALESCE` function. + /// + /// For example: + /// + /// ```swift + /// let name: String? = row.coalesce([Column("nickname"), Column("name")]) + /// ``` + /// + /// Prefer `coalesce` to nil-coalescing row values, which does not + /// return the expected value: + /// + /// ```swift + /// // INCORRECT + /// let name: String? = row[Column("nickname")] ?? row[Column("name")] + /// ``` + public func coalesce( + _ columns: some Collection + ) -> T? { + return coalesce(columns.lazy.map { $0.name }) + } } extension Row { diff --git a/GRDB/Core/SchedulingWatchdog.swift b/GRDB/Core/SchedulingWatchdog.swift index 3c2ba702b0..dfbdfdace0 100644 --- a/GRDB/Core/SchedulingWatchdog.swift +++ b/GRDB/Core/SchedulingWatchdog.swift @@ -1,3 +1,6 @@ +#if !canImport(Darwin) +@preconcurrency +#endif import Dispatch /// SchedulingWatchdog makes sure that databases connections are used on correct diff --git a/GRDB/Documentation.docc/JSON.md b/GRDB/Documentation.docc/JSON.md index ad4acad302..5e3dcd6b89 100644 --- a/GRDB/Documentation.docc/JSON.md +++ b/GRDB/Documentation.docc/JSON.md @@ -98,6 +98,21 @@ extension Team: FetchableRecord, PersistableRecord { } ``` +> Tip: Conform your `Codable` property to `DatabaseValueConvertible` if you want to be able to filter on specific values of it: +> +> ```swift +> struct Address: Codable { ... } +> extension Address: DatabaseValueConvertible {} +> +> // SELECT * FROM player +> // WHERE address = '{"street": "...", "city": "...", "country": "..."}' +> let players = try Player +> .filter(JSONColumn("address") == Address(...)) +> .fetchAll(db) +> ``` +> +> Take care that SQLite will compare strings, not JSON objects: white-space and key ordering matter. For this comparison to succeed, make sure that the database contains values that are formatted exactly like a serialized `Address`. + ## Manipulate JSON values at the database level [SQLite JSON functions and operators](https://www.sqlite.org/json1.html) are available starting iOS 16+, macOS 10.15+, tvOS 17+, and watchOS 9+. diff --git a/GRDB/Fixits.swift b/GRDB/Fixits.swift index 1092b1164c..174a5ce7e2 100644 --- a/GRDB/Fixits.swift +++ b/GRDB/Fixits.swift @@ -1,4 +1,5 @@ // Fixits for changes introduced by GRDB 7.0.0 +// swiftlint:disable all extension Configuration { @available(*, unavailable, message: "The default transaction kind is now automatically managed.") @@ -15,3 +16,5 @@ extension DatabasePool { @available(*, unavailable, message: "concurrentRead has been removed. Use `asyncConcurrentRead` instead.") public func concurrentRead(_ value: @escaping (Database) throws -> T) -> DatabaseFuture { preconditionFailure() } } + +// swiftlint:enable all diff --git a/GRDB/QueryInterface/SQL/SQLExpression.swift b/GRDB/QueryInterface/SQL/SQLExpression.swift index 58acaf7c41..9f26b9983d 100644 --- a/GRDB/QueryInterface/SQL/SQLExpression.swift +++ b/GRDB/QueryInterface/SQL/SQLExpression.swift @@ -2168,6 +2168,7 @@ extension SQLExpressible where Self == Column { /// - ``average(_:filter:)`` /// - ``capitalized`` /// - ``cast(_:as:)-1dmu3`` +/// - ``coalesce(_:)`` /// - ``count(_:)`` /// - ``count(distinct:)`` /// - ``dateTime(_:_:)`` diff --git a/GRDB/QueryInterface/SQL/SQLFunctions.swift b/GRDB/QueryInterface/SQL/SQLFunctions.swift index fab42b8e42..8f3ccfea48 100644 --- a/GRDB/QueryInterface/SQL/SQLFunctions.swift +++ b/GRDB/QueryInterface/SQL/SQLFunctions.swift @@ -71,6 +71,32 @@ public func cast(_ expression: some SQLSpecificExpressible, as storageClass: Dat .cast(expression.sqlExpression, as: storageClass) } +/// The `COALESCE` SQL function. +/// +/// For example: +/// +/// ```swift +/// // COALESCE(value1, value2, ...) +/// coalesce([Column("value1"), Column("value2"), ...]) +/// ``` +/// +/// Unlike the SQL function, `coalesce` accepts any number of arguments. +/// When `values` is empty, the result is `NULL`. When `values` contains a +/// single value, the result is this value. `COALESCE` is used from +/// two values upwards. +public func coalesce(_ values: some Collection) -> SQLExpression { + // SQLite COALESCE wants at least two arguments. + // There is no reason to apply the same limitation. + guard let value = values.first else { + return .null + } + if values.count > 1 { + return .function("COALESCE", values.map { $0.sqlExpression }) + } else { + return value.sqlExpression + } +} + /// The `COUNT` SQL function. /// /// For example: diff --git a/GRDB/Utils/Utils.swift b/GRDB/Utils/Utils.swift index 561c3b91f1..d690b9eecb 100644 --- a/GRDB/Utils/Utils.swift +++ b/GRDB/Utils/Utils.swift @@ -1,3 +1,6 @@ +#if !canImport(Darwin) +@preconcurrency import Dispatch +#endif import Foundation // MARK: - Public diff --git a/README.md b/README.md index ff9bcc9654..b4cf208814 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ CI Status

-**Latest release**: September 29, 2024 • [version 7.0.0-beta.2](https://github.com/groue/GRDB.swift/tree/v7.0.0-beta.2) • [CHANGELOG](CHANGELOG.md) • [Migrating From GRDB 6 to GRDB 7](Documentation/GRDB7MigrationGuide.md) +**Latest release**: October 6, 2024 • [version 7.0.0-beta.3](https://github.com/groue/GRDB.swift/tree/v7.0.0-beta.3) • [CHANGELOG](CHANGELOG.md) • [Migrating From GRDB 6 to GRDB 7](Documentation/GRDB7MigrationGuide.md) **Requirements**: iOS 13.0+ / macOS 10.15+ / tvOS 13.0+ / watchOS 7.0+ • SQLite 3.20.0+ • Swift 6+ / Xcode 16+ @@ -841,6 +841,13 @@ row[...] as Int? > if let int = row[...] as Int? { ... } // GOOD > ``` +> **Warning**: avoid nil-coalescing row values, and prefer the `coalesce` method instead: +> +> ```swift +> let name: String? = row["nickname"] ?? row["name"] // BAD - doesn't work +> let name: String? = row.coalesce(["nickname", "name"]) // GOOD +> ``` + Generally speaking, you can extract the type you need, provided it can be converted from the underlying SQLite value: - **Successful conversions include:** @@ -3936,9 +3943,9 @@ GRDB comes with a Swift version of many SQLite [built-in operators](https://sqli GRDB comes with a Swift version of many SQLite [built-in functions](https://sqlite.org/lang_corefunc.html), listed below. But not all: see [Embedding SQL in Query Interface Requests] for a way to add support for missing SQL functions. -- `ABS`, `AVG`, `COUNT`, `DATETIME`, `JULIANDAY`, `LENGTH`, `MAX`, `MIN`, `SUM`, `TOTAL`: +- `ABS`, `AVG`, `COALESCE`, `COUNT`, `DATETIME`, `JULIANDAY`, `LENGTH`, `MAX`, `MIN`, `SUM`, `TOTAL`: - Those are based on the `abs`, `average`, `count`, `dateTime`, `julianDay`, `length`, `max`, `min`, `sum` and `total` Swift functions: + Those are based on the `abs`, `average`, `coalesce`, `count`, `dateTime`, `julianDay`, `length`, `max`, `min`, `sum`, and `total` Swift functions: ```swift // SELECT MIN(score), MAX(score) FROM player diff --git a/Support/Info.plist b/Support/Info.plist index e1632ae7f2..0f9a7872c8 100644 --- a/Support/Info.plist +++ b/Support/Info.plist @@ -15,7 +15,7 @@ CFBundlePackageType FMWK CFBundleShortVersionString - 7.0.0-beta.2 + 7.0.0-beta.3 CFBundleSignature ???? CFBundleVersion diff --git a/Tests/GRDBTests/QueryInterfaceExpressionsTests.swift b/Tests/GRDBTests/QueryInterfaceExpressionsTests.swift index a4248160fc..f2a6d4b59f 100644 --- a/Tests/GRDBTests/QueryInterfaceExpressionsTests.swift +++ b/Tests/GRDBTests/QueryInterfaceExpressionsTests.swift @@ -1535,6 +1535,22 @@ class QueryInterfaceExpressionsTests: GRDBTestCase { "SELECT CAST(\"name\" AS BLOB) FROM \"readers\"") } + func testCoalesceExpression() throws { + let dbQueue = try makeDatabaseQueue() + + XCTAssertEqual( + sql(dbQueue, tableRequest.select(coalesce([]))), + "SELECT NULL FROM \"readers\"") + + XCTAssertEqual( + sql(dbQueue, tableRequest.select(coalesce([Col.name]))), + "SELECT \"name\" FROM \"readers\"") + + XCTAssertEqual( + sql(dbQueue, tableRequest.select(coalesce([Col.name, Col.age]))), + "SELECT COALESCE(\"name\", \"age\") FROM \"readers\"") + } + func testLengthExpression() throws { let dbQueue = try makeDatabaseQueue() diff --git a/Tests/GRDBTests/RowCopiedFromStatementTests.swift b/Tests/GRDBTests/RowCopiedFromStatementTests.swift index 8bc077c5b3..e672d67d58 100644 --- a/Tests/GRDBTests/RowCopiedFromStatementTests.swift +++ b/Tests/GRDBTests/RowCopiedFromStatementTests.swift @@ -287,4 +287,29 @@ class RowCopiedFromStatementTests: RowTestCase { XCTAssertEqual(row.debugDescription, "[null:NULL int:1 double:1.1 string:\"foo\" data:Data(6 bytes)]") } } + + func testCoalesce() throws { + let dbQueue = try makeDatabaseQueue() + try dbQueue.inDatabase { db in + let values = try Row + .fetchAll(db, sql: """ + SELECT 'Artie' AS nickname, 'Arthur' AS name + UNION ALL SELECT NULL, 'Jacob' + UNION ALL SELECT NULL, NULL + """) + .map { row in + [ + row.coalesce(Array()) as String?, + row.coalesce(["nickname"]) as String?, + row.coalesce(["nickname", "name"]) as String?, + row.coalesce([Column("nickname"), Column("name")]) as String?, + ] + } + XCTAssertEqual(values, [ + [nil, "Artie", "Artie", "Artie"], + [nil, nil, "Jacob", "Jacob"], + [nil, nil, nil, nil], + ]) + } + } } diff --git a/Tests/GRDBTests/RowFromDictionaryLiteralTests.swift b/Tests/GRDBTests/RowFromDictionaryLiteralTests.swift index f840c437a0..2554e6e204 100644 --- a/Tests/GRDBTests/RowFromDictionaryLiteralTests.swift +++ b/Tests/GRDBTests/RowFromDictionaryLiteralTests.swift @@ -232,4 +232,25 @@ class RowFromDictionaryLiteralTests : RowTestCase { XCTAssertEqual(row.description, "[a:0 b:1 c:2]") XCTAssertEqual(row.debugDescription, "[a:0 b:1 c:2]") } + + func testCoalesce() throws { + let rows: [Row] = [ + ["nickname": "Artie", "name": "Arthur"], + ["nickname": nil, "name": "Jacob"], + ["nickname": nil, "name": nil], + ] + let values = rows.map { row in + [ + row.coalesce(Array()) as String?, + row.coalesce(["nickname"]) as String?, + row.coalesce(["nickname", "name"]) as String?, + row.coalesce([Column("nickname"), Column("name")]) as String?, + ] + } + XCTAssertEqual(values, [ + [nil, "Artie", "Artie", "Artie"], + [nil, nil, "Jacob", "Jacob"], + [nil, nil, nil, nil], + ]) + } } diff --git a/Tests/GRDBTests/RowFromDictionaryTests.swift b/Tests/GRDBTests/RowFromDictionaryTests.swift index 08ddca00fd..5625295e4e 100644 --- a/Tests/GRDBTests/RowFromDictionaryTests.swift +++ b/Tests/GRDBTests/RowFromDictionaryTests.swift @@ -224,4 +224,25 @@ class RowFromDictionaryTests : RowTestCase { let debugVariants: Set = ["[a:0 b:\"foo\"]", "[b:\"foo\" a:0]"] XCTAssert(debugVariants.contains(row.debugDescription)) } + + func testCoalesce() throws { + let rows = [ + Row(["nickname": "Artie", "name": "Arthur"]), + Row(["nickname": nil, "name": "Jacob"]), + Row(["nickname": nil, "name": nil]), + ] + let values = rows.map { row in + [ + row.coalesce(Array()) as String?, + row.coalesce(["nickname"]) as String?, + row.coalesce(["nickname", "name"]) as String?, + row.coalesce([Column("nickname"), Column("name")]) as String?, + ] + } + XCTAssertEqual(values, [ + [nil, "Artie", "Artie", "Artie"], + [nil, nil, "Jacob", "Jacob"], + [nil, nil, nil, nil], + ]) + } } diff --git a/Tests/GRDBTests/RowFromStatementTests.swift b/Tests/GRDBTests/RowFromStatementTests.swift index c42624a2b9..85f17ff24b 100644 --- a/Tests/GRDBTests/RowFromStatementTests.swift +++ b/Tests/GRDBTests/RowFromStatementTests.swift @@ -380,4 +380,29 @@ class RowFromStatementTests : RowTestCase { XCTAssertTrue(rowFetched) } } + + func testCoalesce() throws { + let dbQueue = try makeDatabaseQueue() + try dbQueue.inDatabase { db in + let values = try Array(Row + .fetchCursor(db, sql: """ + SELECT 'Artie' AS nickname, 'Arthur' AS name + UNION ALL SELECT NULL, 'Jacob' + UNION ALL SELECT NULL, NULL + """) + .map { row in + [ + row.coalesce(Array()) as String?, + row.coalesce(["nickname"]) as String?, + row.coalesce(["nickname", "name"]) as String?, + row.coalesce([Column("nickname"), Column("name")]) as String?, + ] + }) + XCTAssertEqual(values, [ + [nil, "Artie", "Artie", "Artie"], + [nil, nil, "Jacob", "Jacob"], + [nil, nil, nil, nil], + ]) + } + } }