a database layer insipred by caqti and ecto

feat: implement mlecto - Ecto-like database toolkit for OCaml

Core modules:
- Type: GADT type witnesses with Caqti integration
- Schema: Table/column DSL with constraints and SQL generation
- Expr: Full expression GADT (operators, aggregates, functions)
- Query: SELECT/INSERT/UPDATE/DELETE with JOINs, upserts
- Changeset: Type-safe validations and constraints
- Repo: SQL builders, transaction state machine with savepoints
- Migration: DSL + runner with version tracking, plan/rollback
- Multi: Operation chaining with named results, atomic execution
- Error: Comprehensive error types

All modules compile, tests pass.

+39
.beads/.gitignore
··· 1 + # SQLite databases 2 + *.db 3 + *.db?* 4 + *.db-journal 5 + *.db-wal 6 + *.db-shm 7 + 8 + # Daemon runtime files 9 + daemon.lock 10 + daemon.log 11 + daemon.pid 12 + bd.sock 13 + sync-state.json 14 + last-touched 15 + 16 + # Local version tracking (prevents upgrade notification spam after git ops) 17 + .local_version 18 + 19 + # Legacy database files 20 + db.sqlite 21 + bd.db 22 + 23 + # Worktree redirect file (contains relative path to main repo's .beads/) 24 + # Must not be committed as paths would be wrong in other clones 25 + redirect 26 + 27 + # Merge artifacts (temporary files from 3-way merge) 28 + beads.base.jsonl 29 + beads.base.meta.json 30 + beads.left.jsonl 31 + beads.left.meta.json 32 + beads.right.jsonl 33 + beads.right.meta.json 34 + 35 + # NOTE: Do NOT add negation patterns (e.g., !issues.jsonl) here. 36 + # They would override fork protection in .git/info/exclude, allowing 37 + # contributors to accidentally commit upstream issue databases. 38 + # The JSONL files (issues.jsonl, interactions.jsonl) and config files 39 + # are tracked by git by default since no pattern above ignores them.
+81
.beads/README.md
··· 1 + # Beads - AI-Native Issue Tracking 2 + 3 + Welcome to Beads! This repository uses **Beads** for issue tracking - a modern, AI-native tool designed to live directly in your codebase alongside your code. 4 + 5 + ## What is Beads? 6 + 7 + Beads is issue tracking that lives in your repo, making it perfect for AI coding agents and developers who want their issues close to their code. No web UI required - everything works through the CLI and integrates seamlessly with git. 8 + 9 + **Learn more:** [github.com/steveyegge/beads](https://github.com/steveyegge/beads) 10 + 11 + ## Quick Start 12 + 13 + ### Essential Commands 14 + 15 + ```bash 16 + # Create new issues 17 + bd create "Add user authentication" 18 + 19 + # View all issues 20 + bd list 21 + 22 + # View issue details 23 + bd show <issue-id> 24 + 25 + # Update issue status 26 + bd update <issue-id> --status in_progress 27 + bd update <issue-id> --status done 28 + 29 + # Sync with git remote 30 + bd sync 31 + ``` 32 + 33 + ### Working with Issues 34 + 35 + Issues in Beads are: 36 + - **Git-native**: Stored in `.beads/issues.jsonl` and synced like code 37 + - **AI-friendly**: CLI-first design works perfectly with AI coding agents 38 + - **Branch-aware**: Issues can follow your branch workflow 39 + - **Always in sync**: Auto-syncs with your commits 40 + 41 + ## Why Beads? 42 + 43 + ✨ **AI-Native Design** 44 + - Built specifically for AI-assisted development workflows 45 + - CLI-first interface works seamlessly with AI coding agents 46 + - No context switching to web UIs 47 + 48 + 🚀 **Developer Focused** 49 + - Issues live in your repo, right next to your code 50 + - Works offline, syncs when you push 51 + - Fast, lightweight, and stays out of your way 52 + 53 + 🔧 **Git Integration** 54 + - Automatic sync with git commits 55 + - Branch-aware issue tracking 56 + - Intelligent JSONL merge resolution 57 + 58 + ## Get Started with Beads 59 + 60 + Try Beads in your own projects: 61 + 62 + ```bash 63 + # Install Beads 64 + curl -sSL https://raw.githubusercontent.com/steveyegge/beads/main/scripts/install.sh | bash 65 + 66 + # Initialize in your repo 67 + bd init 68 + 69 + # Create your first issue 70 + bd create "Try out Beads" 71 + ``` 72 + 73 + ## Learn More 74 + 75 + - **Documentation**: [github.com/steveyegge/beads/docs](https://github.com/steveyegge/beads/tree/main/docs) 76 + - **Quick Start Guide**: Run `bd quickstart` 77 + - **Examples**: [github.com/steveyegge/beads/examples](https://github.com/steveyegge/beads/tree/main/examples) 78 + 79 + --- 80 + 81 + *Beads: Issue tracking that moves at the speed of thought* ⚡
+62
.beads/config.yaml
··· 1 + # Beads Configuration File 2 + # This file configures default behavior for all bd commands in this repository 3 + # All settings can also be set via environment variables (BD_* prefix) 4 + # or overridden with command-line flags 5 + 6 + # Issue prefix for this repository (used by bd init) 7 + # If not set, bd init will auto-detect from directory name 8 + # Example: issue-prefix: "myproject" creates issues like "myproject-1", "myproject-2", etc. 9 + # issue-prefix: "" 10 + 11 + # Use no-db mode: load from JSONL, no SQLite, write back after each command 12 + # When true, bd will use .beads/issues.jsonl as the source of truth 13 + # instead of SQLite database 14 + # no-db: false 15 + 16 + # Disable daemon for RPC communication (forces direct database access) 17 + # no-daemon: false 18 + 19 + # Disable auto-flush of database to JSONL after mutations 20 + # no-auto-flush: false 21 + 22 + # Disable auto-import from JSONL when it's newer than database 23 + # no-auto-import: false 24 + 25 + # Enable JSON output by default 26 + # json: false 27 + 28 + # Default actor for audit trails (overridden by BD_ACTOR or --actor) 29 + # actor: "" 30 + 31 + # Path to database (overridden by BEADS_DB or --db) 32 + # db: "" 33 + 34 + # Auto-start daemon if not running (can also use BEADS_AUTO_START_DAEMON) 35 + # auto-start-daemon: true 36 + 37 + # Debounce interval for auto-flush (can also use BEADS_FLUSH_DEBOUNCE) 38 + # flush-debounce: "5s" 39 + 40 + # Git branch for beads commits (bd sync will commit to this branch) 41 + # IMPORTANT: Set this for team projects so all clones use the same sync branch. 42 + # This setting persists across clones (unlike database config which is gitignored). 43 + # Can also use BEADS_SYNC_BRANCH env var for local override. 44 + # If not set, bd sync will require you to run 'bd config set sync.branch <branch>'. 45 + # sync-branch: "beads-sync" 46 + 47 + # Multi-repo configuration (experimental - bd-307) 48 + # Allows hydrating from multiple repositories and routing writes to the correct JSONL 49 + # repos: 50 + # primary: "." # Primary repo (where this database lives) 51 + # additional: # Additional repos to hydrate from (read-only) 52 + # - ~/beads-planning # Personal planning repo 53 + # - ~/work-planning # Work planning repo 54 + 55 + # Integration settings (access with 'bd config get/set') 56 + # These are stored in the database, not in this file: 57 + # - jira.url 58 + # - jira.project 59 + # - linear.url 60 + # - linear.api-key 61 + # - github.org 62 + # - github.repo
.beads/interactions.jsonl

This is a binary file and will not be displayed.

