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,395 @@
1
+ module Diamond
2
+ module Domains
3
+ require_relative 'dml'
4
+ require_relative 'ddl'
5
+ require_relative 'cte'
6
+ require_relative 'dynamic_finders'
7
+
8
+ module DQL
9
+ # how each chainable node reconciles with the nodes already on the
10
+ # chain. builders validate args and construct nodes; _chain applies
11
+ # the strategy. new AST node types declare one line here (unknown
12
+ # classes accumulate).
13
+ RECONCILE = {
14
+ AST::Where => :accumulate,
15
+ AST::Join => :accumulate,
16
+ AST::With => :accumulate,
17
+ AST::Order => :merge,
18
+ AST::Limit => :replace,
19
+ AST::Offset => :replace,
20
+ AST::GroupBy => :replace,
21
+ AST::Having => :replace,
22
+ AST::Projection => :once,
23
+ AST::Distinct => :replace
24
+ }.freeze
25
+
26
+ # the one operation every chain call funnels through. wraps a bare
27
+ # Table into a QueryObject, then reconciles the node into the ast.
28
+ # `.or` passes strategy: :fold_or explicitly — the single escape
29
+ # hatch, since folding rewrites history instead of appending.
30
+ def _chain(node, strategy: nil)
31
+ strategy ||= RECONCILE.fetch(node.class, :accumulate)
32
+ table = self.is_a?(Diamond::Table) ? self : @table
33
+ base = self.is_a?(Diamond::Table) ? [] : @ast
34
+ q = Diamond::QueryObject.new(table, DQL.apply_strategy(strategy, base, node))
35
+ # Phase 7: carry the current mode across chain calls (so .first /
36
+ # .last / .limit / etc. don't reset .as_hash to .struct).
37
+ if self.is_a?(Diamond::QueryObject)
38
+ q.instance_variable_set(:@mode, @mode)
39
+ end
40
+ q
41
+ end
42
+
43
+ def self.apply_strategy(strategy, ast, node)
44
+ case strategy
45
+ when :accumulate
46
+ ast + [node]
47
+ when :replace
48
+ ast.reject { |n| n.instance_of?(node.class) } + [node]
49
+ when :merge # Order: concat specs into a single node
50
+ existing = ast.find { |n| n.is_a?(AST::Order) }
51
+ merged = AST::Order.new((existing ? existing.specs : []) + node.specs)
52
+ ast.reject { |n| n.is_a?(AST::Order) } + [merged]
53
+ when :once # Projection: second one is a usage error
54
+ raise "derive() called twice; use it once on each chain" if ast.any? { |n| n.is_a?(AST::Projection) }
55
+
56
+ ast + [node]
57
+ when :fold_or # .or: merge into the last Where, else append fresh
58
+ idx = ast.rindex { |n| n.is_a?(AST::Where) }
59
+ if idx
60
+ combined = AST::Or.new(ast[idx].condition, node.condition)
61
+ duped = ast.dup
62
+ duped[idx] = AST::Where.new(combined)
63
+ duped
64
+ else
65
+ ast + [node]
66
+ end
67
+ end
68
+ end
69
+
70
+ def _build_where(&block)
71
+ condition = Parser.parse_block(block, _schema_for_dsl, _scope_for_dsl)
72
+ raise "Where block must return an AST condition" unless condition.is_a?(AST::Node)
73
+ _chain(AST::Where.new(condition))
74
+ end
75
+
76
+ def _build_or_where(&block)
77
+ condition = Parser.parse_block(block, _schema_for_dsl, _scope_for_dsl)
78
+ raise "Or block must return an AST condition" unless condition.is_a?(AST::Node)
79
+ _chain(AST::Where.new(condition), strategy: :fold_or)
80
+ end
81
+
82
+ def _build_where_node(condition_node)
83
+ raise "Where node must be an AST::Node" unless condition_node.is_a?(AST::Node)
84
+ _chain(AST::Where.new(condition_node))
85
+ end
86
+
87
+ # Operators allowed as `{ op: value }` hashes inside where-hash
88
+ # values. Anything else raises with this list in the message.
89
+ HASH_OPERATORS = %i[not gt gte lt lte in nin like].freeze
90
+
91
+ def dispatch_where_arg(arg)
92
+ case arg
93
+ when Hash then _build_hash_where(arg)
94
+ when Array then _build_or_chain(arg)
95
+ else raise ArgumentError, "where expects a Hash, Array of Hashes, or a block, got #{arg.class}"
96
+ end
97
+ end
98
+
99
+ # Single hash: AND across columns.
100
+ def _build_hash_where(hash)
101
+ raise ArgumentError, "where hash must not be empty" if hash.empty?
102
+ _build_where_node(combine_and(hash.map { |col, val| hash_condition(col, val) }))
103
+ end
104
+
105
+ # Array of hashes: OR across hashes, AND within each hash.
106
+ def _build_or_chain(hashes)
107
+ raise ArgumentError, "where array must not be empty" if hashes.empty?
108
+ unless hashes.all? { |h| h.is_a?(Hash) }
109
+ raise ArgumentError, "where array must contain only Hashes"
110
+ end
111
+ node = combine_and(hashes.first.map { |col, val| hash_condition(col, val) })
112
+ hashes.drop(1).each do |h|
113
+ node = AST::Or.new(node, combine_and(h.map { |col, val| hash_condition(col, val) }))
114
+ end
115
+ _build_where_node(node)
116
+ end
117
+
118
+ def combine_and(conds)
119
+ raise ArgumentError, "where hash must not be empty" if conds.empty?
120
+ conds.reduce { |acc, c| AST::And.new(acc, c) }
121
+ end
122
+
123
+ # One column => one AST condition node. Values take runtime data
124
+ # directly (no parsing), so locals, method returns, and constants
125
+ # all work here — this is the value path blocks can't provide.
126
+ def hash_condition(col, val)
127
+ col = col.to_sym
128
+ Diamond::Parser.validate_column!(col, _schema_for_dsl)
129
+ column = AST::Column.new(col)
130
+ case val
131
+ when Hash
132
+ unknown = val.keys.map(&:to_sym) - HASH_OPERATORS
133
+ unless unknown.empty?
134
+ raise ArgumentError, "unknown where operator(s) #{unknown.inspect} on #{col}; allowed: #{HASH_OPERATORS.inspect}"
135
+ end
136
+ combine_and(val.map { |op, v| hash_operator(column, col, op.to_sym, v) })
137
+ when Range
138
+ if val.begin.nil? || val.end.nil?
139
+ raise ArgumentError, "where range on #{col} must have both bounds (no endless/beginless ranges)"
140
+ end
141
+ AST::Between.new(column, AST::Literal.new(val.begin), AST::Literal.new(val.end))
142
+ when Array
143
+ # Empty arrays compile to 1=0 natively (same as where_in) —
144
+ # no special case needed.
145
+ AST::In.new(column, val.map { |v| AST::Literal.new(v) })
146
+ when nil
147
+ AST::IsNull.new(column)
148
+ else
149
+ AST::Equality.new(column, AST::Literal.new(val))
150
+ end
151
+ end
152
+
153
+ def hash_operator(column, col, op, v)
154
+ case op
155
+ when :not
156
+ if v.nil?
157
+ AST::IsNotNull.new(column)
158
+ elsif v.is_a?(Array)
159
+ AST::NotIn.new(column, v.map { |e| AST::Literal.new(e) })
160
+ else
161
+ AST::NotEqual.new(column, AST::Literal.new(v))
162
+ end
163
+ when :gt then AST::GreaterThan.new(column, AST::Literal.new(v))
164
+ when :gte then AST::GreaterEqual.new(column, AST::Literal.new(v))
165
+ when :lt then AST::LessThan.new(column, AST::Literal.new(v))
166
+ when :lte then AST::LessEqual.new(column, AST::Literal.new(v))
167
+ when :in then AST::In.new(column, Array(v).map { |e| AST::Literal.new(e) })
168
+ when :nin then AST::NotIn.new(column, Array(v).map { |e| AST::Literal.new(e) })
169
+ when :like then AST::Like.new(column, AST::Literal.new(v))
170
+ end
171
+ end
172
+
173
+ def _build_projection(*args, &block)
174
+ nodes = if args.empty? && block_given?
175
+ Parser.parse_derive(block, _schema_for_dsl)
176
+ else
177
+ args.map do |a|
178
+ if a.is_a?(Symbol)
179
+ unless _schema_for_dsl[:columns].include?(a)
180
+ raise Diamond::UnknownColumnError.build(_schema_for_dsl, a)
181
+ end
182
+ AST::Column.new(a)
183
+ elsif a.is_a?(Array) && a.size == 2
184
+ _qualified_pair_column(a[0].to_sym, a[1].to_sym)
185
+ else
186
+ a
187
+ end
188
+ end
189
+ end
190
+ _chain(AST::Projection.new(nodes))
191
+ end
192
+
193
+ def _build_join(table_name, type, on, eager: false, single: nil, as: nil)
194
+ if on.nil?
195
+ on = _resolve_join_keys(table_name)
196
+ end
197
+ single = _detect_single_join(table_name) if single.nil?
198
+ _chain(AST::Join.new(table_name, type, on, eager: eager, single: single, as: as))
199
+ end
200
+
201
+ # `.includes(:posts)` is sugar for `.join(:posts, eager: true)`.
202
+ # supports a single table or a list. each eagerly-loaded child becomes
203
+ # a nested struct array on the parent.
204
+ # Auto-detects cardinality from FKs: belongs_to -> single, has_many -> collection.
205
+ # `as:` renames the struct member (only meaningful for a single table).
206
+ def _build_includes(*tables, **opts)
207
+ single = opts.fetch(:single, nil)
208
+ as = opts.fetch(:as, nil)
209
+ if as && tables.size > 1
210
+ raise ArgumentError, "includes with `as:` only supports a single table, got #{tables.size}"
211
+ end
212
+ result = self
213
+ tables.each do |table_name|
214
+ result = result._build_join(table_name, :left, nil, eager: true, single: single, as: as)
215
+ end
216
+ result
217
+ end
218
+
219
+ def _build_order(*args, **kwargs)
220
+ _chain(AST::Order.new(_order_pairs(*args, **kwargs)))
221
+ end
222
+
223
+ # Replace any existing ordering (order merges). Bare call with
224
+ # no pairs clears ordering entirely.
225
+ def _build_reorder(*args, **kwargs)
226
+ pairs = args.empty? && kwargs.empty? ? [] : _order_pairs(*args, **kwargs)
227
+ _chain(AST::Order.new(pairs), strategy: :replace)
228
+ end
229
+
230
+ # Pure pair parsing shared by order (merge) and reorder
231
+ # (replace). Same validation, same errors.
232
+ def _order_pairs(*args, **kwargs)
233
+ pairs = []
234
+ args.each do |a|
235
+ qualified = _qualified_sort_pair(a)
236
+ if qualified
237
+ pairs << qualified
238
+ next
239
+ end
240
+ sym, dir =
241
+ if a.is_a?(Array) && a.size == 2 && a[1].is_a?(Symbol)
242
+ [a[0].to_sym, a[1]]
243
+ else
244
+ [a.to_sym, :asc]
245
+ end
246
+ raise Diamond::UnknownColumnError.build(_schema_for_dsl, sym) unless _schema_for_dsl[:columns].include?(sym)
247
+ unless %i[asc desc].include?(dir)
248
+ raise ArgumentError, "direction must be :asc or :desc, got #{dir.inspect}"
249
+ end
250
+ pairs << [sym, dir]
251
+ end
252
+ kwargs.each do |col, direction|
253
+ sym = col.to_sym
254
+ raise Diamond::UnknownColumnError.build(_schema_for_dsl, sym) unless _schema_for_dsl[:columns].include?(sym)
255
+ d = direction.to_s.downcase.to_sym
256
+ unless %i[asc desc].include?(d)
257
+ raise ArgumentError, "direction must be :asc or :desc, got #{direction.inspect}"
258
+ end
259
+ pairs << [sym, d]
260
+ end
261
+ raise ArgumentError, "order requires at least one column" if pairs.empty?
262
+
263
+ pairs
264
+ end
265
+
266
+ def _build_limit(n)
267
+ raise ArgumentError, "limit must be Integer >= 0, got #{n.inspect}" unless n.is_a?(Integer) && n >= 0
268
+ _chain(AST::Limit.new(n))
269
+ end
270
+
271
+ def _build_offset(n)
272
+ raise ArgumentError, "offset must be Integer >= 0, got #{n.inspect}" unless n.is_a?(Integer) && n >= 0
273
+ _chain(AST::Offset.new(n))
274
+ end
275
+
276
+ def _build_group(columns)
277
+ validated = columns.map do |c|
278
+ if c.is_a?(Array)
279
+ unless c.size == 2
280
+ raise ArgumentError, "group takes columns or [table, column] pairs, got #{c.inspect}"
281
+ end
282
+ _qualified_pair_column(c[0].to_sym, c[1].to_sym)
283
+ else
284
+ sym = c.to_sym
285
+ raise Diamond::UnknownColumnError.build(_schema_for_dsl, sym) unless _schema_for_dsl[:columns].include?(sym)
286
+ sym
287
+ end
288
+ end
289
+ _chain(AST::GroupBy.new(validated))
290
+ end
291
+
292
+ def _build_having(&block)
293
+ condition = Parser.parse_block(block, _schema_for_dsl, _scope_for_dsl)
294
+ raise "Having block must return an AST condition" unless condition.is_a?(AST::Node)
295
+ _chain(AST::Having.new(condition))
296
+ end
297
+
298
+ # --- Context Hooks (Used by the DSL modules) ---
299
+ def _schema_for_dsl
300
+ self.is_a?(Diamond::Table) ? @schema : @table.schema
301
+ end
302
+
303
+ # tables the current chain can filter on: the base table plus every
304
+ # joined table, each mapped to its schema. lets `tags.tag` in a
305
+ # block resolve to a qualified column. unknown join targets are
306
+ # skipped (their own errors surface at compile time).
307
+ def _scope_for_dsl
308
+ base = self.is_a?(Diamond::Table) ? @name : @table.name
309
+ scope = { base => _schema_for_dsl }
310
+ joins = self.is_a?(Diamond::Table) ? [] : @ast.select { |n| n.is_a?(AST::Join) }
311
+ joins.each do |j|
312
+ sch = Diamond.engine.schema_cache[j.table_name]
313
+ scope[j.table_name] = sch if sch
314
+ end
315
+ scope
316
+ end
317
+
318
+ private
319
+
320
+ # [table, col] or [table, col, dir] in order/group/derive args.
321
+ # returns [AST::Column(table:), dir] or nil when `a` isn't a
322
+ # qualified shape (caller falls back to the plain path). bare
323
+ # [col, :asc/:desc] pairs keep their old meaning.
324
+ def _qualified_sort_pair(a)
325
+ return nil unless a.is_a?(Array) && (a.size == 2 || a.size == 3)
326
+ return nil unless a[1].is_a?(Symbol) && !%i[asc desc].include?(a[1])
327
+
328
+ dir = a.size == 3 ? a[2] : :asc
329
+ unless %i[asc desc].include?(dir)
330
+ raise ArgumentError, "direction must be :asc or :desc, got #{dir.inspect}"
331
+ end
332
+ [_qualified_pair_column(a[0].to_sym, a[1].to_sym), dir]
333
+ end
334
+
335
+ # validate a [table, column] pair against the chain scope and build
336
+ # a qualified Column. join-first rule mirrors where-blocks.
337
+ def _qualified_pair_column(t, c)
338
+ scope = _scope_for_dsl
339
+ if scope.key?(t)
340
+ sch = scope[t]
341
+ raise Diamond::UnknownColumnError.build(sch, c) unless sch[:columns].include?(c)
342
+
343
+ AST::Column.new(c, table: t)
344
+ elsif Diamond.engine.schema_cache.key?(t)
345
+ raise ArgumentError,
346
+ "using '#{t}.#{c}' needs `.join(:#{t})` first " \
347
+ "(joins must come before the clause that uses them)"
348
+ else
349
+ raise ArgumentError, "unknown table '#{t}' in [table, column] pair"
350
+ end
351
+ end
352
+
353
+ # Convention for the returned `on` hash (consumed by
354
+ # Compiler::DQL.render_on_clauses): `{ <joined_local> => <current_ref> }`.
355
+ # Either side of the FK edge can hold the local column, so normalize.
356
+ def _resolve_join_keys(target_table)
357
+ current = _current_table_name
358
+ fks = Diamond.engine.foreign_keys
359
+
360
+ if fks[current]
361
+ fks[current].each do |fk|
362
+ return { fk[:ref_col] => fk[:local] } if fk[:ref_table] == target_table
363
+ end
364
+ end
365
+
366
+ if fks[target_table]
367
+ fks[target_table].each do |fk|
368
+ return { fk[:local] => fk[:ref_col] } if fk[:ref_table] == current
369
+ end
370
+ end
371
+
372
+ raise Diamond::TableNotFound,
373
+ "No foreign key connects '#{current}' and '#{target_table}'. " \
374
+ "Pass `on: { <local>: <ref> }` explicitly."
375
+ end
376
+
377
+ # Detect if join is to-one (belongs_to) vs to-many (has_many)
378
+ # Current table has FK to target -> belongs_to -> single (to-one)
379
+ # Target table has FK to current -> has_many -> collection (to-many)
380
+ # Default: collection (safer)
381
+ def _detect_single_join(target_table)
382
+ current = _current_table_name
383
+ fks = Diamond.engine.foreign_keys
384
+
385
+ return true if fks[current]&.any? { |fk| fk[:ref_table] == target_table }
386
+ return false if fks[target_table]&.any? { |fk| fk[:ref_table] == current }
387
+ false
388
+ end
389
+
390
+ def _current_table_name
391
+ self.is_a?(Diamond::Table) ? @name : @table.name
392
+ end
393
+ end
394
+ end
395
+ end
@@ -0,0 +1,64 @@
1
+ module Diamond
2
+ module Domains
3
+ module DynamicFinders
4
+ FINDER_PREFIX = 'by_'.freeze
5
+ AND_SEPARATOR = '_and_'.freeze
6
+ CACHES_KEY = Diamond::RACTOR_KEYS[:finder_cols]
7
+
8
+ # Per-Ractor cache for finder name -> column symbols. the parse is pure
9
+ # (name only), so a hot `by_id` loop doesn't re-split every call.
10
+ # validation still runs per call against the live schema.
11
+ def self.cache
12
+ Ractor.current[CACHES_KEY] ||= {}
13
+ end
14
+
15
+ def self.clear_caches!
16
+ Ractor.current[CACHES_KEY] = {}
17
+ end
18
+
19
+ def method_missing(name, *args, &block)
20
+ method_str = name.to_s
21
+ return super unless method_str.start_with?(FINDER_PREFIX)
22
+
23
+ cache = DynamicFinders.cache
24
+ columns = cache[method_str]
25
+ unless columns
26
+ column_strs = method_str.delete_prefix(FINDER_PREFIX).split(AND_SEPARATOR)
27
+ if column_strs.empty?
28
+ raise ArgumentError, "Invalid dynamic finder '#{name}': no columns after prefix"
29
+ end
30
+ columns = column_strs.map(&:to_sym)
31
+ cache[method_str] = columns
32
+ end
33
+ unless args.size == columns.size
34
+ raise ArgumentError,
35
+ "wrong number of arguments for #{name} (given #{args.size}, expected #{columns.size})"
36
+ end
37
+
38
+ columns.each do |col|
39
+ unless _schema_for_dsl[:columns].include?(col)
40
+ raise Diamond::UnknownColumnError.build(_schema_for_dsl, col)
41
+ end
42
+ end
43
+
44
+ pairs = columns.zip(args)
45
+ condition = pairs.drop(1).reduce(build_equality(pairs.first)) do |acc, pair|
46
+ AST::And.new(acc, build_equality(pair))
47
+ end
48
+
49
+ _chain(AST::Where.new(condition))
50
+ end
51
+
52
+ def respond_to_missing?(name, include_private = false)
53
+ name.to_s.start_with?(FINDER_PREFIX) || super
54
+ end
55
+
56
+ private
57
+
58
+ def build_equality(pair)
59
+ col, val = pair
60
+ AST::Equality.new(AST::Column.new(col), AST::Literal.new(val))
61
+ end
62
+ end
63
+ end
64
+ end