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,142 @@
1
+ module Diamond
2
+ module Operators
3
+ # LIKE operator — `where { name =~ pattern }` where pattern is a String
4
+ # or a Regexp.
5
+ #
6
+ # String RHS is passed through as the LIKE pattern verbatim. `%` matches
7
+ # any sequence, `_` matches any single char. Backslash escapes literal
8
+ # `%`, `_`, and `\` themselves.
9
+ #
10
+ # Regexp RHS is translated to a LIKE pattern with documented rules:
11
+ #
12
+ # literal chars -> literal (% and _ get backslash-escaped)
13
+ # \X -> X (with %, _, \ escaped for LIKE)
14
+ # . (unescaped) -> _ any single char
15
+ # .* -> % any sequence
16
+ # ^ at start -> dropped (LIKE doesn't anchor)
17
+ # $ at end -> dropped (LIKE doesn't anchor)
18
+ #
19
+ # Anything else (character classes [], alternation |, groups (), quantifiers
20
+ # + ? {n,m}, lookahead/lookbehind) raises ArgumentError. Honest, not magical.
21
+ module Like
22
+ PRIORITY = 60
23
+
24
+ def self.priority
25
+ PRIORITY
26
+ end
27
+
28
+ def self.parse_where(node, schema)
29
+ return nil unless node.is_a?(Prism::CallNode)
30
+ return nil unless node.name == :=~
31
+ return nil unless node.receiver
32
+ return nil unless node.arguments
33
+ return nil unless node.arguments.arguments.size == 1
34
+
35
+ rhs_node = node.arguments.arguments.first
36
+ pattern =
37
+ case rhs_node
38
+ when Prism::StringNode
39
+ rhs_node.unescaped
40
+ when Prism::RegularExpressionNode
41
+ regexp_to_like(rhs_node)
42
+ else
43
+ return nil
44
+ end
45
+
46
+ lhs = Parser.translate_where(node.receiver, schema)
47
+ AST::Like.new(lhs, AST::Literal.new(pattern))
48
+ end
49
+
50
+ def self.handles?(node)
51
+ node.is_a?(AST::Like)
52
+ end
53
+
54
+ def self.render(node, params)
55
+ params << node.right.value
56
+ "#{node.left.name} LIKE ?"
57
+ end
58
+
59
+ # ----- Regexp -> LIKE translation -----
60
+
61
+ UNSUPPORTED = {
62
+ '(' => 'groups'.freeze,
63
+ ')' => 'groups'.freeze,
64
+ '|' => 'alternation'.freeze,
65
+ '+' => '+ quantifier'.freeze,
66
+ '?' => '? quantifier'.freeze,
67
+ '{' => '{n,m} quantifier'.freeze,
68
+ '[' => 'character class'.freeze
69
+ }.freeze
70
+
71
+ def self.regexp_to_like(regexp_node)
72
+ # `.content` is the raw source between the slashes; `.unescaped`
73
+ # would already process regex-level escapes (\n -> newline), which
74
+ # we don't want — we're translating the source, not matching it.
75
+ source = regexp_node.content
76
+ out = String.new
77
+ i = 0
78
+ len = source.length
79
+
80
+ leading_anchor = false
81
+ trailing_anchor = false
82
+
83
+ if len > 0 && source[0] == '^'
84
+ leading_anchor = true
85
+ i += 1
86
+ end
87
+ if len - i > 0 && source[len - 1] == '$'
88
+ trailing_anchor = true
89
+ len -= 1
90
+ end
91
+
92
+ while i < len
93
+ c = source[i]
94
+ if c == '\\' && i + 1 < len
95
+ nxt = source[i + 1]
96
+ out << escape_like(nxt)
97
+ i += 2
98
+ elsif c == '.'
99
+ # peek for .*
100
+ if i + 1 < len && source[i + 1] == '*'
101
+ out << '%'
102
+ i += 2
103
+ else
104
+ out << '_'
105
+ i += 1
106
+ end
107
+ elsif UNSUPPORTED.key?(c)
108
+ raise ArgumentError,
109
+ "Regexp feature #{UNSUPPORTED[c].inspect} (#{c.inspect}) " \
110
+ "is not supported by LIKE; rewrite without it"
111
+ else
112
+ out << escape_like(c)
113
+ i += 1
114
+ end
115
+ end
116
+
117
+ # ^X -> LIKE 'X%' (starts with)
118
+ # X$ -> LIKE '%X' (ends with)
119
+ # ^X$ -> LIKE 'X' (exact)
120
+ # X -> LIKE '%X%' (anywhere)
121
+ if leading_anchor && trailing_anchor
122
+ out
123
+ elsif leading_anchor
124
+ "#{out}%"
125
+ elsif trailing_anchor
126
+ "%#{out}"
127
+ else
128
+ "%#{out}%"
129
+ end
130
+ end
131
+
132
+ LIKE_WILDCARDS = { '%' => '\%'.freeze, '_' => '\_'.freeze, '\\' => '\\\\'.freeze }.freeze
133
+
134
+ def self.escape_like(ch)
135
+ LIKE_WILDCARDS.fetch(ch, ch)
136
+ end
137
+ end
138
+ end
139
+ end
140
+
141
+ Diamond::Parser::WhereOperators.register(Diamond::Operators::Like)
142
+ Diamond::Compiler::Operators.register(Diamond::Operators::Like)
@@ -0,0 +1,469 @@
1
+ module Diamond
2
+ module Parser
3
+ # Runtime fallback for blocks with no source file on disk: (irb),
4
+ # (eval), `ruby -e`, pry. The Prism path re-parses the block's
5
+ # file, which doesn't exist here — so instead we execute the block
6
+ # against small recorders that build the same AST shapes the
7
+ # translators produce.
8
+ #
9
+ # THE REPL RULE: `&&` and `||` short-circuit inside Ruby itself and
10
+ # can never be intercepted, so console blocks must use `&` / `|`
11
+ # (or the hash/array where forms, which need no parsing at all).
12
+ # File-backed blocks keep full `&&` / `||` support via Prism.
13
+ #
14
+ # Deliberate divergences from the Prism path (console-only):
15
+ # - multi-statement where/derive/update blocks: only the last value
16
+ # is visible (Prism validates statement counts);
17
+ # - custom WhereOperators/DeriveOperators hooks don't fire (they
18
+ # match on Prism nodes);
19
+ # - reversed comparisons (`5 > age`) raise ArgumentError from Ruby
20
+ # (placeholders define no coercion protocol);
21
+ # - string-method calls (`"x".upcase`) execute instead of being
22
+ # silently dropped to the literal.
23
+ module Proxy
24
+ module_function
25
+
26
+ # File truthiness is not enough: (irb)/(eval)/-e are truthy
27
+ # pseudo-paths with no file behind them.
28
+ def file_backed?(block)
29
+ file, _line = block.source_location
30
+ !file.nil? && File.exist?(file)
31
+ end
32
+
33
+ # instance_exec passes the recorder as the block arg when it
34
+ # takes one (`|t|`), and nothing when it takes none — except
35
+ # strict lambdas, which check arity either way.
36
+ def exec_block(target, block)
37
+ if block.lambda? && block.arity == 0
38
+ target.instance_exec(&block)
39
+ else
40
+ target.instance_exec(target, &block)
41
+ end
42
+ end
43
+
44
+ def parse_where(block, schema, scope = nil)
45
+ proxy = SchemaProxy.new(schema, scope, allow_tables: true)
46
+ unwrap_where(exec_block(proxy, block))
47
+ end
48
+
49
+ def parse_derive(block, schema)
50
+ proxy = SchemaProxy.new(schema, nil, allow_tables: false)
51
+ [unwrap_derive(exec_block(proxy, block), schema)]
52
+ end
53
+
54
+ def parse_ddl(block)
55
+ rec = DdlRecorder.new
56
+ exec_block(rec, block)
57
+ rec.statements
58
+ end
59
+
60
+ def parse_update(block, schema)
61
+ rec = UpdateRecorder.new(schema)
62
+ result = exec_block(rec, block)
63
+ return rec.pairs unless rec.pairs.empty?
64
+ if result.is_a?(::Hash)
65
+ result.each_key do |key|
66
+ unless key.is_a?(::Symbol)
67
+ raise BlockMismatch, "Invalid update statement: keys must be Symbols, got #{key.inspect}"
68
+ end
69
+ Parser.validate_column!(key, schema)
70
+ end
71
+ return result
72
+ end
73
+ raise BlockMismatch, "update blocks use the smalltalk form (`age 17`)"
74
+ end
75
+
76
+ # Single coercion point for where positions: placeholders become
77
+ # their AST, Ruby values become literals. Symbols bind as strings
78
+ # (`kind == :text`), mirroring translate_where. Arrays and
79
+ # anything else are BlockMismatch, mirroring the Prism path's
80
+ # unsupported-node errors.
81
+ def unwrap_where(value)
82
+ case value
83
+ when Lazy then value.materialize
84
+ when AST::Node then value
85
+ when ::Symbol then AST::Literal.new(value.to_s)
86
+ when ::Integer, ::Float, ::String, ::TrueClass, ::FalseClass, ::NilClass
87
+ AST::Literal.new(value)
88
+ else
89
+ raise BlockMismatch, "Unsupported value in where block: #{value.inspect}"
90
+ end
91
+ end
92
+
93
+ # Derive binds symbols as COLUMNS (translate_derive validates
94
+ # SymbolNodes against the schema) — unlike where, which binds
95
+ # them as string literals.
96
+ def unwrap_derive(value, schema)
97
+ case value
98
+ when Lazy then value.materialize
99
+ when AST::Node then value
100
+ when ::Symbol
101
+ Parser.validate_column!(value, schema)
102
+ AST::Column.new(value)
103
+ when ::Integer, ::Float, ::String, ::TrueClass, ::FalseClass, ::NilClass
104
+ AST::Literal.new(value)
105
+ else
106
+ raise BlockMismatch, "Unsupported value in derive block: #{value.inspect}"
107
+ end
108
+ end
109
+
110
+ # Catches barewords (`age`), function calls (`count(id)`), and
111
+ # table refs (`tags` in scope). BasicObject so column names never
112
+ # collide with Kernel/Object methods — which also means every
113
+ # host call needs an explicit receiver (::Kernel.raise, Parser.*,
114
+ # ::Diamond.*).
115
+ class SchemaProxy < ::BasicObject
116
+ # allow_tables is false for derive: the file-backed derive
117
+ # translator has no scope concept and rejects qualified refs,
118
+ # so the console must too — otherwise code proven in a console
119
+ # would break the moment it moves into a file.
120
+ def initialize(schema, scope, allow_tables: true)
121
+ @schema = schema
122
+ @scope = scope
123
+ @allow_tables = allow_tables
124
+ end
125
+
126
+ def method_missing(name, *args, **kwargs, &blk)
127
+ unless args.empty? && kwargs.empty? && blk.nil?
128
+ return function_call(name, args)
129
+ end
130
+ if @allow_tables && table_ref?(name)
131
+ in_scope = @scope && @scope.key?(name) ? true : false
132
+ return TableRef.new(name, @schema, in_scope ? @scope[name] : nil, in_scope)
133
+ end
134
+ ::Diamond::Parser::Proxy::ColumnRef.new(name, @schema)
135
+ end
136
+
137
+ private
138
+
139
+ # A bareword is a table ref when it isn't a base column and is
140
+ # either joined (scope) or known to the engine (join hint).
141
+ # Mirrors try_qualified + join_first_hint gating.
142
+ def table_ref?(name)
143
+ return false if @schema[:columns].include?(name)
144
+ return true if @scope && @scope.key?(name)
145
+ engine_knows?(name)
146
+ end
147
+
148
+ # The engine probe needs a woken engine. Without one (unit
149
+ # tests, pre-wake consoles) treat the name as unknown: its
150
+ # later use raises UnknownColumnError via to_ast, which is
151
+ # more actionable than a boot error. With a woken engine this
152
+ # matches the Prism path exactly.
153
+ def engine_knows?(name)
154
+ ::Diamond.engine.schema_cache.key?(name)
155
+ rescue ::StandardError
156
+ false
157
+ end
158
+
159
+ # Bare function call: `count(id)`, `sum(age)`. Stays lazy so
160
+ # prefix operators (`!count(id)`) nest instead of collapsing;
161
+ # args validate as columns at materialize time.
162
+ def function_call(name, args)
163
+ ::Diamond::Parser::Proxy::Op.new(:fn, [name, args])
164
+ end
165
+ end
166
+
167
+ # Lazy condition tree. Operators return Lazy nodes instead of
168
+ # real AST so that prefix operators keep working: Ruby evaluates
169
+ # inside-out, so by the time `!` runs in `!(age > 10)` its
170
+ # operand is already built — if `>` returned a real AST node,
171
+ # `!` would hit BasicObject#! and collapse to `false`. With
172
+ # everything lazy, `!` nests properly and a single materialize
173
+ # pass at unwrap time builds the final tree. (This is also what
174
+ # makes chained comparisons work.)
175
+ #
176
+ # Standalone placeholders, deliberately NOT AST nodes, so the
177
+ # AST layer and its existing callers are untouched.
178
+ class Lazy
179
+ def >(other)
180
+ Op.new(:>, [self, other])
181
+ end
182
+
183
+ def <(other)
184
+ Op.new(:<, [self, other])
185
+ end
186
+
187
+ def >=(other)
188
+ Op.new(:>=, [self, other])
189
+ end
190
+
191
+ def <=(other)
192
+ Op.new(:<=, [self, other])
193
+ end
194
+
195
+ def ==(other)
196
+ Op.new(:==, [self, other])
197
+ end
198
+
199
+ # Ruby defines != as !(==); with our truthy == results that
200
+ # would nest Not(Equality) instead of NotEqual — so != stays
201
+ # explicit.
202
+ def !=(other)
203
+ Op.new(:!=, [self, other])
204
+ end
205
+
206
+ def !
207
+ Op.new(:not, [self])
208
+ end
209
+
210
+ def &(other)
211
+ Op.new(:and, [self, other])
212
+ end
213
+
214
+ def |(other)
215
+ Op.new(:or, [self, other])
216
+ end
217
+
218
+ # `in` via define_method: `def in` risks the pattern-matching
219
+ # keyword, while an explicit-receiver call (`x.in(1, 2)`) and a
220
+ # symbol-defined method are both unambiguous.
221
+ define_method(:in) do |*vals|
222
+ Op.new(:in, [self, vals])
223
+ end
224
+
225
+ def between?(low, high)
226
+ Op.new(:between, [self, low, high])
227
+ end
228
+
229
+ # Derive-only (`rank.over(partition_by: ...)`).
230
+ def over(partition_by: [], order: [])
231
+ Op.new(:over, [self, partition_by, order])
232
+ end
233
+
234
+ def materialize
235
+ raise NotImplementedError, "#{self.class} must implement materialize"
236
+ end
237
+ end
238
+
239
+ # A bareword (or qualified `table.col`). Validation is lazy in
240
+ # materialize: `rank.over(...)` must not validate `rank` as a
241
+ # column, mirroring translate_derive which never visits the
242
+ # over-receiver.
243
+ class ColumnRef < Lazy
244
+ attr_reader :name, :table
245
+
246
+ def initialize(name, schema, table: nil)
247
+ @name = name
248
+ @schema = schema
249
+ @table = table
250
+ end
251
+
252
+ def materialize
253
+ Parser.validate_column!(@name, @schema)
254
+ AST::Column.new(@name, table: @table)
255
+ end
256
+
257
+ # Console safety net: typos (`age.grate_than(10)`) raise a DSL
258
+ # error instead of NoMethodError. Safe to add — the file path
259
+ # never instantiates placeholders, and every DSL-called method
260
+ # (operators, in, between?, over) is defined above.
261
+ def method_missing(name, *_args, **_kwargs, &_blk)
262
+ raise BlockMismatch, "Unknown column method: #{@name}.#{name}"
263
+ end
264
+ end
265
+
266
+ # One deferred operation. Materialization mirrors the Prism
267
+ # translators exactly: nil comparisons become IS [NOT] NULL,
268
+ # symbols bind as strings, barewords validate as columns.
269
+ class Op < Lazy
270
+ def initialize(op, children)
271
+ @op = op
272
+ @children = children
273
+ end
274
+
275
+ def materialize
276
+ case @op
277
+ when :>
278
+ AST::GreaterThan.new(mat0, coerce(@children[1]))
279
+ when :<
280
+ AST::LessThan.new(mat0, coerce(@children[1]))
281
+ when :>=
282
+ AST::GreaterEqual.new(mat0, coerce(@children[1]))
283
+ when :<=
284
+ AST::LessEqual.new(mat0, coerce(@children[1]))
285
+ when :==
286
+ return AST::IsNull.new(mat0) if @children[1].nil?
287
+ AST::Equality.new(mat0, coerce(@children[1]))
288
+ when :!=
289
+ return AST::IsNotNull.new(mat0) if @children[1].nil?
290
+ AST::NotEqual.new(mat0, coerce(@children[1]))
291
+ when :not
292
+ AST::Not.new(mat(@children[0]))
293
+ when :and
294
+ AST::And.new(mat(@children[0]), mat(@children[1]))
295
+ when :or
296
+ AST::Or.new(mat(@children[0]), mat(@children[1]))
297
+ when :in
298
+ AST::In.new(mat0, Array(@children[1]).flatten(1).map { |v| coerce(v) })
299
+ when :between
300
+ AST::Between.new(mat0, coerce(@children[1]), coerce(@children[2]))
301
+ when :fn
302
+ AST::Function.new(@children[0], @children[1].map { |a| coerce(a) })
303
+ when :over
304
+ materialize_over
305
+ end
306
+ end
307
+
308
+ private
309
+
310
+ # Operands arrive as raw Ruby values, ColumnRefs, or nested
311
+ # Ops — unwrap_where funnels every shape through materialize.
312
+ def mat(node)
313
+ Proxy.unwrap_where(node)
314
+ end
315
+
316
+ def mat0
317
+ mat(@children[0])
318
+ end
319
+
320
+ alias coerce mat
321
+
322
+ # The over-receiver must be a bareword in WINDOW_FUNCS —
323
+ # mirroring translate_derive, which rejects anything else.
324
+ def materialize_over
325
+ recv = @children[0]
326
+ unless recv.is_a?(ColumnRef)
327
+ raise BlockMismatch, "Window function receiver must be a bareword call: #{recv.inspect}"
328
+ end
329
+ unless Parser::WINDOW_FUNCS.include?(recv.name)
330
+ raise BlockMismatch, "Not a window function: #{recv.name.inspect}"
331
+ end
332
+ AST::WindowFunction.new(
333
+ recv.name, [],
334
+ partition_by: Array(@children[1]).map(&:to_sym),
335
+ order_by: Array(@children[2]).map(&:to_sym),
336
+ )
337
+ end
338
+ end
339
+
340
+ # `tags` in `tags.tag`: validated against the joined table's
341
+ # schema, or raises the join-first hint when the table is known
342
+ # to the engine but not joined. Mirrors try_qualified.
343
+ #
344
+ # Subclasses ColumnRef so a bare table in any other position
345
+ # (comparison, function arg) validates as a bareword against the
346
+ # base schema — exactly what the Prism path does with it.
347
+ class TableRef < ColumnRef
348
+ def initialize(table, base_schema, table_schema, in_scope)
349
+ super(table, base_schema)
350
+ @table_schema = table_schema
351
+ @in_scope = in_scope
352
+ end
353
+
354
+ def method_missing(col, *args, **kwargs, &blk)
355
+ unless args.empty? && kwargs.empty? && blk.nil?
356
+ raise BlockMismatch, "Unsupported call: #{@name}.#{col}"
357
+ end
358
+ unless @in_scope
359
+ ::Kernel.raise(::ArgumentError,
360
+ "filtering on '#{@name}.#{col}' needs `.join(:#{@name})` first " \
361
+ "(joins must come before the where that filters on them)")
362
+ end
363
+ Parser.validate_column!(col, @table_schema)
364
+ Proxy::ColumnRef.new(col, @table_schema, table: @name)
365
+ end
366
+ end
367
+
368
+ # Executes DDL blocks (`do |t| t.attribute ... end`, and the bare
369
+ # `attribute ...` form via instance_exec self) building the same
370
+ # AST nodes translate_ddl_stmt builds.
371
+ class DdlRecorder
372
+ attr_reader :statements
373
+
374
+ def initialize
375
+ @statements = []
376
+ end
377
+
378
+ def attribute(name, type, **opts)
379
+ check_name!(name, "attribute")
380
+ check_type!(type)
381
+ @statements << AST::ColumnDefinition.new(name, type, opts)
382
+ end
383
+
384
+ def add_column(name, type, **opts)
385
+ check_name!(name, "add_column")
386
+ check_type!(type)
387
+ @statements << AST::ColumnDefinition.new(name, type, opts)
388
+ end
389
+
390
+ def primary_key(name)
391
+ check_name!(name, "primary_key")
392
+ @statements << AST::ColumnDefinition.new(name, Integer, primary_key: true, nullable: false)
393
+ end
394
+
395
+ def foreign_key(local, ref_table, ref_col = :id, on_delete: nil, on_update: nil)
396
+ check_name!(local, "foreign_key")
397
+ check_name!(ref_table, "foreign_key")
398
+ check_name!(ref_col, "foreign_key")
399
+ on_delete = check_fk_action!(:on_delete, on_delete)
400
+ on_update = check_fk_action!(:on_update, on_update)
401
+ @statements << AST::ForeignKey.new(local, ref_table, ref_col,
402
+ on_delete: on_delete, on_update: on_update)
403
+ end
404
+
405
+ def index(*cols, name: nil, unique: false)
406
+ raise ArgumentError, "index requires `name:` kwarg" if name.nil?
407
+ raise ArgumentError, "index requires at least one column" if cols.empty?
408
+ cols.each { |c| check_name!(c, "index") }
409
+ @statements << AST::IndexDefinition.new(name, cols, unique: !!unique)
410
+ end
411
+
412
+ # Mirror translate_ddl_stmt's unknown-method rejection (there a
413
+ # BlockMismatch, not NoMethodError).
414
+ def method_missing(name, *_args, **_kwargs, &_blk)
415
+ raise BlockMismatch, "Unknown DDL method: #{name}"
416
+ end
417
+
418
+ def respond_to_missing?(name, include_private = false)
419
+ Parser::DDL_METHODS.include?(name) || super
420
+ end
421
+
422
+ private
423
+
424
+ def check_name!(value, what)
425
+ return if value.is_a?(::Symbol)
426
+ raise ArgumentError, "#{what} name must be a Symbol, got #{value.inspect}"
427
+ end
428
+
429
+ def check_type!(type)
430
+ return if type.is_a?(::Module)
431
+ raise ArgumentError, "Type must be a constant (e.g., Integer, String)"
432
+ end
433
+
434
+ def check_fk_action!(key, value)
435
+ return nil if value.nil?
436
+ sym = value.to_sym
437
+ unless Parser::DDL_FK_ACTIONS.include?(sym)
438
+ raise ArgumentError, "unknown #{key} action: #{value.inspect}; must be one of #{Parser::DDL_FK_ACTIONS.inspect}"
439
+ end
440
+ sym
441
+ end
442
+ end
443
+
444
+ # Executes smalltalk update blocks (`age 17`). One bareword call
445
+ # with one positional arg per statement, mirroring the Prism
446
+ # path's shape check.
447
+ class UpdateRecorder
448
+ attr_reader :pairs
449
+
450
+ def initialize(schema)
451
+ @schema = schema
452
+ @pairs = {}
453
+ end
454
+
455
+ def method_missing(name, *args, **kwargs, &blk)
456
+ unless args.size == 1 && kwargs.empty? && blk.nil?
457
+ raise BlockMismatch, "Invalid update statement: #{name}"
458
+ end
459
+ Parser.validate_column!(name, @schema)
460
+ @pairs[name] = args.first
461
+ end
462
+
463
+ def respond_to_missing?(_name, _include_private = false)
464
+ true
465
+ end
466
+ end
467
+ end
468
+ end
469
+ end
@@ -0,0 +1,74 @@
1
+ module Diamond
2
+ module Parser
3
+ # Registry consulted at the front of translate_where. External operators
4
+ # run first, sorted by descending priority. First non-nil result wins.
5
+ # Built-ins are implicit priority 0 (they fall through).
6
+ #
7
+ # Per-Ractor: each Ractor owns its own handler list, stored on the
8
+ # Ractor's own local storage. The module holds only the frozen list of
9
+ # built-ins (which is shareable).
10
+ module WhereOperators
11
+ STORAGE_KEY = Diamond::RACTOR_KEYS[:where_ops]
12
+
13
+ # method, not a constant: this file loads before the Like operator
14
+ # is defined, so it must resolve lazily. called once per Ractor
15
+ # (handlers memoizes), so the per-call allocation is irrelevant.
16
+ def self.builtins
17
+ [Diamond::Operators::Like].freeze
18
+ end
19
+
20
+ def self.handlers
21
+ Ractor.current[STORAGE_KEY] ||= builtins.dup
22
+ end
23
+
24
+ def self.register(operator)
25
+ list = handlers
26
+ list << operator unless list.include?(operator)
27
+ nil
28
+ end
29
+
30
+ def self.clear!
31
+ Ractor.current[STORAGE_KEY] = builtins.dup
32
+ end
33
+
34
+ def self.call(node, schema)
35
+ handlers.sort_by { |h| -h.priority }.each do |h|
36
+ result = h.parse_where(node, schema)
37
+ return result if result
38
+ end
39
+ nil
40
+ end
41
+ end
42
+
43
+ # Registry consulted at the front of translate_derive.
44
+ module DeriveOperators
45
+ STORAGE_KEY = Diamond::RACTOR_KEYS[:derive_ops]
46
+
47
+ def self.builtins
48
+ [].freeze # no built-in derive operators yet
49
+ end
50
+
51
+ def self.handlers
52
+ Ractor.current[STORAGE_KEY] ||= builtins.dup
53
+ end
54
+
55
+ def self.register(operator)
56
+ list = handlers
57
+ list << operator unless list.include?(operator)
58
+ nil
59
+ end
60
+
61
+ def self.clear!
62
+ Ractor.current[STORAGE_KEY] = builtins.dup
63
+ end
64
+
65
+ def self.call(node, schema)
66
+ handlers.sort_by { |h| -h.priority }.each do |h|
67
+ result = h.parse_derive(node, schema)
68
+ return result if result
69
+ end
70
+ nil
71
+ end
72
+ end
73
+ end
74
+ end