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,463 @@
1
+ require_relative 'struct_factory'
2
+ require_relative 'compiler/base'
3
+ require_relative 'cursor'
4
+
5
+ module Diamond
6
+ class QueryObject
7
+ include Enumerable
8
+
9
+ # Phase 7 query modes. :struct is the default — returns Diamond's
10
+ # frozen Structs. :hash returns native Extralite hash rows. :array
11
+ # returns positional arrays (SoA-friendly, zero allocation). :splat
12
+ # returns a single column's values (the ultimate pluck).
13
+ VALID_MODES = %i[struct hash array splat].freeze
14
+
15
+ attr_reader :table, :ast, :mode
16
+
17
+ def initialize(table, ast = [])
18
+ @table = table
19
+ @ast = ast
20
+ @cached_result = nil
21
+ @mode = :struct
22
+ end
23
+
24
+ def materialize
25
+ return @cached_result if @cached_result
26
+
27
+ sql, params, transform_spec = Diamond::Compiler::Base.compile(@table, @ast)
28
+
29
+ # eager-loaded queries: hand the row layout to Extralite::Transform
30
+ # so the result is deduplicated and nested per the join graph.
31
+ if transform_spec
32
+ transform = Extralite::Transform.new(transform_spec)
33
+ @cached_result = []
34
+ Diamond.engine.db.query(transform, sql, *params).each do |row|
35
+ @cached_result << Diamond::StructFactory.create_eager(@table, row)
36
+ end
37
+ return @cached_result
38
+ end
39
+
40
+ # Phase 7: hash/array/splat modes bypass the StructFactory and
41
+ # hand back the raw Extralite rows. Much cheaper on the GC.
42
+ case @mode
43
+ when :hash
44
+ @cached_result = Diamond.engine.db.query_hash(sql, *params)
45
+ return @cached_result
46
+ when :array
47
+ @cached_result = Diamond.engine.db.query_array(sql, *params)
48
+ return @cached_result
49
+ when :splat
50
+ @cached_result = Diamond.engine.db.query_splat(sql, *params)
51
+ return @cached_result
52
+ end
53
+
54
+ # Phase 7: use prepared statement cache for :struct mode.
55
+ # Array fetch mode: rows arrive positionally, zero Hash
56
+ # allocations per row (see StructFactory.create_from_array).
57
+ stmt = Diamond.engine.prepared(sql, :array)
58
+ begin
59
+ stmt.bind(*params)
60
+
61
+ # union results carry no top-level projection; members come from
62
+ # the left side (sqlite names union output columns after it).
63
+ union_node = @ast.find { |n| n.is_a?(AST::Union) }
64
+ scope_ast = union_node ? union_node.left.ast : @ast
65
+ projection_node = scope_ast.find { |n| n.is_a?(AST::Projection) }
66
+ projected_columns = projection_node&.columns
67
+
68
+ @cached_result = []
69
+ raw_rows = []
70
+ stmt.each { |row| raw_rows << row }
71
+ @cached_result = Diamond::StructFactory.create_many(@table, raw_rows, projected_columns)
72
+ ensure
73
+ # Note: we don't close the stmt here — it's cached for reuse.
74
+ # The stmt will be closed when the engine is frozen (which doesn't
75
+ # happen) or explicitly closed by the user. Extralite manages
76
+ # prepared statement lifecycle.
77
+ end
78
+
79
+ @cached_result
80
+ end
81
+
82
+ # --- Phase 7 query mode terminals ---
83
+
84
+ def as_hash
85
+ with_mode(:hash)
86
+ end
87
+
88
+ def as_array
89
+ with_mode(:array)
90
+ end
91
+
92
+ def as_splat
93
+ with_mode(:splat)
94
+ end
95
+
96
+ def first(n = 1)
97
+ scope = has_order? ? self : order(resolve_pk!)
98
+ results = scope.limit(n).materialize
99
+ n == 1 ? results.first : results
100
+ end
101
+
102
+ # without a block, returns an Enumerator so .lazy and Enumerable chains work.
103
+ # Early-return before compiling/preparing: the enumerator re-enters here
104
+ # with a block on iteration, so doing work now would leak one statement.
105
+ def each(&block)
106
+ return enum_for(:each) unless block_given?
107
+
108
+ compiled_sql, compiled_params, transform_spec = Diamond::Compiler::Base.compile(@table, @ast)
109
+
110
+ if transform_spec
111
+ transform = Extralite::Transform.new(transform_spec)
112
+ Diamond.engine.db.query(transform, compiled_sql, *compiled_params).each do |row|
113
+ yield Diamond::StructFactory.create_eager(@table, row)
114
+ end
115
+ return
116
+ end
117
+
118
+ stmt = Diamond.engine.db.prepare_array(compiled_sql)
119
+ stmt.bind(*compiled_params)
120
+
121
+ union_node = @ast.find { |n| n.is_a?(AST::Union) }
122
+ scope_ast = union_node ? union_node.left.ast : @ast
123
+ projection_node = scope_ast.find { |n| n.is_a?(AST::Projection) }
124
+ projected_columns = projection_node&.columns
125
+
126
+ cursor = Diamond::Cursor.new(@table, stmt, projected_columns)
127
+
128
+ cursor.each(&block)
129
+ end
130
+
131
+ def last(n = 1)
132
+ scope = has_order? ? self : order([resolve_pk!, :desc])
133
+ results = scope.limit(n).materialize
134
+ n == 1 ? results.first : results.reverse
135
+ end
136
+
137
+ # skip the structs entirely - read values straight off the cursor.
138
+ def pluck(*columns)
139
+ raise ArgumentError, "pluck requires at least one column" if columns.empty?
140
+ validate_pluck_columns!(columns)
141
+ rows = pluck_rows(columns)
142
+ if columns.size == 1
143
+ rows.map { |row| row[0] }
144
+ else
145
+ rows.map(&:dup)
146
+ end
147
+ end
148
+
149
+ # first row's values only: single value for one column, tuple for
150
+ # several, nil on miss. `Users.pick(:name)` ~ `SELECT name ... LIMIT 1`.
151
+ def pick(*columns)
152
+ raise ArgumentError, "pick requires at least one column" if columns.empty?
153
+ validate_pluck_columns!(columns)
154
+ q = projected_query(columns).limit(1)
155
+ sql, params, _transform = Diamond::Compiler::Base.compile(q.table, q.ast)
156
+ row = fetch_first_array_row(sql, params)
157
+ return nil if row.nil?
158
+ columns.size == 1 ? row[0] : row.dup
159
+ end
160
+
161
+ # primary-key values. `Users.ids` ~ `SELECT id FROM users`.
162
+ def ids
163
+ pluck(resolve_pk!)
164
+ end
165
+
166
+ # Zero-translation JSON fast path. Pushes serialization into
167
+ # SQLite's JSON1 C engine: the normally-compiled query becomes a
168
+ # subquery whose rows are encoded by json_object and aggregated by
169
+ # json_group_array — Ruby receives ONE finished string. No
170
+ # StructFactory, no per-row Hash, no Ruby JSON encoding at all.
171
+ # Columns default to the table's schema columns; pass an explicit
172
+ # list to project (which also fixes key order). Returns a
173
+ # Diamond::JsonString so Rutile passes it to the Rack body with
174
+ # zero further processing.
175
+ #
176
+ # The inner query compiles through the normal path, so WHERE,
177
+ # JOIN, GROUP BY, ORDER BY, LIMIT, OFFSET, DISTINCT and UNION ALL
178
+ # all keep working — the wrapper only replaces the SELECT list
179
+ # with a single aggregate expression. One-shot query (no prepared
180
+ # cache): a single row crosses the Ruby/C boundary.
181
+ #
182
+ # Keys are validated identifiers (schema membership, same as
183
+ # pluck), so they interpolate safely as single-quoted literals;
184
+ # value refs are bare subquery-output names, mirroring the
185
+ # compiler's own unquoted column rendering. COALESCE keeps the
186
+ # empty set as '[]' on SQLite builds predating native empty-array
187
+ # aggregation.
188
+ #
189
+ # Explicit limits (fail loud): eager joins (object-graph transform
190
+ # is meaningless for a single string) and WITH/CTE chains (WITH
191
+ # cannot lead inside a subquery). Guards run before column
192
+ # validation: a WITH query's table is a NullTable (empty schema),
193
+ # so validation could never pass — the structural error is the
194
+ # meaningful one.
195
+ def to_json_array(*columns)
196
+ if @ast.any? { |n| n.is_a?(AST::Join) && n.eager }
197
+ raise ArgumentError, "to_json_array does not support eager joins"
198
+ end
199
+ if @ast.any? { |n| n.is_a?(AST::With) }
200
+ raise ArgumentError, "to_json_array does not support WITH/CTE chains"
201
+ end
202
+ columns = @table.schema[:columns] if columns.empty?
203
+ columns.each do |c|
204
+ raise Diamond::UnknownColumnError.build(@table.schema, c) unless @table.schema[:columns].include?(c)
205
+ end
206
+ # Union chains compile standalone (sides carry their own shape;
207
+ # appending a Projection would trip compile's "union terminates
208
+ # the chain" rule). The json_object refs resolve against the
209
+ # union output, named after the left side — sides must project
210
+ # those names (SELECT * on one table always qualifies).
211
+ has_union = @ast.any? { |n| n.is_a?(AST::Union) }
212
+ filtered = if has_union
213
+ @ast
214
+ else
215
+ new_nodes = columns.map { |c| AST::Column.new(c) }
216
+ @ast.reject { |n| n.is_a?(AST::Projection) } + [AST::Projection.new(new_nodes)]
217
+ end
218
+ q = Diamond::QueryObject.new(@table, filtered)
219
+ inner_sql, params, _transform = Diamond::Compiler::Base.compile(q.table, q.ast)
220
+ pairs = columns.map { |c| "'#{c}', #{c}" }.join(', ')
221
+ sql = "SELECT COALESCE(json_group_array(json_object(#{pairs})), '[]') FROM (#{inner_sql})"
222
+ Diamond::JsonString.new(Diamond.engine.db.query_single_splat(sql, *params))
223
+ end
224
+
225
+ def exists?(filter = nil)
226
+ unless filter.nil?
227
+ return filter.is_a?(Hash) ? where(filter).exists? : find(filter).exists?
228
+ end
229
+ pk = resolve_pk!
230
+ col = AST::Column.new(pk)
231
+ filtered = @ast.reject { |n| n.is_a?(AST::Projection) } + [AST::Projection.new([col])]
232
+ q = Diamond::QueryObject.new(@table, filtered).limit(1)
233
+ sql, params, _transform = Diamond::Compiler::Base.compile(q.table, q.ast)
234
+ stmt = Diamond.engine.db.prepare_array(sql)
235
+ begin
236
+ stmt.bind(*params)
237
+ found = false
238
+ stmt.each { |_row| found = true; break }
239
+ found
240
+ ensure
241
+ begin
242
+ stmt.close unless stmt.closed?
243
+ rescue StandardError
244
+ # ensure must not raise
245
+ end
246
+ end
247
+ end
248
+
249
+ # SQL-backed existence checks. With a block (or pattern args for
250
+ # any?) they fall through to Enumerable — same results as today,
251
+ # just without the SQL fast path. NOTE: never bare `super` here.
252
+ # The DSL modules are mixed into QueryObject *after* Enumerable,
253
+ # so `super` would land on the DSL delegate and recurse forever;
254
+ # bind Enumerable explicitly instead.
255
+ def any?(*args, &block)
256
+ return enumerable_fallback(:any?, *args, &block) if block || !args.empty?
257
+ exists?
258
+ end
259
+
260
+ def none?(*args, &block)
261
+ return enumerable_fallback(:none?, *args, &block) if block || !args.empty?
262
+ !exists?
263
+ end
264
+
265
+ def empty?
266
+ !exists?
267
+ end
268
+
269
+ def count(column = nil)
270
+ col = column.nil? ? resolve_pk! : validate_aggregate_column!(column)
271
+ nodes = [AST::Function.new(:count, [AST::Column.new(col)])]
272
+ filtered = @ast.reject { |n| n.is_a?(AST::Projection) } + [AST::Projection.new(nodes)]
273
+ Diamond::QueryObject.new(@table, filtered).materialize.first.public_send(:"count_#{col}")
274
+ end
275
+
276
+ # Scalar aggregate terminals. Each builds one Function projection
277
+ # (exactly like count) and reads the auto-named member
278
+ # (SUM(age) -> sum_age). Empty scope yields one NULL row, so these
279
+ # return nil — never NoMethodError.
280
+ #
281
+ # Block form (or a non-column arg like an Numeric init) falls through
282
+ # to Enumerable#sum — same pattern as min/max below.
283
+ def sum(column = nil, &block)
284
+ if block
285
+ return column.nil? ? enumerable_fallback(:sum, &block) : enumerable_fallback(:sum, column, &block)
286
+ end
287
+ return enumerable_fallback(:sum) if column.nil?
288
+ return enumerable_fallback(:sum, column) unless column.is_a?(Symbol) || column.is_a?(String)
289
+
290
+ aggregate(:sum, column)
291
+ end
292
+
293
+ def minimum(column)
294
+ aggregate(:min, column)
295
+ end
296
+
297
+ def maximum(column)
298
+ aggregate(:max, column)
299
+ end
300
+
301
+ def average(column)
302
+ aggregate(:avg, column)
303
+ end
304
+
305
+ alias avg average
306
+
307
+ # min/max with a column go to SQL; bare min/max, min(n), and
308
+ # block forms keep today's Enumerable behavior (see the super note
309
+ # on any? above for why this binds explicitly).
310
+ def min(column = nil, &block)
311
+ return minimum(column) if !block && !column.nil? && !column.is_a?(Integer)
312
+ column.nil? ? enumerable_fallback(:min, &block) : enumerable_fallback(:min, column, &block)
313
+ end
314
+
315
+ def max(column = nil, &block)
316
+ return maximum(column) if !block && !column.nil? && !column.is_a?(Integer)
317
+ column.nil? ? enumerable_fallback(:max, &block) : enumerable_fallback(:max, column, &block)
318
+ end
319
+
320
+ def method_missing(name, *args, &block)
321
+ if @table.schema[:columns].include?(name)
322
+ record = first
323
+ raise Diamond::RecordNotFound, "No record found for AST: #{@ast.inspect}" if record.nil?
324
+ record.public_send(name, *args, &block)
325
+ else
326
+ super
327
+ end
328
+ end
329
+
330
+ def respond_to_missing?(name, include_private = false)
331
+ @table.schema[:columns].include?(name) || super
332
+ end
333
+
334
+ def inspect
335
+ if @cached_result
336
+ "#<Diamond::QueryObject materialized: #{@cached_result.size} records>"
337
+ else
338
+ "#<Diamond::QueryObject table=#{@table.name} ast=[#{@ast.map(&:class).map(&:name).join(', ')}]>"
339
+ end
340
+ end
341
+
342
+ # the SQL this chain compiles to, without running it. returns
343
+ # [sql, params] — params stay separate so you can bind them yourself.
344
+ def to_sql
345
+ sql, params, _transform = Diamond::Compiler::Base.compile(@table, @ast)
346
+ [sql, params]
347
+ end
348
+
349
+ # sqlite's query plan for this chain. read-only, runs on the
350
+ # caller's own connection so it works from worker ractors too.
351
+ # returns extralite rows (:selectid, :order, :from, :detail).
352
+ def explain
353
+ sql, params, _transform = Diamond::Compiler::Base.compile(@table, @ast)
354
+ Diamond.engine.db.query("EXPLAIN QUERY PLAN #{sql}", *params)
355
+ end
356
+
357
+ # the chain's AST as an indented tree. for staring at what a block
358
+ # actually became.
359
+ def ast_tree
360
+ @ast.map { |n| AST.dump(n) }.join("\n")
361
+ end
362
+
363
+ private
364
+
365
+ # Bypass for Enumerable fallbacks. See the note on any?.
366
+ def enumerable_fallback(method_name, *args, &block)
367
+ Enumerable.instance_method(method_name).bind_call(self, *args, &block)
368
+ end
369
+
370
+ def validate_pluck_columns!(columns)
371
+ columns.each do |c|
372
+ raise Diamond::UnknownColumnError.build(@table.schema, c) unless @table.schema[:columns].include?(c)
373
+ end
374
+ end
375
+
376
+ # fresh QueryObject projecting exactly `columns`, replacing any
377
+ # existing projection (same rule pluck has always used).
378
+ def projected_query(columns)
379
+ new_nodes = columns.map { |c| AST::Column.new(c) }
380
+ filtered = @ast.reject { |n| n.is_a?(AST::Projection) } + [AST::Projection.new(new_nodes)]
381
+ Diamond::QueryObject.new(@table, filtered)
382
+ end
383
+
384
+ # raw positional rows for a column projection (Array mode: row[i]
385
+ # lines up with columns[i]).
386
+ def pluck_rows(columns)
387
+ q = projected_query(columns)
388
+ sql, params, _transform = Diamond::Compiler::Base.compile(q.table, q.ast)
389
+ stmt = Diamond.engine.db.prepare_array(sql)
390
+ begin
391
+ stmt.bind(*params)
392
+ result = []
393
+ # dup: the cursor may reuse its row buffer across iterations
394
+ # (the old pluck body duped for the same reason).
395
+ stmt.each { |row| result << row.dup }
396
+ result
397
+ ensure
398
+ begin
399
+ stmt.close unless stmt.closed?
400
+ rescue StandardError
401
+ # ensure must not raise
402
+ end
403
+ end
404
+ end
405
+
406
+ def fetch_first_array_row(sql, params)
407
+ stmt = Diamond.engine.db.prepare_array(sql)
408
+ begin
409
+ stmt.bind(*params)
410
+ found = nil
411
+ stmt.each { |row| found = row; break }
412
+ found
413
+ ensure
414
+ begin
415
+ stmt.close unless stmt.closed?
416
+ rescue StandardError
417
+ # ensure must not raise
418
+ end
419
+ end
420
+ end
421
+
422
+ # one Function projection (SUM(age) -> sum_age), scalar back.
423
+ # Empty scope yields a single NULL row, so this returns nil there.
424
+ def aggregate(func, column)
425
+ col = validate_aggregate_column!(column)
426
+ nodes = [AST::Function.new(func, [AST::Column.new(col)])]
427
+ filtered = @ast.reject { |n| n.is_a?(AST::Projection) } + [AST::Projection.new(nodes)]
428
+ row = Diamond::QueryObject.new(@table, filtered).materialize.first
429
+ row&.public_send(:"#{func.to_s.downcase}_#{col}")
430
+ end
431
+
432
+ def validate_aggregate_column!(column)
433
+ col = column.to_sym
434
+ unless @table.schema[:columns].include?(col)
435
+ raise Diamond::UnknownColumnError.build(@table.schema, col)
436
+ end
437
+ col
438
+ end
439
+
440
+ def with_mode(new_mode)
441
+ raise ArgumentError, "Unknown mode #{new_mode.inspect}" unless VALID_MODES.include?(new_mode)
442
+ q = Diamond::QueryObject.new(@table, @ast.dup)
443
+ q.instance_variable_set(:@mode, new_mode)
444
+ q
445
+ end
446
+
447
+ def has_order?
448
+ @ast.any? { |n| n.is_a?(AST::Order) }
449
+ end
450
+
451
+ # pk or :id, and it better exist. fail here with a column error
452
+ # instead of letting sqlite complain about COUNT(missing). Also
453
+ # the implicit ORDER BY column for first/last.
454
+ def resolve_pk!
455
+ pk = @table.schema[:primary_key] || :id
456
+ unless @table.schema[:columns].include?(pk)
457
+ raise Diamond::UnknownColumnError.build(@table.schema, pk)
458
+ end
459
+ pk
460
+ end
461
+
462
+ end
463
+ end