Skip to content

Commit 15c73df

Browse files
committed
Upgrade syntax to sqlquery here
This leads to less verbosity in the need for delimiters: sql`x` is shorter than @query("x"). Crucially, editor syntax highlighers seem to highlight all $ interpolations within Cmd-style backticks, so we still get syntax highlighting with this in contrast to a normal string macro. Now that we have more control over parsing, upgrade the parsing rules so that SQL single-quoted strings are handled specifically and allow $ to be written without escapes.
1 parent 3f407f6 commit 15c73df

File tree

3 files changed

+149
-76
lines changed

3 files changed

+149
-76
lines changed

README.md

+34-20
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,49 @@
11
# SqlStrings
22

3-
The main thing provided here is the `@sql` macro to allow queries to be
3+
This package provides the `@sql_cmd` macro to allow SQL query strings to be
44
constructed by normal-looking string interpolation but without danger of SQL
55
injection attacks.
66

7-
Note that `@sql` does not parse the text as SQL source as this wouldn't be
8-
usable across databases and would be a lot of work. Instead, it keeps any
9-
literal SQL text you write as-is and only treats the Julia-level string
10-
interpolations specially.
7+
[![Little Bobby Tables](https://imgs.xkcd.com/comics/exploits_of_a_mom.png)](https://xkcd.com/327)
118

12-
Use `runquery` to execute queries generated by `@sql`.
9+
`@sql_cmd` is quite simple — it understands only the basic rules of SQL
10+
quoting and Julia string interpolation, but does no other parsing of the source
11+
text. In this sense it is quite similar to `Base.Cmd` - it keeps any literal
12+
SQL text you write as-is and captures the Julia-level string interpolations
13+
in a safe way.
1314

1415
## Simple usage
1516

17+
To use with a given database backend, you'll need a small amount of integration
18+
code. In the examples below we'll use with LibPQ.jl and a `runquery()` function
19+
(hopefully integration will be automatic in future).
20+
21+
```julia
22+
import LibPQ
23+
24+
runquery(conn, sql::SqlStrings.Sql)
25+
query, args = SqlStrings.prepare(sql)
26+
LibPQ.execute(conn, query, args)
27+
end
28+
```
29+
1630
Creating a table and inserting some values
1731

1832
```julia
1933
conn = LibPQ.connection(your_connection_string)
2034

21-
runquery(conn, @sql "create table foo (email text, userid integer)")
35+
runquery(conn, sql`CREATE TABLE foo (email text, userid integer)`)
2236

2337
for (email,id) in [ ("admin@example.com", 1)
2438
("foo@example.com", 2)]
25-
runquery(conn, @sql "insert into foo values ($email, $id)")
39+
runquery(conn, sql`INSERT INTO foo VALUES ($email, $id)`)
2640
end
2741
```
2842

2943
Thence:
3044

31-
```
32-
julia> runquery(conn, @sql "select * from foo") |> DataFrame
45+
```julia
46+
julia> runquery(conn, sql`SELECT * FROM foo`) |> DataFrame
3347
2×2 DataFrame
3448
Row │ email userid
3549
│ String? Int32?
@@ -47,7 +61,7 @@ occasion:
4761

4862
```julia
4963
email_and_id = ("bar@example.com", 3)
50-
runquery(conn, @sql "insert into foo values ($(email_and_id...))")
64+
runquery(conn, sql`INSERT INTO foo VALUES ($(email_and_id...))`)
5165
```
5266

5367
## Howto: Using the `in` operator with a Julia collection
@@ -56,7 +70,7 @@ There's two ways to do this. First, using `in` and splatting syntax
5670

5771
```julia
5872
julia> ids = (1,2)
59-
runquery(conn, @sql "select * from foo where userid in ($(ids...))") |> DataFrame
73+
runquery(conn, sql`SELECT * FROM foo WHERE userid IN ($(ids...))`) |> DataFrame
6074
2×2 DataFrame
6175
Row │ email userid
6276
│ String? Int32?
@@ -67,9 +81,9 @@ julia> ids = (1,2)
6781

6882
Second, using the SQL `any` operator and simply passing a single SQL array parameter:
6983

70-
```
84+
```julia
7185
julia> ids = [1,2]
72-
runquery(conn, @sql "select * from foo where userid = any($ids)") |> DataFrame
86+
runquery(conn, sql`SELECT * FROM foo WHERE userid = any($ids)`) |> DataFrame
7387
2×2 DataFrame
7488
Row │ email userid
7589
│ String? Int32?
@@ -81,7 +95,7 @@ julia> ids = [1,2]
8195
## Howto: Building up a query from fragments
8296

8397
On occasion you might want to dynamically build up a complicated query from
84-
fragments of SQL source text. To do this, the result of `@sql` can be
98+
fragments of SQL source text. To do this, the result of `@sql_cmd` can be
8599
interpolated into a larger query as follows.
86100

87101
```julia
@@ -91,16 +105,16 @@ some_condition = true
91105

92106
x = 100
93107
x = 20
94-
# Example of an optional clauses - use empty @sql() to disable it.
95-
and_clause = some_condition ? @sql("and y=$y") : @sql()
108+
# Example of an optional clauses - use empty sql` to disable it.
109+
and_clause = some_condition ? sql`AND y=$y` : sql``
96110

97-
# Interpolation of values produces SQL parameters; interpolating @sql
111+
# Interpolation of values produces SQL parameters; interpolating sql`
98112
# fragments adds them to the query.
99-
q = @sql "select * from table where x=$x $and_clause"
113+
q = sql`SELECT * FROM table WHERE x=$x $and_clause`
100114
runquery(conn, q)
101115
```
102116

103-
A word of warning that constructing SQl logic with Julia-level logic can make
117+
A word of warning that constructing SQL logic with Julia-level logic can make
104118
the code quite hard to understand. It can be worth considering writing one
105119
larger SQL query which does more of the logic on the SQL side.
106120

src/SqlStrings.jl

+93-43
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
module SqlStrings
22

3-
export @sql
3+
export @sql_cmd
44

55
"""
66
Literal(str)
77
8-
Literal string argument to `@sql`. These are literal query fragments of SQL
8+
Literal string argument to `@sql_cmd`. These are literal query fragments of SQL
99
source text.
1010
"""
1111
struct Literal
@@ -19,8 +19,9 @@ Literal(val) = Literal(string(val))
1919
"""
2020
Sql
2121
22-
A query or query-fragment which keeps track of interpolations and will pass
23-
them as SQL query parameters. Construct this type with the `@sql` macro.
22+
An SQL query or query-fragment which keeps track of interpolations and will
23+
pass them as SQL query parameters. Construct this type with the `@sql_cmd`
24+
macro.
2425
"""
2526
struct Sql
2627
args::Vector
@@ -54,12 +55,77 @@ function process_args!(processed, splat::SplatArgs, args...)
5455
return process_args!(processed, args...)
5556
end
5657

58+
function parse_interpolations(str, allow_dollars_in_strings)
59+
args = []
60+
i = 1
61+
literal_start = i
62+
literal_end = 0
63+
in_singlequote = false
64+
prev_was_backslash = false
65+
while i <= lastindex(str)
66+
c = str[i]
67+
if !allow_dollars_in_strings && in_singlequote && c == '$'
68+
error("""Interpolated arguments should not be quoted, but found quoting in sql`$str`
69+
subexpression starting at `$(str[i:end])`""")
70+
end
71+
if c == '$' && !in_singlequote
72+
if prev_was_backslash
73+
literal_end = prevind(str, literal_end, 1)
74+
if literal_start <= literal_end
75+
push!(args, Literal(str[literal_start:literal_end]))
76+
end
77+
literal_start = i
78+
i = nextind(str, i)
79+
else
80+
if literal_start <= literal_end
81+
push!(args, Literal(str[literal_start:literal_end]))
82+
end
83+
(interpolated_arg, i) = Meta.parse(str, i+1; greedy=false)
84+
if Meta.isexpr(interpolated_arg, :...)
85+
push!(args, :(SplatArgs($(esc(interpolated_arg.args[1])))))
86+
else
87+
push!(args, esc(interpolated_arg))
88+
end
89+
literal_start = i
90+
end
91+
else
92+
if c == '\''
93+
# We assume standard SQL which uses '' rather than \' for
94+
# escaping quotes.
95+
in_singlequote = !in_singlequote
96+
end
97+
literal_end = i
98+
i = nextind(str, i)
99+
end
100+
prev_was_backslash = c == '\\'
101+
end
102+
if literal_start <= literal_end
103+
push!(args, Literal(str[literal_start:literal_end]))
104+
end
105+
args
106+
end
107+
108+
"""
109+
allow_dollars_in_strings[] = true
110+
111+
Set this parsing option to `false` to disallow dollars inside SQL strings, for
112+
example disallowing the `'\$s'` in
113+
114+
sql`select * from foo where s = '\$s'`
115+
116+
When converting code from plain string-based interpolation, it's helpful to
117+
have a macro-expansion-time sanity check that no manual quoting of interpolated
118+
arguments remains.
119+
"""
120+
const allow_dollars_in_strings = Ref(true)
121+
57122
"""
58123
sql`SOME SQL ... \$var`
59124
sql`SOME SQL ... \$(var...)`
125+
sql`SOME SQL ... 'A \$literal string'`
60126
sql``
61127
62-
The `@sql` macro is a tool for tracking SQL query strings together with
128+
The `@sql_cmd` macro is a tool for tracking SQL query strings together with
63129
their parameters, but without interpolating the parameters into the query
64130
string directly. Instead, interpolations like `\$x` will result in the value of
65131
`x` being passed as a query parameter. If you've got a collection of values to
@@ -68,69 +134,53 @@ within the interpolation, for example `insert into foo values(\$(x...))`.
68134
69135
Use this rather than direct string interpolation to prevent SQL injection
70136
attacks and allow systematic conversion of Julia types into their SQL
71-
equivalents.
137+
equivalents via the database layer, rather than via `string()`.
72138
73139
Empty query fragments can be generated with ```sql`` ``` which is useful if you
74-
must dynamically generate SQL code based on conditionals (however also consider
75-
embedding any conditionals on the SQL side rather than in the Julia code.)
140+
must dynamically generate SQL code based on conditionals. However you should
141+
also consider embedding any conditionals on the SQL side rather than in the
142+
Julia code.
143+
144+
*Interpolations are ignored* inside standard SQL Strings with a single quote,
145+
so using `'A \$literal string'` will include `\$literal` rather than the value
146+
of the variable `literal`. If converting code from using raw strings, you may
147+
have needed to quote interpolations. In that case you can check your conversion
148+
by setting `SqlStrings.allow_dollars_in_strings[] = false`.
149+
150+
If you need to include a literal `\$` in the SQL code outside a string, you can
151+
escape it with `\\\$`.
76152
"""
77-
macro sql(ex)
78-
if ex isa String
79-
args = [Literal(ex)]
80-
elseif ex isa Expr && ex.head == :string
81-
args = []
82-
for (i,arg) in enumerate(ex.args)
83-
if arg isa String
84-
push!(args, Literal(arg))
85-
else
86-
# Sanity check: arguments should not be quoted
87-
prev_quote = i > 1 && ex.args[i-1] isa String && endswith(ex.args[i-1], '\'')
88-
next_quote = i < length(ex.args) && ex.args[i+1] isa String && startswith(ex.args[i+1], '\'')
89-
if prev_quote || next_quote
90-
error("""Interpolated arguments should not be quoted, but found quoting in subexpression
91-
$(Expr(:string, ex.args[i-1:i+1]...))""")
92-
end
93-
if Meta.isexpr(arg, :...)
94-
push!(args, :(SplatArgs($(esc(arg.args[1])))))
95-
else
96-
push!(args, esc(arg))
97-
end
98-
end
99-
end
100-
else
101-
error("Unexpected expression passed to @sql: `$ex`")
102-
end
153+
macro sql_cmd(str)
154+
args = parse_interpolations(str, allow_dollars_in_strings[])
103155
quote
104156
Sql(process_args!([], $(args...)))
105157
end
106158
end
107159

108-
macro sql()
109-
Sql([])
110-
end
111-
112160
function Base.:*(x::Sql, y::Sql)
113161
Sql(vcat(x.args, [Literal(" ")], y.args))
114162
end
115163

116-
function _prepare(query::Sql)
164+
default_placeholder_string(i) = "\$$i"
165+
166+
function prepare(sql::Sql, to_placeholder = default_placeholder_string)
117167
querystr = ""
118168
arg_values = []
119169
i = 1
120-
for arg in query.args
170+
for arg in sql.args
121171
if arg isa Literal
122172
querystr *= arg.fragment
123173
else
124-
querystr *= "\$$i"
174+
querystr *= to_placeholder(i)
125175
push!(arg_values, arg)
126176
i += 1
127177
end
128178
end
129179
querystr, arg_values
130180
end
131181

132-
function Base.show(io::IO, query::Sql)
133-
query, arg_values = _prepare(query)
182+
function Base.show(io::IO, sql::Sql)
183+
query, arg_values = prepare(sql)
134184
print(io, query)
135185
if !isempty(arg_values)
136186
args_str = join(["\$$i = $(repr(val))" for (i,val) in enumerate(arg_values)], "\n ")

test/runtests.jl

+22-13
Original file line numberDiff line numberDiff line change
@@ -2,46 +2,55 @@ using SqlStrings
22
using Test
33
using UUIDs
44

5-
querystr(q) = SqlStrings._prepare(q)[1]
6-
queryargs(q) = SqlStrings._prepare(q)[2]
5+
querystr(q) = SqlStrings.prepare(q)[1]
6+
queryargs(q) = SqlStrings.prepare(q)[2]
77

88
@testset "SqlStrings.jl" begin
99
x = 1
1010
y = 2
11-
q1 = @sql "select a where b=$x and c=$(x+y)"
11+
q1 = sql`select a where b=$x and c=$(x+y)`
1212
@test querystr(q1) == raw"select a where b=$1 and c=$2"
1313
@test queryargs(q1) == [x, x+y]
1414

1515
# Test concatenation
16-
q2 = @sql("select a") * @sql("where b=$x")
16+
q2 = sql`select a` * sql`where b=$x`
1717
@test querystr(q2) == raw"select a where b=$1"
1818
@test queryargs(q2) == [x,]
1919

2020
# Test that interpolating queries into queries works
21-
where_clause = @sql "where b=$x"
22-
and_clause = @sql "and c=$y"
23-
empty_clause = @sql()
24-
q3 = @sql "select a $where_clause $and_clause $empty_clause"
21+
where_clause = sql`where b=$x`
22+
and_clause = sql`and c=$y`
23+
empty_clause = sql``
24+
q3 = sql`select a $where_clause $and_clause $empty_clause`
2525
@test querystr(q3) == raw"select a where b=$1 and c=$2 "
2626
@test queryargs(q3) == [x, y]
2727

2828
# On occasion, we need to interpolate in a literal string rather than use a
2929
# parameter. Test that interpolating Literal works for this case.
3030
column = "x"
31-
@test querystr(@sql "select $(SqlStrings.Literal(column)) from a") ==
31+
@test querystr(sql`select $(SqlStrings.Literal(column)) from a`) ==
3232
raw"select x from a"
3333

34-
# Test that erroneously adding quoting produces an error message
35-
@test_throws LoadError @macroexpand @sql "select $y where x = '$x'"
36-
3734
# Test splatting syntax
3835
z = [1,"hi"]
39-
q4 = @sql "insert into foo values($(z...))"
36+
q4 = sql`insert into foo values($(z...))`
4037
@test querystr(q4) == raw"insert into foo values($1,$2)"
4138
@test queryargs(q4) == z
4239

4340
# Test that Literal turns values into strings
4441
@test SqlStrings.Literal(:col_name).fragment == "col_name"
4542
@test SqlStrings.Literal(1).fragment == "1"
43+
44+
# Test dollars inside SQL strings - the $x here should be a literal.
45+
q5 = sql`select $y where x = '$x'`
46+
@test querystr(q5) == raw"select $1 where x = '$x'"
47+
@test queryargs(q5) == [y,]
48+
# Escaping of $
49+
q6 = sql`some literal \$a`
50+
@test querystr(q6) == raw"some literal $a"
51+
52+
SqlStrings.allow_dollars_in_strings[] = false
53+
@test_throws LoadError @macroexpand sql`select $y where x = '$x'`
54+
SqlStrings.allow_dollars_in_strings[] = true
4655
end
4756

0 commit comments

Comments
 (0)