1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
(** Migration file discovery and filtering. *)
module Int64Set = Set.Make (Int64)
let default_migrations_dir = "migrations"
let applied_set_of_list (versions : int64 list) : Int64Set.t =
List.fold_left
(fun set version -> Int64Set.add version set)
Int64Set.empty versions
let has_sql_extension (filename : string) : bool =
String.ends_with ~suffix:".sql" (String.lowercase_ascii filename)
(** A well-formed migration filename: exactly what {!Migration.from_file}
accepts. Defined in terms of the parser so the two can never disagree. *)
let is_migration_file (filename : string) : bool =
Result.is_ok (Migration.from_file filename)
(** A [.sql] file whose name starts with a digit clearly intends to be a
timestamped migration; if it then fails to parse it is an error, not
something to silently skip. Files that don't look like migration attempts
(README.md, helpers.sql, ...) are ignored. *)
let looks_like_migration (filename : string) : bool =
has_sql_extension filename
&& String.length filename > 0
&&
let c = filename.[0] in
c >= '0' && c <= '9'
let read_directory (dir_path : string) : (string list, Types.error) result =
try
if not (Sys.file_exists dir_path) then
Error
(Types.DiscoveryError
(Printf.sprintf "Migrations directory does not exist: %s" dir_path))
else if not (Sys.is_directory dir_path) then
Error
(Types.DiscoveryError
(Printf.sprintf "Path is not a directory: %s" dir_path))
else
let files = Sys.readdir dir_path |> Array.to_list in
Ok files
with e ->
Error
(Types.DiscoveryError
(Printf.sprintf "Error reading directory %s: %s" dir_path
(Printexc.to_string e)))
(** Find the first pair of migrations sharing a version. Assumes the list is
sorted by version, so duplicates are adjacent. *)
let rec first_duplicate_version = function
| (a : Migration.t) :: (b :: _ as rest) ->
if Int64.equal a.Migration.version b.Migration.version then Some (a, b)
else first_duplicate_version rest
| _ -> None
let find_migrations ?(dir = default_migrations_dir) () :
(Migration.t list, Types.error) result =
match read_directory dir with
| Error e -> Error e
| Ok files -> (
let rec parse_all acc = function
| [] -> Ok (List.rev acc)
| filename :: rest -> (
if not (has_sql_extension filename) then parse_all acc rest
else
match Migration.from_file (Filename.concat dir filename) with
| Ok migration -> parse_all (migration :: acc) rest
| Error err ->
if looks_like_migration filename then Error err
else parse_all acc rest)
in
match parse_all [] files with
| Error e -> Error e
| Ok migrations -> (
let sorted = List.sort Migration.compare migrations in
match first_duplicate_version sorted with
| Some (a, b) ->
Error
(Types.MigrationError
(Types.VersionConflict
(a.Migration.version, a.file_path, b.file_path)))
| None -> Ok sorted))
(** Find pending migrations (not yet applied) Takes a list of applied versions
and all discovered migrations, returns migrations that haven't been applied
yet. *)
let find_pending (applied_versions : int64 list)
(all_migrations : Migration.t list) : Migration.t list =
let applied_set = applied_set_of_list applied_versions in
List.filter
(fun (migration : Migration.t) ->
not (Int64Set.mem migration.Migration.version applied_set))
all_migrations
let find_by_version (migrations : Migration.t list) (version : int64) :
Migration.t option =
List.find_opt
(fun (m : Migration.t) -> Int64.equal m.Migration.version version)
migrations
let ensure_migrations_dir ?(dir = default_migrations_dir) () :
(unit, Types.error) result =
try
if not (Sys.file_exists dir) then begin
Unix.mkdir dir 0o755;
Ok ()
end
else if Sys.is_directory dir then Ok ()
else
Error
(Types.DiscoveryError
(Printf.sprintf "Path exists but is not a directory: %s" dir))
with e ->
Error
(Types.DiscoveryError
(Printf.sprintf "Error creating migrations directory %s: %s" dir
(Printexc.to_string e)))