00:06 Today we'll look at the new string interpolation APIs from Swift 5, and we'll try them out by building SQL queries with placeholders for parameters.
00:18 We rely on PostgreSQL to prevent SQL injection by properly escaping the parameters we pass into our queries. We wrote an initializer on Query
, and the initializer automatically builds up the query string with a placeholder — in the form of $1
, $2
, etc. — for each of values that need to be escaped.
Building a Query String
00:52 To illustrate this, let's look at a simplified version of our backend code, written in Swift 4.2:
typealias SQLValue = String
struct Query<A> {
let sql: String
let values: [SQLValue]
let parse: ([SQLValue]) -> A
typealias Placeholder = String
init(values: [SQLValue], build: ([Placeholder]) -> String, parse: @escaping ([SQLValue]) -> A) {
let placeholders = values.enumerated().map { "$\($0.0 + 1)" }
self.values = values
self.sql = build(placeholders)
self.parse = parse
}
}
01:28 Let's create an example query that retrieves a user by their ID. The initializer takes both an array of values and a build
function that creates the query string from generated placeholders. This build
function receives a placeholder for each of the values we pass in:
let id = "1234"
let sample = Query<String>(values: [id], build: { params in
"SELECT * FROM users WHERE id=\(params[0])"
}, parse: { $0[0] })
assert(sample.sql == "SELECT * FROM users WHERE id=$1")
assert(sample.values == ["1234"])
Using String Interpolation
01:51 Swift 5 makes string interpolation public, which means we can implement our own type of interpolation that automatically inserts placeholders for values. This will allow us to create our query without using a build
function:
let sample = Query<String>("SELECT * FROM users WHERE id=\(param: id)", parse: { $0[0] })
02:46 In order to make this work, the type of the query parameter we pass into Query
has to conform to the ExpressibleByStringInterpolation
protocol. Of course, we could use String
as the type — and then we wouldn't have to use the param:
label — but we want to be explicit about parameters that need to be escaped (and therefore need a placeholder), so we create a custom type, QueryPart
:
struct QueryPart {
let sql: String
let values: [SQLValue]
}
struct Query<A> {
let query: QueryPart
let parse: ([SQLValue]) -> A
init(_ part: QueryPart, parse: @escaping ([SQLValue]) -> A) {
self.query = part
self.parse = parse
}
}
04:26 Next, we need to make QueryPart
conform to both ExpressibleByStringLiteral
and ExpressibleByStringInterpolation
:
extension QueryPart: ExpressibleByStringLiteral {
init(stringLiteral value: String) {
self.sql = value
self.values = []
}
}
extension QueryPart: ExpressibleByStringInterpolation {
}
05:20 The last extension already compiles because the protocol has a default implementation, i.e. the interpolation type from the standard library:
public protocol ExpressibleByStringInterpolation : ExpressibleByStringLiteral {
/// The type each segment of a string literal containing interpolations
/// should be appended to.
associatedtype StringInterpolation : StringInterpolationProtocol = DefaultStringInterpolation where Self.StringLiteralType == Self.StringInterpolation.StringLiteralType
// ...
}
05:45 We want to override this default implementation by specifying our own type conforming to StringInterpolationProtocol
, and this will be the type for each segment we append to QueryPart
:
struct QueryPartStringInterpolation: StringInterpolationProtocol {
// ...
}
extension QueryPart: ExpressibleByStringInterpolation {
typealias StringInterpolation = QueryPartStringInterpolation
}
06:45 This new interpolation type is where we implement the custom behavior we want when we insert a value into our query string. The first thing we have to implement is a required initializer, which doesn't need to do anything in our case:
struct QueryPartStringInterpolation: StringInterpolationProtocol {
init(literalCapacity: Int, interpolationCount: Int) {
}
}
07:08 The way string interpolation works is that we'll be called with each segment that needs to be appended — i.e. string literals and interpolations. To keep track of what we receive, we'll need the same two properties that QueryPart
has:
struct QueryPartStringInterpolation: StringInterpolationProtocol {
var sql: String = ""
var values: [SQLValue] = []
init(literalCapacity: Int, interpolationCount: Int) {
}
}
08:00 The next step is adding the various appending methods. The first one appends a string literal:
struct QueryPartStringInterpolation: StringInterpolationProtocol {
var sql: String = ""
var values: [SQLValue] = []
// ...
mutating func appendLiteral(_ literal: String) {
sql += literal
}
}
08:22 The second method is for appending SQL values, and we give it the parameter label that corresponds with our call site. Inside the method, we first append the received value to our array of values, and then we append a new placeholder to the query string:
struct QueryPartStringInterpolation: StringInterpolationProtocol {
var sql: String = ""
var values: [SQLValue] = []
// ...
mutating func appendInterpolation(param value: SQLValue) {
sql += "$\(values.count + 1)"
values.append(value)
}
}
09:47 On QueryPart
, we have to add the initializer that takes a QueryPartStringInterpolation
:
extension QueryPart: ExpressibleByStringInterpolation {
typealias StringInterpolation = QueryPartStringInterpolation
init(stringInterpolation: QueryPartStringInterpolation) {
self.sql = stringInterpolation.sql
self.values = stringInterpolation.values
}
}
10:34 Now the code compiles and we can check that our sample query is built correctly:
let id = "1234"
let sample = Query<String>("SELECT * FROM users WHERE id=\(param: id)", parse: { $0[0] })
assert(sample.query.sql == "SELECT * FROM users WHERE id=$1")
assert(sample.query.values == ["1234"])
11:10 It works! Our query string has a placeholder for the ID value, and the values
array contains the ID. Let's try adding another value:
let id = "1234"
let email = "mail@objc.io"
let sample = Query<String>("SELECT * FROM users WHERE id=\(param: id) AND email=\(email)", parse: { $0[0] })
11:27 This doesn't compile because we forgot the param:
label, and that's actually a good thing: we don't want to insert arbitrary strings. After we add the label, we test that the Query
is built up the way we expect:
assert(sample.query.sql == "SELECT * FROM users WHERE id=$1 AND email=$2")
assert(sample.query.values == [id, email])
Inserting Raw Strings
12:06 In the actual codebase of our backend, we dynamically generate queries from Codable
types, and these types supply the table name that should be used. So we also want to be able to dynamically insert a table name into our query:
let tableName = "users"
let sample = Query<String>("SELECT * FROM \(raw: tableName) WHERE id=\(param: id) AND email=\(param: email)", parse: { $0[0] })
12:48 This segment doesn't have to be escaped, and again we want to be explicit about this, in order to avoid accidentally inserting a random string into the query. So we use the label raw:
for this interpolation:
struct QueryPartStringInterpolation: StringInterpolationProtocol {
// ...
mutating func appendInterpolation(raw value: String) {
sql += value
}
}
Simplifying Types
13:19 We can clean up our code by simplifying the types we use. We've made QueryPart
conform to ExpressibleByStringInterpolation
, and then we introduced QueryPartStringInterpolation
as the string interpolation type. But rather than having two separate types with duplicate properties, we can use QueryPart
itself for the string interpolation:
extension QueryPart: ExpressibleByStringInterpolation {
typealias StringInterpolation = QueryPart
init(stringInterpolation: QueryPart) {
self.sql = stringInterpolation.sql
self.values = stringInterpolation.values
}
}
14:37 The two properties on QueryPart
have to become mutable:
struct QueryPart {
var sql: String
var values: [SQLValue]
}
14:56 Then we initialize them in the required initializer:
extension QueryPart: StringInterpolationProtocol {
init(literalCapacity: Int, interpolationCount: Int) {
self.sql = ""
self.values = []
}
// ...
}
15:14 And that's all we have to do to eliminate the need for the separate type, QueryPartStringInterpolation
.
Appending Clauses
15:23 In our backend, we can construct a base query that finds a record by ID, like today's sample query, and then we have the possibility of appending clauses to that base query. This way, we can specify additional filtering (with another condition) or sorting (by appending an ORDER BY
clause) without having to write the base query twice.
15:58 For this to work, we have to add an appending method to Query
itself. Let's add that feature in the next episode.
16:14 We're excited about the possibilities that string interpolation brings us. It's a completely new tool, and as a community, we still have to figure out all the things we can do with it.