Skip to content

Commit 0650039

Browse files
committed
Add default query representations for dates and UUIDs
SQLite does not have date or UUID types, and instead can represent dates and UUIDs in several different ways. While this works for SQLite, other database systems _do_ have dedicated date and UUID types, and so we should probably encode these types in StructuredQueries' decoding and binding layers, and then SQLite drivers (like SharingGRDB) will pick a sensible default, like ISO8601 strings for dates, and lowercased strings for UUIDs. Draft PR for now as we figure out if this is the right direction, and there is plenty to do before merging (documentation, etc.).
1 parent da05323 commit 0650039

File tree

18 files changed

+121
-432
lines changed

18 files changed

+121
-432
lines changed

Sources/StructuredQueriesCore/QueryBindable.swift

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import Foundation
2+
13
/// A type representing a value that can be bound to a parameter of a SQL statement.
24
public protocol QueryBindable: QueryRepresentable, QueryExpression where QueryValue: QueryBindable {
35
/// The Swift data type representation of the expression's SQL bindable data type.
@@ -13,6 +15,10 @@ extension QueryBindable {
1315
public var queryFragment: QueryFragment { "\(queryBinding)" }
1416
}
1517

18+
extension [UInt8]: QueryBindable, QueryExpression {
19+
public var queryBinding: QueryBinding { .blob(self) }
20+
}
21+
1622
extension Bool: QueryBindable {
1723
public var queryBinding: QueryBinding { .int(self ? 1 : 0) }
1824
}
@@ -21,6 +27,10 @@ extension Double: QueryBindable {
2127
public var queryBinding: QueryBinding { .double(self) }
2228
}
2329

30+
extension Date: QueryBindable {
31+
public var queryBinding: QueryBinding { .date(self) }
32+
}
33+
2434
extension Float: QueryBindable {
2535
public var queryBinding: QueryBinding { .double(Double(self)) }
2636
}
@@ -71,8 +81,8 @@ extension UInt64: QueryBindable {
7181
}
7282
}
7383

