CRUD with the Repository

We have schemas, migrations, and changesets. Now let's use the repository to create, read, update, and delete records — and wire it all up to a controller.

Insert

Create a record by building a changeset and passing it to blog_repo:insert/1:

Params = #{<<"title">> => <<"My First Post">>,
           <<"body">> => <<"Hello from Nova!">>,
           <<"status">> => <<"draft">>,
           <<"user_id">> => 1},
CS = post:changeset(#{}, Params),
{ok, Post} = blog_repo:insert(CS).

If the changeset is invalid, insert returns {error, Changeset} with the errors:

CS = post:changeset(#{}, #{}),
{error, #kura_changeset{errors = [{title, <<"can't be blank">>}, ...]}} = blog_repo:insert(CS).

Query all

Use the query builder to fetch records:

Q = kura_query:from(post),
{ok, Posts} = blog_repo:all(Q).

Posts is a list of maps, each representing a row:

[#{id => 1, title => <<"My First Post">>, body => <<"Hello from Nova!">>,
   status => draft, user_id => 1,
   inserted_at => {{2026,2,23},{12,0,0}}, updated_at => {{2026,2,23},{12,0,0}}}]

Notice status is the atom draft, not a binary — Kura handles the conversion.

Get by ID

Fetch a single record by primary key:

{ok, Post} = blog_repo:get(post, 1).
{error, not_found} = blog_repo:get(post, 999).

Update

To update a record, build a changeset from the existing data and new params:

{ok, Post} = blog_repo:get(post, 1),
CS = post:changeset(Post, #{<<"title">> => <<"Updated Title">>}),
{ok, UpdatedPost} = blog_repo:update(CS).

Only the changed fields are included in the UPDATE statement.

Delete

Delete takes a changeset built from the existing record:

{ok, Post} = blog_repo:get(post, 1),
CS = kura_changeset:cast(post, Post, #{}, []),
{ok, _} = blog_repo:delete(CS).

Query builder

The query builder composes — chain functions to build up complex queries:

%% Filter by status
Q = kura_query:from(post),
Q1 = kura_query:where(Q, {status, <<"published">>}),
{ok, Published} = blog_repo:all(Q1).

%% Order by insertion date, newest first
Q2 = kura_query:order_by(Q1, [{inserted_at, desc}]),

%% Limit and offset for pagination
Q3 = kura_query:limit(Q2, 10),
Q4 = kura_query:offset(Q3, 20),
{ok, Page3} = blog_repo:all(Q4).

Where conditions

%% Equality
kura_query:where(Q, {title, <<"Hello">>})

%% Comparison operators
kura_query:where(Q, {user_id, '>', 5})
kura_query:where(Q, {inserted_at, '>=', {{2026,1,1},{0,0,0}}})

%% IN clause
kura_query:where(Q, {status, in, [<<"draft">>, <<"published">>]})

%% LIKE / ILIKE
kura_query:where(Q, {title, ilike, <<"%nova%">>})

%% NULL checks
kura_query:where(Q, {body, is_nil})
kura_query:where(Q, {body, is_not_nil})

%% OR conditions
kura_query:where(Q, {'or', [{status, <<"draft">>}, {status, <<"archived">>}]})

%% AND conditions (multiple where calls are AND by default)
Q1 = kura_query:where(Q, {status, <<"published">>}),
Q2 = kura_query:where(Q1, {user_id, 1}).

Wiring up to a controller

Let's build a posts API controller that uses the repo. Create src/controllers/blog_posts_controller.erl:

-module(blog_posts_controller).
-include_lib("kura/include/kura.hrl").

-export([
         index/1,
         show/1,
         create/1,
         update/1,
         delete/1
        ]).

index(_Req) ->
    Q = kura_query:from(post),
    Q1 = kura_query:order_by(Q, [{inserted_at, desc}]),
    {ok, Posts} = blog_repo:all(Q1),
    {json, #{posts => [post_to_json(P) || P <- Posts]}}.

show(#{bindings := #{<<"id">> := Id}}) ->
    case blog_repo:get(post, binary_to_integer(Id)) of
        {ok, Post} ->
            {json, post_to_json(Post)};
        {error, not_found} ->
            {status, 404, #{}, #{error => <<"post not found">>}}
    end.

create(#{params := Params}) ->
    CS = post:changeset(#{}, Params),
    case blog_repo:insert(CS) of
        {ok, Post} ->
            {json, 201, #{}, post_to_json(Post)};
        {error, #kura_changeset{} = CS1} ->
            {json, 422, #{}, #{errors => changeset_errors_to_json(CS1)}}
    end;
create(_Req) ->
    {status, 422, #{}, #{error => <<"request body required">>}}.

update(#{bindings := #{<<"id">> := Id}, params := Params}) ->
    case blog_repo:get(post, binary_to_integer(Id)) of
        {ok, Post} ->
            CS = post:changeset(Post, Params),
            case blog_repo:update(CS) of
                {ok, Updated} ->
                    {json, post_to_json(Updated)};
                {error, #kura_changeset{} = CS1} ->
                    {json, 422, #{}, #{errors => changeset_errors_to_json(CS1)}}
            end;
        {error, not_found} ->
            {status, 404, #{}, #{error => <<"post not found">>}}
    end.

delete(#{bindings := #{<<"id">> := Id}}) ->
    case blog_repo:get(post, binary_to_integer(Id)) of
        {ok, Post} ->
            CS = kura_changeset:cast(post, Post, #{}, []),
            {ok, _} = blog_repo:delete(CS),
            {status, 204};
        {error, not_found} ->
            {status, 404, #{}, #{error => <<"post not found">>}}
    end.

%% Helpers

post_to_json(#{id := Id, title := Title, body := Body, status := Status,
               user_id := UserId, inserted_at := InsertedAt}) ->
    #{id => Id, title => Title, body => Body,
      status => atom_to_binary(Status), user_id => UserId,
      inserted_at => format_datetime(InsertedAt)}.

changeset_errors_to_json(#kura_changeset{errors = Errors}) ->
    maps:from_list([{atom_to_binary(Field), Msg} || {Field, Msg} <- Errors]).

format_datetime({{Y,Mo,D},{H,Mi,S}}) ->
    list_to_binary(io_lib:format("~4..0B-~2..0B-~2..0BT~2..0B:~2..0B:~2..0B",
                                  [Y, Mo, D, H, Mi, S]));
format_datetime(_) ->
    null.

Adding the routes

#{prefix => "/api",
  security => false,
  routes => [
             {"/posts", fun blog_posts_controller:index/1, #{methods => [get]}},
             {"/posts/:id", fun blog_posts_controller:show/1, #{methods => [get]}},
             {"/posts", fun blog_posts_controller:create/1, #{methods => [post]}},
             {"/posts/:id", fun blog_posts_controller:update/1, #{methods => [put]}},
             {"/posts/:id", fun blog_posts_controller:delete/1, #{methods => [delete]}}
            ]
}

Testing with curl

Start the node and test:

# Create a post
curl -s -X POST localhost:8080/api/posts \
  -H "Content-Type: application/json" \
  -d '{"title": "My First Post", "body": "Hello from Nova!", "status": "draft", "user_id": 1}' \
  | python3 -m json.tool

# List all posts
curl -s localhost:8080/api/posts | python3 -m json.tool

# Get a single post
curl -s localhost:8080/api/posts/1 | python3 -m json.tool

# Update a post
curl -s -X PUT localhost:8080/api/posts/1 \
  -H "Content-Type: application/json" \
  -d '{"title": "Updated Title", "status": "published"}' \
  | python3 -m json.tool

# Delete a post
curl -s -X DELETE localhost:8080/api/posts/1 -w "%{http_code}\n"

# Try creating with invalid data
curl -s -X POST localhost:8080/api/posts \
  -H "Content-Type: application/json" \
  -d '{"title": "Hi"}' \
  | python3 -m json.tool

The last command returns a 422 with validation errors.

No SQL strings anywhere. The query builder composes, the repo executes.


This gives us a working API for a single resource. Next, let's use the code generators to scaffold resources faster and add JSON schemas for documentation.