diff --git a/Sources/StructuredQueriesCore/Internal/Deprecations.swift b/Sources/StructuredQueriesCore/Internal/Deprecations.swift index 6648ec88..fb206709 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..e100a2ac 100644 --- a/Sources/StructuredQueriesCore/QueryFragment.swift +++ b/Sources/StructuredQueriesCore/QueryFragment.swift @@ -5,39 +5,44 @@ 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 { + /// A segment of a query fragment. + public enum Segment: Hashable, Sendable { + /// A raw SQL fragment. + case sql(String) - #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 + /// A binding. + case binding(QueryBinding) + } + + /// An array of segments backing this query fragment. + public internal(set) var segments: [Segment] = [] + + 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.allSatisfy { + switch $0 { + case .sql(let sql): + sql.isEmpty + case .binding: + false + } + } } /// 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. @@ -52,35 +57,35 @@ public struct QueryFragment: Hashable, Sendable, CustomDebugStringConvertible { 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 { 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 +108,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 +137,14 @@ 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) + 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 +165,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 +177,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 +194,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..e3dced2c 100644 --- a/Sources/StructuredQueriesSQLite/Database.swift +++ b/Sources/StructuredQueriesSQLite/Database.swift @@ -127,12 +127,13 @@ public struct Database { func withStatement( _ query: QueryFragment, body: (OpaquePointer) throws -> R ) throws -> R { + let (sql, bindings) = query.prepare { _ in "?" } 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):