Kura

Kura

Database layer for Erlang - Ecto-equivalent abstractions in pure Erlang. Pluggable backends: kura_postgres (PostgreSQL via pgo), kura_sqlite (SQLite via esqlite).

Features

Quick Start

Define a Schema

-module(user).
-behaviour(kura_schema).
-include_lib("kura/include/kura.hrl").
-export([table/0, fields/0]).
table() -> ~"users".
fields() ->
[
#kura_field{name = id, type = id, primary_key = true, nullable = false},
#kura_field{name = name, type = string, nullable = false},
#kura_field{name = email, type = string, nullable = false},
#kura_field{name = age, type = integer},
#kura_field{name = inserted_at, type = utc_datetime},
#kura_field{name = updated_at, type = utc_datetime}
].

Define a Repo

-module(my_repo).
-behaviour(kura_repo).
-export([otp_app/0, start/0, all/1, get/2, insert/1, update/1, delete/1]).
otp_app() -> my_app.
start() -> kura_repo_worker:start(?MODULE).
all(Q) -> kura_repo_worker:all(?MODULE, Q).
get(Schema, Id) -> kura_repo_worker:get(?MODULE, Schema, Id).
insert(CS) -> kura_repo_worker:insert(?MODULE, CS).
update(CS) -> kura_repo_worker:update(?MODULE, CS).
delete(CS) -> kura_repo_worker:delete(?MODULE, CS).

Configure the database connection in sys.config:

[{my_app, [
{my_repo, #{
database => ~"myapp",
hostname => ~"localhost",
port => 5432,
username => ~"postgres",
password => <<>>,
pool_size => 10
}}
]}].

Changesets

%% Cast and validate external params
CS = kura_changeset:cast(user, #{}, #{~"name" => ~"Alice", ~"email" => ~"alice@example.com"}, [name, email, age]),
CS1 = kura_changeset:validate_required(CS, [name, email]),
CS2 = kura_changeset:validate_format(CS1, email, ~"@"),
CS3 = kura_changeset:validate_length(CS2, name, [{min, 1}, {max, 100}]),
%% Insert
{ok, User} = my_repo:insert(CS3).

Query Builder

Q = kura_query:from(user),
Q1 = kura_query:where(Q, {age, '>', 18}),
Q2 = kura_query:where(Q1, {'or', [{role, ~"admin"}, {role, ~"moderator"}]}),
Q3 = kura_query:select(Q2, [name, email]),
Q4 = kura_query:order_by(Q3, [{name, asc}]),
Q5 = kura_query:limit(Q4, 10),
{ok, Users} = my_repo:all(Q5).

Supported conditions: =, !=, <, >, <=, >=, like, ilike, in, not_in, is_nil, is_not_nil, between, {'and', [...]}, {'or', [...]}, {'not', ...}, {fragment, SQL, Params}.

Migrations

-module(m20240115120000_create_users).
-behaviour(kura_migration).
-include_lib("kura/include/kura.hrl").
-export([up/0, down/0]).
up() ->
[{create_table, ~"users", [
#kura_column{name = id, type = id, primary_key = true, nullable = false},
#kura_column{name = name, type = string, nullable = false},
#kura_column{name = email, type = string, nullable = false},
#kura_column{name = age, type = integer},
#kura_column{name = inserted_at, type = utc_datetime},
#kura_column{name = updated_at, type = utc_datetime}
]},
{create_index, ~"users", [email], #{unique => true}}].
down() ->
[{drop_index, ~"users_email_index"},
{drop_table, ~"users"}].

Run migrations:

kura_migrator:migrate(my_repo).
kura_migrator:rollback(my_repo).
kura_migrator:status(my_repo).

Type Mapping

KuraPostgreSQLSQLiteErlang
idBIGSERIALINTEGER PRIMARY KEYinteger()
integerINTEGERINTEGERinteger()
floatDOUBLE PRECISIONREALfloat()
stringVARCHAR(255)TEXTbinary()
textTEXTTEXTbinary()
booleanBOOLEANINTEGER (0/1)boolean()
dateDATETEXT (ISO 8601){Y, M, D}
utc_datetimeTIMESTAMPTZTEXT (ISO 8601)calendar:datetime()
uuidUUIDTEXTbinary()
jsonbJSONBTEXTmap()
{array, T}T[]unsupportedlist()

SQLite values round-trip transparently via kura_types:cast/2 (booleans 0/1 → true/false, ISO 8601 → datetime tuples, JSON text → maps).

Configuration

Configure repos under the kura app env. Each repo is a map keyed by its module name; pick a backend package and Kura starts the configured pool at app boot, populating dialect, pool_module, and driver_module from the aggregator automatically.

%% sys.config — single Postgres repo
[{kura, [
{repos, #{
my_repo => #{
backend => kura_backend_postgres,
host => "localhost",
port => 5432,
database => "my_app_dev",
user => "postgres",
password => "postgres",
pool_size => 10
}
}}
]}].
%% sys.config — single SQLite repo
[{kura, [
{repos, #{
my_repo => #{
backend => kura_backend_sqlite,
database => <<"my_app.db">>, %% or <<":memory:">>
pool_size => 4
}
}}
]}].
%% sys.config — Postgres primary + SQLite analytics
[{kura, [
{repos, #{
my_repo => #{
backend => kura_backend_postgres,
host => "localhost",
database => "main",
user => "postgres",
pool_size => 10
},
analytics_repo => #{
backend => kura_backend_sqlite,
database => <<":memory:">>
}
}}
]}].

Each repo module declares itself in code:

-module(my_repo).
-behaviour(kura_repo).
-export([otp_app/0]).
otp_app() -> my_app.

Queries through my_repo emit Postgres SQL; queries through analytics_repo emit SQLite SQL. The query cache is keyed per repo so the dialects never share entries. UUID primary keys are auto-generated on insert when no value is provided.

Legacy v1.x config forms (still supported)

The flat single-repo form:

[{kura, [
{repo, my_repo},
{backend, kura_backend_postgres},
{host, "localhost"},
{port, 5432},
{database, "my_app_dev"},
{user, "postgres"},
{password, "postgres"},
{pool_size, 10}
]}].

The per-app form:

[{my_app, [
{my_repo, #{
backend => kura_backend_postgres,
database => ~"my_app_dev",
hostname => ~"localhost",
port => 5432,
username => ~"postgres",
password => ~"postgres",
pool_size => 10
}}
]}].

The per-app form requires the consuming app to call my_repo:start() manually. The {repos, #{...}} form (above) is preferred for new projects - single-repo today, no rewrite when you add a second backend.

Migrations are discovered automatically from compiled modules implementing the kura_migration behaviour.

Optional telemetry/logging config:

[{kura, [
{log, true} %% true | {M, F} | false (default)
]}].

Plugins

Examples

Requirements