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,676 @@
1
+ # gems/diamond/lib/diamond/api_catalog.rb — hand-maintained call-form
2
+ # catalog backing bin/dump_api.
3
+ #
4
+ # VARIATIONS is { method_name => Array<String> }. Each string is a
5
+ # compact example call (no trailing meaning; the user knows). Order is
6
+ # pedagogical: simplest → most specific, with block forms before hash
7
+ # forms where both exist. Methods with no VARIATIONS entry fall back to
8
+ # `Users.<method>` placeholder.
9
+ #
10
+ # SQL is { method_name => { example => sql, ... } } for forms that
11
+ # compile to a deterministic short SQL (no DB boot; static strings only).
12
+ # Block forms and value-dependent ones are intentionally omitted from SQL.
13
+ #
14
+ # Update contract: when a method gains a new accepted call form, add
15
+ # the example here and run `rake api:check`. CI fails if a frozen
16
+ # surface method is missing from VARIATIONS or if SQL keys reference
17
+ # unknown examples.
18
+ module Diamond
19
+ module ApiCatalog
20
+ VARIATIONS = {
21
+ # --- Filtering ---
22
+ 'where' => [
23
+ # block: bare comparisons
24
+ 'Users.where { age > 10 }',
25
+ 'Users.where { id == 1 }',
26
+ 'Users.where { id != 5 }',
27
+ 'Users.where { name == "Arle" }',
28
+ 'Users.where { age >= 25 }',
29
+ # block: logical
30
+ 'Users.where { (age > 15) & (name == "Arle") }',
31
+ 'Users.where { (age > 15) && (name == "Arle") }',
32
+ # block: membership
33
+ 'Users.where { id.in(1, 2) }',
34
+ 'Users.where { id.in(5) }',
35
+ 'Users.where { id.in() }',
36
+ 'Users.where { id == [1, 2] }',
37
+ 'Users.where { id == [] }',
38
+ 'Users.where { id != [1, 2] }',
39
+ 'Users.where { id != [] }',
40
+ # block: range
41
+ 'Users.where { age.between?(16, 25) }',
42
+ 'Users.where { age.between?(10, 30) }',
43
+ # block: null
44
+ 'Users.where { parent_id == nil }',
45
+ 'Users.where { parent_id != nil }',
46
+ # block: pattern (LIKE)
47
+ 'Users.where { name =~ /a+/ }',
48
+ 'Users.where { name =~ /^Ar/ }',
49
+ 'Users.where { name =~ /a{2,3}/ }',
50
+ 'Users.where { name =~ /colou?r/ }',
51
+ 'Users.where { name =~ /^A.le$/ }',
52
+ 'Users.where { name =~ /foo|bar/ }',
53
+ 'Users.where { name =~ /(foo)/ }',
54
+ # block: qualified (join first)
55
+ 'Users.where { posts.title == "hi" }',
56
+ 'Users.where { tags.tag == "x" }',
57
+ # hash: AND scalars
58
+ 'Users.where(name: "Arle")',
59
+ 'Users.where(name: "Arle", age: 16)',
60
+ # hash: operators
61
+ 'Users.where(age: { gt: 10 })',
62
+ 'Users.where(age: { gte: 18, lte: 65 })',
63
+ 'Users.where(age: { lt: 100 })',
64
+ 'Users.where(age: { lte: 0 })',
65
+ 'Users.where(age: { in: [16, 25] })',
66
+ 'Users.where(age: { nin: [16, 25] })',
67
+ 'Users.where(name: { not: "Arle" })',
68
+ 'Users.where(name: { like: "A%" })',
69
+ # hash: range, array, nil
70
+ 'Users.where(age: 16..20)',
71
+ 'Users.where(age: 20..150)',
72
+ 'Users.where(age: [16, 25])',
73
+ 'Users.where(age: [])',
74
+ 'Users.where(name: nil)',
75
+ # hash: array of hashes → OR
76
+ 'Users.where([{ age: 16 }, { age: 25 }])',
77
+ 'Users.where([{ name: "Arle" }, { age: 100 }])',
78
+ 'Users.where([{ age: { gt: 100 } }, { name: { in: %w[Arle Sig] } }])'
79
+ ],
80
+ 'or' => [
81
+ 'Users.where { name == "Arle" }.or { age > 20 }',
82
+ 'Users.where(name: "Arle").or { age > 20 }'
83
+ ],
84
+ 'find' => [
85
+ 'Users.find(1)',
86
+ 'Users.find(1).name',
87
+ 'Users.find(99999).name',
88
+ 'Users.find { |r| r.age > 20 }',
89
+ 'Widgets.find(some_uuid)' # uses custom primary_key
90
+ ],
91
+ 'find!' => [
92
+ 'Users.find!(1).name',
93
+ 'Users.find!(999)' # raises RecordNotFound
94
+ ],
95
+ 'where_in' => [
96
+ 'Users.where_in(:id, Posts.derive(:user_id))',
97
+ 'Users.where_in(:id, subquery)',
98
+ 'Users.where_in(:id, subquery).includes(:posts)'
99
+ ],
100
+ 'by_*' => [
101
+ 'Users.by_name("Sig")',
102
+ 'Users.by_name_and_age("Sig", 25)',
103
+ 'Users.by_name("Arle").derive(:age)'
104
+ ],
105
+ 'find_by' => [
106
+ 'Users.find_by(name: "Sig")',
107
+ 'Users.find_by(name: "Nope")'
108
+ ],
109
+ 'find_or_create_by' => [
110
+ 'Users.find_or_create_by(name: "New", age: 5)'
111
+ ],
112
+
113
+ # --- Shaping ---
114
+ 'derive' => [
115
+ 'Users.derive(:name)',
116
+ 'Users.derive(:name, :age)',
117
+ 'Users.derive { name }',
118
+ 'Users.derive { age }',
119
+ 'Users.derive { id; name }',
120
+ 'Users.derive { count(id) }',
121
+ 'Users.derive { id; count(age) }',
122
+ 'Users.derive { count(id); max(age); min(age) }',
123
+ 'Users.derive { row_number.over(order: :id) }',
124
+ 'Users.derive { row_number.over(partition_by: :age, order: :id) }',
125
+ 'Users.derive([:tags, :tag])',
126
+ 'Users.derive(:title, [:tags, :tag])'
127
+ ],
128
+ 'join' => [
129
+ 'Users.join(:posts)',
130
+ 'Users.join(:posts, on: { id: :user_id })',
131
+ 'Users.join(:posts, on: { id: :user_id }, type: :left)',
132
+ 'Users.join(:order_items, on: { user_id: :id })',
133
+ 'Users.join(:posts, eager: true)',
134
+ 'Users.join(:categories)' # raises TableNotFound
135
+ ],
136
+ 'includes' => [
137
+ 'Users.includes(:posts)',
138
+ 'Users.includes(:posts, :comments)',
139
+ 'Users.includes(:posts).group(:age)'
140
+ ],
141
+ 'order' => [
142
+ 'Users.order(:age)',
143
+ 'Users.order(:name, :age)',
144
+ 'Users.order(age: :desc)',
145
+ 'Users.order(name: :asc, age: :desc)',
146
+ 'Users.order(:name, age: :desc)',
147
+ 'Users.order([:age, :desc])',
148
+ 'Users.order([:tags, :tag])',
149
+ 'Users.order([:tags, :tag, :desc])',
150
+ 'Users.order(:a).order(:b)'
151
+ ],
152
+ 'reorder' => [
153
+ 'Users.order(:age).reorder(:name)',
154
+ 'Users.order(:age).reorder(age: :desc)',
155
+ 'Users.order(:age).reorder'
156
+ ],
157
+ 'limit' => [
158
+ 'Users.limit(5)',
159
+ 'Users.limit(5).limit(10)',
160
+ 'Users.limit(-1)' # raises
161
+ ],
162
+ 'offset' => [
163
+ 'Users.offset(2)',
164
+ 'Users.offset(-5)' # raises
165
+ ],
166
+ 'distinct' => [
167
+ 'Users.derive(:name).distinct'
168
+ ],
169
+ 'group' => [
170
+ 'Users.group(:age)',
171
+ 'Users.group(:age, :name)',
172
+ 'Users.group([:tags, :tag])'
173
+ ],
174
+ 'having' => [
175
+ 'Users.group(:age).having { count(id) > 1 }'
176
+ ],
177
+ 'union' => [
178
+ 'Users.where(age: 16).union(Users.where(age: 25))',
179
+ 'Users.where { age > 18 }.union(Users.where { age < 5 })'
180
+ ],
181
+ 'from_cte' => [
182
+ 'Categories.from_cte(:tree)'
183
+ ],
184
+
185
+ # --- Terminals ---
186
+ 'materialize' => [
187
+ 'Users.where { age > 15 }.materialize',
188
+ 'Users.order(:id).materialize'
189
+ ],
190
+ 'first' => [
191
+ 'Users.first',
192
+ 'Users.first(2)',
193
+ 'Users.first(1)',
194
+ 'Users.order(:age).first',
195
+ 'Users.first(2).map(&:name)'
196
+ ],
197
+ 'first!' => [
198
+ 'Users.first!',
199
+ 'Users.where(age: 999).first!' # raises
200
+ ],
201
+ 'last' => [
202
+ 'Users.last',
203
+ 'Users.last(2)'
204
+ ],
205
+ 'last!' => [
206
+ 'Users.last!',
207
+ 'Users.where(age: 999).last!' # raises
208
+ ],
209
+ 'sole' => [
210
+ 'Users.where(age: 16).sole',
211
+ 'Users.where(age: 999).sole' # raises
212
+ ],
213
+ 'head' => [
214
+ 'Users.head',
215
+ 'Users.head(2)'
216
+ ],
217
+ 'all' => [
218
+ 'Users.all',
219
+ 'Users.all.where { age > 15 }',
220
+ 'Users.all.order(:id).materialize'
221
+ ],
222
+ 'all!' => [
223
+ 'Users.all!',
224
+ 'Users.all.where { age > 15 }.all!',
225
+ 'Users.all!.size'
226
+ ],
227
+ 'each' => [
228
+ 'Users.each { |r| puts r.name }',
229
+ 'Users.each',
230
+ 'Users.includes(:posts).each { |u| collected << u }'
231
+ ],
232
+ 'find_each' => [
233
+ 'Users.find_each(batch_size: 1000) { |r| puts r.name }',
234
+ 'Users.find_each(batch_size: 1000)'
235
+ ],
236
+ 'count' => [
237
+ 'Users.count',
238
+ 'Users.count(:age)',
239
+ 'Users.where(age: 16).count'
240
+ ],
241
+ 'sum' => [
242
+ 'Users.sum(:age)',
243
+ 'Users.where(age: 16).sum(:age)',
244
+ 'Users.where(age: 999).sum(:age)', # nil
245
+ 'Users.sum { |r| r.age }'
246
+ ],
247
+ 'minimum' => [
248
+ 'Users.minimum(:age)',
249
+ 'Users.where(age: 999).minimum(:age)' # nil
250
+ ],
251
+ 'maximum' => [
252
+ 'Users.maximum(:age)'
253
+ ],
254
+ 'average' => [
255
+ 'Users.average(:age)',
256
+ 'Users.where(age: 999).average(:age)' # nil
257
+ ],
258
+ 'avg' => [
259
+ 'Users.avg(:age)'
260
+ ],
261
+ 'min' => [
262
+ 'Users.min(:age)',
263
+ 'Users.min',
264
+ 'Users.min(2)',
265
+ 'Users.min { |a, b| a <=> b }'
266
+ ],
267
+ 'max' => [
268
+ 'Users.max(:age)',
269
+ 'Users.max',
270
+ 'Users.max(2)',
271
+ 'Users.max { |a, b| a <=> b }'
272
+ ],
273
+ 'pluck' => [
274
+ 'Users.pluck(:name)',
275
+ 'Users.pluck(:name, :age)',
276
+ 'Users.where(age: 16).pluck(:name)',
277
+ 'Users.order(:id).pluck(:name)',
278
+ 'Users.order(:id).pluck(:name, :age)'
279
+ ],
280
+ 'pick' => [
281
+ 'Users.where(age: 16).pick(:name)',
282
+ 'Users.where(age: 16).pick(:age)',
283
+ 'Users.where(age: 16).pick(:name, :age)',
284
+ 'Users.where(age: 999).pick(:name)' # nil
285
+ ],
286
+ 'ids' => [
287
+ 'Users.ids',
288
+ 'Users.where(age: 16).ids'
289
+ ],
290
+ 'exists?' => [
291
+ 'Users.exists?',
292
+ 'Users.exists?(1)',
293
+ 'Users.exists?(999)',
294
+ 'Users.exists?(name: "Sig")',
295
+ 'Users.exists?(name: "Nope")',
296
+ 'Users.where(age: 25).exists?'
297
+ ],
298
+ 'any?' => [
299
+ 'Users.any?',
300
+ 'Users.where(age: 999).any?',
301
+ 'Users.any? { |r| r.age > 20 }'
302
+ ],
303
+ 'none?' => [
304
+ 'Users.none?',
305
+ 'Users.where(age: 999).none?',
306
+ 'Users.none? { |r| r.age > 20 }'
307
+ ],
308
+ 'empty?' => [
309
+ 'Users.empty?',
310
+ 'Users.where(age: 999).empty?'
311
+ ],
312
+ 'as_hash' => [
313
+ 'Users.where(age: 16).as_hash.materialize'
314
+ ],
315
+ 'as_array' => [
316
+ 'Users.where(age: 16).as_array.materialize'
317
+ ],
318
+ 'as_splat' => [
319
+ 'Users.derive(:name).as_splat.materialize'
320
+ ],
321
+ 'to_json_array' => [
322
+ 'Users.to_json_array(:name)',
323
+ 'Users.order(:id).to_json_array(:id)',
324
+ 'Users.order(:id).to_json_array(:id, :name, :age)',
325
+ 'Users.order(:id).to_json_array',
326
+ 'Users.order(:id).to_json_array(:name, :id)',
327
+ 'Users.order(id: :desc).limit(2).to_json_array(:id, :name)'
328
+ ],
329
+ 'to_sql' => [
330
+ 'Users.where(age: 16).to_sql',
331
+ 'Users.order(:age).reorder(:name).to_sql',
332
+ 'Users.order(:age).reorder.to_sql'
333
+ ],
334
+ 'explain' => [
335
+ 'Users.where(age: 16).explain'
336
+ ],
337
+ 'ast_tree' => [
338
+ 'puts Users.where(age: 16).ast_tree'
339
+ ],
340
+ '[]' => [
341
+ 'Users[1].name',
342
+ 'Users[999]', # nil on miss
343
+ 'Widgets[some_uuid]' # custom primary_key
344
+ ],
345
+ 'columns' => [
346
+ 'Users.columns'
347
+ ],
348
+ 'primary_key' => [
349
+ 'Users.primary_key'
350
+ ],
351
+ 'name' => [
352
+ 'Users.name'
353
+ ],
354
+ 'schema' => [
355
+ 'Users.schema[:columns]'
356
+ ],
357
+
358
+ # --- Writes ---
359
+ 'create' => [
360
+ 'Users.create(id: 1, name: "Arle", age: 16)',
361
+ 'Users.create(name: "Lemres", age: 30)',
362
+ 'Users.create(name: "Ret", age: 40, returning: [:id, :name])',
363
+ 'Users.create(id: 3, name: "Carbuncle", age: 16)'
364
+ ],
365
+ 'insert_all' => [
366
+ 'Users.insert_all([{ name: "A", age: 1 }, { name: "B", age: 2 }])'
367
+ ],
368
+ 'batch_create' => [
369
+ 'Users.batch_create([{ name: "A" }, { name: "B" }])'
370
+ ],
371
+ 'update' => [
372
+ 'Users.where { id == 1 }.update { age 17 }',
373
+ 'Users.where { id == 2 }.update { age 200; name "Carby" }',
374
+ 'Users.where { id == 3 }.update { age 26 }'
375
+ ],
376
+ 'update_all' => [
377
+ 'Users.update_all(age: 30)'
378
+ ],
379
+ 'batch_update' => [
380
+ 'Users.where { id == 1 }.batch_update({ age: 2 }, returning: [:id])'
381
+ ],
382
+ 'delete' => [
383
+ 'Users.where(age: 16).delete',
384
+ 'Users.where(age: 16).delete(returning: [:id])'
385
+ ],
386
+ 'delete_all' => [
387
+ 'Users.where(age: 30).delete_all'
388
+ ],
389
+ 'increment!' => [
390
+ 'Users.increment!(1, :age)',
391
+ 'Users.increment!(1, :age, 2)'
392
+ ],
393
+ 'decrement!' => [
394
+ 'Users.decrement!(1, :age)',
395
+ 'Users.decrement!(1, :age, 2)'
396
+ ],
397
+ 'toggle!' => [
398
+ 'Flags.toggle!(1, :flag)'
399
+ ],
400
+
401
+ # --- DDL ---
402
+ 'define_relation' => [
403
+ 'Diamond.define_relation(:users) { |t| t.attribute :id, Integer, primary_key: true, nullable: false }',
404
+ 'Diamond.define_relation(:users) { |t| t.attribute :name, String }',
405
+ '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 }'
407
+ ],
408
+ 'alter_table' => [
409
+ 'Diamond.alter_table(:users) { |t| t.add_column :nickname, String }',
410
+ 'Diamond.alter_table(:users) { |t| t.add_column :bad, Integer, primary_key: true }' # raises
411
+ ],
412
+ 'create_index' => [
413
+ 'Diamond.create_index(:users, :name, name: "idx_users_name")',
414
+ 'Diamond.create_index(:users, [:name, :age], name: "idx_users_pair", unique: true)'
415
+ ]
416
+ }.freeze
417
+
418
+ # { method => { example_string => sql_string } }. Examples must
419
+ # match VARIATIONS exactly. SQL strings are static and may include
420
+ # `?` placeholders (params are runtime; not shown).
421
+ SQL = {
422
+ 'where' => {
423
+ 'Users.where { age > 10 }' => 'SELECT * FROM users WHERE age > ?',
424
+ 'Users.where { id == 1 }' => 'SELECT * FROM users WHERE id = ?',
425
+ 'Users.where { id != 5 }' => 'SELECT * FROM users WHERE id != ?',
426
+ 'Users.where { name == "Arle" }' => 'SELECT * FROM users WHERE name = ?',
427
+ 'Users.where { (age > 15) & (name == "Arle") }' => 'SELECT * FROM users WHERE (age > ?) AND (name = ?)',
428
+ 'Users.where { (age > 15) && (name == "Arle") }' => 'SELECT * FROM users WHERE (age > ?) AND (name = ?)',
429
+ 'Users.where { id.in(1, 2) }' => 'SELECT * FROM users WHERE id IN (?, ?)',
430
+ 'Users.where { id == [] }' => 'SELECT * FROM users WHERE 1=0',
431
+ 'Users.where { id != [] }' => 'SELECT * FROM users WHERE 1=1',
432
+ 'Users.where { id == [1, 2] }' => 'SELECT * FROM users WHERE id IN (?, ?)',
433
+ 'Users.where { id != [1, 2] }' => 'SELECT * FROM users WHERE id NOT IN (?, ?)',
434
+ 'Users.where { age.between?(16, 25) }' => 'SELECT * FROM users WHERE age BETWEEN ? AND ?',
435
+ 'Users.where { parent_id == nil }' => 'SELECT * FROM users WHERE parent_id IS NULL',
436
+ 'Users.where { name =~ /a+/ }' => 'SELECT * FROM users WHERE name LIKE ?',
437
+ 'Users.where { name =~ /^Ar/ }' => 'SELECT * FROM users WHERE name LIKE ?',
438
+ 'Users.where(name: "Arle")' => 'SELECT * FROM users WHERE name = ?',
439
+ 'Users.where(name: "Arle", age: 16)' => 'SELECT * FROM users WHERE name = ? AND age = ?',
440
+ 'Users.where(age: { gt: 10 })' => 'SELECT * FROM users WHERE age > ?',
441
+ 'Users.where(age: { gte: 18, lte: 65 })' => 'SELECT * FROM users WHERE age >= ? AND age <= ?',
442
+ 'Users.where(age: { in: [16, 25] })' => 'SELECT * FROM users WHERE age IN (?, ?)',
443
+ 'Users.where(age: { nin: [16, 25] })' => 'SELECT * FROM users WHERE age NOT IN (?, ?)',
444
+ 'Users.where(name: { not: "Arle" })' => 'SELECT * FROM users WHERE name != ?',
445
+ 'Users.where(age: 16..20)' => 'SELECT * FROM users WHERE age BETWEEN ? AND ?',
446
+ 'Users.where(age: [16, 25])' => 'SELECT * FROM users WHERE age IN (?, ?)',
447
+ 'Users.where(age: [])' => 'SELECT * FROM users WHERE 1=0',
448
+ 'Users.where(name: nil)' => 'SELECT * FROM users WHERE name IS NULL',
449
+ 'Users.where([{ age: 16 }, { age: 25 }])' => 'SELECT * FROM users WHERE (age = ?) OR (age = ?)'
450
+ },
451
+ 'order' => {
452
+ 'Users.order(:age)' => 'SELECT * FROM users ORDER BY age ASC',
453
+ 'Users.order(:name, :age)' => 'SELECT * FROM users ORDER BY name ASC, age ASC',
454
+ 'Users.order(age: :desc)' => 'SELECT * FROM users ORDER BY age DESC',
455
+ 'Users.order(name: :asc, age: :desc)' => 'SELECT * FROM users ORDER BY name ASC, age DESC',
456
+ 'Users.order(:name, age: :desc)' => 'SELECT * FROM users ORDER BY name ASC, age DESC'
457
+ },
458
+ 'limit' => {
459
+ 'Users.limit(5)' => 'SELECT * FROM users LIMIT 5'
460
+ },
461
+ 'offset' => {
462
+ 'Users.offset(2)' => 'SELECT * FROM users OFFSET 2'
463
+ },
464
+ 'find' => {
465
+ 'Users.find(1)' => 'SELECT * FROM users WHERE id = ?'
466
+ },
467
+ 'where_in' => {
468
+ 'Users.where_in(:id, Posts.derive(:user_id))' => 'SELECT * FROM users WHERE id IN (SELECT user_id FROM posts)'
469
+ },
470
+ 'count' => {
471
+ 'Users.count' => 'SELECT count(id) AS count_id FROM users',
472
+ 'Users.count(:age)' => 'SELECT count(age) AS count_age FROM users'
473
+ },
474
+ 'sum' => {
475
+ 'Users.sum(:age)' => 'SELECT sum(age) AS sum_age FROM users'
476
+ },
477
+ 'minimum' => {
478
+ 'Users.minimum(:age)' => 'SELECT min(age) AS min_age FROM users'
479
+ },
480
+ 'maximum' => {
481
+ 'Users.maximum(:age)' => 'SELECT max(age) AS max_age FROM users'
482
+ },
483
+ 'average' => {
484
+ 'Users.average(:age)' => 'SELECT avg(age) AS avg_age FROM users'
485
+ },
486
+ 'avg' => {
487
+ 'Users.avg(:age)' => 'SELECT avg(age) AS avg_age FROM users'
488
+ },
489
+ 'distinct' => {
490
+ 'Users.derive(:name).distinct' => 'SELECT DISTINCT name FROM users'
491
+ },
492
+ 'group' => {
493
+ 'Users.group(:age)' => 'SELECT * FROM users GROUP BY age'
494
+ }
495
+ }.freeze
496
+
497
+ # Methods with NO documentation entry (intentionally — the user
498
+ # shouldn't see them in the default dump; their only purpose is
499
+ # as a non-method data accessor). Currently empty.
500
+ HIDDEN = [].freeze
501
+
502
+ # Diamond singleton meanings (one-line each). Values are displayed
503
+ # after the signature; if the value is an Array, each element is
504
+ # rendered as an `e.g.` example (matches the table/qo form).
505
+ DIAMOND = {
506
+ 'wake_up' => 'boot engine (:memory: or file path)',
507
+ 'fiber_yield_on_progress' => 'control Fiber autoyield on progress',
508
+ 'engine' => 'per-Ractor engine handle (escape hatch: .db)',
509
+ 'transaction' => 'block transaction (nest via savepoint)',
510
+ 'savepoint' => 'nested savepoint inside transaction',
511
+ 'backup' => 'hot backup to file',
512
+ 'query_with_timeout' => 'run block with progress timeout',
513
+ 'track_changes' => 'changeset capture (needs session ext)',
514
+ 'trace' => 'tap every SQL string',
515
+ 'tables' => 'sorted list of defined table names',
516
+ 'limit' => 'sqlite PRAGMA limit get/set',
517
+ 'status' => 'sqlite PRAGMA status pair',
518
+ 'runtime_status' => 'sqlite runtime value',
519
+ 'busy_timeout' => 'sqlite busy_timeout get',
520
+ 'busy_timeout=' => 'sqlite busy_timeout set',
521
+ 'gvl_release_threshold' => 'GVL release threshold get',
522
+ 'gvl_release_threshold=' => 'GVL release threshold set',
523
+ 'on_progress' => 'install progress callback',
524
+ 'load_extension' => 'load sqlite extension',
525
+ 'sleep' => 'interruptible sleep (yields to engine)',
526
+ 'quote_ident' => 'quote an identifier safely',
527
+ 'validate_ident!' => 'raise unless name matches identifier rules',
528
+ 'clear_caches!' => 'drop parser/struct/finder caches',
529
+ 'with' => [
530
+ 'Diamond.with(teens: Users.where { age < 20 }) { |d| d.from(:teens) }',
531
+ 'Diamond.with(over10: Users.where { age > 10 }) { |d| d.from(:over10).where { ... } }'
532
+ ],
533
+ 'with_recursive' => [
534
+ 'Diamond.with_recursive(:tree, base, recursive)'
535
+ ]
536
+ }.freeze
537
+
538
+ # Per-method notes (one-line, printed under the signature).
539
+ # Use for pk/version/etc. dependencies that don't fit in a single
540
+ # example call.
541
+ 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',
545
+ 'derive' => 'one Projection per chain — a second derive raises',
546
+ 'reorder' => 'replaces existing ORDER BY; bare reorder clears it',
547
+ 'limit' => 'replaces previous limit (last wins)',
548
+ 'offset' => 'replaces previous offset (last wins)',
549
+ 'distinct' => 'replaces previous distinct; idempotent',
550
+ 'union' => 'terminal — further chain calls raise (wrap in a subquery instead)',
551
+ 'from_cte' => 'must run inside the Diamond.with block; queries a CTE alias',
552
+ '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',
555
+ 'each' => 'no block returns an Enumerator (so .lazy and chains work); with block streams via a cursor',
556
+ 'all' => 'lazy QueryObject (no SQL until chained/terminal); equivalent to bare Table but chainable',
557
+ 'all!' => 'eagerly materializes all rows (same as .to_a); returns Array of structs',
558
+ 'pluck' => 'one column -> flat Array; several -> Array of Arrays (no Struct overhead)',
559
+ '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',
561
+ 'materialize' => 'result is memoized per QueryObject; same chain returns the same array',
562
+ 'as_hash' => 'mode carries across chain calls (.as_hash.limit(5).materialize stays hashes)',
563
+ 'as_array' => 'mode carries across chain calls (positional arrays, no Struct)',
564
+ 'as_splat' => 'mode carries; single-column values out (the ultimate pluck)',
565
+ 'to_json_array' => 'rejects eager joins and WITH/CTE chains; one JSON string crosses the Ruby/C boundary',
566
+ 'sleep' => 'interruptible — yields to the engine progress hook, not plain Kernel.sleep',
567
+ 'query_with_timeout' => 'uses sqlite progress handler; raises QueryInterruptedError on timeout',
568
+ 'track_changes' => 'requires the extralite-bundle build (session extension); raises FeatureNotAvailableError otherwise',
569
+ 'transaction' => 'nested calls run inside the outer transaction; use savepoint for explicit rollback points',
570
+ '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',
574
+ 'join' => 'INNER by default; type: :left for LEFT OUTER; eager: true for nested structs (same SQL as includes, but flat results)',
575
+ 'includes' => 'LEFT OUTER + eager loading; returns parent with nested child structs (sugar for join(..., eager: true))',
576
+ '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.',
577
+ 'alter_table' => 'block must use t.add_column only (SQLite cannot ADD COLUMN a primary key or foreign key). t is a scratch DSL proxy. See BLOCK_GRAMMAR.',
578
+ 'create_index' => 'no block form — call directly with column(s) and name: kwarg. Equivalent to t.index(...) inside define_relation, but for existing tables.'
579
+ }.freeze
580
+
581
+ # DDL block grammar. Each key is a method name callable inside the
582
+ # block passed to define_relation / alter_table. The receiver is
583
+ # conventionally named `t` but the variable is just a scratch DSL
584
+ # proxy — see the T_PROXY_NOTE constant below.
585
+ BLOCK_GRAMMAR = {
586
+ 'attribute' => {
587
+ signature: 't.attribute(name, type, primary_key: false, nullable: true, default: <none>)',
588
+ summary: 'column declaration (CREATE TABLE)',
589
+ kwargs: [
590
+ 'primary_key: Bool, default false — emits PRIMARY KEY (overrides nullable)',
591
+ 'nullable: Bool, default true — nullable: false emits NOT NULL',
592
+ 'default: Numeric | true | false | String | nil — emits DEFAULT <value>'
593
+ ],
594
+ types: 'Integer / String / Float / TrueClass / FalseClass (compiler-enforced)',
595
+ available_in: 'define_relation',
596
+ examples: [
597
+ 't.attribute :id, Integer, primary_key: true, nullable: false',
598
+ 't.attribute :name, String, nullable: false',
599
+ 't.attribute :age, Integer',
600
+ 't.attribute :price, Float',
601
+ 't.attribute :tagline, String, default: "new blog"'
602
+ ],
603
+ source: 'parser.rb:429-433 / proxy.rb:378-382'
604
+ },
605
+ 'add_column' => {
606
+ signature: 't.add_column(name, type, primary_key: false, nullable: true, default: <none>)',
607
+ summary: 'column declaration for ALTER TABLE — same shape as attribute',
608
+ kwargs: [
609
+ 'primary_key: Bool (REJECTED at compile time — SQLite cannot ADD COLUMN a primary key)',
610
+ 'nullable: Bool, default true',
611
+ 'default: Numeric | true | false | String | nil'
612
+ ],
613
+ types: 'Integer / String / Float / TrueClass / FalseClass',
614
+ available_in: 'alter_table (only)',
615
+ examples: [
616
+ 't.add_column :bio, String',
617
+ 't.add_column :level, Integer, default: 0'
618
+ ],
619
+ source: 'parser.rb:434-438 / proxy.rb:384-388'
620
+ },
621
+ 'primary_key' => {
622
+ signature: 't.primary_key(name)',
623
+ summary: 'shorthand: Integer column with primary_key: true, nullable: false (no kwargs)',
624
+ available_in: 'define_relation',
625
+ examples: [
626
+ 't.primary_key :id'
627
+ ],
628
+ source: 'parser.rb:439-442 / proxy.rb:390-393'
629
+ },
630
+ 'foreign_key' => {
631
+ signature: 't.foreign_key(local, ref_table, ref_col = :id, on_delete: nil, on_update: nil)',
632
+ summary: 'table-level foreign key (CREATE TABLE child line)',
633
+ kwargs: [
634
+ 'on_delete: :cascade | :set_null | :set_default | :restrict | :no_action',
635
+ 'on_update: same set as on_delete'
636
+ ],
637
+ available_in: 'define_relation (FKs are not allowed in alter_table)',
638
+ examples: [
639
+ 't.foreign_key :user_id, :users',
640
+ 't.foreign_key :owner_id, :owners, on_delete: :cascade',
641
+ 't.foreign_key :post_id, :posts, on_delete: :cascade'
642
+ ],
643
+ source: 'parser.rb:443-465 / proxy.rb:395-403'
644
+ },
645
+ 'index' => {
646
+ signature: 't.index(col1, col2, ..., name: <required>, unique: false)',
647
+ summary: 'inline index (CREATE INDEX) emitted right after CREATE TABLE',
648
+ kwargs: [
649
+ 'name: required (Symbol or identifier string) — used as the index name',
650
+ 'unique: Bool, default false — emits CREATE UNIQUE INDEX'
651
+ ],
652
+ available_in: 'define_relation (use Diamond.create_index for existing tables)',
653
+ examples: [
654
+ 't.index :name, unique: true, name: :idx_widgets_name',
655
+ 't.index :a, :b, name: :idx_w2_ab',
656
+ 't.index :parent_id, :position, unique: true, name: :idx_media_nodes_parent_position'
657
+ ],
658
+ source: 'parser.rb:466-471 / proxy.rb:405-410'
659
+ }
660
+ }.freeze
661
+
662
+ # Explanation of the `t` (or any other) block parameter. The
663
+ # receiver is purely a scratch DSL proxy: in console paths the block
664
+ # runs via instance_exec against a fresh DdlRecorder; in Prism
665
+ # file-backed parsing the receiver is never examined at all — only
666
+ # the method names and their arguments matter.
667
+ T_PROXY_NOTE = <<~NOTE.freeze
668
+ `t` is a scratch DSL proxy — the variable name is convention only.
669
+ In console paths the block runs via instance_exec against a fresh
670
+ DdlRecorder; in the Prism file-backed path the receiver is never
671
+ examined (only the method names and args matter). Any block
672
+ parameter name works (`|t|`, `|x|`, `|tbl|`), and the no-arg
673
+ bare form (`do ... end`) is also accepted.
674
+ NOTE
675
+ end
676
+ end