123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244(** This module is used for common code across different SQL database engines.
You normally will not be using this module, instead you would directly use
either [Fun_sqlite] or [Fun_postgresql]. *)moduletypeSql=sigtypedb(** The database connection or file, etc. *)typearg(** A value sent to the database in the place of a query parameter. *)type_ret(** A decoder of a single row of the resultset from running a query. *)valplaceholder:Format.formatter->int->unit(** A generic way to write placeholders for different database drivers'
prepared statement parameters.
ℹ️ Placeholders are 0-indexed. *)(** {2 Query runners} *)valquery:db->string->?args:arglist->'rret->'r(** The main function through which queries are run is the [query] function.
This function {e always} creates a prepared statement for each partial call
to [query db sql]. This prepared statement can then be called with the
actual arguments (if any) and the resultset row decoder:
{[let add_person =
query db (sql "insert into people (name, age) values (%a, %a)" placeholder 0 placeholder 1)
let add_person name age = add_person ~args:Arg.[text name; int age] unit]}
@raise Invalid_argument if trying to create multiple prepared statements
for the same SQL query in PostgreSQL. To avoid this, just create the
prepared statement {e once only} and call it whenever needed, as shown
above. *)valexec_script:db->string->unit(** [exec_script db sql] executes the [sql] script (possibly made up of multiple
statements) in the database [db]. Note that it ignores any rows returned by
any of the statements.
The script {i must not} have a trailing semicolon. *)(** {2 Binding arguments}
These encode OCaml data as data to be bound to the query statement. *)moduleArg:sigvaltext:string->argvalbool:bool->argvalint:int->argvalnativeint:nativeint->argvalint32:int32->argvalint64:int64->argvalfloat:float->argvalblob:string->argvalopt:('a->arg)->'aoption->arg(** [opt data value] is the optional [value] encoded as query data. *)end(** {2 Return types} *)typerowvalunit:unitret(** [unit] indicates that the query doesn't return any meaningful output. *)valret:(row->'a)->'aSeq.tret(** [ret decode] is a custom return type encoding for a resultset into a
sequence of values of the type decoded by [decode].
[decode] constructs a value of the custom type if possible, else raises
[Failure].
Note that the sequence rows of the resultset is unfolded as it is read from
the database. It can only be traversed {i once,} with e.g. [List.of_seq] or
[Seq.iter]. If traversed multiple times, it will raise [Failure].
@raise Invalid_argument if any row cannot be decoded.
@raise Failure if an unexpected result code is encountered. *)(** {3 Helpers to get typed values from columns} *)valint:int->row->intvalbool:int->row->boolvalint64:int->row->int64valfloat:int->row->floatvaltext:int->row->string(** Also handles values of all other types. Use this when SQLite can change the
exact type of value it returns at runtime, e.g. for very large numbers it
can return text. *)valopt:(int->row->'a)->int->row->'aoption(** [opt dec col row] is the optional value [NULL] turns to [None] at column
[col] of the result [row]. *)endmoduletypeS=sigtypedbtypeargtype_retvalsql:('a,Format.formatter,unit,string)format4->'a(** Helper to construct SQL query strings using [placeholder]s. *)exceptionBad_migrationofstringvalmigrate:db->string->unit(** [migrate db dir] applies the SQL migration scripts in [dir] on the given
database [db], keeping track of those that have already been applied.
To apply the migrations in the correct order, the migration scripts must be
given filenames that are sorted in lexicographical order of the desired
migration order, e.g. [0000_0001_init.sql] will be applied before
[0000_0002_sec.sql], and so on.
Note that this uses [exec_script] internally, which means the migration
scripts {i must not} have trailing semicolons either.
Any files with extensions other than [.sql] are ignored.
@raise Bad_migration an error occurs during applying the migrations. *)valtransaction:db->(unit->'r)->'r(** [transaction db f] runs [f ()] inside a transaction in the [db]. If the
operation succeeds, it commits the transaction and returns its result. If it
fails with an exception, it rolls back the transaction and re-raises the
exception. *)(** {3 Helpers to deal with resultset sequences} *)exceptionMore_than_one(** Thrown if we are expecting at most one result but get more. *)valonly:'aSeq.t->'a(** [only seq] is the first and only element of [seq]. This is a convenience
function because all queries return seqs but sometimes we want only a single
item, otherwise it should be an error.
Use this in preference to calculating the length of the [seq], which would
force the entire data structure.
@raise Not_found if [seq] has 0 items.
@raise More_than_one if [seq] has more than 1 item. *)valoptional:'aSeq.t->'aoption(** [optional seq] is [Some a] if [a] is the first and only element of [seq], or
[None] if [seq] is empty.
@raise More_than_one if [seq] has more than 1 item. *)endmoduleMake(Sql:Sql):Swithtypedb=Sql.dbandtypearg=Sql.argandtype'aret='aSql.ret=structtypedb=Sql.dbtypearg=Sql.argtype'aret='aSql.retletsql=Format.asprintfexceptionMore_than_oneletonlyseq=matchseq()with|Seq.Nil->raiseNot_found|Cons(a,seq)->(matchseq()with|Nil->a|Cons(_,_)->raiseMore_than_one)letoptionalseq=matchseq()with|Seq.Nil->None|Cons(a,seq)->(matchseq()with|Nil->Somea|Cons(_,_)->raiseMore_than_one)openSqllettransactiondbf=querydb"begin"unit;matchf()with|r->querydb"commit"unit;r|exceptione->querydb"rollback"unit;raiseeletslurpfile=letinc=open_infileinFun.protect~finally:(fun()->close_ininc)(fun()->really_input_stringinc(in_channel_lengthinc))exceptionBad_migrationofstringletmigratedb=exec_scriptdb"create table if not exists migration (
filename varchar(1024) not null primary key,
script text not null,
applied_at timestamp
)";letmark_ok=querydb(sql"insert into migration (filename, script, applied_at)
values (%a, %a, current_timestamp)"placeholder0placeholder1)inletmigrated=querydb(sql"select 1 from migration where filename = %a"placeholder0)inletmigratedfilename=0|>bool|>ret|>migrated~args:[filename]|>optional|>Option.fold~none:false~some:Fun.idinfundir->letfiles=Sys.readdirdirinArray.sortcomparefiles;transactiondb@@fun()->files|>Array.iter@@funfilename->letfilename=dir^"/"^filenameinletarg_filename=Arg.textfilenameinifString.ends_with~suffix:".sql"filename&¬(migratedarg_filename)thenletscript=slurpfilenameinmatchexec_scriptdbscriptwith|()->mark_ok~args:Arg.[arg_filename;textscript]unit|exceptionFailuremsg->raise(Bad_migrationmsg)end