Homepage for the changeset library

Data validation with first-class and first-order labels in OCaml.

Library documentation can be found here.

This library takes its inspiration from the Ecto.Changeset module in the Elixir ecosystem.

Getting started

Changesets and data validation. Changesets are data structures well suited to validate data while accumulating errors along the way. They are composed of:

Labels are first-class, that means you can give them as argument to functions, and first-order, so you can pattern match against them. The library promotes the pipeline design approach.

There is a ppx deriving plugin so there is no need to write any boilerplate code.

Install using opam

$ opam pin add changeset_lib https://github.com/phink/changeset.git
$ opam pin add ppx_changeset https://github.com/phink/changeset.git

Update the libraries and preprocess section in your jbuild file:

(libraries (... changeset))
(preprocess (pps (... ppx_changeset)))

Example

Let us illustrate the usage of the library with a first example.

Type definition and derivation

We start by defining a type t that would represent the incoming data.

type t = {
  age: int;
  phone: string;
  password: string;
} [@@deriving changeset]

The [@@deriving changeset] ppx annotation will generate a GADT definition whose constructors correspond to the fields of the original record type t. The parameter type variable of label aims to represent the corresponding type of the label in the record definition. In our example we have the following generated definition.

type 'a label =
  | Age : int label
  | Phone : string label
  | Password : string label

It will also generate a module named Changeset of type Changeset_lib.S that provides, among others, validation functions for the type t.

The workflow

We start by defining a function validate that takes a changeset cset, applies the desired validations, and returns the changeset potentially augmented with new errors returned by the validation functions.

let validate cset : Changeset.t =
  cset
  |> Changeset.validate_int Age [`greater_than_or_equal_to 0]
  |> Changeset.validate_string_length Password [`min 12]
  |> Changeset.validate_format Phone (Str.regexp "^\\+?[1-9][0-9]+$")

The function validate takes a term of type t, creates a changeset in which all the elements in t are loaded as changes using the Changeset.from_record function and calls validate.

let create t : (t, Changeset.t) Base.Result.t =
  t
  |> Changeset.from_record
  |> validate
  |> Changeset.apply

If some of the validations were not respected, an error is registered and the changeset would now be considered as invalid.

The function Changeset.apply returns a value of type (t, Changeset.t) Base.Result.t. That is,

Example of success and error reporting

let run data =
  data
  |> create
  |> Changeset.apply
  |> function
  | Ok _t -> Stdio.print_endline "OK"
  | Error cset -> Stdio.prerr_endline (Changeset.show_errors cset)

Example with valid data

let data : t = {
  age = 10;
  password = "somethinghardtohack";
  phone = "+14155552671"
}

Outputs the following when executing run data:

OK

Example with invalid data

let data : t = {
  age = -1;
  password = "camel<3";
  phone = "+14155552671"
}

Outputs the following when executing run data:

{
  "errors":{
    "password":"should be at least 12 character(s)";
    "age":"must be greater than or equal to 0"
  }
}

Define your own validation functions

One can easily create its own validation with validate_change. For instance, we can create validate_one_uppercase that verifies if the value bound to the label passed as argument contains at least one uppercase. Otherwise an error associating the corresponding label to the errom message "should contain at least one uppercase" would be registered in changeset cset.

let validate_one_uppercase label cset =
  let message = "should contain at least one uppercase" in
  let aux s =
    if not Base.(String.exists s ~f:Char.is_uppercase)
    then [message]
    else []
  in
  Changeset.validate_change label aux cset

The original validate definition should be extended as follows.

let validate cset =
  cset
  |> Changeset.validate_int Age [`greater_than_or_equal_to 0]
  |> Changeset.validate_string_length Password [`min 12]
  |> Changeset.validate_format Phone (Str.regexp "^\\+?[1-9][0-9]+$")
  |> validate_one_uppercase Password

Dynamic side

Information coming from the outside world is not always complete and, as a consequence, we usually have to deal with partial data. Suppose we are implementing a strong password generator API and the only information provided are the phone number and the age. In json format it would be represented as:

{"age": 9; "phone": "+14155552671"}

The changes in a changeset correspond to an heterogeneous map with bindings for keys of type 'a label to values of type 'a. This structure allows us to incrementally build the changeset before calling Changeset.apply.

Remember that the job of the API is to generate passwords. As a consequence, when creating a new changeset we also have to generate a value for the password. Our create function is now defined as follows, assuming a function generate_strong_password of type unit -> string.

let create json =
  let open Yojson.Basic.Util in
  let age = json |> member "age" |> to_int in
  let phone = json |> member "phone" |> to_string in
  Changeset.[Age => age; Phone => phone]
  |> Changeset.put_changes
  |> Changeset.put_change Password (generate_strong_password ())
  |> validate
  |> Changeset.apply

Limitations

Parameterized source types are not yet supported.