jade-sql 0.7.0 → 0.9.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 +411 -113
- data/docs/running.md +103 -18
- data/exe/jade-sql +37 -9
- data/lib/jade-sql/bin/generate_schema.rb +702 -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.rb +32 -0
- data/lib/jade-sql/runtime.rb +74 -23
- 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 +309 -93
- data/lib/jade-sql/sql/write.jd +618 -0
- data/lib/jade-sql/sql.jd +389 -103
- data/lib/jade-sql/tasks.rake +41 -8
- data/lib/jade-sql/version.rb +1 -1
- data/lib/jade-sql.rb +92 -14
- metadata +15 -8
- data/lib/jade-sql/sql/mutation.jd +0 -464
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
module JadeSql
|
|
2
|
+
module Compiler
|
|
3
|
+
module Assignable
|
|
4
|
+
extend self
|
|
5
|
+
include Helpers
|
|
6
|
+
|
|
7
|
+
INTERFACE = 'Sql.Assignable'
|
|
8
|
+
ASSIGNMENT = 'Sql.Assignment'
|
|
9
|
+
ASSIGNMENT_FIELDS = %w[col value_sql params].freeze
|
|
10
|
+
|
|
11
|
+
def supports?(interface) = interface == INTERFACE
|
|
12
|
+
|
|
13
|
+
def derive(constraint, registry, entry_name, &lookup)
|
|
14
|
+
return failed(constraint, entry_name) unless assignment_matches?(registry)
|
|
15
|
+
|
|
16
|
+
case constraint.type
|
|
17
|
+
in Type::Application(constructor: Type::Constructor(name:), args:)
|
|
18
|
+
Symbol
|
|
19
|
+
.type_ref_from_qualified_name(name)
|
|
20
|
+
.then { registry.lookup(it) }
|
|
21
|
+
.then { derive_for(constraint, it, args, registry, lookup, entry_name) }
|
|
22
|
+
|
|
23
|
+
else
|
|
24
|
+
failed(constraint, entry_name)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
# The interface is matched by name, so some other module called `Sql`
|
|
31
|
+
# would otherwise be derived against as though it were jade-sql.
|
|
32
|
+
def assignment_matches?(registry)
|
|
33
|
+
Symbol
|
|
34
|
+
.type_ref_from_qualified_name(ASSIGNMENT)
|
|
35
|
+
.then { registry.lookup(it) }
|
|
36
|
+
.then do
|
|
37
|
+
it in Symbol::Struct(record_type: { fields: }) and
|
|
38
|
+
fields.keys.map(&:to_s) == ASSIGNMENT_FIELDS
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def derive_for(constraint, symbol, args, registry, lookup, entry_name)
|
|
43
|
+
case symbol
|
|
44
|
+
in Symbol::Union if args.empty? && single_payload?(symbol, registry)
|
|
45
|
+
derive_union(constraint, symbol, registry, lookup, entry_name)
|
|
46
|
+
|
|
47
|
+
in Symbol::Struct
|
|
48
|
+
derive_struct(constraint, symbol, args, registry, lookup, entry_name)
|
|
49
|
+
|
|
50
|
+
else
|
|
51
|
+
failed(constraint, entry_name)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# One column per variant, so each variant carries exactly the value
|
|
56
|
+
# that column is set to.
|
|
57
|
+
def single_payload?(union_sym, registry)
|
|
58
|
+
variants(union_sym, registry)
|
|
59
|
+
.then { it.any? && it.all? { it.args.size == 1 } }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def derive_union(constraint, union_sym, registry, lookup, entry_name)
|
|
63
|
+
vs = variants(union_sym, registry)
|
|
64
|
+
|
|
65
|
+
vs
|
|
66
|
+
.map { encodable_dep(it, registry) }
|
|
67
|
+
.map { lookup.call(it) }
|
|
68
|
+
.then { Results.sequence(it) }
|
|
69
|
+
.map { assignable(constraint, union_body(vs), it) }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def derive_struct(constraint, struct_sym, args, registry, lookup, entry_name)
|
|
73
|
+
fields = struct_fields(struct_sym, args, registry)
|
|
74
|
+
|
|
75
|
+
fields
|
|
76
|
+
.map { |_, type| Type.constraint('Encode.Encodable', type, nil) }
|
|
77
|
+
.map { lookup.call(it) }
|
|
78
|
+
.then { Results.sequence(it) }
|
|
79
|
+
.map { assignable(constraint, struct_body(fields), it) }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def struct_body(fields)
|
|
83
|
+
fields
|
|
84
|
+
.each_with_index
|
|
85
|
+
.map { |(name, _), idx| field_assignment(name, idx) }
|
|
86
|
+
.then { [:list, it] }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def field_assignment(name, idx)
|
|
90
|
+
[:call,
|
|
91
|
+
[:struct_constructor, ASSIGNMENT, 3],
|
|
92
|
+
[
|
|
93
|
+
Compiler.column_name(name),
|
|
94
|
+
'?',
|
|
95
|
+
[:list,
|
|
96
|
+
[[:call, [:impl_arg, idx, 'encoder'], [[:access, [:var, 'f'], name.to_s]]]],
|
|
97
|
+
],
|
|
98
|
+
],
|
|
99
|
+
]
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def encodable_dep(variant, registry)
|
|
103
|
+
variant
|
|
104
|
+
.args
|
|
105
|
+
.first
|
|
106
|
+
.then { instantiate(it, {}, registry) }
|
|
107
|
+
.then { Type.constraint('Encode.Encodable', it, nil) }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def union_body(variants)
|
|
111
|
+
variants
|
|
112
|
+
.each_with_index
|
|
113
|
+
.map { |v, idx| [[:constructor, v.qualified_name, ['x']], [assignment(v, idx)]] }
|
|
114
|
+
.then { [:case, [:var, 'f'], it] }
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def assignment(variant, idx)
|
|
118
|
+
[:call,
|
|
119
|
+
[:struct_constructor, ASSIGNMENT, 3],
|
|
120
|
+
[
|
|
121
|
+
wire_name(variant),
|
|
122
|
+
'?',
|
|
123
|
+
[:list, [[:call, [:impl_arg, idx, 'encoder'], [[:var, 'x']]]]],
|
|
124
|
+
],
|
|
125
|
+
]
|
|
126
|
+
.then { [:list, [it]] }
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def failed(constraint, entry_name)
|
|
130
|
+
Err[
|
|
131
|
+
DerivationFailed.new(
|
|
132
|
+
entry_name, constraint.origin&.range, constraint:, trace: [],
|
|
133
|
+
)
|
|
134
|
+
]
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def assignable(constraint, body, deps)
|
|
138
|
+
implementation(
|
|
139
|
+
constraint,
|
|
140
|
+
{ 'to_assigns' => Symbol::DerivedFunction.new(params: ['f'], body:) },
|
|
141
|
+
deps:,
|
|
142
|
+
)
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
module JadeSql
|
|
2
|
+
module Compiler
|
|
3
|
+
# A struct's fields map onto a table's columns by name, and the mapping
|
|
4
|
+
# derives, so nothing else ever compares the two — a field the table has
|
|
5
|
+
# no column for reaches Postgres as invalid SQL. Both types are concrete
|
|
6
|
+
# at the call site, which is why this is a check and not a constraint.
|
|
7
|
+
module Columns
|
|
8
|
+
extend self
|
|
9
|
+
include Helpers
|
|
10
|
+
|
|
11
|
+
TABLE = 'Sql.Table'
|
|
12
|
+
EXPR = 'Sql.Expr'
|
|
13
|
+
LIST = 'List.List'
|
|
14
|
+
TUPLE2 = 'Tuple.Tuple2'
|
|
15
|
+
ASSIGNABLE = 'Sql.Assignable'
|
|
16
|
+
STAMPED = 'Sql.Write.Stamped'
|
|
17
|
+
STAMPS = %w[created_at updated_at].freeze
|
|
18
|
+
|
|
19
|
+
# Where the written value is, where the table is, what the value arrives
|
|
20
|
+
# wrapped in, and whether the row is being created — only then does a
|
|
21
|
+
# column the database cannot fill have to be written.
|
|
22
|
+
#
|
|
23
|
+
# Every write taking a struct of the caller's is here. `update_all` and
|
|
24
|
+
# `delete_all` are not, because they build their SET and WHERE from the
|
|
25
|
+
# table's own `c` and `s` — there is no struct to compare.
|
|
26
|
+
Target = Data.define(:value_at, :table_at, :wrapper, :creates)
|
|
27
|
+
|
|
28
|
+
TARGETS = {
|
|
29
|
+
'Sql.Write.insert' => Target[0, 1, :bare, true],
|
|
30
|
+
'Sql.Write.insert_all' => Target[0, 1, :list, true],
|
|
31
|
+
'Sql.Write.update' => Target[0, 1, :bare, false],
|
|
32
|
+
'Sql.Write.update_many' => Target[0, 1, :keyed_list, false],
|
|
33
|
+
}.freeze
|
|
34
|
+
|
|
35
|
+
def watches = TARGETS.keys
|
|
36
|
+
|
|
37
|
+
def check(ctx)
|
|
38
|
+
TARGETS.fetch(ctx.name).then do |target|
|
|
39
|
+
table = ctx.arg_types[target.table_at]
|
|
40
|
+
value, stamps = unstamp(unwrap(ctx.arg_types[target.value_at], target.wrapper))
|
|
41
|
+
|
|
42
|
+
next [] if written?(value, ctx.registry)
|
|
43
|
+
|
|
44
|
+
compare(value, cols_of(table), ctx) +
|
|
45
|
+
(target.creates ? missing(value, stamps, table, ctx) : [])
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
# A field maps to the column of the same name only because the deriver
|
|
52
|
+
# says so. An author who wrote `to_assigns` by hand has already said
|
|
53
|
+
# what the columns are — one field may become two, or none — so there
|
|
54
|
+
# is nothing here to compare against.
|
|
55
|
+
def written?(value, registry)
|
|
56
|
+
case implementation(value, registry)
|
|
57
|
+
in Symbol::Implementation(module_name: String) then true
|
|
58
|
+
else false
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def implementation(value, registry)
|
|
63
|
+
case value
|
|
64
|
+
in Type::Application(constructor: Type::Constructor(name:))
|
|
65
|
+
registry.implementations[[ASSIGNABLE, name]]
|
|
66
|
+
|
|
67
|
+
else nil
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# `r` names the columns the database will not fill in, so a value that
|
|
72
|
+
# writes none of them produces a row Postgres rejects. `timestamped` writes
|
|
73
|
+
# two of them without them being fields, which is why it is carried
|
|
74
|
+
# alongside the value's own.
|
|
75
|
+
def missing(value, stamps, table, ctx)
|
|
76
|
+
case [fields_of(value, ctx.registry), columns_of(required_of(table), ctx.registry)]
|
|
77
|
+
in [Array => fields, Hash => required]
|
|
78
|
+
required.keys - fields.map { Compiler.column_name(it.first) } - stamps
|
|
79
|
+
|
|
80
|
+
else []
|
|
81
|
+
end
|
|
82
|
+
.then do |absent|
|
|
83
|
+
next [] if absent.empty?
|
|
84
|
+
|
|
85
|
+
[Errors::MissingColumns.new(
|
|
86
|
+
ctx.entry_name, ctx.span,
|
|
87
|
+
struct: name_of(value), table: name_of(cols_of(table)), missing: absent,
|
|
88
|
+
)]
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# `Table(c, m, k, o, r, s)` — the required columns are its fifth
|
|
93
|
+
# argument. Matched by position rather than "the last one", so appending
|
|
94
|
+
# a parameter cannot silently point this at something else.
|
|
95
|
+
def required_of(table)
|
|
96
|
+
case table
|
|
97
|
+
in Type::Application(
|
|
98
|
+
constructor: Type::Constructor(name: TABLE), args: [_, _, _, _, required, _]
|
|
99
|
+
)
|
|
100
|
+
required
|
|
101
|
+
|
|
102
|
+
else nil
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# `timestamped` writes `created_at` and `updated_at` on a value that has no
|
|
107
|
+
# such fields, so it answers for them without appearing among them.
|
|
108
|
+
def unstamp(value)
|
|
109
|
+
case value
|
|
110
|
+
in Type::Application(constructor: Type::Constructor(name: STAMPED), args: [inner])
|
|
111
|
+
[inner, STAMPS]
|
|
112
|
+
|
|
113
|
+
else [value, []]
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Either side may be absent — an argument the caller left off, a table
|
|
118
|
+
# still polymorphic in its columns — and then there is nothing to compare.
|
|
119
|
+
def compare(value, cols, ctx)
|
|
120
|
+
case [fields_of(value, ctx.registry), columns_of(cols, ctx.registry)]
|
|
121
|
+
in [Array => fields, Hash => columns]
|
|
122
|
+
fields.filter_map { mismatch(it, columns, value, cols, ctx) }
|
|
123
|
+
|
|
124
|
+
else []
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def mismatch((name, type), columns, value, cols, ctx)
|
|
129
|
+
column = Compiler.column_name(name)
|
|
130
|
+
|
|
131
|
+
case columns[column]
|
|
132
|
+
in nil
|
|
133
|
+
Errors::UnknownColumn.new(
|
|
134
|
+
ctx.entry_name, ctx.span,
|
|
135
|
+
struct: name_of(value), field: name, table: name_of(cols),
|
|
136
|
+
columns: columns.keys,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
in ^type
|
|
140
|
+
nil
|
|
141
|
+
|
|
142
|
+
in found
|
|
143
|
+
Errors::ColumnTypeMismatch.new(
|
|
144
|
+
ctx.entry_name, ctx.span,
|
|
145
|
+
struct: name_of(value), field: name, table: name_of(cols),
|
|
146
|
+
column:, expected: found, actual: type,
|
|
147
|
+
)
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# `Table(c, m, k, ...)` — the columns are its first argument.
|
|
152
|
+
def cols_of(table)
|
|
153
|
+
case table
|
|
154
|
+
in Type::Application(constructor: Type::Constructor(name: TABLE), args: [cols, *])
|
|
155
|
+
cols
|
|
156
|
+
|
|
157
|
+
else nil
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Each column is an `Expr(t)` whose `t` is what the field has to be.
|
|
162
|
+
def columns_of(cols, registry)
|
|
163
|
+
fields_of(cols, registry)
|
|
164
|
+
&.to_h { |field, type| [Compiler.column_name(field), unwrap_expr(type)] }
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def fields_of(type, registry)
|
|
168
|
+
case type
|
|
169
|
+
in Type::Application(constructor: Type::Constructor(name:), args:)
|
|
170
|
+
Symbol
|
|
171
|
+
.type_ref_from_qualified_name(name)
|
|
172
|
+
.then { registry.lookup(it) }
|
|
173
|
+
.then { (it in Symbol::Struct) ? struct_fields(it, args, registry) : nil }
|
|
174
|
+
|
|
175
|
+
else nil
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def unwrap(type, wrapper)
|
|
180
|
+
case [wrapper, type]
|
|
181
|
+
in [:list, Type::Application(constructor: Type::Constructor(name: LIST), args: [inner])]
|
|
182
|
+
inner
|
|
183
|
+
|
|
184
|
+
# `update_many` takes the key alongside the value, so the struct is the
|
|
185
|
+
# second half of each pair rather than the element itself.
|
|
186
|
+
in [
|
|
187
|
+
:keyed_list,
|
|
188
|
+
Type::Application(
|
|
189
|
+
constructor: Type::Constructor(name: LIST),
|
|
190
|
+
args: [Type::Application(
|
|
191
|
+
constructor: Type::Constructor(name: TUPLE2), args: [_, inner]
|
|
192
|
+
)],
|
|
193
|
+
)
|
|
194
|
+
]
|
|
195
|
+
inner
|
|
196
|
+
|
|
197
|
+
else type
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def unwrap_expr(type)
|
|
202
|
+
case type
|
|
203
|
+
in Type::Application(constructor: Type::Constructor(name: EXPR), args: [inner])
|
|
204
|
+
inner
|
|
205
|
+
|
|
206
|
+
else type
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def name_of(type)
|
|
211
|
+
case type
|
|
212
|
+
in Type::Application(constructor: Type::Constructor(name:), args: _)
|
|
213
|
+
name.split('.').last
|
|
214
|
+
|
|
215
|
+
else type.to_s
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
module JadeSql
|
|
2
|
+
module Compiler
|
|
3
|
+
module Errors
|
|
4
|
+
class UnknownColumn < Jade::Error
|
|
5
|
+
def initialize(entry, span, struct:, field:, table:, columns:)
|
|
6
|
+
@struct = struct
|
|
7
|
+
@field = field
|
|
8
|
+
@table = table
|
|
9
|
+
@columns = columns
|
|
10
|
+
super(entry:, span:)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def message
|
|
14
|
+
"#{@struct}.#{@field} has no column on #{@table}"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def label
|
|
18
|
+
"no column `#{@field}`"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def queried_name = @field.to_s
|
|
22
|
+
|
|
23
|
+
def candidates = @columns
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
class MissingColumns < Jade::Error
|
|
27
|
+
STAMPS = %w[created_at updated_at].freeze
|
|
28
|
+
|
|
29
|
+
def initialize(entry, span, struct:, table:, missing:)
|
|
30
|
+
@struct = struct
|
|
31
|
+
@table = table
|
|
32
|
+
@missing = missing
|
|
33
|
+
super(entry:, span:)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def message
|
|
37
|
+
"#{@struct} does not write #{list(@missing)}, " \
|
|
38
|
+
"which #{@table} requires"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def label
|
|
42
|
+
"missing #{list(@missing)}"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def notes
|
|
46
|
+
return [] unless (@missing - STAMPS).empty?
|
|
47
|
+
|
|
48
|
+
[Jade::Diagnostics::Annotation[:help, 'pipe the value through `timestamped`']]
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def list(names)
|
|
54
|
+
names.map { "`#{it}`" }.then do |quoted|
|
|
55
|
+
quoted.length == 1 ? quoted.first : "#{quoted[..-2].join(', ')} and #{quoted.last}"
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
class ColumnTypeMismatch < Jade::Error
|
|
61
|
+
def initialize(entry, span, struct:, field:, table:, column:, expected:, actual:)
|
|
62
|
+
@struct = struct
|
|
63
|
+
@field = field
|
|
64
|
+
@table = table
|
|
65
|
+
@column = column
|
|
66
|
+
@expected = expected
|
|
67
|
+
@actual = actual
|
|
68
|
+
super(entry:, span:)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def message
|
|
72
|
+
"#{@table}.#{@column} is #{@expected}, " \
|
|
73
|
+
"but #{@struct}.#{@field} is #{@actual}"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def label
|
|
77
|
+
"column is #{@expected}, field is #{@actual}"
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
require 'jade'
|
|
2
|
+
|
|
3
|
+
module JadeSql
|
|
4
|
+
# jade-sql's half of the compiler: an interface it derives and a check it
|
|
5
|
+
# runs, both registered through `Jade::Extensions`.
|
|
6
|
+
module Compiler
|
|
7
|
+
Type = Jade::Type
|
|
8
|
+
Symbol = Jade::Symbol
|
|
9
|
+
Results = Jade::Results
|
|
10
|
+
Ok = Jade::Ok
|
|
11
|
+
Err = Jade::Err
|
|
12
|
+
Lexer = Jade::Lexer
|
|
13
|
+
Helpers = Jade::Frontend::TypeChecking::Constraints::Deriving::Helpers
|
|
14
|
+
DerivationFailed = Jade::Frontend::TypeChecking::Error::DerivationFailed
|
|
15
|
+
|
|
16
|
+
# The generator renames a column that collides with a jade keyword, so
|
|
17
|
+
# `type_` maps back to the `type` it came from.
|
|
18
|
+
def self.column_name(field)
|
|
19
|
+
field
|
|
20
|
+
.to_s
|
|
21
|
+
.then { it.end_with?('_') ? it.delete_suffix('_') : it }
|
|
22
|
+
.then { Lexer::KEYWORDS.include?(it) ? it : field.to_s }
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
require_relative 'compiler/errors'
|
|
28
|
+
require_relative 'compiler/assignable'
|
|
29
|
+
require_relative 'compiler/columns'
|
|
30
|
+
|
|
31
|
+
Jade::Extensions.register_deriver('jade-sql', JadeSql::Compiler::Assignable)
|
|
32
|
+
Jade::Extensions.register_check('jade-sql', :call, JadeSql::Compiler::Columns)
|
data/lib/jade-sql/runtime.rb
CHANGED
|
@@ -11,59 +11,57 @@ module JadeSql
|
|
|
11
11
|
|
|
12
12
|
task :port_execute_count do |t, sql, params|
|
|
13
13
|
conn = ::ActiveRecord::Base.connection
|
|
14
|
-
t.ok(conn.exec_update(
|
|
15
|
-
rescue ::ActiveRecord::RecordNotUnique => e
|
|
16
|
-
t.err(JadeSql::SqlErrors.conflict(constraint_name(e)))
|
|
14
|
+
t.ok(conn.exec_update(statement(sql, params, conn), "Jade", typed_params(params, conn)))
|
|
17
15
|
rescue ::ActiveRecord::StatementInvalid => e
|
|
18
|
-
t.err(
|
|
16
|
+
t.err(translate(e))
|
|
19
17
|
end
|
|
20
18
|
|
|
21
19
|
task :port_execute_one do |t, sql, params|
|
|
22
20
|
conn = ::ActiveRecord::Base.connection
|
|
23
|
-
rows = conn.exec_query(
|
|
21
|
+
rows = conn.exec_query(statement(sql, params, conn), "Jade", typed_params(params, conn)).to_a
|
|
24
22
|
case rows.length
|
|
25
23
|
when 0 then t.err(JadeSql::SqlErrors.not_found)
|
|
26
24
|
when 1 then t.ok(coerce_row(rows.first))
|
|
27
|
-
else t.err(JadeSql::SqlErrors.
|
|
25
|
+
else t.err(JadeSql::SqlErrors.too_many_rows)
|
|
28
26
|
end
|
|
29
|
-
rescue ::ActiveRecord::RecordNotUnique => e
|
|
30
|
-
t.err(JadeSql::SqlErrors.conflict(constraint_name(e)))
|
|
31
27
|
rescue ::ActiveRecord::StatementInvalid => e
|
|
32
|
-
t.err(
|
|
28
|
+
t.err(translate(e))
|
|
33
29
|
end
|
|
34
30
|
|
|
35
31
|
task :port_execute_many do |t, sql, params|
|
|
36
32
|
conn = ::ActiveRecord::Base.connection
|
|
37
|
-
rows = conn.exec_query(
|
|
33
|
+
rows = conn.exec_query(statement(sql, params, conn), "Jade", typed_params(params, conn)).to_a
|
|
38
34
|
t.ok(rows.map { |row| coerce_row(row) })
|
|
39
|
-
rescue ::ActiveRecord::RecordNotUnique => e
|
|
40
|
-
t.err(JadeSql::SqlErrors.conflict(constraint_name(e)))
|
|
41
35
|
rescue ::ActiveRecord::StatementInvalid => e
|
|
42
|
-
t.err(
|
|
36
|
+
t.err(translate(e))
|
|
43
37
|
end
|
|
44
38
|
|
|
45
39
|
# Transaction control on the shared connection. The execute/fetch ports
|
|
46
40
|
# above use the same `ActiveRecord::Base.connection`, so anything they
|
|
47
41
|
# run between begin and commit/rollback is part of this transaction.
|
|
48
|
-
#
|
|
49
|
-
#
|
|
50
|
-
#
|
|
42
|
+
#
|
|
43
|
+
# Not the raw `begin_db_transaction` family: those only emit SQL, so a
|
|
44
|
+
# second BEGIN is a warning and its COMMIT ends whichever transaction was
|
|
45
|
+
# already running. The manager issues a SAVEPOINT instead.
|
|
46
|
+
#
|
|
47
|
+
# Rollback is best-effort: it swallows adapter errors so the original
|
|
48
|
+
# failure is the one that propagates.
|
|
51
49
|
task :port_begin do |t|
|
|
52
|
-
::ActiveRecord::Base.connection.
|
|
50
|
+
::ActiveRecord::Base.connection.begin_transaction(joinable: false)
|
|
53
51
|
t.ok(true)
|
|
54
52
|
rescue ::ActiveRecord::StatementInvalid => e
|
|
55
53
|
t.err(JadeSql::SqlErrors.db_error(e.message))
|
|
56
54
|
end
|
|
57
55
|
|
|
58
56
|
task :port_commit do |t|
|
|
59
|
-
::ActiveRecord::Base.connection.
|
|
57
|
+
::ActiveRecord::Base.connection.commit_transaction
|
|
60
58
|
t.ok(true)
|
|
61
59
|
rescue ::ActiveRecord::StatementInvalid => e
|
|
62
60
|
t.err(JadeSql::SqlErrors.db_error(e.message))
|
|
63
61
|
end
|
|
64
62
|
|
|
65
63
|
task :port_rollback do |t|
|
|
66
|
-
::ActiveRecord::Base.connection.
|
|
64
|
+
::ActiveRecord::Base.connection.rollback_transaction
|
|
67
65
|
t.ok(true)
|
|
68
66
|
rescue ::ActiveRecord::StatementInvalid
|
|
69
67
|
t.ok(true)
|
|
@@ -173,9 +171,36 @@ module JadeSql
|
|
|
173
171
|
raw == "NULL" ? nil : raw
|
|
174
172
|
end
|
|
175
173
|
|
|
176
|
-
#
|
|
177
|
-
#
|
|
178
|
-
|
|
174
|
+
# ActiveRecord already tells these apart by SQLSTATE, and dropping that
|
|
175
|
+
# into a message is what forced callers to match text.
|
|
176
|
+
def self.translate(error)
|
|
177
|
+
case error
|
|
178
|
+
when ::ActiveRecord::RecordNotUnique
|
|
179
|
+
JadeSql::SqlErrors.unique_violation(constraint_name(error))
|
|
180
|
+
when ::ActiveRecord::InvalidForeignKey
|
|
181
|
+
JadeSql::SqlErrors.foreign_key_violation(constraint_name(error))
|
|
182
|
+
when ::ActiveRecord::CheckViolation
|
|
183
|
+
JadeSql::SqlErrors.check_violation(constraint_name(error))
|
|
184
|
+
when ::ActiveRecord::ExclusionViolation
|
|
185
|
+
JadeSql::SqlErrors.exclusion_violation(constraint_name(error))
|
|
186
|
+
when ::ActiveRecord::NotNullViolation
|
|
187
|
+
JadeSql::SqlErrors.not_null_violation(column_name(error))
|
|
188
|
+
when ::ActiveRecord::Deadlocked
|
|
189
|
+
JadeSql::SqlErrors.deadlock
|
|
190
|
+
when ::ActiveRecord::SerializationFailure
|
|
191
|
+
JadeSql::SqlErrors.serialization_failure
|
|
192
|
+
when ::ActiveRecord::LockWaitTimeout
|
|
193
|
+
JadeSql::SqlErrors.lock_timeout
|
|
194
|
+
when ::ActiveRecord::QueryCanceled, ::ActiveRecord::StatementTimeout
|
|
195
|
+
JadeSql::SqlErrors.statement_timeout
|
|
196
|
+
else
|
|
197
|
+
JadeSql::SqlErrors.db_error(error.message)
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# The constraint/index name behind a violation, so callers can route by
|
|
202
|
+
# which one it was. PG reports it in the error's diagnostics; other
|
|
203
|
+
# adapters (or a missing name) fall back to "".
|
|
179
204
|
def self.constraint_name(error)
|
|
180
205
|
cause = error.cause
|
|
181
206
|
return "" unless defined?(::PG::Result) && cause.respond_to?(:result) && cause.result
|
|
@@ -185,7 +210,16 @@ module JadeSql
|
|
|
185
210
|
""
|
|
186
211
|
end
|
|
187
212
|
|
|
188
|
-
|
|
213
|
+
def self.column_name(error)
|
|
214
|
+
cause = error.cause
|
|
215
|
+
return "" unless defined?(::PG::Result) && cause.respond_to?(:result) && cause.result
|
|
216
|
+
|
|
217
|
+
cause.result.error_field(::PG::Result::PG_DIAG_COLUMN_NAME) || ""
|
|
218
|
+
rescue StandardError
|
|
219
|
+
""
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Sql.Write.timestamped emits "$JADE_SQL_NOW$" where created_at /
|
|
189
223
|
# updated_at go. Swap it for one UTC timestamp literal per statement,
|
|
190
224
|
# computed here — the app clock, so it moves with travel_to/Timecop
|
|
191
225
|
# (unlike DB now()). The token lives in SQL we generate, never in a
|
|
@@ -199,6 +233,23 @@ module JadeSql
|
|
|
199
233
|
sql.gsub(NOW_TOKEN) { stamp }
|
|
200
234
|
end
|
|
201
235
|
|
|
236
|
+
def self.statement(sql, params, conn)
|
|
237
|
+
fill_now(sql)
|
|
238
|
+
.tap { refuse_stacked(it) if params.empty? }
|
|
239
|
+
.then { adapt_sql(it, conn) }
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def self.refuse_stacked(sql)
|
|
243
|
+
at = sql.sub(/;\s*\z/, '').index(';')
|
|
244
|
+
return if at.nil?
|
|
245
|
+
|
|
246
|
+
raise ArgumentError,
|
|
247
|
+
"jade-sql refused a statement with a second one after `;` " \
|
|
248
|
+
"(at character #{at + 1}). Without bound values, Postgres would run " \
|
|
249
|
+
"every statement in the string. If a value holds the `;`, bind it " \
|
|
250
|
+
"with `?`. If you meant two statements, make two calls."
|
|
251
|
+
end
|
|
252
|
+
|
|
202
253
|
# Sql renders `?` placeholders uniformly. AR's exec_query/exec_update
|
|
203
254
|
# path on the PG adapter expects `$1, $2, …` — there is no `?`-to-`$n`
|
|
204
255
|
# rewrite at that layer. SQLite and MySQL accept `?` directly, so this
|