Tags, Many-to-Many & Embedded Schemas
Our blog has users, posts, and comments. Now let's add tags (many-to-many through a join table) and post metadata (embedded schema stored as JSONB).
Tag schema
Create src/schemas/tag.erl:
-module(tag).
-behaviour(kura_schema).
-include_lib("kura/include/kura.hrl").
-export([table/0, fields/0, primary_key/0, associations/0, changeset/2]).
table() -> <<"tags">>.
primary_key() -> id.
fields() ->
[
#kura_field{name = id, type = id, primary_key = true, nullable = false},
#kura_field{name = name, type = string, nullable = false},
#kura_field{name = inserted_at, type = utc_datetime}
].
associations() ->
[
#kura_assoc{name = posts, type = many_to_many, schema = post,
join_through = <<"posts_tags">>, join_keys = {tag_id, post_id}}
].
changeset(Data, Params) ->
CS = kura_changeset:cast(tag, Data, Params, [name]),
CS1 = kura_changeset:validate_required(CS, [name]),
kura_changeset:unique_constraint(CS1, name).
Join table schema
The many-to-many relationship needs a join table. Create src/schemas/posts_tags.erl:
-module(posts_tags).
-behaviour(kura_schema).
-include_lib("kura/include/kura.hrl").
-export([table/0, fields/0, primary_key/0]).
table() -> <<"posts_tags">>.
primary_key() -> id.
fields() ->
[
#kura_field{name = id, type = id, primary_key = true, nullable = false},
#kura_field{name = post_id, type = integer, nullable = false},
#kura_field{name = tag_id, type = integer, nullable = false}
].
Adding many-to-many to posts
Update the associations/0 in src/schemas/post.erl:
associations() ->
[
#kura_assoc{name = author, type = belongs_to, schema = user, foreign_key = user_id},
#kura_assoc{name = comments, type = has_many, schema = comment, foreign_key = post_id},
#kura_assoc{name = tags, type = many_to_many, schema = tag,
join_through = <<"posts_tags">>, join_keys = {post_id, tag_id}}
].
The many_to_many association specifies:
join_through— the join table namejoin_keys—{this_side_fk, other_side_fk}on the join table
Generate the migrations
Compile to generate the new tables:
rebar3 compile
===> [kura] Schema diff detected changes
===> [kura] Generated src/migrations/m20260223140000_create_tags.erl
===> [kura] Generated src/migrations/m20260223140100_create_posts_tags.erl
Tagging posts with put_assoc
Use put_assoc to set tags on a post:
%% Get existing tags (or create new ones first)
{ok, Erlang} = blog_repo:get_by(tag, [{name, <<"erlang">>}]),
{ok, Nova} = blog_repo:get_by(tag, [{name, <<"nova">>}]),
%% Assign tags to a post
{ok, Post} = blog_repo:get(post, 1),
CS = kura_changeset:cast(post, Post, #{}, []),
CS1 = kura_changeset:put_assoc(CS, tags, [Erlang, Nova]),
{ok, _} = blog_repo:update(CS1).
put_assoc replaces the entire association — under the hood it deletes existing join table rows and inserts new ones, all in a transaction.
Preloading tags
Q = kura_query:from(post),
Q1 = kura_query:preload(Q, [author, tags]),
{ok, Posts} = blog_repo:all(Q1).
Each post now has a tags key with a list of tag maps:
#{id => 1, title => <<"My First Post">>,
tags => [#{id => 1, name => <<"erlang">>}, #{id => 2, name => <<"nova">>}],
...}
Embedded schemas
Sometimes you need structured data that doesn't deserve its own table. Kura's embedded schemas store nested structures as JSONB columns.
Post metadata
Create src/schemas/post_metadata.erl:
-module(post_metadata).
-behaviour(kura_schema).
-include_lib("kura/include/kura.hrl").
-export([table/0, fields/0, primary_key/0, changeset/2]).
table() -> <<"embedded">>.
primary_key() -> undefined.
fields() ->
[
#kura_field{name = meta_title, type = string},
#kura_field{name = meta_description, type = string},
#kura_field{name = og_image, type = string}
].
changeset(Data, Params) ->
CS = kura_changeset:cast(post_metadata, Data, Params,
[meta_title, meta_description, og_image]),
kura_changeset:validate_length(CS, meta_description, [{max, 160}]).
The embedded schema looks like a regular schema but with table() returning a placeholder (it's never queried directly) and primary_key() returning undefined.
Adding the embed to posts
Update src/schemas/post.erl to add an embeds/0 callback and a metadata JSONB field:
-export([table/0, fields/0, primary_key/0, associations/0, embeds/0, changeset/2]).
fields() ->
[
#kura_field{name = id, type = id, primary_key = true, nullable = false},
#kura_field{name = title, type = string, nullable = false},
#kura_field{name = body, type = text},
#kura_field{name = status, type = {enum, [draft, published, archived]}, default = <<"draft">>},
#kura_field{name = user_id, type = integer},
#kura_field{name = metadata, type = {embed, embeds_one, post_metadata}},
#kura_field{name = inserted_at, type = utc_datetime},
#kura_field{name = updated_at, type = utc_datetime}
].
embeds() ->
[
#kura_embed{name = metadata, type = embeds_one, schema = post_metadata}
].
Compile to generate a migration that adds the metadata JSONB column:
rebar3 compile
Using embedded schemas
Cast the embed in your changeset:
changeset(Data, Params) ->
CS = kura_changeset:cast(post, Data, Params, [title, body, status, user_id, metadata]),
CS1 = kura_changeset:validate_required(CS, [title, body]),
CS2 = kura_changeset:validate_length(CS1, title, [{min, 3}, {max, 200}]),
CS3 = kura_changeset:validate_inclusion(CS2, status, [draft, published, archived]),
CS4 = kura_changeset:foreign_key_constraint(CS3, user_id),
kura_changeset:cast_embed(CS4, metadata).
cast_embed reads the metadata key from params and builds a nested changeset using post_metadata:changeset/2. Create a post with metadata:
curl -s -X POST localhost:8080/api/posts \
-H "Content-Type: application/json" \
-d '{
"title": "SEO Optimized Post",
"body": "Great content here",
"user_id": 1,
"metadata": {
"meta_title": "Best Post Ever",
"meta_description": "A post about great things",
"og_image": "https://example.com/image.jpg"
}
}' | python3 -m json.tool
The metadata is stored as JSONB in PostgreSQL and loaded back as a nested map:
#{id => 5,
title => <<"SEO Optimized Post">>,
metadata => #{meta_title => <<"Best Post Ever">>,
meta_description => <<"A post about great things">>,
og_image => <<"https://example.com/image.jpg">>},
...}
Filtering by tag
To find posts with a specific tag, use a raw SQL fragment or build the query through the join table:
%% Find all post IDs for a given tag
find_posts_by_tag(TagName) ->
{ok, Tag} = blog_repo:get_by(tag, [{name, TagName}]),
TagId = maps:get(id, Tag),
Q = kura_query:from(posts_tags),
Q1 = kura_query:where(Q, {tag_id, TagId}),
{ok, JoinRows} = blog_repo:all(Q1),
PostIds = [maps:get(post_id, R) || R <- JoinRows],
Q2 = kura_query:from(post),
Q3 = kura_query:where(Q2, {id, in, PostIds}),
Q4 = kura_query:preload(Q3, [author, tags]),
blog_repo:all(Q4).
API endpoint for tags
Add a simple tags controller:
-module(blog_tags_controller).
-export([index/1, create/1]).
index(_Req) ->
Q = kura_query:from(tag),
Q1 = kura_query:order_by(Q, [{name, asc}]),
{ok, Tags} = blog_repo:all(Q1),
{json, #{tags => Tags}}.
create(#{params := Params}) ->
CS = tag:changeset(#{}, Params),
case blog_repo:insert(CS) of
{ok, Tag} ->
{json, 201, #{}, Tag};
{error, _CS} ->
{status, 422, #{}, #{error => <<"invalid tag">>}}
end.
We now have a rich data model with associations, many-to-many relationships, and embedded schemas. Next, let's write proper tests for our application.