orfeas_lyra 0.6.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/CHANGELOG.md +222 -0
- data/LICENSE +21 -0
- data/README.md +1165 -0
- data/Rakefile +728 -0
- data/app/controllers/lyra/application_controller.rb +23 -0
- data/app/controllers/lyra/dashboard_controller.rb +624 -0
- data/app/controllers/lyra/flow_controller.rb +224 -0
- data/app/controllers/lyra/privacy_controller.rb +182 -0
- data/app/views/lyra/dashboard/audit_trail.html.erb +324 -0
- data/app/views/lyra/dashboard/discrepancies.html.erb +125 -0
- data/app/views/lyra/dashboard/event_graph_view.html.erb +525 -0
- data/app/views/lyra/dashboard/heatmap_view.html.erb +155 -0
- data/app/views/lyra/dashboard/index.html.erb +119 -0
- data/app/views/lyra/dashboard/model_overview.html.erb +115 -0
- data/app/views/lyra/dashboard/projections.html.erb +302 -0
- data/app/views/lyra/dashboard/schema.html.erb +283 -0
- data/app/views/lyra/dashboard/schema_history.html.erb +78 -0
- data/app/views/lyra/dashboard/schema_version.html.erb +340 -0
- data/app/views/lyra/dashboard/verification.html.erb +370 -0
- data/app/views/lyra/flow/crud_mapping.html.erb +125 -0
- data/app/views/lyra/flow/timeline.html.erb +260 -0
- data/app/views/lyra/privacy/pii_detection.html.erb +148 -0
- data/app/views/lyra/privacy/policy.html.erb +188 -0
- data/app/workflows/es_async_mode_workflow.rb +80 -0
- data/app/workflows/es_sync_mode_workflow.rb +64 -0
- data/app/workflows/hijack_mode_workflow.rb +54 -0
- data/app/workflows/lifecycle_workflow.rb +43 -0
- data/app/workflows/monitor_mode_workflow.rb +39 -0
- data/config/privacy_policies.rb +273 -0
- data/config/routes.rb +48 -0
- data/lib/lyra/aggregate.rb +131 -0
- data/lib/lyra/associations/event_aware.rb +225 -0
- data/lib/lyra/command.rb +81 -0
- data/lib/lyra/command_handler.rb +155 -0
- data/lib/lyra/configuration.rb +124 -0
- data/lib/lyra/consistency/read_your_writes.rb +91 -0
- data/lib/lyra/correlation.rb +144 -0
- data/lib/lyra/dual_view.rb +231 -0
- data/lib/lyra/engine.rb +67 -0
- data/lib/lyra/event.rb +71 -0
- data/lib/lyra/event_analyzer.rb +135 -0
- data/lib/lyra/event_flow.rb +449 -0
- data/lib/lyra/event_mapper.rb +106 -0
- data/lib/lyra/event_store_adapter.rb +72 -0
- data/lib/lyra/id_generator.rb +137 -0
- data/lib/lyra/interceptors/association_interceptor.rb +169 -0
- data/lib/lyra/interceptors/crud_interceptor.rb +543 -0
- data/lib/lyra/privacy/gdpr_compliance.rb +161 -0
- data/lib/lyra/privacy/pii_detector.rb +85 -0
- data/lib/lyra/privacy/pii_masker.rb +66 -0
- data/lib/lyra/privacy/policy_integration.rb +253 -0
- data/lib/lyra/projection.rb +94 -0
- data/lib/lyra/projections/async_projection_job.rb +63 -0
- data/lib/lyra/projections/cached_projection.rb +322 -0
- data/lib/lyra/projections/cached_relation.rb +757 -0
- data/lib/lyra/projections/event_store_reader.rb +127 -0
- data/lib/lyra/projections/model_projection.rb +143 -0
- data/lib/lyra/schema/diff.rb +331 -0
- data/lib/lyra/schema/event_class_registrar.rb +63 -0
- data/lib/lyra/schema/generator.rb +190 -0
- data/lib/lyra/schema/reporter.rb +188 -0
- data/lib/lyra/schema/store.rb +156 -0
- data/lib/lyra/schema/validator.rb +100 -0
- data/lib/lyra/strict_data_access.rb +363 -0
- data/lib/lyra/verification/crud_lifecycle_workflow.rb +456 -0
- data/lib/lyra/verification/workflow_generator.rb +540 -0
- data/lib/lyra/version.rb +3 -0
- data/lib/lyra/visualization/activity_heatmap.rb +215 -0
- data/lib/lyra/visualization/event_graph.rb +310 -0
- data/lib/lyra/visualization/timeline.rb +398 -0
- data/lib/lyra.rb +150 -0
- data/lib/tasks/dist.rake +391 -0
- data/lib/tasks/gems.rake +185 -0
- data/lib/tasks/lyra_schema.rake +231 -0
- data/lib/tasks/lyra_workflows.rake +452 -0
- data/lib/tasks/public_release.rake +351 -0
- data/lib/tasks/stats.rake +175 -0
- data/lib/tasks/testbed.rake +479 -0
- data/lib/tasks/version.rake +159 -0
- metadata +221 -0
|
@@ -0,0 +1,757 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lyra
|
|
4
|
+
module Projections
|
|
5
|
+
# ActiveRecord::Relation-like wrapper for cached projection results.
|
|
6
|
+
#
|
|
7
|
+
# Enables method chaining on cached results so that code written for
|
|
8
|
+
# ActiveRecord works transparently in disabled projections mode.
|
|
9
|
+
#
|
|
10
|
+
# Usage:
|
|
11
|
+
# relation = CachedRelation.new(User, records)
|
|
12
|
+
# relation.where(status: "active").order(:name).limit(10)
|
|
13
|
+
#
|
|
14
|
+
class CachedRelation
|
|
15
|
+
include Enumerable
|
|
16
|
+
|
|
17
|
+
attr_reader :model_class, :records
|
|
18
|
+
|
|
19
|
+
def initialize(model_class, records = [])
|
|
20
|
+
@model_class = model_class
|
|
21
|
+
@records = records.to_a
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# =========================================================================
|
|
25
|
+
# Enumerable / Array-like interface
|
|
26
|
+
# =========================================================================
|
|
27
|
+
|
|
28
|
+
def each(&block)
|
|
29
|
+
@records.each(&block)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def to_a
|
|
33
|
+
@records.dup
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def to_ary
|
|
37
|
+
to_a
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def [](index)
|
|
41
|
+
@records[index]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def size
|
|
45
|
+
@records.size
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def length
|
|
49
|
+
size
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def count(column_name = nil, &block)
|
|
53
|
+
if block_given?
|
|
54
|
+
@records.count(&block)
|
|
55
|
+
elsif column_name
|
|
56
|
+
@records.count { |r| r.send(column_name).present? }
|
|
57
|
+
else
|
|
58
|
+
@records.size
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def empty?
|
|
63
|
+
@records.empty?
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def any?(&block)
|
|
67
|
+
block_given? ? @records.any?(&block) : @records.any?
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def none?(&block)
|
|
71
|
+
block_given? ? @records.none?(&block) : @records.none?
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def one?(&block)
|
|
75
|
+
block_given? ? @records.one?(&block) : @records.one?
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def many?
|
|
79
|
+
@records.size > 1
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def present?
|
|
83
|
+
@records.present?
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def blank?
|
|
87
|
+
@records.blank?
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# =========================================================================
|
|
91
|
+
# Finder methods
|
|
92
|
+
# =========================================================================
|
|
93
|
+
|
|
94
|
+
def first(limit = nil)
|
|
95
|
+
limit ? @records.first(limit) : @records.first
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def last(limit = nil)
|
|
99
|
+
limit ? @records.last(limit) : @records.last
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def second
|
|
103
|
+
@records[1]
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def third
|
|
107
|
+
@records[2]
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def take(limit = nil)
|
|
111
|
+
limit ? @records.first(limit) : @records.first
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def take!
|
|
115
|
+
take || raise(ActiveRecord::RecordNotFound.new("Couldn't find #{model_class.name}", model_class))
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def first!
|
|
119
|
+
first || raise(ActiveRecord::RecordNotFound.new("Couldn't find #{model_class.name}", model_class))
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def last!
|
|
123
|
+
last || raise(ActiveRecord::RecordNotFound.new("Couldn't find #{model_class.name}", model_class))
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def find(id)
|
|
127
|
+
record = @records.find { |r| r.id.to_s == id.to_s }
|
|
128
|
+
record || raise(ActiveRecord::RecordNotFound.new(
|
|
129
|
+
"Couldn't find #{model_class.name} with '#{model_class.primary_key}'=#{id}",
|
|
130
|
+
model_class, model_class.primary_key, id
|
|
131
|
+
))
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def find_by(attributes)
|
|
135
|
+
@records.find do |record|
|
|
136
|
+
attributes.all? { |key, value| matches_value?(record, key, value) }
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def find_by!(attributes)
|
|
141
|
+
find_by(attributes) || raise(ActiveRecord::RecordNotFound.new(
|
|
142
|
+
"Couldn't find #{model_class.name}", model_class
|
|
143
|
+
))
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def exists?(conditions = nil)
|
|
147
|
+
case conditions
|
|
148
|
+
when nil, false
|
|
149
|
+
@records.any?
|
|
150
|
+
when Integer, String
|
|
151
|
+
@records.any? { |r| r.id.to_s == conditions.to_s }
|
|
152
|
+
when Hash
|
|
153
|
+
find_by(conditions).present?
|
|
154
|
+
else
|
|
155
|
+
@records.any?
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# =========================================================================
|
|
160
|
+
# Query methods (return new CachedRelation for chaining)
|
|
161
|
+
# =========================================================================
|
|
162
|
+
|
|
163
|
+
def where(conditions = nil, *args)
|
|
164
|
+
return self if conditions.nil?
|
|
165
|
+
|
|
166
|
+
# Handle where.not(...) chain
|
|
167
|
+
return WhereChain.new(self) if conditions == :chain
|
|
168
|
+
|
|
169
|
+
filtered = @records.select do |record|
|
|
170
|
+
case conditions
|
|
171
|
+
when Hash
|
|
172
|
+
conditions.all? { |key, value| matches_value?(record, key, value) }
|
|
173
|
+
when String
|
|
174
|
+
# SQL string conditions - can't evaluate, return all
|
|
175
|
+
# This is a limitation of the cached approach
|
|
176
|
+
true
|
|
177
|
+
else
|
|
178
|
+
true
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
self.class.new(model_class, filtered)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def not(conditions)
|
|
186
|
+
filtered = @records.reject do |record|
|
|
187
|
+
conditions.all? { |key, value| matches_value?(record, key, value) }
|
|
188
|
+
end
|
|
189
|
+
self.class.new(model_class, filtered)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def order(*args)
|
|
193
|
+
return self if args.empty?
|
|
194
|
+
|
|
195
|
+
sorted = @records.sort do |a, b|
|
|
196
|
+
compare_for_order(a, b, args)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
self.class.new(model_class, sorted)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def reorder(*args)
|
|
203
|
+
order(*args)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def reverse_order
|
|
207
|
+
self.class.new(model_class, @records.reverse)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def limit(count)
|
|
211
|
+
self.class.new(model_class, @records.first(count))
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def offset(count)
|
|
215
|
+
self.class.new(model_class, @records.drop(count))
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# =========================================================================
|
|
219
|
+
# Pagination support (Kaminari/WillPaginate compatibility)
|
|
220
|
+
# =========================================================================
|
|
221
|
+
|
|
222
|
+
def page(num)
|
|
223
|
+
@current_page = [num.to_i, 1].max
|
|
224
|
+
@per_page ||= 25
|
|
225
|
+
self
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def per(num)
|
|
229
|
+
@per_page = num.to_i
|
|
230
|
+
paginated_records
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def total_pages
|
|
234
|
+
return 1 if @per_page.nil? || @per_page <= 0
|
|
235
|
+
(@records.size.to_f / @per_page).ceil
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def current_page
|
|
239
|
+
@current_page || 1
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def total_count
|
|
243
|
+
@records.size
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def limit_value
|
|
247
|
+
@per_page
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def offset_value
|
|
251
|
+
return 0 unless @current_page && @per_page
|
|
252
|
+
(@current_page - 1) * @per_page
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
private def paginated_records
|
|
256
|
+
return self unless @current_page && @per_page
|
|
257
|
+
|
|
258
|
+
start_idx = (@current_page - 1) * @per_page
|
|
259
|
+
paginated = @records[start_idx, @per_page] || []
|
|
260
|
+
|
|
261
|
+
result = self.class.new(model_class, paginated)
|
|
262
|
+
result.instance_variable_set(:@current_page, @current_page)
|
|
263
|
+
result.instance_variable_set(:@per_page, @per_page)
|
|
264
|
+
result.instance_variable_set(:@total_records, @records.size)
|
|
265
|
+
result
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def distinct
|
|
269
|
+
self.class.new(model_class, @records.uniq)
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def uniq
|
|
273
|
+
distinct
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# =========================================================================
|
|
277
|
+
# Eager loading (no-ops for cached records - data is already in memory)
|
|
278
|
+
# =========================================================================
|
|
279
|
+
|
|
280
|
+
def preload(*args)
|
|
281
|
+
# Associations are already loaded or don't exist in cache
|
|
282
|
+
# This is a no-op but allows the chain to continue
|
|
283
|
+
self
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def includes(*args)
|
|
287
|
+
# Same as preload - no-op for cached records
|
|
288
|
+
self
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def eager_load(*args)
|
|
292
|
+
# Same as preload - no-op for cached records
|
|
293
|
+
self
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def references(*args)
|
|
297
|
+
self
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def joins(*args)
|
|
301
|
+
# Can't actually join - return self to allow chain to continue
|
|
302
|
+
# Note: This may produce incorrect results for complex queries
|
|
303
|
+
self
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def left_joins(*args)
|
|
307
|
+
self
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def left_outer_joins(*args)
|
|
311
|
+
self
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
# =========================================================================
|
|
315
|
+
# Scoping methods
|
|
316
|
+
# =========================================================================
|
|
317
|
+
|
|
318
|
+
def all
|
|
319
|
+
self
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def none
|
|
323
|
+
self.class.new(model_class, [])
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def unscoped
|
|
327
|
+
self
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def readonly(value = true)
|
|
331
|
+
self
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
# =========================================================================
|
|
335
|
+
# AR internal methods for association scope building
|
|
336
|
+
# These are needed for belongs_to/has_many association loading
|
|
337
|
+
# =========================================================================
|
|
338
|
+
|
|
339
|
+
def alias_tracker
|
|
340
|
+
@alias_tracker ||= ActiveRecord::Associations::AliasTracker.create(
|
|
341
|
+
model_class.connection_pool, table.name, []
|
|
342
|
+
)
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
def table
|
|
346
|
+
model_class.arel_table
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
def connection
|
|
350
|
+
model_class.connection
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
def joins_values
|
|
354
|
+
[]
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
def left_outer_joins_values
|
|
358
|
+
[]
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
def where_clause
|
|
362
|
+
ActiveRecord::Relation::WhereClause.empty
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
def klass
|
|
366
|
+
model_class
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
def scope_for_create
|
|
370
|
+
{}
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
def limit!(value)
|
|
374
|
+
self.class.new(model_class, @records.first(value))
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
def values
|
|
378
|
+
{}
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
def extending!(*modules, &block)
|
|
382
|
+
# No-op for cached relation - extensions are for AR scopes
|
|
383
|
+
self
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
def extending(*modules, &block)
|
|
387
|
+
self
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
def spawn
|
|
391
|
+
self.class.new(model_class, @records.dup)
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
def merge(other, *rest)
|
|
395
|
+
# For association scopes, just return self since we already have filtered records
|
|
396
|
+
self
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
def merge!(other, *rest)
|
|
400
|
+
self
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
def bind_attribute(name, value)
|
|
404
|
+
self
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
def rewhere(conditions)
|
|
408
|
+
where(conditions)
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
def except(*skips)
|
|
412
|
+
self
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
def only(*keeps)
|
|
416
|
+
self
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
def where!(conditions)
|
|
420
|
+
# In-place where modification (for AR internal use)
|
|
421
|
+
# Filter records and update @records directly
|
|
422
|
+
return self if conditions.nil?
|
|
423
|
+
|
|
424
|
+
@records = @records.select do |record|
|
|
425
|
+
case conditions
|
|
426
|
+
when Hash
|
|
427
|
+
conditions.all? { |key, value| matches_value?(record, key, value) }
|
|
428
|
+
else
|
|
429
|
+
true
|
|
430
|
+
end
|
|
431
|
+
end
|
|
432
|
+
self
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
def order!(*args)
|
|
436
|
+
# In-place order modification
|
|
437
|
+
return self if args.empty?
|
|
438
|
+
@records = @records.sort { |a, b| compare_for_order(a, b, args) }
|
|
439
|
+
self
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
def reselect(*args)
|
|
443
|
+
self
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
def select(*args, &block)
|
|
447
|
+
if block_given?
|
|
448
|
+
# Enumerable select
|
|
449
|
+
self.class.new(model_class, @records.select(&block))
|
|
450
|
+
else
|
|
451
|
+
# AR select (column selection) - return self since we have full records
|
|
452
|
+
self
|
|
453
|
+
end
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
# =========================================================================
|
|
457
|
+
# Aggregations
|
|
458
|
+
# =========================================================================
|
|
459
|
+
|
|
460
|
+
def sum(column_name = nil, &block)
|
|
461
|
+
if block_given?
|
|
462
|
+
@records.sum(&block)
|
|
463
|
+
elsif column_name
|
|
464
|
+
@records.sum { |r| r.send(column_name).to_f }
|
|
465
|
+
else
|
|
466
|
+
0
|
|
467
|
+
end
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
def average(column_name)
|
|
471
|
+
values = @records.map { |r| r.send(column_name) }.compact
|
|
472
|
+
return nil if values.empty?
|
|
473
|
+
values.sum.to_f / values.size
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
def minimum(column_name)
|
|
477
|
+
@records.map { |r| r.send(column_name) }.compact.min
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
def maximum(column_name)
|
|
481
|
+
@records.map { |r| r.send(column_name) }.compact.max
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
def pluck(*column_names)
|
|
485
|
+
@records.map do |record|
|
|
486
|
+
if column_names.size == 1
|
|
487
|
+
record.send(column_names.first)
|
|
488
|
+
else
|
|
489
|
+
column_names.map { |col| record.send(col) }
|
|
490
|
+
end
|
|
491
|
+
end
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
def ids
|
|
495
|
+
pluck(:id)
|
|
496
|
+
end
|
|
497
|
+
|
|
498
|
+
def pick(*column_names)
|
|
499
|
+
record = first
|
|
500
|
+
return nil unless record
|
|
501
|
+
|
|
502
|
+
if column_names.size == 1
|
|
503
|
+
record.send(column_names.first)
|
|
504
|
+
else
|
|
505
|
+
column_names.map { |col| record.send(col) }
|
|
506
|
+
end
|
|
507
|
+
end
|
|
508
|
+
|
|
509
|
+
# =========================================================================
|
|
510
|
+
# Batching (simplified - all records are in memory)
|
|
511
|
+
# =========================================================================
|
|
512
|
+
|
|
513
|
+
def find_each(batch_size: 1000, &block)
|
|
514
|
+
each(&block)
|
|
515
|
+
end
|
|
516
|
+
|
|
517
|
+
def find_in_batches(batch_size: 1000)
|
|
518
|
+
@records.each_slice(batch_size) do |batch|
|
|
519
|
+
yield batch
|
|
520
|
+
end
|
|
521
|
+
end
|
|
522
|
+
|
|
523
|
+
def in_batches(of: 1000)
|
|
524
|
+
@records.each_slice(of) do |batch|
|
|
525
|
+
yield self.class.new(model_class, batch)
|
|
526
|
+
end
|
|
527
|
+
end
|
|
528
|
+
|
|
529
|
+
# =========================================================================
|
|
530
|
+
# Inspection
|
|
531
|
+
# =========================================================================
|
|
532
|
+
|
|
533
|
+
def inspect
|
|
534
|
+
"#<#{self.class.name} [#{@records.map(&:inspect).join(', ')}]>"
|
|
535
|
+
end
|
|
536
|
+
|
|
537
|
+
def to_s
|
|
538
|
+
inspect
|
|
539
|
+
end
|
|
540
|
+
|
|
541
|
+
# =========================================================================
|
|
542
|
+
# Scope support - AR calls _exec_scope for named scopes
|
|
543
|
+
# =========================================================================
|
|
544
|
+
|
|
545
|
+
# Called by AR when executing scopes
|
|
546
|
+
# Rails passes scope arguments and the scope body block
|
|
547
|
+
def _exec_scope(*args, &block)
|
|
548
|
+
# Execute the scope block in our context
|
|
549
|
+
# The block typically calls methods like `where`, `order`, etc.
|
|
550
|
+
result = instance_exec(*args, &block)
|
|
551
|
+
|
|
552
|
+
# Return the result if it's a CachedRelation, otherwise self
|
|
553
|
+
result.is_a?(CachedRelation) ? result : self
|
|
554
|
+
end
|
|
555
|
+
|
|
556
|
+
def scoping(skip_inherited_scope = false, full = nil, all_queries: nil, &block)
|
|
557
|
+
# Yield self for scoping blocks
|
|
558
|
+
yield self if block_given?
|
|
559
|
+
self
|
|
560
|
+
end
|
|
561
|
+
|
|
562
|
+
def _scoping(skip_inherited_scope = false, full = nil, all_queries: nil, &block)
|
|
563
|
+
scoping(skip_inherited_scope, full, all_queries: all_queries, &block)
|
|
564
|
+
end
|
|
565
|
+
|
|
566
|
+
# Allow method_missing for scope delegation
|
|
567
|
+
def respond_to_missing?(method_name, include_private = false)
|
|
568
|
+
model_class.respond_to?(method_name) || super
|
|
569
|
+
end
|
|
570
|
+
|
|
571
|
+
def method_missing(method_name, *args, **kwargs, &block)
|
|
572
|
+
# Try to delegate to model class scopes
|
|
573
|
+
if model_class.respond_to?(method_name)
|
|
574
|
+
# Execute the scope on a bare unscoped relation to get the where conditions
|
|
575
|
+
# This works because scopes add where clauses to the relation
|
|
576
|
+
begin
|
|
577
|
+
# Temporarily bypass Lyra's read overrides so scope calls go through AR
|
|
578
|
+
# Without this, scope lambdas that call `where(...)` would hit our override
|
|
579
|
+
# and return CachedRelation instead of building AR conditions
|
|
580
|
+
Thread.current[:lyra_bypass_read_override] = true
|
|
581
|
+
base_relation = model_class.unscoped
|
|
582
|
+
scope_result = base_relation.public_send(method_name, *args, **kwargs, &block)
|
|
583
|
+
rescue Lyra::StrictDataAccessViolation
|
|
584
|
+
# Don't swallow strict data access violations - these are intentional framework errors
|
|
585
|
+
raise
|
|
586
|
+
rescue => e
|
|
587
|
+
Rails.logger.debug("Lyra::CachedRelation: Could not execute scope #{method_name} - #{e.message}")
|
|
588
|
+
return self
|
|
589
|
+
ensure
|
|
590
|
+
Thread.current[:lyra_bypass_read_override] = nil
|
|
591
|
+
end
|
|
592
|
+
|
|
593
|
+
if scope_result.is_a?(ActiveRecord::Relation)
|
|
594
|
+
# Extract where conditions from the scope result
|
|
595
|
+
where_hash = extract_where_conditions(scope_result)
|
|
596
|
+
if where_hash.present?
|
|
597
|
+
return where(where_hash)
|
|
598
|
+
end
|
|
599
|
+
end
|
|
600
|
+
|
|
601
|
+
# Fallback: return self to allow chaining
|
|
602
|
+
self
|
|
603
|
+
else
|
|
604
|
+
super
|
|
605
|
+
end
|
|
606
|
+
end
|
|
607
|
+
|
|
608
|
+
def extract_where_conditions(relation)
|
|
609
|
+
# Try to extract hash conditions from the relation's where clause
|
|
610
|
+
return {} unless relation.respond_to?(:where_clause)
|
|
611
|
+
|
|
612
|
+
where_clause = relation.where_clause
|
|
613
|
+
return {} if where_clause.empty?
|
|
614
|
+
|
|
615
|
+
# Rails 7+ stores conditions in predicates
|
|
616
|
+
# Try to convert Arel predicates to a hash
|
|
617
|
+
conditions = {}
|
|
618
|
+
where_clause.send(:predicates).each do |predicate|
|
|
619
|
+
case predicate
|
|
620
|
+
when Arel::Nodes::Equality
|
|
621
|
+
# Simple equality: column = value
|
|
622
|
+
if predicate.left.respond_to?(:name)
|
|
623
|
+
column = predicate.left.name.to_sym
|
|
624
|
+
value = extract_predicate_value(predicate.right)
|
|
625
|
+
conditions[column] = value
|
|
626
|
+
end
|
|
627
|
+
when Arel::Nodes::In
|
|
628
|
+
# IN clause: column IN (values)
|
|
629
|
+
if predicate.left.respond_to?(:name)
|
|
630
|
+
column = predicate.left.name.to_sym
|
|
631
|
+
values = predicate.right.map { |v| extract_predicate_value(v) }
|
|
632
|
+
conditions[column] = values
|
|
633
|
+
end
|
|
634
|
+
end
|
|
635
|
+
end
|
|
636
|
+
|
|
637
|
+
conditions
|
|
638
|
+
rescue => e
|
|
639
|
+
Rails.logger.debug("Lyra::CachedRelation: Could not extract where conditions - #{e.message}")
|
|
640
|
+
{}
|
|
641
|
+
end
|
|
642
|
+
|
|
643
|
+
def extract_predicate_value(node)
|
|
644
|
+
case node
|
|
645
|
+
when Arel::Nodes::Casted
|
|
646
|
+
node.value
|
|
647
|
+
when Arel::Nodes::BindParam
|
|
648
|
+
# Rails 7+ bind params
|
|
649
|
+
node.value.value_before_type_cast
|
|
650
|
+
when NilClass
|
|
651
|
+
nil
|
|
652
|
+
else
|
|
653
|
+
node.respond_to?(:value) ? node.value : node
|
|
654
|
+
end
|
|
655
|
+
end
|
|
656
|
+
|
|
657
|
+
private
|
|
658
|
+
|
|
659
|
+
def matches_value?(record, key, value)
|
|
660
|
+
record_value = record.send(key)
|
|
661
|
+
|
|
662
|
+
case value
|
|
663
|
+
when Array
|
|
664
|
+
# Handle type coercion for arrays (e.g., array of string IDs vs integer column)
|
|
665
|
+
value.any? { |v| values_match?(record_value, v) }
|
|
666
|
+
when Range
|
|
667
|
+
value.cover?(record_value)
|
|
668
|
+
when nil
|
|
669
|
+
record_value.nil?
|
|
670
|
+
when Regexp
|
|
671
|
+
record_value.to_s.match?(value)
|
|
672
|
+
else
|
|
673
|
+
values_match?(record_value, value)
|
|
674
|
+
end
|
|
675
|
+
rescue NoMethodError
|
|
676
|
+
# Attribute doesn't exist on record
|
|
677
|
+
false
|
|
678
|
+
end
|
|
679
|
+
|
|
680
|
+
# Compare values with type coercion for common AR patterns
|
|
681
|
+
def values_match?(record_value, query_value)
|
|
682
|
+
return true if record_value == query_value
|
|
683
|
+
|
|
684
|
+
# Handle string/integer coercion (common with params)
|
|
685
|
+
if record_value.is_a?(Integer) && query_value.is_a?(String)
|
|
686
|
+
record_value == query_value.to_i
|
|
687
|
+
elsif record_value.is_a?(String) && query_value.is_a?(Integer)
|
|
688
|
+
record_value.to_i == query_value
|
|
689
|
+
# Handle boolean string coercion
|
|
690
|
+
elsif record_value.in?([true, false]) && query_value.is_a?(String)
|
|
691
|
+
record_value == ActiveModel::Type::Boolean.new.cast(query_value)
|
|
692
|
+
else
|
|
693
|
+
false
|
|
694
|
+
end
|
|
695
|
+
end
|
|
696
|
+
|
|
697
|
+
def compare_for_order(a, b, order_args)
|
|
698
|
+
order_args.each do |arg|
|
|
699
|
+
result = compare_single_order(a, b, arg)
|
|
700
|
+
return result unless result == 0
|
|
701
|
+
end
|
|
702
|
+
0
|
|
703
|
+
end
|
|
704
|
+
|
|
705
|
+
def compare_single_order(a, b, arg)
|
|
706
|
+
case arg
|
|
707
|
+
when Symbol, String
|
|
708
|
+
compare_values(a.send(arg), b.send(arg))
|
|
709
|
+
when Hash
|
|
710
|
+
arg.each do |column, direction|
|
|
711
|
+
val_a = a.send(column)
|
|
712
|
+
val_b = b.send(column)
|
|
713
|
+
result = compare_values(val_a, val_b)
|
|
714
|
+
result = -result if direction.to_s.downcase == "desc"
|
|
715
|
+
return result unless result == 0
|
|
716
|
+
end
|
|
717
|
+
0
|
|
718
|
+
else
|
|
719
|
+
0
|
|
720
|
+
end
|
|
721
|
+
rescue NoMethodError
|
|
722
|
+
0
|
|
723
|
+
end
|
|
724
|
+
|
|
725
|
+
def compare_values(a, b)
|
|
726
|
+
return 0 if a.nil? && b.nil?
|
|
727
|
+
return 1 if a.nil?
|
|
728
|
+
return -1 if b.nil?
|
|
729
|
+
a <=> b || 0
|
|
730
|
+
end
|
|
731
|
+
|
|
732
|
+
# =========================================================================
|
|
733
|
+
# WhereChain for where.not(...) support
|
|
734
|
+
# =========================================================================
|
|
735
|
+
|
|
736
|
+
class WhereChain
|
|
737
|
+
def initialize(relation)
|
|
738
|
+
@relation = relation
|
|
739
|
+
end
|
|
740
|
+
|
|
741
|
+
def not(conditions)
|
|
742
|
+
@relation.not(conditions)
|
|
743
|
+
end
|
|
744
|
+
|
|
745
|
+
def missing(*associations)
|
|
746
|
+
# Can't check missing associations in cached mode
|
|
747
|
+
@relation
|
|
748
|
+
end
|
|
749
|
+
|
|
750
|
+
def associated(*associations)
|
|
751
|
+
# Can't check associations in cached mode
|
|
752
|
+
@relation
|
|
753
|
+
end
|
|
754
|
+
end
|
|
755
|
+
end
|
|
756
|
+
end
|
|
757
|
+
end
|