Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support for the CAST SQLite function #1515

Merged
merged 7 commits into from Mar 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Expand Up @@ -128,6 +128,7 @@ GRDB adheres to [Semantic Versioning](https://semver.org/), with one exception:
- **New**: [#1503](https://github.com/groue/GRDB.swift/pull/1503) by [@simba909](https://github.com/simba909): Conform Database.ColumnType to Sendable
- **New**: [#1510](https://github.com/groue/GRDB.swift/pull/1510) by [@groue](https://github.com/groue): Add Sendable conformances and unavailabilities
- **New**: [#1511](https://github.com/groue/GRDB.swift/pull/1511) by [@groue](https://github.com/groue): Database schema dump
- **New**: [#1515](https://github.com/groue/GRDB.swift/pull/1515) by [@groue](https://github.com/groue): Support for the CAST SQLite function
- **Fixed**: [#1508](https://github.com/groue/GRDB.swift/pull/1508) by [@groue](https://github.com/groue): Fix ValueObservation mishandling of database schema modification

## 6.25.0
Expand Down
2 changes: 1 addition & 1 deletion Documentation/AssociationsBasics.md
Expand Up @@ -2661,7 +2661,7 @@ Aggregates can be modified and combined with Swift operators:
let request = Team.annotated(with: Team.players.min(Column("score")) ?? 0)
```

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

<details>
<summary>SQL</summary>
Expand Down
27 changes: 27 additions & 0 deletions GRDB/Core/Database.swift
Expand Up @@ -115,6 +115,7 @@ let SQLITE_TRANSIENT = unsafeBitCast(OpaquePointer(bitPattern: -1), to: sqlite3_
/// - ``trace(options:_:)``
/// - ``CheckpointMode``
/// - ``DatabaseBackupProgress``
/// - ``StorageClass``
/// - ``TraceEvent``
/// - ``TracingOptions``
public final class Database: CustomStringConvertible, CustomDebugStringConvertible {
Expand Down Expand Up @@ -2005,6 +2006,32 @@ extension Database {
/// An error log function that takes an error code and message.
public typealias LogErrorFunction = (_ resultCode: ResultCode, _ message: String) -> Void

/// An SQLite storage class.
///
/// For more information, see
/// [Datatypes In SQLite](https://www.sqlite.org/datatype3.html).
public struct StorageClass: RawRepresentable, Hashable, Sendable {
/// The SQL for the storage class (`"INTEGER"`, `"REAL"`, etc.)
public let rawValue: String

/// Creates an SQL storage class.
public init(rawValue: String) {
self.rawValue = rawValue
}

/// The `INTEGER` storage class.
public static let integer = StorageClass(rawValue: "INTEGER")

/// The `REAL` storage class.
public static let real = StorageClass(rawValue: "REAL")

/// The `TEXT` storage class.
public static let text = StorageClass(rawValue: "TEXT")

/// The `BLOB` storage class.
public static let blob = StorageClass(rawValue: "BLOB")
}

/// An option for the SQLite tracing feature.
///
/// You use `TracingOptions` with the `Database`
Expand Down
17 changes: 13 additions & 4 deletions GRDB/QueryInterface/Request/Association/AssociationAggregate.swift
Expand Up @@ -835,7 +835,7 @@ extension AssociationAggregate {
}
}

// MARK: - IFNULL(...)
// MARK: - Functions

extension AssociationAggregate {
/// The `IFNULL` SQL function.
Expand All @@ -854,16 +854,25 @@ extension AssociationAggregate {
}
}

// MARK: - ABS(...)

/// The `ABS` SQL function.
public func abs<RowDecoder>(_ aggregate: AssociationAggregate<RowDecoder>)
-> AssociationAggregate<RowDecoder>
{
aggregate.map(abs)
}

// MARK: - LENGTH(...)
/// The `CAST` SQL function.
///
/// Related SQLite documentation: <https://www.sqlite.org/lang_expr.html#castexpr>
public func cast<RowDecoder>(
_ aggregate: AssociationAggregate<RowDecoder>,
as storageClass: Database.StorageClass)
-> AssociationAggregate<RowDecoder>
{
aggregate
.map { cast($0, as: storageClass) }
.with { $0.key = aggregate.key } // Preserve key
}

/// The `LENGTH` SQL function.
public func length<RowDecoder>(_ aggregate: AssociationAggregate<RowDecoder>)
Expand Down
21 changes: 21 additions & 0 deletions GRDB/QueryInterface/SQL/SQLExpression.swift
Expand Up @@ -90,6 +90,11 @@ public struct SQLExpression {
/// A literal SQL expression
case literal(SQL)

/// The `CAST(expr AS storage-class)` expression.
///
/// See <https://www.sqlite.org/lang_expr.html#castexpr>.
indirect case cast(SQLExpression, Database.StorageClass)

/// The `BETWEEN` and `NOT BETWEEN` operators.
///
/// <expression> BETWEEN <lowerBound> AND <upperBound>
Expand Down Expand Up @@ -224,6 +229,9 @@ public struct SQLExpression {
case let .literal(sqlLiteral):
return .literal(sqlLiteral.qualified(with: alias))

case let .cast(expression, storageClass):
return .cast(expression.qualified(with: alias), storageClass)

case let .between(
expression: expression,
lowerBound: lowerBound,
Expand Down Expand Up @@ -1092,6 +1100,13 @@ extension SQLExpression {
self.init(impl: .isEmpty(expression, isNegated: isNegated))
}

/// The `CAST(expr AS storage-class)` expression.
///
/// See <https://www.sqlite.org/lang_expr.html#castexpr>.
static func cast(_ expression: SQLExpression, as storageClass: Database.StorageClass) -> Self {
self.init(impl: .cast(expression, storageClass))
}

// MARK: Deferred

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

case let .cast(expression, storageClass):
return try "CAST(\(expression.sql(context, wrappedInParenthesis: false)) AS \(storageClass.rawValue))"

case let .between(expression: expression, lowerBound: lowerBound, upperBound: upperBound, isNegated: isNegated):
var resultSQL = try """
\(expression.sql(context, wrappedInParenthesis: true)) \
Expand Down Expand Up @@ -1822,6 +1840,9 @@ extension SQLExpression {
let .associativeBinary(_, expressions):
return expressions.allSatisfy(\.isConstantInRequest)

case let .cast(expression, _):
return expression.isConstantInRequest

case let .between(expression: expression, lowerBound: lowerBound, upperBound: upperBound, isNegated: _):
return expression.isConstantInRequest
&& lowerBound.isConstantInRequest
Expand Down
14 changes: 14 additions & 0 deletions GRDB/QueryInterface/SQL/SQLFunctions.swift
Expand Up @@ -57,6 +57,20 @@ public func average(_ value: some SQLSpecificExpressible) -> SQLExpression {
}
#endif

/// The `CAST` SQL function.
///
/// For example:
///
/// ```swift
/// // CAST(value AS REAL)
/// cast(Column("value"), as: .real)
/// ```
///
/// Related SQLite documentation: <https://www.sqlite.org/lang_expr.html#castexpr>
public func cast(_ expression: some SQLSpecificExpressible, as storageClass: Database.StorageClass) -> SQLExpression {
.cast(expression.sqlExpression, as: storageClass)
}

/// The `COUNT` SQL function.
///
/// For example:
Expand Down
11 changes: 11 additions & 0 deletions README.md
Expand Up @@ -4291,6 +4291,17 @@ GRDB comes with a Swift version of many SQLite [built-in functions](https://sqli

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

- `CAST`

Use the `cast` Swift function:

```swift
// SELECT (CAST(wins AS REAL) / games) AS successRate FROM player
Player.select((cast(winsColumn, as: .real) / gamesColumn).forKey("successRate"))
```

See [CAST expressions](https://www.sqlite.org/lang_expr.html#castexpr) for more information about SQLite conversions.

- `IFNULL`

Use the Swift `??` operator:
Expand Down
24 changes: 24 additions & 0 deletions Tests/GRDBTests/AssociationAggregateTests.swift
Expand Up @@ -1511,6 +1511,30 @@ class AssociationAggregateTests: GRDBTestCase {
}
}

func testCast() throws {
let dbQueue = try makeDatabaseQueue()
try dbQueue.read { db in
do {
let request = Team.annotated(with: cast(Team.players.count, as: .real))
try assertEqualSQL(db, request, """
SELECT "team".*, CAST(COUNT(DISTINCT "player"."id") AS REAL) AS "playerCount" \
FROM "team" \
LEFT JOIN "player" ON "player"."teamId" = "team"."id" \
GROUP BY "team"."id"
""")
}
do {
let request = Team.annotated(with: cast(Team.players.count, as: .real).forKey("foo"))
try assertEqualSQL(db, request, """
SELECT "team".*, CAST(COUNT(DISTINCT "player"."id") AS REAL) AS "foo" \
FROM "team" \
LEFT JOIN "player" ON "player"."teamId" = "team"."id" \
GROUP BY "team"."id"
""")
}
}
}

func testLength() throws {
let dbQueue = try makeDatabaseQueue()
try dbQueue.read { db in
Expand Down
10 changes: 9 additions & 1 deletion Tests/GRDBTests/QueryInterfaceExpressionsTests.swift
Expand Up @@ -1526,7 +1526,15 @@ class QueryInterfaceExpressionsTests: GRDBTestCase {
sql(dbQueue, tableRequest.select(average(Col.age / 2, filter: Col.age > 0))),
"SELECT AVG(\"age\" / 2) FILTER (WHERE \"age\" > 0) FROM \"readers\"")
}


func testCastExpression() throws {
let dbQueue = try makeDatabaseQueue()

XCTAssertEqual(
sql(dbQueue, tableRequest.select(cast(Col.name, as: .blob))),
"SELECT CAST(\"name\" AS BLOB) FROM \"readers\"")
}

func testLengthExpression() throws {
let dbQueue = try makeDatabaseQueue()

Expand Down
6 changes: 3 additions & 3 deletions Tests/GRDBTests/QueryInterfaceExtensibilityTests.swift
@@ -1,7 +1,7 @@
import XCTest
import GRDB

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

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

do {
let request = Record.select(cast(Column("text"), as: .blob))
let request = Record.select(myCast(Column("text"), as: .blob))
let dbValue = try DatabaseValue.fetchOne(db, request)!
switch dbValue.storage {
case .blob:
Expand All @@ -30,7 +30,7 @@ class QueryInterfaceExtensibilityTests: GRDBTestCase {
XCTAssertEqual(self.lastSQLQuery, "SELECT CAST(\"text\" AS BLOB) FROM \"records\" LIMIT 1")
}
do {
let request = Record.select(cast(Column("text"), as: .blob) && true)
let request = Record.select(myCast(Column("text"), as: .blob) && true)
_ = try DatabaseValue.fetchOne(db, request)!
XCTAssertEqual(self.lastSQLQuery, "SELECT (CAST(\"text\" AS BLOB)) AND 1 FROM \"records\" LIMIT 1")
}
Expand Down
4 changes: 4 additions & 0 deletions Tests/GRDBTests/SQLExpressionIsConstantTests.swift
Expand Up @@ -274,6 +274,10 @@ class SQLExpressionIsConstantTests: GRDBTestCase {
XCTAssertFalse((Column("a") - 2.databaseValue).isConstantInRequest)
XCTAssertFalse((1.databaseValue - Column("a")).isConstantInRequest)

// CAST
XCTAssertTrue(cast(1.databaseValue, as: .real).isConstantInRequest)
XCTAssertFalse(cast(Column("a"), as: .real).isConstantInRequest)

// SQLExpressionCollate
XCTAssertTrue("foo".databaseValue.collating(.binary).isConstantInRequest)
XCTAssertFalse(Column("a").collating(.binary).isConstantInRequest)
Expand Down