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 +4 -4
- data/lib/diamond/api_catalog.rb +20 -14
- data/lib/diamond/ast.rb +17 -0
- data/lib/diamond/compiler/ddl.rb +13 -3
- data/lib/diamond/compiler/dql.rb +13 -6
- data/lib/diamond/cursor.rb +15 -7
- data/lib/diamond/dsl/default.rb +67 -8
- data/lib/diamond/engine.rb +15 -3
- data/lib/diamond/null_table.rb +1 -1
- data/lib/diamond/parser/proxy.rb +8 -3
- data/lib/diamond/parser.rb +7 -2
- data/lib/diamond/query_object.rb +125 -21
- data/lib/diamond/struct_factory.rb +8 -6
- data/lib/diamond/table.rb +7 -0
- data/lib/diamond/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 12bd9185ce557de9f864bbdeb245b70015b498f3cf2bab68a9d780b4b2e8dbe6
|
|
4
|
+
data.tar.gz: 6e8acbdebdd733244718aa6e4ba5b03d66832ce429f0eee177dba8d743757daf
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c867fbbf67eefd91a8c90f3e6c803f6ff883caee436d22883d1bb52c785c4f0f4f4ea2e689fc0a46c5466f3eabd696008bd9b4a9b4a1da654710fcde60c7df55
|
|
7
|
+
data.tar.gz: fb00be24993be996c4f40499cb3407e549da03070bafa7efae21da287e89c0c74750939a491879ade554aa066a7c0caa431423cf13c64a2a3264984672ab7e2a
|
data/lib/diamond/api_catalog.rb
CHANGED
|
@@ -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' => '
|
|
544
|
-
'find!' => '
|
|
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;
|
|
572
|
-
'decrement!' => 'issues a SQL UPDATE;
|
|
573
|
-
'toggle!' => 'reads-then-writes
|
|
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: '
|
|
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-
|
|
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
|
data/lib/diamond/compiler/ddl.rb
CHANGED
|
@@ -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
|
|
17
|
-
fk_clauses
|
|
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
|
data/lib/diamond/compiler/dql.rb
CHANGED
|
@@ -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
|
-
|
|
198
|
-
|
|
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
|
-
|
|
220
|
-
columns_spec[pk.to_s][:identity] = true
|
|
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})"
|
data/lib/diamond/cursor.rb
CHANGED
|
@@ -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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
data/lib/diamond/dsl/default.rb
CHANGED
|
@@ -35,12 +35,7 @@ module Diamond
|
|
|
35
35
|
end
|
|
36
36
|
raise ArgumentError, "find requires an id" if id.nil?
|
|
37
37
|
|
|
38
|
-
|
|
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
|
-
|
|
341
|
-
|
|
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
|
data/lib/diamond/engine.rb
CHANGED
|
@@ -181,7 +181,7 @@ module Diamond
|
|
|
181
181
|
nullable = {}
|
|
182
182
|
defaults = {}
|
|
183
183
|
required = {}
|
|
184
|
-
|
|
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
|
-
|
|
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
|
|
data/lib/diamond/null_table.rb
CHANGED
data/lib/diamond/parser/proxy.rb
CHANGED
|
@@ -387,9 +387,14 @@ module Diamond
|
|
|
387
387
|
@statements << AST::ColumnDefinition.new(name, type, opts)
|
|
388
388
|
end
|
|
389
389
|
|
|
390
|
-
def primary_key(
|
|
391
|
-
|
|
392
|
-
|
|
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)
|
data/lib/diamond/parser.rb
CHANGED
|
@@ -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
|
-
|
|
442
|
-
|
|
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])
|
data/lib/diamond/query_object.rb
CHANGED
|
@@ -94,7 +94,7 @@ module Diamond
|
|
|
94
94
|
end
|
|
95
95
|
|
|
96
96
|
def first(n = 1)
|
|
97
|
-
scope = has_order? ? self : order(
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
271
|
-
|
|
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(
|
|
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
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
113
|
-
if seen[
|
|
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[
|
|
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).
|
data/lib/diamond/version.rb
CHANGED
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.
|
|
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.
|
|
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: []
|