Skip to content

Commit 852c902

Browse files
authored
add initial SQL parser (#2)
1 parent f096cad commit 852c902

2 files changed

Lines changed: 447 additions & 0 deletions

File tree

lib/bier/query_parser.ex

Lines changed: 355 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,355 @@
1+
defmodule Bier.QueryParser do
2+
@moduledoc """
3+
Parser for SQL queries given via query strings
4+
"""
5+
import NimbleParsec
6+
7+
@space 0x0020
8+
@horizontal_tab 0x0009
9+
10+
# SQL identifiers and key words must begin with a letter (a-z, but also
11+
# letters with diacritical marks and non-Latin letters) or an underscore (_).
12+
# Subsequent characters in an identifier or key word can be letters,
13+
# underscores, digits (0-9), or dollar signs ($). Note that dollar signs are
14+
# not allowed in identifiers according to the letter of the SQL standard, so
15+
# their use might render applications less portable. The SQL standard will
16+
# not define a key word that contains digits or starts or ends with an
17+
# underscore, so identifiers of this form are safe against possible conflict
18+
# with future extensions of the standard.
19+
#
20+
# See: https://www.postgresql.org/docs/9.2/sql-syntax-lexical.html#SQL-SYNTAX-IDENTIFIERS
21+
# FIXME: Complete this based on previous description
22+
identifier =
23+
[?_, ?A..?Z, ?a..?z]
24+
|> ascii_char()
25+
|> repeat(ascii_char([?_, ?a..?z, ?A..?Z, ?0..?9]))
26+
27+
whitespace =
28+
ascii_char([
29+
@horizontal_tab,
30+
@space
31+
])
32+
33+
default_select =
34+
ascii_char([?*])
35+
|> concat(eos())
36+
|> tag(:default)
37+
38+
column_separator = ascii_char([?,])
39+
alias_separator = ascii_char([?:])
40+
cast_separator = times(ascii_char([?:]), 2)
41+
42+
column_alias =
43+
identifier
44+
|> lookahead_not(cast_separator)
45+
|> concat(ignore(alias_separator))
46+
|> tag(:alias)
47+
48+
# TODO: Complete the following list
49+
casting_types =
50+
choice([
51+
string("boolean"),
52+
string("date"),
53+
string("float"),
54+
string("integer"),
55+
string("interval"),
56+
string("text"),
57+
string("timestamp")
58+
])
59+
60+
defp normalize(rest, [casting_type], %{} = context, {_line, _line_offset}, _byte_offset) do
61+
{rest, casting_type |> String.reverse() |> String.to_charlist(), context}
62+
end
63+
64+
column_cast =
65+
cast_separator
66+
|> ignore()
67+
|> concat(casting_types)
68+
|> post_traverse(:normalize)
69+
|> tag(:cast)
70+
71+
column =
72+
column_alias
73+
|> optional()
74+
|> concat(tag(identifier, :name))
75+
|> optional(column_cast)
76+
|> wrap()
77+
78+
detailed_select =
79+
column
80+
|> optional(ignore(optional(column_separator, whitespace)))
81+
|> times(min: 1)
82+
|> eos()
83+
84+
defparsecp(:select, choice([default_select, detailed_select]))
85+
86+
@doc """
87+
Parse the given `select` query string
88+
89+
## Examples
90+
91+
iex> parse_select("*")
92+
{:ok, [default: '*']}
93+
iex> parse_select("first_name,age")
94+
{:ok, [[name: 'first_name'], [name: 'age']]}
95+
iex> parse_select("fullName:full_name,birthDate:birth_date")
96+
{:ok, [[alias: 'fullName', name: 'full_name'], [alias: 'birthDate', name: 'birth_date']]}
97+
iex> parse_select("uno:first::text, dos:second, third, forth::text")
98+
{:ok, [[alias: 'uno', name: 'first', cast: 'text'], [alias: 'dos', name: 'second'], [name: 'third'], [name: 'forth', cast: 'text']]}
99+
"""
100+
@spec parse_select(String.t()) :: {:ok, String.t()} | {:error, String.t()}
101+
def parse_select(select) do
102+
case select(select) do
103+
{:ok, result, _rest = "", _context, _line, _byte_offset} ->
104+
{:ok, result}
105+
106+
{:error, reason, _rest, _contact, _line, _byte_offset} ->
107+
{:error, reason}
108+
end
109+
end
110+
111+
########################
112+
## Horizontal Filters ##
113+
########################
114+
filter_separator = ascii_char([?.])
115+
116+
# TODO: Extract the following into a common combinator
117+
# |> post_traverse(:normalize)
118+
# |> tag(:operator)
119+
# |> ignore(filter_separator)
120+
121+
is_value =
122+
[
123+
string("false") |> replace(false),
124+
string("true") |> replace(true)
125+
]
126+
|> choice()
127+
|> unwrap_and_tag(:value)
128+
129+
is_operator =
130+
string("is")
131+
|> post_traverse(:normalize)
132+
|> tag(:operator)
133+
|> ignore(filter_separator)
134+
|> concat(is_value)
135+
136+
like_value =
137+
[
138+
ascii_char([?*]) |> replace(?%),
139+
ascii_char([])
140+
]
141+
|> choice()
142+
|> repeat()
143+
|> eos()
144+
|> tag(:value)
145+
146+
like_operator =
147+
[
148+
string("like"),
149+
string("ilike")
150+
]
151+
|> choice()
152+
|> post_traverse(:normalize)
153+
|> tag(:operator)
154+
|> ignore(filter_separator)
155+
|> concat(like_value)
156+
157+
rest_value =
158+
ascii_char([])
159+
|> repeat()
160+
|> eos()
161+
|> tag(:value)
162+
163+
rest_operator =
164+
[
165+
string("eq") |> replace("="),
166+
string("gte") |> replace(">="),
167+
string("gt") |> replace(">"),
168+
string("lte") |> replace("<="),
169+
string("lt") |> replace("<"),
170+
string("neq") |> replace("<>"),
171+
string("in")
172+
]
173+
|> choice()
174+
|> post_traverse(:normalize)
175+
|> tag(:operator)
176+
|> ignore(filter_separator)
177+
|> concat(rest_value)
178+
179+
horizontal_filter =
180+
string("not")
181+
|> replace(true)
182+
|> unwrap_and_tag(:negation?)
183+
|> ignore(filter_separator)
184+
|> optional()
185+
|> choice([is_operator, like_operator, rest_operator])
186+
187+
defparsecp(:horizontal_filter, horizontal_filter)
188+
189+
@doc """
190+
Parse the given horizontal filters (rows)
191+
192+
You can filter result rows by filtering conditions on columns.
193+
194+
## Examples
195+
196+
iex> parse_filters(%{age: "lt.13"})
197+
{:ok, [{:age, [negation?: false, operator: '<', value: '13']}]}
198+
iex> parse_filters(%{age: "gt.13"})
199+
{:ok, [{:age, [negation?: false, operator: '>', value: '13']}]}
200+
iex> parse_filters(%{age: "gte.13"})
201+
{:ok, [{:age, [negation?: false, operator: '>=', value: '13']}]}
202+
iex> parse_filters(%{age: "not.gte.13"})
203+
{:ok, [{:age, [negation?: true, operator: '>=', value: '13']}]}
204+
"""
205+
def parse_filters(params) when is_map(params) do
206+
result =
207+
Enum.reduce_while(params, [], fn {field, filter}, acc ->
208+
case horizontal_filter(filter) do
209+
{:ok, parsed, "", %{}, _, _} ->
210+
parsed_filter = Keyword.put_new(parsed, :negation?, false)
211+
{:cont, [{field, parsed_filter} | acc]}
212+
213+
_ ->
214+
{:halt, :bad_request}
215+
end
216+
end)
217+
218+
case result do
219+
:bad_request -> {:error, :bad_request}
220+
result -> {:ok, result}
221+
end
222+
end
223+
224+
defguardp order_direction(direction) when direction in ["asc", "desc"]
225+
defguardp nulls_order(nulls) when nulls in ["nullsfirst", "nullslast"]
226+
227+
@doc """
228+
Parses the given order clause
229+
230+
## Examples
231+
232+
iex> parse_order("")
233+
{:ok, []}
234+
iex> parse_order("age")
235+
{:ok, [{"age", "asc", "nulls last"}]}
236+
iex> parse_order("age.desc,height.asc")
237+
{:ok, [{"height", "asc", "nulls last"}, {"age", "desc", "nulls first"}]}
238+
iex> parse_order("age.nullsfirst")
239+
{:ok, [{"age", "asc", "nulls first"}]}
240+
iex> parse_order("age.desc.nullslast")
241+
{:ok, [{"age", "desc", "nulls last"}]}
242+
iex> parse_order("age.left,height.asc")
243+
{:error, :bad_request}
244+
"""
245+
def parse_order(""), do: {:ok, []}
246+
247+
def parse_order(order) do
248+
result =
249+
order
250+
|> String.split(",")
251+
|> Enum.reduce_while([], fn line, acc ->
252+
case String.split(line, ".", parts: 3) do
253+
[field, direction, nulls] when order_direction(direction) and nulls_order(nulls) ->
254+
{:cont, [{field, direction, transform_nulls(nulls)} | acc]}
255+
256+
[field, direction] when order_direction(direction) ->
257+
{:cont, [{field, direction, default_null_option(direction)} | acc]}
258+
259+
[field, nulls] when nulls_order(nulls) ->
260+
{:cont, [{field, "asc", transform_nulls(nulls)} | acc]}
261+
262+
[field] ->
263+
{:cont, [{field, "asc", "nulls last"} | acc]}
264+
265+
_ ->
266+
{:halt, :bad_request}
267+
end
268+
end)
269+
270+
case result do
271+
:bad_request -> {:error, :bad_request}
272+
result -> {:ok, result}
273+
end
274+
end
275+
276+
defp default_null_option("desc"), do: "nulls first"
277+
defp default_null_option("asc"), do: "nulls last"
278+
279+
defp transform_nulls("nullsfirst"), do: "nulls first"
280+
defp transform_nulls("nullslast"), do: "nulls last"
281+
282+
@doc """
283+
Parse the given limit
284+
285+
## Examples
286+
287+
iex> parse_limit(10)
288+
{:ok, 10}
289+
iex> parse_limit("10")
290+
{:ok, 10}
291+
iex> parse_limit("10.1")
292+
{:error, :bad_request}
293+
iex> parse_limit("0")
294+
{:error, :bad_request}
295+
iex> parse_limit(%{})
296+
{:error, :bad_request}
297+
"""
298+
def parse_limit(limit) when is_integer(limit) and limit > 0, do: {:ok, limit}
299+
300+
def parse_limit(limit) when is_binary(limit) do
301+
case Integer.parse(limit) do
302+
{limit, ""} when limit > 0 -> {:ok, limit}
303+
_ -> {:error, :bad_request}
304+
end
305+
end
306+
307+
def parse_limit(_), do: {:error, :bad_request}
308+
309+
@doc """
310+
Parses request body before querying the database
311+
"""
312+
def parse_request_body(params) when is_list(params) or is_map(params) do
313+
params
314+
|> List.wrap()
315+
|> prepare_params_for_insert()
316+
end
317+
318+
defp prepare_params_for_insert([h | _t] = params) do
319+
keys = Map.keys(h)
320+
321+
result =
322+
Enum.reduce_while(params, [], fn p, acc ->
323+
case prepare_row_for_insert(keys, p) do
324+
{values, map} when map_size(map) == 0 ->
325+
{:cont, [Enum.reverse(values) | acc]}
326+
327+
_ ->
328+
{:halt, :mismatch}
329+
end
330+
end)
331+
332+
case result do
333+
:mismatch ->
334+
{:error, :mismatch}
335+
336+
values ->
337+
{:ok, %{keys: keys, values: values}}
338+
end
339+
end
340+
341+
defp prepare_row_for_insert(keys, row) do
342+
Enum.reduce_while(keys, {[], row}, fn key, {values, map} ->
343+
case Map.pop(map, key) do
344+
{nil, _} ->
345+
{:halt, :mismatch}
346+
347+
{v, updated_map} ->
348+
{:cont, {[prepare_value_for_insert(v) | values], updated_map}}
349+
end
350+
end)
351+
end
352+
353+
defp prepare_value_for_insert(value) when is_binary(value), do: "'#{value}'"
354+
defp prepare_value_for_insert(value), do: value
355+
end

0 commit comments

Comments
 (0)