+6
.beads/issues.jsonl
+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
+2
-2
bin/dune
+1
-1
bin/main.ml
+1
-1
bin/main.ml
+5
-5
dune-project
+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
+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
+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
+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
+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
+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
-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
-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
lib/mlecto_error.ml
lib/error.ml
-217
lib/mlecto_expr.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
+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
lib/mlecto_migration.ml
lib/migration.ml
+14
-16
lib/mlecto_multi.ml
lib/multi.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
+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
-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
+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
lib/mlecto_type.ml
lib/types.ml
+263
lib/repo.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
+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
+2
-2
test/dune
-1
test/test_mlecto.ml
-1
test/test_mlecto.ml
···
1
-
let () = print_endline "Tests not yet implemented"
+1464
test/test_repodb.ml
+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
+
]