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.
data/lib/diamond.rb ADDED
@@ -0,0 +1,436 @@
1
+ # shareable_constant_value: literal
2
+ #
3
+ # makes every top-level constant declared below shareable across Ractors.
4
+ # `Users` etc. become references to shareable objects, so a Ractor can
5
+ # resolve `Users` without going through any global mutable state.
6
+ #
7
+ # After this file runs, every Diamond::* constant (and every public
8
+ # constant nested under Diamond::Parser / ::Compiler / ::Domains / ::DSL /
9
+ # ::Operators) is registered via Module#autoload — referencing one
10
+ # loads the corresponding file from gems/diamond/lib on first use.
11
+ # The eager top-half defines the error classes, RACTOR_KEYS, IDENT_RE
12
+ # and the global const_missing prepend, all of which must exist before
13
+ # any user code touches a Diamond table.
14
+
15
+ require 'extralite'
16
+ require 'prism'
17
+ require 'did_you_mean'
18
+
19
+ module Diamond
20
+ class Error < StandardError; end
21
+ class TableNotFound < Error; end
22
+ class RecordNotFound < Error; end
23
+ class InertObjectError < Error; end
24
+
25
+ class UnknownColumnError < StandardError
26
+ def self.build(schema, name)
27
+ cols = schema[:columns].map(&:to_s)
28
+
29
+ spell_checker = DidYouMean::SpellChecker.new(dictionary: cols)
30
+ suggestions = spell_checker.correct(name.to_s)
31
+
32
+ message = "Table has no column '#{name}'."
33
+ message += " Did you mean '#{suggestions.first}'?" unless suggestions.empty?
34
+
35
+ new(message)
36
+ end
37
+ end
38
+
39
+ # Central registry of keys used to stash per-Ractor state in
40
+ # `Ractor.current[...]`. One place so the namespace stays grep-able and
41
+ # collisions are impossible. Each consumer module has a constant pointing
42
+ # here: `Diamond::RACTOR_KEYS[:engine]`, etc.
43
+ RACTOR_KEYS = {
44
+ engine: :_diamond_engine,
45
+ parser_caches: :_diamond_parser_caches,
46
+ struct_caches: :_diamond_struct_caches,
47
+ where_ops: :_diamond_where_ops,
48
+ derive_ops: :_diamond_derive_ops,
49
+ compiler_ops: :_diamond_compiler_ops,
50
+ finder_cols: :_diamond_finder_cols
51
+ }.freeze
52
+
53
+ IDENT_RE = /\A[a-zA-Z_][a-zA-Z0-9_]*\z/.freeze
54
+
55
+ @db_path = nil
56
+
57
+ # Engine tuning inherited by lazily-booted worker-Ractor engines
58
+ # (see .engine). Engine-safe keys only: on_progress procs can never
59
+ # cross Ractors and auto_fiber_yield is wake_up-only, so neither is
60
+ # stored here. Always a frozen Hash of shareable values.
61
+ @diamond_opts = {}.freeze
62
+
63
+ # Autoloads — one entry per public constant. Loading :Parser / :Compiler
64
+ # / :Domains / :DSL / :Operators pulls in the namespace AND its sibling
65
+ # implementation files via require_relative inside each namespace-opener
66
+ # (see diamond/parser.rb, diamond/compiler/base.rb, diamond/domains/dql.rb).
67
+ autoload :VERSION, 'diamond/version'
68
+ autoload :Operator, 'diamond/operator'
69
+ autoload :Engine, 'diamond/engine'
70
+ autoload :Changeset, 'diamond/changeset'
71
+ autoload :AST, 'diamond/ast'
72
+ autoload :NullTable, 'diamond/null_table'
73
+ autoload :StructJSON, 'diamond/struct_factory'
74
+ autoload :StructFactory, 'diamond/struct_factory'
75
+ autoload :Parser, 'diamond/parser'
76
+ autoload :Compiler, 'diamond/compiler/base'
77
+ autoload :Domains, 'diamond/domains/dql'
78
+ autoload :DSL, 'diamond/dsl/default'
79
+ autoload :Operators, 'diamond/operators/like'
80
+ autoload :Table, 'diamond/table'
81
+ autoload :QueryObject, 'diamond/query_object'
82
+ autoload :Cursor, 'diamond/cursor'
83
+ autoload :JsonString, 'diamond/json_string'
84
+ end
85
+
86
+ # const_missing is kept as a fallback only. wake_up's bind_tables! already
87
+ # defines every schema table; this hook only fires for typo'd constants
88
+ # (where Diamond can't help) or in older code paths. Either way it raises
89
+ # NameError cleanly instead of silently inventing proxies.
90
+ #
91
+ # Prepended to Module — must run exactly once at load time so every
92
+ # later const lookup goes through it.
93
+ module DiamondConstMissing
94
+ def const_missing(name)
95
+ table_sym = name.to_s.downcase.to_sym
96
+
97
+ # This hook is prepended to every Module, so it fires for missing
98
+ # constants anywhere (not just table proxies). When Diamond was
99
+ # never woken, the engine cannot boot (nil path) — degrade to a
100
+ # clean NameError instead of leaking a TypeError from Extralite.
101
+ engine = begin
102
+ Diamond.engine
103
+ rescue StandardError
104
+ nil
105
+ end
106
+
107
+ if engine && engine.schema_cache.key?(table_sym)
108
+ # schema had this table but it wasn't eager-bound (e.g., bind_tables!
109
+ # ran before this table existed). bind it now.
110
+ proxy = Diamond::Table.new(table_sym).freeze
111
+ Object.const_set(name, proxy)
112
+ Diamond.note_bound_table(name)
113
+ proxy
114
+ else
115
+ super
116
+ end
117
+ end
118
+ end
119
+ Module.prepend(DiamondConstMissing)
120
+
121
+ module Diamond
122
+ def self.quote_ident(name)
123
+ "\"#{name.to_s.gsub('"', '""')}\""
124
+ end
125
+
126
+ def self.validate_ident!(name, what = "identifier")
127
+ unless name.to_s.match?(IDENT_RE)
128
+ raise ArgumentError, "invalid #{what} #{name.inspect}: must match #{IDENT_RE.inspect}"
129
+ end
130
+ name
131
+ end
132
+
133
+ # Names we've bound as frozen constants, so a later wake_up can unbind them
134
+ # instead of leaving them pinned to the dead engine's schema.
135
+ @bound_tables = []
136
+
137
+ # Phase 7 + Fiber amendment: when waking up, auto-detect whether a
138
+ # Fiber::Scheduler is running and, if so, install an on_progress hook
139
+ # that yields to the scheduler while SQLite is waiting on I/O. This
140
+ # turns Diamond into a non-blocking database when used inside Async /
141
+ # Polyphony / any Fiber.scheduler-driven runtime.
142
+ def self.wake_up(db_path, busy_timeout: nil, gvl_release_threshold: nil,
143
+ on_progress: nil, extensions: [], auto_fiber_yield: true)
144
+ @db_path = db_path.freeze
145
+ # Stash engine-safe tuning for worker Ractors that boot lazily via
146
+ # .engine long after this returns. Deep-frozen (dup-freeze each
147
+ # extension path so caller-owned strings are never mutated) and
148
+ # shareable, so cross-Ractor reads never trip isolation.
149
+ @diamond_opts = {
150
+ busy_timeout: busy_timeout,
151
+ gvl_release_threshold: gvl_release_threshold,
152
+ extensions: Array(extensions).map { |p| p.dup.freeze }.freeze
153
+ }.freeze
154
+
155
+ # Phase 7: the caller may pass a custom on_progress; otherwise we
156
+ # build one that yields to the running Fiber::Scheduler when present.
157
+ # auto_fiber_yield=false opts out (e.g. for tests) — passed through
158
+ # as `false` so Engine#initialize can tell "explicitly none" apart
159
+ # from "absent" (absent + scheduler present would auto-install).
160
+ progress = on_progress
161
+ if progress.nil?
162
+ if auto_fiber_yield && Fiber.scheduler
163
+ progress = method(:fiber_yield_on_progress)
164
+ elsif !auto_fiber_yield
165
+ progress = false
166
+ end
167
+ end
168
+
169
+ # Main Ractor initializes its connection immediately for backward compat
170
+ # (so existing tests that touch `Diamond.engine` after wake_up still work).
171
+ Ractor.current[Diamond::RACTOR_KEYS[:engine]] = Engine.new(
172
+ @db_path,
173
+ busy_timeout: busy_timeout,
174
+ gvl_release_threshold: gvl_release_threshold,
175
+ on_progress: progress,
176
+ extensions: extensions
177
+ ).freeze!
178
+
179
+ # including twice is a no-op for ancestors but still busts ruby's global
180
+ # method cache. guard it so per-test wake_up stays cheap.
181
+ unless Diamond::Table.include?(Diamond::DSL::Default)
182
+ Diamond::Table.include(Diamond::DSL::Default)
183
+ Diamond::Table.include(Diamond::Domains::DQL)
184
+ Diamond::Table.include(Diamond::Domains::DML)
185
+ Diamond::Table.include(Diamond::Domains::DynamicFinders)
186
+
187
+ Diamond::QueryObject.include(Diamond::DSL::Default)
188
+ Diamond::QueryObject.include(Diamond::Domains::DQL)
189
+ Diamond::QueryObject.include(Diamond::Domains::DML)
190
+ Diamond::QueryObject.include(Diamond::Domains::DynamicFinders)
191
+
192
+ Diamond.extend(Diamond::Domains::DDL)
193
+ Diamond.extend(Diamond::Domains::CTE)
194
+ Diamond.extend(Diamond::DSL::Default)
195
+ end
196
+
197
+ rebind_tables!
198
+ bind_tables!
199
+ end
200
+
201
+ # Phase 7 Fiber-amendment hook. Called by Extralite while waiting on
202
+ # SQLite I/O. `Kernel.sleep(0)` dispatches to the running
203
+ # Fiber::Scheduler's kernel_sleep (a real yield) when inside a
204
+ # fiber, and is a fast no-op on plain threads — so one body covers
205
+ # both worlds with no scheduler sniffing here (presence was already
206
+ # checked at install time). Ends falsy; the return value is never
207
+ # an abort signal. `Kernel.` prefix is mandatory: bare `sleep`
208
+ # would resolve to Diamond.sleep itself and recurse forever.
209
+ # Defaulted arg: Extralite 3.0.1 invokes the handler with zero
210
+ # arguments (the documented busy flag is not passed).
211
+ def self.fiber_yield_on_progress(_busy = nil)
212
+ Kernel.sleep(0)
213
+ nil
214
+ end
215
+
216
+ # drop old constants so the next reference re-resolves (or raises
217
+ # NameError) instead of serving the dead engine's table.
218
+ def self.rebind_tables!
219
+ @bound_tables.each do |const_name|
220
+ Object.send(:remove_const, const_name) if Object.const_defined?(const_name, false)
221
+ end
222
+ @bound_tables.clear
223
+ end
224
+
225
+ # eager-bind every schema table as a frozen top-level constant. Ractor-safe
226
+ # because the Table object is immutable after .freeze, and the engine's
227
+ # schema cache is frozen (see Engine#freeze!).
228
+ def self.bind_tables!
229
+ return unless Ractor.current[Diamond::RACTOR_KEYS[:engine]]
230
+ Ractor.current[Diamond::RACTOR_KEYS[:engine]].schema_cache.each_key do |table_sym|
231
+ const_name = table_sym.to_s.split('_').map(&:capitalize).join
232
+ next if Object.const_defined?(const_name, false)
233
+ proxy = Table.new(table_sym).freeze
234
+ Object.const_set(const_name, proxy)
235
+ @bound_tables << const_name unless @bound_tables.include?(const_name)
236
+ end
237
+ end
238
+
239
+ def self.note_bound_table(name)
240
+ @bound_tables << name unless @bound_tables.include?(name)
241
+ end
242
+
243
+ # Per-Ractor engine. Each Ractor opens its own Extralite::Database connection
244
+ # lazily on first access. The shared schema/FK caches are loaded from the
245
+ # same DB file/connection-string, so every Ractor sees the same logical
246
+ # schema — but live query execution goes through each Ractor's own connection.
247
+ def self.engine
248
+ # Workers inherit the main Ractor's engine tuning (busy_timeout,
249
+ # gvl_release_threshold, extensions) stashed at wake_up. Hooks are
250
+ # deliberately NOT inherited: procs can't cross Ractors, and each
251
+ # engine re-runs presence detection for its own Ractor (which has
252
+ # no scheduler, so no hook — same as before).
253
+ engine = Ractor.current[Diamond::RACTOR_KEYS[:engine]] ||= Engine.new(@db_path, **@diamond_opts).freeze!
254
+ # Late-bind the yield hook if a scheduler appeared after this
255
+ # engine booted (no-op when already installed, opted out, or
256
+ # schedulerless — see Engine#sync_scheduler_hook!).
257
+ engine.sync_scheduler_hook!
258
+ engine
259
+ end
260
+
261
+ # Phase 7: backup to a file or another Engine. Block (if given) gets
262
+ # called with (remaining, total_pages) on each step.
263
+ def self.backup(target, &block)
264
+ engine.db.backup(target, &block)
265
+ end
266
+
267
+ # Phase 7: track changes to the given tables inside a block. Returns
268
+ # a Diamond::Changeset wrapper around the underlying Extralite
269
+ # changeset. Use .apply(target_db) to replay on another connection,
270
+ # .invert to undo, .to_blob / .load(blob) to serialize over the wire.
271
+ #
272
+ # Requires the extralite-bundle build (sqlite3 session extension).
273
+ # Plain extralite 3.0.1 does NOT include track_changes — calling
274
+ # here raises FeatureNotAvailableError.
275
+ def self.track_changes(*tables, &block)
276
+ raise ArgumentError, "track_changes requires a block" unless block
277
+ unless defined?(Extralite::Changeset)
278
+ raise FeatureNotAvailableError,
279
+ "track_changes requires extralite-bundle (sqlite3 session extension). " \
280
+ "Install with `gem install extralite-bundle` and activate it with " \
281
+ "`gem \"extralite-bundle\"` before `require \"extralite\"`."
282
+ end
283
+ # NOTE: the block must run *inside* Changeset#track — that method
284
+ # creates the sqlite session, yields, materializes via
285
+ # sqlite3session_changeset, and tears down in ensure. The db-level
286
+ # track_changes never yields, so calling it blockless dies in
287
+ # rb_yield (LocalJumpError) and calling it first would leave the
288
+ # session closed before the block runs (empty changeset).
289
+ raw = Extralite::Changeset.new
290
+ raw.track(engine.db, tables, &block)
291
+ Diamond::Changeset.new(raw)
292
+ end
293
+
294
+ RUNTIME_STATUS_CODES = {
295
+ memory_used: Extralite::SQLITE_STATUS_MEMORY_USED,
296
+ pagecache_used: Extralite::SQLITE_STATUS_PAGECACHE_USED,
297
+ pagecache_overflow: Extralite::SQLITE_STATUS_PAGECACHE_OVERFLOW,
298
+ scratch_used: Extralite::SQLITE_STATUS_SCRATCH_USED,
299
+ scratch_overflow: Extralite::SQLITE_STATUS_SCRATCH_OVERFLOW,
300
+ malloc_size: Extralite::SQLITE_STATUS_MALLOC_SIZE,
301
+ parser_stack: Extralite::SQLITE_STATUS_PARSER_STACK
302
+ }.freeze
303
+
304
+ # Phase 7: run a query with a wall-clock timeout. After `seconds`, a
305
+ # background timer calls Diamond.engine.db.interrupt; the query raises
306
+ # Extralite::InterruptError which we re-wrap as our own error class.
307
+ def self.query_with_timeout(seconds, &block)
308
+ raise ArgumentError, "query_with_timeout requires a block" unless block
309
+ return block.call if seconds.nil? || seconds <= 0
310
+
311
+ timer = Thread.new do
312
+ Kernel.sleep(seconds)
313
+ Diamond.engine.db.interrupt
314
+ end
315
+ begin
316
+ block.call
317
+ rescue Extralite::InterruptError => e
318
+ raise QueryInterruptedError, "Query interrupted after #{seconds}s: #{e.message}"
319
+ ensure
320
+ timer.kill rescue nil
321
+ timer.join rescue nil
322
+ end
323
+ end
324
+
325
+ # Raised by query_with_timeout when the wall-clock deadline fires.
326
+ class QueryInterruptedError < Error; end
327
+
328
+ # Feature gate: raises if the running Extralite build doesn't expose
329
+ # the requested feature. Use for changesets, etc. that require the
330
+ # extralite-bundle build.
331
+ class FeatureNotAvailableError < Error; end
332
+
333
+ # Phase 7 Fiber amendment + Kino integration. Prefer Kino.sleep when
334
+ # available — it uses the OS clock instead of MRI's coarse timer and
335
+ # is correct inside non-main Ractors. Otherwise Kernel.sleep, which
336
+ # dispatches to the running Fiber::Scheduler's kernel_sleep on its
337
+ # own when one is present (no explicit scheduler branch needed) and
338
+ # sleeps normally when not.
339
+ # `Kernel.sleep` explicitly: bare `sleep` here would resolve to
340
+ # Diamond.sleep itself (self == Diamond) and recurse forever.
341
+ def self.sleep(seconds)
342
+ if defined?(Kino) && Kino.respond_to?(:sleep)
343
+ Kino.sleep(seconds)
344
+ else
345
+ Kernel.sleep(seconds)
346
+ end
347
+ end
348
+
349
+ # Wrap a block in BEGIN/COMMIT. Rolls back on exception.
350
+ #
351
+ # Diamond.transaction do
352
+ # Users.create(name: 'A')
353
+ # Posts.create(title: 'B', user_id: 1)
354
+ # end
355
+ #
356
+ # Pass a mode for explicit transaction type:
357
+ # Diamond.transaction(:deferred) { ... }
358
+ # Diamond.transaction(:immediate) { ... } # default
359
+ # Diamond.transaction(:exclusive) { ... }
360
+ #
361
+ # Returns the block's return value on commit, re-raises on rollback.
362
+ def self.transaction(mode = :immediate, &block)
363
+ raise ArgumentError, "transaction requires a block" unless block
364
+ engine.db.transaction(mode) { block.call }
365
+ end
366
+
367
+ # Phase 7: nested transactions via SQLite savepoints.
368
+ # Diamond.transaction do
369
+ # Users.create(name: 'A')
370
+ # Diamond.savepoint(:risky_op) do
371
+ # Users.create(name: 'Bad')
372
+ # raise 'oops'
373
+ # end # rolls back to savepoint
374
+ # end # commits Users.create(:A)
375
+ def self.savepoint(name, &block)
376
+ raise ArgumentError, "savepoint requires a block" unless block
377
+ db = engine.db
378
+ db.savepoint(name)
379
+ begin
380
+ result = block.call
381
+ db.release(name)
382
+ result
383
+ rescue StandardError
384
+ db.rollback_to(name)
385
+ raise
386
+ end
387
+ end
388
+
389
+ # table names the calling Ractor's engine knows about, sorted.
390
+ def self.tables
391
+ engine.schema_cache.keys.sort
392
+ end
393
+
394
+ # Phase 7: concurrency tuning shortcuts
395
+ def self.busy_timeout=(val); engine.busy_timeout = val; end
396
+ def self.busy_timeout; engine.busy_timeout; end
397
+ def self.gvl_release_threshold=(val); engine.gvl_release_threshold = val; end
398
+ def self.gvl_release_threshold; engine.gvl_release_threshold; end
399
+
400
+ # Phase 7: progress hook
401
+ def self.on_progress(&block); engine.on_progress(&block); end
402
+
403
+ # Phase 7: load extension
404
+ def self.load_extension(path); engine.load_extension(path); end
405
+
406
+ # Phase 7: tracing
407
+ def self.trace(&block)
408
+ if block
409
+ engine.db.trace(&block)
410
+ else
411
+ engine.db.trace
412
+ end
413
+ end
414
+
415
+ # Phase 7: runtime status and limits
416
+ def self.runtime_status(code)
417
+ Extralite.runtime_status(RUNTIME_STATUS_CODES.fetch(code))
418
+ end
419
+
420
+ def self.status(code)
421
+ engine.db.status(Engine::DBSTATUS_CODES.fetch(code))
422
+ end
423
+
424
+ def self.limit(code, value = nil)
425
+ engine.db.limit(code, value)
426
+ end
427
+
428
+ # drop everything derived from the old schema. runs on reload_schema!.
429
+ # per-Ractor: clears only the calling Ractor's parser/struct/finder
430
+ # caches. operator registries are untouched (use their `clear!`).
431
+ def self.clear_caches!
432
+ Parser.clear_caches!
433
+ StructFactory.clear_caches!
434
+ Domains::DynamicFinders.clear_caches!
435
+ end
436
+ end