Skip to content

Commit 0434567

Browse files
authored
Merge pull request #1515 from groue/dev/cast
Support for the CAST SQLite function
2 parents a49fd85 + 155a16b commit 0434567

File tree

11 files changed

+128
-9
lines changed

11 files changed

+128
-9
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ GRDB adheres to [Semantic Versioning](https://semver.org/), with one exception:
128128
- **New**: [#1503](https://github.com/groue/GRDB.swift/pull/1503) by [@simba909](https://github.com/simba909): Conform Database.ColumnType to Sendable
129129
- **New**: [#1510](https://github.com/groue/GRDB.swift/pull/1510) by [@groue](https://github.com/groue): Add Sendable conformances and unavailabilities
130130
- **New**: [#1511](https://github.com/groue/GRDB.swift/pull/1511) by [@groue](https://github.com/groue): Database schema dump
131+
- **New**: [#1515](https://github.com/groue/GRDB.swift/pull/1515) by [@groue](https://github.com/groue): Support for the CAST SQLite function
131132
- **Fixed**: [#1508](https://github.com/groue/GRDB.swift/pull/1508) by [@groue](https://github.com/groue): Fix ValueObservation mishandling of database schema modification
132133

133134
## 6.25.0

Documentation/AssociationsBasics.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2661,7 +2661,7 @@ Aggregates can be modified and combined with Swift operators:
26612661
let request = Team.annotated(with: Team.players.min(Column("score")) ?? 0)
26622662
```
26632663

2664-
- SQL functions `ABS` and `LENGTH` are available as the `abs` and `length` Swift functions:
2664+
- SQL functions `ABS`, `CAST`, and `LENGTH` are available as the `abs`, `cast`, and `length` Swift functions:
26652665

26662666
<details>
26672667
<summary>SQL</summary>

GRDB/Core/Database.swift

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ let SQLITE_TRANSIENT = unsafeBitCast(OpaquePointer(bitPattern: -1), to: sqlite3_
115115
/// - ``trace(options:_:)``
116116
/// - ``CheckpointMode``
117117
/// - ``DatabaseBackupProgress``
118+
/// - ``StorageClass``
118119
/// - ``TraceEvent``
119120
/// - ``TracingOptions``
120121
public final class Database: CustomStringConvertible, CustomDebugStringConvertible {
@@ -2005,6 +2006,32 @@ extension Database {
20052006
/// An error log function that takes an error code and message.
20062007
public typealias LogErrorFunction = (_ resultCode: ResultCode, _ message: String) -> Void
20072008

2009+
/// An SQLite storage class.
2010+
///
2011+
/// For more information, see
2012+
/// [Datatypes In SQLite](https://www.sqlite.org/datatype3.html).
2013+
public struct StorageClass: RawRepresentable, Hashable, Sendable {
2014+
/// The SQL for the storage class (`"INTEGER"`, `"REAL"`, etc.)
2015+
public let rawValue: String
2016+
2017+
/// Creates an SQL storage class.
2018+
public init(rawValue: String) {
2019+
self.rawValue = rawValue
2020+
}
2021+
2022+
/// The `INTEGER` storage class.
2023+
public static let integer = StorageClass(rawValue: "INTEGER")
2024+
2025+
/// The `REAL` storage class.
2026+
public static let real = StorageClass(rawValue: "REAL")
2027+
2028+
/// The `TEXT` storage class.
2029+
public static let text = StorageClass(rawValue: "TEXT")
2030+
2031+
/// The `BLOB` storage class.
2032+
public static let blob = StorageClass(rawValue: "BLOB")
2033+
}
2034+
20082035
/// An option for the SQLite tracing feature.
20092036
///
20102037
/// You use `TracingOptions` with the `Database`

GRDB/QueryInterface/Request/Association/AssociationAggregate.swift

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -835,7 +835,7 @@ extension AssociationAggregate {
835835
}
836836
}
837837

838-
// MARK: - IFNULL(...)
838+
// MARK: - Functions
839839

840840
extension AssociationAggregate {
841841
/// The `IFNULL` SQL function.
@@ -854,16 +854,25 @@ extension AssociationAggregate {
854854
}
855855
}
856856

857-
// MARK: - ABS(...)
858-
859857
/// The `ABS` SQL function.
860858
public func abs<RowDecoder>(_ aggregate: AssociationAggregate<RowDecoder>)
861859
-> AssociationAggregate<RowDecoder>
862860
{
863861
aggregate.map(abs)
864862
}
865863

866-
// MARK: - LENGTH(...)
864+
/// The `CAST` SQL function.
865+
///
866+
/// Related SQLite documentation: <https://www.sqlite.org/lang_expr.html#castexpr>
867+
public func cast<RowDecoder>(
868+
_ aggregate: AssociationAggregate<RowDecoder>,
869+
as storageClass: Database.StorageClass)
870+
-> AssociationAggregate<RowDecoder>
871+
{
872+
aggregate
873+
.map { cast($0, as: storageClass) }
874+
.with { $0.key = aggregate.key } // Preserve key
875+
}
867876

868877
/// The `LENGTH` SQL function.
869878
public func length<RowDecoder>(_ aggregate: AssociationAggregate<RowDecoder>)

GRDB/QueryInterface/SQL/SQLExpression.swift

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,11 @@ public struct SQLExpression {
9090
/// A literal SQL expression
9191
case literal(SQL)
9292

93+
/// The `CAST(expr AS storage-class)` expression.
94+
///
95+
/// See <https://www.sqlite.org/lang_expr.html#castexpr>.
96+
indirect case cast(SQLExpression, Database.StorageClass)
97+
9398
/// The `BETWEEN` and `NOT BETWEEN` operators.
9499
///
95100
/// <expression> BETWEEN <lowerBound> AND <upperBound>
@@ -224,6 +229,9 @@ public struct SQLExpression {
224229
case let .literal(sqlLiteral):
225230
return .literal(sqlLiteral.qualified(with: alias))
226231

232+
case let .cast(expression, storageClass):
233+
return .cast(expression.qualified(with: alias), storageClass)
234+
227235
case let .between(
228236
expression: expression,
229237
lowerBound: lowerBound,
@@ -1092,6 +1100,13 @@ extension SQLExpression {
10921100
self.init(impl: .isEmpty(expression, isNegated: isNegated))
10931101
}
10941102

1103+
/// The `CAST(expr AS storage-class)` expression.
1104+
///
1105+
/// See <https://www.sqlite.org/lang_expr.html#castexpr>.
1106+
static func cast(_ expression: SQLExpression, as storageClass: Database.StorageClass) -> Self {
1107+
self.init(impl: .cast(expression, storageClass))
1108+
}
1109+
10951110
// MARK: Deferred
10961111

10971112
// TODO: replace with something that can work for WITHOUT ROWID table with a multi-columns primary key.
@@ -1269,6 +1284,9 @@ extension SQLExpression {
12691284
}
12701285
return resultSQL
12711286

1287+
case let .cast(expression, storageClass):
1288+
return try "CAST(\(expression.sql(context, wrappedInParenthesis: false)) AS \(storageClass.rawValue))"
1289+
12721290
case let .between(expression: expression, lowerBound: lowerBound, upperBound: upperBound, isNegated: isNegated):
12731291
var resultSQL = try """
12741292
\(expression.sql(context, wrappedInParenthesis: true)) \
@@ -1822,6 +1840,9 @@ extension SQLExpression {
18221840
let .associativeBinary(_, expressions):
18231841
return expressions.allSatisfy(\.isConstantInRequest)
18241842

1843+
case let .cast(expression, _):
1844+
return expression.isConstantInRequest
1845+
18251846
case let .between(expression: expression, lowerBound: lowerBound, upperBound: upperBound, isNegated: _):
18261847
return expression.isConstantInRequest
18271848
&& lowerBound.isConstantInRequest

GRDB/QueryInterface/SQL/SQLFunctions.swift

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,20 @@ public func average(_ value: some SQLSpecificExpressible) -> SQLExpression {
5757
}
5858
#endif
5959

60+
/// The `CAST` SQL function.
61+
///
62+
/// For example:
63+
///
64+
/// ```swift
65+
/// // CAST(value AS REAL)
66+
/// cast(Column("value"), as: .real)
67+
/// ```
68+
///
69+
/// Related SQLite documentation: <https://www.sqlite.org/lang_expr.html#castexpr>
70+
public func cast(_ expression: some SQLSpecificExpressible, as storageClass: Database.StorageClass) -> SQLExpression {
71+
.cast(expression.sqlExpression, as: storageClass)
72+
}
73+
6074
/// The `COUNT` SQL function.
6175
///
6276
/// For example:

README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4291,6 +4291,17 @@ GRDB comes with a Swift version of many SQLite [built-in functions](https://sqli
42914291

42924292
For more information about the functions `dateTime` and `julianDay`, see [Date And Time Functions](https://www.sqlite.org/lang_datefunc.html).
42934293

4294+
- `CAST`
4295+
4296+
Use the `cast` Swift function:
4297+
4298+
```swift
4299+
// SELECT (CAST(wins AS REAL) / games) AS successRate FROM player
4300+
Player.select((cast(winsColumn, as: .real) / gamesColumn).forKey("successRate"))
4301+
```
4302+
4303+
See [CAST expressions](https://www.sqlite.org/lang_expr.html#castexpr) for more information about SQLite conversions.
4304+
42944305
- `IFNULL`
42954306

42964307
Use the Swift `??` operator:

Tests/GRDBTests/AssociationAggregateTests.swift

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1511,6 +1511,30 @@ class AssociationAggregateTests: GRDBTestCase {
15111511
}
15121512
}
15131513

1514+
func testCast() throws {
1515+
let dbQueue = try makeDatabaseQueue()
1516+
try dbQueue.read { db in
1517+
do {
1518+
let request = Team.annotated(with: cast(Team.players.count, as: .real))
1519+
try assertEqualSQL(db, request, """
1520+
SELECT "team".*, CAST(COUNT(DISTINCT "player"."id") AS REAL) AS "playerCount" \
1521+
FROM "team" \
1522+
LEFT JOIN "player" ON "player"."teamId" = "team"."id" \
1523+
GROUP BY "team"."id"
1524+
""")
1525+
}
1526+
do {
1527+
let request = Team.annotated(with: cast(Team.players.count, as: .real).forKey("foo"))
1528+
try assertEqualSQL(db, request, """
1529+
SELECT "team".*, CAST(COUNT(DISTINCT "player"."id") AS REAL) AS "foo" \
1530+
FROM "team" \
1531+
LEFT JOIN "player" ON "player"."teamId" = "team"."id" \
1532+
GROUP BY "team"."id"
1533+
""")
1534+
}
1535+
}
1536+
}
1537+
15141538
func testLength() throws {
15151539
let dbQueue = try makeDatabaseQueue()
15161540
try dbQueue.read { db in

Tests/GRDBTests/QueryInterfaceExpressionsTests.swift

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1526,7 +1526,15 @@ class QueryInterfaceExpressionsTests: GRDBTestCase {
15261526
sql(dbQueue, tableRequest.select(average(Col.age / 2, filter: Col.age > 0))),
15271527
"SELECT AVG(\"age\" / 2) FILTER (WHERE \"age\" > 0) FROM \"readers\"")
15281528
}
1529-
1529+
1530+
func testCastExpression() throws {
1531+
let dbQueue = try makeDatabaseQueue()
1532+
1533+
XCTAssertEqual(
1534+
sql(dbQueue, tableRequest.select(cast(Col.name, as: .blob))),
1535+
"SELECT CAST(\"name\" AS BLOB) FROM \"readers\"")
1536+
}
1537+
15301538
func testLengthExpression() throws {
15311539
let dbQueue = try makeDatabaseQueue()
15321540

Tests/GRDBTests/QueryInterfaceExtensibilityTests.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import XCTest
22
import GRDB
33

4-
private func cast<T: SQLExpressible>(_ value: T, as type: Database.ColumnType) -> SQLExpression {
4+
private func myCast<T: SQLExpressible>(_ value: T, as type: Database.ColumnType) -> SQLExpression {
55
SQL("CAST(\(value) AS \(sql: type.rawValue))").sqlExpression
66
}
77

@@ -19,7 +19,7 @@ class QueryInterfaceExtensibilityTests: GRDBTestCase {
1919
try db.execute(sql: "INSERT INTO records (text) VALUES (?)", arguments: ["foo"])
2020

2121
do {
22-
let request = Record.select(cast(Column("text"), as: .blob))
22+
let request = Record.select(myCast(Column("text"), as: .blob))
2323
let dbValue = try DatabaseValue.fetchOne(db, request)!
2424
switch dbValue.storage {
2525
case .blob:
@@ -30,7 +30,7 @@ class QueryInterfaceExtensibilityTests: GRDBTestCase {
3030
XCTAssertEqual(self.lastSQLQuery, "SELECT CAST(\"text\" AS BLOB) FROM \"records\" LIMIT 1")
3131
}
3232
do {
33-
let request = Record.select(cast(Column("text"), as: .blob) && true)
33+
let request = Record.select(myCast(Column("text"), as: .blob) && true)
3434
_ = try DatabaseValue.fetchOne(db, request)!
3535
XCTAssertEqual(self.lastSQLQuery, "SELECT (CAST(\"text\" AS BLOB)) AND 1 FROM \"records\" LIMIT 1")
3636
}

0 commit comments

Comments
 (0)