diamond-orm 0.1.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 +7 -0
- data/lib/diamond/api_catalog.rb +676 -0
- data/lib/diamond/ast.rb +397 -0
- data/lib/diamond/changeset.rb +36 -0
- data/lib/diamond/compiler/base.rb +17 -0
- data/lib/diamond/compiler/ddl.rb +94 -0
- data/lib/diamond/compiler/dml.rb +109 -0
- data/lib/diamond/compiler/dql.rb +390 -0
- data/lib/diamond/compiler/registry.rb +45 -0
- data/lib/diamond/cursor.rb +52 -0
- data/lib/diamond/domains/cte.rb +25 -0
- data/lib/diamond/domains/ddl.rb +11 -0
- data/lib/diamond/domains/dml.rb +106 -0
- data/lib/diamond/domains/dql.rb +395 -0
- data/lib/diamond/domains/dynamic_finders.rb +64 -0
- data/lib/diamond/dsl/default.rb +419 -0
- data/lib/diamond/engine.rb +226 -0
- data/lib/diamond/json_string.rb +13 -0
- data/lib/diamond/null_table.rb +10 -0
- data/lib/diamond/operator.rb +31 -0
- data/lib/diamond/operators/like.rb +142 -0
- data/lib/diamond/parser/proxy.rb +469 -0
- data/lib/diamond/parser/registry.rb +74 -0
- data/lib/diamond/parser.rb +571 -0
- data/lib/diamond/query_object.rb +463 -0
- data/lib/diamond/struct_factory.rb +262 -0
- data/lib/diamond/table.rb +32 -0
- data/lib/diamond/version.rb +3 -0
- data/lib/diamond.rb +436 -0
- data/sig/diamond.rbs +693 -0
- metadata +95 -0
|
@@ -0,0 +1,571 @@
|
|
|
1
|
+
require 'prism'
|
|
2
|
+
require_relative 'parser/registry'
|
|
3
|
+
require_relative 'parser/proxy'
|
|
4
|
+
|
|
5
|
+
module Diamond
|
|
6
|
+
module Parser
|
|
7
|
+
WINDOW_FUNCS = [:row_number, :rank, :dense_rank, :lag, :lead].freeze
|
|
8
|
+
AGGREGATIONS = [:count, :sum, :avg, :min, :max].freeze
|
|
9
|
+
DDL_METHODS = [:attribute, :primary_key, :foreign_key, :index, :add_column].freeze
|
|
10
|
+
# Shared by translate_ddl_stmt and the console DDL recorder so the
|
|
11
|
+
# two paths can never disagree on valid actions.
|
|
12
|
+
DDL_FK_ACTIONS = %i[cascade set_null set_default restrict no_action].freeze
|
|
13
|
+
|
|
14
|
+
# caches are per-Ractor so a worker Ractor doesn't trip an isolation
|
|
15
|
+
# error trying to read main-Ractor Prism::Node values. each Ractor
|
|
16
|
+
# keeps its own hash on Ractor.current's local storage, keyed by
|
|
17
|
+
# purpose (:where, :derive, :ddl, :update, :line).
|
|
18
|
+
CACHES_KEY = Diamond::RACTOR_KEYS[:parser_caches]
|
|
19
|
+
|
|
20
|
+
def self.caches
|
|
21
|
+
Ractor.current[CACHES_KEY] ||= Hash.new { |h, k| h[k] = {} }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# everything the parser remembers lives here. clear_caches! (runs on
|
|
25
|
+
# reload_schema!) drops it all so DDL-heavy scripts don't leak prism
|
|
26
|
+
# trees and dev-reload never serves stale ASTs. no whole-file tree
|
|
27
|
+
# cache on purpose - candidate_blocks indexes once per file, buckets
|
|
28
|
+
# blocks per line, then lets the tree die. per-Ractor: clears only
|
|
29
|
+
# the current Ractor's caches (other Ractors retain theirs).
|
|
30
|
+
def self.clear_caches!
|
|
31
|
+
Ractor.current[CACHES_KEY] = Hash.new { |h, k| h[k] = {} }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# the per-Ractor cache hash for a given purpose.
|
|
35
|
+
def self.cache_for(purpose)
|
|
36
|
+
caches[purpose]
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# the per-Ractor line cache (file+line -> Prism::BlockNode[]).
|
|
40
|
+
def self.line_cache
|
|
41
|
+
caches[:line]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# ====================================================================
|
|
45
|
+
# where blocks: one expression in, one AST::Node out. `scope` maps
|
|
46
|
+
# table names in play (base + joins) to their schemas; when present,
|
|
47
|
+
# `tags.tag` in a block resolves to a qualified column.
|
|
48
|
+
#
|
|
49
|
+
# Blocks from real files go through Prism (full && / || support);
|
|
50
|
+
# blocks from consoles (irb/eval/-e/pry) fall back to the runtime
|
|
51
|
+
# proxy (see Proxy — REPL rule: use & / | there).
|
|
52
|
+
# ====================================================================
|
|
53
|
+
def self.parse_block(block, schema, scope = nil)
|
|
54
|
+
return parse_block_with_prism(block, schema, scope) if Proxy.file_backed?(block)
|
|
55
|
+
Proxy.parse_where(block, schema, scope)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def self.parse_block_with_prism(block, schema, scope = nil)
|
|
59
|
+
_parse_with_candidates(block, :where) { |node| translate_where(node, schema, scope) }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# ====================================================================
|
|
63
|
+
# derive blocks: one AST node per statement out. bare columns,
|
|
64
|
+
# function calls, window chains, all of it.
|
|
65
|
+
# ====================================================================
|
|
66
|
+
def self.parse_derive(block, schema)
|
|
67
|
+
return parse_derive_with_prism(block, schema) if Proxy.file_backed?(block)
|
|
68
|
+
Proxy.parse_derive(block, schema)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def self.parse_derive_with_prism(block, schema)
|
|
72
|
+
_parse_with_candidates(block, :derive) do |node|
|
|
73
|
+
unwrap_statements(node).map { |stmt| translate_derive(stmt, schema) }
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# ====================================================================
|
|
78
|
+
# define_relation blocks: ColumnDefinitions, ForeignKeys and Indexes out.
|
|
79
|
+
# ====================================================================
|
|
80
|
+
def self.parse_ddl(block)
|
|
81
|
+
return parse_ddl_with_prism(block) if Proxy.file_backed?(block)
|
|
82
|
+
Proxy.parse_ddl(block)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def self.parse_ddl_with_prism(block)
|
|
86
|
+
_parse_with_candidates(block, :ddl) do |node|
|
|
87
|
+
unwrap_statements(node).map { |stmt| translate_ddl_stmt(stmt) }
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# ====================================================================
|
|
92
|
+
# update blocks: { col => value } out. smalltalk form only: `age 17`.
|
|
93
|
+
# the old `age = 17` assignment form is gone — blocks are parsed,
|
|
94
|
+
# not run, so an assignment never assigned anything anyway.
|
|
95
|
+
# ====================================================================
|
|
96
|
+
def self.parse_update(block, schema)
|
|
97
|
+
return parse_update_with_prism(block, schema) if Proxy.file_backed?(block)
|
|
98
|
+
Proxy.parse_update(block, schema)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def self.parse_update_with_prism(block, schema)
|
|
102
|
+
_parse_with_candidates(block, :update) do |node|
|
|
103
|
+
statements = unwrap_statements(node)
|
|
104
|
+
hash = {}
|
|
105
|
+
statements.each do |stmt|
|
|
106
|
+
case stmt
|
|
107
|
+
when Prism::CallNode
|
|
108
|
+
positional, kwargs = split_args(stmt)
|
|
109
|
+
if stmt.receiver.nil? && positional.size == 1 && kwargs.empty?
|
|
110
|
+
validate_column!(stmt.name, schema)
|
|
111
|
+
hash[stmt.name] = literal_value(positional.first)
|
|
112
|
+
else
|
|
113
|
+
raise BlockMismatch, "Invalid update statement: #{stmt.inspect}"
|
|
114
|
+
end
|
|
115
|
+
else
|
|
116
|
+
raise BlockMismatch, "Invalid update statement: #{stmt.class}"
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
hash
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# ====================================================================
|
|
124
|
+
# Internals
|
|
125
|
+
# ====================================================================
|
|
126
|
+
|
|
127
|
+
# wrong block, try the next candidate. different from UnknownColumnError
|
|
128
|
+
# and friends, which mean right block, bad content.
|
|
129
|
+
class BlockMismatch < StandardError; end
|
|
130
|
+
|
|
131
|
+
# first lookup for a file parses once, walks once, buckets every block
|
|
132
|
+
# by line. later lines are hash hits. tree dies after indexing - only
|
|
133
|
+
# per-line block subtrees stick around.
|
|
134
|
+
def self.candidate_blocks(file, line)
|
|
135
|
+
lc = line_cache
|
|
136
|
+
key = [file, line]
|
|
137
|
+
return lc[key] if lc.key?(key)
|
|
138
|
+
index_file_blocks(file)
|
|
139
|
+
lc[key] ||= []
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def self.index_file_blocks(file)
|
|
143
|
+
lc = line_cache
|
|
144
|
+
marker = [file, :__indexed__]
|
|
145
|
+
return if lc.key?(marker)
|
|
146
|
+
tree = Prism.parse_file(file).value
|
|
147
|
+
bucket = Hash.new { |h, k| h[k] = [] }
|
|
148
|
+
collect_blocks_into(tree, bucket)
|
|
149
|
+
bucket.each { |ln, nodes| lc[[file, ln]] = nodes }
|
|
150
|
+
lc[marker] = true
|
|
151
|
+
# `tree` falls out of scope here by design (see clear_caches! note).
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def self.collect_blocks_into(node, bucket)
|
|
155
|
+
return unless node.respond_to?(:location)
|
|
156
|
+
bucket[node.location.start_line] << node if node.is_a?(Prism::BlockNode)
|
|
157
|
+
if node.compact_child_nodes
|
|
158
|
+
node.compact_child_nodes.each { |c| collect_blocks_into(c, bucket) }
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# chained blocks on one line (`.where{}.or{}`) all live on the same
|
|
163
|
+
# line, so consume candidates in call order: each call scans forward
|
|
164
|
+
# from a per-key cursor (wrapping) and takes the first candidate that
|
|
165
|
+
# parses. the wrap matters — a chained line inside a loop must resolve
|
|
166
|
+
# 0,1,0,1... across iterations, not drift. single-block lines always
|
|
167
|
+
# resolve index 0, so existing behavior is unchanged.
|
|
168
|
+
# real errors (UnknownColumn etc.) still blow up - only shape
|
|
169
|
+
# mismatches move on to the next candidate.
|
|
170
|
+
def self._parse_with_candidates(block, purpose, &translator)
|
|
171
|
+
file, line = block.source_location
|
|
172
|
+
raise "Cannot parse block without a file source" unless file
|
|
173
|
+
|
|
174
|
+
cache = cache_for(purpose)
|
|
175
|
+
candidates = candidate_blocks(file, line)
|
|
176
|
+
raise "No parseable #{purpose} block found at #{file}:#{line}" if candidates.empty?
|
|
177
|
+
|
|
178
|
+
cursor_key = [file, line, purpose, :cursor]
|
|
179
|
+
cursor = cache[cursor_key] || 0
|
|
180
|
+
|
|
181
|
+
result = nil
|
|
182
|
+
result_index = nil
|
|
183
|
+
candidates.size.times do |step|
|
|
184
|
+
idx = (cursor + step) % candidates.size
|
|
185
|
+
cache_key = [file, line, purpose, idx]
|
|
186
|
+
if cache.key?(cache_key)
|
|
187
|
+
result = cache[cache_key]
|
|
188
|
+
result_index = idx
|
|
189
|
+
break
|
|
190
|
+
end
|
|
191
|
+
begin
|
|
192
|
+
result = translator.call(candidates[idx])
|
|
193
|
+
result_index = idx
|
|
194
|
+
cache[cache_key] = result
|
|
195
|
+
break
|
|
196
|
+
rescue BlockMismatch
|
|
197
|
+
next
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
raise "No parseable #{purpose} block found at #{file}:#{line}" if result.nil?
|
|
202
|
+
cache[cursor_key] = (result_index + 1) % candidates.size
|
|
203
|
+
result
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def self.unwrap_statements(block_node)
|
|
207
|
+
body = block_node.body
|
|
208
|
+
body = body.body if body.is_a?(Prism::ParenthesesNode)
|
|
209
|
+
# body is now StatementsNode
|
|
210
|
+
body.body.reject { |stmt| stmt.is_a?(Prism::ProgramNode) }
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# ----- Where translation -----
|
|
214
|
+
def self.translate_where(node, schema, scope = nil)
|
|
215
|
+
hook = WhereOperators.call(node, schema)
|
|
216
|
+
return hook if hook
|
|
217
|
+
case node
|
|
218
|
+
when Prism::BlockNode
|
|
219
|
+
translate_where(node.body, schema, scope)
|
|
220
|
+
when Prism::StatementsNode
|
|
221
|
+
# one expression per block. silently taking the first would drop
|
|
222
|
+
# conditions without a trace — say so instead. ArgumentError, not
|
|
223
|
+
# BlockMismatch: this is bad content in the right block, and the
|
|
224
|
+
# candidate fallback must not swallow it.
|
|
225
|
+
if node.body.size != 1
|
|
226
|
+
raise ArgumentError, "where blocks hold one expression, got #{node.body.size} statements"
|
|
227
|
+
end
|
|
228
|
+
translate_where(node.body.first, schema, scope)
|
|
229
|
+
when Prism::ParenthesesNode
|
|
230
|
+
translate_where(node.body, schema, scope)
|
|
231
|
+
when Prism::CallNode
|
|
232
|
+
# `.in()` takes any number of args (including zero), so catch it
|
|
233
|
+
# before the binary-op path that assumes exactly one.
|
|
234
|
+
if node.name == :in && node.receiver
|
|
235
|
+
lhs = translate_where(node.receiver, schema, scope)
|
|
236
|
+
vals = node.arguments ? node.arguments.arguments.map { |a| translate_where(a, schema, scope) } : []
|
|
237
|
+
return AST::In.new(lhs, vals)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
if node.name == :proc && node.block
|
|
241
|
+
translate_where(node.block, schema, scope)
|
|
242
|
+
elsif node.name == :between? && node.receiver
|
|
243
|
+
# column.between?(low, high)
|
|
244
|
+
col = translate_where(node.receiver, schema, scope)
|
|
245
|
+
args = node.arguments.arguments
|
|
246
|
+
raise BlockMismatch, "between? requires exactly 2 arguments" unless args.size == 2
|
|
247
|
+
low = translate_where(args[0], schema, scope)
|
|
248
|
+
high = translate_where(args[1], schema, scope)
|
|
249
|
+
AST::Between.new(col, low, high)
|
|
250
|
+
elsif node.receiver.nil? && node.arguments.nil?
|
|
251
|
+
validate_column!(node.name, schema)
|
|
252
|
+
AST::Column.new(node.name)
|
|
253
|
+
elsif node.receiver.nil? && node.arguments
|
|
254
|
+
# bare function call: `count(id)`, `sum(age)`, etc.
|
|
255
|
+
# useful for HAVING clauses.
|
|
256
|
+
args = node.arguments.arguments.map { |a| translate_where(a, schema, scope) }
|
|
257
|
+
AST::Function.new(node.name, args)
|
|
258
|
+
elsif node.name == :! && node.receiver && node.arguments.nil?
|
|
259
|
+
# `!(cond)` and `not cond` are the same Prism shape.
|
|
260
|
+
AST::Not.new(translate_where(node.receiver, schema, scope))
|
|
261
|
+
elsif node.receiver && node.arguments.nil?
|
|
262
|
+
# standalone qualified ref: `tags.tag` (as a between?/in
|
|
263
|
+
# receiver, say). anything else here is still a mismatch.
|
|
264
|
+
qualified = try_qualified(node.receiver, node.name, schema, scope)
|
|
265
|
+
return qualified if qualified
|
|
266
|
+
join_first_hint(node.receiver, node.name, schema, scope)
|
|
267
|
+
raise BlockMismatch, "Unsupported call: #{node.inspect}"
|
|
268
|
+
elsif node.receiver && node.arguments
|
|
269
|
+
|
|
270
|
+
left = translate_receiver(node, schema, scope)
|
|
271
|
+
right_arg = node.arguments.arguments.first
|
|
272
|
+
|
|
273
|
+
if node.name == :== && right_arg.is_a?(Prism::ArrayNode)
|
|
274
|
+
vals = right_arg.elements.map { |e| translate_where(e, schema, scope) }
|
|
275
|
+
return AST::In.new(left, vals)
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
if node.name == :"!=" && right_arg.is_a?(Prism::ArrayNode)
|
|
279
|
+
vals = right_arg.elements.map { |e| translate_where(e, schema, scope) }
|
|
280
|
+
return AST::NotIn.new(left, vals)
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
right = translate_where(right_arg, schema, scope)
|
|
284
|
+
|
|
285
|
+
# handle nil comparisons specially: == nil -> IS NULL, != nil -> IS NOT NULL
|
|
286
|
+
if right.is_a?(AST::Literal) && right.value.nil?
|
|
287
|
+
return case node.name
|
|
288
|
+
when :== then AST::IsNull.new(left)
|
|
289
|
+
when :"!=" then AST::IsNotNull.new(left)
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
case node.name
|
|
294
|
+
when :> then AST::GreaterThan.new(left, right)
|
|
295
|
+
when :< then AST::LessThan.new(left, right)
|
|
296
|
+
when :>= then AST::GreaterEqual.new(left, right)
|
|
297
|
+
when :<= then AST::LessEqual.new(left, right)
|
|
298
|
+
when :== then AST::Equality.new(left, right)
|
|
299
|
+
when :"!=" then AST::NotEqual.new(left, right)
|
|
300
|
+
when :&, :"&&" then AST::And.new(left, right)
|
|
301
|
+
when :|, :"||" then AST::Or.new(left, right)
|
|
302
|
+
else
|
|
303
|
+
raise BlockMismatch, "Unsupported operator: #{node.name}"
|
|
304
|
+
end
|
|
305
|
+
else
|
|
306
|
+
raise BlockMismatch, "Unsupported call: #{node.inspect}"
|
|
307
|
+
end
|
|
308
|
+
when Prism::AndNode
|
|
309
|
+
AST::And.new(translate_where(node.left, schema, scope), translate_where(node.right, schema, scope))
|
|
310
|
+
when Prism::OrNode
|
|
311
|
+
AST::Or.new(translate_where(node.left, schema, scope), translate_where(node.right, schema, scope))
|
|
312
|
+
when Prism::IntegerNode
|
|
313
|
+
AST::Literal.new(node.value)
|
|
314
|
+
when Prism::FloatNode
|
|
315
|
+
AST::Literal.new(node.value)
|
|
316
|
+
when Prism::StringNode
|
|
317
|
+
AST::Literal.new(node.unescaped)
|
|
318
|
+
when Prism::NilNode
|
|
319
|
+
AST::Literal.new(nil)
|
|
320
|
+
when Prism::TrueNode
|
|
321
|
+
AST::Literal.new(true)
|
|
322
|
+
when Prism::FalseNode
|
|
323
|
+
AST::Literal.new(false)
|
|
324
|
+
when Prism::SymbolNode
|
|
325
|
+
# symbols bind as strings: `kind == :text` means `kind = 'text'`.
|
|
326
|
+
AST::Literal.new(node.value.to_s)
|
|
327
|
+
else
|
|
328
|
+
raise BlockMismatch, "Unsupported Prism AST Node: #{node.class}"
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
# left side of a binary op. usually a plain column; `tags.tag` (a
|
|
333
|
+
# bareword call on a bareword table in scope) resolves qualified.
|
|
334
|
+
def self.translate_receiver(node, schema, scope)
|
|
335
|
+
recv = node.receiver
|
|
336
|
+
qualified = try_qualified(recv, node.name, schema, scope)
|
|
337
|
+
return qualified if qualified
|
|
338
|
+
join_first_hint(recv, node.name, schema, scope)
|
|
339
|
+
translate_where(recv, schema, scope)
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
# `recv` must be a bareword (`tags`), `col` the method on it (`tag`).
|
|
343
|
+
# plain base columns win ties so old queries keep working. returns nil
|
|
344
|
+
# when this isn't a qualified ref at all (caller falls through).
|
|
345
|
+
def self.try_qualified(recv, col, schema, scope)
|
|
346
|
+
return nil unless recv.is_a?(Prism::CallNode) && recv.receiver.nil? && recv.arguments.nil?
|
|
347
|
+
return nil if schema[:columns].include?(recv.name)
|
|
348
|
+
return nil unless scope && scope.key?(recv.name)
|
|
349
|
+
|
|
350
|
+
validate_column!(col, scope[recv.name])
|
|
351
|
+
AST::Column.new(col, table: recv.name)
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
# same shape, but the table isn't joined (yet). happens when the where
|
|
355
|
+
# runs before the join in the chain — tell them the order matters
|
|
356
|
+
# instead of a confusing column error. nil when not applicable.
|
|
357
|
+
def self.join_first_hint(recv, col, schema, scope)
|
|
358
|
+
return nil unless recv.is_a?(Prism::CallNode) && recv.receiver.nil? && recv.arguments.nil?
|
|
359
|
+
return nil if schema[:columns].include?(recv.name)
|
|
360
|
+
return nil if scope && scope.key?(recv.name)
|
|
361
|
+
return nil unless Diamond.engine.schema_cache.key?(recv.name)
|
|
362
|
+
|
|
363
|
+
raise ArgumentError,
|
|
364
|
+
"filtering on '#{recv.name}.#{col}' needs `.join(:#{recv.name})` first " \
|
|
365
|
+
"(joins must come before the where that filters on them)"
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
# ----- Derive translation -----
|
|
369
|
+
def self.translate_derive(node, schema)
|
|
370
|
+
hook = DeriveOperators.call(node, schema)
|
|
371
|
+
return hook if hook
|
|
372
|
+
case node
|
|
373
|
+
when Prism::CallNode
|
|
374
|
+
positional, kwargs = split_args(node)
|
|
375
|
+
|
|
376
|
+
if node.receiver.nil? && positional.empty? && kwargs.empty?
|
|
377
|
+
validate_column!(node.name, schema)
|
|
378
|
+
return AST::Column.new(node.name)
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
if node.receiver.nil? && !positional.empty? && kwargs.empty?
|
|
382
|
+
args = positional.map { |a| translate_derive(a, schema) }
|
|
383
|
+
return AST::Function.new(node.name, args)
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
if node.name == :over && node.receiver
|
|
387
|
+
inner = node.receiver
|
|
388
|
+
unless inner.is_a?(Prism::CallNode) && inner.receiver.nil? && inner.arguments.nil?
|
|
389
|
+
raise BlockMismatch, "Window function receiver must be a bareword call: #{inner.inspect}"
|
|
390
|
+
end
|
|
391
|
+
unless WINDOW_FUNCS.include?(inner.name)
|
|
392
|
+
raise BlockMismatch, "Not a window function: #{inner.name}"
|
|
393
|
+
end
|
|
394
|
+
# kwargs already parsed above - don't walk the args twice.
|
|
395
|
+
partition_by = Array(kwargs[:partition_by]).map(&:to_sym)
|
|
396
|
+
order_by = Array(kwargs[:order]).map(&:to_sym)
|
|
397
|
+
return AST::WindowFunction.new(inner.name, [], partition_by: partition_by, order_by: order_by)
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
raise BlockMismatch, "Unsupported derive call: #{node.inspect}"
|
|
401
|
+
when Prism::SymbolNode
|
|
402
|
+
validate_column!(node.value, schema)
|
|
403
|
+
AST::Column.new(node.value)
|
|
404
|
+
when Prism::IntegerNode
|
|
405
|
+
AST::Literal.new(node.value)
|
|
406
|
+
when Prism::FloatNode
|
|
407
|
+
AST::Literal.new(node.value)
|
|
408
|
+
when Prism::StringNode
|
|
409
|
+
AST::Literal.new(node.unescaped)
|
|
410
|
+
when Prism::NilNode
|
|
411
|
+
AST::Literal.new(nil)
|
|
412
|
+
when Prism::TrueNode
|
|
413
|
+
AST::Literal.new(true)
|
|
414
|
+
when Prism::FalseNode
|
|
415
|
+
AST::Literal.new(false)
|
|
416
|
+
else
|
|
417
|
+
raise BlockMismatch, "Unsupported derive AST node: #{node.class}"
|
|
418
|
+
end
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
# ----- DDL translation -----
|
|
422
|
+
def self.translate_ddl_stmt(node)
|
|
423
|
+
raise BlockMismatch, "DDL statement must be a method call, got #{node.class}" unless node.is_a?(Prism::CallNode)
|
|
424
|
+
raise BlockMismatch, "Unknown DDL method: #{node.name}" unless DDL_METHODS.include?(node.name)
|
|
425
|
+
|
|
426
|
+
positional, kwargs = split_args(node)
|
|
427
|
+
|
|
428
|
+
case node.name
|
|
429
|
+
when :attribute
|
|
430
|
+
raise "attribute requires name and type" if positional.size < 2
|
|
431
|
+
name = symbol_value(positional[0])
|
|
432
|
+
type = translate_type(positional[1])
|
|
433
|
+
AST::ColumnDefinition.new(name, type, kwargs)
|
|
434
|
+
when :add_column
|
|
435
|
+
raise "add_column requires name and type" if positional.size < 2
|
|
436
|
+
name = symbol_value(positional[0])
|
|
437
|
+
type = translate_type(positional[1])
|
|
438
|
+
AST::ColumnDefinition.new(name, type, kwargs)
|
|
439
|
+
when :primary_key
|
|
440
|
+
raise "primary_key requires a name argument" if positional.empty?
|
|
441
|
+
name = symbol_value(positional[0])
|
|
442
|
+
AST::ColumnDefinition.new(name, Integer, primary_key: true, nullable: false)
|
|
443
|
+
when :foreign_key
|
|
444
|
+
raise "foreign_key requires local column and ref table" if positional.size < 2
|
|
445
|
+
local = symbol_value(positional[0])
|
|
446
|
+
ref_table = symbol_value(positional[1])
|
|
447
|
+
ref_col = positional[2] ? symbol_value(positional[2]) : :id
|
|
448
|
+
|
|
449
|
+
on_delete = kwargs[:on_delete]
|
|
450
|
+
on_update = kwargs[:on_update]
|
|
451
|
+
if on_delete
|
|
452
|
+
on_delete = on_delete.to_sym
|
|
453
|
+
unless DDL_FK_ACTIONS.include?(on_delete)
|
|
454
|
+
raise ArgumentError, "unknown on_delete action: #{on_delete.inspect}; must be one of #{DDL_FK_ACTIONS.inspect}"
|
|
455
|
+
end
|
|
456
|
+
end
|
|
457
|
+
if on_update
|
|
458
|
+
on_update = on_update.to_sym
|
|
459
|
+
unless DDL_FK_ACTIONS.include?(on_update)
|
|
460
|
+
raise ArgumentError, "unknown on_update action: #{on_update.inspect}; must be one of #{DDL_FK_ACTIONS.inspect}"
|
|
461
|
+
end
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
AST::ForeignKey.new(local, ref_table, ref_col,
|
|
465
|
+
on_delete: on_delete, on_update: on_update)
|
|
466
|
+
when :index
|
|
467
|
+
raise "index requires at least one column" if positional.empty?
|
|
468
|
+
cols = positional.map { |a| symbol_value(a) }
|
|
469
|
+
idx_name = kwargs[:name] || raise(ArgumentError, "index requires `name:` kwarg")
|
|
470
|
+
unique = !!kwargs[:unique]
|
|
471
|
+
AST::IndexDefinition.new(idx_name, cols, unique: unique)
|
|
472
|
+
end
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
# ----- Shared helpers -----
|
|
476
|
+
def self.split_args(call_node)
|
|
477
|
+
return [[], {}] unless call_node.arguments
|
|
478
|
+
|
|
479
|
+
positional = []
|
|
480
|
+
kwargs = {}
|
|
481
|
+
call_node.arguments.arguments.each do |arg|
|
|
482
|
+
if arg.is_a?(Prism::KeywordHashNode)
|
|
483
|
+
arg.elements.each do |assoc|
|
|
484
|
+
key = kwarg_key(assoc.key)
|
|
485
|
+
kwargs[key] = literal_value_or_array(assoc.value)
|
|
486
|
+
end
|
|
487
|
+
else
|
|
488
|
+
positional << arg
|
|
489
|
+
end
|
|
490
|
+
end
|
|
491
|
+
[positional, kwargs]
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
def self.kwarg_key(node)
|
|
495
|
+
case node
|
|
496
|
+
when Prism::SymbolNode then node.value.to_sym
|
|
497
|
+
when Prism::StringNode then node.unescaped.to_sym
|
|
498
|
+
else node.name.to_sym
|
|
499
|
+
end
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
def self.symbol_value(node)
|
|
503
|
+
raise "Expected SymbolNode, got #{node.class}" unless node.is_a?(Prism::SymbolNode)
|
|
504
|
+
node.value
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
def self.translate_type(node)
|
|
508
|
+
unless node.is_a?(Prism::ConstantReadNode)
|
|
509
|
+
raise ArgumentError, "Type must be a constant (e.g., Integer, String)"
|
|
510
|
+
end
|
|
511
|
+
begin
|
|
512
|
+
Object.const_get(node.name)
|
|
513
|
+
rescue NameError
|
|
514
|
+
type_spellcheck(node.name)
|
|
515
|
+
end
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
def self.type_spellcheck(name)
|
|
519
|
+
dictionary = Object.constants.map(&:to_s)
|
|
520
|
+
suggestions = DidYouMean::SpellChecker.new(dictionary: dictionary).correct(name.to_s)
|
|
521
|
+
msg = "Unknown type '#{name}'."
|
|
522
|
+
msg += " Did you mean '#{suggestions.first}'?" unless suggestions.empty?
|
|
523
|
+
raise ArgumentError, msg
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
def self.extract_keyword_hash(call_node)
|
|
527
|
+
_, kwargs = split_args(call_node)
|
|
528
|
+
kwargs
|
|
529
|
+
end
|
|
530
|
+
|
|
531
|
+
def self.literal_value_or_array(node)
|
|
532
|
+
if node.is_a?(Prism::ArrayNode)
|
|
533
|
+
node.elements.map { |e| literal_value(e) }
|
|
534
|
+
else
|
|
535
|
+
literal_value(node)
|
|
536
|
+
end
|
|
537
|
+
end
|
|
538
|
+
|
|
539
|
+
def self.literal_value(node)
|
|
540
|
+
case node
|
|
541
|
+
when Prism::IntegerNode then node.value
|
|
542
|
+
when Prism::FloatNode then node.value
|
|
543
|
+
when Prism::StringNode then node.unescaped
|
|
544
|
+
when Prism::SymbolNode then node.value
|
|
545
|
+
when Prism::TrueNode then true
|
|
546
|
+
when Prism::FalseNode then false
|
|
547
|
+
when Prism::NilNode then nil
|
|
548
|
+
when Prism::ConstantReadNode
|
|
549
|
+
begin
|
|
550
|
+
Object.const_get(node.name)
|
|
551
|
+
rescue NameError
|
|
552
|
+
node.name
|
|
553
|
+
end
|
|
554
|
+
else
|
|
555
|
+
raise "Not a literal value: #{node.class}"
|
|
556
|
+
end
|
|
557
|
+
end
|
|
558
|
+
|
|
559
|
+
def self.validate_column!(name, schema)
|
|
560
|
+
Diamond.validate_ident!(name, "column name")
|
|
561
|
+
return if schema[:columns].empty?
|
|
562
|
+
return if schema[:columns].include?(name)
|
|
563
|
+
|
|
564
|
+
dictionary = schema[:columns].map(&:to_s)
|
|
565
|
+
suggestions = DidYouMean::SpellChecker.new(dictionary: dictionary).correct(name.to_s)
|
|
566
|
+
msg = "Table has no column '#{name}'."
|
|
567
|
+
msg += " Did you mean '#{suggestions.first}'?" unless suggestions.empty?
|
|
568
|
+
raise Diamond::UnknownColumnError, msg
|
|
569
|
+
end
|
|
570
|
+
end
|
|
571
|
+
end
|