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/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/README.md b/README.md index ff9bcc9654..ab9e29495c 100644 --- a/README.md +++ b/README.md @@ -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/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], + ]) + } + } }