jade-sql 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,115 @@
1
+ module JadeSql
2
+ # What a checked-in schema.jd says against what the database says now.
3
+ #
4
+ # The generator reads `structure.sql` and nothing else, so a schema that
5
+ # was not regenerated after a migration describes a database that no
6
+ # longer exists, and every type built on it is wrong in a way the
7
+ # compiler cannot see.
8
+ module SchemaDrift
9
+ extend self
10
+
11
+ Report = Data.define(:added, :removed, :changed) do
12
+ def any?
13
+ [added, removed, changed].any?(&:any?)
14
+ end
15
+
16
+ def to_s
17
+ [
18
+ 'schema.jd no longer matches the database:',
19
+ '',
20
+ *line('in the database, missing here', added),
21
+ *line('here, gone from the database', removed),
22
+ *line('different', changed),
23
+ '',
24
+ 'Regenerate it with `jade-sql schema`.',
25
+ ].join("\n")
26
+ end
27
+
28
+ private
29
+
30
+ def line(label, names)
31
+ names.empty? ? [] : [" #{label}: #{names.join(', ')}"]
32
+ end
33
+ end
34
+
35
+ # Both sides are module name to source, since a schema is a module per
36
+ # enum plus the root one. A module the database no longer calls for is
37
+ # itself a difference, so the comparison is per module rather than over
38
+ # everything concatenated.
39
+ def between(generated, existing)
40
+ names = generated.keys | existing.keys
41
+ tables = names
42
+ .flat_map { table_names(generated[it].to_s) + table_names(existing[it].to_s) }
43
+ .uniq
44
+
45
+ names
46
+ .map { module_report(generated[it], existing[it], it, tables) }
47
+ .then { |reports| merge(reports) }
48
+ end
49
+
50
+ private
51
+
52
+ # A module present on one side only is reported whole, under its own name.
53
+ # Otherwise the definitions inside it are compared and grouped by table.
54
+ def module_report(from_db, on_disk, name, tables)
55
+ case [from_db, on_disk]
56
+ in [String, nil] then Report[[name], [], []]
57
+ in [nil, String] then Report[[], [name], []]
58
+ in [String, String] then report(definitions(from_db), definitions(on_disk), tables)
59
+ end
60
+ end
61
+
62
+ def merge(reports)
63
+ Report[
64
+ reports.flat_map(&:added).uniq.sort,
65
+ reports.flat_map(&:removed).uniq.sort,
66
+ reports.flat_map(&:changed).uniq.sort,
67
+ ]
68
+ end
69
+
70
+ def report(from_db, on_disk, tables)
71
+ Report[
72
+ grouped(from_db.keys - on_disk.keys, tables),
73
+ grouped(on_disk.keys - from_db.keys, tables),
74
+ grouped((from_db.keys & on_disk.keys).select { from_db[it] != on_disk[it] }, tables),
75
+ ]
76
+ end
77
+
78
+ # One table produces eight definitions, and a report naming all eight
79
+ # says less than one naming the table.
80
+ def grouped(names, tables)
81
+ names.map { table_for(it, tables) || it }.uniq.sort
82
+ end
83
+
84
+ def table_for(name, tables)
85
+ name
86
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
87
+ .downcase
88
+ .then { |snake| tables.select { snake.include?(it) } }
89
+ .max_by(&:length)
90
+ end
91
+
92
+ # A table function returns the per-table alias rather than `Table(...)`
93
+ # itself, so the aliases are read first and the functions matched against
94
+ # them. Grouping every definition under its table is the whole point of
95
+ # the report: one migration renames a column and a dozen structs change.
96
+ def table_names(source)
97
+ source
98
+ .scan(/^type alias (\w+) = Table\(/)
99
+ .flatten
100
+ .then { |aliases| source.scan(/^def (\w+) -> (#{Regexp.union(aliases)})$/) }
101
+ .map(&:first)
102
+ end
103
+
104
+ # Split on what a definition starts with, so the report names the table
105
+ # or type that moved rather than a line number.
106
+ DEFINITION = /^(?:def|struct|type)\s+([\w.?!]+)/
107
+
108
+ def definitions(source)
109
+ source
110
+ .split(/^(?=(?:def|struct|type)\s)/)
111
+ .filter_map { |chunk| chunk[DEFINITION, 1]&.then { |name| [name, chunk.strip] } }
112
+ .to_h
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,65 @@
1
+ module Sql.Expr exposing (
2
+ eq,
3
+ gt,
4
+ gte,
5
+ ilike,
6
+ like,
7
+ lt,
8
+ lte,
9
+ neq,
10
+ )
11
+
12
+ import Sql exposing (Expr(..))
13
+
14
+
15
+ # The same comparisons `Sql` exposes, against another expression rather than a
16
+ # value you hold: a second column, `db_now`, an aggregate, anything already
17
+ # built. Import it qualified, so the call says which side it takes:
18
+ #
19
+ # join(orders, (o) -> { p.id |> Expr.eq(o.person_id) })
20
+ # where(s.expires_at |> Expr.gt(db_now))
21
+ def eq(left: Expr(a), right: Expr(a)) -> Expr(Bool)
22
+ binary(left, " = ", right)
23
+ end
24
+
25
+
26
+ def neq(left: Expr(a), right: Expr(a)) -> Expr(Bool)
27
+ binary(left, " <> ", right)
28
+ end
29
+
30
+
31
+ def gt(left: Expr(a), right: Expr(a)) -> Expr(Bool)
32
+ binary(left, " > ", right)
33
+ end
34
+
35
+
36
+ def gte(left: Expr(a), right: Expr(a)) -> Expr(Bool)
37
+ binary(left, " >= ", right)
38
+ end
39
+
40
+
41
+ def lt(left: Expr(a), right: Expr(a)) -> Expr(Bool)
42
+ binary(left, " < ", right)
43
+ end
44
+
45
+
46
+ def lte(left: Expr(a), right: Expr(a)) -> Expr(Bool)
47
+ binary(left, " <= ", right)
48
+ end
49
+
50
+
51
+ def like(e: Expr(String), pattern: Expr(String)) -> Expr(Bool)
52
+ binary(e, " LIKE ", pattern)
53
+ end
54
+
55
+
56
+ def ilike(e: Expr(String), pattern: Expr(String)) -> Expr(Bool)
57
+ binary(e, " ILIKE ", pattern)
58
+ end
59
+
60
+
61
+
62
+
63
+ def binary(left: Expr(a), op: String, right: Expr(b)) -> Expr(Bool)
64
+ Expr(left.sql ++ op ++ right.sql, left.params ++ right.params)
65
+ end
@@ -0,0 +1,154 @@
1
+ module Sql.Json exposing (
2
+ Doc,
3
+ Json,
4
+ Object,
5
+ agg,
6
+ build,
7
+ coalesce,
8
+ correlated,
9
+ empty_list,
10
+ fetch_many,
11
+ fetch_one,
12
+ nested,
13
+ object,
14
+ of_array,
15
+ prop,
16
+ select,
17
+ text,
18
+ )
19
+
20
+ # Builds JSON in the database instead of in Ruby.
21
+ #
22
+ # `Expr(Json(a))` is a SQL expression whose value is JSON text encoding an
23
+ # `a`. `Json(a)` has no exported constructors, so the only way to get one is
24
+ # through this module — which means a projection can't drift from the type it
25
+ # claims to encode without the type checker noticing.
26
+ #
27
+ # The builder mirrors `Sql.Query.select`/`field` deliberately: `object` takes
28
+ # the target constructor as a type witness and `prop` peels one argument per
29
+ # field, so a projection that doesn't match the struct's fields in arity and
30
+ # type fails to compile. The witness is never called — Postgres does the
31
+ # constructing.
32
+
33
+ import Sql exposing (Expr(..), Selector, SqlError, unsafe_cast)
34
+ import Sql.Query as Query exposing (Query)
35
+ import Decode exposing (Value)
36
+
37
+
38
+ type Json(a)
39
+ = Json
40
+
41
+
42
+ struct Object(a) = {
43
+ pairs: List(String),
44
+ params: List(Value)
45
+ }
46
+
47
+
48
+ def object(make: a -> b) -> Object(a -> b)
49
+ Object([], [])
50
+ end
51
+
52
+
53
+ def prop(o: Object(a -> b), name: String, e: Expr(a)) -> Object(b)
54
+ Object(o.pairs ++ ["'" ++ name ++ "', " ++ e.sql], o.params ++ e.params)
55
+ end
56
+
57
+
58
+ def build(o: Object(a)) -> Expr(Json(a))
59
+ Expr("json_build_object(" ++ String.join(o.pairs, ", ") ++ ")", o.params)
60
+ end
61
+
62
+
63
+ # `json_agg` over an empty group is NULL, not `[]` — the same hazard
64
+ # `Sql.sum` models. Returning Maybe forces the caller through `coalesce`,
65
+ # which makes the empty-collection bug unrepresentable rather than merely
66
+ # documented.
67
+ def agg(e: Expr(Json(a)), by: Expr(b)) -> Expr(Maybe(List(a)))
68
+ Expr(
69
+ "json_agg(" ++ e.sql ++ " ORDER BY " ++ by.sql ++ ")",
70
+ e.params ++ by.params,
71
+ )
72
+ end
73
+
74
+
75
+ # `json_agg` over no rows is NULL, so an aggregation that may match nothing is
76
+ # `Maybe`. This is how it stops being one.
77
+ #
78
+ # `Sql.coalesce` cannot: its default is a value it binds, and the empty JSON
79
+ # array is a literal Postgres has to read as `json` rather than a parameter it
80
+ # would take for an array.
81
+ def coalesce(e: Expr(Maybe(List(a)))) -> Expr(List(a))
82
+ Expr("COALESCE(" ++ e.sql ++ ", '[]'::json)", e.params)
83
+ end
84
+
85
+
86
+ def empty_list -> Expr(List(a))
87
+ Expr("'[]'::json", [])
88
+ end
89
+
90
+
91
+ # Embeds one object as an object-valued field. `agg` is the same placement
92
+ # for a list-valued one: both take a rendered object and hand back the type
93
+ # the target field actually has, because `prop` peels the field's type.
94
+ def nested(e: Expr(Json(a))) -> Expr(a)
95
+ Expr(e.sql, e.params)
96
+ end
97
+
98
+
99
+ # Postgres arrays (`text[]`) are not JSON arrays: without this a `List(a)`
100
+ # column serializes as `{a,b}`. The type is unchanged — this is a rendering
101
+ # concern, not a different value.
102
+ def of_array(e: Expr(List(a))) -> Expr(List(a))
103
+ Expr("to_jsonb(" ++ e.sql ++ ")", e.params)
104
+ end
105
+
106
+
107
+ # A scalar subquery, which is what a preload becomes once the child rows are
108
+ # aggregated. The outer columns are passed in rather than closed over so the
109
+ # correlated scope is a named argument: reusing a builder under a different
110
+ # outer table is then a type error instead of a silently wrong correlation.
111
+ def correlated(outer: c, q: c -> Query(Selector(a))) -> Expr(a)
112
+ (sql, params) = outer |> q |> Query.to_sql
113
+
114
+ Expr("(" ++ sql ++ ")", params)
115
+ end
116
+
117
+
118
+ # JSON text that decodes as an `a`. The tag survives the round trip, so a
119
+ # signature can still say what a response contains after the value has
120
+ # stopped being a structure.
121
+ struct Doc(a) = {
122
+ text: String
123
+ }
124
+
125
+
126
+ def text(d: Doc(a)) -> String
127
+ d.text
128
+ end
129
+
130
+
131
+ # The row comes back as a hash keyed by column name, so the projection needs
132
+ # an alias matching Doc's field. `cast` is the escape hatch that erases
133
+ # Json(a) to the String the column actually holds — it stays inside this
134
+ # module, and the signature ties the result's tag back to the projection's.
135
+ def select(e: Expr(Json(a))) -> Query(Selector(Doc(a)))
136
+ Query.select(Doc(_)) |> Query.field_as(unsafe_cast(e), "text")
137
+ end
138
+
139
+
140
+ # The parent list is joined here rather than aggregated in SQL: LIMIT applies
141
+ # to an aggregate's single output row, not to the rows feeding it, so a paged
142
+ # json_agg silently returns the wrong page.
143
+ def fetch_many(q: Query(Selector(Doc(a)))) -> Task(Doc(List(a)), SqlError)
144
+ docs <- Query.fetch_many(q)
145
+
146
+ Task.succeed(Doc("[" ++ String.join(List.map(docs, text), ",") ++ "]"))
147
+ end
148
+
149
+
150
+ def fetch_one(q: Query(Selector(Doc(a)))) -> Task(Doc(a), SqlError)
151
+ doc <- Query.fetch_one(q)
152
+
153
+ Task.succeed(doc)
154
+ end