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