From 40445060e0070ab8390b5fa74a7b28452d5f152a Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Sat, 14 Jun 2025 13:50:50 -0500 Subject: [PATCH 1/4] Begin to decouple query fragments from SQLite Currently, query fragments are modeled as strings with `?`s denoting bindings, and an array of bindings. Instead we should model things as a single array of segments that can be concatenated with whatever form of binding identifier we need. For example, Postgres can use `$1`, `$2`, etc. This remodel also has the positive effect of safer debug descriptions of our queries. --- .../Internal/Deprecations.swift | 36 +++++++ .../Internal/PrettyPrinting.swift | 10 +- .../StructuredQueriesCore/QueryFragment.swift | 94 +++++++------------ .../StructuredQueriesCore/TableAlias.swift | 12 ++- .../StructuredQueriesSQLite/Database.swift | 13 ++- 5 files changed, 100 insertions(+), 65 deletions(-) diff --git a/Sources/StructuredQueriesCore/Internal/Deprecations.swift b/Sources/StructuredQueriesCore/Internal/Deprecations.swift index 8dc54d96..cb6f8e17 100644 --- a/Sources/StructuredQueriesCore/Internal/Deprecations.swift +++ b/Sources/StructuredQueriesCore/Internal/Deprecations.swift @@ -1,5 +1,41 @@ import Foundation +// NB: Deprecated after 0.6.0: + +extension QueryFragment { + @available( + *, + deprecated, + message: "Use 'QueryFragment.segments' to build up a SQL string and bindings in a single loop." + ) + public var string: String { + segments.reduce(into: "") { string, segment in + switch segment { + case .sql(let sql): + string.append(sql) + case .binding: + string.append("?") + } + } + } + + @available( + *, + deprecated, + message: "Use 'QueryFragment.segments' to build up a SQL string and bindings in a single loop." + ) + public var bindings: [QueryBinding] { + segments.reduce(into: []) { bindings, segment in + switch segment { + case .sql: + break + case .binding(let binding): + bindings.append(binding) + } + } + } +} + // NB: Deprecated after 0.5.1: extension Table { diff --git a/Sources/StructuredQueriesCore/Internal/PrettyPrinting.swift b/Sources/StructuredQueriesCore/Internal/PrettyPrinting.swift index 067821a7..478a4852 100644 --- a/Sources/StructuredQueriesCore/Internal/PrettyPrinting.swift +++ b/Sources/StructuredQueriesCore/Internal/PrettyPrinting.swift @@ -30,7 +30,15 @@ extension QueryFragment { #if DEBUG guard isTesting else { return self } var query = self - query.string = " \(query.string.replacingOccurrences(of: "\n", with: "\n "))" + query.segments.insert(.sql(" "), at: 0) + for index in query.segments.indices { + switch query.segments[index] { + case .sql(let sql): + query.segments[index] = .sql(sql.replacingOccurrences(of: "\n", with: "\n ")) + case .binding: + continue + } + } return query #else return self diff --git a/Sources/StructuredQueriesCore/QueryFragment.swift b/Sources/StructuredQueriesCore/QueryFragment.swift index 0f571222..5e348920 100644 --- a/Sources/StructuredQueriesCore/QueryFragment.swift +++ b/Sources/StructuredQueriesCore/QueryFragment.swift @@ -5,39 +5,34 @@ import StructuredQueriesSupport /// You will typically create instances of this type using string literals, where bindings are /// directly interpolated into the string. This most commonly occurs when using the `#sql` macro, /// which takes values of this type. -public struct QueryFragment: Hashable, Sendable, CustomDebugStringConvertible { - #if DEBUG - /// The underlying SQL string. - public var string: String - #else - /// The underlying SQL string. - public package(set) var string: String - #endif +public struct QueryFragment: Hashable, Sendable { + // TODO: Call this 'Element' and make 'QueryFragment' a collection of them? + public enum Segment: Hashable, Sendable { + case sql(String) + case binding(QueryBinding) + } + + // TODO: Make 'private(set)' and add APIs to support extensibility like 'indent()'? + public internal(set) var segments: [Segment] = [] - #if DEBUG - /// An array of parameterized statement bindings. - public var bindings: [QueryBinding] - #else - /// An array of parameterized statement bindings. - public package(set) var bindings: [QueryBinding] - #endif + fileprivate init(segments: [Segment]) { + self.segments = segments + } - init(_ string: String = "", _ bindings: [QueryBinding] = []) { - self.string = string - self.bindings = bindings + init(_ string: String = "") { + self.init(segments: [.sql(string)]) } /// A Boolean value indicating whether the query fragment is empty. public var isEmpty: Bool { - return string.isEmpty && bindings.isEmpty + segments.isEmpty } /// Appends the given fragment to this query fragment. /// /// - Parameter other: Another query fragment. public mutating func append(_ other: Self) { - string.append(other.string) - bindings.append(contentsOf: other.bindings) + segments.append(contentsOf: other.segments) } /// Appends a given query fragment to another fragment. @@ -51,36 +46,18 @@ public struct QueryFragment: Hashable, Sendable, CustomDebugStringConvertible { query += rhs return query } +} +extension QueryFragment: CustomDebugStringConvertible { public var debugDescription: String { - var compiled = "" - var bindings = bindings - var currentDelimiter: Character? - compiled.reserveCapacity(string.count) - let delimiters: [Character: Character] = [ - #"""#: #"""#, - "'": "'", - "`": "`", - "[": "]", - ] - for character in string { - if let delimiter = currentDelimiter { - if delimiter == character, - compiled.last != character || compiled.last == delimiters[delimiter] - { - currentDelimiter = nil - } - compiled.append(character) - } else if delimiters.keys.contains(character) { - currentDelimiter = character - compiled.append(character) - } else if character == "?" { - compiled.append(bindings.removeFirst().debugDescription) - } else { - compiled.append(character) + segments.reduce(into: "") { debugDescription, segment in + switch segment { + case .sql(let sql): + debugDescription.append(sql) + case .binding(let binding): + debugDescription.append(binding.debugDescription) } } - return compiled } } @@ -103,7 +80,7 @@ extension [QueryFragment] { extension QueryFragment: ExpressibleByStringInterpolation { public init(stringInterpolation: StringInterpolation) { - self.init(stringInterpolation.string, stringInterpolation.bindings) + self.init(segments: stringInterpolation.segments) } public init(stringLiteral value: String) { @@ -132,16 +109,15 @@ extension QueryFragment: ExpressibleByStringInterpolation { } public struct StringInterpolation: StringInterpolationProtocol { - public var string = "" - public var bindings: [QueryBinding] = [] + fileprivate var segments: [Segment] = [] public init(literalCapacity: Int, interpolationCount: Int) { - string.reserveCapacity(literalCapacity) - bindings.reserveCapacity(interpolationCount) + // TODO: Should all the segments' strings share the same contiguous storage as substring/span? + segments.reserveCapacity(interpolationCount) } public mutating func appendLiteral(_ literal: String) { - string.append(literal) + segments.append(.sql(literal)) } /// Append a quoted fragment to the interpolation. @@ -162,7 +138,7 @@ extension QueryFragment: ExpressibleByStringInterpolation { quote sql: String, delimiter: QuoteDelimiter = .identifier ) { - string.append(sql.quoted(delimiter)) + segments.append(.sql(sql.quoted(delimiter))) } /// Append a raw SQL string to the interpolation. @@ -174,7 +150,7 @@ extension QueryFragment: ExpressibleByStringInterpolation { /// /// - Parameter sql: A raw query string. public mutating func appendInterpolation(raw sql: String) { - string.append(sql) + segments.append(.sql(sql)) } /// Append a raw lossless string to the interpolation. @@ -191,23 +167,21 @@ extension QueryFragment: ExpressibleByStringInterpolation { /// /// - Parameter sql: A raw query string. public mutating func appendInterpolation(raw sql: some LosslessStringConvertible) { - string.append(sql.description) + segments.append(.sql(sql.description)) } /// Append a query binding to the interpolation. /// /// - Parameter binding: A query binding. public mutating func appendInterpolation(_ binding: QueryBinding) { - string.append("?") - bindings.append(binding) + segments.append(.binding(binding)) } /// Append a query fragment to the interpolation. /// /// - Parameter fragment: A query fragment. public mutating func appendInterpolation(_ fragment: QueryFragment) { - string.append(fragment.string) - bindings.append(contentsOf: fragment.bindings) + segments.append(contentsOf: fragment.segments) } /// Append a query expression to the interpolation. diff --git a/Sources/StructuredQueriesCore/TableAlias.swift b/Sources/StructuredQueriesCore/TableAlias.swift index f1d43053..cef462ec 100644 --- a/Sources/StructuredQueriesCore/TableAlias.swift +++ b/Sources/StructuredQueriesCore/TableAlias.swift @@ -223,8 +223,16 @@ extension QueryFragment { of _: T.Type, with _: A.Type ) -> QueryFragment { var query = self - query.string = query.string - .replacingOccurrences(of: T.tableName.quoted(), with: A.aliasName.quoted()) + for index in query.segments.indices { + switch query.segments[index] { + case .sql(let sql): + query.segments[index] = .sql( + sql.replacingOccurrences(of: T.tableName.quoted(), with: A.aliasName.quoted()) + ) + case .binding: + continue + } + } return query } } diff --git a/Sources/StructuredQueriesSQLite/Database.swift b/Sources/StructuredQueriesSQLite/Database.swift index df4a8f7a..eb2cc19f 100644 --- a/Sources/StructuredQueriesSQLite/Database.swift +++ b/Sources/StructuredQueriesSQLite/Database.swift @@ -127,12 +127,21 @@ public struct Database { func withStatement( _ query: QueryFragment, body: (OpaquePointer) throws -> R ) throws -> R { + let (sql, bindings) = query.segments.reduce(into: (sql: "", bindings: [QueryBinding]())) { + switch $1 { + case .sql(let sql): + $0.sql.append(sql) + case .binding(let binding): + $0.sql.append("?") + $0.bindings.append(binding) + } + } var statement: OpaquePointer? - let code = sqlite3_prepare_v2(storage.handle, query.string, -1, &statement, nil) + let code = sqlite3_prepare_v2(storage.handle, sql, -1, &statement, nil) guard code == SQLITE_OK, let statement else { throw SQLiteError(db: storage.handle) } defer { sqlite3_finalize(statement) } - for (index, binding) in zip(Int32(1)..., query.bindings) { + for (index, binding) in zip(Int32(1)..., bindings) { let result = switch binding { case .blob(let blob): From 1ad553e0a62381f8c24d59565b1ab7f0b953b517 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Mon, 16 Jun 2025 09:10:16 -0700 Subject: [PATCH 2/4] wip --- Sources/StructuredQueriesCore/QueryFragment.swift | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/Sources/StructuredQueriesCore/QueryFragment.swift b/Sources/StructuredQueriesCore/QueryFragment.swift index 5e348920..da65ce12 100644 --- a/Sources/StructuredQueriesCore/QueryFragment.swift +++ b/Sources/StructuredQueriesCore/QueryFragment.swift @@ -25,7 +25,14 @@ public struct QueryFragment: Hashable, Sendable { /// A Boolean value indicating whether the query fragment is empty. public var isEmpty: Bool { - segments.isEmpty + segments.allSatisfy { + switch $0 { + case .sql(let sql): + sql.isEmpty + case .binding(let binding): + false + } + } } /// Appends the given fragment to this query fragment. From 79210ad67db314b46eeb3490a2be9178c6095c05 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Mon, 16 Jun 2025 11:42:58 -0700 Subject: [PATCH 3/4] wip --- Sources/StructuredQueriesCore/QueryFragment.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/StructuredQueriesCore/QueryFragment.swift b/Sources/StructuredQueriesCore/QueryFragment.swift index da65ce12..d2b42c5a 100644 --- a/Sources/StructuredQueriesCore/QueryFragment.swift +++ b/Sources/StructuredQueriesCore/QueryFragment.swift @@ -29,7 +29,7 @@ public struct QueryFragment: Hashable, Sendable { switch $0 { case .sql(let sql): sql.isEmpty - case .binding(let binding): + case .binding: false } } From 95d0ac10a58ff20cfbbd66703fa5788ba25afc10 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Mon, 16 Jun 2025 13:01:43 -0700 Subject: [PATCH 4/4] wip --- .../StructuredQueriesCore/QueryFragment.swift | 26 ++++++++++++++++--- .../StructuredQueriesSQLite/Database.swift | 10 +------ 2 files changed, 24 insertions(+), 12 deletions(-) diff --git a/Sources/StructuredQueriesCore/QueryFragment.swift b/Sources/StructuredQueriesCore/QueryFragment.swift index d2b42c5a..e100a2ac 100644 --- a/Sources/StructuredQueriesCore/QueryFragment.swift +++ b/Sources/StructuredQueriesCore/QueryFragment.swift @@ -6,13 +6,16 @@ import StructuredQueriesSupport /// directly interpolated into the string. This most commonly occurs when using the `#sql` macro, /// which takes values of this type. public struct QueryFragment: Hashable, Sendable { - // TODO: Call this 'Element' and make 'QueryFragment' a collection of them? + /// A segment of a query fragment. public enum Segment: Hashable, Sendable { + /// A raw SQL fragment. case sql(String) + + /// A binding. case binding(QueryBinding) } - // TODO: Make 'private(set)' and add APIs to support extensibility like 'indent()'? + /// An array of segments backing this query fragment. public internal(set) var segments: [Segment] = [] fileprivate init(segments: [Segment]) { @@ -53,6 +56,24 @@ public struct QueryFragment: Hashable, Sendable { query += rhs return query } + + /// Returns a prepared SQL string and associated bindings for this query. + /// + /// - Parameter template: Prepare a template string for a binding at a given 1-based offset. + /// - Returns: A SQL string and array of associated bindings. + public func prepare( + _ template: (_ offset: Int) -> String + ) -> (sql: String, bindings: [QueryBinding]) { + segments.enumerated().reduce(into: (sql: "", bindings: [QueryBinding]())) { + switch $1.element { + case .sql(let sql): + $0.sql.append(sql) + case .binding(let binding): + $0.sql.append(template($1.offset + 1)) + $0.bindings.append(binding) + } + } + } } extension QueryFragment: CustomDebugStringConvertible { @@ -119,7 +140,6 @@ extension QueryFragment: ExpressibleByStringInterpolation { fileprivate var segments: [Segment] = [] public init(literalCapacity: Int, interpolationCount: Int) { - // TODO: Should all the segments' strings share the same contiguous storage as substring/span? segments.reserveCapacity(interpolationCount) } diff --git a/Sources/StructuredQueriesSQLite/Database.swift b/Sources/StructuredQueriesSQLite/Database.swift index eb2cc19f..e3dced2c 100644 --- a/Sources/StructuredQueriesSQLite/Database.swift +++ b/Sources/StructuredQueriesSQLite/Database.swift @@ -127,15 +127,7 @@ public struct Database { func withStatement( _ query: QueryFragment, body: (OpaquePointer) throws -> R ) throws -> R { - let (sql, bindings) = query.segments.reduce(into: (sql: "", bindings: [QueryBinding]())) { - switch $1 { - case .sql(let sql): - $0.sql.append(sql) - case .binding(let binding): - $0.sql.append("?") - $0.bindings.append(binding) - } - } + let (sql, bindings) = query.prepare { _ in "?" } var statement: OpaquePointer? let code = sqlite3_prepare_v2(storage.handle, sql, -1, &statement, nil) guard code == SQLITE_OK, let statement