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.
- checksums.yaml +7 -0
- data/lib/diamond/api_catalog.rb +676 -0
- data/lib/diamond/ast.rb +397 -0
- data/lib/diamond/changeset.rb +36 -0
- data/lib/diamond/compiler/base.rb +17 -0
- data/lib/diamond/compiler/ddl.rb +94 -0
- data/lib/diamond/compiler/dml.rb +109 -0
- data/lib/diamond/compiler/dql.rb +390 -0
- data/lib/diamond/compiler/registry.rb +45 -0
- data/lib/diamond/cursor.rb +52 -0
- data/lib/diamond/domains/cte.rb +25 -0
- data/lib/diamond/domains/ddl.rb +11 -0
- data/lib/diamond/domains/dml.rb +106 -0
- data/lib/diamond/domains/dql.rb +395 -0
- data/lib/diamond/domains/dynamic_finders.rb +64 -0
- data/lib/diamond/dsl/default.rb +419 -0
- data/lib/diamond/engine.rb +226 -0
- data/lib/diamond/json_string.rb +13 -0
- data/lib/diamond/null_table.rb +10 -0
- data/lib/diamond/operator.rb +31 -0
- data/lib/diamond/operators/like.rb +142 -0
- data/lib/diamond/parser/proxy.rb +469 -0
- data/lib/diamond/parser/registry.rb +74 -0
- data/lib/diamond/parser.rb +571 -0
- data/lib/diamond/query_object.rb +463 -0
- data/lib/diamond/struct_factory.rb +262 -0
- data/lib/diamond/table.rb +32 -0
- data/lib/diamond/version.rb +3 -0
- data/lib/diamond.rb +436 -0
- data/sig/diamond.rbs +693 -0
- metadata +95 -0
|
@@ -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,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
|