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.
@@ -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