SWAFQueries (swaf_queries v0.2.0)
SWAFQueries is a thin layer on top of Ecto and MyXQL driver that compiles raw SQL files into Elixir functions.
Overview
When used in a source file with a call to the __using__ macro,
SWAFQueries searches for .sql files in a specific
directory. Each .sql file is a regular SQL file with some meta
information (contained in SQL comments, e.g.: lines stating with
--) so that SWAFQueries will be able to create and compile every
query present in the file into one or several Elixir function. All
functions will belong to a module named after the name of the .sql
file. Comments on top of the file will be treated as module's
documentation. Comments before each query will be treated as
function's documentation.
Quick start
Add the dependencies
To your application's mix.exs file add the following dependencies:
defp deps do
[
{:swaf_queries, "~> 0.1"},
{:myxql, "~> 0.8.0"},
{:ecto_sql, "~> 3.0"},,
{:ex_doc, "~> 0.34", only: :dev, runtime: false},
]
end:ex_doc is optional but as SWAFQueries generates Elixir
documentation from SQL comments, it can be pretty useful.
Configure the database connection
First of all, we need a running MariaDB server. For development and tests (and only for those purposes), I use the following snippet to get a server up and running within seconds:
DBIMG="mariadb:10.10-jammy"
DB_NAME="my_app"
docker run -i --rm --name ${DB_NAME}_db \
-v "$(pwd)/${DB_NAME}_data:/var/lib/mysql/" \
-p "3306:3306" \
-e MARIADB_DATABASE=${DB_NAME}_db \
-e MARIADB_USER=${DB_NAME}_user \
-e MARIADB_PASSWORD=${DB_NAME}_pass \
-e MARIADB_ROOT_PASSWORD=${DB_NAME}_root_password \
-d ${DBIMG}
Then use mycli to access the database in raw SQL (but any client will
do the work):
mycli \
-h 127.0.0.1 \
-u ${DB_NAME}_user --password=${DB_NAME}_pass \
${DB_NAME}_db
Now that we have a running database server, edit the file
config.exs and add the following parameters. These are the usual
Ecto configuration parameters:
config :my_app, MyApp.Repo,
adapter: Ecto.Adapters.MyXQL,
database: "my_app_db",
username: "my_app_user",
password: "my_app_pass",
hostname: "127.0.0.1",
pool_size: 2,
port: 3306Create a repo module
As SWAFQueries uses Ecto.Repo, we need to define a repo.ex
file which will simply contain the following:
defmodule MyApp.Repo do
use Ecto.Repo,
otp_app: :my_app,
adapter: Ecto.Adapters.MyXQL
endCreate a queries module
MyApp.Queries will be the root module for all the sub-modules with
the query functions. SWAFQueries will also inject in
MyApp.Queries some utility functions that are used internally but
that may also be used by the application.
defmodule MyApp.Queries do
use SWAFQueries,
sql_dir: "sql",
repo_module: MyApp.Repo
endBoth parameters are mandatory:
sql_dir:defines where the.sqlfiles are located. As defined above, it will be in the root directory of the project;repo_module:is the name of the module we have just define in the previous section.
Start the connection in the supervision tree
With Ecto, starting the connection means starting the repo
module. In file lib/my_app/application.ex, add:
def start(_type, _args) do
children = [
MyApp.Repo # <<< This line
]Add SQL files to the SQL directory
Provided that you have a user table with a column names id,
create the file sql/user.sql containing the following SQL code:
--- Queries to manage informations in the `user` table.
-- defq: get_user_by_id map: raw:
-- Searches the database for a user with `id` equal to `:id`
SELECT * FROM user WHERE id = :id
-- defq: get_users map: raw:
SELECT * FROM userCompile and execute
Compile and run the REPL with iex -S mix. You'll the following output:
Erlang/OTP 27 [erts-15.2] [source] [64-bit] [smp:12:12] [ds:12:12:10] [async-threads:1] [jit:ns]
[...]
-- Counted 2 SQL files to compile from directory 'sql'
-- Compiling queries from file 'sql/user.sql'
-- Generating function 'get_users/0'
-- Generating function 'get_users_m/0'
-- Generating function 'get_user_by_id_m/1'
Generated test_app app
Interactive Elixir (1.18.1) - press Ctrl+C to exit (type h() ENTER for help)You can now try the following:
iex(2)> MyApp.Queries.User.get_users_m
{:ok,
[
%{
"created_at" => ~U[2025-10-11 10:32:32Z],
"email" => "pierre@email.com",
"id" => 1,
"name" => "pierre",
"updated_at" => ~U[2025-10-11 10:32:32Z]
},
%{
"created_at" => ~U[2025-10-11 10:32:32Z],
"email" => "paul@email.com",
"id" => 2,
"name" => "paul",
"updated_at" => ~U[2025-10-11 10:32:32Z]
}
]}or
iex(3)> h MyApp.Queries.User.get_users_m
def get_users_m()
Returns a list of maps which represent records.
## SQL query sent to the server
SELECT * FROM user
## Returns
• {:ok, []} in case of no rows.
• {:ok, list_of_maps} where list_of_maps is a list where every element is
a map representing a record from the database. Keys of the maps are
strings.
• {:error, reason} in case of error.Format of the SQL files
Even if the .sql files are regular SQL files, they need to respect
a simple format to be understood by SWAFQueries. They basically
have two sections:
The first section is whatever number of SQL comment lines with markdown text. All those comments will be gathered and added to the module's documentation. This section can be empty. From the above example, this section corresponds to:
--- Queries to manage informations in the `user` table.The second section is a list of one or more query definitions. Each definition may have two or three parts:
One line SQL comment starting with
-- defq:. This line defines the base name of the functions to be created for the query that will be defined after. Thedefq:keyword may be followed by some hints tellingSWAFQuerieswhat kind of function to define. We'll see the details below. This line is obviously mandatory. From the above example, this part corresponds to:-- defq: get_user_by_id map: raw:The detailed format for this line is described below
The second part is optional. It may contain any number of SQL comment lines with markdown text. All those comments will be gathered and added to the function's documentation. From the above example, this part corresponds to:
-- Searches the database for a user with `id` equal to `:id`Finally comes the SQL code for the query. The SQL code can be split on any number of lines but must contain only one SQL statement (See the discussion in the section dedicated to transactions). This part is obviously mandatory. From the above example, this part corresponds to:
SELECT * FROM user WHERE id = :id
Few things to notice:
Comments are regular Elixir documentation strings except that each line has to be prefixed with
--to also be a valid SQL comment. For example, a multi-line documentation with a list will look like this:-- This comment will be put in a documentation string. -- -- This is the second paragraph of the documentation. Let's start make a list: -- * first item -- * second itemThe only exception to SQL validity is the part
id = :id.idis the name of a column, but:idis an identifier that will be used bySWAFQueriesto create the parameter list of the function.
Query functions generation
Kind of generated functions
As stated previously, each query is introduced by a line with the
defq: keyword which may be followed by some hints telling
SWAFQueries what kind of function to define. The kind of
function is basically what it returns and in which format. Hints are
"commutable", meaning that one can declare multiple hints for the
same query. In this case multiple functions will be generated. The
hits are:
without any hint (
--defq: <query_name>) or with theraw:hint (--defq: <query_name> raw:)SWAFQuerieswill generate the function named<query_name>(...)(well discuss the arity in the next section). This function will return the row result of the execution of the query:{:ok, %MyXQL.Result{ ... }}on success{:error, %MyXQL.Error{ ... }}on error
the
map:hint (--defq: <query_name> map:) will makeSWAFQueriesgenerate the function named<query_name>(...)_m(notice the_m) that returns the result of the query as a list of maps, each map being a record from the database:{:ok, []}in case of no record.{:ok, list_of_maps}wherelist_of_mapsis a list where every element is a map representing a record from the database. Keys of the maps are strings.{:error, reason}in case of error.
The
map:hint is obviously useful for read-type queries withSELECT.For example:
iex(12)> MyApp.Queries.User.get_users_m() {:ok [ %{ "email" => "pierre@email.com", "id" => 1, "name" => "pierre", }, %{ "email" => "bjoorn@email.com", "id" => 2, "name" => "bjoorn", } ] }the
stream:hint will makeSWAFQueriesgenerate the function named<query_name>(...)_sthat returns the result of the query as a stream. The behaviour is the same as for themap:hint.stream:is intended for read-type queries with a huge amount of data returned.Notice that
stream:is not implemented yet. If you use it or if you useis_read:, a warning will be generated during query compilation.the
result:hint (--defq: <query_name> result:) will makeSWAFQueriesgenerate the function named<query_name>(...)_rwhich returns a selected subset of information contained in the row query result. This hint is indented to be used with write-type queries likeDELETE,INSERTorUPDATE. Returned values are:{:ok, %{last_insert_id: id, num_rows: nb}}where:last_insert_idis the ID of the last inserted elementnum_rowsis the number of rows affected by the query
{:error, %{message: message, statement: statement}}where:messageis the SQL error messagestatementis the SQL statement which had been sent to the servererrcodeis the error code returned by the database server
One can declare several hints for one function, which makes
SWAFQueries generate all corresponding functions. For example, the
definition
-- defq: get_users map: result: raw:will generate the following functions:
get_users(...)
get_users_m(...)
get_users_r(...)Arity of the generated functions
The arity of the generated functions is dependant on the SQL query. Basically, every parameter used in a query, will be considered as a parameter to the functions.
Here are some examples:
SELECT * FROM user: no parameter used here. The generated functions will have arity equal to zero.For example:
get_users().SELECT * FROM user WHERE id = :id: one parameter used,:id. The generated functions will have arity equal to one;For example:
get_user_by_id(id).SELECT * FROM user WHERE id = :id AND age > :age: two parameters used,:idand:age. The generated functions will have arity equal to two.For example:
get_user_by_id_and_age(id, age).SELECT user.name, address.town FROM user, address WHERE user.id = :id AND address.user_id = :id: one parameter used twice. Even if the parameter:idis used twice, the generated functions will have arity equal to one.For example:
get_user_name_and_town_by_id(id).
Remember that Elixir is your friend in terms of documentation,
either in the REPL or if you generate it with mix docs.
Transactions and multi-statement calls
At the moment, it is not possible to define transactions in a .sql
file as this will generate multiple statements in a single call
which the MyXQL driver does not allow. There a connection option,
multiple_statements: which can be set to true but its purpose is
limited and it does not work in our situation.
A query definition like:
-- defq: create_user_and_address result:
START TRANSACTION;
INSERT INTO user (name) VALUES (:name);
SET @last_user_id = LAST_INSERT_ID();
INSERT INTO address (user_id, town) VALUES (@last_user_id, :town);
COMMIT;will compile and a the function create_user_and_address/2 will be
create, but it will produce an error (output reformatted for
readability):
iex(5)> MyApp.Queries.User.create_user_and_address_r("maurycy", "Paris")
{:error,
%{
message: "You have an error in your SQL syntax; check the manual that corresponds to
your MariaDB server version for the right syntax to use near:
'INSERT INTO user (name) VALUES ( ? ); SET @last_user_id = LAST_INSERT_ID(); I...' at line 1",
statement: " START TRANSACTION; INSERT INTO user (name) VALUES ( ? );
SET @last_user_id = LAST_INSERT_ID();
INSERT INTO address (user_id, town) VALUES (@last_user_id, ? ); COMMIT;",
errcode: 1064
}}The actual solution is to use the transaction/2 function injected
in the root module of the application (MyApp.Queries):
# for example in queries.ex file but it could be anywhere else
alias MyApp.Queries.User
def add_user(name, email, town) do
transaction(fn ->
with
{:ok, res0} <- User.insert_user_r(name, email),
{:ok, _} <-User.insert_user_address_r(res0.last_insert_id, town) do
{:ok, res0}
end
end, [timeout: 2_000])
endThen, the call to MyAppApp.Queries.add_user("maurycy", "maurycy@email", "Paris") the first time will generate the output
{:ok, %{last_insert_id: 69, num_rows: 1}}But a second call (and provided that there is a uniqueness constraint
on the email) will produce the following error (which hopefully is
expected):
{:error,
%{
message: "Duplicate entry 'maurycy@email' for key 'email'",
statement: " INSERT INTO user (name, email) VALUES ( ? , ? )",
errcode: 1062
}}See the documentation of transaction/2 for more details.
Functions injected in queries root module
SWAFQueries injects a handful of functions to the root module it is
called from (e.g. MyApp.Queries). They are all documented if you
run mix docs. Quickly:
query(sql_request, values): executes the SQL request with the parameters from thevalueslist. Returns{:ok, %MyXQL.Result{ ... }}on success{:error, %MyXQL.Error{ ... }}on error
query!(sql_request, values): same asquery/2but returns%MyXQL.Result{ ... }or raises an error on invalid queries.transaction(fun, opts \ []): executes the functionfun/0in a transaction or rollbacks it upon errorrollback(value): Provokes a rollback of the current transaction and returnsvalueto_map(rows, cols): Zips a list of names of columns with a list of list of rows into a list of maps.
SQL code recompilation
Working in the REPL and recompiling the modified files of a project
from within the REPL is really comfortable. SWAFQueries tries to
make it possible by setting the @external_resource "<file.sql>"
attribute when creating the corresponding module. However, for a
newly created .sql file, the compiler has no way to know that this
file exists before its first compilation. The rule of thumbs is:
- If a
.sqlfile that had already been compiled is modified, arecompilein the REPL will recompile it - If it's a newly created file, we need either to exit the repl and
re-enter or to touch another
.sqlso that all.sqlfiles will get recompiled.