Current section
Files
Jump to
Current section
Files
lib/ayesql.ex
defmodule AyeSQL do
@moduledoc """
_AyeSQL_ is a library for using raw SQL.
> **Aye** _/ʌɪ/_ _exclamation (archaic dialect)_: said to express assent; yes.
## Overview
Inspired by Clojure library [Yesql](https://github.com/krisajenkins/yesql),
_AyeSQL_ tries to find a middle ground between strings with raw SQL queries and
SQL DSLs by:
- Keeping SQL in SQL files.
- Generating Elixir functions for every query.
- Supporting mandatory and optional named parameters.
- Allowing query composability with ease.
- Working out of the box with PostgreSQL using
[Ecto](https://github.com/elixir-ecto/ecto_sql) or
[Postgrex](https://github.com/elixir-ecto/postgrex):
- Being extended to support other databases using the behaviour
`AyeSQL.Runner`.
## Small Example
Let's say we have a
[SQL query](https://stackoverflow.com/questions/39556763/use-ecto-to-generate-series-in-postgres-and-also-retrieve-null-values-as-0)
to retrieve the click count of a certain type of link every day of the last `X`
days. In raw SQL this could be written as:
```sql
WITH computed_dates AS (
SELECT dates::date AS date
FROM generate_series(
current_date - $1::interval,
current_date - interval '1 day',
interval '1 day'
) AS dates
)
SELECT dates.date AS day, count(clicks.id) AS count
FROM computed_dates AS dates
LEFT JOIN clicks AS clicks ON date(clicks.inserted_at) = dates.date
WHERE clicks.link_id = $2
GROUP BY dates.date
ORDER BY dates.date;
```
The equivalent query in Ecto would be:
```elixir
dates = ~s(
SELECT generate_series(
current_date - ?::interval,
current_date - interval '1 day',
interval '1 day'
)::date AS d
)
from(
c in "clicks",
right_join: day in fragment(dates, ^days),
on: day.d == fragment("date(?)", c.inserted_at),
where: c.link_id = ^link_id
group_by: day.d,
order_by: day.d,
select: %{
day: fragment("date(?)", day.d),
count: count(c.id)
}
)
```
Using fragments can get convoluted and difficult to maintain. In AyeSQL, the
equivalent would be to create an SQL file with the query e.g. `queries.sql`:
```sql
-- name: get_day_interval
SELECT datetime::date AS date
FROM generate_series(
current_date - :days::interval, -- Named parameter :days
current_date - interval '1 day',
interval '1 day'
);
-- name: get_avg_clicks
-- docs: Gets average click count.
WITH computed_dates AS ( :get_day_interval ) -- Composing with another query
SELECT dates.date AS day, count(clicks.id) AS count
FROM computed_date AS dates
LEFT JOIN clicks AS clicks ON date(clicks.inserted_at) = dates.date
WHERE clicks.link_id = :link_id -- Named parameter :link_id
GROUP BY dates.date
ORDER BY dates.date;
```
In Elixir, we would load all the queries in this file by creating the following
module:
```elixir
defmodule Queries do
use AyeSQL, repo: MyRepo
defqueries("queries.sql") # File name with relative path to SQL file.
end
```
or using the macro `defqueries/3`:
```elixir
import AyeSQL, only: [defqueries: 3]
defqueries(Queries, "queries.sql", repo: MyRepo)
```
Both approaches will create a module called `Queries` with all the queries
defined in `queries.sql`.
And then we could execute the query as follows:
```elixir
iex> params = [
...> link_id: 42,
...> days: %Postgrex.Interval{secs: 864_000} # 10 days
...> ]
iex> Queries.get_avg_clicks(params)
{:ok,
[
%{day: ..., count: ...},
%{day: ..., count: ...},
%{day: ..., count: ...},
...
]
}
```
AyeSQL also allows you to choose the type of returned data structures.
Instead of the default map you can also pass an `into` option to your query
possible values are:
- an empty map: `Map.new()` or `%{}`
- an empty list: `Keyword.new()` or `[]`
- a struct
- `:raw` which returns the unmodified Postgrex result
```elixir
iex> Queries.get_avg_clicks(params, into: [])
{:ok,
[
[day: ..., count: ...],
[day: ..., count: ...],
[day: ..., count: ...],
...
]
}
```
```elixir
iex> defmodule AvgClicks do defstruct [:day, :count] end
iex> Queries.get_avg_clicks(params, into: AvgClicks)
{:ok,
[
%AvgClicks{day: ..., count: ...},
%AvgClicks{day: ..., count: ...},
%AvgClicks{day: ..., count: ...},
...
]
}
```
"""
alias AyeSQL.Compiler
alias AyeSQL.Query
@doc """
Uses `AyeSQL` for loading queries.
By default, supports the option `runner` (see `AyeSQL.Runner` behaviour).
Any other option will be passed to the runner.
"""
@spec __using__(keyword()) :: Macro.t()
defmacro __using__(options) do
{db_runner, db_options} = Keyword.pop(options, :runner, AyeSQL.Runner.Ecto)
quote do
import AyeSQL, only: [defqueries: 1]
@__db_runner__ unquote(db_runner)
@__db_options__ unquote(db_options)
@doc """
Runs the `query`. On error, fails.
"""
@spec run!(Query.t()) :: term() | no_return()
@spec run!(Query.t(), keyword()) :: term() | no_return()
def run!(query, options \\ [])
def run!(query, options) do
case run(query, options) do
{:ok, result} ->
result
{:error, reason} ->
raise RuntimeError, message: reason
end
end
@doc """
Runs the `query`.
"""
@spec run(Query.t()) :: {:ok, term()} | {:error, term()}
@spec run(Query.t(), keyword()) :: {:ok, term()} | {:error, term()}
def run(query, options \\ [])
def run(%Query{} = query, options) do
AyeSQL.run(@__db_runner__, query, options)
end
########################
# Helpers for inspection
@doc false
@spec __db_runner__() :: module()
def __db_runner__, do: @__db_runner__
@doc false
@spec __db_options__() :: term()
def __db_options__, do: @__db_options__
end
end
@doc """
Evaluates the `contents` of a string with a query and generates an anonyous
function that receives parameters and options.
"""
@spec eval_query(binary()) ::
(AyeSQL.Core.parameters(), AyeSQL.Core.options() ->
{:ok, AyeSQL.Query.t() | term()}
| {:error, AyeSQL.Error.t() | term()})
| no_return()
@spec eval_query(binary(), AyeSQL.Lexer.options()) ::
(AyeSQL.Core.parameters(), AyeSQL.Core.options() ->
{:ok, AyeSQL.Query.t() | term()}
| {:error, AyeSQL.Error.t() | term()})
| no_return()
defdelegate eval_query(contents, options \\ []), to: AyeSQL.Compiler
# Runs a `stmt` with some `args` in an `app`.
@doc false
@spec run(module(), Query.t(), keyword()) ::
{:ok, term()} | {:error, term()}
def run(module, query, options)
def run(module, %Query{} = query, options) do
module.run(query, options)
end
@doc """
Macro to load queries from a `file`.
Let's say we have the file `lib/sql/queries.sql` with the following contents:
```sql
-- name: get_user
-- docs: Gets user by username
SELECT *
FROM users
WHERE username = :username;
```
Then we can load our queries to Elixir using the macro `defqueries/1`:
```
# file: lib/queries.ex
defmodule Queries do
use AyeSQL, repo: MyRepo
defqueries("sql/queries.sql")
end
```
## Multi-file Support
You can load queries from multiple files using a list or glob patterns:
```
# file: lib/queries.ex
defmodule Queries do
use AyeSQL, repo: MyRepo
# List of files
defqueries(["sql/users.sql", "sql/posts.sql", "sql/comments.sql"])
# Or using glob patterns
defqueries("sql/**/*.sql")
end
```
**Important notes for multi-file usage:**
- Files matched by glob patterns are processed in alphabetical order
- All query names must be unique across all files
- Each file is tracked as an `@external_resource` for recompilation
- Queries can reference other queries from any file using `:query_name` syntax
or the macro `defqueries/3`:
```
# file: lib/queries.ex
import AyeSQL, only: [defqueries: 3]
defqueries(Queries, "sql/queries.ex", repo: MyRepo)
# Multi-file examples
defqueries(Queries, ["sql/users.sql", "sql/posts.sql"], repo: MyRepo)
defqueries(Queries, "sql/**/*.sql", repo: MyRepo)
```
And finally we can inspect the query:
```
iex(1)> Queries.get_user(username: "some_user", run: false)
{:ok,
%AyeSQL.Query{
statement: "SELECT * FROM user WHERE username = $1",
arguments: ["some_user"]
}
}
```
or run it:
```
iex(1)> Queries.get_user(username: "some_user")
{:ok,
[
%{username: ..., ...}
]
}
```
"""
@spec defqueries(Path.t() | [Path.t()]) :: [Macro.t()]
defmacro defqueries(path_or_paths) do
dirname = Path.dirname(__CALLER__.file)
# Resolve to list of absolute paths
files = resolve_files(dirname, path_or_paths)
# Load all files
contents_with_metadata = load_files(files)
# Check for duplicates
check_duplicates!(contents_with_metadata)
# Merge contents
combined_contents = merge_contents(contents_with_metadata)
# Track all files as external resources
external_resources =
Enum.map(files, fn file ->
quote(do: @external_resource(unquote(file)))
end)
# Compile queries
compiled = Compiler.compile_queries(combined_contents)
# Return both external resources and compiled queries
external_resources ++ [compiled]
end
@doc """
Macro to load queries from one or more files and create a module for them.
Same as `defqueries/1`, but creates a module e.g for the query file
`lib/sql/queries.sql` we can use this macro as follows:
```
# file: lib/queries.ex
import AyeSQL, only: [defqueries: 3]
defqueries(Queries, "sql/queries.sql", repo: MyRepo)
```
## Multi-file Support
You can also load from multiple files or glob patterns:
```
# List of files
defqueries(Queries, ["sql/users.sql", "sql/posts.sql"], repo: MyRepo)
# Glob pattern
defqueries(Queries, "sql/**/*.sql", repo: MyRepo)
```
This will generate the module `Queries` and it'll contain all the SQL
statements included in the specified file(s). See `defqueries/1` for more
details on multi-file behavior.
"""
@spec defqueries(module(), Path.t() | [Path.t()], keyword()) :: Macro.t()
defmacro defqueries(module, path_or_paths, options) do
quote do
defmodule unquote(module) do
@moduledoc """
This module defines functions for queries in `#{unquote(path_or_paths)}`
"""
use AyeSQL, unquote(options)
defqueries(unquote(path_or_paths))
end
end
end
##################
# Helper functions
# Resolves input to list of absolute file paths
@spec resolve_files(Path.t(), Path.t() | [Path.t()]) :: [Path.t()]
defp resolve_files(dirname, path) when is_binary(path) do
if String.contains?(path, ["*", "?", "["]) do
expand_glob(dirname, path)
else
[Path.expand("#{dirname}/#{path}")]
end
end
defp resolve_files(dirname, paths) when is_list(paths) do
paths
|> Stream.map(&Path.expand("#{dirname}/#{&1}"))
|> Enum.sort()
end
# Expands glob pattern to sorted list
@spec expand_glob(Path.t(), Path.t()) :: [Path.t()]
defp expand_glob(dirname, pattern) do
full_pattern = "#{dirname}/#{pattern}"
case Path.wildcard(full_pattern) do
[] ->
raise AyeSQL.CompileError,
contents: "",
line: 1,
header: "No files matched pattern: #{pattern}",
filename: "defqueries"
files ->
Enum.sort(files)
end
end
# Loads all files with metadata
@spec load_files([Path.t()]) :: [{Path.t(), binary()}]
defp load_files([]) do
raise AyeSQL.CompileError,
contents: "",
line: 1,
header: "No files provided to defqueries",
filename: "defqueries"
end
defp load_files(files) do
Enum.map(files, fn file ->
{file, File.read!(file)}
end)
end
# Merges contents from multiple files
@spec merge_contents([{Path.t(), binary()}]) :: binary()
defp merge_contents(contents_with_metadata) do
contents_with_metadata
|> Stream.map(fn {_file, contents} -> contents end)
|> Enum.join("\n\n")
end
# Checks for duplicate query names
@spec check_duplicates!([{Path.t(), binary()}]) :: :ok
defp check_duplicates!(contents_with_metadata) do
name_to_files =
contents_with_metadata
|> Stream.flat_map(fn {file, contents} ->
contents
|> extract_query_names()
|> Enum.map(fn name -> {name, file} end)
end)
|> Enum.group_by(&elem(&1, 0), &elem(&1, 1))
duplicates =
name_to_files
|> Stream.filter(fn {_name, files} -> length(files) > 1 end)
|> Enum.into(%{})
unless Enum.empty?(duplicates) do
raise_duplicate_error!(duplicates)
end
:ok
end
# Extracts query names from SQL contents
@spec extract_query_names(binary()) :: [atom()]
defp extract_query_names(contents) do
case AyeSQL.Lexer.tokenize(contents) |> :ayesql_parser.parse() do
{:ok, queries} ->
queries
|> Stream.map(fn {name, _docs, _fragments} -> name end)
|> Enum.reject(&is_nil/1)
{:error, _} ->
[]
end
end
# Raises error for duplicates
@spec raise_duplicate_error!(%{atom() => [Path.t()]}) :: no_return()
defp raise_duplicate_error!(duplicates) do
details =
duplicates
|> Stream.map(fn {name, files} ->
file_list = Enum.map_join(files, ", ", &Path.basename/1)
" - #{name}: found in #{file_list}"
end)
|> Enum.join("\n")
raise AyeSQL.CompileError,
contents: "",
line: 1,
header: """
Duplicate query names found across multiple files:
#{details}
Each query name must be unique across all loaded files.
""",
filename: "defqueries"
end
end