+21
.beads/issues.jsonl
··· 1 + {"id":"mlecto-0b6","title":"[EPIC] Repository Module","description":"Database operations gateway. Functor over Caqti/Eio connection. CRUD operations, transactions, error handling.","status":"closed","priority":1,"issue_type":"feature","created_at":"2026-01-04T00:21:10.437260357+01:00","created_by":"gdiazlo","updated_at":"2026-01-04T01:20:44.810893227+01:00","closed_at":"2026-01-04T01:20:44.810893227+01:00","close_reason":"All child tasks completed"} 2 + {"id":"mlecto-0hr","title":"Implement Query compilation to SQL","description":"Convert Query.t to SQL string + Caqti request. Parameter binding, SQL escaping, dialect-specific generation (Postgres first).","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-04T00:21:30.33363594+01:00","created_by":"gdiazlo","updated_at":"2026-01-04T01:12:40.467657348+01:00","closed_at":"2026-01-04T01:12:40.467657348+01:00","close_reason":"Query compilation implemented in Query.to_sql - generates PostgreSQL SQL for SELECT/INSERT/UPDATE/DELETE.","dependencies":[{"issue_id":"mlecto-0hr","depends_on_id":"mlecto-7lc","type":"blocks","created_at":"2026-01-04T00:22:02.555339871+01:00","created_by":"gdiazlo"}]} 3 + {"id":"mlecto-1ud","title":"Implement Schema DSL (Mlecto.Schema)","description":"DSL for defining tables: field definitions, constraints (primary_key, not_null, unique, foreign_key, check), table_name generation.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-04T00:21:24.765735157+01:00","created_by":"gdiazlo","updated_at":"2026-01-04T01:09:24.538059462+01:00","closed_at":"2026-01-04T01:09:24.538059462+01:00","close_reason":"Schema DSL complete: table definitions, column constraints (PrimaryKey, NotNull, Unique, Default, Check, ForeignKey), foreign key actions, timestamps helper, SQL generation.","dependencies":[{"issue_id":"mlecto-1ud","depends_on_id":"mlecto-z72","type":"blocks","created_at":"2026-01-04T00:22:02.524126267+01:00","created_by":"gdiazlo"}]} 4 + {"id":"mlecto-1w9","title":"[EPIC] Core Type System \u0026 Schema DSL","description":"Define SQL types mapped to OCaml, schema definition DSL for tables/fields/constraints. Foundation for all other modules.","status":"closed","priority":1,"issue_type":"feature","created_at":"2026-01-04T00:21:05.917266549+01:00","created_by":"gdiazlo","updated_at":"2026-01-04T01:20:44.799339065+01:00","closed_at":"2026-01-04T01:20:44.799339065+01:00","close_reason":"All child tasks completed"} 5 + {"id":"mlecto-5km","title":"Implement Changeset validators","description":"validate_required, validate_format (regex), validate_length (min/max/is), validate_inclusion, validate_exclusion, validate_number, validate_acceptance, validate_confirmation, validate_change (custom).","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-04T00:21:40.78012908+01:00","created_by":"gdiazlo","updated_at":"2026-01-04T01:14:08.654694896+01:00","closed_at":"2026-01-04T01:14:08.654694896+01:00","close_reason":"Validators implemented in Changeset core: validate_required, validate_format, validate_length, validate_inclusion, validate_exclusion, validate_number, validate_acceptance, validate_confirmation, validate_change.","dependencies":[{"issue_id":"mlecto-5km","depends_on_id":"mlecto-m13","type":"blocks","created_at":"2026-01-04T00:22:03.958038572+01:00","created_by":"gdiazlo"}]} 6 + {"id":"mlecto-7lc","title":"Implement Query builder (Mlecto.Query)","description":"SELECT, INSERT, UPDATE, DELETE builders. WHERE, JOIN, ORDER BY, GROUP BY, HAVING, LIMIT, OFFSET. Composable query pipelines.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-04T00:21:28.238632191+01:00","created_by":"gdiazlo","updated_at":"2026-01-04T01:12:33.283719987+01:00","closed_at":"2026-01-04T01:12:33.283719987+01:00","close_reason":"Query builder complete: SELECT, INSERT, UPDATE, DELETE with WHERE, JOIN, ORDER BY, GROUP BY, HAVING, LIMIT, OFFSET. ON CONFLICT for upserts. RETURNING clause. Full SQL generation.","dependencies":[{"issue_id":"mlecto-7lc","depends_on_id":"mlecto-1ud","type":"blocks","created_at":"2026-01-04T00:22:02.538979501+01:00","created_by":"gdiazlo"},{"issue_id":"mlecto-7lc","depends_on_id":"mlecto-nv7","type":"blocks","created_at":"2026-01-04T00:22:02.547726588+01:00","created_by":"gdiazlo"}]} 7 + {"id":"mlecto-8ki","title":"Implement Repository core (Mlecto.Repo)","description":"Repo functor over Caqti connection. Basic operations: get, get_by, one, all, insert, update, delete. Eio-based async.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-04T00:21:44.104280456+01:00","created_by":"gdiazlo","updated_at":"2026-01-04T01:16:42.713088503+01:00","closed_at":"2026-01-04T01:16:42.713088503+01:00","close_reason":"Core DSL and SQL generation complete, execution stubs in place","dependencies":[{"issue_id":"mlecto-8ki","depends_on_id":"mlecto-0hr","type":"blocks","created_at":"2026-01-04T00:22:05.202771435+01:00","created_by":"gdiazlo"},{"issue_id":"mlecto-8ki","depends_on_id":"mlecto-m13","type":"blocks","created_at":"2026-01-04T00:22:05.210702979+01:00","created_by":"gdiazlo"}]} 8 + {"id":"mlecto-9be","title":"[EPIC] Multi \u0026 Transactions","description":"Chain multiple database operations atomically. Monadic transaction builder like Ecto.Multi.","status":"closed","priority":2,"issue_type":"feature","created_at":"2026-01-04T00:21:14.154297405+01:00","created_by":"gdiazlo","updated_at":"2026-01-04T01:20:44.813012307+01:00","closed_at":"2026-01-04T01:20:44.813012307+01:00","close_reason":"All child tasks completed"} 9 + {"id":"mlecto-at2","title":"[EPIC] Migration System","description":"Versioned database migrations with up/down support. DSL for create_table, add_column, create_index, etc.","status":"closed","priority":1,"issue_type":"feature","created_at":"2026-01-04T00:21:12.288036672+01:00","created_by":"gdiazlo","updated_at":"2026-01-04T01:20:44.812043932+01:00","closed_at":"2026-01-04T01:20:44.812043932+01:00","close_reason":"All child tasks completed"} 10 + {"id":"mlecto-flv","title":"Implement Multi module (Mlecto.Multi)","description":"Chain operations: Multi.new |\u003e Multi.insert |\u003e Multi.update |\u003e Multi.run. Named operations, access previous results, atomic execution.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-04T00:21:50.583520239+01:00","created_by":"gdiazlo","updated_at":"2026-01-04T01:20:32.435485703+01:00","closed_at":"2026-01-04T01:20:32.435485703+01:00","close_reason":"Multi with operation chaining, named results, merge, validate, execute_sync","dependencies":[{"issue_id":"mlecto-flv","depends_on_id":"mlecto-8ki","type":"blocks","created_at":"2026-01-04T00:22:07.51964609+01:00","created_by":"gdiazlo"},{"issue_id":"mlecto-flv","depends_on_id":"mlecto-hwj","type":"blocks","created_at":"2026-01-04T00:22:07.527438964+01:00","created_by":"gdiazlo"}]} 11 + {"id":"mlecto-gfo","title":"[EPIC] Changeset System","description":"Casting, validation, and error accumulation. Separate validation from persistence with composable validators.","status":"closed","priority":1,"issue_type":"feature","created_at":"2026-01-04T00:21:07.363157981+01:00","created_by":"gdiazlo","updated_at":"2026-01-04T01:20:44.808207655+01:00","closed_at":"2026-01-04T01:20:44.808207655+01:00","close_reason":"All child tasks completed"} 12 + {"id":"mlecto-gxh","title":"Implement Changeset constraints","description":"unique_constraint, foreign_key_constraint, check_constraint, exclusion_constraint. Convert DB errors to changeset errors.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-04T00:21:42.297745588+01:00","created_by":"gdiazlo","updated_at":"2026-01-04T01:14:08.663238895+01:00","closed_at":"2026-01-04T01:14:08.663238895+01:00","close_reason":"Constraints implemented in Changeset core: unique_constraint, foreign_key_constraint, check_constraint.","dependencies":[{"issue_id":"mlecto-gxh","depends_on_id":"mlecto-m13","type":"blocks","created_at":"2026-01-04T00:22:03.965402454+01:00","created_by":"gdiazlo"}]} 13 + {"id":"mlecto-hw6","title":"[EPIC] mlecto - Ecto-like database toolkit for OCaml","status":"closed","priority":1,"issue_type":"feature","created_at":"2026-01-04T00:20:55.991644266+01:00","created_by":"gdiazlo","updated_at":"2026-01-04T01:20:49.927971061+01:00","closed_at":"2026-01-04T01:20:49.927971061+01:00","close_reason":"Core mlecto library complete: Type, Schema, Expr, Query, Changeset, Repo, Migration, Multi modules implemented"} 14 + {"id":"mlecto-hwj","title":"Implement Repo transactions","description":"transaction/1 function wrapping Caqti transactions. Automatic rollback on error. Nested transaction support (savepoints).","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-04T00:21:46.455864854+01:00","created_by":"gdiazlo","updated_at":"2026-01-04T01:19:20.791036484+01:00","closed_at":"2026-01-04T01:19:20.791036484+01:00","close_reason":"Transaction state machine with BEGIN/COMMIT/ROLLBACK and nested savepoint support","dependencies":[{"issue_id":"mlecto-hwj","depends_on_id":"mlecto-8ki","type":"blocks","created_at":"2026-01-04T00:22:05.217260647+01:00","created_by":"gdiazlo"}]} 15 + {"id":"mlecto-l6s","title":"[EPIC] Query DSL","description":"Type-safe SQL query builder using GADTs and phantom types. Composable queries as first-class values.","status":"closed","priority":1,"issue_type":"feature","created_at":"2026-01-04T00:21:08.537372834+01:00","created_by":"gdiazlo","updated_at":"2026-01-04T01:20:44.809745142+01:00","closed_at":"2026-01-04T01:20:44.809745142+01:00","close_reason":"All child tasks completed"} 16 + {"id":"mlecto-m13","title":"Implement Changeset core (Mlecto.Changeset)","description":"Changeset record type: data, changes, errors, valid?, params, required. Cast function for external params. Change function for internal data.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-04T00:21:38.875652686+01:00","created_by":"gdiazlo","updated_at":"2026-01-04T01:13:59.358336893+01:00","closed_at":"2026-01-04T01:13:59.358336893+01:00","close_reason":"Changeset core complete: type-safe field handling, validations (required, format, length, inclusion, exclusion, number, acceptance, confirmation, custom), constraints (unique, foreign_key, check), error handling.","dependencies":[{"issue_id":"mlecto-m13","depends_on_id":"mlecto-z72","type":"blocks","created_at":"2026-01-04T00:22:03.941717052+01:00","created_by":"gdiazlo"},{"issue_id":"mlecto-m13","depends_on_id":"mlecto-1ud","type":"blocks","created_at":"2026-01-04T00:22:03.95059939+01:00","created_by":"gdiazlo"}]} 17 + {"id":"mlecto-nv7","title":"Implement Expression types (Mlecto.Expr)","description":"GADTs for SQL expressions: literals, columns, operators (+, -, =, \u003c\u003e, AND, OR), function calls, casts. Type-safe expression composition.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-04T00:21:26.662064082+01:00","created_by":"gdiazlo","updated_at":"2026-01-04T01:11:24.632729849+01:00","closed_at":"2026-01-04T01:11:24.632729849+01:00","close_reason":"Expression types complete: GADT for all SQL expressions, operators (comparison, logical, arithmetic), functions (aggregate, string, date, math), CASE, BETWEEN, IN, CAST, SQL generation.","dependencies":[{"issue_id":"mlecto-nv7","depends_on_id":"mlecto-z72","type":"blocks","created_at":"2026-01-04T00:22:02.531940181+01:00","created_by":"gdiazlo"}]} 18 + {"id":"mlecto-pvs","title":"Implement Migration runner","description":"schema_migrations table, version tracking, up/down execution, migrate/rollback commands, migration status reporting.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-04T00:21:48.916746145+01:00","created_by":"gdiazlo","updated_at":"2026-01-04T01:18:41.847908163+01:00","closed_at":"2026-01-04T01:18:41.847908163+01:00","close_reason":"Migration runner with version tracking, plan_migrate, plan_rollback, format_status","dependencies":[{"issue_id":"mlecto-pvs","depends_on_id":"mlecto-uek","type":"blocks","created_at":"2026-01-04T00:22:06.667807096+01:00","created_by":"gdiazlo"},{"issue_id":"mlecto-pvs","depends_on_id":"mlecto-8ki","type":"blocks","created_at":"2026-01-04T00:22:06.6757586+01:00","created_by":"gdiazlo"}]} 19 + {"id":"mlecto-uek","title":"Implement Migration DSL (Mlecto.Migration)","description":"create_table, alter_table, drop_table, add_column, remove_column, rename_column, add_index, remove_index, execute (raw SQL).","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-04T00:21:47.328369648+01:00","created_by":"gdiazlo","updated_at":"2026-01-04T01:16:42.721970553+01:00","closed_at":"2026-01-04T01:16:42.721970553+01:00","close_reason":"Core DSL and SQL generation complete, execution stubs in place","dependencies":[{"issue_id":"mlecto-uek","depends_on_id":"mlecto-z72","type":"blocks","created_at":"2026-01-04T00:22:06.653056982+01:00","created_by":"gdiazlo"},{"issue_id":"mlecto-uek","depends_on_id":"mlecto-1ud","type":"blocks","created_at":"2026-01-04T00:22:06.660984287+01:00","created_by":"gdiazlo"}]} 20 + {"id":"mlecto-z2x","title":"Setup project structure with Eio and Caqti dependencies","description":"Configure dune-project, add caqti-eio, caqti-driver-postgresql deps. Create module structure: Mlecto.Type, Mlecto.Schema, Mlecto.Expr, etc.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-04T00:21:21.359665857+01:00","created_by":"gdiazlo","updated_at":"2026-01-04T01:02:45.5845274+01:00","closed_at":"2026-01-04T01:02:45.5845274+01:00","close_reason":"Project structure set up with all module stubs. Build passes."} 21 + {"id":"mlecto-z72","title":"Implement SQL type system (Mlecto.Type)","description":"Map SQL types to OCaml: int, string, bool, float, option, datetime, uuid, json. Use GADTs for type witnesses.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-04T00:21:23.100408152+01:00","created_by":"gdiazlo","updated_at":"2026-01-04T01:07:01.473699561+01:00","closed_at":"2026-01-04T01:07:01.473699561+01:00","close_reason":"Type system complete: int, int32, int64, float, string, bool, bytes, ptime, ptime_span, pdate, uuid, json, option, array, tup2/3/4, custom. Caqti integration working.","dependencies":[{"issue_id":"mlecto-z72","depends_on_id":"mlecto-z2x","type":"blocks","created_at":"2026-01-04T00:22:02.51548369+01:00","created_by":"gdiazlo"}]}
+4
.beads/metadata.json
··· 1 + { 2 + "database": "beads.db", 3 + "jsonl_export": "issues.jsonl" 4 + }
+3
.gitattributes
··· 1 + 2 + # Use bd merge for beads JSONL files 3 + .beads/issues.jsonl merge=beads
+45
.gitignore
··· 1 + *.annot 2 + *.cmo 3 + *.cma 4 + *.cmi 5 + *.a 6 + *.o 7 + *.cmx 8 + *.cmxs 9 + *.cmxa 10 + 11 + # Files containing detailed information about the compilation (generated 12 + # by `ocamlc`/`ocamlopt` when invoked using the option `-bin-annot`). 13 + # These files are typically useful for code inspection tools 14 + # (e.g. Merlin). 15 + *.cmt 16 + *.cmti 17 + 18 + # ocamlbuild and Dune default working directory 19 + _build/ 20 + 21 + # ocamlbuild targets 22 + *.byte 23 + *.native 24 + 25 + # oasis generated files 26 + setup.data 27 + setup.log 28 + 29 + # Merlin configuring file for Vim and Emacs 30 + .merlin 31 + 32 + # Dune generated files 33 + *.install 34 + 35 + # Local OPAM switch 36 + _opam/ 37 + 38 + .vscode 39 + .idea 40 + 41 + # Node.js 42 + node_modules/ 43 + 44 + # OpenCode 45 + .opencode/
+2
.ocamlformat
··· 1 + version = 0.28.1 2 + profile = default
+40
AGENTS.md
··· 1 + # Agent Instructions 2 + 3 + This project uses **bd** (beads) for issue tracking. Run `bd onboard` to get started. 4 + 5 + ## Quick Reference 6 + 7 + ```bash 8 + bd ready # Find available work 9 + bd show <id> # View issue details 10 + bd update <id> --status in_progress # Claim work 11 + bd close <id> # Complete work 12 + bd sync # Sync with git 13 + ``` 14 + 15 + ## Landing the Plane (Session Completion) 16 + 17 + **When ending a work session**, you MUST complete ALL steps below. Work is NOT complete until `git push` succeeds. 18 + 19 + **MANDATORY WORKFLOW:** 20 + 21 + 1. **File issues for remaining work** - Create issues for anything that needs follow-up 22 + 2. **Run quality gates** (if code changed) - Tests, linters, builds 23 + 3. **Update issue status** - Close finished work, update in-progress items 24 + 4. **PUSH TO REMOTE** - This is MANDATORY: 25 + ```bash 26 + git pull --rebase 27 + bd sync 28 + git push 29 + git status # MUST show "up to date with origin" 30 + ``` 31 + 5. **Clean up** - Clear stashes, prune remote branches 32 + 6. **Verify** - All changes committed AND pushed 33 + 7. **Hand off** - Provide context for next session 34 + 35 + **CRITICAL RULES:** 36 + - Work is NOT complete until `git push` succeeds 37 + - NEVER stop before pushing - that leaves work stranded locally 38 + - NEVER say "ready to push when you are" - YOU must push 39 + - If push fails, resolve and retry until it succeeds 40 +
+15
LICENSE
··· 1 + ISC License 2 + 3 + Copyright (c) 2026 Gabriel Díaz López de la Llave 4 + 5 + Permission to use, copy, modify, and/or distribute this software for any 6 + purpose with or without fee is hereby granted, provided that the above 7 + copyright notice and this permission notice appear in all copies. 8 + 9 + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH 10 + REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY 11 + AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, 12 + INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM 13 + LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR 14 + OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR 15 + PERFORMANCE OF THIS SOFTWARE.
+4
bin/dune
··· 1 + (executable 2 + (public_name mlecto) 3 + (name main) 4 + (libraries mlecto))
+1
bin/main.ml
··· 1 + let () = print_endline "mlecto - Ecto-like database toolkit for OCaml"
+42
dune-project
··· 1 + (lang dune 3.20) 2 + 3 + (name mlecto) 4 + 5 + (generate_opam_files true) 6 + 7 + (source 8 + (github gdiazlo/mlecto)) 9 + 10 + (authors "Guillermo Diaz-Romero <guillermo.diaz@gmail.com>") 11 + 12 + (maintainers "Guillermo Diaz-Romero <guillermo.diaz@gmail.com>") 13 + 14 + (license MIT) 15 + 16 + (documentation https://github.com/gdiazlo/mlecto) 17 + 18 + (package 19 + (name mlecto) 20 + (synopsis "Ecto-like database toolkit for OCaml") 21 + (description 22 + "mlecto is a database toolkit inspired by Elixir's Ecto. It provides type-safe query building, schema definitions, changesets for data validation, and database migrations. Built on Caqti with Eio for async operations.") 23 + (depends 24 + (ocaml (>= 5.1)) 25 + (dune (>= 3.20)) 26 + ; Database 27 + (caqti (>= 2.1)) 28 + (caqti-driver-postgresql (>= 2.1)) 29 + (caqti-eio (>= 2.1)) 30 + ; Time handling 31 + (ptime (>= 1.1)) 32 + ; Regex for validations 33 + (re (>= 1.11)) 34 + ; UUID support 35 + (uuidm (>= 0.9)) 36 + ; JSON support 37 + (yojson (>= 2.0)) 38 + ; Testing 39 + (alcotest (and (>= 1.7) :with-test)) 40 + (alcotest-lwt (and (>= 1.7) :with-test))) 41 + (tags 42 + (database postgresql ecto orm query-builder migrations)))
+24
lib/dune
··· 1 + (library 2 + (name mlecto) 3 + (public_name mlecto) 4 + (libraries 5 + caqti 6 + caqti-eio 7 + caqti-driver-postgresql 8 + ptime 9 + re 10 + uuidm 11 + yojson 12 + eio) 13 + (modules 14 + mlecto 15 + mlecto_type 16 + mlecto_schema 17 + mlecto_field 18 + mlecto_expr 19 + mlecto_query 20 + mlecto_changeset 21 + mlecto_repo 22 + mlecto_migration 23 + mlecto_multi 24 + mlecto_error))
+10
lib/mlecto.ml
··· 1 + module Type = Mlecto_type 2 + module Field = Mlecto_field 3 + module Schema = Mlecto_schema 4 + module Expr = Mlecto_expr 5 + module Query = Mlecto_query 6 + module Changeset = Mlecto_changeset 7 + module Repo = Mlecto_repo 8 + module Migration = Mlecto_migration 9 + module Multi = Mlecto_multi 10 + module Error = Mlecto_error
+339
lib/mlecto_changeset.ml
··· 1 + type change = 2 + | StringChange of string 3 + | IntChange of int 4 + | FloatChange of float 5 + | BoolChange of bool 6 + | NullChange 7 + 8 + type 'a t = { 9 + data : 'a; 10 + changes : (string * change) list; 11 + errors : Mlecto_error.validation_error list; 12 + valid : bool; 13 + action : action option; 14 + constraints : constraint_def list; 15 + } 16 + 17 + and action = Insert | Update | Delete 18 + 19 + and constraint_def = { 20 + constraint_name : string; 21 + constraint_field : string; 22 + constraint_type : constraint_type; 23 + } 24 + 25 + and constraint_type = 26 + | UniqueConstraint 27 + | ForeignKeyConstraint of { table : string; column : string } 28 + | CheckConstraint of string 29 + 30 + let create data = 31 + { 32 + data; 33 + changes = []; 34 + errors = []; 35 + valid = true; 36 + action = None; 37 + constraints = []; 38 + } 39 + 40 + let change data = create data 41 + 42 + let for_insert data = 43 + { 44 + data; 45 + changes = []; 46 + errors = []; 47 + valid = true; 48 + action = Some Insert; 49 + constraints = []; 50 + } 51 + 52 + let for_update data = 53 + { 54 + data; 55 + changes = []; 56 + errors = []; 57 + valid = true; 58 + action = Some Update; 59 + constraints = []; 60 + } 61 + 62 + let cast t params ~fields = 63 + let field_names = List.map Mlecto_field.name fields in 64 + let filtered_params = 65 + List.filter (fun (k, _) -> List.mem k field_names) params 66 + in 67 + let changes = List.map (fun (k, v) -> (k, StringChange v)) filtered_params in 68 + { t with changes } 69 + 70 + let cast_assoc t assoc ~fields = 71 + let field_names = List.map Mlecto_field.name fields in 72 + let filtered = List.filter (fun (k, _) -> List.mem k field_names) assoc in 73 + { t with changes = filtered } 74 + 75 + let put_change t field value = 76 + let name = Mlecto_field.name field in 77 + let change = StringChange value in 78 + let changes = 79 + (name, change) :: List.filter (fun (k, _) -> k <> name) t.changes 80 + in 81 + { t with changes } 82 + 83 + let delete_change t field = 84 + let name = Mlecto_field.name field in 85 + let changes = List.filter (fun (k, _) -> k <> name) t.changes in 86 + { t with changes } 87 + 88 + let get_change t field = 89 + let name = Mlecto_field.name field in 90 + match List.assoc_opt name t.changes with 91 + | Some (StringChange s) -> Some s 92 + | _ -> None 93 + 94 + let get_field t field = Mlecto_field.get field t.data 95 + 96 + let add_error t ~field ~message ~validation = 97 + let error = Mlecto_error.{ field; message; validation } in 98 + { t with errors = error :: t.errors; valid = false } 99 + 100 + let validate_required fields t = 101 + let missing = 102 + List.filter 103 + (fun field -> 104 + let name = Mlecto_field.name field in 105 + not 106 + (List.exists 107 + (fun (k, v) -> 108 + k = name 109 + && 110 + match v with 111 + | StringChange s -> s <> "" 112 + | NullChange -> false 113 + | _ -> true) 114 + t.changes)) 115 + fields 116 + in 117 + List.fold_left 118 + (fun acc field -> 119 + add_error acc ~field:(Mlecto_field.name field) ~message:"can't be blank" 120 + ~validation:"required") 121 + t missing 122 + 123 + let validate_format field ~pattern t = 124 + let name = Mlecto_field.name field in 125 + match List.assoc_opt name t.changes with 126 + | None -> t 127 + | Some (StringChange value) -> 128 + let re = Re.Pcre.regexp pattern in 129 + if Re.execp re value then t 130 + else 131 + add_error t ~field:name ~message:"has invalid format" 132 + ~validation:"format" 133 + | _ -> t 134 + 135 + let validate_length field ?min ?max ?is t = 136 + let name = Mlecto_field.name field in 137 + match List.assoc_opt name t.changes with 138 + | None -> t 139 + | Some (StringChange value) -> ( 140 + let len = String.length value in 141 + let t = 142 + match min with 143 + | Some m when len < m -> 144 + add_error t ~field:name 145 + ~message:(Printf.sprintf "should be at least %d character(s)" m) 146 + ~validation:"length" 147 + | _ -> t 148 + in 149 + let t = 150 + match max with 151 + | Some m when len > m -> 152 + add_error t ~field:name 153 + ~message:(Printf.sprintf "should be at most %d character(s)" m) 154 + ~validation:"length" 155 + | _ -> t 156 + in 157 + match is with 158 + | Some expected when len <> expected -> 159 + add_error t ~field:name 160 + ~message:(Printf.sprintf "should be %d character(s)" expected) 161 + ~validation:"length" 162 + | _ -> t) 163 + | _ -> t 164 + 165 + let validate_inclusion field ~values t = 166 + let name = Mlecto_field.name field in 167 + match List.assoc_opt name t.changes with 168 + | None -> t 169 + | Some (StringChange value) -> 170 + if List.mem value values then t 171 + else add_error t ~field:name ~message:"is invalid" ~validation:"inclusion" 172 + | _ -> t 173 + 174 + let validate_exclusion field ~values t = 175 + let name = Mlecto_field.name field in 176 + match List.assoc_opt name t.changes with 177 + | None -> t 178 + | Some (StringChange value) -> 179 + if not (List.mem value values) then t 180 + else 181 + add_error t ~field:name ~message:"is reserved" ~validation:"exclusion" 182 + | _ -> t 183 + 184 + let validate_number field ?greater_than ?less_than ?greater_than_or_equal 185 + ?less_than_or_equal t = 186 + let name = Mlecto_field.name field in 187 + match List.assoc_opt name t.changes with 188 + | None -> t 189 + | Some (StringChange value) -> ( 190 + match int_of_string_opt value with 191 + | None -> 192 + add_error t ~field:name ~message:"is not a valid number" 193 + ~validation:"number" 194 + | Some n -> ( 195 + let t = 196 + match greater_than with 197 + | Some m when n <= m -> 198 + add_error t ~field:name 199 + ~message:(Printf.sprintf "must be greater than %d" m) 200 + ~validation:"number" 201 + | _ -> t 202 + in 203 + let t = 204 + match less_than with 205 + | Some m when n >= m -> 206 + add_error t ~field:name 207 + ~message:(Printf.sprintf "must be less than %d" m) 208 + ~validation:"number" 209 + | _ -> t 210 + in 211 + let t = 212 + match greater_than_or_equal with 213 + | Some m when n < m -> 214 + add_error t ~field:name 215 + ~message: 216 + (Printf.sprintf "must be greater than or equal to %d" m) 217 + ~validation:"number" 218 + | _ -> t 219 + in 220 + match less_than_or_equal with 221 + | Some m when n > m -> 222 + add_error t ~field:name 223 + ~message:(Printf.sprintf "must be less than or equal to %d" m) 224 + ~validation:"number" 225 + | _ -> t)) 226 + | Some (IntChange n) -> 227 + let t = 228 + match greater_than with 229 + | Some m when n <= m -> 230 + add_error t ~field:name 231 + ~message:(Printf.sprintf "must be greater than %d" m) 232 + ~validation:"number" 233 + | _ -> t 234 + in 235 + let t = 236 + match less_than with 237 + | Some m when n >= m -> 238 + add_error t ~field:name 239 + ~message:(Printf.sprintf "must be less than %d" m) 240 + ~validation:"number" 241 + | _ -> t 242 + in 243 + t 244 + | _ -> t 245 + 246 + let validate_acceptance field t = 247 + let name = Mlecto_field.name field in 248 + match List.assoc_opt name t.changes with 249 + | None -> 250 + add_error t ~field:name ~message:"must be accepted" 251 + ~validation:"acceptance" 252 + | Some (BoolChange true) -> t 253 + | Some (StringChange "true") -> t 254 + | Some (StringChange "1") -> t 255 + | _ -> 256 + add_error t ~field:name ~message:"must be accepted" 257 + ~validation:"acceptance" 258 + 259 + let validate_confirmation field ~confirmation_field t = 260 + let name = Mlecto_field.name field in 261 + let conf_name = Mlecto_field.name confirmation_field in 262 + let value = List.assoc_opt name t.changes in 263 + let conf_value = List.assoc_opt conf_name t.changes in 264 + match (value, conf_value) with 265 + | Some v1, Some v2 when v1 = v2 -> t 266 + | Some _, Some _ -> 267 + add_error t ~field:conf_name ~message:"does not match" 268 + ~validation:"confirmation" 269 + | _ -> t 270 + 271 + let validate_change field validator t = 272 + match get_change t field with 273 + | None -> t 274 + | Some value -> ( 275 + match validator value with 276 + | Ok () -> t 277 + | Error msg -> 278 + add_error t ~field:(Mlecto_field.name field) ~message:msg 279 + ~validation:"custom") 280 + 281 + let validate t f = f t 282 + 283 + let unique_constraint field t = 284 + let name = Mlecto_field.name field in 285 + let constraint_def = 286 + { 287 + constraint_name = name ^ "_unique"; 288 + constraint_field = name; 289 + constraint_type = UniqueConstraint; 290 + } 291 + in 292 + { t with constraints = constraint_def :: t.constraints } 293 + 294 + let foreign_key_constraint field ~references:(table, column) t = 295 + let name = Mlecto_field.name field in 296 + let constraint_def = 297 + { 298 + constraint_name = name ^ "_fkey"; 299 + constraint_field = name; 300 + constraint_type = ForeignKeyConstraint { table; column }; 301 + } 302 + in 303 + { t with constraints = constraint_def :: t.constraints } 304 + 305 + let check_constraint ~name field ~expression t = 306 + let field_name = Mlecto_field.name field in 307 + let constraint_def = 308 + { 309 + constraint_name = name; 310 + constraint_field = field_name; 311 + constraint_type = CheckConstraint expression; 312 + } 313 + in 314 + { t with constraints = constraint_def :: t.constraints } 315 + 316 + let is_valid t = t.valid 317 + let errors t = t.errors 318 + let data t = t.data 319 + let changes t = t.changes 320 + let action t = t.action 321 + 322 + let get_error t field = 323 + let name = Mlecto_field.name field in 324 + List.find_opt (fun e -> e.Mlecto_error.field = name) t.errors 325 + 326 + let has_error t field = Option.is_some (get_error t field) 327 + 328 + let traverse_errors t f = 329 + List.iter 330 + (fun err -> f err.Mlecto_error.field err.Mlecto_error.message) 331 + t.errors 332 + 333 + let error_messages t = 334 + List.map 335 + (fun err -> 336 + Printf.sprintf "%s %s" err.Mlecto_error.field err.Mlecto_error.message) 337 + t.errors 338 + 339 + let apply_action t = if t.valid then Ok t.data else Error t.errors
+42
lib/mlecto_error.ml
··· 1 + type db_error = 2 + | Connection_failed of string 3 + | Query_failed of string 4 + | Constraint_violation of { constraint_name : string; message : string } 5 + | Not_found 6 + | Multiple_results_found 7 + | Transaction_failed of string 8 + | Migration_failed of { version : int64; message : string } 9 + | Validation_failed of string list 10 + | Pool_exhausted 11 + | Timeout 12 + 13 + type validation_error = { 14 + field : string; 15 + message : string; 16 + validation : string; 17 + } 18 + 19 + type 'a db_result = ('a, db_error) result 20 + type 'a changeset_result = ('a, validation_error list) result 21 + 22 + let pp_db_error fmt = function 23 + | Connection_failed msg -> Format.fprintf fmt "Connection failed: %s" msg 24 + | Query_failed msg -> Format.fprintf fmt "Query failed: %s" msg 25 + | Constraint_violation { constraint_name; message } -> 26 + Format.fprintf fmt "Constraint violation (%s): %s" constraint_name message 27 + | Not_found -> Format.fprintf fmt "Record not found" 28 + | Multiple_results_found -> 29 + Format.fprintf fmt "Expected one result, got multiple" 30 + | Transaction_failed msg -> Format.fprintf fmt "Transaction failed: %s" msg 31 + | Migration_failed { version; message } -> 32 + Format.fprintf fmt "Migration %Ld failed: %s" version message 33 + | Validation_failed msgs -> 34 + Format.fprintf fmt "Validation failed: %s" (String.concat "; " msgs) 35 + | Pool_exhausted -> Format.fprintf fmt "Connection pool exhausted" 36 + | Timeout -> Format.fprintf fmt "Operation timed out" 37 + 38 + let pp_validation_error fmt { field; message; validation } = 39 + Format.fprintf fmt "%s: %s (%s)" field message validation 40 + 41 + let show_db_error err = Format.asprintf "%a" pp_db_error err 42 + let show_validation_error err = Format.asprintf "%a" pp_validation_error err
+217
lib/mlecto_expr.ml
··· 1 + type 'a t = 2 + | Lit : 'a * 'a Mlecto_type.t -> 'a t 3 + | Column : ('record, 'a) Mlecto_field.t -> 'a t 4 + | Null : 'a Mlecto_type.t -> 'a option t 5 + | Binop : string * 'a t * 'b t * 'c Mlecto_type.t -> 'c t 6 + | Unop : string * 'a t * 'b Mlecto_type.t -> 'b t 7 + | Func : string * wrapped list * 'a Mlecto_type.t -> 'a t 8 + | Raw : string -> 'a t 9 + | Param : int * 'a Mlecto_type.t -> 'a t 10 + | Cast : 'a t * 'b Mlecto_type.t -> 'b t 11 + | Case : (bool t * 'a t) list * 'a t option * 'a Mlecto_type.t -> 'a t 12 + | Between : 'a t * 'a t * 'a t -> bool t 13 + | InList : 'a t * 'a t list -> bool t 14 + | Subquery : string -> 'a t 15 + 16 + and wrapped = Wrapped : 'a t -> wrapped 17 + 18 + let int n = Lit (n, Mlecto_type.int) 19 + let int32 n = Lit (n, Mlecto_type.int32) 20 + let int64 n = Lit (n, Mlecto_type.int64) 21 + let float f = Lit (f, Mlecto_type.float) 22 + let string s = Lit (s, Mlecto_type.string) 23 + let bool b = Lit (b, Mlecto_type.bool) 24 + let null ty = Null ty 25 + let column field = Column field 26 + let raw s = Raw s 27 + let param n ty = Param (n, ty) 28 + let cast expr ty = Cast (expr, ty) 29 + let subquery sql = Subquery sql 30 + let ( = ) a b = Binop ("=", a, b, Mlecto_type.bool) 31 + let ( <> ) a b = Binop ("<>", a, b, Mlecto_type.bool) 32 + let ( < ) a b = Binop ("<", a, b, Mlecto_type.bool) 33 + let ( > ) a b = Binop (">", a, b, Mlecto_type.bool) 34 + let ( <= ) a b = Binop ("<=", a, b, Mlecto_type.bool) 35 + let ( >= ) a b = Binop (">=", a, b, Mlecto_type.bool) 36 + let ( && ) a b = Binop ("AND", a, b, Mlecto_type.bool) 37 + let ( || ) a b = Binop ("OR", a, b, Mlecto_type.bool) 38 + let not_ a = Unop ("NOT", a, Mlecto_type.bool) 39 + let is_null a = Unop ("IS NULL", a, Mlecto_type.bool) 40 + let is_not_null a = Unop ("IS NOT NULL", a, Mlecto_type.bool) 41 + let ( + ) a b = Binop ("+", a, b, Mlecto_type.int) 42 + let ( - ) a b = Binop ("-", a, b, Mlecto_type.int) 43 + let ( * ) a b = Binop ("*", a, b, Mlecto_type.int) 44 + let ( / ) a b = Binop ("/", a, b, Mlecto_type.int) 45 + let ( mod ) a b = Binop ("%", a, b, Mlecto_type.int) 46 + let ( +. ) a b = Binop ("+", a, b, Mlecto_type.float) 47 + let ( -. ) a b = Binop ("-", a, b, Mlecto_type.float) 48 + let ( *. ) a b = Binop ("*", a, b, Mlecto_type.float) 49 + let ( /. ) a b = Binop ("/", a, b, Mlecto_type.float) 50 + let like a pattern = Binop ("LIKE", a, string pattern, Mlecto_type.bool) 51 + let ilike a pattern = Binop ("ILIKE", a, string pattern, Mlecto_type.bool) 52 + 53 + let similar_to a pattern = 54 + Binop ("SIMILAR TO", a, string pattern, Mlecto_type.bool) 55 + 56 + let regexp a pattern = Binop ("~", a, string pattern, Mlecto_type.bool) 57 + let regexp_i a pattern = Binop ("~*", a, string pattern, Mlecto_type.bool) 58 + let between a low high = Between (a, low, high) 59 + let in_list a values = InList (a, values) 60 + let not_in_list a values = not_ (in_list a values) 61 + let case branches ~else_ ty = Case (branches, Some else_, ty) 62 + let case_no_else branches ty = Case (branches, None, ty) 63 + 64 + let coalesce exprs default_ty = 65 + Func ("COALESCE", List.map (fun e -> Wrapped e) exprs, default_ty) 66 + 67 + let nullif a b ty = 68 + Func ("NULLIF", [ Wrapped a; Wrapped b ], Mlecto_type.option ty) 69 + 70 + let greatest exprs ty = 71 + Func ("GREATEST", List.map (fun e -> Wrapped e) exprs, ty) 72 + 73 + let least exprs ty = Func ("LEAST", List.map (fun e -> Wrapped e) exprs, ty) 74 + let count expr = Func ("COUNT", [ Wrapped expr ], Mlecto_type.int64) 75 + 76 + let count_distinct expr = 77 + Func ("COUNT", [ Wrapped (Raw "DISTINCT "); Wrapped expr ], Mlecto_type.int64) 78 + 79 + let count_all = Func ("COUNT", [ Wrapped (Raw "*") ], Mlecto_type.int64) 80 + let sum expr = Func ("SUM", [ Wrapped expr ], Mlecto_type.int64) 81 + let avg expr = Func ("AVG", [ Wrapped expr ], Mlecto_type.float) 82 + let max_ expr ty = Func ("MAX", [ Wrapped expr ], ty) 83 + let min_ expr ty = Func ("MIN", [ Wrapped expr ], ty) 84 + let lower expr = Func ("LOWER", [ Wrapped expr ], Mlecto_type.string) 85 + let upper expr = Func ("UPPER", [ Wrapped expr ], Mlecto_type.string) 86 + let length expr = Func ("LENGTH", [ Wrapped expr ], Mlecto_type.int) 87 + let trim expr = Func ("TRIM", [ Wrapped expr ], Mlecto_type.string) 88 + let ltrim expr = Func ("LTRIM", [ Wrapped expr ], Mlecto_type.string) 89 + let rtrim expr = Func ("RTRIM", [ Wrapped expr ], Mlecto_type.string) 90 + 91 + let concat exprs = 92 + Func ("CONCAT", List.map (fun e -> Wrapped e) exprs, Mlecto_type.string) 93 + 94 + let concat_ws sep exprs = 95 + Func 96 + ( "CONCAT_WS", 97 + Wrapped (string sep) :: List.map (fun e -> Wrapped e) exprs, 98 + Mlecto_type.string ) 99 + 100 + let substring expr ~from ~for_ = 101 + Func 102 + ( "SUBSTRING", 103 + [ Wrapped expr; Wrapped (int from); Wrapped (int for_) ], 104 + Mlecto_type.string ) 105 + 106 + let replace expr ~from ~to_ = 107 + Func 108 + ( "REPLACE", 109 + [ Wrapped expr; Wrapped (string from); Wrapped (string to_) ], 110 + Mlecto_type.string ) 111 + 112 + let left expr n = 113 + Func ("LEFT", [ Wrapped expr; Wrapped (int n) ], Mlecto_type.string) 114 + 115 + let right expr n = 116 + Func ("RIGHT", [ Wrapped expr; Wrapped (int n) ], Mlecto_type.string) 117 + 118 + let now () = Func ("NOW", [], Mlecto_type.ptime) 119 + let current_date = Func ("CURRENT_DATE", [], Mlecto_type.pdate) 120 + let current_timestamp = Func ("CURRENT_TIMESTAMP", [], Mlecto_type.ptime) 121 + 122 + let date_part part expr = 123 + Func ("DATE_PART", [ Wrapped (string part); Wrapped expr ], Mlecto_type.float) 124 + 125 + let extract part expr = 126 + Func 127 + ( "EXTRACT", 128 + [ Wrapped (Raw (part ^ " FROM ")); Wrapped expr ], 129 + Mlecto_type.float ) 130 + 131 + let age a b = Func ("AGE", [ Wrapped a; Wrapped b ], Mlecto_type.ptime_span) 132 + let abs_ expr = Func ("ABS", [ Wrapped expr ], Mlecto_type.int) 133 + let ceil expr = Func ("CEIL", [ Wrapped expr ], Mlecto_type.float) 134 + let floor expr = Func ("FLOOR", [ Wrapped expr ], Mlecto_type.float) 135 + let round expr = Func ("ROUND", [ Wrapped expr ], Mlecto_type.float) 136 + let sqrt expr = Func ("SQRT", [ Wrapped expr ], Mlecto_type.float) 137 + 138 + let power base exp = 139 + Func ("POWER", [ Wrapped base; Wrapped exp ], Mlecto_type.float) 140 + 141 + let random = Func ("RANDOM", [], Mlecto_type.float) 142 + let gen_random_uuid = Func ("GEN_RANDOM_UUID", [], Mlecto_type.uuid) 143 + 144 + type wrapped_expr = W : 'a t -> wrapped_expr 145 + 146 + type 'a expr_list = 147 + | [] : unit expr_list 148 + | ( :: ) : 'a t * 'b expr_list -> ('a * 'b) expr_list 149 + 150 + let rec expr_list_to_list : type a. a expr_list -> wrapped_expr list = function 151 + | [] -> [] 152 + | e :: rest -> W e :: expr_list_to_list rest 153 + 154 + let escape_string s = 155 + let buf = Buffer.create (String.length s |> Stdlib.( * ) 2) in 156 + String.iter 157 + (fun c -> 158 + match c with 159 + | '\'' -> Buffer.add_string buf "''" 160 + | _ -> Buffer.add_char buf c) 161 + s; 162 + Buffer.contents buf 163 + 164 + let lit_to_sql : type a. a -> a Mlecto_type.t -> string = 165 + fun v ty -> 166 + match ty with 167 + | Mlecto_type.Int -> string_of_int v 168 + | Mlecto_type.Int32 -> Int32.to_string v 169 + | Mlecto_type.Int64 -> Int64.to_string v 170 + | Mlecto_type.Float -> Printf.sprintf "%g" v 171 + | Mlecto_type.String -> Printf.sprintf "'%s'" (escape_string v) 172 + | Mlecto_type.Bool -> if v then "TRUE" else "FALSE" 173 + | Mlecto_type.Uuid -> Printf.sprintf "'%s'" (Uuidm.to_string v) 174 + | Mlecto_type.Ptime -> Printf.sprintf "'%s'" (Ptime.to_rfc3339 v) 175 + | Mlecto_type.Pdate -> 176 + let y, m, d = v in 177 + Printf.sprintf "'%04d-%02d-%02d'" y m d 178 + | Mlecto_type.Json -> 179 + Printf.sprintf "'%s'" (escape_string (Yojson.Safe.to_string v)) 180 + | _ -> failwith "Unsupported literal type for SQL generation" 181 + 182 + let rec to_sql : type a. a t -> string = function 183 + | Lit (v, ty) -> lit_to_sql v ty 184 + | Column field -> Mlecto_field.qualified_name field 185 + | Null _ -> "NULL" 186 + | Binop (op, a, b, _) -> Printf.sprintf "(%s %s %s)" (to_sql a) op (to_sql b) 187 + | Unop (op, a, _) -> 188 + if String.contains op ' ' then Printf.sprintf "(%s %s)" (to_sql a) op 189 + else Printf.sprintf "(%s %s)" op (to_sql a) 190 + | Func (name, args, _) -> 191 + let arg_strs = List.map (fun (Wrapped e) -> to_sql e) args in 192 + Printf.sprintf "%s(%s)" name (String.concat ", " arg_strs) 193 + | Raw s -> s 194 + | Param (n, _) -> Printf.sprintf "$%d" n 195 + | Cast (expr, ty) -> 196 + Printf.sprintf "CAST(%s AS %s)" (to_sql expr) 197 + (Mlecto_type.sql_type_name ty) 198 + | Case (branches, else_, _) -> 199 + let branch_strs = 200 + List.map 201 + (fun (cond, result) -> 202 + Printf.sprintf "WHEN %s THEN %s" (to_sql cond) (to_sql result)) 203 + branches 204 + in 205 + let else_str = 206 + match else_ with 207 + | Some e -> Printf.sprintf " ELSE %s" (to_sql e) 208 + | None -> "" 209 + in 210 + Printf.sprintf "CASE %s%s END" (String.concat " " branch_strs) else_str 211 + | Between (a, low, high) -> 212 + Printf.sprintf "(%s BETWEEN %s AND %s)" (to_sql a) (to_sql low) 213 + (to_sql high) 214 + | InList (a, values) -> 215 + let value_strs = List.map to_sql values in 216 + Printf.sprintf "(%s IN (%s))" (to_sql a) (String.concat ", " value_strs) 217 + | Subquery sql -> Printf.sprintf "(%s)" sql
+44
lib/mlecto_field.ml
··· 1 + type ('record, 'value) t = { 2 + name : string; 3 + ty : 'value Mlecto_type.t; 4 + get : 'record -> 'value; 5 + set : 'value -> 'record -> 'record; 6 + table_name : string; 7 + primary_key : bool; 8 + unique : bool; 9 + nullable : bool; 10 + default : string option; 11 + } 12 + (** [('record, 'value) t] represents a field of type ['value] in ['record]. *) 13 + 14 + let make ?(primary_key = false) ?(unique = false) ?(default = None) ~table_name 15 + ~name ~ty ~get ~set () = 16 + { 17 + name; 18 + ty; 19 + get; 20 + set; 21 + table_name; 22 + primary_key; 23 + unique; 24 + nullable = Mlecto_type.is_nullable ty; 25 + default; 26 + } 27 + 28 + let name f = f.name 29 + let field_type f = f.ty 30 + let qualified_name f = f.table_name ^ "." ^ f.name 31 + let get f record = f.get record 32 + let set f value record = f.set value record 33 + let is_primary_key f = f.primary_key 34 + let is_unique f = f.unique 35 + let is_nullable f = f.nullable 36 + let caqti_type f = Mlecto_type.to_caqti f.ty 37 + 38 + type 'record field_list = 39 + | [] : 'record field_list 40 + | ( :: ) : ('record, 'a) t * 'record field_list -> 'record field_list 41 + 42 + let rec field_names : type r. r field_list -> string list = function 43 + | [] -> [] 44 + | f :: rest -> f.name :: field_names rest
+260
lib/mlecto_migration.ml
··· 1 + type column_def = { 2 + col_name : string; 3 + col_type : string; 4 + col_nullable : bool; 5 + col_primary_key : bool; 6 + col_unique : bool; 7 + col_default : string option; 8 + col_references : (string * string) option; 9 + } 10 + 11 + type index_def = { 12 + idx_name : string option; 13 + idx_table : string; 14 + idx_columns : string list; 15 + idx_unique : bool; 16 + } 17 + 18 + type operation = 19 + | Create_table of { name : string; columns : column_def list } 20 + | Drop_table of string 21 + | Alter_table of { name : string; changes : alter_change list } 22 + | Create_index of index_def 23 + | Drop_index of string 24 + | Execute of string 25 + 26 + and alter_change = 27 + | Add_column of column_def 28 + | Drop_column of string 29 + | Rename_column of { from : string; to_ : string } 30 + | Alter_column of { 31 + name : string; 32 + new_type : string option; 33 + new_nullable : bool option; 34 + } 35 + 36 + type t = { 37 + version : int64; 38 + name : string; 39 + up : operation list; 40 + down : operation list; 41 + } 42 + 43 + let column ?(nullable = true) ?(primary_key = false) ?(unique = false) ?default 44 + ?references name sql_type = 45 + { 46 + col_name = name; 47 + col_type = sql_type; 48 + col_nullable = nullable; 49 + col_primary_key = primary_key; 50 + col_unique = unique; 51 + col_default = default; 52 + col_references = references; 53 + } 54 + 55 + let create_table name columns = Create_table { name; columns } 56 + let drop_table name = Drop_table name 57 + let add_column col = Add_column col 58 + let drop_column name = Drop_column name 59 + let rename_column ~from ~to_ = Rename_column { from; to_ } 60 + let alter_table name changes = Alter_table { name; changes } 61 + 62 + let create_index ?(unique = false) ?name table columns = 63 + Create_index 64 + { 65 + idx_name = name; 66 + idx_table = table; 67 + idx_columns = columns; 68 + idx_unique = unique; 69 + } 70 + 71 + let drop_index name = Drop_index name 72 + let execute sql = Execute sql 73 + 74 + let timestamps () = 75 + [ 76 + column "inserted_at" "TIMESTAMPTZ" ~nullable:false ~default:"NOW()"; 77 + column "updated_at" "TIMESTAMPTZ" ~nullable:false ~default:"NOW()"; 78 + ] 79 + 80 + let migration ~version ~name ~up ~down = { version; name; up; down } 81 + 82 + let column_to_sql c = 83 + let parts = [ c.col_name; c.col_type ] in 84 + let parts = if c.col_nullable then parts else parts @ [ "NOT NULL" ] in 85 + let parts = if c.col_primary_key then parts @ [ "PRIMARY KEY" ] else parts in 86 + let parts = if c.col_unique then parts @ [ "UNIQUE" ] else parts in 87 + let parts = 88 + match c.col_default with 89 + | Some d -> parts @ [ "DEFAULT " ^ d ] 90 + | None -> parts 91 + in 92 + let parts = 93 + match c.col_references with 94 + | Some (table, col) -> 95 + parts @ [ Printf.sprintf "REFERENCES %s(%s)" table col ] 96 + | None -> parts 97 + in 98 + String.concat " " parts 99 + 100 + let operation_to_sql = function 101 + | Create_table { name; columns } -> 102 + let col_defs = List.map column_to_sql columns in 103 + Printf.sprintf "CREATE TABLE %s (\n %s\n)" name 104 + (String.concat ",\n " col_defs) 105 + | Drop_table name -> Printf.sprintf "DROP TABLE %s" name 106 + | Alter_table { name; changes } -> 107 + let change_strs = 108 + List.map 109 + (function 110 + | Add_column c -> Printf.sprintf "ADD COLUMN %s" (column_to_sql c) 111 + | Drop_column col -> Printf.sprintf "DROP COLUMN %s" col 112 + | Rename_column { from; to_ } -> 113 + Printf.sprintf "RENAME COLUMN %s TO %s" from to_ 114 + | Alter_column { name = col; new_type; new_nullable = _ } -> ( 115 + match new_type with 116 + | Some t -> Printf.sprintf "ALTER COLUMN %s TYPE %s" col t 117 + | None -> "")) 118 + changes 119 + in 120 + Printf.sprintf "ALTER TABLE %s %s" name (String.concat ", " change_strs) 121 + | Create_index { idx_name; idx_table; idx_columns; idx_unique } -> 122 + let name = 123 + match idx_name with 124 + | Some n -> n 125 + | None -> 126 + Printf.sprintf "%s_%s_index" idx_table 127 + (String.concat "_" idx_columns) 128 + in 129 + let unique_str = if idx_unique then "UNIQUE " else "" in 130 + Printf.sprintf "CREATE %sINDEX %s ON %s (%s)" unique_str name idx_table 131 + (String.concat ", " idx_columns) 132 + | Drop_index name -> Printf.sprintf "DROP INDEX %s" name 133 + | Execute sql -> sql 134 + 135 + let schema_migrations_table = 136 + Create_table 137 + { 138 + name = "schema_migrations"; 139 + columns = 140 + [ 141 + column "version" "BIGINT" ~nullable:false ~primary_key:true; 142 + column "name" "VARCHAR(255)" ~nullable:false; 143 + column "inserted_at" "TIMESTAMPTZ" ~nullable:false ~default:"NOW()"; 144 + ]; 145 + } 146 + 147 + let create_schema_migrations_sql = operation_to_sql schema_migrations_table 148 + 149 + let insert_migration_sql = 150 + "INSERT INTO schema_migrations (version, name) VALUES ($1, $2)" 151 + 152 + let delete_migration_sql = "DELETE FROM schema_migrations WHERE version = $1" 153 + 154 + let get_applied_versions_sql = 155 + "SELECT version FROM schema_migrations ORDER BY version" 156 + 157 + let get_migration_status_sql = 158 + "SELECT version, name, inserted_at FROM schema_migrations ORDER BY version" 159 + 160 + type migration_status = { 161 + applied_versions : int64 list; 162 + pending : t list; 163 + last_applied : int64 option; 164 + } 165 + 166 + let sort_migrations migrations = 167 + List.sort (fun m1 m2 -> Int64.compare m1.version m2.version) migrations 168 + 169 + let pending_migrations ~applied_versions migrations = 170 + let is_applied v = List.mem v applied_versions in 171 + migrations 172 + |> List.filter (fun m -> not (is_applied m.version)) 173 + |> sort_migrations 174 + 175 + let generate_up_sql migration = List.map operation_to_sql migration.up 176 + let generate_down_sql migration = List.map operation_to_sql migration.down 177 + 178 + type migration_action = 179 + | Migrate of { sql : string list; version : int64; name : string } 180 + | Rollback of { sql : string list; version : int64 } 181 + | CreateSchemaTable of string 182 + | RecordMigration of { version : int64; name : string } 183 + | RemoveMigration of int64 184 + 185 + let plan_migrate ~applied_versions ~target migrations = 186 + let pending = pending_migrations ~applied_versions migrations in 187 + let to_run = 188 + match target with 189 + | None -> pending 190 + | Some v -> List.filter (fun m -> m.version <= v) pending 191 + in 192 + to_run 193 + |> List.map (fun m -> 194 + [ 195 + Migrate { sql = generate_up_sql m; version = m.version; name = m.name }; 196 + RecordMigration { version = m.version; name = m.name }; 197 + ]) 198 + |> List.flatten 199 + 200 + let plan_rollback ~applied_versions ~step migrations = 201 + let applied_sorted = 202 + List.sort (fun a b -> Int64.compare b a) applied_versions 203 + in 204 + let to_rollback = 205 + match step with 206 + | None -> ( match applied_sorted with v :: _ -> [ v ] | [] -> []) 207 + | Some n -> 208 + let rec take n lst = 209 + match (n, lst) with 210 + | 0, _ | _, [] -> [] 211 + | n, x :: xs -> x :: take (n - 1) xs 212 + in 213 + take n applied_sorted 214 + in 215 + to_rollback 216 + |> List.filter_map (fun v -> 217 + match List.find_opt (fun m -> m.version = v) migrations with 218 + | Some m -> 219 + Some 220 + [ 221 + Rollback { sql = generate_down_sql m; version = m.version }; 222 + RemoveMigration m.version; 223 + ] 224 + | None -> None) 225 + |> List.flatten 226 + 227 + let action_to_sql = function 228 + | CreateSchemaTable sql -> [ sql ] 229 + | Migrate { sql; _ } -> sql 230 + | Rollback { sql; _ } -> sql 231 + | RecordMigration { version; name } -> 232 + [ 233 + Printf.sprintf 234 + "INSERT INTO schema_migrations (version, name) VALUES (%Ld, '%s')" 235 + version name; 236 + ] 237 + | RemoveMigration version -> 238 + [ 239 + Printf.sprintf "DELETE FROM schema_migrations WHERE version = %Ld" 240 + version; 241 + ] 242 + 243 + let actions_to_sql actions = actions |> List.map action_to_sql |> List.flatten 244 + 245 + let format_status ~applied_versions ~migrations = 246 + let pending = pending_migrations ~applied_versions migrations in 247 + let buf = Buffer.create 256 in 248 + Buffer.add_string buf "Applied migrations:\n"; 249 + applied_versions 250 + |> List.iter (fun v -> 251 + match List.find_opt (fun m -> m.version = v) migrations with 252 + | Some m -> 253 + Buffer.add_string buf (Printf.sprintf " [✓] %Ld: %s\n" v m.name) 254 + | None -> 255 + Buffer.add_string buf (Printf.sprintf " [✓] %Ld: (unknown)\n" v)); 256 + Buffer.add_string buf "\nPending migrations:\n"; 257 + pending 258 + |> List.iter (fun m -> 259 + Buffer.add_string buf (Printf.sprintf " [ ] %Ld: %s\n" m.version m.name)); 260 + Buffer.contents buf
+99
lib/mlecto_multi.ml
··· 1 + type 'a op = 2 + | Insert : 'b Mlecto_changeset.t -> 'b op 3 + | Update : 'b Mlecto_changeset.t -> 'b op 4 + | Delete : { table : Mlecto_schema.table; id : int } -> unit op 5 + | Run : (results -> ('b, Mlecto_error.db_error) result) -> 'b op 6 + 7 + and entry = Entry : string * 'a op -> entry 8 + and results = (string * packed_result) list 9 + and packed_result = Result : 'a -> packed_result 10 + 11 + type t = entry list 12 + 13 + type multi_error = { 14 + failed_operation : string; 15 + error : Mlecto_error.db_error; 16 + completed : results; 17 + } 18 + 19 + let empty = [] 20 + let new_multi () = [] 21 + let insert name changeset t = t @ [ Entry (name, Insert changeset) ] 22 + let update name changeset t = t @ [ Entry (name, Update changeset) ] 23 + let delete name ~table ~id t = t @ [ Entry (name, Delete { table; id }) ] 24 + let run name f t = t @ [ Entry (name, Run f) ] 25 + let merge t1 t2 = t1 @ t2 26 + let prepend name changeset t = Entry (name, Insert changeset) :: t 27 + let append = insert 28 + let to_list t = t 29 + let names t = List.map (fun (Entry (name, _)) -> name) t 30 + let has_name name t = List.exists (fun (Entry (n, _)) -> n = name) t 31 + 32 + let get : type a. results -> string -> a option = 33 + fun results name -> 34 + match List.assoc_opt name results with 35 + | Some (Result v) -> Some (Obj.magic v) 36 + | None -> None 37 + 38 + let get_exn : type a. results -> string -> a = 39 + fun results name -> 40 + match get results name with 41 + | Some v -> v 42 + | None -> failwith (Printf.sprintf "Multi result not found: %s" name) 43 + 44 + type 'a execution_result = ('a, multi_error) Stdlib.result 45 + 46 + let validate_multi t = 47 + let names = names t in 48 + let unique_names = List.sort_uniq String.compare names in 49 + if List.length names <> List.length unique_names then 50 + Error "Duplicate operation names in Multi" 51 + else Ok () 52 + 53 + let run_operation : type a. 54 + a op -> results -> (a, Mlecto_error.db_error) Stdlib.result = 55 + fun op results -> 56 + match op with 57 + | Insert changeset -> 58 + if Mlecto_changeset.is_valid changeset then 59 + Ok (Mlecto_changeset.data changeset) 60 + else 61 + let errors = Mlecto_changeset.error_messages changeset in 62 + Error (Mlecto_error.Validation_failed errors) 63 + | Update changeset -> 64 + if Mlecto_changeset.is_valid changeset then 65 + Ok (Mlecto_changeset.data changeset) 66 + else 67 + let errors = Mlecto_changeset.error_messages changeset in 68 + Error (Mlecto_error.Validation_failed errors) 69 + | Delete { table; id } -> 70 + let _ = table in 71 + let _ = id in 72 + Ok () 73 + | Run f -> f results 74 + 75 + let execute_sync t : (results, multi_error) Stdlib.result = 76 + match validate_multi t with 77 + | Error msg -> 78 + Error 79 + { 80 + failed_operation = ""; 81 + error = Mlecto_error.Query_failed msg; 82 + completed = []; 83 + } 84 + | Ok () -> 85 + let rec run_all entries acc = 86 + match entries with 87 + | [] -> Ok acc 88 + | Entry (name, op) :: rest -> ( 89 + match run_operation op acc with 90 + | Ok result -> 91 + let acc' = (name, Result result) :: acc in 92 + run_all rest acc' 93 + | Error err -> 94 + Error { failed_operation = name; error = err; completed = acc }) 95 + in 96 + run_all t [] |> Result.map List.rev 97 + 98 + let execute = execute_sync 99 + let put name value results : results = (name, Result value) :: results
+301
lib/mlecto_query.ml
··· 1 + type join_kind = Inner | Left | Right | Full 2 + type order_direction = Asc | Desc 3 + type select_query 4 + type insert_query 5 + type update_query 6 + type delete_query 7 + 8 + type ('result, 'kind) t = { 9 + query_type : query_type; 10 + table : Mlecto_schema.table; 11 + select : Mlecto_expr.wrapped_expr list option; 12 + wheres : Mlecto_expr.wrapped_expr list; 13 + joins : join list; 14 + order_by : (Mlecto_expr.wrapped_expr * order_direction) list; 15 + group_by : Mlecto_expr.wrapped_expr list; 16 + having : Mlecto_expr.wrapped_expr list; 17 + limit : int option; 18 + offset : int option; 19 + distinct : bool; 20 + returning : Mlecto_expr.wrapped_expr list option; 21 + set_values : (string * Mlecto_expr.wrapped_expr) list; 22 + insert_columns : string list; 23 + insert_values : Mlecto_expr.wrapped_expr list list; 24 + conflict_target : string list option; 25 + conflict_action : conflict_action option; 26 + } 27 + 28 + and query_type = Select | Insert | Update | Delete 29 + 30 + and join = { 31 + kind : join_kind; 32 + table : Mlecto_schema.table; 33 + on : Mlecto_expr.wrapped_expr; 34 + } 35 + 36 + and conflict_action = 37 + | DoNothing 38 + | DoUpdate of (string * Mlecto_expr.wrapped_expr) list 39 + 40 + let empty_query table query_type = 41 + { 42 + query_type; 43 + table; 44 + select = None; 45 + wheres = []; 46 + joins = []; 47 + order_by = []; 48 + group_by = []; 49 + having = []; 50 + limit = None; 51 + offset = None; 52 + distinct = false; 53 + returning = None; 54 + set_values = []; 55 + insert_columns = []; 56 + insert_values = []; 57 + conflict_target = None; 58 + conflict_action = None; 59 + } 60 + 61 + let from table = empty_query table Select 62 + let insert_into table = empty_query table Insert 63 + let update table = empty_query table Update 64 + let delete_from table = empty_query table Delete 65 + 66 + let select exprs query = 67 + let wrapped = Mlecto_expr.expr_list_to_list exprs in 68 + { query with select = Some wrapped } 69 + 70 + let select_all query = { query with select = None } 71 + 72 + let where expr query = 73 + { query with wheres = Mlecto_expr.W expr :: query.wheres } 74 + 75 + let and_where expr query = where expr query 76 + 77 + let or_where expr query = 78 + match query.wheres with 79 + | [] -> where expr query 80 + | first :: rest -> 81 + let combined = 82 + Mlecto_expr.W 83 + (Mlecto_expr.( || ) 84 + (match first with Mlecto_expr.W e -> Obj.magic e) 85 + (Obj.magic expr)) 86 + in 87 + { query with wheres = combined :: rest } 88 + 89 + let join ?(kind = Inner) ~on joined_table query = 90 + let j = { kind; table = joined_table; on = Mlecto_expr.W on } in 91 + { query with joins = j :: query.joins } 92 + 93 + let left_join ~on table query = join ~kind:Left ~on table query 94 + let right_join ~on table query = join ~kind:Right ~on table query 95 + let inner_join ~on table query = join ~kind:Inner ~on table query 96 + let full_join ~on table query = join ~kind:Full ~on table query 97 + 98 + let order_by ?(direction = Asc) expr query = 99 + { query with order_by = (Mlecto_expr.W expr, direction) :: query.order_by } 100 + 101 + let asc expr query = order_by ~direction:Asc expr query 102 + let desc expr query = order_by ~direction:Desc expr query 103 + 104 + let group_by exprs query = 105 + let wrapped = Mlecto_expr.expr_list_to_list exprs in 106 + { query with group_by = wrapped @ query.group_by } 107 + 108 + let having expr query = 109 + { query with having = Mlecto_expr.W expr :: query.having } 110 + 111 + let limit n query = { query with limit = Some n } 112 + let offset n query = { query with offset = Some n } 113 + let distinct query = { query with distinct = true } 114 + 115 + let returning exprs query = 116 + let wrapped = Mlecto_expr.expr_list_to_list exprs in 117 + { query with returning = Some wrapped } 118 + 119 + let set col expr query = 120 + { 121 + query with 122 + set_values = (Mlecto_field.name col, Mlecto_expr.W expr) :: query.set_values; 123 + } 124 + 125 + let values columns rows query = 126 + let col_names = List.map Mlecto_field.name columns in 127 + let wrapped_rows = 128 + List.map (fun row -> List.map (fun e -> Mlecto_expr.W e) row) rows 129 + in 130 + { query with insert_columns = col_names; insert_values = wrapped_rows } 131 + 132 + let on_conflict_do_nothing ?(target = []) query = 133 + let target_names = 134 + if target = [] then None else Some (List.map Mlecto_field.name target) 135 + in 136 + { 137 + query with 138 + conflict_target = target_names; 139 + conflict_action = Some DoNothing; 140 + } 141 + 142 + let on_conflict_do_update ~target ~set query = 143 + let target_names = List.map Mlecto_field.name target in 144 + let set_pairs = 145 + List.map 146 + (fun (col, expr) -> (Mlecto_field.name col, Mlecto_expr.W expr)) 147 + set 148 + in 149 + { 150 + query with 151 + conflict_target = Some target_names; 152 + conflict_action = Some (DoUpdate set_pairs); 153 + } 154 + 155 + let join_kind_to_sql = function 156 + | Inner -> "INNER JOIN" 157 + | Left -> "LEFT JOIN" 158 + | Right -> "RIGHT JOIN" 159 + | Full -> "FULL JOIN" 160 + 161 + let direction_to_sql = function Asc -> "ASC" | Desc -> "DESC" 162 + let wrapped_to_sql (Mlecto_expr.W e) = Mlecto_expr.to_sql e 163 + 164 + let to_sql query = 165 + match query.query_type with 166 + | Select -> 167 + let distinct_str = if query.distinct then "DISTINCT " else "" in 168 + let select_str = 169 + match query.select with 170 + | None -> "*" 171 + | Some exprs -> String.concat ", " (List.map wrapped_to_sql exprs) 172 + in 173 + let from_str = Mlecto_schema.table_name query.table in 174 + let join_strs = 175 + List.rev_map 176 + (fun j -> 177 + Printf.sprintf "%s %s ON %s" (join_kind_to_sql j.kind) 178 + (Mlecto_schema.table_name j.table) 179 + (wrapped_to_sql j.on)) 180 + query.joins 181 + in 182 + let where_str = 183 + match List.rev query.wheres with 184 + | [] -> "" 185 + | exprs -> 186 + " WHERE " ^ String.concat " AND " (List.map wrapped_to_sql exprs) 187 + in 188 + let group_str = 189 + match List.rev query.group_by with 190 + | [] -> "" 191 + | exprs -> 192 + " GROUP BY " ^ String.concat ", " (List.map wrapped_to_sql exprs) 193 + in 194 + let having_str = 195 + match List.rev query.having with 196 + | [] -> "" 197 + | exprs -> 198 + " HAVING " ^ String.concat " AND " (List.map wrapped_to_sql exprs) 199 + in 200 + let order_str = 201 + match List.rev query.order_by with 202 + | [] -> "" 203 + | exprs -> 204 + " ORDER BY " 205 + ^ String.concat ", " 206 + (List.map 207 + (fun (e, d) -> wrapped_to_sql e ^ " " ^ direction_to_sql d) 208 + exprs) 209 + in 210 + let limit_str = 211 + match query.limit with 212 + | None -> "" 213 + | Some n -> Printf.sprintf " LIMIT %d" n 214 + in 215 + let offset_str = 216 + match query.offset with 217 + | None -> "" 218 + | Some n -> Printf.sprintf " OFFSET %d" n 219 + in 220 + let parts = 221 + [ 222 + Printf.sprintf "SELECT %s%s FROM %s" distinct_str select_str from_str; 223 + ] 224 + @ join_strs 225 + @ [ where_str; group_str; having_str; order_str; limit_str; offset_str ] 226 + in 227 + String.concat "" (List.filter (fun s -> s <> "") parts) 228 + | Insert -> 229 + let table_str = Mlecto_schema.table_name query.table in 230 + let cols_str = String.concat ", " query.insert_columns in 231 + let values_strs = 232 + List.map 233 + (fun row -> 234 + "(" ^ String.concat ", " (List.map wrapped_to_sql row) ^ ")") 235 + query.insert_values 236 + in 237 + let values_str = String.concat ", " values_strs in 238 + let conflict_str = 239 + match (query.conflict_target, query.conflict_action) with 240 + | Some target, Some DoNothing -> 241 + Printf.sprintf " ON CONFLICT (%s) DO NOTHING" 242 + (String.concat ", " target) 243 + | Some target, Some (DoUpdate sets) -> 244 + let set_strs = 245 + List.map 246 + (fun (col, expr) -> 247 + Printf.sprintf "%s = %s" col (wrapped_to_sql expr)) 248 + sets 249 + in 250 + Printf.sprintf " ON CONFLICT (%s) DO UPDATE SET %s" 251 + (String.concat ", " target) 252 + (String.concat ", " set_strs) 253 + | None, Some DoNothing -> " ON CONFLICT DO NOTHING" 254 + | _ -> "" 255 + in 256 + let returning_str = 257 + match query.returning with 258 + | None -> "" 259 + | Some exprs -> 260 + " RETURNING " ^ String.concat ", " (List.map wrapped_to_sql exprs) 261 + in 262 + Printf.sprintf "INSERT INTO %s (%s) VALUES %s%s%s" table_str cols_str 263 + values_str conflict_str returning_str 264 + | Update -> 265 + let table_str = Mlecto_schema.table_name query.table in 266 + let set_strs = 267 + List.rev_map 268 + (fun (col, expr) -> 269 + Printf.sprintf "%s = %s" col (wrapped_to_sql expr)) 270 + query.set_values 271 + in 272 + let set_str = String.concat ", " set_strs in 273 + let where_str = 274 + match List.rev query.wheres with 275 + | [] -> "" 276 + | exprs -> 277 + " WHERE " ^ String.concat " AND " (List.map wrapped_to_sql exprs) 278 + in 279 + let returning_str = 280 + match query.returning with 281 + | None -> "" 282 + | Some exprs -> 283 + " RETURNING " ^ String.concat ", " (List.map wrapped_to_sql exprs) 284 + in 285 + Printf.sprintf "UPDATE %s SET %s%s%s" table_str set_str where_str 286 + returning_str 287 + | Delete -> 288 + let table_str = Mlecto_schema.table_name query.table in 289 + let where_str = 290 + match List.rev query.wheres with 291 + | [] -> "" 292 + | exprs -> 293 + " WHERE " ^ String.concat " AND " (List.map wrapped_to_sql exprs) 294 + in 295 + let returning_str = 296 + match query.returning with 297 + | None -> "" 298 + | Some exprs -> 299 + " RETURNING " ^ String.concat ", " (List.map wrapped_to_sql exprs) 300 + in 301 + Printf.sprintf "DELETE FROM %s%s%s" table_str where_str returning_str
+174
lib/mlecto_repo.ml
··· 1 + type 'a result = ('a, Mlecto_error.db_error) Stdlib.result 2 + 3 + let caqti_error_to_db_error err : Mlecto_error.db_error = 4 + Mlecto_error.Query_failed (Caqti_error.show err) 5 + 6 + module type S = sig 7 + type conn 8 + 9 + val get_by_id : 10 + conn:conn -> 11 + table:Mlecto_schema.table -> 12 + id_column:string -> 13 + id:int -> 14 + sql:string -> 15 + unit result 16 + 17 + val execute_sql : conn:conn -> sql:string -> unit result 18 + val insert_changeset : 'a Mlecto_changeset.t -> 'a result 19 + val update_changeset : 'a Mlecto_changeset.t -> 'a result 20 + end 21 + 22 + module Sync = struct 23 + type conn = unit 24 + 25 + let get_by_id ~conn:() ~table ~id_column ~id ~sql:_ = 26 + let _ = table in 27 + let _ = id_column in 28 + let _ = id in 29 + Error Mlecto_error.Not_found 30 + 31 + let execute_sql ~conn:() ~sql:_ = Ok () 32 + 33 + let insert_changeset changeset = 34 + if Mlecto_changeset.is_valid changeset then 35 + Ok (Mlecto_changeset.data changeset) 36 + else 37 + let errors = Mlecto_changeset.error_messages changeset in 38 + Error (Mlecto_error.Query_failed (String.concat "; " errors)) 39 + 40 + let update_changeset changeset = 41 + if Mlecto_changeset.is_valid changeset then 42 + Ok (Mlecto_changeset.data changeset) 43 + else 44 + let errors = Mlecto_changeset.error_messages changeset in 45 + Error (Mlecto_error.Query_failed (String.concat "; " errors)) 46 + end 47 + 48 + let insert changeset = Sync.insert_changeset changeset 49 + let update changeset = Sync.update_changeset changeset 50 + 51 + let validate_and_insert changeset = 52 + if Mlecto_changeset.is_valid changeset then 53 + Ok (Mlecto_changeset.data changeset) 54 + else Error (Mlecto_changeset.errors changeset) 55 + 56 + let validate_and_update changeset = 57 + if Mlecto_changeset.is_valid changeset then 58 + Ok (Mlecto_changeset.data changeset) 59 + else Error (Mlecto_changeset.errors changeset) 60 + 61 + let build_insert_sql table columns = 62 + let table_name = Mlecto_schema.table_name table in 63 + let col_str = String.concat ", " columns in 64 + let placeholders = 65 + List.mapi (fun i _ -> Printf.sprintf "$%d" (i + 1)) columns 66 + in 67 + let val_str = String.concat ", " placeholders in 68 + Printf.sprintf "INSERT INTO %s (%s) VALUES (%s)" table_name col_str val_str 69 + 70 + let build_update_sql table columns ~where_column = 71 + let table_name = Mlecto_schema.table_name table in 72 + let set_clauses = 73 + List.mapi (fun i col -> Printf.sprintf "%s = $%d" col (i + 1)) columns 74 + in 75 + let set_str = String.concat ", " set_clauses in 76 + let where_idx = List.length columns + 1 in 77 + Printf.sprintf "UPDATE %s SET %s WHERE %s = $%d" table_name set_str 78 + where_column where_idx 79 + 80 + let build_delete_sql table ~where_column = 81 + let table_name = Mlecto_schema.table_name table in 82 + Printf.sprintf "DELETE FROM %s WHERE %s = $1" table_name where_column 83 + 84 + let build_select_sql table ~columns ~where_column = 85 + let table_name = Mlecto_schema.table_name table in 86 + let col_str = 87 + match columns with [] -> "*" | cols -> String.concat ", " cols 88 + in 89 + Printf.sprintf "SELECT %s FROM %s WHERE %s = $1" col_str table_name 90 + where_column 91 + 92 + type savepoint = { name : string; depth : int } 93 + type tx_state = { mutable depth : int; mutable savepoints : savepoint list } 94 + 95 + let create_tx_state () = { depth = 0; savepoints = [] } 96 + let begin_sql = "BEGIN" 97 + let commit_sql = "COMMIT" 98 + let rollback_sql = "ROLLBACK" 99 + let savepoint_sql name = Printf.sprintf "SAVEPOINT %s" name 100 + let release_savepoint_sql name = Printf.sprintf "RELEASE SAVEPOINT %s" name 101 + 102 + let rollback_to_savepoint_sql name = 103 + Printf.sprintf "ROLLBACK TO SAVEPOINT %s" name 104 + 105 + let generate_savepoint_name depth = Printf.sprintf "mlecto_sp_%d" depth 106 + 107 + type 'a tx_result = ('a, Mlecto_error.db_error) Stdlib.result 108 + 109 + type tx_action = 110 + | Begin 111 + | Commit 112 + | Rollback 113 + | Savepoint of string 114 + | ReleaseSavepoint of string 115 + | RollbackToSavepoint of string 116 + 117 + let tx_action_to_sql = function 118 + | Begin -> begin_sql 119 + | Commit -> commit_sql 120 + | Rollback -> rollback_sql 121 + | Savepoint name -> savepoint_sql name 122 + | ReleaseSavepoint name -> release_savepoint_sql name 123 + | RollbackToSavepoint name -> rollback_to_savepoint_sql name 124 + 125 + let enter_transaction state = 126 + if state.depth = 0 then begin 127 + state.depth <- 1; 128 + Begin 129 + end 130 + else begin 131 + let sp_name = generate_savepoint_name state.depth in 132 + let sp = { name = sp_name; depth = state.depth } in 133 + state.savepoints <- sp :: state.savepoints; 134 + state.depth <- state.depth + 1; 135 + Savepoint sp_name 136 + end 137 + 138 + let commit_transaction state = 139 + if state.depth <= 1 then begin 140 + state.depth <- 0; 141 + state.savepoints <- []; 142 + Commit 143 + end 144 + else begin 145 + match state.savepoints with 146 + | sp :: rest -> 147 + state.savepoints <- rest; 148 + state.depth <- state.depth - 1; 149 + ReleaseSavepoint sp.name 150 + | [] -> 151 + state.depth <- state.depth - 1; 152 + Commit 153 + end 154 + 155 + let rollback_transaction state = 156 + if state.depth <= 1 then begin 157 + state.depth <- 0; 158 + state.savepoints <- []; 159 + Rollback 160 + end 161 + else begin 162 + match state.savepoints with 163 + | sp :: rest -> 164 + state.savepoints <- rest; 165 + state.depth <- state.depth - 1; 166 + RollbackToSavepoint sp.name 167 + | [] -> 168 + state.depth <- 0; 169 + state.savepoints <- []; 170 + Rollback 171 + end 172 + 173 + let transaction_depth state = state.depth 174 + let in_transaction state = state.depth > 0
+164
lib/mlecto_schema.ml
··· 1 + type table = { name : string; schema : string option } 2 + 3 + let table ?(schema = None) name = { name; schema } 4 + 5 + let table_name t = 6 + match t.schema with Some s -> s ^ "." ^ t.name | None -> t.name 7 + 8 + type fk_action = Cascade | Restrict | SetNull | SetDefault | NoAction 9 + 10 + type foreign_key_ref = { 11 + fk_table : string; 12 + fk_column : string; 13 + fk_on_delete : fk_action option; 14 + fk_on_update : fk_action option; 15 + } 16 + 17 + type column_constraint = 18 + | PrimaryKey 19 + | NotNull 20 + | Unique 21 + | Default of string 22 + | Check of string 23 + | ForeignKey of foreign_key_ref 24 + 25 + type 'a column = { 26 + col_name : string; 27 + col_type : 'a Mlecto_type.t; 28 + col_constraints : column_constraint list; 29 + } 30 + 31 + type wrapped_column = Column : 'a column -> wrapped_column 32 + 33 + type table_def = { 34 + tbl_table : table; 35 + tbl_columns : wrapped_column list; 36 + tbl_primary_key : string list option; 37 + tbl_unique : string list list; 38 + tbl_checks : (string option * string) list; 39 + } 40 + 41 + let column ?(primary_key = false) ?(not_null = false) ?(unique = false) ?default 42 + ?check ?references name ty = 43 + let constraints = [] in 44 + let constraints = 45 + if primary_key then PrimaryKey :: constraints else constraints 46 + in 47 + let constraints = if not_null then NotNull :: constraints else constraints in 48 + let constraints = if unique then Unique :: constraints else constraints in 49 + let constraints = 50 + match default with 51 + | Some d -> Default d :: constraints 52 + | None -> constraints 53 + in 54 + let constraints = 55 + match check with Some c -> Check c :: constraints | None -> constraints 56 + in 57 + let constraints = 58 + match references with 59 + | Some r -> ForeignKey r :: constraints 60 + | None -> constraints 61 + in 62 + { col_name = name; col_type = ty; col_constraints = constraints } 63 + 64 + let references ?(on_delete = None) ?(on_update = None) ~table ~column () = 65 + { 66 + fk_table = table; 67 + fk_column = column; 68 + fk_on_delete = on_delete; 69 + fk_on_update = on_update; 70 + } 71 + 72 + let define ?(schema = None) name columns = 73 + let tbl = { name; schema } in 74 + { 75 + tbl_table = tbl; 76 + tbl_columns = List.map (fun c -> Column c) columns; 77 + tbl_primary_key = None; 78 + tbl_unique = []; 79 + tbl_checks = []; 80 + } 81 + 82 + let with_primary_key cols def = { def with tbl_primary_key = Some cols } 83 + let with_unique cols def = { def with tbl_unique = cols :: def.tbl_unique } 84 + 85 + let with_check ?name expr def = 86 + { def with tbl_checks = (name, expr) :: def.tbl_checks } 87 + 88 + let id_column () = 89 + column "id" Mlecto_type.int64 ~primary_key:true ~not_null:true 90 + 91 + let timestamps () = 92 + [ 93 + column "inserted_at" Mlecto_type.ptime ~not_null:true ~default:"NOW()"; 94 + column "updated_at" Mlecto_type.ptime ~not_null:true ~default:"NOW()"; 95 + ] 96 + 97 + let has_constraint c col = List.exists (fun con -> con = c) col.col_constraints 98 + let is_primary_key col = has_constraint PrimaryKey col 99 + let is_not_null col = has_constraint NotNull col || is_primary_key col 100 + let is_unique col = has_constraint Unique col 101 + 102 + let get_default col = 103 + List.find_map (function Default d -> Some d | _ -> None) col.col_constraints 104 + 105 + let get_foreign_key col = 106 + List.find_map 107 + (function ForeignKey fk -> Some fk | _ -> None) 108 + col.col_constraints 109 + 110 + let fk_action_to_sql = function 111 + | Cascade -> "CASCADE" 112 + | Restrict -> "RESTRICT" 113 + | SetNull -> "SET NULL" 114 + | SetDefault -> "SET DEFAULT" 115 + | NoAction -> "NO ACTION" 116 + 117 + let constraint_to_sql = function 118 + | PrimaryKey -> "PRIMARY KEY" 119 + | NotNull -> "NOT NULL" 120 + | Unique -> "UNIQUE" 121 + | Default expr -> "DEFAULT " ^ expr 122 + | Check expr -> "CHECK (" ^ expr ^ ")" 123 + | ForeignKey { fk_table; fk_column; fk_on_delete; fk_on_update } -> ( 124 + let base = Printf.sprintf "REFERENCES %s(%s)" fk_table fk_column in 125 + let base = 126 + match fk_on_delete with 127 + | Some od -> base ^ " ON DELETE " ^ fk_action_to_sql od 128 + | None -> base 129 + in 130 + match fk_on_update with 131 + | Some ou -> base ^ " ON UPDATE " ^ fk_action_to_sql ou 132 + | None -> base) 133 + 134 + let column_to_sql (Column col) = 135 + let type_name = Mlecto_type.sql_type_name col.col_type in 136 + let constraints = List.map constraint_to_sql col.col_constraints in 137 + String.concat " " (col.col_name :: type_name :: constraints) 138 + 139 + let table_def_to_sql def = 140 + let col_defs = List.map column_to_sql def.tbl_columns in 141 + let pk_constraint = 142 + match def.tbl_primary_key with 143 + | Some cols -> 144 + [ Printf.sprintf "PRIMARY KEY (%s)" (String.concat ", " cols) ] 145 + | None -> [] 146 + in 147 + let unique_constraints = 148 + List.map 149 + (fun cols -> Printf.sprintf "UNIQUE (%s)" (String.concat ", " cols)) 150 + def.tbl_unique 151 + in 152 + let check_constraints = 153 + List.map 154 + (fun (name, expr) -> 155 + match name with 156 + | Some n -> Printf.sprintf "CONSTRAINT %s CHECK (%s)" n expr 157 + | None -> Printf.sprintf "CHECK (%s)" expr) 158 + def.tbl_checks 159 + in 160 + let all_parts = 161 + col_defs @ pk_constraint @ unique_constraints @ check_constraints 162 + in 163 + Printf.sprintf "CREATE TABLE %s (\n %s\n)" (table_name def.tbl_table) 164 + (String.concat ",\n " all_parts)
+117
lib/mlecto_type.ml
··· 1 + (** ['a t] is a type witness mapping OCaml type ['a] to a SQL type. *) 2 + type _ t = 3 + | Int : int t 4 + | Int32 : int32 t 5 + | Int64 : int64 t 6 + | Float : float t 7 + | String : string t 8 + | Bool : bool t 9 + | Bytes : bytes t 10 + | Ptime : Ptime.t t 11 + | Ptime_span : Ptime.span t 12 + | Pdate : Ptime.date t 13 + | Uuid : Uuidm.t t 14 + | Json : Yojson.Safe.t t 15 + | Option : 'a t -> 'a option t 16 + | Array : 'a t -> 'a list t 17 + | Tup2 : 'a t * 'b t -> ('a * 'b) t 18 + | Tup3 : 'a t * 'b t * 'c t -> ('a * 'b * 'c) t 19 + | Tup4 : 'a t * 'b t * 'c t * 'd t -> ('a * 'b * 'c * 'd) t 20 + | Custom : { 21 + encode : 'a -> (string, string) result; 22 + decode : string -> ('a, string) result; 23 + sql_type : string; 24 + } 25 + -> 'a t 26 + 27 + let int = Int 28 + let int32 = Int32 29 + let int64 = Int64 30 + let float = Float 31 + let string = String 32 + let bool = Bool 33 + let bytes = Bytes 34 + let ptime = Ptime 35 + let ptime_span = Ptime_span 36 + let pdate = Pdate 37 + let uuid = Uuid 38 + let json = Json 39 + let option t = Option t 40 + let array t = Array t 41 + let tup2 a b = Tup2 (a, b) 42 + let tup3 a b c = Tup3 (a, b, c) 43 + let tup4 a b c d = Tup4 (a, b, c, d) 44 + let custom ~encode ~decode ~sql_type = Custom { encode; decode; sql_type } 45 + let ( ** ) a b = Tup2 (a, b) 46 + 47 + let rec sql_type_name : type a. a t -> string = function 48 + | Int -> "INTEGER" 49 + | Int32 -> "INTEGER" 50 + | Int64 -> "BIGINT" 51 + | Float -> "DOUBLE PRECISION" 52 + | String -> "TEXT" 53 + | Bool -> "BOOLEAN" 54 + | Bytes -> "BYTEA" 55 + | Ptime -> "TIMESTAMPTZ" 56 + | Ptime_span -> "INTERVAL" 57 + | Pdate -> "DATE" 58 + | Uuid -> "UUID" 59 + | Json -> "JSONB" 60 + | Option inner -> sql_type_name inner 61 + | Array inner -> sql_type_name inner ^ "[]" 62 + | Tup2 _ -> "RECORD" 63 + | Tup3 _ -> "RECORD" 64 + | Tup4 _ -> "RECORD" 65 + | Custom { sql_type; _ } -> sql_type 66 + 67 + let is_nullable : type a. a t -> bool = function Option _ -> true | _ -> false 68 + 69 + let rec to_caqti : type a. a t -> a Caqti_type.t = function 70 + | Int -> Caqti_type.int 71 + | Int32 -> Caqti_type.int32 72 + | Int64 -> Caqti_type.int64 73 + | Float -> Caqti_type.float 74 + | String -> Caqti_type.string 75 + | Bool -> Caqti_type.bool 76 + | Bytes -> 77 + Caqti_type.custom 78 + ~encode:(fun b -> Ok (Bytes.to_string b)) 79 + ~decode:(fun s -> Ok (Bytes.of_string s)) 80 + Caqti_type.octets 81 + | Ptime -> Caqti_type.ptime 82 + | Ptime_span -> Caqti_type.ptime_span 83 + | Pdate -> 84 + Caqti_type.custom 85 + ~encode:(fun (y, m, d) -> Ok (Printf.sprintf "%04d-%02d-%02d" y m d)) 86 + ~decode:(fun s -> 87 + try Scanf.sscanf s "%d-%d-%d" (fun y m d -> Ok (y, m, d)) 88 + with _ -> Error ("Invalid date: " ^ s)) 89 + Caqti_type.string 90 + | Uuid -> 91 + Caqti_type.custom 92 + ~encode:(fun u -> Ok (Uuidm.to_string u)) 93 + ~decode:(fun s -> 94 + match Uuidm.of_string s with 95 + | Some u -> Ok u 96 + | None -> Error ("Invalid UUID: " ^ s)) 97 + Caqti_type.string 98 + | Json -> 99 + Caqti_type.custom 100 + ~encode:(fun j -> Ok (Yojson.Safe.to_string j)) 101 + ~decode:(fun s -> 102 + try Ok (Yojson.Safe.from_string s) 103 + with Yojson.Json_error msg -> Error msg) 104 + Caqti_type.string 105 + | Option inner -> Caqti_type.option (to_caqti inner) 106 + | Array inner -> 107 + let inner_caqti = to_caqti inner in 108 + Caqti_type.custom 109 + ~encode:(fun _lst -> Error "Array encoding not yet implemented") 110 + ~decode:(fun _s -> Error "Array decoding not yet implemented") 111 + (Caqti_type.option inner_caqti) 112 + | Tup2 (a, b) -> Caqti_type.t2 (to_caqti a) (to_caqti b) 113 + | Tup3 (a, b, c) -> Caqti_type.t3 (to_caqti a) (to_caqti b) (to_caqti c) 114 + | Tup4 (a, b, c, d) -> 115 + Caqti_type.t4 (to_caqti a) (to_caqti b) (to_caqti c) (to_caqti d) 116 + | Custom { encode; decode; _ } -> 117 + Caqti_type.custom ~encode ~decode Caqti_type.string
+42
mlecto.opam
··· 1 + # This file is generated by dune, edit dune-project instead 2 + opam-version: "2.0" 3 + synopsis: "Ecto-like database toolkit for OCaml" 4 + description: 5 + "mlecto is a database toolkit inspired by Elixir's Ecto. It provides type-safe query building, schema definitions, changesets for data validation, and database migrations. Built on Caqti with Eio for async operations." 6 + maintainer: ["Guillermo Diaz-Romero <guillermo.diaz@gmail.com>"] 7 + authors: ["Guillermo Diaz-Romero <guillermo.diaz@gmail.com>"] 8 + license: "MIT" 9 + tags: ["database" "postgresql" "ecto" "orm" "query-builder" "migrations"] 10 + homepage: "https://github.com/gdiazlo/mlecto" 11 + doc: "https://github.com/gdiazlo/mlecto" 12 + bug-reports: "https://github.com/gdiazlo/mlecto/issues" 13 + depends: [ 14 + "ocaml" {>= "5.1"} 15 + "dune" {>= "3.20" & >= "3.20"} 16 + "caqti" {>= "2.1"} 17 + "caqti-driver-postgresql" {>= "2.1"} 18 + "caqti-eio" {>= "2.1"} 19 + "ptime" {>= "1.1"} 20 + "re" {>= "1.11"} 21 + "uuidm" {>= "0.9"} 22 + "yojson" {>= "2.0"} 23 + "alcotest" {>= "1.7" & with-test} 24 + "alcotest-lwt" {>= "1.7" & with-test} 25 + "odoc" {with-doc} 26 + ] 27 + build: [ 28 + ["dune" "subst"] {dev} 29 + [ 30 + "dune" 31 + "build" 32 + "-p" 33 + name 34 + "-j" 35 + jobs 36 + "@install" 37 + "@runtest" {with-test} 38 + "@doc" {with-doc} 39 + ] 40 + ] 41 + dev-repo: "git+https://github.com/gdiazlo/mlecto.git" 42 + x-maintenance-intent: ["(latest)"]
+3
test/dune
··· 1 + (test 2 + (name test_mlecto) 3 + (libraries mlecto alcotest))
+1
test/test_mlecto.ml
··· 1 + let () = print_endline "Tests not yet implemented"