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,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
|