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#
- Queries - Build and execute queries
- Repo - Database operations
- Associations - Define relationships