Skip to content

Commit

Permalink
Merge pull request #1645 from philmitchell/coalesce
Browse files Browse the repository at this point in the history
Add coalesce free function and Row method
  • Loading branch information
groue authored Oct 6, 2024
2 parents fd04831 + a17d72e commit 97c0c72
Show file tree
Hide file tree
Showing 9 changed files with 193 additions and 2 deletions.
49 changes: 49 additions & 0 deletions GRDB/Core/Row.swift
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ import Foundation
/// - ``subscript(_:)-3tp8o``
/// - ``subscript(_:)-4k8od``
/// - ``subscript(_:)-9rbo7``
/// - ``coalesce(_:)-359k7``
/// - ``coalesce(_:)-6nbah``
/// - ``withUnsafeData(named:_:)``
/// - ``dataNoCopy(named:)``
///
Expand Down Expand Up @@ -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<T: DatabaseValueConvertible>(
_ columns: some Collection<String>
) -> 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<T: DatabaseValueConvertible>(
_ columns: some Collection<any ColumnExpression>
) -> T? {
return coalesce(columns.lazy.map { $0.name })
}
}

extension Row {
Expand Down
1 change: 1 addition & 0 deletions GRDB/QueryInterface/SQL/SQLExpression.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2168,6 +2168,7 @@ extension SQLExpressible where Self == Column {
/// - ``average(_:filter:)``
/// - ``capitalized``
/// - ``cast(_:as:)-1dmu3``
/// - ``coalesce(_:)``
/// - ``count(_:)``
/// - ``count(distinct:)``
/// - ``dateTime(_:_:)``
Expand Down
26 changes: 26 additions & 0 deletions GRDB/QueryInterface/SQL/SQLFunctions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<any SQLSpecificExpressible>) -> 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:
Expand Down
11 changes: 9 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:**
Expand Down Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions Tests/GRDBTests/QueryInterfaceExpressionsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
25 changes: 25 additions & 0 deletions Tests/GRDBTests/RowCopiedFromStatementTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>()) 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],
])
}
}
}
21 changes: 21 additions & 0 deletions Tests/GRDBTests/RowFromDictionaryLiteralTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>()) 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],
])
}
}
21 changes: 21 additions & 0 deletions Tests/GRDBTests/RowFromDictionaryTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -224,4 +224,25 @@ class RowFromDictionaryTests : RowTestCase {
let debugVariants: Set<String> = ["[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<String>()) 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],
])
}
}
25 changes: 25 additions & 0 deletions Tests/GRDBTests/RowFromStatementTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>()) 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],
])
}
}
}

0 comments on commit 97c0c72

Please sign in to comment.