a database layer insipred by caqti and ecto

Changesets#

Changesets are the heart of data validation in repodb. They track changes to your data and validate them before persisting to the database.

Creating Changesets#

Start with a record value and create a changeset:

open Repodb

type user = { id : int; name : string; email : string; age : int }

let empty_user = { id = 0; name = ""; email = ""; age = 0 }

(* Create a changeset from a record *)
let cs = Changeset.create empty_user

For Insert vs Update#

Use explicit action markers for clarity:

(* For new records *)
let cs = Changeset.for_insert empty_user

(* For existing records *)
let cs = Changeset.for_update existing_user

Casting Parameters#

Cast external input (e.g., from HTTP requests) to your changeset:

let params = [("name", "Alice"); ("email", "alice@example.com"); ("age", "25")]

let cs =
  Changeset.create empty_user
  |> Changeset.cast params ~fields:[name_field; email_field; age_field]

Only fields listed in ~fields are accepted. Other parameters are ignored, protecting against mass assignment.

Working with Changes#

(* Add a change programmatically *)
let cs = Changeset.put_change name_field "Bob" cs

(* Remove a change *)
let cs = Changeset.delete_change name_field cs

(* Get a specific change *)
let name_change = Changeset.get_change cs name_field  (* string option *)

(* Get all changes as list *)
let all_changes = Changeset.changes cs  (* (string * change) list *)

Validations#

Required Fields#

let cs =
  Changeset.create empty_user
  |> Changeset.cast params ~fields:[name_field; email_field]
  |> Changeset.validate_required [name_field; email_field]

Adds error "can't be blank" if field is missing or empty string.

Format Validation#

let cs =
  cs |> Changeset.validate_format email_field ~pattern:"^[^@]+@[^@]+$"

Uses PCRE regular expressions. Adds error "has invalid format" on mismatch.

Length Validation#

let cs =
  cs
  |> Changeset.validate_length name_field ~min:2 ~max:100
  |> Changeset.validate_length password_field ~min:8
  |> Changeset.validate_length pin_field ~is:4  (* exact length *)

Error messages:

  • "should be at least N character(s)"
  • "should be at most N character(s)"
  • "should be N character(s)"

Number Validation#

let cs =
  cs
  |> Changeset.validate_number age_field ~greater_than_or_equal:0
  |> Changeset.validate_number age_field ~less_than:150
  |> Changeset.validate_number price_field ~greater_than:0

Options:

  • ~greater_than:n
  • ~less_than:n
  • ~greater_than_or_equal:n
  • ~less_than_or_equal:n

Inclusion/Exclusion#

(* Value must be one of these *)
let cs =
  cs |> Changeset.validate_inclusion status_field ~values:["active"; "inactive"]

(* Value must NOT be any of these *)
let cs =
  cs |> Changeset.validate_exclusion username_field ~values:["admin"; "root"]

Acceptance (for checkboxes)#

let cs = cs |> Changeset.validate_acceptance terms_field

Validates that value is true, "true", or "1".

Confirmation (password confirmation)#

let cs =
  cs |> Changeset.validate_confirmation password_field
       ~confirmation_field:password_confirmation_field

Adds error "does not match" on the confirmation field if values differ.

Custom Validation#

let cs =
  cs |> Changeset.validate_change email_field (fun email ->
    if String.contains email '+' then
      Error "plus signs not allowed in email"
    else
      Ok ()
  )

Generic Validation Function#

For complex multi-field validation:

let cs =
  cs |> Changeset.validate (fun cs ->
    match Changeset.get_change cs start_date_field,
          Changeset.get_change cs end_date_field with
    | Some start_date, Some end_date when end_date < start_date ->
        Changeset.add_error cs
          ~field:"end_date"
          ~message:"must be after start date"
          ~validation:"date_range"
    | _ -> cs
  )

Checking Validity#

if Changeset.is_valid cs then
  (* proceed with insert/update *)
  let user = Changeset.data cs in
  ...
else
  (* handle errors *)
  let errors = Changeset.errors cs in
  ...

Error Handling#

(* Get all errors *)
let errors = Changeset.errors cs  (* Error.validation_error list *)

(* Get error messages as strings *)
let messages = Changeset.error_messages cs  (* ["name can't be blank"; ...] *)

(* Check if specific field has error *)
let has_name_error = Changeset.has_error cs name_field

(* Get error for specific field *)
let name_error = Changeset.get_error cs name_field  (* validation_error option *)

(* Iterate over errors *)
Changeset.traverse_errors cs (fun field message ->
  Printf.printf "%s: %s\n" field message
)

Extract Data#

(* Get the underlying data (with changes applied - not automatic!) *)
let user = Changeset.data cs

(* Apply action - returns Ok data or Error errors *)
let result = Changeset.apply_action cs
(* result : (user, Error.validation_error list) result *)

Database Constraints#

Mark fields for database-level constraint checking:

let cs =
  Changeset.create empty_user
  |> Changeset.cast params ~fields:[name_field; email_field]
  |> Changeset.unique_constraint email_field
  |> Changeset.foreign_key_constraint author_id_field
       ~references:("users", "id")
  |> Changeset.check_constraint ~name:"positive_age" age_field
       ~expression:"age >= 0"

These don't validate in OCaml but help map database constraint violations back to changeset errors.

Nested Changesets#

Single Association#

let cs =
  Changeset.create empty_post
  |> Changeset.cast params ~fields:[title_field; body_field]
  |> Changeset.cast_assoc_one ~assoc_name:"author"
       ~params:author_params
       ~cast_fn:create_author_changeset

Errors are prefixed: "author.name can't be blank"

Multiple Associations#

let cs =
  Changeset.create empty_post
  |> Changeset.cast_assoc_many ~assoc_name:"comments"
       ~params_list:[comment1_params; comment2_params]
       ~cast_fn:create_comment_changeset

Errors are indexed: "comments[0].body can't be blank"

Embedded Data#

For JSON/embedded documents:

let cs =
  Changeset.create empty_user
  |> Changeset.cast_embed ~embed_name:"settings"
       ~params:settings_params
       ~parse:parse_settings

Complete Example#

let create_user_changeset params =
  let empty_user = { id = 0; name = ""; email = ""; age = 0 } in
  Changeset.create empty_user
  |> Changeset.cast params ~fields:[name_field; email_field; age_field]
  |> Changeset.validate_required [name_field; email_field]
  |> Changeset.validate_format email_field ~pattern:"^[^@]+@[^@]+\\.[^@]+$"
  |> Changeset.validate_length name_field ~min:2 ~max:100
  |> Changeset.validate_number age_field ~greater_than_or_equal:0 ~less_than:150
  |> Changeset.unique_constraint email_field

let insert_user conn params =
  let cs = create_user_changeset params in
  if Changeset.is_valid cs then
    let user = Changeset.data cs in
    Repo.insert conn
      ~table:users_table
      ~columns:["name"; "email"; "age"]
      ~values:[
        Driver.Value.text user.name;
        Driver.Value.text user.email;
        Driver.Value.int user.age;
      ]
  else
    Error (Error.Validation_failed (Changeset.error_messages cs))

Next Steps#