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,390 @@
|
|
|
1
|
+
module Diamond
|
|
2
|
+
module Compiler
|
|
3
|
+
module DQL
|
|
4
|
+
JOIN_TYPE_MAP = {
|
|
5
|
+
inner: 'INNER JOIN',
|
|
6
|
+
left: 'LEFT OUTER JOIN',
|
|
7
|
+
right: 'RIGHT OUTER JOIN',
|
|
8
|
+
full: 'FULL OUTER JOIN'
|
|
9
|
+
}.freeze
|
|
10
|
+
|
|
11
|
+
def self.compile(table, ast, params = [])
|
|
12
|
+
# a top-level union renders on its own path: the two sides
|
|
13
|
+
# compile recursively, params concatenate in order. terminal —
|
|
14
|
+
# any companion clauses mean someone chained after a union.
|
|
15
|
+
if (union_node = ast.find { |n| n.is_a?(AST::Union) })
|
|
16
|
+
if ast.size != 1
|
|
17
|
+
raise ArgumentError, "union() terminates the chain — chaining after a union isn't supported"
|
|
18
|
+
end
|
|
19
|
+
return compile_union(union_node, params)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# one loop over ast instead of ~8 selects. bucket everything, render after.
|
|
23
|
+
with_clauses = []
|
|
24
|
+
projection = nil
|
|
25
|
+
joins = []
|
|
26
|
+
wheres = []
|
|
27
|
+
from_node = nil
|
|
28
|
+
order_specs = []
|
|
29
|
+
limit_node = nil
|
|
30
|
+
offset_node = nil
|
|
31
|
+
group_by_node = nil
|
|
32
|
+
having_node = nil
|
|
33
|
+
distinct = false
|
|
34
|
+
ast.each do |n|
|
|
35
|
+
case n
|
|
36
|
+
when AST::With then with_clauses << n
|
|
37
|
+
when AST::Projection then projection ||= n
|
|
38
|
+
when AST::Join then joins << n
|
|
39
|
+
when AST::Where then wheres << n
|
|
40
|
+
when AST::From then from_node ||= n
|
|
41
|
+
when AST::Order then order_specs.concat(n.specs)
|
|
42
|
+
when AST::Limit then limit_node = n
|
|
43
|
+
when AST::Offset then offset_node = n
|
|
44
|
+
when AST::GroupBy then group_by_node = n
|
|
45
|
+
when AST::Having then having_node = n
|
|
46
|
+
when AST::Distinct then distinct = true
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
from_target = from_node ? from_node.name : table.name
|
|
51
|
+
Diamond.validate_ident!(from_target, "FROM target")
|
|
52
|
+
|
|
53
|
+
# if any join is eager, we need aliased columns + a transform spec
|
|
54
|
+
# so the result rows can be deduplicated and nested.
|
|
55
|
+
has_eager = joins.any?(&:eager)
|
|
56
|
+
if has_eager
|
|
57
|
+
return compile_eager(table, from_target, projection, joins, wheres, order_specs,
|
|
58
|
+
with_clauses, limit_node, offset_node,
|
|
59
|
+
group_by_node, having_node, distinct, params)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
with_sql = render_with(with_clauses, params)
|
|
63
|
+
select_sql = render_projection(projection, params)
|
|
64
|
+
from_sql = "FROM #{from_target}"
|
|
65
|
+
joins_sql = joins.map { |j| render_join(j, from_target) }.join(' ')
|
|
66
|
+
where_sql = wheres.empty? ? '' : ' WHERE ' + wheres.map { |w| translate_node(w.condition, params) }.join(' AND ')
|
|
67
|
+
group_sql = group_by_node ? " GROUP BY #{group_by_node.columns.map { |c| column_sql(c) }.join(', ')}" : ''
|
|
68
|
+
having_sql = having_node ? ' HAVING ' + translate_node(having_node.condition, params) : ''
|
|
69
|
+
|
|
70
|
+
# specs already merged up top. one ORDER BY out.
|
|
71
|
+
order_sql = order_specs.empty? ? '' : ' ORDER BY ' + order_specs.map { |col, dir| "#{column_sql(col)} #{dir.to_s.upcase}" }.join(', ')
|
|
72
|
+
|
|
73
|
+
# last Limit/Offset node wins. values go in raw, not bound -
|
|
74
|
+
# _build_limit/_build_offset already checked Integer >= 0, and
|
|
75
|
+
# old sqlite builds choke on (or misplan) bound LIMIT ?.
|
|
76
|
+
limit_sql = limit_node ? " LIMIT #{limit_node.value}" : ''
|
|
77
|
+
|
|
78
|
+
offset_sql = offset_node ? " OFFSET #{offset_node.value}" : ''
|
|
79
|
+
|
|
80
|
+
distinct_sql = distinct ? 'DISTINCT ' : ''
|
|
81
|
+
sql = "#{with_sql} SELECT #{distinct_sql}#{select_sql} #{from_sql}#{joins_sql.empty? ? '' : ' ' + joins_sql}#{where_sql}#{group_sql}#{having_sql}#{order_sql}#{limit_sql}#{offset_sql}"
|
|
82
|
+
sql = sql.strip
|
|
83
|
+
[sql, params, nil]
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# `(left) UNION ALL (right)`. sides compile through the normal
|
|
87
|
+
# path so nesting works; each side's params append in order.
|
|
88
|
+
def self.compile_union(node, params)
|
|
89
|
+
[node.left, node.right].each do |side|
|
|
90
|
+
if side.ast.any? { |n| n.is_a?(AST::Join) && n.eager }
|
|
91
|
+
raise ArgumentError, "union() over eager loads isn't supported"
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
lsql, _ = Diamond::Compiler::Base.compile(node.left.table, node.left.ast, params)
|
|
95
|
+
rsql, _ = Diamond::Compiler::Base.compile(node.right.table, node.right.ast, params)
|
|
96
|
+
["#{lsql} UNION ALL #{rsql}", params, nil]
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# eager-loading path: build a SELECT with aliased columns (table.col AS
|
|
100
|
+
# "table.col") so the Extralite::Transform can disambiguate which table
|
|
101
|
+
# a column belongs to, then return the transform spec alongside SQL.
|
|
102
|
+
def self.compile_eager(table, from_target, projection, joins, wheres, order_specs, with_clauses, limit_node, offset_node, group_by_node, having_node, distinct, params)
|
|
103
|
+
# 1. column list: parent cols (with optional projection) + aliased
|
|
104
|
+
# columns from each eager join
|
|
105
|
+
parent_cols = if projection
|
|
106
|
+
projection.columns.map { |c| member_column(c) }.compact
|
|
107
|
+
else
|
|
108
|
+
table.schema[:columns]
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
select_parts = []
|
|
112
|
+
# parent: bare column names. Extralite returns them as keys like
|
|
113
|
+
# "id", "name", etc. The transform spec keys must match these.
|
|
114
|
+
parent_cols.each do |col|
|
|
115
|
+
select_parts << "#{from_target}.#{col}"
|
|
116
|
+
end
|
|
117
|
+
# children: alias each column as "table.col" so the transform can
|
|
118
|
+
# tell parent columns from child columns with the same name (e.g.
|
|
119
|
+
# both tables have an "id" column).
|
|
120
|
+
joins.each do |j|
|
|
121
|
+
next unless j.eager
|
|
122
|
+
child_table = j.table_name
|
|
123
|
+
child_schema = Diamond.engine.schema_cache[child_table]
|
|
124
|
+
child_schema[:columns].each do |col|
|
|
125
|
+
select_parts << "#{child_table}.#{col} AS \"#{child_table}.#{col}\""
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
select_sql = select_parts.join(', ')
|
|
129
|
+
|
|
130
|
+
# 2. JOIN clauses. eager joins get rewritten to LEFT OUTER JOIN so
|
|
131
|
+
# parents without children still appear.
|
|
132
|
+
render_join_eager = ->(j) {
|
|
133
|
+
sql_type = (j.eager ? 'LEFT OUTER JOIN' : JOIN_TYPE_MAP[j.type])
|
|
134
|
+
Diamond.validate_ident!(j.table_name, "join table")
|
|
135
|
+
on_clauses = render_on_clauses(j.on, from_target, j.table_name)
|
|
136
|
+
"#{sql_type} #{j.table_name} ON #{on_clauses.join(' AND ')}"
|
|
137
|
+
}
|
|
138
|
+
joins_sql = joins.map { |j| render_join_eager.call(j) }.join(' ')
|
|
139
|
+
|
|
140
|
+
# 3. WHERE, GROUP BY, HAVING, ORDER BY, LIMIT, OFFSET. when the query
|
|
141
|
+
# is eager, qualify bare column names in WHERE/HAVING with the
|
|
142
|
+
# parent table name so they don't collide with child table columns.
|
|
143
|
+
where_sql = if wheres.empty?
|
|
144
|
+
''
|
|
145
|
+
else
|
|
146
|
+
prefix = "#{from_target}."
|
|
147
|
+
' WHERE ' + wheres.map { |w| translate_where_qualified(w.condition, params, prefix) }.join(' AND ')
|
|
148
|
+
end
|
|
149
|
+
group_sql = group_by_node ? " GROUP BY #{group_by_node.columns.map { |c| column_sql(c, "#{from_target}.") }.join(', ')}" : ''
|
|
150
|
+
having_sql = having_node ? ' HAVING ' + translate_where_qualified(having_node.condition, params, "#{from_target}.") : ''
|
|
151
|
+
order_sql = order_specs.empty? ? '' : ' ORDER BY ' + order_specs.map { |col, dir| "#{column_sql(col, "#{from_target}.")} #{dir.to_s.upcase}" }.join(', ')
|
|
152
|
+
limit_sql = limit_node ? " LIMIT #{limit_node.value}" : ''
|
|
153
|
+
offset_sql = offset_node ? " OFFSET #{offset_node.value}" : ''
|
|
154
|
+
|
|
155
|
+
with_sql = render_with(with_clauses, params)
|
|
156
|
+
distinct_sql = distinct ? 'DISTINCT ' : ''
|
|
157
|
+
sql = "#{with_sql} SELECT #{distinct_sql}#{select_sql} FROM #{from_target}#{joins_sql.empty? ? '' : ' ' + joins_sql}#{where_sql}#{group_sql}#{having_sql}#{order_sql}#{limit_sql}#{offset_sql}".strip
|
|
158
|
+
|
|
159
|
+
# 4. build the transform spec
|
|
160
|
+
transform = build_transform(from_target, table, parent_cols, joins)
|
|
161
|
+
|
|
162
|
+
[sql, params, transform]
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# AST column -> underlying column name (only Column nodes; ignore
|
|
166
|
+
# Function/WindowFunction in eager mode for now).
|
|
167
|
+
def self.member_column(node)
|
|
168
|
+
case node
|
|
169
|
+
when AST::Column then node.name
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def self.build_transform(from_target, table, parent_cols, joins)
|
|
174
|
+
# build a hash describing the expected row layout. The Extralite
|
|
175
|
+
# transform expects the spec wrapped in { columns: { ... } } and
|
|
176
|
+
# uses each spec key as the lookup key against the result set's
|
|
177
|
+
# column name. We alias child columns as "table.col" so the
|
|
178
|
+
# transform can disambiguate when parent and child share column
|
|
179
|
+
# names like "id".
|
|
180
|
+
eager_joins = joins.select(&:eager)
|
|
181
|
+
columns_spec = {}
|
|
182
|
+
|
|
183
|
+
parent_cols.each do |col|
|
|
184
|
+
type = type_for_column(from_target, col)
|
|
185
|
+
columns_spec[col.to_s] = { type: type }
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
eager_joins.each do |j|
|
|
189
|
+
child_table = j.table_name
|
|
190
|
+
child_schema = Diamond.engine.schema_cache[child_table]
|
|
191
|
+
child_columns = {}
|
|
192
|
+
child_schema[:columns].each do |col|
|
|
193
|
+
type = type_for_column(child_table, col)
|
|
194
|
+
child_columns["#{child_table}.#{col}"] = { type: type }
|
|
195
|
+
end
|
|
196
|
+
# mark primary key for dedup
|
|
197
|
+
pk = child_schema[:primary_key]
|
|
198
|
+
child_columns["#{child_table}.#{pk}"][:identity] = true if pk
|
|
199
|
+
|
|
200
|
+
# use :as alias as struct member name if provided, else table name
|
|
201
|
+
member_key = (j.as || child_table).to_s
|
|
202
|
+
|
|
203
|
+
if j.single
|
|
204
|
+
# single object (to-one): Hash with type: :relation (not Array)
|
|
205
|
+
columns_spec[member_key] = {
|
|
206
|
+
type: :relation,
|
|
207
|
+
columns: child_columns
|
|
208
|
+
}
|
|
209
|
+
else
|
|
210
|
+
# collection (to-many): Array of Hash
|
|
211
|
+
columns_spec[member_key] = [{
|
|
212
|
+
type: :relation,
|
|
213
|
+
columns: child_columns
|
|
214
|
+
}]
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# parent primary key for dedup at the parent level
|
|
219
|
+
pk = table.schema[:primary_key]
|
|
220
|
+
columns_spec[pk.to_s][:identity] = true if pk
|
|
221
|
+
|
|
222
|
+
{ columns: columns_spec }
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def self.type_for_column(table, column)
|
|
226
|
+
# map sqlite type strings (INTEGER, TEXT, REAL) to extralite
|
|
227
|
+
# transform types. default to text for unknown types.
|
|
228
|
+
type_str = Diamond.engine.schema_cache[table][:types][column]
|
|
229
|
+
case type_str
|
|
230
|
+
when 'INTEGER' then :integer
|
|
231
|
+
when 'REAL' then :float
|
|
232
|
+
else :text
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# Kept for the eager path; now a thin wrapper around translate_node
|
|
237
|
+
# with the parent-table prefix so unqualified Column references don't
|
|
238
|
+
# collide with child-table columns.
|
|
239
|
+
def self.translate_where_qualified(node, params, prefix)
|
|
240
|
+
translate_node(node, params, prefix: prefix)
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def self.render_with(with_nodes, params)
|
|
244
|
+
return '' if with_nodes.empty?
|
|
245
|
+
pieces = with_nodes.map { |w| render_single_with(w, params) }
|
|
246
|
+
'WITH ' + pieces.join(', ')
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def self.render_single_with(node, params)
|
|
250
|
+
rec = node.recursive ? 'RECURSIVE ' : ''
|
|
251
|
+
sub_sql = render_with_query(node.query, params)
|
|
252
|
+
"#{rec}#{node.name} AS (#{sub_sql})"
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def self.render_with_query(query, params)
|
|
256
|
+
if query.is_a?(AST::Union)
|
|
257
|
+
left_sql = render_with_query(query.left, params)
|
|
258
|
+
right_sql = render_with_query(query.right, params)
|
|
259
|
+
"#{left_sql} #{query.operator} #{right_sql}"
|
|
260
|
+
else
|
|
261
|
+
sub_sql, _ = Diamond::Compiler::Base.compile(query.table, query.ast, params)
|
|
262
|
+
sub_sql
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def self.render_projection(node, params)
|
|
267
|
+
return '*' if node.nil?
|
|
268
|
+
node.columns.map { |c| translate_node(c, params) }.join(', ')
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# order/group entries are bare Symbols or qualified Columns.
|
|
272
|
+
# qualified columns render with their own table; bare ones take
|
|
273
|
+
# the caller's prefix (parent table in eager mode, none otherwise).
|
|
274
|
+
def self.column_sql(col, prefix = '')
|
|
275
|
+
col.is_a?(AST::Column) && col.table ? "#{col.table}.#{col.name}" : "#{prefix}#{col}"
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def self.render_join(node, current_table)
|
|
279
|
+
Diamond.validate_ident!(node.table_name, "join table")
|
|
280
|
+
sql_type = JOIN_TYPE_MAP[node.type] || raise(ArgumentError, "Unknown join type: #{node.type}")
|
|
281
|
+
on_clauses = render_on_clauses(node.on, current_table, node.table_name)
|
|
282
|
+
"#{sql_type} #{node.table_name} ON #{on_clauses.join(' AND ')}"
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# Convention for the `on` hash (consumed from `_resolve_join_keys`
|
|
286
|
+
# and explicit `join(table, on:)` calls): `{ <joined_local> =>
|
|
287
|
+
# <current_ref> }`. Emit `<joined>.<local> = <current>.<ref>`.
|
|
288
|
+
# The schema-membership check below is a guard, not a side-decider
|
|
289
|
+
# — it catches a typo in `on:` early instead of letting SQLite
|
|
290
|
+
# raise an obscure "no such column" error at run time.
|
|
291
|
+
def self.render_on_clauses(on_hash, current_table, joined_table)
|
|
292
|
+
schema_cache = Diamond.engine.schema_cache
|
|
293
|
+
current_schema = schema_cache[current_table]
|
|
294
|
+
joined_schema = schema_cache[joined_table]
|
|
295
|
+
on_hash.map do |local, ref|
|
|
296
|
+
if current_schema && joined_schema
|
|
297
|
+
current_cols = current_schema[:columns]
|
|
298
|
+
joined_cols = joined_schema[:columns]
|
|
299
|
+
unless joined_cols.include?(local.to_sym)
|
|
300
|
+
raise ArgumentError,
|
|
301
|
+
"JOIN ON column '#{local}' not found on #{joined_table}; " \
|
|
302
|
+
"pass `on:` with a column that exists on the joined table"
|
|
303
|
+
end
|
|
304
|
+
unless current_cols.include?(ref.to_sym)
|
|
305
|
+
raise ArgumentError,
|
|
306
|
+
"JOIN ON column '#{ref}' not found on #{current_table}; " \
|
|
307
|
+
"pass `on:` with a column that exists on the current table"
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
"#{joined_table}.#{local} = #{current_table}.#{ref}"
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def self.translate_node(node, params, prefix: '')
|
|
315
|
+
hook = Operators.call(node, params)
|
|
316
|
+
return hook if hook
|
|
317
|
+
case node
|
|
318
|
+
when Symbol
|
|
319
|
+
node.to_s
|
|
320
|
+
when AST::Column
|
|
321
|
+
# already qualified (`tags.tag`) wins over the eager prefix.
|
|
322
|
+
node.table ? "#{node.table}.#{node.name}" : "#{prefix}#{node.name}"
|
|
323
|
+
when AST::Literal
|
|
324
|
+
params << node.value
|
|
325
|
+
'?'
|
|
326
|
+
when AST::Subquery
|
|
327
|
+
sub_sql, _ = Diamond::Compiler::Base.compile(node.query.table, node.query.ast, params)
|
|
328
|
+
"(#{sub_sql})"
|
|
329
|
+
when AST::Not
|
|
330
|
+
"NOT (#{translate_node(node.condition, params, prefix: prefix)})"
|
|
331
|
+
when AST::IsNull
|
|
332
|
+
"#{translate_node(node.column, params, prefix: prefix)} IS NULL"
|
|
333
|
+
when AST::IsNotNull
|
|
334
|
+
"#{translate_node(node.column, params, prefix: prefix)} IS NOT NULL"
|
|
335
|
+
when AST::Between
|
|
336
|
+
col_sql = translate_node(node.column, params, prefix: prefix)
|
|
337
|
+
low_sql = translate_node(node.low, params, prefix: prefix)
|
|
338
|
+
high_sql = translate_node(node.high, params, prefix: prefix)
|
|
339
|
+
"#{col_sql} BETWEEN #{low_sql} AND #{high_sql}"
|
|
340
|
+
when AST::Function
|
|
341
|
+
args_str = node.args.map { |a| translate_node(a, params, prefix: prefix) }.join(', ')
|
|
342
|
+
"#{node.name}(#{args_str})"
|
|
343
|
+
when AST::WindowFunction
|
|
344
|
+
args_str = node.args.map { |a| translate_node(a, params, prefix: prefix) }.join(', ')
|
|
345
|
+
func_str = "#{node.func_name}(#{args_str})"
|
|
346
|
+
parts = []
|
|
347
|
+
parts << "PARTITION BY #{node.partition_by.join(', ')}" unless node.partition_by.empty?
|
|
348
|
+
parts << "ORDER BY #{node.order_by.join(', ')}" unless node.order_by.empty?
|
|
349
|
+
if parts.empty?
|
|
350
|
+
func_str
|
|
351
|
+
else
|
|
352
|
+
"#{func_str} OVER (#{parts.join(' ')})"
|
|
353
|
+
end
|
|
354
|
+
when AST::BinaryOp
|
|
355
|
+
left = translate_node(node.left, params, prefix: prefix)
|
|
356
|
+
right = translate_node(node.right, params, prefix: prefix)
|
|
357
|
+
if [:AND, :OR].include?(node.operator)
|
|
358
|
+
"(#{left} #{node.operator} #{right})"
|
|
359
|
+
else
|
|
360
|
+
"#{left} #{node.operator} #{right}"
|
|
361
|
+
end
|
|
362
|
+
when AST::In, AST::NotIn
|
|
363
|
+
# subquery form: `col IN (SELECT ...)`
|
|
364
|
+
if node.right.is_a?(AST::Subquery)
|
|
365
|
+
left_sql = translate_node(node.left, params, prefix: prefix)
|
|
366
|
+
right_sql = translate_node(node.right, params, prefix: prefix)
|
|
367
|
+
kw = node.is_a?(AST::NotIn) ? 'NOT IN' : 'IN'
|
|
368
|
+
return "#{left_sql} #{kw} #{right_sql}"
|
|
369
|
+
end
|
|
370
|
+
# no `IN ()` in sql, so empty means `1=0`, empty NOT IN means `1=1`.
|
|
371
|
+
if node.right.empty?
|
|
372
|
+
return node.is_a?(AST::NotIn) ? '1=1' : '1=0'
|
|
373
|
+
end
|
|
374
|
+
# Elements may be expressions, not just Literals. no `?` then.
|
|
375
|
+
element_sqls = node.right.map { |r| translate_node(r, params, prefix: prefix) }
|
|
376
|
+
kw = node.is_a?(AST::NotIn) ? 'NOT IN' : 'IN'
|
|
377
|
+
left_sql = translate_node(node.left, [], prefix: prefix)
|
|
378
|
+
# sqlite caps bound vars per statement, so slice big lists into
|
|
379
|
+
# 500s. IN groups get OR, NOT IN groups get AND (de morgan).
|
|
380
|
+
groups = element_sqls.each_slice(500).map { |g| "#{left_sql} #{kw} (#{g.join(', ')})" }
|
|
381
|
+
return groups.first if groups.size == 1
|
|
382
|
+
joiner = node.is_a?(AST::NotIn) ? ' AND ' : ' OR '
|
|
383
|
+
"(#{groups.join(joiner)})"
|
|
384
|
+
else
|
|
385
|
+
raise "Unknown AST Node: #{node.class}"
|
|
386
|
+
end
|
|
387
|
+
end
|
|
388
|
+
end
|
|
389
|
+
end
|
|
390
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
module Diamond
|
|
2
|
+
module Compiler
|
|
3
|
+
# Registry consulted at the front of DQL.translate_node. Operators declare
|
|
4
|
+
# which AST nodes they handle via .handles?, then render to SQL fragments.
|
|
5
|
+
# The render method is expected to mutate the params array (matches the
|
|
6
|
+
# convention used by the built-in case statement).
|
|
7
|
+
#
|
|
8
|
+
# Per-Ractor: each Ractor owns its own handler list, stored on the
|
|
9
|
+
# Ractor's local storage. The module holds only the frozen list of
|
|
10
|
+
# built-ins (which is shareable).
|
|
11
|
+
module Operators
|
|
12
|
+
STORAGE_KEY = Diamond::RACTOR_KEYS[:compiler_ops]
|
|
13
|
+
|
|
14
|
+
# method, not a constant: this file loads before the Like operator
|
|
15
|
+
# is defined, so it must resolve lazily. called once per Ractor
|
|
16
|
+
# (handlers memoizes), so the per-call allocation is irrelevant.
|
|
17
|
+
def self.builtins
|
|
18
|
+
[Diamond::Operators::Like].freeze
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.handlers
|
|
22
|
+
Ractor.current[STORAGE_KEY] ||= builtins.dup
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.register(operator)
|
|
26
|
+
list = handlers
|
|
27
|
+
list << operator unless list.include?(operator)
|
|
28
|
+
nil
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.clear!
|
|
32
|
+
Ractor.current[STORAGE_KEY] = builtins.dup
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.call(node, params)
|
|
36
|
+
handlers.sort_by { |h| -h.priority }.each do |h|
|
|
37
|
+
if h.handles?(node)
|
|
38
|
+
return h.render(node, params)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
nil
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
module Diamond
|
|
2
|
+
# streams rows out of a prepared Extralite statement, one frozen struct at a time.
|
|
3
|
+
#
|
|
4
|
+
# statement gets closed by `ensure` when iteration completes (or breaks, or
|
|
5
|
+
# raises), plus a GC finalizer for cursors dropped on the floor. the
|
|
6
|
+
# finalizer is built by ::make_finalizer so it never closes over the cursor
|
|
7
|
+
# itself. rows arrive as arrays (positions match the SELECT order), avoiding
|
|
8
|
+
# hash allocation per row.
|
|
9
|
+
class Cursor
|
|
10
|
+
include Enumerable
|
|
11
|
+
|
|
12
|
+
def self.make_finalizer(stmt)
|
|
13
|
+
->(_id) {
|
|
14
|
+
begin
|
|
15
|
+
stmt.close unless stmt.closed?
|
|
16
|
+
rescue StandardError
|
|
17
|
+
# best effort - finalizers must not raise
|
|
18
|
+
end
|
|
19
|
+
}
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def initialize(table, stmt, projected_columns)
|
|
23
|
+
@table = table
|
|
24
|
+
@stmt = stmt
|
|
25
|
+
@projected_columns = projected_columns
|
|
26
|
+
# Pre-resolve the struct class once: per-row work stays at
|
|
27
|
+
# splat + new + freeze (see StructFactory.bulk path).
|
|
28
|
+
@factory = Diamond::StructFactory.factory_for(table, projected_columns)
|
|
29
|
+
|
|
30
|
+
ObjectSpace.define_finalizer(self, self.class.make_finalizer(@stmt))
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def each(&block)
|
|
34
|
+
return enum_for(:each) unless block
|
|
35
|
+
begin
|
|
36
|
+
@stmt.each do |row|
|
|
37
|
+
if row.is_a?(Hash)
|
|
38
|
+
yield Diamond::StructFactory.create(@table, row, @projected_columns)
|
|
39
|
+
else
|
|
40
|
+
yield Diamond::StructFactory.build(@factory, row)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
ensure
|
|
44
|
+
begin
|
|
45
|
+
@stmt.close unless @stmt.closed?
|
|
46
|
+
rescue StandardError
|
|
47
|
+
# ensure must not raise
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
module Diamond
|
|
2
|
+
module Domains
|
|
3
|
+
module CTE
|
|
4
|
+
def with(cte_hash, &block)
|
|
5
|
+
raise ArgumentError, "Diamond.with requires a block" unless block_given?
|
|
6
|
+
|
|
7
|
+
cte_nodes = cte_hash.map { |alias_name, query| AST::With.new(alias_name, query) }
|
|
8
|
+
|
|
9
|
+
proxy = Object.new
|
|
10
|
+
proxy.define_singleton_method(:from) do |alias_name|
|
|
11
|
+
QueryObject.new(NullTable.new(alias_name), [AST::From.new(alias_name)])
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
main_query = yield(proxy)
|
|
15
|
+
QueryObject.new(main_query.table, cte_nodes + main_query.ast)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def with_recursive(name, base_query, recursive_query)
|
|
19
|
+
union_node = AST::Union.new(base_query, recursive_query)
|
|
20
|
+
dummy_table = NullTable.new(name)
|
|
21
|
+
QueryObject.new(dummy_table, [AST::With.new(name, union_node, recursive: true)])
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
module Diamond
|
|
2
|
+
module Domains
|
|
3
|
+
module DDL
|
|
4
|
+
def _build_relation(name, &block)
|
|
5
|
+
raise ArgumentError, "define_relation requires a block" unless block
|
|
6
|
+
columns = Diamond::Parser.parse_ddl(block)
|
|
7
|
+
AST::DefineRelation.new(name, columns)
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
module Diamond
|
|
2
|
+
module Domains
|
|
3
|
+
module DML
|
|
4
|
+
# returning: :id (single Symbol) returns a scalar for single-row
|
|
5
|
+
# create. returning: [:id, :name] (Array) returns an Array of
|
|
6
|
+
# structs (even for create's 1-row case, for consistency with
|
|
7
|
+
# update/delete). Empty returning returns the created row.
|
|
8
|
+
def _build_create(hash, returning: [])
|
|
9
|
+
single = returning.is_a?(Symbol) || returning.is_a?(String)
|
|
10
|
+
cols = Array(returning)
|
|
11
|
+
ast = AST::Insert.new(hash)
|
|
12
|
+
result = Diamond::Compiler::DML.compile_insert(_current_table, ast, returning: cols)
|
|
13
|
+
if cols.empty?
|
|
14
|
+
_current_table.find(result).first
|
|
15
|
+
elsif single
|
|
16
|
+
result.first[cols.first.to_sym]
|
|
17
|
+
else
|
|
18
|
+
# Array form: always return Array of Structs for consistency
|
|
19
|
+
# with multi-row DML (update/delete).
|
|
20
|
+
result.map { |row| Diamond::StructFactory.create_from_returning(_current_table, row, cols) }
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def _build_update(returning: [], &block)
|
|
25
|
+
raise ArgumentError, "update requires a block" unless block_given?
|
|
26
|
+
single = returning.is_a?(Symbol) || returning.is_a?(String)
|
|
27
|
+
cols = Array(returning)
|
|
28
|
+
hash = Diamond::Parser.parse_update(block, _schema_for_dsl)
|
|
29
|
+
wheres = _where_nodes
|
|
30
|
+
result = Diamond::Compiler::DML.compile_update(_current_table, hash, wheres, returning: cols)
|
|
31
|
+
if cols.empty?
|
|
32
|
+
result # row count
|
|
33
|
+
elsif single
|
|
34
|
+
col = cols.first.to_sym
|
|
35
|
+
result.map { |row| row[col] }
|
|
36
|
+
else
|
|
37
|
+
result.map { |row| Diamond::StructFactory.create_from_returning(_current_table, row, cols) }
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def _build_delete(returning: [])
|
|
42
|
+
single = returning.is_a?(Symbol) || returning.is_a?(String)
|
|
43
|
+
cols = Array(returning)
|
|
44
|
+
wheres = _where_nodes
|
|
45
|
+
result = Diamond::Compiler::DML.compile_delete(_current_table, wheres, returning: cols)
|
|
46
|
+
if cols.empty?
|
|
47
|
+
result # row count
|
|
48
|
+
elsif single
|
|
49
|
+
col = cols.first.to_sym
|
|
50
|
+
result.map { |row| row[col] }
|
|
51
|
+
else
|
|
52
|
+
result.map { |row| Diamond::StructFactory.create_from_returning(_current_table, row, cols) }
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Phase 7: bulk insert. Records is an Enumerable of Hashes. Returns
|
|
57
|
+
# the number of rows inserted. Single SQL statement + C-level loop
|
|
58
|
+
# = dramatically faster than N individual inserts.
|
|
59
|
+
#
|
|
60
|
+
# Note: does not support `returning:` — batch_execute (Extralite's
|
|
61
|
+
# C-level bulk path) doesn't expose RETURNING rows. Use individual
|
|
62
|
+
# .create() calls with returning if you need the inserted values.
|
|
63
|
+
def batch_create(records)
|
|
64
|
+
records = Array(records)
|
|
65
|
+
return 0 if records.empty?
|
|
66
|
+
first = records.first
|
|
67
|
+
cols = first.keys
|
|
68
|
+
placeholders = "(#{(['?'] * cols.size).join(', ')})"
|
|
69
|
+
sql = "INSERT INTO #{_current_table.name} (#{cols.join(', ')}) VALUES #{placeholders}"
|
|
70
|
+
Diamond.engine.db.batch_execute(sql, records.map { |r| r.values })
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
alias insert_all batch_create
|
|
74
|
+
|
|
75
|
+
# Phase 7: bulk update with optional RETURNING. Reuses the WHERE
|
|
76
|
+
# nodes already on the chain; applies the set_hash; returns the row
|
|
77
|
+
# count, a flat array of values (single Symbol returning), or an
|
|
78
|
+
# array of structs (Array returning).
|
|
79
|
+
def batch_update(set_hash, returning: [])
|
|
80
|
+
single = returning.is_a?(Symbol) || returning.is_a?(String)
|
|
81
|
+
cols = Array(returning)
|
|
82
|
+
wheres = _where_nodes
|
|
83
|
+
result = Diamond::Compiler::DML.compile_update(_current_table, set_hash, wheres, returning: cols)
|
|
84
|
+
if cols.empty?
|
|
85
|
+
result
|
|
86
|
+
elsif single
|
|
87
|
+
col = cols.first.to_sym
|
|
88
|
+
result.map { |row| row[col] }
|
|
89
|
+
else
|
|
90
|
+
result.map { |row| Diamond::StructFactory.create_from_returning(_current_table, row, cols) }
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
def _current_table
|
|
97
|
+
is_a?(Diamond::Table) ? self : @table
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def _where_nodes
|
|
101
|
+
return [] unless self.is_a?(Diamond::QueryObject)
|
|
102
|
+
@ast.select { |n| n.is_a?(AST::Where) }
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|