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 {
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.