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,419 @@
1
+ module Diamond
2
+ module DSL
3
+ module Default
4
+ # Triple form, all lazy and chainable:
5
+ # where { age > 10 } # block: pure column predicates
6
+ # where(status: 'active') # hash: AND across columns
7
+ # where([{ a: 1 }, { b: 2 }]) # array of hashes: OR across hashes
8
+ # Blocks stay exactly as before (columns + literals only, no
9
+ # locals); hashes take runtime values without parsing.
10
+ def where(*args, &block)
11
+ if block
12
+ unless args.empty?
13
+ raise ArgumentError, "where takes a Hash, Array of Hashes, or a block — not both"
14
+ end
15
+ _build_where(&block)
16
+ elsif args.size == 1
17
+ dispatch_where_arg(args.first)
18
+ else
19
+ raise ArgumentError, "where expects a Hash, Array of Hashes, or a block, got #{args.size} positional args"
20
+ end
21
+ end
22
+
23
+ # chain `.or { condition }` to OR the last where clause with the new one
24
+ # `Users.where { name == 'Arle' }.or { age > 10 }` => `WHERE name = ? OR age > ?`
25
+ def or(&block)
26
+ _build_or_where(&block)
27
+ end
28
+
29
+ def find(id = nil, &block)
30
+ # Block form is Enumerable#find (Ruby-side search). Bare form is the
31
+ # SQL PK lookup. Never bare `super`: DSL sits after Enumerable in the
32
+ # chain, so bind explicitly (same reason as QueryObject#any?).
33
+ if block
34
+ return id.nil? ? Enumerable.instance_method(:find).bind_call(self, &block) : Enumerable.instance_method(:find).bind_call(self, id, &block)
35
+ end
36
+ raise ArgumentError, "find requires an id" if id.nil?
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)
44
+ end
45
+
46
+ # like find, but raises RecordNotFound instead of returning an
47
+ # empty chain. find stays lazy so it keeps chaining.
48
+ def find!(id)
49
+ record = find(id).first
50
+ raise Diamond::RecordNotFound, "No record found with id #{id.inspect}" if record.nil?
51
+
52
+ record
53
+ end
54
+
55
+ # `where_in(:col, query)` creates a WHERE col IN (SELECT ...) condition.
56
+ # `Users.where_in(:id, Posts.derive(:user_id))` compiles to
57
+ # `SELECT * FROM users WHERE id IN (SELECT user_id FROM posts)`.
58
+ def where_in(column, subquery)
59
+ col_node = AST::Column.new(column)
60
+ sub_node = AST::Subquery.new(subquery)
61
+ condition = AST::In.new(col_node, sub_node)
62
+ _build_where_node(condition)
63
+ end
64
+
65
+ # `left UNION ALL right`. both sides must project the same width
66
+ # (`*` counts as the table's width). terminal: the result supports
67
+ # materialize/each/to_sql/explain, but chaining more clauses after
68
+ # a union raises — aggregates need wrapping.
69
+ def union(other)
70
+ raise ArgumentError, "union needs a QueryObject, got #{other.class}" unless other.is_a?(Diamond::QueryObject)
71
+
72
+ left = _wrap
73
+ width = ->(q) {
74
+ proj = q.ast.find { |n| n.is_a?(AST::Projection) }
75
+ proj ? proj.columns.size : q.table.schema[:columns].size
76
+ }
77
+ lw, rw = width.call(left), width.call(other)
78
+ unless lw == rw
79
+ raise ArgumentError, "union needs equal widths, got #{lw} vs #{rw}"
80
+ end
81
+
82
+ Diamond::QueryObject.new(left.table, [AST::Union.new(left, other)])
83
+ end
84
+
85
+ # Build a projection. Accepts column symbols, AST nodes, or a block
86
+ # that yields bare columns, function calls, and window chains. Also
87
+ # used to shape a subquery for `where_in`:
88
+ # Users.where_in(:id, Posts.derive(:user_id))
89
+ def derive(*args, &block)
90
+ _build_projection(*args, &block)
91
+ end
92
+
93
+ def join(table_name, on: nil, type: :inner, eager: false, as: nil)
94
+ _build_join(table_name, type, on, eager: eager, as: as)
95
+ end
96
+
97
+ # `.includes(:posts, :comments)` eager-loads child relations.
98
+ # sugar for `.join(:posts, eager: true).join(:comments, eager: true)`.
99
+ def includes(*tables, **opts)
100
+ _build_includes(*tables, **opts)
101
+ end
102
+
103
+ def define_relation(name, &block)
104
+ ast = _build_relation(name, &block)
105
+ sql, params = Diamond::Compiler::DDL.compile(ast)
106
+ Diamond.engine.db.execute(sql, *params)
107
+
108
+ # indexes go after the table exists. needs PRAGMA foreign_keys=ON
109
+ # for cascades (wake_up turns it on).
110
+ ast.columns.select { |c| c.is_a?(Diamond::AST::IndexDefinition) }.each do |idx|
111
+ idx_sql, idx_params = Diamond::Compiler::DDL.compile_index(idx, name)
112
+ Diamond.engine.db.execute(idx_sql, *idx_params)
113
+ end
114
+
115
+ Diamond.engine.load_one_table!(name)
116
+
117
+ # bind a frozen top-level constant immediately. without this, a
118
+ # worker Ractor would have to fall through `const_missing`, which
119
+ # calls `Object.const_set` and is illegal from non-main Ractors.
120
+ const_name = name.to_s.split('_').map(&:capitalize).join
121
+ unless Object.const_defined?(const_name, false)
122
+ proxy = Diamond::Table.new(name).freeze
123
+ Object.const_set(const_name, proxy)
124
+ Diamond.note_bound_table(const_name)
125
+ end
126
+
127
+ ast
128
+ end
129
+
130
+ def create_index(table_name, columns, unique: false, name:)
131
+ raise ArgumentError, "create_index requires `name:` kwarg" unless name
132
+ cols = Array(columns)
133
+ raise ArgumentError, "create_index requires at least one column" if cols.empty?
134
+ idx = Diamond::AST::IndexDefinition.new(name, cols, unique: !!unique)
135
+ sql, params = Diamond::Compiler::DDL.compile_index(idx, table_name)
136
+ Diamond.engine.db.execute(sql, *params)
137
+ Diamond.engine.load_one_table!(table_name)
138
+ idx
139
+ end
140
+
141
+ # Safe migrations: add columns to a live table. Only `add_column`
142
+ # statements allowed in the block (indexes have create_index;
143
+ # SQLite can't ADD COLUMN a primary key or foreign key).
144
+ #
145
+ # Diamond.alter_table(:widgets) do |t|
146
+ # t.add_column :bio, String
147
+ # t.add_column :stock, Integer, default: 0
148
+ # end
149
+ def alter_table(table_name, &block)
150
+ raise ArgumentError, "alter_table requires a block" unless block
151
+ nodes = Diamond::Parser.parse_ddl(block)
152
+ nodes.each do |node|
153
+ unless node.is_a?(Diamond::AST::ColumnDefinition)
154
+ raise ArgumentError, "alter_table only supports `add_column`, got #{node.class}"
155
+ end
156
+ sql, params = Diamond::Compiler::DDL.compile_add_column(table_name, node)
157
+ Diamond.engine.db.execute(sql, *params)
158
+ end
159
+ Diamond.engine.load_one_table!(table_name)
160
+ nodes
161
+ end
162
+
163
+ def create(**kwargs)
164
+ returning = kwargs.delete(:returning) || []
165
+ _build_create(kwargs, returning: returning)
166
+ end
167
+
168
+ def update(returning: [], &block)
169
+ result = _build_update(returning: returning, &block)
170
+ @cached_result = nil if defined?(@cached_result) && @cached_result
171
+ result
172
+ end
173
+
174
+ def delete(returning: [])
175
+ result = _build_delete(returning: returning)
176
+ @cached_result = nil if defined?(@cached_result) && @cached_result
177
+ result
178
+ end
179
+
180
+ def from_cte(alias_name)
181
+ Diamond::QueryObject.new(self, [Diamond::AST::From.new(alias_name)])
182
+ end
183
+
184
+ # --- Chaining + terminals ---
185
+
186
+ def order(*args, **kwargs)
187
+ _build_order(*args, **kwargs)
188
+ end
189
+
190
+ def limit(n)
191
+ _build_limit(n)
192
+ end
193
+
194
+ def offset(n)
195
+ _build_offset(n)
196
+ end
197
+
198
+ def distinct
199
+ _chain(AST::Distinct.new)
200
+ end
201
+
202
+ def group(*columns)
203
+ _build_group(columns)
204
+ end
205
+
206
+ def having(&block)
207
+ _build_having(&block)
208
+ end
209
+
210
+ def pluck(*columns)
211
+ _wrap.pluck(*columns)
212
+ end
213
+
214
+ def pick(*columns)
215
+ _wrap.pick(*columns)
216
+ end
217
+
218
+ def ids
219
+ _wrap.ids
220
+ end
221
+
222
+ def exists?(filter = nil)
223
+ _wrap.exists?(filter)
224
+ end
225
+
226
+ def any?(*args, &block)
227
+ _wrap.any?(*args, &block)
228
+ end
229
+
230
+ def none?(*args, &block)
231
+ _wrap.none?(*args, &block)
232
+ end
233
+
234
+ def empty?
235
+ _wrap.empty?
236
+ end
237
+
238
+ def count(column = nil)
239
+ _wrap.count(column)
240
+ end
241
+
242
+ def sum(column = nil, &block)
243
+ _wrap.sum(column, &block)
244
+ end
245
+
246
+ def minimum(column)
247
+ _wrap.minimum(column)
248
+ end
249
+
250
+ def maximum(column)
251
+ _wrap.maximum(column)
252
+ end
253
+
254
+ def average(column)
255
+ _wrap.average(column)
256
+ end
257
+
258
+ alias avg average
259
+
260
+ def min(column = nil, &block)
261
+ _wrap.min(column, &block)
262
+ end
263
+
264
+ def max(column = nil, &block)
265
+ _wrap.max(column, &block)
266
+ end
267
+
268
+ def first(n = 1)
269
+ _wrap.first(n)
270
+ end
271
+
272
+ def first!
273
+ _wrap.first || raise(Diamond::RecordNotFound, "No record found for #{_wrap.table.name}")
274
+ end
275
+
276
+ def last(n = 1)
277
+ _wrap.last(n)
278
+ end
279
+
280
+ def last!
281
+ _wrap.last || raise(Diamond::RecordNotFound, "No record found for #{_wrap.table.name}")
282
+ end
283
+
284
+ # exactly one row or RecordNotFound (on zero AND on two-plus).
285
+ # Bounds the work with LIMIT 2 unless the chain already limits.
286
+ def sole
287
+ q = _wrap
288
+ q = q.limit(2) unless q.ast.any? { |n| n.is_a?(AST::Limit) }
289
+ results = q.materialize
290
+ unless results.size == 1
291
+ raise Diamond::RecordNotFound, "Expected exactly one record, got #{results.size}"
292
+ end
293
+ results.first
294
+ end
295
+
296
+ # Lazy shorthand for the unchained scope (`SELECT * FROM table`).
297
+ # Returns a QueryObject so further chains (where/order/etc.) keep working.
298
+ def all
299
+ _wrap
300
+ end
301
+
302
+ # Eagerly materialize all records. Same SQL as `.all` but
303
+ # returns an Array (not a lazy QueryObject).
304
+ def all!
305
+ all.to_a
306
+ end
307
+
308
+ # First row(s) with no implicit ordering (first/last inject
309
+ # ORDER BY pk). Same single-vs-array shape as first.
310
+ def head(n = 1)
311
+ results = _wrap.limit(n).materialize
312
+ n == 1 ? results.first : results
313
+ end
314
+
315
+ def each(&block)
316
+ _wrap.each(&block)
317
+ end
318
+
319
+ def as_hash
320
+ _wrap.as_hash
321
+ end
322
+
323
+ def as_array
324
+ _wrap.as_array
325
+ end
326
+
327
+ def as_splat
328
+ _wrap.as_splat
329
+ end
330
+
331
+ def to_json_array(*columns)
332
+ _wrap.to_json_array(*columns)
333
+ end
334
+
335
+ # pk-ordered batched iteration. Returns an enumerator without a
336
+ # block. Appends the pk as a final tiebreak so batches are
337
+ # stable even over user-ordered chains.
338
+ def find_each(batch_size: 1000, &block)
339
+ return to_enum(:find_each, batch_size: batch_size) unless block
340
+ pk = _schema_for_dsl[:primary_key] || :id
341
+ scope = _wrap.order(pk)
342
+ offset = 0
343
+ loop do
344
+ batch = scope.limit(batch_size).offset(offset).materialize
345
+ break if batch.empty?
346
+ batch.each(&block)
347
+ break if batch.size < batch_size
348
+ offset += batch_size
349
+ end
350
+ nil
351
+ end
352
+
353
+ def find_by(**attrs)
354
+ raise ArgumentError, "find_by requires at least one attribute" if attrs.empty?
355
+ where(attrs).first
356
+ end
357
+
358
+ # First match in the current scope, else create with exactly the
359
+ # given attributes (scope conditions are NOT auto-merged). No
360
+ # locking — same caveat as anywhere without a unique index.
361
+ def find_or_create_by(**attrs)
362
+ raise ArgumentError, "find_or_create_by requires at least one attribute" if attrs.empty?
363
+ where(attrs).first || create(**attrs)
364
+ end
365
+
366
+ # Like order, but replaces any existing ordering instead of
367
+ # merging. Bare `reorder` clears ordering entirely.
368
+ def reorder(*args, **kwargs)
369
+ _build_reorder(*args, **kwargs)
370
+ end
371
+
372
+ def delete_all(returning: [])
373
+ delete(returning: returning)
374
+ end
375
+
376
+ # Bulk update with a runtime hash — no block, no parsing.
377
+ # Returns the row count, a flat array of values (single Symbol
378
+ # returning), or an array of structs (Array returning).
379
+ def update_all(returning: [], **hash)
380
+ raise ArgumentError, "update_all requires at least one column" if hash.empty?
381
+ hash.each_key { |c| Diamond::Parser.validate_column!(c, _schema_for_dsl) }
382
+ result = _wrap.batch_update(hash, returning: returning)
383
+ @cached_result = nil if defined?(@cached_result) && @cached_result
384
+ result
385
+ end
386
+
387
+ def increment!(id, column, by = 1)
388
+ column = column.to_sym
389
+ Diamond::Parser.validate_column!(column, _schema_for_dsl)
390
+ record = find!(id)
391
+ new_value = record.public_send(column) + by
392
+ find(id).update_all(column => new_value)
393
+ new_value
394
+ end
395
+
396
+ def decrement!(id, column, by = 1)
397
+ increment!(id, column, -by)
398
+ end
399
+
400
+ # Flips 0/1-int columns (1/true read as on). Narrow by design —
401
+ # Diamond has no boolean convention beyond SQLite ints.
402
+ def toggle!(id, column)
403
+ column = column.to_sym
404
+ Diamond::Parser.validate_column!(column, _schema_for_dsl)
405
+ record = find!(id)
406
+ current = record.public_send(column)
407
+ new_value = (current == true || current == 1) ? 0 : 1
408
+ find(id).update_all(column => new_value)
409
+ new_value
410
+ end
411
+
412
+ private
413
+
414
+ def _wrap
415
+ is_a?(Diamond::Table) ? Diamond::QueryObject.new(self) : self
416
+ end
417
+ end
418
+ end
419
+ end
@@ -0,0 +1,226 @@
1
+ require 'extralite'
2
+
3
+ module Diamond
4
+ # Engine wraps an Extralite database connection. Schema introspection happens
5
+ # at boot; everything derived from the schema (table proxies, FK caches)
6
+ # lives on the engine instance. Extralite 3.0 enables WAL, foreign_keys and
7
+ # synchronous=NORMAL by default; no PRAGMA setup needed at wake_up.
8
+ class Engine
9
+ attr_reader :db, :schema_cache, :foreign_keys, :prepared_cache
10
+
11
+ def initialize(db_path, busy_timeout: nil, gvl_release_threshold: nil,
12
+ on_progress: nil, extensions: [])
13
+ @db = Extralite::Database.new(db_path)
14
+ @schema_cache = {}
15
+ @foreign_keys = {}
16
+ @prepared_cache = {} # Phase 7: per-engine, mutable (frozen engines are fine)
17
+
18
+ @busy_timeout = busy_timeout
19
+ @db.busy_timeout = busy_timeout if busy_timeout
20
+ @db.gvl_release_threshold = gvl_release_threshold if gvl_release_threshold
21
+ # Progress-hook precedence: an explicit proc always wins; `false`
22
+ # is an explicit opt-out (see wake_up's auto_fiber_yield: false);
23
+ # otherwise auto-install the yield hook when a Fiber::Scheduler
24
+ # is present at boot. No scheduler → no hook, so standard
25
+ # threaded/Ractor apps pay nothing and behave exactly as before.
26
+ @explicit_progress_hook = !on_progress.nil?
27
+ @scheduler_hook_installed = false
28
+ unless on_progress == false
29
+ @db.on_progress(&on_progress) if on_progress
30
+ # `Kernel.` prefix is load-bearing in spirit: never let this
31
+ # resolve to anything but Kernel#sleep.
32
+ if on_progress.nil? && Fiber.scheduler
33
+ @db.on_progress { Kernel.sleep(0); nil }
34
+ @scheduler_hook_installed = true
35
+ end
36
+ end
37
+ Array(extensions).each { |path| @db.load_extension(path) }
38
+
39
+ load_schema!
40
+ end
41
+
42
+ # Late-bind the yield hook when a scheduler appears after boot
43
+ # (e.g. engines booted pre-reactor, fibers later). Idempotent and
44
+ # safe under concurrent first touch (GVL-atomic sets of an
45
+ # identical hook); never overrides an explicit hook or an opt-out,
46
+ # never uninstalls. Called from Diamond.engine on every access —
47
+ # two ivar reads once settled.
48
+ def sync_scheduler_hook!
49
+ return if @explicit_progress_hook || @scheduler_hook_installed
50
+ return unless Fiber.scheduler
51
+ @db.on_progress { Kernel.sleep(0); nil }
52
+ @scheduler_hook_installed = true
53
+ end
54
+
55
+ def reload_schema!
56
+ @schema_cache = {}
57
+ @foreign_keys = {}
58
+ @prepared_cache.clear
59
+ load_schema!
60
+ Diamond.clear_caches!
61
+ freeze!
62
+ end
63
+
64
+ # freeze the schema-derived state so the engine is shareable across
65
+ # Ractors. the underlying Extralite database handle remains live; only
66
+ # the cached metadata gets sealed. prepared_cache stays mutable so
67
+ # queries can cache new statements after freeze.
68
+ def freeze!
69
+ @schema_cache.freeze
70
+ @foreign_keys.freeze
71
+ @schema_cache.each_value do |schema|
72
+ schema.freeze
73
+ schema[:columns].freeze
74
+ schema[:types].freeze
75
+ schema[:types].each_value(&:freeze)
76
+ schema[:nullable].freeze
77
+ schema[:nullable].each_value(&:freeze)
78
+ schema[:defaults].freeze
79
+ schema[:required].freeze
80
+ schema[:required].each_value(&:freeze)
81
+ end
82
+ @foreign_keys.each_value(&:freeze)
83
+ self
84
+ end
85
+
86
+ # refresh one table instead of rescanning everything.
87
+ def load_one_table!(table_name)
88
+ sym = table_name.to_sym
89
+ new_schema = @schema_cache.merge(sym => parse_table_schema(sym))
90
+ new_fks = @foreign_keys.merge(sym => parse_foreign_keys(sym))
91
+ @schema_cache = new_schema
92
+ @foreign_keys = new_fks
93
+ @prepared_cache.clear
94
+ freeze!
95
+ end
96
+
97
+ # --- Phase 7 concurrency tuning pass-throughs ---
98
+ # SQLite exposes no busy_timeout read, so the engine tracks the
99
+ # last-set value itself (nil until first set).
100
+ def busy_timeout; @busy_timeout; end
101
+ def busy_timeout=(val); @busy_timeout = val; @db.busy_timeout = val; end
102
+ def gvl_release_threshold; @db.gvl_release_threshold; end
103
+ def gvl_release_threshold=(val); @db.gvl_release_threshold = val; end
104
+
105
+ # --- Phase 7 progress hook ---
106
+ def on_progress(&block); @db.on_progress(&block); end
107
+
108
+ # --- Phase 7 extension loading ---
109
+ def load_extension(path); @db.load_extension(path); end
110
+
111
+ # --- Phase 7 prepared-statement cache ---
112
+ # Returns a cached Extralite::Query (prepared statement) for the given
113
+ # SQL, creating it on first use. Each engine has its own cache; each
114
+ # Ractor gets its own engine, so cache state stays per-Ractor.
115
+ #
116
+ # Cache key includes the fetch mode: a statement fixed to :array
117
+ # must never be handed to a hash-mode caller and vice versa.
118
+ def prepared(sql, mode = :hash)
119
+ key = [sql, mode]
120
+ @prepared_cache[key] ||= case mode
121
+ when :array then @db.prepare_array(sql)
122
+ else @db.prepare(sql)
123
+ end
124
+ end
125
+
126
+ # --- Phase 7 status / limit introspection ---
127
+ DBSTATUS_CODES = {
128
+ lookaside_used: Extralite::SQLITE_DBSTATUS_LOOKASIDE_USED,
129
+ cache_used: Extralite::SQLITE_DBSTATUS_CACHE_USED,
130
+ schema_used: Extralite::SQLITE_DBSTATUS_SCHEMA_USED,
131
+ stmt_used: Extralite::SQLITE_DBSTATUS_STMT_USED,
132
+ cache_hit: Extralite::SQLITE_DBSTATUS_CACHE_HIT,
133
+ cache_miss: Extralite::SQLITE_DBSTATUS_CACHE_MISS,
134
+ deferred_fks: Extralite::SQLITE_DBSTATUS_DEFERRED_FKS
135
+ }.freeze
136
+
137
+ LIMIT_CODES = {
138
+ length: Extralite::SQLITE_LIMIT_LENGTH,
139
+ sql_length: Extralite::SQLITE_LIMIT_SQL_LENGTH,
140
+ column: Extralite::SQLITE_LIMIT_COLUMN,
141
+ expr_depth: Extralite::SQLITE_LIMIT_EXPR_DEPTH,
142
+ compound_select: Extralite::SQLITE_LIMIT_COMPOUND_SELECT,
143
+ function_arg: Extralite::SQLITE_LIMIT_FUNCTION_ARG,
144
+ attached: Extralite::SQLITE_LIMIT_ATTACHED,
145
+ variable_number: Extralite::SQLITE_LIMIT_VARIABLE_NUMBER,
146
+ trigger_depth: Extralite::SQLITE_LIMIT_TRIGGER_DEPTH,
147
+ worker_threads: Extralite::SQLITE_LIMIT_WORKER_THREADS
148
+ }.freeze
149
+
150
+ # db.status(code) → [current, high_watermark]
151
+ def status(code)
152
+ @db.status(DBSTATUS_CODES.fetch(code))
153
+ end
154
+
155
+ # db.limit(code) → current
156
+ # db.limit(code, value) → set + return value
157
+ def limit(code, value = nil)
158
+ if value.nil?
159
+ @db.limit(LIMIT_CODES.fetch(code))
160
+ else
161
+ @db.limit = [LIMIT_CODES.fetch(code), value]
162
+ value
163
+ end
164
+ end
165
+
166
+ private
167
+
168
+ def load_schema!
169
+ tables = @db.query("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")
170
+
171
+ tables.each do |row|
172
+ table_name = row[:name].to_sym
173
+ @schema_cache[table_name] = parse_table_schema(table_name)
174
+ @foreign_keys[table_name] = parse_foreign_keys(table_name)
175
+ end
176
+ end
177
+
178
+ def parse_table_schema(table_name)
179
+ columns = []
180
+ types = {}
181
+ nullable = {}
182
+ defaults = {}
183
+ required = {}
184
+ primary_key = nil
185
+
186
+ @db.query("PRAGMA table_info(#{Diamond.quote_ident(table_name)})").each do |col|
187
+ col_name = col[:name].to_sym
188
+ columns << col_name
189
+ types[col_name] = col[:type]
190
+ # PRAGMA table_info returns :notnull as 1 (NOT NULL) / 0 (nullable).
191
+ # Invert into :nullable so the meaning matches the variable name.
192
+ nullable[col_name] = col[:notnull] == 0
193
+ # :dflt_value is the SQL literal for the default (String), or NULL
194
+ # for no default. We keep the raw string so callers can re-emit it.
195
+ defaults[col_name] = col[:dflt_value]
196
+ # SQLite quirk: PRIMARY KEY columns return notnull=0 even though
197
+ # they're implicitly NOT NULL. Mark them required explicitly so
198
+ # validators don't have to know the SQLite PRAGMA shape.
199
+ # A column is required when (NOT NULL OR pk > 0) AND has no default.
200
+ required[col_name] =
201
+ (col[:notnull] != 0 || col[:pk] > 0) && col[:dflt_value].nil?
202
+ primary_key = col_name if col[:pk] == 1
203
+ end
204
+
205
+ {
206
+ columns: columns,
207
+ types: types,
208
+ nullable: nullable,
209
+ defaults: defaults,
210
+ required: required,
211
+ primary_key: primary_key
212
+ }
213
+ end
214
+
215
+ def parse_foreign_keys(table_name)
216
+ rows = @db.query("PRAGMA foreign_key_list(#{Diamond.quote_ident(table_name)})")
217
+ rows.map do |row|
218
+ {
219
+ local: row[:from].to_sym,
220
+ ref_table: row[:table].to_sym,
221
+ ref_col: row[:to].to_sym
222
+ }
223
+ end
224
+ end
225
+ end
226
+ end
@@ -0,0 +1,13 @@
1
+ require 'json'
2
+
3
+ module Diamond
4
+ # Pre-encoded JSON response body. Returned by QueryObject#to_json_array
5
+ # so Rutile's Response.format can pass it straight to the Rack body
6
+ # with zero further processing — no re-parse, no re-encode, no Hash
7
+ # allocation. A String subclass so the body array carries it as-is;
8
+ # Rutile matches it with an explicit first branch (behind a defined?
9
+ # guard, so Rutile stays loadable without Diamond) before its generic
10
+ # String → text/html branch.
11
+ class JsonString < String
12
+ end
13
+ end
@@ -0,0 +1,10 @@
1
+ module Diamond
2
+ class NullTable
3
+ attr_reader :name, :schema
4
+
5
+ def initialize(name = nil)
6
+ @name = name
7
+ @schema = { columns: [], types: {}, primary_key: nil }
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,31 @@
1
+ module Diamond
2
+ # Operator contract — modules under lib/diamond/operators/ implement this.
3
+ #
4
+ # Three registries consult operators at dispatch time:
5
+ #
6
+ # Parser::WhereOperators — Prism node -> AST::Node (where blocks)
7
+ # Parser::DeriveOperators — Prism node -> AST::Node (derive blocks)
8
+ # Compiler::Operators — AST::Node -> SQL fragment (SQL rendering)
9
+ #
10
+ # Built-in operators are the fallthrough (implicit priority 0). External
11
+ # operators run first, sorted by descending priority; first non-nil result
12
+ # wins. AST::Column and AST::Literal are primitives, not operators — the
13
+ # foundation stays stable across extensions.
14
+ #
15
+ # Canonical implementation: lib/diamond/operators/like.rb.
16
+ module Operator
17
+ # An operator module exposes:
18
+ #
19
+ # PRIORITY - Integer. Higher runs first. Built-ins are 0.
20
+ # priority - class method returning PRIORITY
21
+ # parse_where(node, schema) -> AST::Node | nil
22
+ # parse_derive(node, schema) -> AST::Node | nil
23
+ # handles?(node) -> Bool
24
+ # render(node, params) -> String (mutates params)
25
+ #
26
+ # Register by calling:
27
+ # Parser::WhereOperators.register(MyOp)
28
+ # Parser::DeriveOperators.register(MyOp) # optional
29
+ # Compiler::Operators.register(MyOp)
30
+ end
31
+ end