Skip to content

Add compile time warnings #22

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jul 31, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .iex.exs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@
defmodule SQL.Repo do
use Ecto.Repo, otp_app: :sql, adapter: Ecto.Adapters.Postgres
end
Application.put_env(:sql, :driver, Postgrex)
Application.put_env(:sql, :username, "postgres")
Application.put_env(:sql, :password, "postgres")
Application.put_env(:sql, :hostname, "localhost")
Application.put_env(:sql, :database, "sql_test#{System.get_env("MIX_TEST_PARTITION")}")
Application.put_env(:sql, :adapter, SQL.Adapters.Postgres)
Application.put_env(:sql, :ecto_repos, [SQL.Repo])
Application.put_env(:sql, SQL.Repo, username: "postgres", password: "postgres", hostname: "localhost", database: "sql_test#{System.get_env("MIX_TEST_PARTITION")}", pool: Ecto.Adapters.SQL.Sandbox, pool_size: 10)
Mix.Tasks.Ecto.Create.run(["-r", "SQL.Repo"])
Expand Down
8 changes: 7 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,19 @@
## v0.3.0 (2025-08-01)

### Enhancement
- Improve SQL generation with 57-344x compared to Ecto [#12](https://github.com/elixir-dbvisor/sql/pull/12).
- Improve SQL generation with over 100x compared to Ecto [#12](https://github.com/elixir-dbvisor/sql/pull/12), [#19](https://github.com/elixir-dbvisor/sql/pull/19).
- Fix bug for complex CTE [#15](https://github.com/elixir-dbvisor/sql/pull/15). Thanks to @kafaichoi
- Support for PostgresSQL GiST operators [#18](https://github.com/elixir-dbvisor/sql/pull/18). Thanks to @ibarchenkov
- `float` and `integer` nodes have now become `numeric` with metadata to distinguish `sign`, `whole` and `fractional` [#19](https://github.com/elixir-dbvisor/sql/pull/19).
- `keyword` nodes are now `ident` with metadata distinguish if it's a `keyword` [#19](https://github.com/elixir-dbvisor/sql/pull/19).
- `SQL.Lexer.lex/4` now returns `{:ok, context, tokens}` [#19](https://github.com/elixir-dbvisor/sql/pull/19).
- `SQL.Parser.parse/1` has become `SQL.Parser.parse/2` and takes `tokens` and `context` from `SQL.Lexer.lex/4` and returns `{:ok, context, tokens}` or raises an error [#19](https://github.com/elixir-dbvisor/sql/pull/19).
- Support for compile time warnings on missing relations in a query. [#22](https://github.com/elixir-dbvisor/sql/pull/22)
- `mix sql.get` creates a lock file which are used to generate warnings at compile time. [#22](https://github.com/elixir-dbvisor/sql/pull/22)
- Support SQL formatting. [#22](https://github.com/elixir-dbvisor/sql/pull/22)

### Deprecation
- token_to_string/2 is deprecated in favor of to_iodata/3 [#22](https://github.com/elixir-dbvisor/sql/pull/22).


## v0.2.0 (2025-05-04)
Expand Down
18 changes: 15 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,18 +20,30 @@ iex(1)> email = "[email protected]"
"[email protected]"
iex(2)> ~SQL[from users] |> ~SQL[where email = {{email}}] |> ~SQL"select id, email"
~SQL"""
where email = {{email}} from users select id, email
select
id,
email
from
users
where
email = {{email}}
"""
iex(3)> sql = ~SQL[from users where email = {{email}} select id, email]
~SQL"""
from users where email = {{email}} select id, email
select
id,
email
from
users
where
email = {{email}}
"""
iex(4)> to_sql(sql)
{"select id, email from users where email = ?", ["[email protected]"]}
iex(5)> to_string(sql)
"select id, email from users where email = ?"
iex(6)> inspect(sql)
"~SQL\"\"\"\nfrom users where email = {{email}} select id, email\n\"\"\""
"~SQL\"\"\"\nselect\n id, \n email\nfrom\n users\nwhere\n email = {{email}}\n\"\"\""
```

### Leverage the Enumerable protocol in your repository
Expand Down
2 changes: 1 addition & 1 deletion bench.exs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ sql = ~SQL[with recursive temp (n, fact) as (select 0, 1 union all select n+1, (
query = "temp" |> recursive_ctes(true) |> with_cte("temp", as: ^union_all(select("temp", [t], %{n: 0, fact: 1}), ^where(select("temp", [t], [t.n+1, t.n+1*t.fact]), [t], t.n < 9))) |> select([t], [t.n])
result = Tuple.to_list(SQL.Lexer.lex("with recursive temp (n, fact) as (select 0, 1 union all select n+1, (n+1)*fact from temp where n < 9)"))
tokens = Enum.at(result, -1)
context = Enum.at(result, 1)
context = Map.put(Enum.at(result, 1), :sql_lock, nil)
Benchee.run(
%{
"comptime to_string" => fn _ -> to_string(sql) end,
Expand Down
137 changes: 134 additions & 3 deletions lib/adapters/ansi.ex
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ defmodule SQL.Adapters.ANSI do
"#{mod.token_to_string(value)};"
end
def token_to_string({:comma, _, value}, mod) do
", #{mod.token_to_string(value)}"
"#{mod.token_to_string(value)},"
end
def token_to_string({:dot, _, [left, right]}, mod) do
"#{mod.token_to_string(left)}.#{mod.token_to_string(right)}"
Expand Down Expand Up @@ -108,9 +108,140 @@ defmodule SQL.Adapters.ANSI do
values
|> Enum.reduce([], fn
[], acc -> acc
token, [] -> [mod.token_to_string(token)]
{:comma, _, _} = token, acc -> [acc,mod.token_to_string(token)]
{:comma, _, _} = token, acc -> [acc, mod.token_to_string(token), " "]
token, [] -> mod.token_to_string(token)
token, [_, _, " "] = acc -> [acc, mod.token_to_string(token)]
token, [_, " "] = acc -> [acc, mod.token_to_string(token)]
token, acc -> [acc, " ", mod.token_to_string(token)]
end)
end

@doc false
def to_iodata({tag, _, values}, context, indent) when tag in ~w[inner outer left right full natural cross]a do
[context.module.to_iodata(tag, context, indent),context.module.to_iodata(values, context, indent, [?\s])]
end
def to_iodata({tag, _, values}, context, indent) when tag in ~w[join]a do
v = Enum.reduce(values, [], fn token, acc -> [acc, ?\s, context.module.to_iodata(token, context, indent)] end)

[indention(indent), context.module.to_iodata(tag, context, indent) | [v, ?\n]]
end
def to_iodata({tag, _, values}, context, indent) when tag in ~w[select from join where group having window order limit offset fetch]a do
v = Enum.reduce(values, [?\n], fn token, acc -> [acc, indention(indent+1), context.module.to_iodata(token, context, indent+1), ?\n] end)

[indention(indent), context.module.to_iodata(tag, context, indent) | v]
end
def to_iodata({:as, [], [left, right]}, context, indent) do
[context.module.to_iodata(left, context, indent),?\s|context.module.to_iodata(right, context, indent)]
end
def to_iodata({tag, _, [left]}, context, indent) when tag in ~w[asc desc isnull notnull]a do
[context.module.to_iodata(left, context, indent),?\s|context.module.to_iodata(tag, context, indent)]
end
def to_iodata({:fun, _, [left, right]}, context, indent) do
[context.module.to_iodata(left, context, indent)|context.module.to_iodata(right, context, indent)]
end
def to_iodata({tag, [{:type, :operator}|_], [left, {:paren, _, _} = right]}, context, indent) do
[context.module.to_iodata(left, context, indent),?\s,context.module.to_iodata(tag, context, indent),?\s|context.module.to_iodata(right, context, indent)]
end
def to_iodata({tag, [{:type, :operator}|_], [left, right]}, context, indent) do
[context.module.to_iodata(left, context, indent),?\s,context.module.to_iodata(tag, context, indent),?\s|context.module.to_iodata(right, context, indent)]
end
def to_iodata({:ident, [{:type, :non_reserved},{:tag, tag}|_], [{:paren, _, _} = value]}, context, indent) do
[context.module.to_iodata(tag, context, indent)|context.module.to_iodata(value, context, indent)]
end
def to_iodata({:ident, [{:type, :non_reserved}, {:tag, tag}|_], [{:numeric, _, _} = value]}, context, indent) do
[context.module.to_iodata(tag, context, indent),?\s|context.module.to_iodata(value, context, indent)]
end
def to_iodata({:ident, [{:type, :non_reserved}, {:tag, tag}|_], _}, context, indent) do
context.module.to_iodata(tag, context, indent)
end
def to_iodata({tag, [{:type, :reserved}|_], [{:paren, _, _} = value]}, context, indent) when tag not in ~w[on in select]a do
[context.module.to_iodata(tag, context, indent)|context.module.to_iodata(value, context, indent)]
end
def to_iodata({tag, [{:type, :reserved}|_], []}, context, indent) do
[?\s, context.module.to_iodata(tag, context, indent)]
end
def to_iodata({tag, _, [left, {:all = t, _, right}]}, context, indent) when tag in ~w[union except intersect]a do
[context.module.to_iodata(left, context, indent), indention(indent), context.module.to_iodata(tag, context, indent),?\s,context.module.to_iodata(t, context, indent),?\n|context.module.to_iodata(right, context, indent)]
end
def to_iodata({:between = tag, _, [{:not = t, _, right}, left]}, context, indent) do
[context.module.to_iodata(right, context, indent),?\s,context.module.to_iodata(t, context, indent),?\s,context.module.to_iodata(tag, context, indent),?\s|context.module.to_iodata(left, context, indent)]
end
def to_iodata({:binding, _, [idx]}, %{format: true, binding: binding}, _indent) do
[?{,?{,Macro.to_string(Enum.at(binding, idx-1))|[?},?}]]
end
def to_iodata({:binding, _, _}, _context, _indent) do
[??]
end
def to_iodata({:comment, _, value}, _context, _indent) do
[?-,?-|value]
end
def to_iodata({:comments, _, value}, _context, _indent) do
[?\\,?*,value|[?*, ?\\]]
end
def to_iodata({:double_quote, _, value}=node, context, _indent) do
case node in context.errors do
true -> [[?",:red,value|[:reset, ?"]]]
false -> value
end
end
def to_iodata({:quote, _, value}, _context, _indent) do
[?',value|[?']]
end
def to_iodata({:paren, _, [{_,[{:type, :reserved}|_],_}|_] = value}, context, indent) do
[?(,?\n, context.module.to_iodata(value, context, indent+1)|?)]
end
def to_iodata({:paren, _, value}, context, indent) do
[?(,context.module.to_iodata(value, context, indent)|[?)]]
end
def to_iodata({:bracket, _, value}, context, indent) do
[?[,context.module.to_iodata(value, context, indent)|[?]]]
end
def to_iodata({:colon, _, value}, context, indent) do
[context.module.to_iodata(value, context, indent)|[?;,?\n]]
end
def to_iodata({:comma, _, value}, context, indent) do
[context.module.to_iodata(value, context, indent), ?,, ?\s]
end
def to_iodata({:dot, _, [left, right]}, context, indent) do
[context.module.to_iodata(left, context, indent),?\.|context.module.to_iodata(right, context, indent)]
end
def to_iodata({tag, _, value} = node, context, _indent) when tag in ~w[ident numeric]a do
case node in context.errors do
true -> [:red, value, :reset]
false -> value
end
end
def to_iodata(value, _context, _indent) when is_atom(value) do
~c"#{value}"
end
def to_iodata(value, _context, _indent) when is_binary(value) do
[?',value|[?']]
end
def to_iodata(value, _context, _indent) when is_integer(value) do
[value]
end
def to_iodata(value, _context, _indent) when is_struct(value) do
to_string(value)
end
def to_iodata({tag, _, [left, right]}, context, indent) when tag in ~w[like ilike union except intersect between and or is not in cursor for to]a do
[context.module.to_iodata(left, context, indent),?\s,context.module.to_iodata(tag, context, indent),?\s|context.module.to_iodata(right, context, indent)]
end
def to_iodata({tag, [{:type, :reserved}|_], values}, context, indent) do
[context.module.to_iodata(tag, context, indent),?\s|context.module.to_iodata(values, context, indent)]
end
def to_iodata({tag, [{:type, :non_reserved}|_], values}, context, indent) when tag != :ident do
[context.module.to_iodata(tag, context, indent),?\s|context.module.to_iodata(values, context, indent)]
end
def to_iodata({tag, _, []}, context, indent) do
[indention(indent), context.module.to_iodata(tag, context, indent)]
end
def to_iodata([[{_,_,_}|_]|_]=tokens, context, indent) do
to_iodata(tokens, context, indent, [])
end
def to_iodata([{_,_,_}|_]=tokens, context, indent) do
to_iodata(tokens, context, indent, [])
end
def to_iodata([]=tokens, _context, _indent) do
tokens
end
end
3 changes: 3 additions & 0 deletions lib/adapters/mysql.ex
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,7 @@ defmodule SQL.Adapters.MySQL do
@doc false
def token_to_string(value, mod \\ __MODULE__)
def token_to_string(token, mod), do: SQL.Adapters.ANSI.token_to_string(token, mod)

@doc false
def to_iodata(token, context, indent), do: SQL.Adapters.ANSI.to_iodata(token, context, indent)
end
21 changes: 21 additions & 0 deletions lib/adapters/postgres.ex
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,25 @@ defmodule SQL.Adapters.Postgres do
"#{mod.token_to_string(left)} #{mod.token_to_string(tag)} #{mod.token_to_string(right)}"
end
def token_to_string(token, mod), do: SQL.Adapters.ANSI.token_to_string(token, mod)

@doc false
def to_iodata({:not, _, [left, {:in, _, [{:binding, _, [idx]}]}]}, %{format: true, binding: binding} = context, indent) do
[context.module.to_iodata(left, context, indent), ?\s, ?{,?{,Macro.to_string(Enum.at(binding, idx-1)),?},?}]
end
def to_iodata({:not, _, [left, {:in, _, [{:binding, _, _} = right]}]}, context, indent) do
[context.module.to_iodata(left, context, indent), ?!, ?=, ?A,?N,?Y,?(, context.module.to_iodata(right, context, indent), ?)]
end
def to_iodata({:in, _, [left, {:binding, _, [idx]}]}, %{format: true, binding: binding} = context, indent) do
[context.module.to_iodata(left, context, indent), ?\s, ?{,?{,Macro.to_string(Enum.at(binding, idx-1)),?},?}]
end
def to_iodata({:in, _, [left, {:binding, _, _} = right]}, context, indent) do
[context.module.to_iodata(left, context, indent), ?=, ?A,?N,?Y,?(, context.module.to_iodata(right, context, indent), ?)]
end
def to_iodata({:binding, _, [idx]}, %{format: true, binding: binding}, _indent) do
[?{,?{,Macro.to_string(Enum.at(binding, idx-1))|[?},?}]]
end
def to_iodata({:binding, _, [idx]}, _context, _indent) do
~c"$#{idx}"
end
def to_iodata(token, context, indent), do: SQL.Adapters.ANSI.to_iodata(token, context, indent)
end
7 changes: 7 additions & 0 deletions lib/adapters/tds.ex
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,11 @@ defmodule SQL.Adapters.TDS do
def token_to_string(value, mod \\ __MODULE__)
def token_to_string({:binding, _, [idx]}, _mod) when is_integer(idx), do: "@#{idx}"
def token_to_string(token, mod), do: SQL.Adapters.ANSI.token_to_string(token, mod)

@doc false
def to_iodata({:binding, _, [idx]}, %{format: true, binding: binding}, _indent) do
[?{,?{,Macro.to_string(Enum.at(binding, idx-1))|[?},?}]]
end
def to_iodata({:binding, _, [idx]}, _context, _indent) when is_integer(idx), do: ~c"@#{idx}"
def to_iodata(token, context, indent), do: SQL.Adapters.ANSI.to_iodata(token, context, indent)
end
2 changes: 1 addition & 1 deletion lib/bnf.ex
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ defmodule SQL.BNF do
cond do
String.ends_with?(r, "word>") == true ->
e = if is_map_key(opts, r), do: e ++ opts[r], else: e
{[{r, (for v <- e, v != "|", do: {atom(v), match(v), guard(v)})} | keywords], operators, letters, digits, terminals}
{[{r, (for v <- e, v not in ["|", "AS"], do: {atom(v), match(v), guard(v)})} | keywords], operators, letters, digits, terminals}
String.ends_with?(r, "letter>") == true -> {keywords, operators, [{r, Enum.reject(e, &(&1 == "|"))}|letters], digits, terminals}
String.ends_with?(r, "digit>") == true -> {keywords, operators, letters, [{r, Enum.reject(e, &(&1 == "|"))}|digits], terminals}
String.ends_with?(r, "operator>") == true -> {keywords, [rule | operators], letters, digits, terminals}
Expand Down
2 changes: 1 addition & 1 deletion lib/formatter.ex
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,5 @@ defmodule SQL.MixFormatter do
def features(opts), do: [sigils: [:SQL], extensions: get_in(opts, [:sql, :extensions])]

@impl Mix.Tasks.Format
def format(source, _opts), do: "#{SQL.parse(source)}"
def format(source, _opts), do: SQL.parse(source)
end
Loading
Loading