Changesets

View Source

Changesets are the core data validation layer. They cast external parameters, track changes against existing data, validate fields, and declare database constraints.

Creating a Changeset

Use kura_changeset:cast/4 to create a changeset from a schema module, existing data, external params, and a list of allowed fields:

%% New record (empty data)
CS = kura_changeset:cast(my_user, #{}, Params, [name, email, age]).

%% Existing record (update)
CS = kura_changeset:cast(my_user, ExistingUser, Params, [name, email]).

Parameters can have binary or atom keys - they are normalized automatically. Only fields in the allowed list are cast. Values are automatically cast to the type defined in the schema.

Validations

Required Fields

CS1 = kura_changeset:validate_required(CS, [name, email]).

Checks that each field is present and non-blank (not undefined, null, or <<>>).

Format

CS1 = kura_changeset:validate_format(CS, email, ~"^[^@]+@[^@]+$").

Validates a binary field against a regex pattern.

Length

CS1 = kura_changeset:validate_length(CS, name, [{min, 2}, {max, 100}]).
CS2 = kura_changeset:validate_length(CS, code, [{is, 6}]).

Options: {min, N}, {max, N}, {is, N}. Works on binaries (byte size) and lists (length).

Number

CS1 = kura_changeset:validate_number(CS, age, [{greater_than_or_equal_to, 0}, {less_than, 150}]).

Options: {greater_than, N}, {less_than, N}, {greater_than_or_equal_to, N}, {less_than_or_equal_to, N}, {equal_to, N}.

Inclusion

CS1 = kura_changeset:validate_inclusion(CS, role, [~"admin", ~"user", ~"guest"]).

Custom Validation

CS1 = kura_changeset:validate_change(CS, email, fun(Val) ->
    case binary:match(Val, ~"@") of
        nomatch -> {error, ~"must contain @"};
        _ -> ok
    end
end).

Constraint Declarations

Constraints map PostgreSQL constraint violations to friendly changeset errors on insert/update.

Automatic Registration via indexes/0

The recommended approach is to declare unique indexes on your schema. They are automatically registered as changeset constraints - no manual calls needed:

%% In your schema module
indexes() ->
    [{[email], #{unique => true}},
     {[username], #{unique => true}}].

When a PG unique violation fires on users_email_index, the changeset gets {email, <<"has already been taken">>} automatically.

Unique Constraint (Manual)

For cases where you need custom constraint names or messages:

CS1 = kura_changeset:unique_constraint(CS, email).

%% With custom constraint name and message
CS1 = kura_changeset:unique_constraint(CS, email, #{
    name => ~"users_email_index",
    message => ~"is already registered"
}).

Foreign Key Constraint

CS1 = kura_changeset:foreign_key_constraint(CS, team_id).

Check Constraint

CS1 = kura_changeset:check_constraint(CS, ~"users_age_check", age, #{
    message => ~"must be positive"
}).

Working with Changes

%% Get a changed value
Name = kura_changeset:get_change(CS, name).
Name = kura_changeset:get_change(CS, name, ~"default").

%% Get the effective field value (changes take precedence over data)
Email = kura_changeset:get_field(CS, email).

%% Manually set a change
CS1 = kura_changeset:put_change(CS, role, ~"admin").

%% Add a custom error
CS1 = kura_changeset:add_error(CS, email, ~"is not allowed").

Applying Changes

%% Merge changes into data (ignores validity)
Map = kura_changeset:apply_changes(CS).

%% Apply with action check - returns error if invalid
{ok, Map} = kura_changeset:apply_action(CS, insert).
{error, CS1} = kura_changeset:apply_action(InvalidCS, insert).

Error Handling

Errors are stored as [{atom(), binary()}] in the changeset's errors field. The valid field is false when any errors are present.

case kura_repo_worker:insert(my_repo, CS) of
    {ok, Record} ->
        %% success
        Record;
    {error, #kura_changeset{errors = Errors}} ->
        %% Errors is e.g. [{email, ~"has already been taken"}]
        Errors
end.

Dump Failures

put_change/3 writes a value into the changeset without casting it, so a value the field's type cannot serialise only surfaces on the way to the driver. Writes fail closed there: the field's dump error is added to the changeset and the statement is never sent.

CS1 = kura_changeset:put_change(CS, metadata, {not_json, encodable}),
{error, #kura_changeset{errors = [{metadata, ~"cannot cast to jsonb"}]}} =
    my_repo:insert(CS1).

The bulk paths have no changeset to carry the error, so they return it directly:

{error, {dump_failed, metadata, ~"cannot cast to jsonb"}} =
    my_repo:insert_all(my_schema, Entries).

{error, {dump_failed, metadata, ~"cannot cast to jsonb"}} =
    my_repo:update_all(Query, #{metadata => {not_json, encodable}}).

update_all/2 dumps its SET map through the schema's field types whenever the query's source is a schema module, so a jsonb map or any other non-primitive reaches the driver already serialised.

The bulk paths take raw maps rather than cast changesets, so they also accept values in the form cast/2 accepts: ~"admin" for an {enum, _} field, ~"2026-01-01" for a date. A dump is attempted first and the cast only runs if it fails, so the happy path costs nothing.

A binary written to a jsonb field is treated as an already-serialised document only when it parses as a JSON object or array; anything else is stored as a JSON string. That keeps both directions honest: a pre-encoded document survives the bulk paths, and a value that came back from load/2 - which decodes a stored JSON string into a bare binary - re-encodes to the string it was, rather than turning "123" into the number 123 on the next read-modify-write. To store a scalar, pass the Erlang term: 123, or true, not their text.

One asymmetry to know about: a jsonb column holding JSON null loads as the atom null, and writing that back stores SQL NULL rather than JSON null. col IS NULL and jsonb_typeof(col) = 'null' distinguish the two, so read a nullable jsonb column with that in mind.