jade-sql 0.7.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.
- checksums.yaml +4 -4
- data/README.md +16 -10
- data/docs/building.md +398 -96
- data/docs/running.md +37 -17
- data/exe/jade-sql +37 -9
- data/lib/jade-sql/bin/generate_schema.rb +655 -44
- data/lib/jade-sql/compiler/assignable.rb +146 -0
- data/lib/jade-sql/compiler/columns.rb +220 -0
- data/lib/jade-sql/compiler/errors.rb +82 -0
- data/lib/jade-sql/compiler/selectable.rb +76 -0
- data/lib/jade-sql/compiler.rb +34 -0
- data/lib/jade-sql/runtime.rb +15 -11
- data/lib/jade-sql/schema_drift.rb +115 -0
- data/lib/jade-sql/sql/expr.jd +65 -0
- data/lib/jade-sql/sql/json.jd +154 -0
- data/lib/jade-sql/sql/query.jd +284 -92
- data/lib/jade-sql/sql/write.jd +625 -0
- data/lib/jade-sql/sql.jd +328 -90
- data/lib/jade-sql/tasks.rake +41 -8
- data/lib/jade-sql/version.rb +1 -1
- data/lib/jade-sql.rb +8 -7
- metadata +16 -8
- data/lib/jade-sql/sql/mutation.jd +0 -464
|
@@ -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
|