Ayesql
Library for using raw SQL in Elixir
Install / Use
/learn @alexdesousa/AyesqlREADME
AyeSQL
Aye /ʌɪ/ exclamation (archaic dialect): said to express assent; yes.
AyeSQL is a library for using raw SQL.
Overview
Inspired by Clojure library Yesql, AyeSQL tries to find a middle ground between strings with raw SQL queries and SQL DSLs. This library aims to:
- Keep SQL in SQL files.
- Generate easy to use Elixir functions for every query.
- Parameterize queries using maps and keyword lists.
- Allow query composablity.
- Support loading from multiple files or glob patterns.
- Work out-of-the-box with PostgreSQL using Ecto or Postgrex.
- Work out-of-the-box with DuckDB using Duckdbex.
If you want to know more about AyeSQL:
And the following additional links provide more information about the library:
- Full Documentation
- AyeSQL: Writing Raw SQL in Elixir
- Why raw SQL?
- Dynamic queries with EEx
- Adding support to other databases
Small Example
In AyeSQL, the equivalent would be to create an SQL file with the query e.g.
queries.sql:
-- file: queries.sql
-- name: get_avg_clicks
-- docs: Gets average click count.
WITH computed_dates AS (
SELECT datetime::date AS date
FROM generate_series(
current_date - :days::interval, -- Named parameter :days
current_date - interval '1 day',
interval '1 day'
)
)
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:
# file: lib/queries.ex
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:
# file: lib/queries.ex
import AyeSQL, only: [defqueries: 3]
defqueries(Queries, "queries.sql", repo: MyRepo)
Note: The file name used in
defqueriesmacro should be relative to the file where the macro is used.
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:
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: ...},
...
]
}
Multi-file Support
For larger projects, you can organize your SQL queries across multiple files and load them all at once. AyeSQL supports both explicit file lists and glob patterns.
Loading Multiple Files
You can pass a list of file paths to defqueries:
defmodule MyApp.Queries do
use AyeSQL, repo: MyApp.Repo
defqueries([
"sql/users.sql",
"sql/posts.sql",
"sql/comments.sql"
])
end
Using Glob Patterns
Or use glob patterns to automatically load all matching files:
defmodule MyApp.Queries do
use AyeSQL, repo: MyApp.Repo
# Load all .sql files in the sql/ directory and subdirectories
defqueries("sql/**/*.sql")
end
This is particularly useful for organizing queries by domain or feature:
sql/
├── orders/
│ ├── reads.sql
│ └── writes.sql
├── clients/
│ └── clients.sql
└── payments/
└── payments.sql
Multi-file Behavior
When using multiple files:
-
Alphabetical ordering: Files are processed in alphabetical order by their full path, ensuring deterministic and predictable behavior.
-
Unique query names: All query names must be unique across all loaded files. If duplicate names are found, a compile-time error will be raised with details about which files contain the duplicates.
-
Cross-file composition: Queries can reference other queries from any loaded file using the
:query_namesyntax, enabling composition across your entire query library. -
Recompilation tracking: Each file is registered as an
@external_resource, so your module will automatically recompile when any SQL file changes.
Example with the defqueries/3 Macro
The standalone macro also supports multi-file loading:
import AyeSQL, only: [defqueries: 3]
# List of files
defqueries(Queries, ["sql/users.sql", "sql/posts.sql"], repo: MyRepo)
# Glob pattern
defqueries(Queries, "sql/**/*.sql", repo: MyRepo)
Syntax
An SQL file can have as many queries as you want as long as they are named.
For the following sections we'll assume we have:
-
lib/my_repo.exwhich is anEctorepo calledMyRepo. -
lib/queries.sqlwith SQL queries. -
lib/queries.exwith the following structure:import AyeSQL, only: [defqueries: 3] defqueries(Queries, "queries.sql", repo: MyRepo)
Naming Queries
For naming queries, we add a comment with the keyword -- name: followed by
the name of the function e.g the following query would generate the function
Queries.get_hostnames/2:
-- name: get_hostnames
SELECT hostname FROM server
Additionally, we could also add documentation for the query by adding a comment
with the keyword -- docs: followed by the query's documentation e.g:
-- name: get_hostnames
-- docs: Gets hostnames from the servers.
SELECT hostname FROM server
Important: if the function does not have
-- docs:it won't have documentation e.g.@doc false.
Parameters
There are two types of parameters:
- Mandatory: for passing parameters to a query. They start with
:e.g.:hostname. - Optional: for query composability. They start with
:_e.g.:_order_by.
Additionally, any query in a file can be accessed with its name adding : at
the front e.g :get_hostnames.
Mandatory Parameters
Let's say we want to get the name of an operative system by architecture:
-- name: get_os_by_architecture
-- docs: Gets operative system's name by a given architecture.
SELECT name
FROM operative_system
WHERE architecture = :architecture
The previous query would generate the function
Queries.get_os_by_architecture/2 that can be called as:
iex> Queries.get_os_by_architecture(architecture: "AMD64")
{:ok,
[
%{name: "Debian Buster"},
%{name: "Windows 10"},
...
]
}
Query Composition
Now if we would like to get hostnames by architecture we could compose queries by doing the following:
-- name: get_os_by_architecture
-- docs: Gets operative system's name by a given architecture.
SELECT name
FROM operative_system
WHERE architecture = :architecture
-- name: get_hostnames_by_architecture
-- docs: Gets hostnames by architecture.
SELECT hostname
FROM servers
WHERE os_name IN ( :get_os_by_architecture )
The previous query would generate the function
Queries.get_hostnames_by_architecture/2 that can be called as:
iex> Queries.get_hostnames_by_architecture(architecture: "AMD64")
{:ok,
[
%{hostname: "server0"},
%{hostname: "server1"},
...
]
}
Optional Fragments
Let's say that now we need to order ascending or descending by hostname by
using an optional :_order_by parameter e.g:
-- name: get_os_by_architecture
-- docs: Gets operative system's name by a given architecture.
SELECT name
FROM operative_system
WHERE architecture = :architecture
-- name: get_hostnames_by_architecture
-- docs: Gets hostnames by architecture.
SELECT hostname
FROM servers
WHERE os_name IN ( :get_os_by_architecture )
:_order_by
-- name: ascending
ORDER BY hostname ASC
-- name: descending
ORDER BY hostname DESC
The previous query could be called as before:
iex> Queries.get_hostnames_by_architecture(architecture: "AMD64")
{:ok,
[
%{hostname: "Barcelona"},
%{hostname: "Granada"},
%{hostname: "Madrid"},
...
]
}
or by order ascending:
iex> params = [architecture: "AMD64", _order_by: :ascending]
iex> Queries.get_hostnames_by_architecture(params)
{:ok,
[
%{hostname: "Barcelona"},
%{hostname: "Madrid"},
%{hostname: "Granada"},
...
]
}
or descending:
iex> params = [architecture: "AMD64", _order_by: :descending]
iex> Queries.get_hostnames_by_architecture(params)
{:ok,
[
%{hostname: "Zaragoza"},
%{hostname: "Madrid"},
%{hostname: "Granada"},
...
]
}
Important: A query can be called by name e.g.
:descendingif it's defined in the same SQL file. Otherwise, we need to pass the function instead e.g.Queries.descending/2iex> params = [architecture: "AMD64", _order_by: &
