Skip to content

Taure/kura

Repository files navigation

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

  • Schema - behaviour-based schema definitions with type metadata
  • Changeset - cast external params, validate, track changes and errors
  • Query Builder - composable, functional query construction
  • SQL Compiler - parameterized SQL generation (no string interpolation)
  • Repo - CRUD operations with automatic type conversion and PG error mapping
  • Associations - belongs_to, has_one, has_many, many_to_many with preloading
  • Embedded Schemas - embeds_one, embeds_many stored as JSONB
  • Multi - atomic transaction pipelines
  • Migrations - DDL operations with automatic module-based discovery
  • Enums - atom-backed enum types stored as VARCHAR
  • Telemetry - query logging with timing
  • Lifecycle Hooks - before/after callbacks for insert, update, delete
  • Audit Trail - automatic change tracking with actor context
  • Pagination - offset-based and cursor-based pagination
  • Streaming - server-side cursor streaming for large result sets
  • Multitenancy - schema prefix and attribute-based tenant isolation
  • Optimistic Locking - concurrent update conflict detection

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

Kura PostgreSQL SQLite Erlang
id BIGSERIAL INTEGER PRIMARY KEY integer()
integer INTEGER INTEGER integer()
float DOUBLE PRECISION REAL float()
string VARCHAR(255) TEXT binary()
text TEXT TEXT binary()
boolean BOOLEAN INTEGER (0/1) boolean()
date DATE TEXT (ISO 8601) {Y, M, D}
utc_datetime TIMESTAMPTZ TEXT (ISO 8601) calendar:datetime()
uuid UUID TEXT binary()
jsonb JSONB TEXT map()
{array, T} T[] unsupported list()

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

  • rebar3_kura - Rebar3 plugin that auto-generates migration files from schema changes. Add a field to your schema, run rebar3 compile, and the migration is created for you.
  • opentelemetry_kura - OpenTelemetry instrumentation. Subscribes to Kura's telemetry events and creates spans for every database query.

Examples

  • pet_store - A sample REST API built with Kura and Nova demonstrating schemas, changesets, queries, migrations, and associations in practice.

Requirements

About

Database layer for Erlang

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors