diamond-orm 0.1.0 → 0.1.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 13c6999334b70e19afef786aa57f2ad0cc7835fafacc12f391d6414b3dc6309b
4
- data.tar.gz: 30efa17f6e63476342f9101dabb0775ccc71ed3e63e91ea3e8604f6e118f675d
3
+ metadata.gz: 12bd9185ce557de9f864bbdeb245b70015b498f3cf2bab68a9d780b4b2e8dbe6
4
+ data.tar.gz: 6e8acbdebdd733244718aa6e4ba5b03d66832ce429f0eee177dba8d743757daf
5
5
  SHA512:
6
- metadata.gz: 2e14c2e9937d10c70962f802f9a162d77ca71a74dbddfcf7ded4425079800afdfa6093b28af2bc37a78380e8720f442636ddd0f1a7a518bcd1cd6bba38f41e79
7
- data.tar.gz: ed794d682668a04a47f93471168863ab650b0e4b71d6fd48a39b60d4847bc98f5b7b337dfd94ce78e6158c084bdd154ac6edfaac87ab0cf68b68471e5665798a
6
+ metadata.gz: c867fbbf67eefd91a8c90f3e6c803f6ff883caee436d22883d1bb52c785c4f0f4f4ea2e689fc0a46c5466f3eabd696008bd9b4a9b4a1da654710fcde60c7df55
7
+ data.tar.gz: fb00be24993be996c4f40499cb3407e549da03070bafa7efae21da287e89c0c74750939a491879ade554aa066a7c0caa431423cf13c64a2a3264984672ab7e2a
@@ -348,6 +348,9 @@ module Diamond
348
348
  'primary_key' => [
349
349
  'Users.primary_key'
350
350
  ],
351
+ 'primary_keys' => [
352
+ 'Users.primary_keys' # composite PK when defined; single column for single-PK
353
+ ],
351
354
  'name' => [
352
355
  'Users.name'
353
356
  ],
@@ -403,7 +406,8 @@ module Diamond
403
406
  'Diamond.define_relation(:users) { |t| t.attribute :id, Integer, primary_key: true, nullable: false }',
404
407
  'Diamond.define_relation(:users) { |t| t.attribute :name, String }',
405
408
  'Diamond.define_relation(:posts) { |t| t.attribute :user_id, Integer; t.foreign_key :user_id, :users }',
406
- 'Diamond.define_relation(:blogs) { |t| t.index :name, name: "idx_blogs_name", unique: true }'
409
+ 'Diamond.define_relation(:blogs) { |t| t.index :name, name: "idx_blogs_name", unique: true }',
410
+ 'Diamond.define_relation(:memberships) { |t| t.primary_key :tenant_id, :user_id; t.attribute :role, String }'
407
411
  ],
408
412
  'alter_table' => [
409
413
  'Diamond.alter_table(:users) { |t| t.add_column :nickname, String }',
@@ -539,9 +543,9 @@ module Diamond
539
543
  # Use for pk/version/etc. dependencies that don't fit in a single
540
544
  # example call.
541
545
  NOTES = {
542
- '[]' => 'uses the table primary_key (custom or :id)',
543
- 'find' => 'uses the table primary_key (custom or :id); block form delegates to Enumerable#find',
544
- 'find!' => 'uses the table primary_key (custom or :id); raises RecordNotFound on miss',
546
+ '[]' => 'uses the table primary_key (custom or :id); for composite-PK tables pass an Array or Hash',
547
+ 'find' => 'single-PK: scalar id; composite-PK: Array (positional) or Hash (named). Block form delegates to Enumerable#find',
548
+ 'find!' => 'single-PK: scalar id; composite-PK: Array or Hash. Raises RecordNotFound on miss',
545
549
  'derive' => 'one Projection per chain — a second derive raises',
546
550
  'reorder' => 'replaces existing ORDER BY; bare reorder clears it',
547
551
  'limit' => 'replaces previous limit (last wins)',
@@ -550,14 +554,15 @@ module Diamond
550
554
  'union' => 'terminal — further chain calls raise (wrap in a subquery instead)',
551
555
  'from_cte' => 'must run inside the Diamond.with block; queries a CTE alias',
552
556
  'sole' => 'raises RecordNotFound on both zero rows AND two-plus',
553
- 'first' => 'injects ORDER BY primary_key when no order is set; n==1 returns a struct, n>1 returns an Array',
554
- 'last' => 'injects ORDER BY primary_key DESC when no order is set; n>1 reverses back to ascending',
557
+ 'first' => 'injects ORDER BY primary_key(s) when no order is set; n==1 returns a struct, n>1 returns an Array',
558
+ 'last' => 'injects ORDER BY primary_key(s) DESC when no order is set; n>1 reverses back to ascending',
559
+ 'ids' => 'flat Array of PK values; raises on composite-PK tables — use pluck(*primary_keys)',
555
560
  'each' => 'no block returns an Enumerator (so .lazy and chains work); with block streams via a cursor',
556
561
  'all' => 'lazy QueryObject (no SQL until chained/terminal); equivalent to bare Table but chainable',
557
562
  'all!' => 'eagerly materializes all rows (same as .to_a); returns Array of structs',
558
563
  'pluck' => 'one column -> flat Array; several -> Array of Arrays (no Struct overhead)',
559
564
  'pick' => 'one column -> single value; several -> Array; nil on miss (no Struct)',
560
- 'count' => 'COUNT(primary_key) when column is nil; COUNT(col) when given',
565
+ 'count' => 'COUNT(primary_key) when column is nil and PK is single; COUNT(*) for composite-PK; COUNT(col) when given',
561
566
  'materialize' => 'result is memoized per QueryObject; same chain returns the same array',
562
567
  'as_hash' => 'mode carries across chain calls (.as_hash.limit(5).materialize stays hashes)',
563
568
  'as_array' => 'mode carries across chain calls (positional arrays, no Struct)',
@@ -568,9 +573,9 @@ module Diamond
568
573
  'track_changes' => 'requires the extralite-bundle build (session extension); raises FeatureNotAvailableError otherwise',
569
574
  'transaction' => 'nested calls run inside the outer transaction; use savepoint for explicit rollback points',
570
575
  'savepoint' => 'only valid inside a transaction; rolls back to the named savepoint on raise',
571
- 'increment!' => 'issues a SQL UPDATE; not atomic with concurrent writers — pair with a unique index if contention matters',
572
- 'decrement!' => 'issues a SQL UPDATE; not atomic with concurrent writers — pair with a unique index if contention matters',
573
- 'toggle!' => 'reads-then-writes — not atomic; pair with a unique index if contention matters',
576
+ 'increment!' => 'issues a SQL UPDATE; single-PK: scalar id, composite-PK: Array id. Not atomic with concurrent writers — pair with a unique index if contention matters',
577
+ 'decrement!' => 'issues a SQL UPDATE; single-PK: scalar id, composite-PK: Array id. Not atomic with concurrent writers — pair with a unique index if contention matters',
578
+ 'toggle!' => 'reads-then-writes; single-PK: scalar id, composite-PK: Array id. Not atomic; pair with a unique index if contention matters',
574
579
  'join' => 'INNER by default; type: :left for LEFT OUTER; eager: true for nested structs (same SQL as includes, but flat results)',
575
580
  'includes' => 'LEFT OUTER + eager loading; returns parent with nested child structs (sugar for join(..., eager: true))',
576
581
  'define_relation' => 'block captures an AST; t is a scratch DSL proxy (instance_exec in console, ignored by Prism file parser). Block return value is ignored. See BLOCK_GRAMMAR for the methods callable inside the block.',
@@ -619,13 +624,14 @@ module Diamond
619
624
  source: 'parser.rb:434-438 / proxy.rb:384-388'
620
625
  },
621
626
  'primary_key' => {
622
- signature: 't.primary_key(name)',
623
- summary: 'shorthand: Integer column with primary_key: true, nullable: false (no kwargs)',
627
+ signature: 't.primary_key(name, *more_names)',
628
+ summary: 'one arg: Integer column with primary_key: true, nullable: false (column-level PRIMARY KEY). 2+ args: table-level composite PRIMARY KEY (col1, col2, …)',
624
629
  available_in: 'define_relation',
625
630
  examples: [
626
- 't.primary_key :id'
631
+ 't.primary_key :id',
632
+ 't.primary_key :tenant_id, :user_id'
627
633
  ],
628
- source: 'parser.rb:439-442 / proxy.rb:390-393'
634
+ source: 'parser.rb:439-447 / proxy.rb:390-398'
629
635
  },
630
636
  'foreign_key' => {
631
637
  signature: 't.foreign_key(local, ref_table, ref_col = :id, on_delete: nil, on_update: nil)',
data/lib/diamond/ast.rb CHANGED
@@ -40,6 +40,12 @@ module Diamond
40
40
  def initialize(value); @value = value; end
41
41
  end
42
42
 
43
+ # `*` selector. Renders as a bare `*` in SQL, distinct from a
44
+ # column reference so function args (`COUNT(*)`) and projections
45
+ # can carry it without colliding with identifier names.
46
+ class Star < Node
47
+ end
48
+
43
49
  class BinaryOp < Node
44
50
  attr_reader :left, :right, :operator
45
51
  def initialize(left, right, operator)
@@ -277,6 +283,17 @@ module Diamond
277
283
  end
278
284
  end
279
285
 
286
+ # Table-level constraint (e.g. composite PRIMARY KEY). Sits in the
287
+ # CREATE TABLE body alongside ColumnDefinition. `columns` is the
288
+ # ordered list of column names (Symbols) the constraint applies to.
289
+ class TableConstraint < Node
290
+ attr_reader :kind, :columns
291
+ def initialize(kind:, columns:)
292
+ @kind = kind
293
+ @columns = columns
294
+ end
295
+ end
296
+
280
297
  # lifted into the AST so define_relation does CREATE TABLE then
281
298
  # CREATE INDEX in one go.
282
299
  class IndexDefinition < Node
@@ -13,10 +13,11 @@ module Diamond
13
13
  raise ArgumentError, "Expected AST::DefineRelation, got #{node.class}" unless node.is_a?(AST::DefineRelation)
14
14
  Diamond.validate_ident!(node.name, "table name")
15
15
 
16
- column_clauses = node.columns.select { |c| c.is_a?(AST::ColumnDefinition) }.map { |c| render_column(c) }
17
- fk_clauses = node.columns.select { |c| c.is_a?(AST::ForeignKey) }.map { |c| render_foreign_key(c) }
16
+ column_clauses = node.columns.select { |c| c.is_a?(AST::ColumnDefinition) }.map { |c| render_column(c) }
17
+ fk_clauses = node.columns.select { |c| c.is_a?(AST::ForeignKey) }.map { |c| render_foreign_key(c) }
18
+ constraint_clauses = node.columns.select { |c| c.is_a?(AST::TableConstraint) }.map { |c| render_table_constraint(c) }
18
19
 
19
- body = (column_clauses + fk_clauses).join(', ')
20
+ body = (column_clauses + fk_clauses + constraint_clauses).join(', ')
20
21
  sql = "CREATE TABLE IF NOT EXISTS #{node.name} (#{body})"
21
22
  [sql, []]
22
23
  end
@@ -77,6 +78,15 @@ module Diamond
77
78
  sql
78
79
  end
79
80
 
81
+ # Table-level constraint — currently only composite PRIMARY KEY.
82
+ # Emitted at the end of the CREATE TABLE body so column-level
83
+ # column definitions aren't shadowed.
84
+ def self.render_table_constraint(node)
85
+ raise ArgumentError, "Unknown table constraint kind: #{node.kind}" unless node.kind == :primary_key
86
+ node.columns.each { |c| Diamond.validate_ident!(c, "primary_key column") }
87
+ "PRIMARY KEY (#{node.columns.join(', ')})"
88
+ end
89
+
80
90
  def self.format_action(sym)
81
91
  sym.to_s.upcase.tr('_', ' ')
82
92
  end
@@ -193,9 +193,11 @@ module Diamond
193
193
  type = type_for_column(child_table, col)
194
194
  child_columns["#{child_table}.#{col}"] = { type: type }
195
195
  end
196
- # mark primary key for dedup
197
- pk = child_schema[:primary_key]
198
- child_columns["#{child_table}.#{pk}"][:identity] = true if pk
196
+ # mark primary key(s) for dedup
197
+ pks = child_schema[:primary_keys] || [child_schema[:primary_key]].compact
198
+ pks.each do |pk|
199
+ child_columns["#{child_table}.#{pk}"][:identity] = true
200
+ end
199
201
 
200
202
  # use :as alias as struct member name if provided, else table name
201
203
  member_key = (j.as || child_table).to_s
@@ -215,9 +217,9 @@ module Diamond
215
217
  end
216
218
  end
217
219
 
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
220
+ # parent primary key(s) for dedup at the parent level
221
+ pks = table.schema[:primary_keys] || [table.schema[:primary_key]].compact
222
+ pks.each { |pk| columns_spec[pk.to_s][:identity] = true }
221
223
 
222
224
  { columns: columns_spec }
223
225
  end
@@ -340,6 +342,11 @@ module Diamond
340
342
  when AST::Function
341
343
  args_str = node.args.map { |a| translate_node(a, params, prefix: prefix) }.join(', ')
342
344
  "#{node.name}(#{args_str})"
345
+ when AST::Star
346
+ # Star only appears as a function arg (e.g. COUNT(*)). A
347
+ # bare SELECT * projection is the table's full column set,
348
+ # which compiles elsewhere.
349
+ '*'
343
350
  when AST::WindowFunction
344
351
  args_str = node.args.map { |a| translate_node(a, params, prefix: prefix) }.join(', ')
345
352
  func_str = "#{node.func_name}(#{args_str})"
@@ -6,11 +6,16 @@ module Diamond
6
6
  # finalizer is built by ::make_finalizer so it never closes over the cursor
7
7
  # itself. rows arrive as arrays (positions match the SELECT order), avoiding
8
8
  # hash allocation per row.
9
+ #
10
+ # Pass `owns_stmt: false` when the statement comes from
11
+ # `Diamond.engine.prepared` (the engine cache owns the lifetime).
12
+ # Default is true (legacy callers prepared an ephemeral stmt).
9
13
  class Cursor
10
14
  include Enumerable
11
15
 
12
- def self.make_finalizer(stmt)
16
+ def self.make_finalizer(stmt, owns_stmt)
13
17
  ->(_id) {
18
+ next unless owns_stmt
14
19
  begin
15
20
  stmt.close unless stmt.closed?
16
21
  rescue StandardError
@@ -19,15 +24,16 @@ module Diamond
19
24
  }
20
25
  end
21
26
 
22
- def initialize(table, stmt, projected_columns)
27
+ def initialize(table, stmt, projected_columns, owns_stmt: true)
23
28
  @table = table
24
29
  @stmt = stmt
25
30
  @projected_columns = projected_columns
31
+ @owns_stmt = owns_stmt
26
32
  # Pre-resolve the struct class once: per-row work stays at
27
33
  # splat + new + freeze (see StructFactory.bulk path).
28
34
  @factory = Diamond::StructFactory.factory_for(table, projected_columns)
29
35
 
30
- ObjectSpace.define_finalizer(self, self.class.make_finalizer(@stmt))
36
+ ObjectSpace.define_finalizer(self, self.class.make_finalizer(@stmt, owns_stmt))
31
37
  end
32
38
 
33
39
  def each(&block)
@@ -41,10 +47,12 @@ module Diamond
41
47
  end
42
48
  end
43
49
  ensure
44
- begin
45
- @stmt.close unless @stmt.closed?
46
- rescue StandardError
47
- # ensure must not raise
50
+ if @owns_stmt
51
+ begin
52
+ @stmt.close unless @stmt.closed?
53
+ rescue StandardError
54
+ # ensure must not raise
55
+ end
48
56
  end
49
57
  end
50
58
  end
@@ -35,12 +35,7 @@ module Diamond
35
35
  end
36
36
  raise ArgumentError, "find requires an id" if id.nil?
37
37
 
38
- pk = _schema_for_dsl[:primary_key] || :id
39
- condition = Diamond::AST::Equality.new(
40
- Diamond::AST::Column.new(pk),
41
- Diamond::AST::Literal.new(id)
42
- )
43
- _build_where_node(condition)
38
+ _build_where_node(_pk_lookup_condition(id))
44
39
  end
45
40
 
46
41
  # like find, but raises RecordNotFound instead of returning an
@@ -337,8 +332,9 @@ module Diamond
337
332
  # stable even over user-ordered chains.
338
333
  def find_each(batch_size: 1000, &block)
339
334
  return to_enum(:find_each, batch_size: batch_size) unless block
340
- pk = _schema_for_dsl[:primary_key] || :id
341
- scope = _wrap.order(pk)
335
+ pks = _schema_for_dsl[:primary_keys] || []
336
+ pks = [pks.first || :id] if pks.empty?
337
+ scope = _wrap.order(*pks)
342
338
  offset = 0
343
339
  loop do
344
340
  batch = scope.limit(batch_size).offset(offset).materialize
@@ -409,6 +405,69 @@ module Diamond
409
405
  new_value
410
406
  end
411
407
 
408
+ # Builds the WHERE clause for a PK lookup.
409
+ # Single PK + scalar → pk = id (one Equality)
410
+ # Composite PK + Array → pk1 = a AND pk2 = b ...
411
+ # Composite PK + Hash → pk1 = v1 AND pk2 = v2 ...
412
+ # Anything else (wrong arity, mixed scalar+array, mixed
413
+ # single+composite) raises. Returns an AST::Equality node for
414
+ # single, or an AST::And chain for composite.
415
+ def _pk_lookup_condition(id)
416
+ pks = _schema_for_dsl[:primary_keys] || []
417
+ # Back-compat with older schemas that lack :primary_keys.
418
+ pks = [pks.first || :id] if pks.empty?
419
+
420
+ if pks.size == 1
421
+ unless !id.is_a?(Array) && !id.is_a?(Hash)
422
+ raise ArgumentError,
423
+ "find on a single-PK table expects a scalar id, got #{id.inspect}"
424
+ end
425
+ Diamond::AST::Equality.new(
426
+ Diamond::AST::Column.new(pks.first),
427
+ Diamond::AST::Literal.new(id)
428
+ )
429
+ else
430
+ pairs =
431
+ case id
432
+ when Array
433
+ if id.size != pks.size
434
+ raise ArgumentError,
435
+ "find(#{id.inspect}) expects #{pks.size} values for composite PK #{pks.inspect}"
436
+ end
437
+ pks.zip(id)
438
+ when Hash
439
+ # hash form: { pk_col => value, ... }. Missing keys raise.
440
+ missing = pks - id.keys
441
+ unless missing.empty?
442
+ raise ArgumentError,
443
+ "find(#{id.inspect}) missing PK column(s): #{missing.inspect}"
444
+ end
445
+ extra = id.keys - pks
446
+ unless extra.empty?
447
+ raise ArgumentError,
448
+ "find(#{id.inspect}) has keys outside the PK columns #{pks.inspect}: #{extra.inspect}"
449
+ end
450
+ pks.map { |pk| [pk, id[pk]] }
451
+ else
452
+ raise ArgumentError,
453
+ "find on a composite-PK table expects an Array or Hash, got #{id.inspect}"
454
+ end
455
+
456
+ # each_with_index yields [element, index]; destructure into
457
+ # (pair, idx) so pair gets the (col, val) tuple — then
458
+ # destructure pair into (col, val) for the Equality.
459
+ pairs.each_with_index.inject(nil) do |acc, (pair, _idx)|
460
+ col, val = pair
461
+ eq = Diamond::AST::Equality.new(
462
+ Diamond::AST::Column.new(col),
463
+ Diamond::AST::Literal.new(val)
464
+ )
465
+ acc.nil? ? eq : Diamond::AST::And.new(acc, eq)
466
+ end
467
+ end
468
+ end
469
+ private :_pk_lookup_condition
470
+
412
471
  private
413
472
 
414
473
  def _wrap
@@ -181,7 +181,7 @@ module Diamond
181
181
  nullable = {}
182
182
  defaults = {}
183
183
  required = {}
184
- primary_key = nil
184
+ pk_positions = [] # ordered, parallel to PRAGMA row order
185
185
 
186
186
  @db.query("PRAGMA table_info(#{Diamond.quote_ident(table_name)})").each do |col|
187
187
  col_name = col[:name].to_sym
@@ -199,16 +199,28 @@ module Diamond
199
199
  # A column is required when (NOT NULL OR pk > 0) AND has no default.
200
200
  required[col_name] =
201
201
  (col[:notnull] != 0 || col[:pk] > 0) && col[:dflt_value].nil?
202
- primary_key = col_name if col[:pk] == 1
202
+ # PRAGMA table_info returns :pk as the column's POSITION in the
203
+ # primary key (1, 2, 3, ...). Single-PK tables always have one
204
+ # row with pk=1; composite PKs return multiple rows with pk>0.
205
+ # Capture each in declaration order to preserve the composite
206
+ # tuple shape.
207
+ pk_positions << [col[:pk], col_name] if col[:pk] > 0
203
208
  end
204
209
 
210
+ pk_positions.sort_by!(&:first)
211
+ primary_keys = pk_positions.map(&:last)
212
+ # Back-compat: `primary_key` (singular) is the first column of a
213
+ # composite PK, or nil for PK-less tables.
214
+ primary_key = primary_keys.first
215
+
205
216
  {
206
217
  columns: columns,
207
218
  types: types,
208
219
  nullable: nullable,
209
220
  defaults: defaults,
210
221
  required: required,
211
- primary_key: primary_key
222
+ primary_key: primary_key,
223
+ primary_keys: primary_keys
212
224
  }
213
225
  end
214
226
 
@@ -4,7 +4,7 @@ module Diamond
4
4
 
5
5
  def initialize(name = nil)
6
6
  @name = name
7
- @schema = { columns: [], types: {}, primary_key: nil }
7
+ @schema = { columns: [], types: {}, primary_key: nil, primary_keys: [] }
8
8
  end
9
9
  end
10
10
  end
@@ -387,9 +387,14 @@ module Diamond
387
387
  @statements << AST::ColumnDefinition.new(name, type, opts)
388
388
  end
389
389
 
390
- def primary_key(name)
391
- check_name!(name, "primary_key")
392
- @statements << AST::ColumnDefinition.new(name, Integer, primary_key: true, nullable: false)
390
+ def primary_key(*names)
391
+ raise ArgumentError, "primary_key requires at least one column" if names.empty?
392
+ names.each { |n| check_name!(n, "primary_key") }
393
+ if names.size == 1
394
+ @statements << AST::ColumnDefinition.new(names.first, Integer, primary_key: true, nullable: false)
395
+ else
396
+ @statements << AST::TableConstraint.new(kind: :primary_key, columns: names)
397
+ end
393
398
  end
394
399
 
395
400
  def foreign_key(local, ref_table, ref_col = :id, on_delete: nil, on_update: nil)
@@ -438,8 +438,13 @@ module Diamond
438
438
  AST::ColumnDefinition.new(name, type, kwargs)
439
439
  when :primary_key
440
440
  raise "primary_key requires a name argument" if positional.empty?
441
- name = symbol_value(positional[0])
442
- AST::ColumnDefinition.new(name, Integer, primary_key: true, nullable: false)
441
+ names = positional.map { |a| symbol_value(a) }
442
+ names.each { |n| Diamond.validate_ident!(n, "primary_key column") }
443
+ if names.size == 1
444
+ AST::ColumnDefinition.new(names.first, Integer, primary_key: true, nullable: false)
445
+ else
446
+ AST::TableConstraint.new(kind: :primary_key, columns: names)
447
+ end
443
448
  when :foreign_key
444
449
  raise "foreign_key requires local column and ref table" if positional.size < 2
445
450
  local = symbol_value(positional[0])
@@ -94,7 +94,7 @@ module Diamond
94
94
  end
95
95
 
96
96
  def first(n = 1)
97
- scope = has_order? ? self : order(resolve_pk!)
97
+ scope = has_order? ? self : order(*resolve_pks!)
98
98
  results = scope.limit(n).materialize
99
99
  n == 1 ? results.first : results
100
100
  end
@@ -115,7 +115,7 @@ module Diamond
115
115
  return
116
116
  end
117
117
 
118
- stmt = Diamond.engine.db.prepare_array(compiled_sql)
118
+ stmt = Diamond.engine.prepared(compiled_sql, :array)
119
119
  stmt.bind(*compiled_params)
120
120
 
121
121
  union_node = @ast.find { |n| n.is_a?(AST::Union) }
@@ -123,13 +123,15 @@ module Diamond
123
123
  projection_node = scope_ast.find { |n| n.is_a?(AST::Projection) }
124
124
  projected_columns = projection_node&.columns
125
125
 
126
- cursor = Diamond::Cursor.new(@table, stmt, projected_columns)
126
+ cursor = Diamond::Cursor.new(@table, stmt, projected_columns, owns_stmt: false)
127
127
 
128
128
  cursor.each(&block)
129
129
  end
130
130
 
131
131
  def last(n = 1)
132
- scope = has_order? ? self : order([resolve_pk!, :desc])
132
+ pks = resolve_pks!
133
+ desc_orders = pks.map { |pk| [pk, :desc] }
134
+ scope = has_order? ? self : order(*desc_orders)
133
135
  results = scope.limit(n).materialize
134
136
  n == 1 ? results.first : results.reverse
135
137
  end
@@ -159,8 +161,17 @@ module Diamond
159
161
  end
160
162
 
161
163
  # primary-key values. `Users.ids` ~ `SELECT id FROM users`.
164
+ # Raises on composite-PK tables since the flat-array shape is
165
+ # ambiguous for tuples — use `pluck(*primary_keys)` to get the
166
+ # full set as `[ [a1, b1], [a2, b2], ... ]` or per-column pluck.
162
167
  def ids
163
- pluck(resolve_pk!)
168
+ pks = @table.schema[:primary_keys] || []
169
+ if pks.size > 1
170
+ raise ArgumentError,
171
+ "ids() is not supported on composite-PK tables " \
172
+ "(#{pks.inspect}); use pluck(*primary_keys) instead"
173
+ end
174
+ pluck(pks.first || :id)
164
175
  end
165
176
 
166
177
  # Zero-translation JSON fast path. Pushes serialization into
@@ -267,10 +278,37 @@ module Diamond
267
278
  end
268
279
 
269
280
  def count(column = nil)
270
- col = column.nil? ? resolve_pk! : validate_aggregate_column!(column)
271
- nodes = [AST::Function.new(:count, [AST::Column.new(col)])]
281
+ # Tier 3a: bare-table fast path. COUNT on a trivial scope becomes
282
+ # a one-shot `SELECT COUNT(...) FROM <table>` with no AST /
283
+ # Projection / struct overhead — closes the 50x gap to Extralite.
284
+ #
285
+ # SQLite has a `COUNT(*)` optimization that uses internal table
286
+ # stats; `COUNT(non_null_col)` forces a full row scan. For the
287
+ # no-column form (which the existing AST path translates to
288
+ # COUNT(pk_col) for single-PK and COUNT(*) for composite-PK), we
289
+ # always emit COUNT(*) since it gives the same answer for any
290
+ # row-count intent and gets the optimization.
291
+ if trivial_scope?
292
+ return fast_aggregate(:count, '*', 0) if column.nil?
293
+ return fast_aggregate(:count, validate_aggregate_column!(column), 0)
294
+ end
295
+
296
+ if column.nil?
297
+ if @table.schema[:primary_keys]&.size.to_i > 1
298
+ nodes = [AST::Function.new(:count, [AST::Star.new])]
299
+ member_name = :count_star
300
+ else
301
+ col = resolve_pk!
302
+ nodes = [AST::Function.new(:count, [AST::Column.new(col)])]
303
+ member_name = :"count_#{col}"
304
+ end
305
+ else
306
+ col = validate_aggregate_column!(column)
307
+ nodes = [AST::Function.new(:count, [AST::Column.new(col)])]
308
+ member_name = :"count_#{col}"
309
+ end
272
310
  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}")
311
+ Diamond::QueryObject.new(@table, filtered).materialize.first.public_send(member_name)
274
312
  end
275
313
 
276
314
  # Scalar aggregate terminals. Each builds one Function projection
@@ -287,18 +325,27 @@ module Diamond
287
325
  return enumerable_fallback(:sum) if column.nil?
288
326
  return enumerable_fallback(:sum, column) unless column.is_a?(Symbol) || column.is_a?(String)
289
327
 
328
+ # Tier 3a: trivial scope → direct SQL, skip AST/struct.
329
+ return fast_aggregate(:sum, column, 0) if trivial_scope?
330
+
290
331
  aggregate(:sum, column)
291
332
  end
292
333
 
293
334
  def minimum(column)
335
+ return fast_aggregate(:min, column, nil) if trivial_scope?
336
+
294
337
  aggregate(:min, column)
295
338
  end
296
339
 
297
340
  def maximum(column)
341
+ return fast_aggregate(:max, column, nil) if trivial_scope?
342
+
298
343
  aggregate(:max, column)
299
344
  end
300
345
 
301
346
  def average(column)
347
+ return fast_aggregate(:avg, column, nil) if trivial_scope?
348
+
302
349
  aggregate(:avg, column)
303
350
  end
304
351
 
@@ -404,19 +451,13 @@ module Diamond
404
451
  end
405
452
 
406
453
  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
454
+ # NOTE: statement comes from the engine's shared prepared cache —
455
+ # do NOT close it. Cache lifetime is the engine's.
456
+ stmt = Diamond.engine.prepared(sql, :array)
457
+ stmt.bind(*params)
458
+ found = nil
459
+ stmt.each { |row| found = row; break }
460
+ found
420
461
  end
421
462
 
422
463
  # one Function projection (SUM(age) -> sum_age), scalar back.
@@ -448,6 +489,48 @@ module Diamond
448
489
  @ast.any? { |n| n.is_a?(AST::Order) }
449
490
  end
450
491
 
492
+ # Tier 3a: bare-table predicate. True when the current scope has no
493
+ # modifiers that would require the AST/compile path. Adding a new
494
+ # AST node that affects aggregates? Add it here too.
495
+ def trivial_scope?
496
+ @ast.none? do |n|
497
+ n.is_a?(AST::Projection) ||
498
+ n.is_a?(AST::Where) ||
499
+ n.is_a?(AST::Order) ||
500
+ n.is_a?(AST::Group) ||
501
+ n.is_a?(AST::Having) ||
502
+ n.is_a?(AST::Join) ||
503
+ n.is_a?(AST::Limit) ||
504
+ n.is_a?(AST::Offset) ||
505
+ n.is_a?(AST::Distinct) ||
506
+ n.is_a?(AST::Union) ||
507
+ n.is_a?(AST::With)
508
+ end
509
+ end
510
+
511
+ # Tier 3a: direct SQL aggregate over a trivial scope. Skips AST,
512
+ # compile, projection, struct — just emits `SELECT <FUNC>(<arg>)
513
+ # FROM <table>` and reads the scalar. `null_default` is returned
514
+ # when the result is NULL (empty scope): 0 for "row count-like"
515
+ # aggregates (count/sum/avg), nil for min/max.
516
+ #
517
+ # column may be:
518
+ # - nil → use '*' (COUNT(*) only — sum/min/max/avg pass a column)
519
+ # - a Symbol → validated against the schema
520
+ # - the String '*' → passed through (used by count(*) fast path)
521
+ def fast_aggregate(func, column, null_default)
522
+ arg =
523
+ if column.nil? || column == :* || column == '*'
524
+ '*'
525
+ else
526
+ validate_aggregate_column!(column).to_s
527
+ end
528
+ sql = "SELECT #{func.to_s.upcase}(#{arg}) FROM #{Diamond.quote_ident(@table.name)}"
529
+ result = Diamond.engine.db.query_single_splat(sql)
530
+ result.nil? ? null_default : result
531
+ end
532
+ private :fast_aggregate
533
+
451
534
  # pk or :id, and it better exist. fail here with a column error
452
535
  # instead of letting sqlite complain about COUNT(missing). Also
453
536
  # the implicit ORDER BY column for first/last.
@@ -459,5 +542,26 @@ module Diamond
459
542
  pk
460
543
  end
461
544
 
545
+ # All PK columns, in declaration order. Single-PK tables return a
546
+ # one-element array. Used by composite-aware paths (first/last,
547
+ # find_each, dedup, count). Each column is validated via
548
+ # resolve_pk!'s check so composite typos fail loudly.
549
+ def resolve_pks!
550
+ pks = @table.schema[:primary_keys]
551
+ if pks.nil? || pks.empty?
552
+ pk = :id
553
+ unless @table.schema[:columns].include?(pk)
554
+ raise Diamond::UnknownColumnError.build(@table.schema, pk)
555
+ end
556
+ return [pk]
557
+ end
558
+ pks.each do |pk|
559
+ unless @table.schema[:columns].include?(pk)
560
+ raise Diamond::UnknownColumnError.build(@table.schema, pk)
561
+ end
562
+ end
563
+ pks
564
+ end
565
+
462
566
  end
463
567
  end
@@ -91,7 +91,7 @@ module Diamond
91
91
  member_name
92
92
  end
93
93
  child_schema = Diamond.engine.schema_cache[child_table_sym]
94
- child_pk = child_schema[:primary_key]
94
+ child_pks = child_schema[:primary_keys] || [child_schema[:primary_key]].compact
95
95
 
96
96
  # strip table. prefix from each child row's keys
97
97
  stripped = value.map do |child_row|
@@ -105,15 +105,16 @@ module Diamond
105
105
  # children after a LEFT JOIN)
106
106
  stripped.reject! { |child_row| child_row.values.all?(&:nil?) }
107
107
 
108
- # dedupe by primary key
109
- if child_pk
108
+ # dedupe by primary key (single value for single-PK, tuple
109
+ # for composite-PK so two PK columns together identify a row).
110
+ unless child_pks.empty?
110
111
  seen = {}
111
112
  stripped = stripped.reject do |r|
112
- pk_val = r[child_pk]
113
- if seen[pk_val]
113
+ pk_key = child_pks.size == 1 ? r[child_pks.first] : child_pks.map { |pk| r[pk] }
114
+ if seen[pk_key]
114
115
  true
115
116
  else
116
- seen[pk_val] = true
117
+ seen[pk_key] = true
117
118
  false
118
119
  end
119
120
  end
@@ -241,6 +242,7 @@ module Diamond
241
242
  first_arg = node.args.first
242
243
  suffix = case first_arg
243
244
  when Diamond::AST::Column then first_arg.name
245
+ when Diamond::AST::Star then 'star'
244
246
  when Diamond::AST::Literal
245
247
  val = first_arg.value
246
248
  val.is_a?(String) ? val : (val.nil? ? 'nil' : val.to_s)
data/lib/diamond/table.rb CHANGED
@@ -22,6 +22,13 @@ module Diamond
22
22
  @schema[:primary_key]
23
23
  end
24
24
 
25
+ # ordered list of primary-key columns. Empty for PK-less tables,
26
+ # one entry for single-PK, multiple entries for composite PKs (in
27
+ # the order SQLite stores them — typically declaration order).
28
+ def primary_keys
29
+ @schema[:primary_keys] || []
30
+ end
31
+
25
32
  # Sequel-style sugar: Users[1] → find(1).first. Returns the frozen
26
33
  # struct, or nil when no row matches (Hash-like miss semantics —
27
34
  # use find! when you want RecordNotFound instead).
@@ -1,3 +1,3 @@
1
1
  module Diamond
2
- VERSION = "0.1.0"
2
+ VERSION = "0.1.1"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: diamond-orm
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - GunsOrigins
@@ -89,7 +89,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
89
89
  - !ruby/object:Gem::Version
90
90
  version: '0'
91
91
  requirements: []
92
- rubygems_version: 4.0.16
92
+ rubygems_version: 4.0.21
93
93
  specification_version: 4
94
94
  summary: Diamond, a relational algebra library for SQLite.
95
95
  test_files: []