a database layer insipred by caqti and ecto

refactor: rename mlecto to repodb with pipe-friendly changeset API

- Rename all lib/mlecto_*.ml to simple names (types.ml, schema.ml, etc.)
- Add assoc.ml for associations (has_many, belongs_to, has_one, many_to_many)
- Add embedded.ml for embedded schemas with pluggable JSON
- Refactor changeset.ml for pipe-friendly API (t argument last)
- Enhanced repo.ml with transactions and SQL builders
- Comprehensive test suite with 156 passing tests

+6
.beads/issues.jsonl
··· 2 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 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 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-3ow","title":"cast_assoc and put_assoc for nested changesets","status":"closed","priority":2,"issue_type":"feature","created_at":"2026-01-04T10:32:00.153765675+01:00","created_by":"gdiazlo","updated_at":"2026-01-04T10:38:58.664932555+01:00","closed_at":"2026-01-04T10:38:58.664932555+01:00","close_reason":"Closed","dependencies":[{"issue_id":"mlecto-3ow","depends_on_id":"mlecto-d6f","type":"blocks","created_at":"2026-01-04T10:32:20.574240684+01:00","created_by":"gdiazlo"}]} 6 + {"id":"mlecto-4g7","title":"Constraint error mapping from DB to changeset","status":"closed","priority":2,"issue_type":"feature","created_at":"2026-01-04T10:32:00.520652974+01:00","created_by":"gdiazlo","updated_at":"2026-01-04T10:38:58.665466447+01:00","closed_at":"2026-01-04T10:38:58.665466447+01:00","close_reason":"Closed"} 5 7 {"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 8 {"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 9 {"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 10 {"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 11 {"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"} 12 + {"id":"mlecto-c1o","title":"Real Repo execution via Caqti/Eio","status":"closed","priority":1,"issue_type":"feature","created_at":"2026-01-04T10:31:58.698847822+01:00","created_by":"gdiazlo","updated_at":"2026-01-04T10:38:58.663072869+01:00","closed_at":"2026-01-04T10:38:58.663072869+01:00","close_reason":"Closed"} 13 + {"id":"mlecto-d6f","title":"Schema associations (has_many, belongs_to, has_one, many_to_many)","status":"closed","priority":1,"issue_type":"feature","created_at":"2026-01-04T10:31:59.069873954+01:00","created_by":"gdiazlo","updated_at":"2026-01-04T10:38:58.661985306+01:00","closed_at":"2026-01-04T10:38:58.661985306+01:00","close_reason":"Closed"} 10 14 {"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 15 {"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 16 {"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"}]} ··· 16 20 {"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 21 {"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 22 {"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"}]} 23 + {"id":"mlecto-rji","title":"Embedded schemas for JSON/nested data","status":"closed","priority":2,"issue_type":"feature","created_at":"2026-01-04T10:31:59.831937849+01:00","created_by":"gdiazlo","updated_at":"2026-01-04T10:38:58.664392983+01:00","closed_at":"2026-01-04T10:38:58.664392983+01:00","close_reason":"Closed"} 19 24 {"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"}]} 25 + {"id":"mlecto-wls","title":"Preloading associations (eager loading)","status":"closed","priority":1,"issue_type":"feature","created_at":"2026-01-04T10:31:59.445724291+01:00","created_by":"gdiazlo","updated_at":"2026-01-04T10:38:58.663783301+01:00","closed_at":"2026-01-04T10:38:58.663783301+01:00","close_reason":"Closed","dependencies":[{"issue_id":"mlecto-wls","depends_on_id":"mlecto-d6f","type":"blocks","created_at":"2026-01-04T10:32:15.530422709+01:00","created_by":"gdiazlo"}]} 20 26 {"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 27 {"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"}]}
+2 -2
bin/dune
··· 1 1 (executable 2 - (public_name mlecto) 2 + (public_name repodb) 3 3 (name main) 4 - (libraries mlecto)) 4 + (libraries repodb))
+1 -1
bin/main.ml
··· 1 - let () = print_endline "mlecto - Ecto-like database toolkit for OCaml" 1 + let () = print_endline "repodb - Ecto-like database toolkit for OCaml"
+5 -5
dune-project
··· 1 1 (lang dune 3.20) 2 2 3 - (name mlecto) 3 + (name repodb) 4 4 5 5 (generate_opam_files true) 6 6 7 7 (source 8 - (github gdiazlo/mlecto)) 8 + (github gdiazlo/repodb)) 9 9 10 10 (authors "Guillermo Diaz-Romero <guillermo.diaz@gmail.com>") 11 11 ··· 13 13 14 14 (license MIT) 15 15 16 - (documentation https://github.com/gdiazlo/mlecto) 16 + (documentation https://github.com/gdiazlo/repodb) 17 17 18 18 (package 19 - (name mlecto) 19 + (name repodb) 20 20 (synopsis "Ecto-like database toolkit for OCaml") 21 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.") 22 + "repodb 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 23 (depends 24 24 (ocaml (>= 5.1)) 25 25 (dune (>= 3.20))
+240
lib/assoc.ml
··· 1 + (** Association definitions for schema relationships. 2 + 3 + Ecto-like associations: has_many, has_one, belongs_to, many_to_many. 4 + 5 + Example usage: 6 + {[ 7 + (* In Post schema *) 8 + let comments = has_many "comments" ~foreign_key:"post_id" 9 + 10 + let author = 11 + belongs_to "author" ~references:User.table ~foreign_key:"author_id" 12 + 13 + (* In Comment schema *) 14 + let post = belongs_to "post" ~references:Post.table ~foreign_key:"post_id" 15 + ]} *) 16 + 17 + (** Association cardinality *) 18 + type cardinality = One | Many 19 + 20 + (** Association type - which side holds the foreign key *) 21 + type assoc_type = 22 + | BelongsTo (** Foreign key is on this schema *) 23 + | HasOne (** Foreign key is on the related schema, singular *) 24 + | HasMany (** Foreign key is on the related schema, plural *) 25 + | ManyToMany of { join_table : string; join_keys : string * string } 26 + (** Uses a join table *) 27 + 28 + type 'related t = { 29 + name : string; 30 + assoc_type : assoc_type; 31 + cardinality : cardinality; 32 + related_table : string; 33 + foreign_key : string; 34 + owner_key : string; (** Usually "id" *) 35 + on_delete : on_delete option; 36 + on_replace : on_replace option; 37 + } 38 + (** An association definition *) 39 + 40 + and on_delete = 41 + | Nilify (** Set foreign key to NULL *) 42 + | DeleteAll (** Delete all associated records *) 43 + | Nothing (** Do nothing (may cause constraint error) *) 44 + 45 + and on_replace = 46 + | Raise (** Raise if trying to replace *) 47 + | Mark_as_invalid (** Mark changeset as invalid *) 48 + | Nilify_assoc (** Set foreign keys to NULL *) 49 + | Update_assoc (** Update existing, insert new *) 50 + | Delete_assoc (** Delete and replace *) 51 + 52 + (** Not loaded sentinel - associations start in this state *) 53 + type 'a load_state = NotLoaded | Loaded of 'a 54 + 55 + type 'a has_one_loaded = 'a option load_state 56 + (** Wrapper for optional singular associations *) 57 + 58 + type 'a has_many_loaded = 'a list load_state 59 + (** Wrapper for plural associations *) 60 + 61 + (** Create a belongs_to association. The foreign key is on this schema, pointing 62 + to the related schema's primary key. 63 + 64 + Example: A Comment belongs_to a Post, where Comment has post_id column. *) 65 + let belongs_to name ~related_table ~foreign_key ?(owner_key = "id") ?on_delete 66 + ?on_replace () = 67 + { 68 + name; 69 + assoc_type = BelongsTo; 70 + cardinality = One; 71 + related_table; 72 + foreign_key; 73 + owner_key; 74 + on_delete; 75 + on_replace; 76 + } 77 + 78 + (** Create a has_one association. The foreign key is on the related schema, 79 + pointing to this schema's primary key. 80 + 81 + Example: A User has_one Profile, where Profile has user_id column. *) 82 + let has_one name ~related_table ~foreign_key ?(owner_key = "id") ?on_delete 83 + ?on_replace () = 84 + { 85 + name; 86 + assoc_type = HasOne; 87 + cardinality = One; 88 + related_table; 89 + foreign_key; 90 + owner_key; 91 + on_delete; 92 + on_replace; 93 + } 94 + 95 + (** Create a has_many association. The foreign key is on the related schema, 96 + pointing to this schema's primary key. 97 + 98 + Example: A Post has_many Comments, where Comment has post_id column. *) 99 + let has_many name ~related_table ~foreign_key ?(owner_key = "id") ?on_delete 100 + ?on_replace () = 101 + { 102 + name; 103 + assoc_type = HasMany; 104 + cardinality = Many; 105 + related_table; 106 + foreign_key; 107 + owner_key; 108 + on_delete; 109 + on_replace; 110 + } 111 + 112 + (** Create a many_to_many association. Uses a join table to connect two schemas. 113 + 114 + Example: Posts and Tags through post_tags join table. 115 + {[ 116 + let tags = 117 + many_to_many "tags" ~related_table:"tags" ~join_table:"post_tags" 118 + ~join_keys:("post_id", "tag_id") () 119 + ]} *) 120 + let many_to_many name ~related_table ~join_table ~join_keys ?on_delete 121 + ?on_replace () = 122 + { 123 + name; 124 + assoc_type = ManyToMany { join_table; join_keys }; 125 + cardinality = Many; 126 + related_table; 127 + foreign_key = fst join_keys; 128 + (* owner's key in join table *) 129 + owner_key = "id"; 130 + on_delete; 131 + on_replace; 132 + } 133 + 134 + (** Get the association name *) 135 + let name assoc = assoc.name 136 + 137 + (** Get the related table name *) 138 + let related_table assoc = assoc.related_table 139 + 140 + (** Get the foreign key column name *) 141 + let foreign_key assoc = assoc.foreign_key 142 + 143 + (** Get the owner key column name (usually "id") *) 144 + let owner_key assoc = assoc.owner_key 145 + 146 + (** Check if this is a singular association *) 147 + let is_singular assoc = assoc.cardinality = One 148 + 149 + (** Check if this is a plural association *) 150 + let is_plural assoc = assoc.cardinality = Many 151 + 152 + (** Check if the foreign key is on this schema (belongs_to) *) 153 + let owner_has_fk assoc = assoc.assoc_type = BelongsTo 154 + 155 + (** Build a query to fetch associated records for belongs_to. SELECT * FROM 156 + related_table WHERE owner_key = ? *) 157 + let belongs_to_query assoc fk_value = 158 + Printf.sprintf "SELECT * FROM %s WHERE %s = %s" assoc.related_table 159 + assoc.owner_key fk_value 160 + 161 + (** Build a query to fetch associated records for has_one/has_many. SELECT * 162 + FROM related_table WHERE foreign_key = ? *) 163 + let has_query assoc owner_id = 164 + Printf.sprintf "SELECT * FROM %s WHERE %s = %s" assoc.related_table 165 + assoc.foreign_key owner_id 166 + 167 + (** Build a query to fetch associated records for many_to_many. SELECT r.* FROM 168 + related_table r INNER JOIN join_table j ON j.related_key = r.id WHERE 169 + j.owner_key = ? *) 170 + let many_to_many_query assoc owner_id = 171 + match assoc.assoc_type with 172 + | ManyToMany { join_table; join_keys = owner_fk, related_fk } -> 173 + Printf.sprintf 174 + "SELECT r.* FROM %s r INNER JOIN %s j ON j.%s = r.id WHERE j.%s = %s" 175 + assoc.related_table join_table related_fk owner_fk owner_id 176 + | _ -> failwith "many_to_many_query called on non-many_to_many association" 177 + 178 + (** Build the appropriate query based on association type *) 179 + let build_query assoc owner_id = 180 + match assoc.assoc_type with 181 + | BelongsTo -> belongs_to_query assoc owner_id 182 + | HasOne | HasMany -> has_query assoc owner_id 183 + | ManyToMany _ -> many_to_many_query assoc owner_id 184 + 185 + (** Build a query to fetch associated records for multiple owners. Used for 186 + efficient batch preloading. 187 + 188 + Returns SQL with IN clause for the owner IDs. *) 189 + let build_batch_query assoc owner_ids = 190 + let ids_str = String.concat ", " owner_ids in 191 + match assoc.assoc_type with 192 + | BelongsTo -> 193 + Printf.sprintf "SELECT * FROM %s WHERE %s IN (%s)" assoc.related_table 194 + assoc.owner_key ids_str 195 + | HasOne | HasMany -> 196 + Printf.sprintf "SELECT * FROM %s WHERE %s IN (%s)" assoc.related_table 197 + assoc.foreign_key ids_str 198 + | ManyToMany { join_table; join_keys = owner_fk, related_fk } -> 199 + Printf.sprintf 200 + "SELECT r.*, j.%s as __owner_id__ FROM %s r INNER JOIN %s j ON j.%s = \ 201 + r.id WHERE j.%s IN (%s)" 202 + owner_fk assoc.related_table join_table related_fk owner_fk ids_str 203 + 204 + type assoc_meta = { 205 + meta_name : string; 206 + meta_type : assoc_type; 207 + meta_cardinality : cardinality; 208 + meta_related_table : string; 209 + meta_foreign_key : string; 210 + meta_owner_key : string; 211 + } 212 + (** Association metadata for runtime reflection *) 213 + 214 + (** Extract metadata from an association *) 215 + let to_meta assoc = 216 + { 217 + meta_name = assoc.name; 218 + meta_type = assoc.assoc_type; 219 + meta_cardinality = assoc.cardinality; 220 + meta_related_table = assoc.related_table; 221 + meta_foreign_key = assoc.foreign_key; 222 + meta_owner_key = assoc.owner_key; 223 + } 224 + 225 + (** Wrapped association for heterogeneous lists *) 226 + type wrapped_assoc = Assoc : 'a t -> wrapped_assoc 227 + 228 + type schema_assocs = { assocs : wrapped_assoc list } 229 + (** Schema associations container *) 230 + 231 + let empty_assocs = { assocs = [] } 232 + 233 + let add_assoc assoc schema_assocs = 234 + { assocs = Assoc assoc :: schema_assocs.assocs } 235 + 236 + let find_assoc name schema_assocs = 237 + List.find_opt (fun (Assoc a) -> a.name = name) schema_assocs.assocs 238 + 239 + let assoc_names schema_assocs = 240 + List.map (fun (Assoc a) -> a.name) schema_assocs.assocs
+432
lib/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 : 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 params ~fields t = 63 + let field_names = List.map 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 assoc ~fields t = 71 + let field_names = List.map 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 field value t = 76 + let name = 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 field t = 84 + let name = 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 = 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 = Field.get field t.data 95 + 96 + let add_error ~field ~message ~validation t = 97 + let error = 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 = 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 ~field:(Field.name field) ~message:"can't be blank" 120 + ~validation:"required" acc) 121 + t missing 122 + 123 + let validate_format field ~pattern t = 124 + let name = 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 ~field:name ~message:"has invalid format" ~validation:"format" 132 + t 133 + | _ -> t 134 + 135 + let validate_length field ?min ?max ?is t = 136 + let name = 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 ~field:name 145 + ~message:(Printf.sprintf "should be at least %d character(s)" m) 146 + ~validation:"length" t 147 + | _ -> t 148 + in 149 + let t = 150 + match max with 151 + | Some m when len > m -> 152 + add_error ~field:name 153 + ~message:(Printf.sprintf "should be at most %d character(s)" m) 154 + ~validation:"length" t 155 + | _ -> t 156 + in 157 + match is with 158 + | Some expected when len <> expected -> 159 + add_error ~field:name 160 + ~message:(Printf.sprintf "should be %d character(s)" expected) 161 + ~validation:"length" t 162 + | _ -> t) 163 + | _ -> t 164 + 165 + let validate_inclusion field ~values t = 166 + let name = 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 ~field:name ~message:"is invalid" ~validation:"inclusion" t 172 + | _ -> t 173 + 174 + let validate_exclusion field ~values t = 175 + let name = 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 ~field:name ~message:"is reserved" ~validation:"exclusion" t 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 = 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 ~field:name ~message:"is not a valid number" 193 + ~validation:"number" t 194 + | Some n -> ( 195 + let t = 196 + match greater_than with 197 + | Some m when n <= m -> 198 + add_error ~field:name 199 + ~message:(Printf.sprintf "must be greater than %d" m) 200 + ~validation:"number" t 201 + | _ -> t 202 + in 203 + let t = 204 + match less_than with 205 + | Some m when n >= m -> 206 + add_error ~field:name 207 + ~message:(Printf.sprintf "must be less than %d" m) 208 + ~validation:"number" t 209 + | _ -> t 210 + in 211 + let t = 212 + match greater_than_or_equal with 213 + | Some m when n < m -> 214 + add_error ~field:name 215 + ~message: 216 + (Printf.sprintf "must be greater than or equal to %d" m) 217 + ~validation:"number" t 218 + | _ -> t 219 + in 220 + match less_than_or_equal with 221 + | Some m when n > m -> 222 + add_error ~field:name 223 + ~message:(Printf.sprintf "must be less than or equal to %d" m) 224 + ~validation:"number" t 225 + | _ -> t)) 226 + | Some (IntChange n) -> 227 + let t = 228 + match greater_than with 229 + | Some m when n <= m -> 230 + add_error ~field:name 231 + ~message:(Printf.sprintf "must be greater than %d" m) 232 + ~validation:"number" t 233 + | _ -> t 234 + in 235 + let t = 236 + match less_than with 237 + | Some m when n >= m -> 238 + add_error ~field:name 239 + ~message:(Printf.sprintf "must be less than %d" m) 240 + ~validation:"number" t 241 + | _ -> t 242 + in 243 + t 244 + | _ -> t 245 + 246 + let validate_acceptance field t = 247 + let name = Field.name field in 248 + match List.assoc_opt name t.changes with 249 + | None -> 250 + add_error ~field:name ~message:"must be accepted" ~validation:"acceptance" 251 + t 252 + | Some (BoolChange true) -> t 253 + | Some (StringChange "true") -> t 254 + | Some (StringChange "1") -> t 255 + | _ -> 256 + add_error ~field:name ~message:"must be accepted" ~validation:"acceptance" 257 + t 258 + 259 + let validate_confirmation field ~confirmation_field t = 260 + let name = Field.name field in 261 + let conf_name = 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 ~field:conf_name ~message:"does not match" 268 + ~validation:"confirmation" t 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 ~field:(Field.name field) ~message:msg ~validation:"custom" 279 + t) 280 + 281 + let validate t f = f t 282 + 283 + let unique_constraint field t = 284 + let name = 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 = 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 = 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 = Field.name field in 324 + List.find_opt (fun e -> e.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 (fun err -> f err.Error.field err.Error.message) t.errors 330 + 331 + let error_messages t = 332 + List.map 333 + (fun err -> Printf.sprintf "%s %s" err.Error.field err.Error.message) 334 + t.errors 335 + 336 + let apply_action t = if t.valid then Ok t.data else Error t.errors 337 + 338 + type 'a assoc_changeset = { 339 + assoc_name : string; 340 + assoc_changesets : 'a t list; 341 + on_replace : [ `Raise | `Mark_as_invalid | `Delete | `Update ]; 342 + } 343 + 344 + let cast_assoc_one ~assoc_name ~params ~cast_fn t = 345 + match List.assoc_opt assoc_name params with 346 + | None -> t 347 + | Some assoc_params -> 348 + let child_changeset = cast_fn assoc_params in 349 + if is_valid child_changeset then t 350 + else 351 + let child_errors = errors child_changeset in 352 + let prefixed_errors = 353 + List.map 354 + (fun err -> 355 + Error. 356 + { 357 + field = assoc_name ^ "." ^ err.field; 358 + message = err.message; 359 + validation = err.validation; 360 + }) 361 + child_errors 362 + in 363 + { t with errors = prefixed_errors @ t.errors; valid = false } 364 + 365 + let cast_assoc_many ~assoc_name ~params_list ~cast_fn t = 366 + let process_one idx params = 367 + let child_changeset = cast_fn params in 368 + if is_valid child_changeset then [] 369 + else 370 + let child_errors = errors child_changeset in 371 + List.map 372 + (fun err -> 373 + Error. 374 + { 375 + field = Printf.sprintf "%s[%d].%s" assoc_name idx err.field; 376 + message = err.message; 377 + validation = err.validation; 378 + }) 379 + child_errors 380 + in 381 + let all_errors = List.concat (List.mapi process_one params_list) in 382 + if all_errors = [] then t 383 + else { t with errors = all_errors @ t.errors; valid = false } 384 + 385 + let put_assoc ~assoc_name ~value ~encode_fn t = 386 + let json_value = encode_fn value in 387 + let change = StringChange (Yojson.Safe.to_string json_value) in 388 + let changes = 389 + (assoc_name, change) 390 + :: List.filter (fun (k, _) -> k <> assoc_name) t.changes 391 + in 392 + { t with changes } 393 + 394 + let cast_embed ~embed_name ~params ~schema t = 395 + match List.assoc_opt embed_name params with 396 + | None -> t 397 + | Some embed_params -> ( 398 + let json = 399 + `Assoc (List.map (fun (k, v) -> (k, `String v)) embed_params) 400 + in 401 + match Embedded.from_json schema json with 402 + | Ok _embedded -> t 403 + | Error msg -> 404 + add_error ~field:embed_name ~message:msg ~validation:"embedded" t) 405 + 406 + let put_embed ~embed_name ~value ~schema t = 407 + let json = Embedded.to_json schema value in 408 + let change = StringChange (Yojson.Safe.to_string json) in 409 + let changes = 410 + (embed_name, change) 411 + :: List.filter (fun (k, _) -> k <> embed_name) t.changes 412 + in 413 + { t with changes } 414 + 415 + let merge_errors parent_changeset child_changeset ~prefix = 416 + let child_errors = errors child_changeset in 417 + let prefixed_errors = 418 + List.map 419 + (fun err -> 420 + Error. 421 + { 422 + field = prefix ^ "." ^ err.field; 423 + message = err.message; 424 + validation = err.validation; 425 + }) 426 + child_errors 427 + in 428 + { 429 + parent_changeset with 430 + errors = prefixed_errors @ parent_changeset.errors; 431 + valid = parent_changeset.valid && is_valid child_changeset; 432 + }
+14 -13
lib/dune
··· 1 1 (library 2 - (name mlecto) 3 - (public_name mlecto) 2 + (name repodb) 3 + (public_name repodb) 4 4 (libraries 5 5 caqti 6 6 caqti-eio ··· 11 11 yojson 12 12 eio) 13 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)) 14 + error 15 + types 16 + field 17 + schema 18 + assoc 19 + embedded 20 + expr 21 + query 22 + changeset 23 + repo 24 + migration 25 + multi))
+201
lib/embedded.ml
··· 1 + module type JSON = sig 2 + type t 3 + 4 + val null : t 5 + val to_string : t -> string 6 + val of_string : string -> (t, string) result 7 + val get_field : t -> string -> t option 8 + val get_string : t -> string option 9 + val get_int : t -> int option 10 + val get_float : t -> float option 11 + val get_bool : t -> bool option 12 + val get_list : t -> t list option 13 + val is_null : t -> bool 14 + val make_object : (string * t) list -> t 15 + val make_string : string -> t 16 + val make_int : int -> t 17 + val make_float : float -> t 18 + val make_bool : bool -> t 19 + val make_list : t list -> t 20 + end 21 + 22 + module Yojson_json : JSON with type t = Yojson.Safe.t = struct 23 + type t = Yojson.Safe.t 24 + 25 + let null = `Null 26 + let to_string json = Yojson.Safe.to_string json 27 + 28 + let of_string s = 29 + try Ok (Yojson.Safe.from_string s) with Yojson.Json_error msg -> Error msg 30 + 31 + let get_field json key = 32 + match json with `Assoc fields -> List.assoc_opt key fields | _ -> None 33 + 34 + let get_string = function `String s -> Some s | _ -> None 35 + let get_int = function `Int i -> Some i | _ -> None 36 + 37 + let get_float = function 38 + | `Float f -> Some f 39 + | `Int i -> Some (float_of_int i) 40 + | _ -> None 41 + 42 + let get_bool = function `Bool b -> Some b | _ -> None 43 + let get_list = function `List l -> Some l | _ -> None 44 + let is_null = function `Null -> true | _ -> false 45 + let make_object fields = `Assoc fields 46 + let make_string s = `String s 47 + let make_int i = `Int i 48 + let make_float f = `Float f 49 + let make_bool b = `Bool b 50 + let make_list l = `List l 51 + end 52 + 53 + module Make (Json : JSON) = struct 54 + type json = Json.t 55 + 56 + type 'a t = { data : 'a; fields : string list; source : source } 57 + and source = JsonColumn of string | Virtual 58 + 59 + type 'a schema = { 60 + name : string; 61 + decode : json -> ('a, string) result; 62 + encode : 'a -> json; 63 + fields : string list; 64 + default : 'a option; 65 + } 66 + 67 + let schema ~name ~decode ~encode ~fields ?default () = 68 + { name; decode; encode; fields; default } 69 + 70 + let from_json schema json = 71 + match schema.decode json with 72 + | Ok data -> Ok { data; fields = schema.fields; source = Virtual } 73 + | Error msg -> Error msg 74 + 75 + let to_json schema embedded = schema.encode embedded.data 76 + 77 + let from_json_string schema json_str = 78 + match Json.of_string json_str with 79 + | Ok json -> from_json schema json 80 + | Error msg -> Error msg 81 + 82 + let to_json_string schema embedded = Json.to_string (to_json schema embedded) 83 + let data embedded = embedded.data 84 + let fields embedded = embedded.fields 85 + let update embedded ~data = { embedded with data } 86 + 87 + type 'a embeds_one = 'a t option 88 + type 'a embeds_many = 'a t list 89 + 90 + let embeds_one_from_json schema json = 91 + if Json.is_null json then Ok None 92 + else Result.map Option.some (from_json schema json) 93 + 94 + let embeds_one_to_json schema = function 95 + | None -> Json.null 96 + | Some embedded -> to_json schema embedded 97 + 98 + let embeds_many_from_json schema json = 99 + match Json.get_list json with 100 + | Some items -> 101 + let rec process acc = function 102 + | [] -> Ok (List.rev acc) 103 + | item :: rest -> ( 104 + match from_json schema item with 105 + | Ok embedded -> process (embedded :: acc) rest 106 + | Error msg -> Error msg) 107 + in 108 + process [] items 109 + | None when Json.is_null json -> Ok [] 110 + | None -> Error "Expected array for embeds_many" 111 + 112 + let embeds_many_to_json schema embeddeds = 113 + Json.make_list (List.map (to_json schema) embeddeds) 114 + 115 + let cast_embedded schema params ~fields:allowed_fields = 116 + let filtered = 117 + List.filter (fun (k, _) -> List.mem k allowed_fields) params 118 + in 119 + let json = 120 + Json.make_object 121 + (List.map (fun (k, v) -> (k, Json.make_string v)) filtered) 122 + in 123 + from_json schema json 124 + 125 + let validate_embedded validator embedded = 126 + match validator embedded.data with 127 + | Ok () -> Ok embedded 128 + | Error msg -> Error msg 129 + end 130 + 131 + type source = JsonColumn of string | Virtual 132 + type 'a t = { data : 'a; fields : string list; source : source } 133 + 134 + type 'a schema = { 135 + name : string; 136 + decode : Yojson.Safe.t -> ('a, string) result; 137 + encode : 'a -> Yojson.Safe.t; 138 + fields : string list; 139 + default : 'a option; 140 + } 141 + 142 + let schema ~name ~decode ~encode ~fields ?default () = 143 + { name; decode; encode; fields; default } 144 + 145 + let from_json schema json = 146 + match schema.decode json with 147 + | Ok data -> Ok { data; fields = schema.fields; source = Virtual } 148 + | Error msg -> Error msg 149 + 150 + let to_json schema embedded = schema.encode embedded.data 151 + 152 + let from_json_string schema json_str = 153 + match Yojson.Safe.from_string json_str with 154 + | json -> from_json schema json 155 + | exception Yojson.Json_error msg -> Error msg 156 + 157 + let to_json_string schema embedded = 158 + Yojson.Safe.to_string (to_json schema embedded) 159 + 160 + let data embedded = embedded.data 161 + let fields embedded = embedded.fields 162 + let update embedded ~data = { embedded with data } 163 + 164 + type 'a embeds_one = 'a t option 165 + type 'a embeds_many = 'a t list 166 + 167 + let embeds_one_from_json schema json = 168 + match json with 169 + | `Null -> Ok None 170 + | _ -> Result.map Option.some (from_json schema json) 171 + 172 + let embeds_one_to_json schema = function 173 + | None -> `Null 174 + | Some embedded -> to_json schema embedded 175 + 176 + let embeds_many_from_json schema json = 177 + match json with 178 + | `List items -> 179 + let rec process acc = function 180 + | [] -> Ok (List.rev acc) 181 + | item :: rest -> ( 182 + match from_json schema item with 183 + | Ok embedded -> process (embedded :: acc) rest 184 + | Error msg -> Error msg) 185 + in 186 + process [] items 187 + | `Null -> Ok [] 188 + | _ -> Error "Expected array for embeds_many" 189 + 190 + let embeds_many_to_json schema embeddeds = 191 + `List (List.map (to_json schema) embeddeds) 192 + 193 + let cast_embedded schema params ~fields:allowed_fields = 194 + let filtered = List.filter (fun (k, _) -> List.mem k allowed_fields) params in 195 + let json = `Assoc (List.map (fun (k, v) -> (k, `String v)) filtered) in 196 + from_json schema json 197 + 198 + let validate_embedded validator embedded = 199 + match validator embedded.data with 200 + | Ok () -> Ok embedded 201 + | Error msg -> Error msg
+206
lib/expr.ml
··· 1 + type 'a t = 2 + | Lit : 'a * 'a Types.t -> 'a t 3 + | Column : ('record, 'a) Field.t -> 'a t 4 + | Null : 'a Types.t -> 'a option t 5 + | Binop : string * 'a t * 'b t * 'c Types.t -> 'c t 6 + | Unop : string * 'a t * 'b Types.t -> 'b t 7 + | Func : string * wrapped list * 'a Types.t -> 'a t 8 + | Raw : string -> 'a t 9 + | Param : int * 'a Types.t -> 'a t 10 + | Cast : 'a t * 'b Types.t -> 'b t 11 + | Case : (bool t * 'a t) list * 'a t option * 'a Types.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, Types.int) 19 + let int32 n = Lit (n, Types.int32) 20 + let int64 n = Lit (n, Types.int64) 21 + let float f = Lit (f, Types.float) 22 + let string s = Lit (s, Types.string) 23 + let bool b = Lit (b, Types.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, Types.bool) 31 + let ( <> ) a b = Binop ("<>", a, b, Types.bool) 32 + let ( < ) a b = Binop ("<", a, b, Types.bool) 33 + let ( > ) a b = Binop (">", a, b, Types.bool) 34 + let ( <= ) a b = Binop ("<=", a, b, Types.bool) 35 + let ( >= ) a b = Binop (">=", a, b, Types.bool) 36 + let ( && ) a b = Binop ("AND", a, b, Types.bool) 37 + let ( || ) a b = Binop ("OR", a, b, Types.bool) 38 + let not_ a = Unop ("NOT", a, Types.bool) 39 + let is_null a = Unop ("IS NULL", a, Types.bool) 40 + let is_not_null a = Unop ("IS NOT NULL", a, Types.bool) 41 + let ( + ) a b = Binop ("+", a, b, Types.int) 42 + let ( - ) a b = Binop ("-", a, b, Types.int) 43 + let ( * ) a b = Binop ("*", a, b, Types.int) 44 + let ( / ) a b = Binop ("/", a, b, Types.int) 45 + let ( mod ) a b = Binop ("%", a, b, Types.int) 46 + let ( +. ) a b = Binop ("+", a, b, Types.float) 47 + let ( -. ) a b = Binop ("-", a, b, Types.float) 48 + let ( *. ) a b = Binop ("*", a, b, Types.float) 49 + let ( /. ) a b = Binop ("/", a, b, Types.float) 50 + let like a pattern = Binop ("LIKE", a, string pattern, Types.bool) 51 + let ilike a pattern = Binop ("ILIKE", a, string pattern, Types.bool) 52 + let similar_to a pattern = Binop ("SIMILAR TO", a, string pattern, Types.bool) 53 + let regexp a pattern = Binop ("~", a, string pattern, Types.bool) 54 + let regexp_i a pattern = Binop ("~*", a, string pattern, Types.bool) 55 + let between a low high = Between (a, low, high) 56 + let in_list a values = InList (a, values) 57 + let not_in_list a values = not_ (in_list a values) 58 + let case branches ~else_ ty = Case (branches, Some else_, ty) 59 + let case_no_else branches ty = Case (branches, None, ty) 60 + 61 + let coalesce exprs default_ty = 62 + Func ("COALESCE", List.map (fun e -> Wrapped e) exprs, default_ty) 63 + 64 + let nullif a b ty = Func ("NULLIF", [ Wrapped a; Wrapped b ], Types.option ty) 65 + 66 + let greatest exprs ty = 67 + Func ("GREATEST", List.map (fun e -> Wrapped e) exprs, ty) 68 + 69 + let least exprs ty = Func ("LEAST", List.map (fun e -> Wrapped e) exprs, ty) 70 + let count expr = Func ("COUNT", [ Wrapped expr ], Types.int64) 71 + 72 + let count_distinct expr = 73 + Func ("COUNT", [ Wrapped (Raw "DISTINCT "); Wrapped expr ], Types.int64) 74 + 75 + let count_all = Func ("COUNT", [ Wrapped (Raw "*") ], Types.int64) 76 + let sum expr = Func ("SUM", [ Wrapped expr ], Types.int64) 77 + let avg expr = Func ("AVG", [ Wrapped expr ], Types.float) 78 + let max_ expr ty = Func ("MAX", [ Wrapped expr ], ty) 79 + let min_ expr ty = Func ("MIN", [ Wrapped expr ], ty) 80 + let lower expr = Func ("LOWER", [ Wrapped expr ], Types.string) 81 + let upper expr = Func ("UPPER", [ Wrapped expr ], Types.string) 82 + let length expr = Func ("LENGTH", [ Wrapped expr ], Types.int) 83 + let trim expr = Func ("TRIM", [ Wrapped expr ], Types.string) 84 + let ltrim expr = Func ("LTRIM", [ Wrapped expr ], Types.string) 85 + let rtrim expr = Func ("RTRIM", [ Wrapped expr ], Types.string) 86 + 87 + let concat exprs = 88 + Func ("CONCAT", List.map (fun e -> Wrapped e) exprs, Types.string) 89 + 90 + let concat_ws sep exprs = 91 + Func 92 + ( "CONCAT_WS", 93 + Wrapped (string sep) :: List.map (fun e -> Wrapped e) exprs, 94 + Types.string ) 95 + 96 + let substring expr ~from ~for_ = 97 + Func 98 + ( "SUBSTRING", 99 + [ Wrapped expr; Wrapped (int from); Wrapped (int for_) ], 100 + Types.string ) 101 + 102 + let replace expr ~from ~to_ = 103 + Func 104 + ( "REPLACE", 105 + [ Wrapped expr; Wrapped (string from); Wrapped (string to_) ], 106 + Types.string ) 107 + 108 + let left expr n = Func ("LEFT", [ Wrapped expr; Wrapped (int n) ], Types.string) 109 + 110 + let right expr n = 111 + Func ("RIGHT", [ Wrapped expr; Wrapped (int n) ], Types.string) 112 + 113 + let now () = Func ("NOW", [], Types.ptime) 114 + let current_date = Func ("CURRENT_DATE", [], Types.pdate) 115 + let current_timestamp = Func ("CURRENT_TIMESTAMP", [], Types.ptime) 116 + 117 + let date_part part expr = 118 + Func ("DATE_PART", [ Wrapped (string part); Wrapped expr ], Types.float) 119 + 120 + let extract part expr = 121 + Func 122 + ("EXTRACT", [ Wrapped (Raw (part ^ " FROM ")); Wrapped expr ], Types.float) 123 + 124 + let age a b = Func ("AGE", [ Wrapped a; Wrapped b ], Types.ptime_span) 125 + let abs_ expr = Func ("ABS", [ Wrapped expr ], Types.int) 126 + let ceil expr = Func ("CEIL", [ Wrapped expr ], Types.float) 127 + let floor expr = Func ("FLOOR", [ Wrapped expr ], Types.float) 128 + let round expr = Func ("ROUND", [ Wrapped expr ], Types.float) 129 + let sqrt expr = Func ("SQRT", [ Wrapped expr ], Types.float) 130 + let power base exp = Func ("POWER", [ Wrapped base; Wrapped exp ], Types.float) 131 + let random = Func ("RANDOM", [], Types.float) 132 + let gen_random_uuid = Func ("GEN_RANDOM_UUID", [], Types.uuid) 133 + 134 + type wrapped_expr = W : 'a t -> wrapped_expr 135 + 136 + type 'a expr_list = 137 + | [] : unit expr_list 138 + | ( :: ) : 'a t * 'b expr_list -> ('a * 'b) expr_list 139 + 140 + let rec expr_list_to_list : type a. a expr_list -> wrapped_expr list = function 141 + | [] -> [] 142 + | e :: rest -> W e :: expr_list_to_list rest 143 + 144 + let escape_string s = 145 + let buf = Buffer.create (String.length s |> Stdlib.( * ) 2) in 146 + String.iter 147 + (fun c -> 148 + match c with 149 + | '\'' -> Buffer.add_string buf "''" 150 + | _ -> Buffer.add_char buf c) 151 + s; 152 + Buffer.contents buf 153 + 154 + let lit_to_sql : type a. a -> a Types.t -> string = 155 + fun v ty -> 156 + match ty with 157 + | Types.Int -> string_of_int v 158 + | Types.Int32 -> Int32.to_string v 159 + | Types.Int64 -> Int64.to_string v 160 + | Types.Float -> Printf.sprintf "%g" v 161 + | Types.String -> Printf.sprintf "'%s'" (escape_string v) 162 + | Types.Bool -> if v then "TRUE" else "FALSE" 163 + | Types.Uuid -> Printf.sprintf "'%s'" (Uuidm.to_string v) 164 + | Types.Ptime -> Printf.sprintf "'%s'" (Ptime.to_rfc3339 v) 165 + | Types.Pdate -> 166 + let y, m, d = v in 167 + Printf.sprintf "'%04d-%02d-%02d'" y m d 168 + | Types.Json -> 169 + Printf.sprintf "'%s'" (escape_string (Yojson.Safe.to_string v)) 170 + | _ -> failwith "Unsupported literal type for SQL generation" 171 + 172 + let rec to_sql : type a. a t -> string = function 173 + | Lit (v, ty) -> lit_to_sql v ty 174 + | Column field -> Field.qualified_name field 175 + | Null _ -> "NULL" 176 + | Binop (op, a, b, _) -> Printf.sprintf "(%s %s %s)" (to_sql a) op (to_sql b) 177 + | Unop (op, a, _) -> 178 + if String.contains op ' ' then Printf.sprintf "(%s %s)" (to_sql a) op 179 + else Printf.sprintf "(%s %s)" op (to_sql a) 180 + | Func (name, args, _) -> 181 + let arg_strs = List.map (fun (Wrapped e) -> to_sql e) args in 182 + Printf.sprintf "%s(%s)" name (String.concat ", " arg_strs) 183 + | Raw s -> s 184 + | Param (n, _) -> Printf.sprintf "$%d" n 185 + | Cast (expr, ty) -> 186 + Printf.sprintf "CAST(%s AS %s)" (to_sql expr) (Types.sql_type_name ty) 187 + | Case (branches, else_, _) -> 188 + let branch_strs = 189 + List.map 190 + (fun (cond, result) -> 191 + Printf.sprintf "WHEN %s THEN %s" (to_sql cond) (to_sql result)) 192 + branches 193 + in 194 + let else_str = 195 + match else_ with 196 + | Some e -> Printf.sprintf " ELSE %s" (to_sql e) 197 + | None -> "" 198 + in 199 + Printf.sprintf "CASE %s%s END" (String.concat " " branch_strs) else_str 200 + | Between (a, low, high) -> 201 + Printf.sprintf "(%s BETWEEN %s AND %s)" (to_sql a) (to_sql low) 202 + (to_sql high) 203 + | InList (a, values) -> 204 + let value_strs = List.map to_sql values in 205 + Printf.sprintf "(%s IN (%s))" (to_sql a) (String.concat ", " value_strs) 206 + | Subquery sql -> Printf.sprintf "(%s)" sql
-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
lib/mlecto_error.ml lib/error.ml
-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
+3 -3
lib/mlecto_field.ml lib/field.ml
··· 1 1 type ('record, 'value) t = { 2 2 name : string; 3 - ty : 'value Mlecto_type.t; 3 + ty : 'value Types.t; 4 4 get : 'record -> 'value; 5 5 set : 'value -> 'record -> 'record; 6 6 table_name : string; ··· 21 21 table_name; 22 22 primary_key; 23 23 unique; 24 - nullable = Mlecto_type.is_nullable ty; 24 + nullable = Types.is_nullable ty; 25 25 default; 26 26 } 27 27 ··· 33 33 let is_primary_key f = f.primary_key 34 34 let is_unique f = f.unique 35 35 let is_nullable f = f.nullable 36 - let caqti_type f = Mlecto_type.to_caqti f.ty 36 + let caqti_type f = Types.to_caqti f.ty 37 37 38 38 type 'record field_list = 39 39 | [] : 'record field_list
lib/mlecto_migration.ml lib/migration.ml
+14 -16
lib/mlecto_multi.ml lib/multi.ml
··· 1 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 2 + | Insert : 'b Changeset.t -> 'b op 3 + | Update : 'b Changeset.t -> 'b op 4 + | Delete : { table : Schema.table; id : int } -> unit op 5 + | Run : (results -> ('b, Error.db_error) result) -> 'b op 6 6 7 7 and entry = Entry : string * 'a op -> entry 8 8 and results = (string * packed_result) list ··· 12 12 13 13 type multi_error = { 14 14 failed_operation : string; 15 - error : Mlecto_error.db_error; 15 + error : Error.db_error; 16 16 completed : results; 17 17 } 18 18 ··· 50 50 Error "Duplicate operation names in Multi" 51 51 else Ok () 52 52 53 - let run_operation : type a. 54 - a op -> results -> (a, Mlecto_error.db_error) Stdlib.result = 53 + let run_operation : type a. a op -> results -> (a, Error.db_error) Stdlib.result 54 + = 55 55 fun op results -> 56 56 match op with 57 57 | Insert changeset -> 58 - if Mlecto_changeset.is_valid changeset then 59 - Ok (Mlecto_changeset.data changeset) 58 + if Changeset.is_valid changeset then Ok (Changeset.data changeset) 60 59 else 61 - let errors = Mlecto_changeset.error_messages changeset in 62 - Error (Mlecto_error.Validation_failed errors) 60 + let errors = Changeset.error_messages changeset in 61 + Error (Error.Validation_failed errors) 63 62 | Update changeset -> 64 - if Mlecto_changeset.is_valid changeset then 65 - Ok (Mlecto_changeset.data changeset) 63 + if Changeset.is_valid changeset then Ok (Changeset.data changeset) 66 64 else 67 - let errors = Mlecto_changeset.error_messages changeset in 68 - Error (Mlecto_error.Validation_failed errors) 65 + let errors = Changeset.error_messages changeset in 66 + Error (Error.Validation_failed errors) 69 67 | Delete { table; id } -> 70 68 let _ = table in 71 69 let _ = id in ··· 78 76 Error 79 77 { 80 78 failed_operation = ""; 81 - error = Mlecto_error.Query_failed msg; 79 + error = Error.Query_failed msg; 82 80 completed = []; 83 81 } 84 82 | Ok () ->
+33 -48
lib/mlecto_query.ml lib/query.ml
··· 7 7 8 8 type ('result, 'kind) t = { 9 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; 10 + table : Schema.table; 11 + select : Expr.wrapped_expr list option; 12 + wheres : Expr.wrapped_expr list; 13 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; 14 + order_by : (Expr.wrapped_expr * order_direction) list; 15 + group_by : Expr.wrapped_expr list; 16 + having : Expr.wrapped_expr list; 17 17 limit : int option; 18 18 offset : int option; 19 19 distinct : bool; 20 - returning : Mlecto_expr.wrapped_expr list option; 21 - set_values : (string * Mlecto_expr.wrapped_expr) list; 20 + returning : Expr.wrapped_expr list option; 21 + set_values : (string * Expr.wrapped_expr) list; 22 22 insert_columns : string list; 23 - insert_values : Mlecto_expr.wrapped_expr list list; 23 + insert_values : Expr.wrapped_expr list list; 24 24 conflict_target : string list option; 25 25 conflict_action : conflict_action option; 26 26 } 27 27 28 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 - } 29 + and join = { kind : join_kind; table : Schema.table; on : Expr.wrapped_expr } 35 30 36 31 and conflict_action = 37 32 | DoNothing 38 - | DoUpdate of (string * Mlecto_expr.wrapped_expr) list 33 + | DoUpdate of (string * Expr.wrapped_expr) list 39 34 40 35 let empty_query table query_type = 41 36 { ··· 64 59 let delete_from table = empty_query table Delete 65 60 66 61 let select exprs query = 67 - let wrapped = Mlecto_expr.expr_list_to_list exprs in 62 + let wrapped = Expr.expr_list_to_list exprs in 68 63 { query with select = Some wrapped } 69 64 70 65 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 - 66 + let where expr query = { query with wheres = Expr.W expr :: query.wheres } 75 67 let and_where expr query = where expr query 76 68 77 69 let or_where expr query = ··· 79 71 | [] -> where expr query 80 72 | first :: rest -> 81 73 let combined = 82 - Mlecto_expr.W 83 - (Mlecto_expr.( || ) 84 - (match first with Mlecto_expr.W e -> Obj.magic e) 74 + Expr.W 75 + (Expr.( || ) 76 + (match first with Expr.W e -> Obj.magic e) 85 77 (Obj.magic expr)) 86 78 in 87 79 { query with wheres = combined :: rest } 88 80 89 81 let join ?(kind = Inner) ~on joined_table query = 90 - let j = { kind; table = joined_table; on = Mlecto_expr.W on } in 82 + let j = { kind; table = joined_table; on = Expr.W on } in 91 83 { query with joins = j :: query.joins } 92 84 93 85 let left_join ~on table query = join ~kind:Left ~on table query ··· 96 88 let full_join ~on table query = join ~kind:Full ~on table query 97 89 98 90 let order_by ?(direction = Asc) expr query = 99 - { query with order_by = (Mlecto_expr.W expr, direction) :: query.order_by } 91 + { query with order_by = (Expr.W expr, direction) :: query.order_by } 100 92 101 93 let asc expr query = order_by ~direction:Asc expr query 102 94 let desc expr query = order_by ~direction:Desc expr query 103 95 104 96 let group_by exprs query = 105 - let wrapped = Mlecto_expr.expr_list_to_list exprs in 97 + let wrapped = Expr.expr_list_to_list exprs in 106 98 { query with group_by = wrapped @ query.group_by } 107 99 108 - let having expr query = 109 - { query with having = Mlecto_expr.W expr :: query.having } 110 - 100 + let having expr query = { query with having = Expr.W expr :: query.having } 111 101 let limit n query = { query with limit = Some n } 112 102 let offset n query = { query with offset = Some n } 113 103 let distinct query = { query with distinct = true } 114 104 115 105 let returning exprs query = 116 - let wrapped = Mlecto_expr.expr_list_to_list exprs in 106 + let wrapped = Expr.expr_list_to_list exprs in 117 107 { query with returning = Some wrapped } 118 108 119 109 let set col expr query = 120 - { 121 - query with 122 - set_values = (Mlecto_field.name col, Mlecto_expr.W expr) :: query.set_values; 123 - } 110 + { query with set_values = (Field.name col, Expr.W expr) :: query.set_values } 124 111 125 112 let values columns rows query = 126 - let col_names = List.map Mlecto_field.name columns in 113 + let col_names = List.map Field.name columns in 127 114 let wrapped_rows = 128 - List.map (fun row -> List.map (fun e -> Mlecto_expr.W e) row) rows 115 + List.map (fun row -> List.map (fun e -> Expr.W e) row) rows 129 116 in 130 117 { query with insert_columns = col_names; insert_values = wrapped_rows } 131 118 132 119 let on_conflict_do_nothing ?(target = []) query = 133 120 let target_names = 134 - if target = [] then None else Some (List.map Mlecto_field.name target) 121 + if target = [] then None else Some (List.map Field.name target) 135 122 in 136 123 { 137 124 query with ··· 140 127 } 141 128 142 129 let on_conflict_do_update ~target ~set query = 143 - let target_names = List.map Mlecto_field.name target in 130 + let target_names = List.map Field.name target in 144 131 let set_pairs = 145 - List.map 146 - (fun (col, expr) -> (Mlecto_field.name col, Mlecto_expr.W expr)) 147 - set 132 + List.map (fun (col, expr) -> (Field.name col, Expr.W expr)) set 148 133 in 149 134 { 150 135 query with ··· 159 144 | Full -> "FULL JOIN" 160 145 161 146 let direction_to_sql = function Asc -> "ASC" | Desc -> "DESC" 162 - let wrapped_to_sql (Mlecto_expr.W e) = Mlecto_expr.to_sql e 147 + let wrapped_to_sql (Expr.W e) = Expr.to_sql e 163 148 164 149 let to_sql query = 165 150 match query.query_type with ··· 170 155 | None -> "*" 171 156 | Some exprs -> String.concat ", " (List.map wrapped_to_sql exprs) 172 157 in 173 - let from_str = Mlecto_schema.table_name query.table in 158 + let from_str = Schema.table_name query.table in 174 159 let join_strs = 175 160 List.rev_map 176 161 (fun j -> 177 162 Printf.sprintf "%s %s ON %s" (join_kind_to_sql j.kind) 178 - (Mlecto_schema.table_name j.table) 163 + (Schema.table_name j.table) 179 164 (wrapped_to_sql j.on)) 180 165 query.joins 181 166 in ··· 226 211 in 227 212 String.concat "" (List.filter (fun s -> s <> "") parts) 228 213 | Insert -> 229 - let table_str = Mlecto_schema.table_name query.table in 214 + let table_str = Schema.table_name query.table in 230 215 let cols_str = String.concat ", " query.insert_columns in 231 216 let values_strs = 232 217 List.map ··· 262 247 Printf.sprintf "INSERT INTO %s (%s) VALUES %s%s%s" table_str cols_str 263 248 values_str conflict_str returning_str 264 249 | Update -> 265 - let table_str = Mlecto_schema.table_name query.table in 250 + let table_str = Schema.table_name query.table in 266 251 let set_strs = 267 252 List.rev_map 268 253 (fun (col, expr) -> ··· 285 270 Printf.sprintf "UPDATE %s SET %s%s%s" table_str set_str where_str 286 271 returning_str 287 272 | Delete -> 288 - let table_str = Mlecto_schema.table_name query.table in 273 + let table_str = Schema.table_name query.table in 289 274 let where_str = 290 275 match List.rev query.wheres with 291 276 | [] -> ""
-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
+5 -6
lib/mlecto_schema.ml lib/schema.ml
··· 24 24 25 25 type 'a column = { 26 26 col_name : string; 27 - col_type : 'a Mlecto_type.t; 27 + col_type : 'a Types.t; 28 28 col_constraints : column_constraint list; 29 29 } 30 30 ··· 85 85 let with_check ?name expr def = 86 86 { def with tbl_checks = (name, expr) :: def.tbl_checks } 87 87 88 - let id_column () = 89 - column "id" Mlecto_type.int64 ~primary_key:true ~not_null:true 88 + let id_column () = column "id" Types.int64 ~primary_key:true ~not_null:true 90 89 91 90 let timestamps () = 92 91 [ 93 - column "inserted_at" Mlecto_type.ptime ~not_null:true ~default:"NOW()"; 94 - column "updated_at" Mlecto_type.ptime ~not_null:true ~default:"NOW()"; 92 + column "inserted_at" Types.ptime ~not_null:true ~default:"NOW()"; 93 + column "updated_at" Types.ptime ~not_null:true ~default:"NOW()"; 95 94 ] 96 95 97 96 let has_constraint c col = List.exists (fun con -> con = c) col.col_constraints ··· 132 131 | None -> base) 133 132 134 133 let column_to_sql (Column col) = 135 - let type_name = Mlecto_type.sql_type_name col.col_type in 134 + let type_name = Types.sql_type_name col.col_type in 136 135 let constraints = List.map constraint_to_sql col.col_constraints in 137 136 String.concat " " (col.col_name :: type_name :: constraints) 138 137
lib/mlecto_type.ml lib/types.ml
+263
lib/repo.ml
··· 1 + type 'a result = ('a, Error.db_error) Stdlib.result 2 + 3 + let caqti_error_to_db_error err : Error.db_error = 4 + Error.Query_failed (Caqti_error.show err) 5 + 6 + module type CONNECTION = sig 7 + type t 8 + 9 + val exec : t -> string -> (unit, Caqti_error.t) Stdlib.result 10 + val find : t -> string -> ('a, Caqti_error.t) Stdlib.result 11 + val find_opt : t -> string -> ('a option, Caqti_error.t) Stdlib.result 12 + val collect_list : t -> string -> ('a list, Caqti_error.t) Stdlib.result 13 + end 14 + 15 + module type REPO = sig 16 + type conn 17 + 18 + val get : 19 + conn -> 20 + table:Schema.table -> 21 + id:int -> 22 + decode:(string list -> 'a) -> 23 + 'a result 24 + 25 + val get_opt : 26 + conn -> 27 + table:Schema.table -> 28 + id:int -> 29 + decode:(string list -> 'a) -> 30 + 'a option result 31 + 32 + val all : 33 + conn -> table:Schema.table -> decode:(string list -> 'a) -> 'a list result 34 + 35 + val insert : 36 + conn -> 37 + table:Schema.table -> 38 + columns:string list -> 39 + values:string list -> 40 + unit result 41 + 42 + val update : 43 + conn -> 44 + table:Schema.table -> 45 + columns:string list -> 46 + values:string list -> 47 + where_column:string -> 48 + where_value:string -> 49 + unit result 50 + 51 + val delete : 52 + conn -> 53 + table:Schema.table -> 54 + where_column:string -> 55 + where_value:string -> 56 + unit result 57 + 58 + val transaction : conn -> (conn -> 'a result) -> 'a result 59 + end 60 + 61 + type savepoint = { name : string; depth : int } 62 + type tx_state = { mutable depth : int; mutable savepoints : savepoint list } 63 + 64 + let create_tx_state () = { depth = 0; savepoints = [] } 65 + let generate_savepoint_name depth = Printf.sprintf "repodb_sp_%d" depth 66 + 67 + type tx_action = 68 + | Begin 69 + | Commit 70 + | Rollback 71 + | Savepoint of string 72 + | ReleaseSavepoint of string 73 + | RollbackToSavepoint of string 74 + 75 + let tx_action_to_sql = function 76 + | Begin -> "BEGIN" 77 + | Commit -> "COMMIT" 78 + | Rollback -> "ROLLBACK" 79 + | Savepoint name -> Printf.sprintf "SAVEPOINT %s" name 80 + | ReleaseSavepoint name -> Printf.sprintf "RELEASE SAVEPOINT %s" name 81 + | RollbackToSavepoint name -> Printf.sprintf "ROLLBACK TO SAVEPOINT %s" name 82 + 83 + let enter_transaction state = 84 + if state.depth = 0 then begin 85 + state.depth <- 1; 86 + Begin 87 + end 88 + else begin 89 + let sp_name = generate_savepoint_name state.depth in 90 + let sp = { name = sp_name; depth = state.depth } in 91 + state.savepoints <- sp :: state.savepoints; 92 + state.depth <- state.depth + 1; 93 + Savepoint sp_name 94 + end 95 + 96 + let commit_transaction state = 97 + if state.depth <= 1 then begin 98 + state.depth <- 0; 99 + state.savepoints <- []; 100 + Commit 101 + end 102 + else begin 103 + match state.savepoints with 104 + | sp :: rest -> 105 + state.savepoints <- rest; 106 + state.depth <- state.depth - 1; 107 + ReleaseSavepoint sp.name 108 + | [] -> 109 + state.depth <- state.depth - 1; 110 + Commit 111 + end 112 + 113 + let rollback_transaction state = 114 + if state.depth <= 1 then begin 115 + state.depth <- 0; 116 + state.savepoints <- []; 117 + Rollback 118 + end 119 + else begin 120 + match state.savepoints with 121 + | sp :: rest -> 122 + state.savepoints <- rest; 123 + state.depth <- state.depth - 1; 124 + RollbackToSavepoint sp.name 125 + | [] -> 126 + state.depth <- 0; 127 + state.savepoints <- []; 128 + Rollback 129 + end 130 + 131 + let in_transaction state = state.depth > 0 132 + let transaction_depth state = state.depth 133 + 134 + let insert_changeset ~changeset ~on_valid ~on_invalid = 135 + if Changeset.is_valid changeset then on_valid (Changeset.data changeset) 136 + else 137 + let errors = Changeset.error_messages changeset in 138 + on_invalid errors 139 + 140 + let update_changeset ~changeset ~on_valid ~on_invalid = 141 + if Changeset.is_valid changeset then on_valid (Changeset.data changeset) 142 + else 143 + let errors = Changeset.error_messages changeset in 144 + on_invalid errors 145 + 146 + let validate_changeset changeset = 147 + if Changeset.is_valid changeset then Ok (Changeset.data changeset) 148 + else Error (Error.Validation_failed (Changeset.error_messages changeset)) 149 + 150 + let build_insert_sql table columns = 151 + let table_name = Schema.table_name table in 152 + let col_str = String.concat ", " columns in 153 + let placeholders = 154 + List.mapi (fun i _ -> Printf.sprintf "$%d" (i + 1)) columns 155 + in 156 + let val_str = String.concat ", " placeholders in 157 + Printf.sprintf "INSERT INTO %s (%s) VALUES (%s)" table_name col_str val_str 158 + 159 + let build_insert_returning_sql table columns = 160 + let table_name = Schema.table_name table in 161 + let col_str = String.concat ", " columns in 162 + let placeholders = 163 + List.mapi (fun i _ -> Printf.sprintf "$%d" (i + 1)) columns 164 + in 165 + let val_str = String.concat ", " placeholders in 166 + Printf.sprintf "INSERT INTO %s (%s) VALUES (%s) RETURNING *" table_name 167 + col_str val_str 168 + 169 + let build_update_sql table columns ~where_column = 170 + let table_name = Schema.table_name table in 171 + let set_clauses = 172 + List.mapi (fun i col -> Printf.sprintf "%s = $%d" col (i + 1)) columns 173 + in 174 + let set_str = String.concat ", " set_clauses in 175 + let where_idx = List.length columns + 1 in 176 + Printf.sprintf "UPDATE %s SET %s WHERE %s = $%d" table_name set_str 177 + where_column where_idx 178 + 179 + let build_update_returning_sql table columns ~where_column = 180 + let table_name = Schema.table_name table in 181 + let set_clauses = 182 + List.mapi (fun i col -> Printf.sprintf "%s = $%d" col (i + 1)) columns 183 + in 184 + let set_str = String.concat ", " set_clauses in 185 + let where_idx = List.length columns + 1 in 186 + Printf.sprintf "UPDATE %s SET %s WHERE %s = $%d RETURNING *" table_name 187 + set_str where_column where_idx 188 + 189 + let build_delete_sql table ~where_column = 190 + let table_name = Schema.table_name table in 191 + Printf.sprintf "DELETE FROM %s WHERE %s = $1" table_name where_column 192 + 193 + let build_select_sql table ~columns ~where_column = 194 + let table_name = Schema.table_name table in 195 + let col_str = 196 + match columns with [] -> "*" | cols -> String.concat ", " cols 197 + in 198 + Printf.sprintf "SELECT %s FROM %s WHERE %s = $1" col_str table_name 199 + where_column 200 + 201 + let build_select_all_sql table ~columns = 202 + let table_name = Schema.table_name table in 203 + let col_str = 204 + match columns with [] -> "*" | cols -> String.concat ", " cols 205 + in 206 + Printf.sprintf "SELECT %s FROM %s" col_str table_name 207 + 208 + let build_select_where_sql table ~columns ~where_sql = 209 + let table_name = Schema.table_name table in 210 + let col_str = 211 + match columns with [] -> "*" | cols -> String.concat ", " cols 212 + in 213 + Printf.sprintf "SELECT %s FROM %s WHERE %s" col_str table_name where_sql 214 + 215 + let build_preload_sql ~related_table ~fk_column ~n_ids = 216 + let placeholders = 217 + List.init n_ids (fun i -> Printf.sprintf "$%d" (i + 1)) 218 + |> String.concat ", " 219 + in 220 + Printf.sprintf "SELECT * FROM %s WHERE %s IN (%s)" related_table fk_column 221 + placeholders 222 + 223 + let build_preload_assoc_sql assoc owner_ids = 224 + let n = List.length owner_ids in 225 + if n = 0 then None 226 + else 227 + let ids_str = List.map string_of_int owner_ids |> String.concat ", " in 228 + Some 229 + (Assoc.build_batch_query assoc (List.map string_of_int owner_ids), ids_str) 230 + 231 + let constraint_error_to_changeset ~constraint_name ~message = 232 + Error.Constraint_violation { constraint_name; message } 233 + 234 + let parse_pg_constraint_error error_msg = 235 + let unique_re = 236 + Re.Pcre.regexp {|duplicate key value violates unique constraint "([^"]+)"|} 237 + in 238 + let fk_re = Re.Pcre.regexp {|violates foreign key constraint "([^"]+)"|} in 239 + match Re.exec_opt unique_re error_msg with 240 + | Some groups -> 241 + let constraint_name = Re.Group.get groups 1 in 242 + Some (`Unique constraint_name) 243 + | None -> ( 244 + match Re.exec_opt fk_re error_msg with 245 + | Some groups -> 246 + let constraint_name = Re.Group.get groups 1 in 247 + Some (`ForeignKey constraint_name) 248 + | None -> None) 249 + 250 + let map_constraint_error changeset error_msg constraint_mapping = 251 + match parse_pg_constraint_error error_msg with 252 + | None -> changeset 253 + | Some constraint_type -> ( 254 + let constraint_name = 255 + match constraint_type with 256 + | `Unique name -> name 257 + | `ForeignKey name -> name 258 + in 259 + match List.assoc_opt constraint_name constraint_mapping with 260 + | None -> changeset 261 + | Some (field, message) -> 262 + Changeset.add_error changeset ~field ~message ~validation:"constraint" 263 + )
+5 -5
mlecto.opam repodb.opam
··· 2 2 opam-version: "2.0" 3 3 synopsis: "Ecto-like database toolkit for OCaml" 4 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." 5 + "repodb 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 6 maintainer: ["Guillermo Diaz-Romero <guillermo.diaz@gmail.com>"] 7 7 authors: ["Guillermo Diaz-Romero <guillermo.diaz@gmail.com>"] 8 8 license: "MIT" 9 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" 10 + homepage: "https://github.com/gdiazlo/repodb" 11 + doc: "https://github.com/gdiazlo/repodb" 12 + bug-reports: "https://github.com/gdiazlo/repodb/issues" 13 13 depends: [ 14 14 "ocaml" {>= "5.1"} 15 15 "dune" {>= "3.20" & >= "3.20"} ··· 38 38 "@doc" {with-doc} 39 39 ] 40 40 ] 41 - dev-repo: "git+https://github.com/gdiazlo/mlecto.git" 41 + dev-repo: "git+https://github.com/gdiazlo/repodb.git" 42 42 x-maintenance-intent: ["(latest)"]
+2 -2
test/dune
··· 1 1 (test 2 - (name test_mlecto) 3 - (libraries mlecto alcotest)) 2 + (name test_repodb) 3 + (libraries repodb alcotest))
-1
test/test_mlecto.ml
··· 1 - let () = print_endline "Tests not yet implemented"
+1464
test/test_repodb.ml
··· 1 + open Repodb 2 + 3 + type user = { 4 + id : int; 5 + name : string; 6 + email : string; 7 + age : int; 8 + active : bool; 9 + } 10 + 11 + let make_user ?(id = 0) ?(name = "") ?(email = "") ?(age = 0) ?(active = true) 12 + () = 13 + { id; name; email; age; active } 14 + 15 + let users_table = Schema.table "users" 16 + 17 + let id_field : (user, int) Field.t = 18 + Field.make ~table_name:"users" ~name:"id" ~ty:Types.int 19 + ~get:(fun u -> u.id) 20 + ~set:(fun v u -> { u with id = v }) 21 + ~primary_key:true () 22 + 23 + let name_field : (user, string) Field.t = 24 + Field.make ~table_name:"users" ~name:"name" ~ty:Types.string 25 + ~get:(fun u -> u.name) 26 + ~set:(fun v u -> { u with name = v }) 27 + () 28 + 29 + let email_field : (user, string) Field.t = 30 + Field.make ~table_name:"users" ~name:"email" ~ty:Types.string 31 + ~get:(fun u -> u.email) 32 + ~set:(fun v u -> { u with email = v }) 33 + () 34 + 35 + let age_field : (user, int) Field.t = 36 + Field.make ~table_name:"users" ~name:"age" ~ty:Types.int 37 + ~get:(fun u -> u.age) 38 + ~set:(fun v u -> { u with age = v }) 39 + () 40 + 41 + let _active_field : (user, bool) Field.t = 42 + Field.make ~table_name:"users" ~name:"active" ~ty:Types.bool 43 + ~get:(fun u -> u.active) 44 + ~set:(fun v u -> { u with active = v }) 45 + () 46 + 47 + module Types_test = struct 48 + let test_sql_type_name_int () = 49 + Alcotest.(check string) 50 + "int maps to INTEGER" "INTEGER" 51 + (Types.sql_type_name Types.int) 52 + 53 + let test_sql_type_name_int64 () = 54 + Alcotest.(check string) 55 + "int64 maps to BIGINT" "BIGINT" 56 + (Types.sql_type_name Types.int64) 57 + 58 + let test_sql_type_name_string () = 59 + Alcotest.(check string) 60 + "string maps to TEXT" "TEXT" 61 + (Types.sql_type_name Types.string) 62 + 63 + let test_sql_type_name_bool () = 64 + Alcotest.(check string) 65 + "bool maps to BOOLEAN" "BOOLEAN" 66 + (Types.sql_type_name Types.bool) 67 + 68 + let test_sql_type_name_float () = 69 + Alcotest.(check string) 70 + "float maps to DOUBLE PRECISION" "DOUBLE PRECISION" 71 + (Types.sql_type_name Types.float) 72 + 73 + let test_sql_type_name_ptime () = 74 + Alcotest.(check string) 75 + "ptime maps to TIMESTAMPTZ" "TIMESTAMPTZ" 76 + (Types.sql_type_name Types.ptime) 77 + 78 + let test_sql_type_name_uuid () = 79 + Alcotest.(check string) 80 + "uuid maps to UUID" "UUID" 81 + (Types.sql_type_name Types.uuid) 82 + 83 + let test_sql_type_name_json () = 84 + Alcotest.(check string) 85 + "json maps to JSONB" "JSONB" 86 + (Types.sql_type_name Types.json) 87 + 88 + let test_sql_type_name_option () = 89 + Alcotest.(check string) 90 + "option string still maps to TEXT" "TEXT" 91 + (Types.sql_type_name (Types.option Types.string)) 92 + 93 + let test_sql_type_name_array () = 94 + Alcotest.(check string) 95 + "array int maps to INTEGER[]" "INTEGER[]" 96 + (Types.sql_type_name (Types.array Types.int)) 97 + 98 + let test_is_nullable_option () = 99 + Alcotest.(check bool) 100 + "option is nullable" true 101 + (Types.is_nullable (Types.option Types.string)) 102 + 103 + let test_is_nullable_non_option () = 104 + Alcotest.(check bool) 105 + "non-option is not nullable" false 106 + (Types.is_nullable Types.string) 107 + 108 + let test_custom_type () = 109 + let custom = 110 + Types.custom 111 + ~encode:(fun s -> Ok s) 112 + ~decode:(fun s -> Ok s) 113 + ~sql_type:"CUSTOM_TYPE" 114 + in 115 + Alcotest.(check string) 116 + "custom type name" "CUSTOM_TYPE" 117 + (Types.sql_type_name custom) 118 + 119 + let tests = 120 + [ 121 + ("sql_type_name int", `Quick, test_sql_type_name_int); 122 + ("sql_type_name int64", `Quick, test_sql_type_name_int64); 123 + ("sql_type_name string", `Quick, test_sql_type_name_string); 124 + ("sql_type_name bool", `Quick, test_sql_type_name_bool); 125 + ("sql_type_name float", `Quick, test_sql_type_name_float); 126 + ("sql_type_name ptime", `Quick, test_sql_type_name_ptime); 127 + ("sql_type_name uuid", `Quick, test_sql_type_name_uuid); 128 + ("sql_type_name json", `Quick, test_sql_type_name_json); 129 + ("sql_type_name option", `Quick, test_sql_type_name_option); 130 + ("sql_type_name array", `Quick, test_sql_type_name_array); 131 + ("is_nullable option", `Quick, test_is_nullable_option); 132 + ("is_nullable non-option", `Quick, test_is_nullable_non_option); 133 + ("custom type", `Quick, test_custom_type); 134 + ] 135 + end 136 + 137 + module Error_test = struct 138 + let test_show_db_error_not_found () = 139 + Alcotest.(check string) 140 + "Not_found message" "Record not found" 141 + (Error.show_db_error Error.Not_found) 142 + 143 + let test_show_db_error_query_failed () = 144 + Alcotest.(check string) 145 + "Query_failed message" "Query failed: syntax error" 146 + (Error.show_db_error (Error.Query_failed "syntax error")) 147 + 148 + let test_show_db_error_constraint_violation () = 149 + let err = 150 + Error.Constraint_violation 151 + { constraint_name = "users_email_key"; message = "duplicate key" } 152 + in 153 + Alcotest.(check string) 154 + "Constraint_violation message" 155 + "Constraint violation (users_email_key): duplicate key" 156 + (Error.show_db_error err) 157 + 158 + let test_show_db_error_validation_failed () = 159 + let err = 160 + Error.Validation_failed [ "name can't be blank"; "email invalid" ] 161 + in 162 + Alcotest.(check string) 163 + "Validation_failed message" 164 + "Validation failed: name can't be blank; email invalid" 165 + (Error.show_db_error err) 166 + 167 + let test_show_validation_error () = 168 + let err = 169 + Error. 170 + { 171 + field = "email"; 172 + message = "has invalid format"; 173 + validation = "format"; 174 + } 175 + in 176 + Alcotest.(check string) 177 + "validation error format" "email: has invalid format (format)" 178 + (Error.show_validation_error err) 179 + 180 + let tests = 181 + [ 182 + ("show_db_error Not_found", `Quick, test_show_db_error_not_found); 183 + ("show_db_error Query_failed", `Quick, test_show_db_error_query_failed); 184 + ( "show_db_error Constraint_violation", 185 + `Quick, 186 + test_show_db_error_constraint_violation ); 187 + ( "show_db_error Validation_failed", 188 + `Quick, 189 + test_show_db_error_validation_failed ); 190 + ("show_validation_error", `Quick, test_show_validation_error); 191 + ] 192 + end 193 + 194 + module Field_test = struct 195 + let test_field_name () = 196 + Alcotest.(check string) "field name" "name" (Field.name name_field) 197 + 198 + let test_field_qualified_name () = 199 + Alcotest.(check string) 200 + "qualified name" "users.name" 201 + (Field.qualified_name name_field) 202 + 203 + let test_field_get () = 204 + let user = make_user ~name:"Alice" () in 205 + Alcotest.(check string) 206 + "get field value" "Alice" 207 + (Field.get name_field user) 208 + 209 + let test_field_set () = 210 + let user = make_user ~name:"Alice" () in 211 + let updated = Field.set name_field "Bob" user in 212 + Alcotest.(check string) "set field value" "Bob" updated.name 213 + 214 + let test_field_is_primary_key () = 215 + Alcotest.(check bool) 216 + "id is primary key" true 217 + (Field.is_primary_key id_field); 218 + Alcotest.(check bool) 219 + "name is not primary key" false 220 + (Field.is_primary_key name_field) 221 + 222 + let test_field_is_nullable () = 223 + Alcotest.(check bool) 224 + "name is not nullable" false 225 + (Field.is_nullable name_field) 226 + 227 + let tests = 228 + [ 229 + ("field name", `Quick, test_field_name); 230 + ("field qualified_name", `Quick, test_field_qualified_name); 231 + ("field get", `Quick, test_field_get); 232 + ("field set", `Quick, test_field_set); 233 + ("field is_primary_key", `Quick, test_field_is_primary_key); 234 + ("field is_nullable", `Quick, test_field_is_nullable); 235 + ] 236 + end 237 + 238 + module Schema_test = struct 239 + let test_table_name () = 240 + let t = Schema.table "posts" in 241 + Alcotest.(check string) "table name" "posts" (Schema.table_name t) 242 + 243 + let test_table_name_with_schema () = 244 + let t = Schema.table ~schema:(Some "public") "posts" in 245 + Alcotest.(check string) 246 + "table with schema" "public.posts" (Schema.table_name t) 247 + 248 + let test_column_creation () = 249 + let col = Schema.column "title" Types.string ~not_null:true in 250 + Alcotest.(check string) "column name" "title" col.col_name 251 + 252 + let test_column_with_primary_key () = 253 + let col = Schema.column "id" Types.int64 ~primary_key:true ~not_null:true in 254 + Alcotest.(check bool) "is primary key" true (Schema.is_primary_key col) 255 + 256 + let test_column_with_default () = 257 + let col = Schema.column "created_at" Types.ptime ~default:"NOW()" in 258 + Alcotest.(check (option string)) 259 + "has default" (Some "NOW()") (Schema.get_default col) 260 + 261 + let test_id_column () = 262 + let col = Schema.id_column () in 263 + Alcotest.(check string) "id column name" "id" col.col_name; 264 + Alcotest.(check bool) "id is primary key" true (Schema.is_primary_key col) 265 + 266 + let test_timestamps () = 267 + let cols = Schema.timestamps () in 268 + Alcotest.(check int) "timestamps count" 2 (List.length cols); 269 + let names = List.map (fun c -> c.Schema.col_name) cols in 270 + Alcotest.(check bool) "has inserted_at" true (List.mem "inserted_at" names); 271 + Alcotest.(check bool) "has updated_at" true (List.mem "updated_at" names) 272 + 273 + let test_table_def_to_sql () = 274 + let id_col = 275 + Schema.column "id" Types.int64 ~primary_key:true ~not_null:true 276 + in 277 + let def = Schema.define "users" [ id_col ] in 278 + let sql = Schema.table_def_to_sql def in 279 + Alcotest.(check bool) 280 + "contains CREATE TABLE" true 281 + (String.length sql > 0 && String.sub sql 0 12 = "CREATE TABLE") 282 + 283 + let test_foreign_key_reference () = 284 + let fk = Schema.references ~table:"posts" ~column:"id" () in 285 + Alcotest.(check string) "fk table" "posts" fk.fk_table; 286 + Alcotest.(check string) "fk column" "id" fk.fk_column 287 + 288 + let tests = 289 + [ 290 + ("table_name", `Quick, test_table_name); 291 + ("table_name with schema", `Quick, test_table_name_with_schema); 292 + ("column creation", `Quick, test_column_creation); 293 + ("column with primary_key", `Quick, test_column_with_primary_key); 294 + ("column with default", `Quick, test_column_with_default); 295 + ("id_column helper", `Quick, test_id_column); 296 + ("timestamps helper", `Quick, test_timestamps); 297 + ("table_def_to_sql", `Quick, test_table_def_to_sql); 298 + ("foreign_key reference", `Quick, test_foreign_key_reference); 299 + ] 300 + end 301 + 302 + module Changeset_test = struct 303 + let test_create_changeset () = 304 + let user = make_user () in 305 + let cs = Changeset.create user in 306 + Alcotest.(check bool) "new changeset is valid" true (Changeset.is_valid cs) 307 + 308 + let test_cast_filters_fields () = 309 + let user = make_user () in 310 + let cs = 311 + Changeset.create user 312 + |> Changeset.cast 313 + [ ("name", "Alice"); ("unknown", "value") ] 314 + ~fields:[ name_field ] 315 + in 316 + let changes = Changeset.changes cs in 317 + Alcotest.(check int) "only allowed fields" 1 (List.length changes); 318 + Alcotest.(check bool) "has name change" true (List.mem_assoc "name" changes) 319 + 320 + let test_validate_required_passes () = 321 + let user = make_user () in 322 + let cs = 323 + Changeset.create user 324 + |> Changeset.cast [ ("name", "Alice") ] ~fields:[ name_field ] 325 + |> Changeset.validate_required [ name_field ] 326 + in 327 + Alcotest.(check bool) 328 + "valid with required field" true (Changeset.is_valid cs) 329 + 330 + let test_validate_required_fails_empty () = 331 + let user = make_user () in 332 + let cs = 333 + Changeset.create user 334 + |> Changeset.cast [ ("name", "") ] ~fields:[ name_field ] 335 + |> Changeset.validate_required [ name_field ] 336 + in 337 + Alcotest.(check bool) 338 + "invalid with empty required" false (Changeset.is_valid cs) 339 + 340 + let test_validate_required_fails_missing () = 341 + let user = make_user () in 342 + let cs = 343 + Changeset.create user 344 + |> Changeset.cast [] ~fields:[ name_field ] 345 + |> Changeset.validate_required [ name_field ] 346 + in 347 + Alcotest.(check bool) 348 + "invalid with missing required" false (Changeset.is_valid cs) 349 + 350 + let test_validate_format_passes () = 351 + let user = make_user () in 352 + let cs = 353 + Changeset.create user 354 + |> Changeset.cast 355 + [ ("email", "test@example.com") ] 356 + ~fields:[ email_field ] 357 + |> Changeset.validate_format email_field ~pattern:"^[^@]+@[^@]+$" 358 + in 359 + Alcotest.(check bool) "valid email format" true (Changeset.is_valid cs) 360 + 361 + let test_validate_format_fails () = 362 + let user = make_user () in 363 + let cs = 364 + Changeset.create user 365 + |> Changeset.cast [ ("email", "invalid-email") ] ~fields:[ email_field ] 366 + |> Changeset.validate_format email_field ~pattern:"^[^@]+@[^@]+$" 367 + in 368 + Alcotest.(check bool) "invalid email format" false (Changeset.is_valid cs) 369 + 370 + let test_validate_length_min () = 371 + let user = make_user () in 372 + let cs = 373 + Changeset.create user 374 + |> Changeset.cast [ ("name", "Al") ] ~fields:[ name_field ] 375 + |> Changeset.validate_length name_field ~min:3 376 + in 377 + Alcotest.(check bool) "too short" false (Changeset.is_valid cs) 378 + 379 + let test_validate_length_max () = 380 + let user = make_user () in 381 + let cs = 382 + Changeset.create user 383 + |> Changeset.cast [ ("name", "A very long name") ] ~fields:[ name_field ] 384 + |> Changeset.validate_length name_field ~max:5 385 + in 386 + Alcotest.(check bool) "too long" false (Changeset.is_valid cs) 387 + 388 + let test_validate_length_exact () = 389 + let user = make_user () in 390 + let cs = 391 + Changeset.create user 392 + |> Changeset.cast [ ("name", "Alice") ] ~fields:[ name_field ] 393 + |> Changeset.validate_length name_field ~is:5 394 + in 395 + Alcotest.(check bool) "exact length valid" true (Changeset.is_valid cs) 396 + 397 + let test_validate_inclusion () = 398 + let user = make_user () in 399 + let cs = 400 + Changeset.create user 401 + |> Changeset.cast [ ("name", "admin") ] ~fields:[ name_field ] 402 + |> Changeset.validate_inclusion name_field 403 + ~values:[ "admin"; "user"; "guest" ] 404 + in 405 + Alcotest.(check bool) "value in list" true (Changeset.is_valid cs) 406 + 407 + let test_validate_inclusion_fails () = 408 + let user = make_user () in 409 + let cs = 410 + Changeset.create user 411 + |> Changeset.cast [ ("name", "hacker") ] ~fields:[ name_field ] 412 + |> Changeset.validate_inclusion name_field 413 + ~values:[ "admin"; "user"; "guest" ] 414 + in 415 + Alcotest.(check bool) "value not in list" false (Changeset.is_valid cs) 416 + 417 + let test_validate_exclusion () = 418 + let user = make_user () in 419 + let cs = 420 + Changeset.create user 421 + |> Changeset.cast [ ("name", "admin") ] ~fields:[ name_field ] 422 + |> Changeset.validate_exclusion name_field ~values:[ "root"; "system" ] 423 + in 424 + Alcotest.(check bool) "value not excluded" true (Changeset.is_valid cs) 425 + 426 + let test_validate_exclusion_fails () = 427 + let user = make_user () in 428 + let cs = 429 + Changeset.create user 430 + |> Changeset.cast [ ("name", "root") ] ~fields:[ name_field ] 431 + |> Changeset.validate_exclusion name_field ~values:[ "root"; "system" ] 432 + in 433 + Alcotest.(check bool) "value excluded" false (Changeset.is_valid cs) 434 + 435 + let test_validate_number_greater_than () = 436 + let user = make_user () in 437 + let cs = 438 + Changeset.create user 439 + |> Changeset.cast [ ("age", "25") ] ~fields:[ age_field ] 440 + |> Changeset.validate_number age_field ~greater_than:18 441 + in 442 + Alcotest.(check bool) "number greater than" true (Changeset.is_valid cs) 443 + 444 + let test_validate_number_greater_than_fails () = 445 + let user = make_user () in 446 + let cs = 447 + Changeset.create user 448 + |> Changeset.cast [ ("age", "15") ] ~fields:[ age_field ] 449 + |> Changeset.validate_number age_field ~greater_than:18 450 + in 451 + Alcotest.(check bool) 452 + "number not greater than" false (Changeset.is_valid cs) 453 + 454 + let test_add_error () = 455 + let user = make_user () in 456 + let cs = 457 + Changeset.create user 458 + |> Changeset.add_error ~field:"email" ~message:"is taken" 459 + ~validation:"unique" 460 + in 461 + Alcotest.(check bool) "has error" false (Changeset.is_valid cs); 462 + Alcotest.(check int) "error count" 1 (List.length (Changeset.errors cs)) 463 + 464 + let test_error_messages () = 465 + let user = make_user () in 466 + let cs = 467 + Changeset.create user 468 + |> Changeset.add_error ~field:"name" ~message:"can't be blank" 469 + ~validation:"required" 470 + |> Changeset.add_error ~field:"email" ~message:"is invalid" 471 + ~validation:"format" 472 + in 473 + let msgs = Changeset.error_messages cs in 474 + Alcotest.(check int) "two error messages" 2 (List.length msgs) 475 + 476 + let test_get_change () = 477 + let user = make_user () in 478 + let cs = 479 + Changeset.create user 480 + |> Changeset.cast [ ("name", "Alice") ] ~fields:[ name_field ] 481 + in 482 + Alcotest.(check (option string)) 483 + "get change" (Some "Alice") 484 + (Changeset.get_change cs name_field) 485 + 486 + let test_put_change () = 487 + let user = make_user () in 488 + let cs = Changeset.create user |> Changeset.put_change name_field "Bob" in 489 + Alcotest.(check (option string)) 490 + "put change" (Some "Bob") 491 + (Changeset.get_change cs name_field) 492 + 493 + let test_delete_change () = 494 + let user = make_user () in 495 + let cs = 496 + Changeset.create user 497 + |> Changeset.cast [ ("name", "Alice") ] ~fields:[ name_field ] 498 + |> Changeset.delete_change name_field 499 + in 500 + Alcotest.(check (option string)) 501 + "delete change" None 502 + (Changeset.get_change cs name_field) 503 + 504 + let test_for_insert () = 505 + let user = make_user () in 506 + let cs = Changeset.for_insert user in 507 + Alcotest.(check (option bool)) 508 + "action is insert" (Some true) 509 + (Option.map (fun a -> a = Changeset.Insert) (Changeset.action cs)) 510 + 511 + let test_for_update () = 512 + let user = make_user () in 513 + let cs = Changeset.for_update user in 514 + Alcotest.(check (option bool)) 515 + "action is update" (Some true) 516 + (Option.map (fun a -> a = Changeset.Update) (Changeset.action cs)) 517 + 518 + let test_apply_action_valid () = 519 + let user = make_user ~name:"Alice" () in 520 + let cs = Changeset.create user in 521 + match Changeset.apply_action cs with 522 + | Ok data -> Alcotest.(check string) "returns data" "Alice" data.name 523 + | Error _ -> Alcotest.fail "should not error" 524 + 525 + let test_apply_action_invalid () = 526 + let user = make_user () in 527 + let cs = 528 + Changeset.create user 529 + |> Changeset.add_error ~field:"name" ~message:"error" ~validation:"test" 530 + in 531 + match Changeset.apply_action cs with 532 + | Ok _ -> Alcotest.fail "should error" 533 + | Error errs -> Alcotest.(check int) "returns errors" 1 (List.length errs) 534 + 535 + let test_unique_constraint () = 536 + let user = make_user () in 537 + let cs = Changeset.create user |> Changeset.unique_constraint email_field in 538 + Alcotest.(check bool) "still valid" true (Changeset.is_valid cs) 539 + 540 + let test_validate_confirmation () = 541 + let password_field : (user, string) Field.t = 542 + Field.make ~table_name:"users" ~name:"password" ~ty:Types.string 543 + ~get:(fun _ -> "") 544 + ~set:(fun _ u -> u) 545 + () 546 + in 547 + let password_confirmation_field : (user, string) Field.t = 548 + Field.make ~table_name:"users" ~name:"password_confirmation" 549 + ~ty:Types.string 550 + ~get:(fun _ -> "") 551 + ~set:(fun _ u -> u) 552 + () 553 + in 554 + let user = make_user () in 555 + let cs = 556 + Changeset.create user 557 + |> Changeset.cast 558 + [ ("password", "secret123"); ("password_confirmation", "secret123") ] 559 + ~fields:[ password_field; password_confirmation_field ] 560 + |> Changeset.validate_confirmation password_field 561 + ~confirmation_field:password_confirmation_field 562 + in 563 + Alcotest.(check bool) "passwords match" true (Changeset.is_valid cs) 564 + 565 + let test_validate_confirmation_fails () = 566 + let password_field : (user, string) Field.t = 567 + Field.make ~table_name:"users" ~name:"password" ~ty:Types.string 568 + ~get:(fun _ -> "") 569 + ~set:(fun _ u -> u) 570 + () 571 + in 572 + let password_confirmation_field : (user, string) Field.t = 573 + Field.make ~table_name:"users" ~name:"password_confirmation" 574 + ~ty:Types.string 575 + ~get:(fun _ -> "") 576 + ~set:(fun _ u -> u) 577 + () 578 + in 579 + let user = make_user () in 580 + let cs = 581 + Changeset.create user 582 + |> Changeset.cast 583 + [ ("password", "secret123"); ("password_confirmation", "different") ] 584 + ~fields:[ password_field; password_confirmation_field ] 585 + |> Changeset.validate_confirmation password_field 586 + ~confirmation_field:password_confirmation_field 587 + in 588 + Alcotest.(check bool) "passwords don't match" false (Changeset.is_valid cs) 589 + 590 + let tests = 591 + [ 592 + ("create changeset", `Quick, test_create_changeset); 593 + ("cast filters fields", `Quick, test_cast_filters_fields); 594 + ("validate_required passes", `Quick, test_validate_required_passes); 595 + ( "validate_required fails empty", 596 + `Quick, 597 + test_validate_required_fails_empty ); 598 + ( "validate_required fails missing", 599 + `Quick, 600 + test_validate_required_fails_missing ); 601 + ("validate_format passes", `Quick, test_validate_format_passes); 602 + ("validate_format fails", `Quick, test_validate_format_fails); 603 + ("validate_length min", `Quick, test_validate_length_min); 604 + ("validate_length max", `Quick, test_validate_length_max); 605 + ("validate_length exact", `Quick, test_validate_length_exact); 606 + ("validate_inclusion", `Quick, test_validate_inclusion); 607 + ("validate_inclusion fails", `Quick, test_validate_inclusion_fails); 608 + ("validate_exclusion", `Quick, test_validate_exclusion); 609 + ("validate_exclusion fails", `Quick, test_validate_exclusion_fails); 610 + ("validate_number greater_than", `Quick, test_validate_number_greater_than); 611 + ( "validate_number greater_than fails", 612 + `Quick, 613 + test_validate_number_greater_than_fails ); 614 + ("add_error", `Quick, test_add_error); 615 + ("error_messages", `Quick, test_error_messages); 616 + ("get_change", `Quick, test_get_change); 617 + ("put_change", `Quick, test_put_change); 618 + ("delete_change", `Quick, test_delete_change); 619 + ("for_insert", `Quick, test_for_insert); 620 + ("for_update", `Quick, test_for_update); 621 + ("apply_action valid", `Quick, test_apply_action_valid); 622 + ("apply_action invalid", `Quick, test_apply_action_invalid); 623 + ("unique_constraint", `Quick, test_unique_constraint); 624 + ("validate_confirmation", `Quick, test_validate_confirmation); 625 + ("validate_confirmation fails", `Quick, test_validate_confirmation_fails); 626 + ] 627 + end 628 + 629 + module Query_test = struct 630 + let posts_table = Schema.table "posts" 631 + 632 + let test_from () = 633 + let q = Query.from posts_table in 634 + let sql = Query.to_sql q in 635 + Alcotest.(check bool) "basic SELECT" true (String.sub sql 0 6 = "SELECT") 636 + 637 + let test_select_all () = 638 + let q = Query.from posts_table |> Query.select_all in 639 + let sql = Query.to_sql q in 640 + Alcotest.(check bool) "SELECT *" true (String.length sql > 0) 641 + 642 + let test_where () = 643 + let q = Query.from posts_table |> Query.where Expr.(int 1 = int 1) in 644 + let sql = Query.to_sql q in 645 + Alcotest.(check bool) "has WHERE" true (String.length sql > 0) 646 + 647 + let test_limit () = 648 + let q = Query.from posts_table |> Query.limit 10 in 649 + let sql = Query.to_sql q in 650 + Alcotest.(check bool) "has LIMIT" true (String.length sql > 0) 651 + 652 + let test_offset () = 653 + let q = Query.from posts_table |> Query.offset 5 in 654 + let sql = Query.to_sql q in 655 + Alcotest.(check bool) "has OFFSET" true (String.length sql > 0) 656 + 657 + let test_order_by_asc () = 658 + let q = Query.from posts_table |> Query.asc (Expr.raw "title") in 659 + let sql = Query.to_sql q in 660 + Alcotest.(check bool) "has ORDER BY" true (String.length sql > 0) 661 + 662 + let test_order_by_desc () = 663 + let q = Query.from posts_table |> Query.desc (Expr.raw "created_at") in 664 + let sql = Query.to_sql q in 665 + Alcotest.(check bool) "has DESC" true (String.length sql > 0) 666 + 667 + let test_distinct () = 668 + let q = Query.from posts_table |> Query.distinct in 669 + let sql = Query.to_sql q in 670 + Alcotest.(check bool) "has DISTINCT" true (String.length sql > 0) 671 + 672 + let test_insert_into () = 673 + let q = 674 + Query.insert_into posts_table 675 + |> Query.values [ name_field ] [ [ Expr.string "Hello" ] ] 676 + in 677 + let sql = Query.to_sql q in 678 + Alcotest.(check bool) "INSERT INTO" true (String.sub sql 0 6 = "INSERT") 679 + 680 + let test_update () = 681 + let q = 682 + Query.update posts_table |> Query.set name_field (Expr.string "Updated") 683 + in 684 + let sql = Query.to_sql q in 685 + Alcotest.(check bool) "UPDATE" true (String.sub sql 0 6 = "UPDATE") 686 + 687 + let test_delete_from () = 688 + let q = Query.delete_from posts_table in 689 + let sql = Query.to_sql q in 690 + Alcotest.(check bool) "DELETE FROM" true (String.sub sql 0 6 = "DELETE") 691 + 692 + let test_join () = 693 + let comments_table = Schema.table "comments" in 694 + let q = 695 + Query.from posts_table 696 + |> Query.join ~on:Expr.(int 1 = int 1) comments_table 697 + in 698 + let sql = Query.to_sql q in 699 + Alcotest.(check bool) "has JOIN" true (String.length sql > 0) 700 + 701 + let test_left_join () = 702 + let comments_table = Schema.table "comments" in 703 + let q = 704 + Query.from posts_table 705 + |> Query.left_join ~on:Expr.(int 1 = int 1) comments_table 706 + in 707 + let sql = Query.to_sql q in 708 + Alcotest.(check bool) "has LEFT JOIN" true (String.length sql > 0) 709 + 710 + let test_on_conflict_do_nothing () = 711 + let q = 712 + Query.insert_into posts_table 713 + |> Query.values [ name_field ] [ [ Expr.string "Hello" ] ] 714 + |> Query.on_conflict_do_nothing 715 + in 716 + let sql = Query.to_sql q in 717 + Alcotest.(check bool) "has ON CONFLICT" true (String.length sql > 0) 718 + 719 + let tests = 720 + [ 721 + ("from", `Quick, test_from); 722 + ("select_all", `Quick, test_select_all); 723 + ("where", `Quick, test_where); 724 + ("limit", `Quick, test_limit); 725 + ("offset", `Quick, test_offset); 726 + ("order_by asc", `Quick, test_order_by_asc); 727 + ("order_by desc", `Quick, test_order_by_desc); 728 + ("distinct", `Quick, test_distinct); 729 + ("insert_into", `Quick, test_insert_into); 730 + ("update", `Quick, test_update); 731 + ("delete_from", `Quick, test_delete_from); 732 + ("join", `Quick, test_join); 733 + ("left_join", `Quick, test_left_join); 734 + ("on_conflict_do_nothing", `Quick, test_on_conflict_do_nothing); 735 + ] 736 + end 737 + 738 + module Expr_test = struct 739 + let test_int_literal () = 740 + let sql = Expr.to_sql (Expr.int 42) in 741 + Alcotest.(check string) "int literal" "42" sql 742 + 743 + let test_string_literal () = 744 + let sql = Expr.to_sql (Expr.string "hello") in 745 + Alcotest.(check string) "string literal" "'hello'" sql 746 + 747 + let test_string_escaping () = 748 + let sql = Expr.to_sql (Expr.string "it's") in 749 + Alcotest.(check string) "escaped string" "'it''s'" sql 750 + 751 + let test_bool_true () = 752 + let sql = Expr.to_sql (Expr.bool true) in 753 + Alcotest.(check string) "bool true" "TRUE" sql 754 + 755 + let test_bool_false () = 756 + let sql = Expr.to_sql (Expr.bool false) in 757 + Alcotest.(check string) "bool false" "FALSE" sql 758 + 759 + let test_equality () = 760 + let sql = Expr.to_sql Expr.(int 1 = int 1) in 761 + Alcotest.(check string) "equality" "(1 = 1)" sql 762 + 763 + let test_inequality () = 764 + let sql = Expr.to_sql Expr.(int 1 <> int 2) in 765 + Alcotest.(check string) "inequality" "(1 <> 2)" sql 766 + 767 + let test_less_than () = 768 + let sql = Expr.to_sql Expr.(int 1 < int 2) in 769 + Alcotest.(check string) "less than" "(1 < 2)" sql 770 + 771 + let test_greater_than () = 772 + let sql = Expr.to_sql Expr.(int 2 > int 1) in 773 + Alcotest.(check string) "greater than" "(2 > 1)" sql 774 + 775 + let test_and () = 776 + let sql = Expr.to_sql Expr.(bool true && bool false) in 777 + Alcotest.(check string) "AND" "(TRUE AND FALSE)" sql 778 + 779 + let test_or () = 780 + let sql = Expr.to_sql Expr.(bool true || bool false) in 781 + Alcotest.(check string) "OR" "(TRUE OR FALSE)" sql 782 + 783 + let test_not () = 784 + let sql = Expr.to_sql Expr.(not_ (bool true)) in 785 + Alcotest.(check string) "NOT" "(NOT TRUE)" sql 786 + 787 + let test_addition () = 788 + let sql = Expr.to_sql Expr.(int 1 + int 2) in 789 + Alcotest.(check string) "addition" "(1 + 2)" sql 790 + 791 + let test_subtraction () = 792 + let sql = Expr.to_sql Expr.(int 5 - int 3) in 793 + Alcotest.(check string) "subtraction" "(5 - 3)" sql 794 + 795 + let test_multiplication () = 796 + let sql = Expr.to_sql Expr.(int 2 * int 3) in 797 + Alcotest.(check string) "multiplication" "(2 * 3)" sql 798 + 799 + let test_division () = 800 + let sql = Expr.to_sql Expr.(int 6 / int 2) in 801 + Alcotest.(check string) "division" "(6 / 2)" sql 802 + 803 + let test_like () = 804 + let sql = Expr.to_sql (Expr.like (Expr.string "hello") "%ello") in 805 + Alcotest.(check string) "LIKE" "('hello' LIKE '%ello')" sql 806 + 807 + let test_ilike () = 808 + let sql = Expr.to_sql (Expr.ilike (Expr.string "HELLO") "%ello") in 809 + Alcotest.(check string) "ILIKE" "('HELLO' ILIKE '%ello')" sql 810 + 811 + let test_is_null () = 812 + let sql = Expr.to_sql (Expr.is_null (Expr.raw "column")) in 813 + Alcotest.(check string) "IS NULL" "(column IS NULL)" sql 814 + 815 + let test_is_not_null () = 816 + let sql = Expr.to_sql (Expr.is_not_null (Expr.raw "column")) in 817 + Alcotest.(check string) "IS NOT NULL" "(column IS NOT NULL)" sql 818 + 819 + let test_between () = 820 + let sql = 821 + Expr.to_sql (Expr.between (Expr.int 5) (Expr.int 1) (Expr.int 10)) 822 + in 823 + Alcotest.(check string) "BETWEEN" "(5 BETWEEN 1 AND 10)" sql 824 + 825 + let test_in_list () = 826 + let sql = 827 + Expr.to_sql 828 + (Expr.in_list (Expr.int 1) [ Expr.int 1; Expr.int 2; Expr.int 3 ]) 829 + in 830 + Alcotest.(check string) "IN" "(1 IN (1, 2, 3))" sql 831 + 832 + let test_count () = 833 + let sql = Expr.to_sql Expr.count_all in 834 + Alcotest.(check string) "COUNT(*)" "COUNT(*)" sql 835 + 836 + let test_lower () = 837 + let sql = Expr.to_sql (Expr.lower (Expr.string "HELLO")) in 838 + Alcotest.(check string) "LOWER" "LOWER('HELLO')" sql 839 + 840 + let test_upper () = 841 + let sql = Expr.to_sql (Expr.upper (Expr.string "hello")) in 842 + Alcotest.(check string) "UPPER" "UPPER('hello')" sql 843 + 844 + let test_concat () = 845 + let sql = Expr.to_sql (Expr.concat [ Expr.string "a"; Expr.string "b" ]) in 846 + Alcotest.(check string) "CONCAT" "CONCAT('a', 'b')" sql 847 + 848 + let test_coalesce () = 849 + let sql = 850 + Expr.to_sql 851 + (Expr.coalesce 852 + [ Expr.raw "nullable"; Expr.string "default" ] 853 + Types.string) 854 + in 855 + Alcotest.(check string) "COALESCE" "COALESCE(nullable, 'default')" sql 856 + 857 + let test_cast () = 858 + let sql = Expr.to_sql (Expr.cast (Expr.int 42) Types.string) in 859 + Alcotest.(check string) "CAST" "CAST(42 AS TEXT)" sql 860 + 861 + let test_raw () = 862 + let sql = Expr.to_sql (Expr.raw "custom_function()") in 863 + Alcotest.(check string) "raw SQL" "custom_function()" sql 864 + 865 + let tests = 866 + [ 867 + ("int literal", `Quick, test_int_literal); 868 + ("string literal", `Quick, test_string_literal); 869 + ("string escaping", `Quick, test_string_escaping); 870 + ("bool true", `Quick, test_bool_true); 871 + ("bool false", `Quick, test_bool_false); 872 + ("equality", `Quick, test_equality); 873 + ("inequality", `Quick, test_inequality); 874 + ("less than", `Quick, test_less_than); 875 + ("greater than", `Quick, test_greater_than); 876 + ("AND", `Quick, test_and); 877 + ("OR", `Quick, test_or); 878 + ("NOT", `Quick, test_not); 879 + ("addition", `Quick, test_addition); 880 + ("subtraction", `Quick, test_subtraction); 881 + ("multiplication", `Quick, test_multiplication); 882 + ("division", `Quick, test_division); 883 + ("LIKE", `Quick, test_like); 884 + ("ILIKE", `Quick, test_ilike); 885 + ("IS NULL", `Quick, test_is_null); 886 + ("IS NOT NULL", `Quick, test_is_not_null); 887 + ("BETWEEN", `Quick, test_between); 888 + ("IN list", `Quick, test_in_list); 889 + ("COUNT", `Quick, test_count); 890 + ("LOWER", `Quick, test_lower); 891 + ("UPPER", `Quick, test_upper); 892 + ("CONCAT", `Quick, test_concat); 893 + ("COALESCE", `Quick, test_coalesce); 894 + ("CAST", `Quick, test_cast); 895 + ("raw", `Quick, test_raw); 896 + ] 897 + end 898 + 899 + module Assoc_test = struct 900 + let test_belongs_to () = 901 + let assoc = 902 + Assoc.belongs_to "post" ~related_table:"posts" ~foreign_key:"post_id" () 903 + in 904 + Alcotest.(check string) "name" "post" (Assoc.name assoc); 905 + Alcotest.(check string) "related_table" "posts" (Assoc.related_table assoc); 906 + Alcotest.(check string) "foreign_key" "post_id" (Assoc.foreign_key assoc); 907 + Alcotest.(check bool) "is singular" true (Assoc.is_singular assoc); 908 + Alcotest.(check bool) "owner has fk" true (Assoc.owner_has_fk assoc) 909 + 910 + let test_has_one () = 911 + let assoc = 912 + Assoc.has_one "profile" ~related_table:"profiles" ~foreign_key:"user_id" 913 + () 914 + in 915 + Alcotest.(check string) "name" "profile" (Assoc.name assoc); 916 + Alcotest.(check bool) "is singular" true (Assoc.is_singular assoc); 917 + Alcotest.(check bool) "owner has fk" false (Assoc.owner_has_fk assoc) 918 + 919 + let test_has_many () = 920 + let assoc = 921 + Assoc.has_many "comments" ~related_table:"comments" ~foreign_key:"post_id" 922 + () 923 + in 924 + Alcotest.(check string) "name" "comments" (Assoc.name assoc); 925 + Alcotest.(check bool) "is plural" true (Assoc.is_plural assoc); 926 + Alcotest.(check bool) "is singular" false (Assoc.is_singular assoc) 927 + 928 + let test_many_to_many () = 929 + let assoc = 930 + Assoc.many_to_many "tags" ~related_table:"tags" ~join_table:"post_tags" 931 + ~join_keys:("post_id", "tag_id") () 932 + in 933 + Alcotest.(check string) "name" "tags" (Assoc.name assoc); 934 + Alcotest.(check bool) "is plural" true (Assoc.is_plural assoc) 935 + 936 + let test_build_query_belongs_to () = 937 + let assoc = 938 + Assoc.belongs_to "post" ~related_table:"posts" ~foreign_key:"post_id" () 939 + in 940 + let sql = Assoc.build_query assoc "42" in 941 + Alcotest.(check bool) "SELECT from posts" true (String.length sql > 0) 942 + 943 + let test_build_query_has_many () = 944 + let assoc = 945 + Assoc.has_many "comments" ~related_table:"comments" ~foreign_key:"post_id" 946 + () 947 + in 948 + let sql = Assoc.build_query assoc "42" in 949 + Alcotest.(check bool) "has query" true (String.length sql > 0) 950 + 951 + let test_build_batch_query () = 952 + let assoc = 953 + Assoc.has_many "comments" ~related_table:"comments" ~foreign_key:"post_id" 954 + () 955 + in 956 + let sql = Assoc.build_batch_query assoc [ "1"; "2"; "3" ] in 957 + Alcotest.(check bool) "has IN clause" true (String.length sql > 0) 958 + 959 + let test_many_to_many_query () = 960 + let assoc = 961 + Assoc.many_to_many "tags" ~related_table:"tags" ~join_table:"post_tags" 962 + ~join_keys:("post_id", "tag_id") () 963 + in 964 + let sql = Assoc.build_query assoc "42" in 965 + Alcotest.(check bool) "has JOIN" true (String.length sql > 0) 966 + 967 + let tests = 968 + [ 969 + ("belongs_to", `Quick, test_belongs_to); 970 + ("has_one", `Quick, test_has_one); 971 + ("has_many", `Quick, test_has_many); 972 + ("many_to_many", `Quick, test_many_to_many); 973 + ("build_query belongs_to", `Quick, test_build_query_belongs_to); 974 + ("build_query has_many", `Quick, test_build_query_has_many); 975 + ("build_batch_query", `Quick, test_build_batch_query); 976 + ("many_to_many query", `Quick, test_many_to_many_query); 977 + ] 978 + end 979 + 980 + module Embedded_test = struct 981 + type address = { street : string; city : string; zip : string } 982 + 983 + let address_schema = 984 + Embedded.schema ~name:"address" 985 + ~decode:(fun json -> 986 + match json with 987 + | `Assoc fields -> 988 + let get_string key = 989 + match List.assoc_opt key fields with 990 + | Some (`String s) -> s 991 + | _ -> "" 992 + in 993 + Ok 994 + { 995 + street = get_string "street"; 996 + city = get_string "city"; 997 + zip = get_string "zip"; 998 + } 999 + | _ -> Error "Expected object") 1000 + ~encode:(fun addr -> 1001 + `Assoc 1002 + [ 1003 + ("street", `String addr.street); 1004 + ("city", `String addr.city); 1005 + ("zip", `String addr.zip); 1006 + ]) 1007 + ~fields:[ "street"; "city"; "zip" ] 1008 + () 1009 + 1010 + let test_from_json () = 1011 + let json = 1012 + `Assoc 1013 + [ 1014 + ("street", `String "123 Main St"); 1015 + ("city", `String "Springfield"); 1016 + ("zip", `String "12345"); 1017 + ] 1018 + in 1019 + match Embedded.from_json address_schema json with 1020 + | Ok embedded -> 1021 + let data = Embedded.data embedded in 1022 + Alcotest.(check string) "street" "123 Main St" data.street; 1023 + Alcotest.(check string) "city" "Springfield" data.city 1024 + | Error msg -> Alcotest.fail msg 1025 + 1026 + let test_to_json () = 1027 + let addr = 1028 + { street = "456 Oak Ave"; city = "Shelbyville"; zip = "67890" } 1029 + in 1030 + let embedded = 1031 + { 1032 + Embedded.data = addr; 1033 + fields = [ "street"; "city"; "zip" ]; 1034 + source = Embedded.Virtual; 1035 + } 1036 + in 1037 + let json = Embedded.to_json address_schema embedded in 1038 + match json with 1039 + | `Assoc fields -> ( 1040 + match List.assoc_opt "street" fields with 1041 + | Some (`String s) -> 1042 + Alcotest.(check string) "street in json" "456 Oak Ave" s 1043 + | _ -> Alcotest.fail "missing street") 1044 + | _ -> Alcotest.fail "expected object" 1045 + 1046 + let test_from_json_string () = 1047 + let json_str = 1048 + {|{"street":"789 Elm Blvd","city":"Capital","zip":"11111"}|} 1049 + in 1050 + match Embedded.from_json_string address_schema json_str with 1051 + | Ok embedded -> 1052 + let data = Embedded.data embedded in 1053 + Alcotest.(check string) "parsed street" "789 Elm Blvd" data.street 1054 + | Error msg -> Alcotest.fail msg 1055 + 1056 + let test_to_json_string () = 1057 + let addr = { street = "Test St"; city = "Test City"; zip = "00000" } in 1058 + let embedded = 1059 + { 1060 + Embedded.data = addr; 1061 + fields = [ "street"; "city"; "zip" ]; 1062 + source = Embedded.Virtual; 1063 + } 1064 + in 1065 + let json_str = Embedded.to_json_string address_schema embedded in 1066 + Alcotest.(check bool) "contains street" true (String.length json_str > 0) 1067 + 1068 + let test_embeds_one_from_json () = 1069 + let json = 1070 + `Assoc 1071 + [ 1072 + ("street", `String "Single"); 1073 + ("city", `String "Town"); 1074 + ("zip", `String "1"); 1075 + ] 1076 + in 1077 + match Embedded.embeds_one_from_json address_schema json with 1078 + | Ok (Some embedded) -> 1079 + Alcotest.(check string) 1080 + "embeds_one street" "Single" (Embedded.data embedded).street 1081 + | Ok None -> Alcotest.fail "expected Some" 1082 + | Error msg -> Alcotest.fail msg 1083 + 1084 + let test_embeds_one_from_json_null () = 1085 + match Embedded.embeds_one_from_json address_schema `Null with 1086 + | Ok None -> Alcotest.(check pass) "null is None" () () 1087 + | Ok (Some _) -> Alcotest.fail "expected None" 1088 + | Error msg -> Alcotest.fail msg 1089 + 1090 + let test_embeds_many_from_json () = 1091 + let json = 1092 + `List 1093 + [ 1094 + `Assoc 1095 + [ 1096 + ("street", `String "First"); 1097 + ("city", `String "A"); 1098 + ("zip", `String "1"); 1099 + ]; 1100 + `Assoc 1101 + [ 1102 + ("street", `String "Second"); 1103 + ("city", `String "B"); 1104 + ("zip", `String "2"); 1105 + ]; 1106 + ] 1107 + in 1108 + match Embedded.embeds_many_from_json address_schema json with 1109 + | Ok embeddeds -> 1110 + Alcotest.(check int) "two items" 2 (List.length embeddeds); 1111 + Alcotest.(check string) 1112 + "first street" "First" (Embedded.data (List.hd embeddeds)).street 1113 + | Error msg -> Alcotest.fail msg 1114 + 1115 + let tests = 1116 + [ 1117 + ("from_json", `Quick, test_from_json); 1118 + ("to_json", `Quick, test_to_json); 1119 + ("from_json_string", `Quick, test_from_json_string); 1120 + ("to_json_string", `Quick, test_to_json_string); 1121 + ("embeds_one from_json", `Quick, test_embeds_one_from_json); 1122 + ("embeds_one from_json null", `Quick, test_embeds_one_from_json_null); 1123 + ("embeds_many from_json", `Quick, test_embeds_many_from_json); 1124 + ] 1125 + end 1126 + 1127 + module Repo_test = struct 1128 + let test_build_insert_sql () = 1129 + let sql = Repo.build_insert_sql users_table [ "name"; "email" ] in 1130 + Alcotest.(check bool) "INSERT INTO users" true (String.length sql > 0) 1131 + 1132 + let test_build_insert_returning_sql () = 1133 + let sql = Repo.build_insert_returning_sql users_table [ "name" ] in 1134 + Alcotest.(check bool) "has RETURNING" true (String.length sql > 0) 1135 + 1136 + let test_build_update_sql () = 1137 + let sql = 1138 + Repo.build_update_sql users_table [ "name"; "email" ] ~where_column:"id" 1139 + in 1140 + Alcotest.(check bool) "UPDATE users" true (String.length sql > 0) 1141 + 1142 + let test_build_delete_sql () = 1143 + let sql = Repo.build_delete_sql users_table ~where_column:"id" in 1144 + Alcotest.(check bool) "DELETE FROM users" true (String.length sql > 0) 1145 + 1146 + let test_build_select_sql () = 1147 + let sql = 1148 + Repo.build_select_sql users_table ~columns:[ "name"; "email" ] 1149 + ~where_column:"id" 1150 + in 1151 + Alcotest.(check bool) "SELECT columns" true (String.length sql > 0) 1152 + 1153 + let test_build_select_all_sql () = 1154 + let sql = Repo.build_select_all_sql users_table ~columns:[] in 1155 + Alcotest.(check bool) "SELECT * FROM users" true (String.length sql > 0) 1156 + 1157 + let test_build_preload_sql () = 1158 + let sql = 1159 + Repo.build_preload_sql ~related_table:"comments" ~fk_column:"post_id" 1160 + ~n_ids:3 1161 + in 1162 + Alcotest.(check bool) "has IN clause" true (String.length sql > 0) 1163 + 1164 + let test_transaction_state () = 1165 + let state = Repo.create_tx_state () in 1166 + Alcotest.(check bool) "not in transaction" false (Repo.in_transaction state); 1167 + Alcotest.(check int) "depth 0" 0 (Repo.transaction_depth state) 1168 + 1169 + let test_enter_transaction () = 1170 + let state = Repo.create_tx_state () in 1171 + let action = Repo.enter_transaction state in 1172 + Alcotest.(check bool) "action is Begin" true (action = Repo.Begin); 1173 + Alcotest.(check bool) "now in transaction" true (Repo.in_transaction state); 1174 + Alcotest.(check int) "depth 1" 1 (Repo.transaction_depth state) 1175 + 1176 + let test_nested_transaction () = 1177 + let state = Repo.create_tx_state () in 1178 + let _ = Repo.enter_transaction state in 1179 + let action2 = Repo.enter_transaction state in 1180 + (match action2 with 1181 + | Repo.Savepoint name -> 1182 + Alcotest.(check bool) "savepoint name" true (String.length name > 0) 1183 + | _ -> Alcotest.fail "expected Savepoint"); 1184 + Alcotest.(check int) "depth 2" 2 (Repo.transaction_depth state) 1185 + 1186 + let test_commit_transaction () = 1187 + let state = Repo.create_tx_state () in 1188 + let _ = Repo.enter_transaction state in 1189 + let action = Repo.commit_transaction state in 1190 + Alcotest.(check bool) "action is Commit" true (action = Repo.Commit); 1191 + Alcotest.(check bool) "not in transaction" false (Repo.in_transaction state) 1192 + 1193 + let test_rollback_transaction () = 1194 + let state = Repo.create_tx_state () in 1195 + let _ = Repo.enter_transaction state in 1196 + let action = Repo.rollback_transaction state in 1197 + Alcotest.(check bool) "action is Rollback" true (action = Repo.Rollback); 1198 + Alcotest.(check bool) "not in transaction" false (Repo.in_transaction state) 1199 + 1200 + let test_validate_changeset () = 1201 + let user = make_user ~name:"Alice" () in 1202 + let cs = Changeset.create user in 1203 + match Repo.validate_changeset cs with 1204 + | Ok data -> Alcotest.(check string) "returns data" "Alice" data.name 1205 + | Error _ -> Alcotest.fail "should not error" 1206 + 1207 + let test_validate_changeset_invalid () = 1208 + let user = make_user () in 1209 + let cs = 1210 + Changeset.create user 1211 + |> Changeset.add_error ~field:"name" ~message:"error" ~validation:"test" 1212 + in 1213 + match Repo.validate_changeset cs with 1214 + | Ok _ -> Alcotest.fail "should error" 1215 + | Error (Error.Validation_failed _) -> 1216 + Alcotest.(check pass) "validation failed" () () 1217 + | Error _ -> Alcotest.fail "wrong error type" 1218 + 1219 + let test_parse_pg_constraint_unique () = 1220 + let msg = 1221 + {|duplicate key value violates unique constraint "users_email_key"|} 1222 + in 1223 + match Repo.parse_pg_constraint_error msg with 1224 + | Some (`Unique name) -> 1225 + Alcotest.(check string) "constraint name" "users_email_key" name 1226 + | _ -> Alcotest.fail "should parse unique constraint" 1227 + 1228 + let test_parse_pg_constraint_foreign_key () = 1229 + let msg = {|violates foreign key constraint "comments_post_id_fkey"|} in 1230 + match Repo.parse_pg_constraint_error msg with 1231 + | Some (`ForeignKey name) -> 1232 + Alcotest.(check string) "constraint name" "comments_post_id_fkey" name 1233 + | _ -> Alcotest.fail "should parse foreign key constraint" 1234 + 1235 + let tests = 1236 + [ 1237 + ("build_insert_sql", `Quick, test_build_insert_sql); 1238 + ("build_insert_returning_sql", `Quick, test_build_insert_returning_sql); 1239 + ("build_update_sql", `Quick, test_build_update_sql); 1240 + ("build_delete_sql", `Quick, test_build_delete_sql); 1241 + ("build_select_sql", `Quick, test_build_select_sql); 1242 + ("build_select_all_sql", `Quick, test_build_select_all_sql); 1243 + ("build_preload_sql", `Quick, test_build_preload_sql); 1244 + ("transaction_state", `Quick, test_transaction_state); 1245 + ("enter_transaction", `Quick, test_enter_transaction); 1246 + ("nested_transaction", `Quick, test_nested_transaction); 1247 + ("commit_transaction", `Quick, test_commit_transaction); 1248 + ("rollback_transaction", `Quick, test_rollback_transaction); 1249 + ("validate_changeset", `Quick, test_validate_changeset); 1250 + ("validate_changeset invalid", `Quick, test_validate_changeset_invalid); 1251 + ("parse_pg_constraint unique", `Quick, test_parse_pg_constraint_unique); 1252 + ( "parse_pg_constraint foreign_key", 1253 + `Quick, 1254 + test_parse_pg_constraint_foreign_key ); 1255 + ] 1256 + end 1257 + 1258 + module Migration_test = struct 1259 + let test_create_table () = 1260 + let op = 1261 + Migration.create_table "posts" 1262 + [ 1263 + Migration.column "id" "BIGINT" ~nullable:false ~primary_key:true; 1264 + Migration.column "title" "TEXT" ~nullable:false; 1265 + ] 1266 + in 1267 + let sql = Migration.operation_to_sql op in 1268 + Alcotest.(check bool) 1269 + "CREATE TABLE" true 1270 + (String.sub sql 0 12 = "CREATE TABLE") 1271 + 1272 + let test_drop_table () = 1273 + let op = Migration.drop_table "posts" in 1274 + let sql = Migration.operation_to_sql op in 1275 + Alcotest.(check string) "DROP TABLE" "DROP TABLE posts" sql 1276 + 1277 + let test_add_column () = 1278 + let change = 1279 + Migration.add_column (Migration.column "email" "TEXT" ~nullable:false) 1280 + in 1281 + let op = Migration.alter_table "users" [ change ] in 1282 + let sql = Migration.operation_to_sql op in 1283 + Alcotest.(check bool) "ADD COLUMN" true (String.length sql > 0) 1284 + 1285 + let test_drop_column () = 1286 + let change = Migration.drop_column "old_column" in 1287 + let op = Migration.alter_table "users" [ change ] in 1288 + let sql = Migration.operation_to_sql op in 1289 + Alcotest.(check bool) "DROP COLUMN" true (String.length sql > 0) 1290 + 1291 + let test_rename_column () = 1292 + let change = Migration.rename_column ~from:"old_name" ~to_:"new_name" in 1293 + let op = Migration.alter_table "users" [ change ] in 1294 + let sql = Migration.operation_to_sql op in 1295 + Alcotest.(check bool) "RENAME COLUMN" true (String.length sql > 0) 1296 + 1297 + let test_create_index () = 1298 + let op = Migration.create_index "users" [ "email" ] ~unique:true in 1299 + let sql = Migration.operation_to_sql op in 1300 + Alcotest.(check bool) "CREATE UNIQUE INDEX" true (String.length sql > 0) 1301 + 1302 + let test_drop_index () = 1303 + let op = Migration.drop_index "users_email_index" in 1304 + let sql = Migration.operation_to_sql op in 1305 + Alcotest.(check string) "DROP INDEX" "DROP INDEX users_email_index" sql 1306 + 1307 + let test_timestamps () = 1308 + let cols = Migration.timestamps () in 1309 + Alcotest.(check int) "two timestamp columns" 2 (List.length cols) 1310 + 1311 + let test_migration_definition () = 1312 + let m = 1313 + Migration.migration ~version:1L ~name:"create_users" 1314 + ~up: 1315 + [ Migration.create_table "users" [ Migration.column "id" "BIGINT" ] ] 1316 + ~down:[ Migration.drop_table "users" ] 1317 + in 1318 + Alcotest.(check int64) "version" 1L m.version; 1319 + Alcotest.(check string) "name" "create_users" m.name 1320 + 1321 + let test_generate_up_sql () = 1322 + let m = 1323 + Migration.migration ~version:1L ~name:"test" 1324 + ~up:[ Migration.create_table "test" [ Migration.column "id" "BIGINT" ] ] 1325 + ~down:[] 1326 + in 1327 + let sqls = Migration.generate_up_sql m in 1328 + Alcotest.(check int) "one SQL statement" 1 (List.length sqls) 1329 + 1330 + let test_pending_migrations () = 1331 + let m1 = Migration.migration ~version:1L ~name:"first" ~up:[] ~down:[] in 1332 + let m2 = Migration.migration ~version:2L ~name:"second" ~up:[] ~down:[] in 1333 + let m3 = Migration.migration ~version:3L ~name:"third" ~up:[] ~down:[] in 1334 + let pending = 1335 + Migration.pending_migrations ~applied_versions:[ 1L ] [ m1; m2; m3 ] 1336 + in 1337 + Alcotest.(check int) "two pending" 2 (List.length pending) 1338 + 1339 + let test_sort_migrations () = 1340 + let m1 = Migration.migration ~version:3L ~name:"third" ~up:[] ~down:[] in 1341 + let m2 = Migration.migration ~version:1L ~name:"first" ~up:[] ~down:[] in 1342 + let m3 = Migration.migration ~version:2L ~name:"second" ~up:[] ~down:[] in 1343 + let sorted = Migration.sort_migrations [ m1; m2; m3 ] in 1344 + Alcotest.(check int64) "first is 1" 1L (List.hd sorted).version 1345 + 1346 + let tests = 1347 + [ 1348 + ("create_table", `Quick, test_create_table); 1349 + ("drop_table", `Quick, test_drop_table); 1350 + ("add_column", `Quick, test_add_column); 1351 + ("drop_column", `Quick, test_drop_column); 1352 + ("rename_column", `Quick, test_rename_column); 1353 + ("create_index", `Quick, test_create_index); 1354 + ("drop_index", `Quick, test_drop_index); 1355 + ("timestamps", `Quick, test_timestamps); 1356 + ("migration definition", `Quick, test_migration_definition); 1357 + ("generate_up_sql", `Quick, test_generate_up_sql); 1358 + ("pending_migrations", `Quick, test_pending_migrations); 1359 + ("sort_migrations", `Quick, test_sort_migrations); 1360 + ] 1361 + end 1362 + 1363 + module Multi_test = struct 1364 + let test_empty_multi () = 1365 + let m = Multi.empty in 1366 + Alcotest.(check int) 1367 + "empty has no entries" 0 1368 + (List.length (Multi.to_list m)) 1369 + 1370 + let test_insert () = 1371 + let user = make_user ~name:"Alice" () in 1372 + let cs = Changeset.create user in 1373 + let m = Multi.empty |> Multi.insert "user" cs in 1374 + let names = Multi.names m in 1375 + Alcotest.(check bool) "has user" true (List.mem "user" names) 1376 + 1377 + let test_update () = 1378 + let user = make_user ~name:"Bob" () in 1379 + let cs = Changeset.create user in 1380 + let m = Multi.empty |> Multi.update "user" cs in 1381 + Alcotest.(check int) "one entry" 1 (List.length (Multi.to_list m)) 1382 + 1383 + let test_delete () = 1384 + let m = Multi.empty |> Multi.delete "user" ~table:users_table ~id:1 in 1385 + Alcotest.(check int) "one entry" 1 (List.length (Multi.to_list m)) 1386 + 1387 + let test_merge () = 1388 + let user = make_user () in 1389 + let cs = Changeset.create user in 1390 + let m1 = Multi.empty |> Multi.insert "first" cs in 1391 + let m2 = Multi.empty |> Multi.insert "second" cs in 1392 + let merged = Multi.merge m1 m2 in 1393 + Alcotest.(check int) "two entries" 2 (List.length (Multi.to_list merged)) 1394 + 1395 + let test_has_name () = 1396 + let user = make_user () in 1397 + let cs = Changeset.create user in 1398 + let m = Multi.empty |> Multi.insert "test" cs in 1399 + Alcotest.(check bool) "has test" true (Multi.has_name "test" m); 1400 + Alcotest.(check bool) "no other" false (Multi.has_name "other" m) 1401 + 1402 + let test_execute_sync_valid () = 1403 + let user = make_user ~name:"Test" () in 1404 + let cs = Changeset.create user in 1405 + let m = Multi.empty |> Multi.insert "user" cs in 1406 + match Multi.execute_sync m with 1407 + | Ok results -> ( 1408 + match Multi.get results "user" with 1409 + | Some (data : user) -> 1410 + Alcotest.(check string) "got user" "Test" data.name 1411 + | None -> Alcotest.fail "no user result") 1412 + | Error err -> 1413 + Alcotest.fail (Printf.sprintf "error: %s" err.failed_operation) 1414 + 1415 + let test_execute_sync_invalid () = 1416 + let user = make_user () in 1417 + let cs = 1418 + Changeset.create user 1419 + |> Changeset.add_error ~field:"name" ~message:"error" ~validation:"test" 1420 + in 1421 + let m = Multi.empty |> Multi.insert "user" cs in 1422 + match Multi.execute_sync m with 1423 + | Ok _ -> Alcotest.fail "should fail" 1424 + | Error err -> 1425 + Alcotest.(check string) "failed operation" "user" err.failed_operation 1426 + 1427 + let test_validate_multi_duplicate_names () = 1428 + let user = make_user () in 1429 + let cs = Changeset.create user in 1430 + let m = Multi.empty |> Multi.insert "same" cs |> Multi.insert "same" cs in 1431 + match Multi.validate_multi m with 1432 + | Ok () -> Alcotest.fail "should fail with duplicates" 1433 + | Error _ -> Alcotest.(check pass) "detected duplicates" () () 1434 + 1435 + let tests = 1436 + [ 1437 + ("empty multi", `Quick, test_empty_multi); 1438 + ("insert", `Quick, test_insert); 1439 + ("update", `Quick, test_update); 1440 + ("delete", `Quick, test_delete); 1441 + ("merge", `Quick, test_merge); 1442 + ("has_name", `Quick, test_has_name); 1443 + ("execute_sync valid", `Quick, test_execute_sync_valid); 1444 + ("execute_sync invalid", `Quick, test_execute_sync_invalid); 1445 + ("validate_multi duplicates", `Quick, test_validate_multi_duplicate_names); 1446 + ] 1447 + end 1448 + 1449 + let () = 1450 + Alcotest.run "repodb" 1451 + [ 1452 + ("Types", Types_test.tests); 1453 + ("Error", Error_test.tests); 1454 + ("Field", Field_test.tests); 1455 + ("Schema", Schema_test.tests); 1456 + ("Changeset", Changeset_test.tests); 1457 + ("Query", Query_test.tests); 1458 + ("Expr", Expr_test.tests); 1459 + ("Assoc", Assoc_test.tests); 1460 + ("Embedded", Embedded_test.tests); 1461 + ("Repo", Repo_test.tests); 1462 + ("Migration", Migration_test.tests); 1463 + ("Multi", Multi_test.tests); 1464 + ]