Skip to content

Commit 3f407f6

Browse files
committed
Copy across basic @ sql macro and tests
Will likely change the syntax here to use @ sql_cmd instead.
1 parent 801e8f2 commit 3f407f6

File tree

5 files changed

+286
-5
lines changed

5 files changed

+286
-5
lines changed

LICENSE

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
MIT License
22

3-
Copyright (c) 2021 Chris Foster <chris42f@gmail.com> and contributors
3+
Copyright (c) 2021 Julia Computing and contributors
44

55
Permission is hereby granted, free of charge, to any person obtaining a copy
66
of this software and associated documentation files (the "Software"), to deal

Project.toml

+2-1
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ julia = "1"
88

99
[extras]
1010
Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40"
11+
UUIDs = "cf7118a7-6976-5b1a-9a39-7adc72f591a4"
1112

1213
[targets]
13-
test = ["Test"]
14+
test = ["Test", "UUIDs"]

README.md

+104-1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,106 @@
11
# SqlStrings
22

3-
[![Build Status](https://github.com/JuliaComputing/SqlStrings.jl/workflows/CI/badge.svg)](https://github.com/JuliaComputing/SqlStrings.jl/actions)
3+
The main thing provided here is the `@sql` macro to allow queries to be
4+
constructed by normal-looking string interpolation but without danger of SQL
5+
injection attacks.
6+
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.
11+
12+
Use `runquery` to execute queries generated by `@sql`.
13+
14+
## Simple usage
15+
16+
Creating a table and inserting some values
17+
18+
```julia
19+
conn = LibPQ.connection(your_connection_string)
20+
21+
runquery(conn, @sql "create table foo (email text, userid integer)")
22+
23+
for (email,id) in [ ("admin@example.com", 1)
24+
("foo@example.com", 2)]
25+
runquery(conn, @sql "insert into foo values ($email, $id)")
26+
end
27+
```
28+
29+
Thence:
30+
31+
```
32+
julia> runquery(conn, @sql "select * from foo") |> DataFrame
33+
2×2 DataFrame
34+
Row │ email userid
35+
│ String? Int32?
36+
─────┼───────────────────────────
37+
1 │ admin@example.com 1
38+
2 │ foo@example.com 2
39+
```
40+
41+
## Howto: Inserting values from a Julia array into a row
42+
43+
In some circumstances it can be useful to use splatting syntax to interpolate a
44+
Julia collection into a comma-separated list of values. Generally simple scalar
45+
parameters should be preferred for simplicity, but splatting can be useful on
46+
occasion:
47+
48+
```julia
49+
email_and_id = ("bar@example.com", 3)
50+
runquery(conn, @sql "insert into foo values ($(email_and_id...))")
51+
```
52+
53+
## Howto: Using the `in` operator with a Julia collection
54+
55+
There's two ways to do this. First, using `in` and splatting syntax
56+
57+
```julia
58+
julia> ids = (1,2)
59+
runquery(conn, @sql "select * from foo where userid in ($(ids...))") |> DataFrame
60+
2×2 DataFrame
61+
Row │ email userid
62+
│ String? Int32?
63+
─────┼───────────────────────────
64+
1 │ admin@example.com 1
65+
2 │ foo@example.com 2
66+
```
67+
68+
Second, using the SQL `any` operator and simply passing a single SQL array parameter:
69+
70+
```
71+
julia> ids = [1,2]
72+
runquery(conn, @sql "select * from foo where userid = any($ids)") |> DataFrame
73+
2×2 DataFrame
74+
Row │ email userid
75+
│ String? Int32?
76+
─────┼───────────────────────────
77+
1 │ admin@example.com 1
78+
2 │ foo@example.com 2
79+
```
80+
81+
## Howto: Building up a query from fragments
82+
83+
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
85+
interpolated into a larger query as follows.
86+
87+
```julia
88+
conn = LibPQ.connection(your_connection_string)
89+
90+
some_condition = true
91+
92+
x = 100
93+
x = 20
94+
# Example of an optional clauses - use empty @sql() to disable it.
95+
and_clause = some_condition ? @sql("and y=$y") : @sql()
96+
97+
# Interpolation of values produces SQL parameters; interpolating @sql
98+
# fragments adds them to the query.
99+
q = @sql "select * from table where x=$x $and_clause"
100+
runquery(conn, q)
101+
```
102+
103+
A word of warning that constructing SQl logic with Julia-level logic can make
104+
the code quite hard to understand. It can be worth considering writing one
105+
larger SQL query which does more of the logic on the SQL side.
106+

src/SqlStrings.jl

+137-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,141 @@
11
module SqlStrings
22

3-
# Write your package code here.
3+
export @sql
4+
5+
"""
6+
Literal(str)
7+
8+
Literal string argument to `@sql`. These are literal query fragments of SQL
9+
source text.
10+
"""
11+
struct Literal
12+
fragment::String
13+
14+
Literal(val::AbstractString) = new(convert(String, val))
15+
end
16+
17+
Literal(val) = Literal(string(val))
18+
19+
"""
20+
Sql
21+
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.
24+
"""
25+
struct Sql
26+
args::Vector
27+
end
28+
29+
struct SplatArgs
30+
args
31+
end
32+
33+
function process_args!(processed)
34+
return processed
35+
end
36+
37+
function process_args!(processed, a, args...)
38+
push!(processed, a)
39+
return process_args!(processed, args...)
40+
end
41+
42+
# Query fragments can be interpolated into other queries
43+
function process_args!(processed, a::Sql, args...)
44+
return process_args!(processed, a.args..., args...)
45+
end
46+
47+
function process_args!(processed, splat::SplatArgs, args...)
48+
for (i,a) in enumerate(splat.args)
49+
process_args!(processed, a)
50+
if i < length(splat.args)
51+
push!(processed, Literal(","))
52+
end
53+
end
54+
return process_args!(processed, args...)
55+
end
56+
57+
"""
58+
sql`SOME SQL ... \$var`
59+
sql`SOME SQL ... \$(var...)`
60+
sql``
61+
62+
The `@sql` macro is a tool for tracking SQL query strings together with
63+
their parameters, but without interpolating the parameters into the query
64+
string directly. Instead, interpolations like `\$x` will result in the value of
65+
`x` being passed as a query parameter. If you've got a collection of values to
66+
interpolate into a comma separated context you can also use splatting syntax
67+
within the interpolation, for example `insert into foo values(\$(x...))`.
68+
69+
Use this rather than direct string interpolation to prevent SQL injection
70+
attacks and allow systematic conversion of Julia types into their SQL
71+
equivalents.
72+
73+
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.)
76+
"""
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
103+
quote
104+
Sql(process_args!([], $(args...)))
105+
end
106+
end
107+
108+
macro sql()
109+
Sql([])
110+
end
111+
112+
function Base.:*(x::Sql, y::Sql)
113+
Sql(vcat(x.args, [Literal(" ")], y.args))
114+
end
115+
116+
function _prepare(query::Sql)
117+
querystr = ""
118+
arg_values = []
119+
i = 1
120+
for arg in query.args
121+
if arg isa Literal
122+
querystr *= arg.fragment
123+
else
124+
querystr *= "\$$i"
125+
push!(arg_values, arg)
126+
i += 1
127+
end
128+
end
129+
querystr, arg_values
130+
end
131+
132+
function Base.show(io::IO, query::Sql)
133+
query, arg_values = _prepare(query)
134+
print(io, query)
135+
if !isempty(arg_values)
136+
args_str = join(["\$$i = $(repr(val))" for (i,val) in enumerate(arg_values)], "\n ")
137+
print(io, "\n ", args_str)
138+
end
139+
end
4140

5141
end

test/runtests.jl

+42-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,47 @@
11
using SqlStrings
22
using Test
3+
using UUIDs
4+
5+
querystr(q) = SqlStrings._prepare(q)[1]
6+
queryargs(q) = SqlStrings._prepare(q)[2]
37

48
@testset "SqlStrings.jl" begin
5-
# Write your tests here.
9+
x = 1
10+
y = 2
11+
q1 = @sql "select a where b=$x and c=$(x+y)"
12+
@test querystr(q1) == raw"select a where b=$1 and c=$2"
13+
@test queryargs(q1) == [x, x+y]
14+
15+
# Test concatenation
16+
q2 = @sql("select a") * @sql("where b=$x")
17+
@test querystr(q2) == raw"select a where b=$1"
18+
@test queryargs(q2) == [x,]
19+
20+
# 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"
25+
@test querystr(q3) == raw"select a where b=$1 and c=$2 "
26+
@test queryargs(q3) == [x, y]
27+
28+
# On occasion, we need to interpolate in a literal string rather than use a
29+
# parameter. Test that interpolating Literal works for this case.
30+
column = "x"
31+
@test querystr(@sql "select $(SqlStrings.Literal(column)) from a") ==
32+
raw"select x from a"
33+
34+
# Test that erroneously adding quoting produces an error message
35+
@test_throws LoadError @macroexpand @sql "select $y where x = '$x'"
36+
37+
# Test splatting syntax
38+
z = [1,"hi"]
39+
q4 = @sql "insert into foo values($(z...))"
40+
@test querystr(q4) == raw"insert into foo values($1,$2)"
41+
@test queryargs(q4) == z
42+
43+
# Test that Literal turns values into strings
44+
@test SqlStrings.Literal(:col_name).fragment == "col_name"
45+
@test SqlStrings.Literal(1).fragment == "1"
646
end
47+

0 commit comments

Comments
 (0)