74-
extension [UInt8]: QueryBindable, QueryExpression {
75-
public var queryBinding: QueryBinding { .blob(self) }
84+
extension UUID: QueryBindable {
85+
public var queryBinding: QueryBinding { .uuid(self) }
7686
}
7787

7888
extension DefaultStringInterpolation {

Sources/StructuredQueriesCore/QueryBinding.swift

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ public enum QueryBinding: Hashable, Sendable {
99
/// A value that should be bound to a statement as a double.
1010
case double(Double)
1111

12+
/// A value that should be bound to a statement as a date.
13+
case date(Date)
14+
1215
/// A value that should be bound to a statement as an integer.
1316
case int(Int64)
1417

@@ -18,6 +21,9 @@ public enum QueryBinding: Hashable, Sendable {
1821
/// A value that should be bound to a statement as a string.
1922
case text(String)
2023

24+
/// A value that should be bound to a statement as a unique identifier.
25+
case uuid(UUID)
26+
2127
/// An error describing why a value cannot be bound to a statement.
2228
case invalid(QueryBindingError)
2329

@@ -46,6 +52,8 @@ extension QueryBinding: CustomDebugStringConvertible {
4652
.dropLast()
4753
.dropFirst()
4854
.quoted(.text)
55+
case let .date(date):
56+
return date.iso8601String.quoted(.text)
4957
case let .double(value):
5058
return "\(value)"
5159
case let .int(value):
@@ -54,6 +62,8 @@ extension QueryBinding: CustomDebugStringConvertible {
5462
return "NULL"
5563
case let .text(string):
5664
return string.quoted(.text)
65+
case let .uuid(uuid):
66+
return uuid.uuidString.lowercased().quoted(.text)
5767
case let .invalid(error):
5868
return "<invalid: \(error.underlyingError.localizedDescription)>"
5969
}

Sources/StructuredQueriesCore/QueryDecodable.swift

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import Foundation
2+
13
/// A type that can decode itself from a query.
24
public protocol QueryDecodable: _OptionalPromotable {
35
/// Creates a new instance by decoding from the given decoder.
@@ -52,6 +54,15 @@ extension Bool: QueryDecodable {
5254
}
5355
}
5456

57+
extension Date: QueryDecodable {
58+
@inlinable
59+
public init(decoder: inout some QueryDecoder) throws {
60+
guard let result = try decoder.decode(Date.self)
61+
else { throw QueryDecodingError.missingRequiredColumn }
62+
self = result
63+
}
64+
}
65+
5566
extension Float: QueryDecodable {
5667
@inlinable
5768
public init(decoder: inout some QueryDecoder) throws {
@@ -138,6 +149,15 @@ extension UInt64: QueryDecodable {
138149
}
139150
}
140151

152+
extension UUID: QueryDecodable {
153+
@inlinable
154+
public init(decoder: inout some QueryDecoder) throws {
155+
guard let result = try decoder.decode(UUID.self)
156+
else { throw QueryDecodingError.missingRequiredColumn }
157+
self = result
158+
}
159+
}
160+
141161
extension QueryDecodable where Self: LosslessStringConvertible {
142162
@inlinable
143163
public init(decoder: inout some QueryDecoder) throws {

Sources/StructuredQueriesCore/QueryDecoder.swift

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import Foundation
2+
13
/// A type that can decode values from a database connection into in-memory representations.
24
public protocol QueryDecoder {
35
/// Decodes a single value of the given type from the current column.
@@ -36,6 +38,18 @@ public protocol QueryDecoder {
3638
/// - Returns: A value of the requested type, or `nil` if the column is `NULL`.
3739
mutating func decode(_ columnType: Int.Type) throws -> Int?
3840

41+
/// Decodes a single value of the given type from the current column.
42+
///
43+
/// - Parameter columnType: The type to decode as.
44+
/// - Returns: A value of the requested type, or `nil` if the column is `NULL`.
45+
mutating func decode(_ columnType: Date.Type) throws -> Date?
46+
47+
/// Decodes a single value of the given type from the current column.
48+
///
49+
/// - Parameter columnType: The type to decode as.
50+
/// - Returns: A value of the requested type, or `nil` if the column is `NULL`.
51+
mutating func decode(_ columnType: UUID.Type) throws -> UUID?
52+
3953
/// Decodes a single value of the given type starting from the current column.
4054
///
4155
/// - Parameter columnType: The type to decode as.

Sources/StructuredQueriesCore/QueryRepresentable/Codable+JSON.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ extension _CodableJSONRepresentation: SQLiteType {
5757
private let jsonDecoder: JSONDecoder = {
5858
var decoder = JSONDecoder()
5959
decoder.dateDecodingStrategy = .custom {
60-
try $0.singleValueContainer().decode(String.self).iso8601
60+
try Date(iso8601String: $0.singleValueContainer().decode(String.self))
6161
}
6262
return decoder
6363
}()

Sources/StructuredQueriesCore/QueryRepresentable/Date+ISO8601.swift

Lines changed: 25 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -34,12 +34,12 @@ extension Date.ISO8601Representation: QueryBindable {
3434

3535
extension Date.ISO8601Representation: QueryDecodable {
3636
public init(decoder: inout some QueryDecoder) throws {
37-
try self.init(queryOutput: String(decoder: &decoder).iso8601)
37+
try self.init(queryOutput: Date(iso8601String: String(decoder: &decoder)))
3838
}
3939
}
4040

4141
extension Date {
42-
fileprivate var iso8601String: String {
42+
package var iso8601String: String {
4343
if #available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) {
4444
return formatted(.iso8601.currentTimestamp(includingFractionalSeconds: true))
4545
} else {
@@ -72,31 +72,30 @@ extension DateFormatter {
7272
}()
7373
}
7474

75-
extension String {
76-
var iso8601: Date {
77-
get throws {
78-
if #available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) {
79-
do {
80-
return try Date(
81-
queryOutput,
82-
strategy: .iso8601.currentTimestamp(includingFractionalSeconds: true)
83-
)
84-
} catch {
85-
return try Date(
86-
queryOutput,
87-
strategy: .iso8601.currentTimestamp(includingFractionalSeconds: false)
88-
)
89-
}
90-
} else {
91-
guard
92-
let date = DateFormatter.iso8601(includingFractionalSeconds: true).date(from: self)
93-
?? DateFormatter.iso8601(includingFractionalSeconds: false).date(from: self)
94-
else {
95-
struct InvalidDate: Error { let string: String }
96-
throw InvalidDate(string: self)
97-
}
98-
return date
75+
extension Date {
76+
@usableFromInline
77+
package init(iso8601String: String) throws {
78+
if #available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) {
79+
do {
80+
try self.init(
81+
iso8601String.queryOutput,
82+
strategy: .iso8601.currentTimestamp(includingFractionalSeconds: true)
83+
)
84+
} catch {
85+
try self.init(
86+
iso8601String.queryOutput,
87+
strategy: .iso8601.currentTimestamp(includingFractionalSeconds: false)
88+
)
89+
}
90+
} else {
91+
guard
92+
let date = DateFormatter.iso8601(includingFractionalSeconds: true).date(from: iso8601String)
93+
?? DateFormatter.iso8601(includingFractionalSeconds: false).date(from: iso8601String)
94+
else {
95+
struct InvalidDate: Error { let string: String }
96+
throw InvalidDate(string: iso8601String)
9997
}
98+
self = date
10099
}
101100
}
102101
}

Sources/StructuredQueriesMacros/SelectionMacro.swift

Lines changed: 0 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -134,67 +134,6 @@ extension SelectionMacro: ExtensionMacro {
134134
.baseName
135135
.text
136136
}
137-
if columnQueryValueType == columnQueryOutputType,
138-
let typeIdentifier = columnQueryValueType?.identifier ?? assignedType,
139-
["Date", "UUID"].contains(typeIdentifier)
140-
{
141-
var fixIts: [FixIt] = []
142-
let optional = columnQueryValueType?.isOptionalType == true ? "?" : ""
143-
if typeIdentifier.hasPrefix("Date") {
144-
for representation in ["ISO8601", "UnixTime", "JulianDay"] {
145-
var newProperty = property.with(\.leadingTrivia, "")
146-
let attribute = "@Column(as: Date.\(representation)Representation\(optional).self)"
147-
newProperty.attributes.insert(
148-
AttributeListSyntax.Element("\(raw: attribute)")
149-
.with(
150-
\.trailingTrivia,
151-
.newline.merging(property.leadingTrivia.indentation(isOnNewline: true))
152-
),
153-
at: newProperty.attributes.startIndex
154-
)
155-
fixIts.append(
156-
FixIt(
157-
message: MacroExpansionFixItMessage("Insert '\(attribute)'"),
158-
changes: [
159-
.replace(
160-
oldNode: Syntax(property),
161-
newNode: Syntax(newProperty.with(\.leadingTrivia, property.leadingTrivia))
162-
)
163-
]
164-
)
165-
)
166-
}
167-
} else if typeIdentifier.hasPrefix("UUID") {
168-
for representation in ["Lowercased", "Uppercased", "Bytes"] {
169-
var newProperty = property.with(\.leadingTrivia, "")
170-
let attribute = "@Column(as: UUID.\(representation)Representation\(optional).self)"
171-
newProperty.attributes.insert(
172-
AttributeListSyntax.Element("\(raw: attribute)"),
173-
at: newProperty.attributes.startIndex
174-
)
175-
fixIts.append(
176-
FixIt(
177-
message: MacroExpansionFixItMessage("Insert '\(attribute)'"),
178-
changes: [
179-
.replace(
180-
oldNode: Syntax(property),
181-
newNode: Syntax(newProperty.with(\.leadingTrivia, property.leadingTrivia))
182-
)
183-
]
184-
)
185-
)
186-
}
187-
}
188-
diagnostics.append(
189-
Diagnostic(
190-
node: property,
191-
message: MacroExpansionErrorMessage(
192-
"'\(typeIdentifier)' column requires a query representation"
193-
),
194-
fixIts: fixIts
195-
)
196-
)
197-
}
198137

199138
allColumns.append((identifier, columnQueryValueType))
200139
let decodedType = columnQueryValueType?.asNonOptionalType()

Sources/StructuredQueriesMacros/TableMacro.swift

Lines changed: 0 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -217,67 +217,6 @@ extension TableMacro: ExtensionMacro {
217217
.baseName
218218
.text
219219
}
220-
if columnQueryValueType == columnQueryOutputType,
221-
let typeIdentifier = columnQueryValueType?.identifier ?? assignedType,
222-
["Date", "UUID"].contains(typeIdentifier)
223-
{
224-
var fixIts: [FixIt] = []
225-
let optional = columnQueryValueType?.isOptionalType == true ? "?" : ""
226-
if typeIdentifier.hasPrefix("Date") {
227-
for representation in ["ISO8601", "UnixTime", "JulianDay"] {
228-
var newProperty = property.with(\.leadingTrivia, "")
229-
let attribute = "@Column(as: Date.\(representation)Representation\(optional).self)"
230-
newProperty.attributes.insert(
231-
AttributeListSyntax.Element("\(raw: attribute)")
232-
.with(
233-
\.trailingTrivia,
234-
.newline.merging(property.leadingTrivia.indentation(isOnNewline: true))
235-
),
236-
at: newProperty.attributes.startIndex
237-
)
238-
fixIts.append(
239-
FixIt(
240-
message: MacroExpansionFixItMessage("Insert '\(attribute)'"),
241-
changes: [
242-
.replace(
243-
oldNode: Syntax(property),
244-
newNode: Syntax(newProperty.with(\.leadingTrivia, property.leadingTrivia))
245-
)
246-
]
247-
)
248-
)
249-
}
250-
} else if typeIdentifier.hasPrefix("UUID") {
251-
for representation in ["Lowercased", "Uppercased", "Bytes"] {
252-
var newProperty = property.with(\.leadingTrivia, "")
253-
let attribute = "@Column(as: UUID.\(representation)Representation\(optional).self)\n"
254-
newProperty.attributes.insert(
255-
AttributeListSyntax.Element("\(raw: attribute)"),
256-
at: newProperty.attributes.startIndex
257-
)
258-
fixIts.append(
259-
FixIt(
260-
message: MacroExpansionFixItMessage("Insert '\(attribute)'"),
261-
changes: [
262-
.replace(
263-
oldNode: Syntax(property),
264-
newNode: Syntax(newProperty.with(\.leadingTrivia, property.leadingTrivia))
265-
)
266-
]
267-
)
268-
)
269-
}
270-
}
271-
diagnostics.append(
272-
Diagnostic(
273-
node: property,
274-
message: MacroExpansionErrorMessage(
275-
"'\(typeIdentifier)' column requires a query representation"
276-
),
277-
fixIts: fixIts
278-
)
279-
)
280-
}
281220

282221
let defaultValue = binding.initializer?.value.rewritten(selfRewriter)
283222
columnsProperties.append(

Sources/StructuredQueriesSQLite/Database.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,8 @@ public struct Database {
137137
switch binding {
138138
case let .blob(blob):
139139
sqlite3_bind_blob(statement, index, Array(blob), Int32(blob.count), SQLITE_TRANSIENT)
140+
case let .date(date):
141+
sqlite3_bind_text(statement, index, date.iso8601String, -1, SQLITE_TRANSIENT)
140142
case let .double(double):
141143
sqlite3_bind_double(statement, index, double)
142144
case let .int(int):
@@ -145,6 +147,8 @@ public struct Database {
145147
sqlite3_bind_null(statement, index)
146148
case let .text(text):
147149
sqlite3_bind_text(statement, index, text, -1, SQLITE_TRANSIENT)
150+
case let .uuid(uuid):
151+
sqlite3_bind_text(statement, index, uuid.uuidString.lowercased(), -1, SQLITE_TRANSIENT)
148152
case let .invalid(error):
149153
throw error.underlyingError
150154
}

0 commit comments

Comments
 (0)