a database layer insipred by caqti and ecto
OCaml 98%
Shell <1%
<1%
Dune <1%
Standard ML <1%

README.md

repodb #

An Ecto-inspired database toolkit for OCaml.

repodb tries to bring Elixir's Ecto ideas to OCaml: type-safe queries, composable changesets, and clean separation between your domain and the database.

Features #

  • Schemas - Define your data structures with typed fields
  • PPX bindings - Derive fields, schemas, and row codecs from OCaml records
  • Changesets - Validate and track changes before persisting
  • Query DSL - Build type-safe queries with a composable API
  • Type-Safe INSERTs - Compile-time verification that column types match value types
  • Repo - Clean abstraction over database operations
  • Dialect-Aware DDL - Generate schema and migration SQL from the selected backend
  • Shared Backend Contract Tests - Portable integration coverage runs against PostgreSQL, SQLite, and MariaDB
  • Associations - Define relationships (has_many, belongs_to, many_to_many)
  • Preloading - Efficiently load associations, avoiding N+1 queries
  • Multi - Compose multiple operations in a single transaction
  • Migrations - Version your database schema
  • Connection Pool - Scheduler-agnostic, lock-free connection pooling
  • CQRS - Transparent read/write splitting with replica support
  • Multi-Server Pool - Round-robin load balancing across database servers

Quick Example #

(* Define a schema *)
let users_table = Schema.table "users"

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

let name_field =
  Field.make ~table_name:"users" ~name:"name" ~ty:Types.string
    ~get:(fun u -> u.name) ~set:(fun v u -> { u with name = v }) ()

let age_field =
  Field.make ~table_name:"users" ~name:"age" ~ty:Types.int
    ~get:(fun u -> u.age) ~set:(fun v u -> { u with age = v }) ()

(* Create and validate a changeset *)
let empty_user = { id = 0; name = ""; email = ""; age = 0 }

let changeset =
  Changeset.create empty_user
  |> Changeset.cast [("name", "Alice"); ("email", "alice@example.com")]
       ~fields:[name_field; email_field]
  |> Changeset.validate_required [name_field; email_field]
  |> Changeset.validate_format email_field ~pattern:"^[^@]+@[^@]+$"

(* Build type-safe queries using field definitions *)
let query =
  Query.(from users_table
    |> where Expr.(column age_field > int 18)
    |> order_by ~direction:Desc (Expr.column created_at_field)
    |> limit 10)

let users = Repo.all_query conn query ~decode:decode_user

(* Type-safe insert - compiler verifies column/value types match *)
let insert_query =
  Query.insert_into users_table
  |> Query_values.values3
      (name_field, email_field, age_field)
      (Expr.string "Alice", Expr.string "alice@example.com", Expr.int 25)

Documentation #

Packages #

Package Description
repodb Core library with schemas, changesets, queries
repodb-ppx Optional record deriver for fields, schemas, and row codecs
repodb-sqlite SQLite driver
repodb-postgresql PostgreSQL driver
repodb-mariadb MariaDB/MySQL driver

Installation #

opam install repodb repodb-sqlite   # or repodb-postgresql / repodb-mariadb

MariaDB / MySQL #

repodb-mariadb supports the same core driver contract as the existing backends:

  • connections and native transaction control (Mariadb.Blocking.start_txn, commit, rollback) - no transaction-control statement is prepared or cached, so MySQL 8 never raises error 1295 and row locks are held until the transaction ends
  • prepared statements
  • query execution and decoding, including full-width signed BIGINT and unsigned BIGINT values, which are carried as exact decimal text above Int64.max_int instead of silently wrapping
  • affected-row counts and full-width generated ids through exec_result : ... -> (Driver.command_result, error) result
  • connection pools via Pool.Make(Repodb_mariadb)
  • MySQL/MariaDB placeholder rendering with ?
  • MySQL/MariaDB upsert rendering with ON DUPLICATE KEY UPDATE
  • SELECT ... FOR UPDATE SKIP LOCKED and common table expressions through the Query DSL

Connection strings use key=value pairs such as:

host=localhost port=3306 dbname=myapp user=root password=secret charset=utf8mb4

Runtime usage and backend-aware schema/migration SQL are supported now through Repo.Ddl / Ddl.Make(Driver). Raw SQL migration types/defaults remain explicit escape hatches, just like Ecto's adapters.

Current limitations:

  • RETURNING flows are not available on repodb-mariadb. UPDATE ... RETURNING and UPDATE ... FROM are rejected at render time rather than emitted as SQL MySQL would refuse; see docs/queues.md for the transactional two-step form.
  • CREATE INDEX IF NOT EXISTS and the column guards are MariaDB-only. The shared MySQL dialect defaults to the MySQL-safe capability set; use Repodb_mariadb.detect_ddl_capabilities to opt into MariaDB's.
  • JSON support is backend-specific and should not be treated as portable across backends

See docs/getting-started.md, docs/repo.md, docs/schemas.md, docs/queries.md, docs/queues.md, and docs/migrations.md for details.

Philosophy #

repodb follows Ecto's philosophy:

  1. Explicit over implicit - No magic, every operation is visible
  2. Composable - Small functions that combine well
  3. Separation of concerns - Domain logic separate from persistence
  4. Fail fast - Validate early with changesets

License #

ISC License