upkeep-rails 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.
Potentially problematic release.
This version of upkeep-rails might be problematic. Click here for more details.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +424 -0
- data/docs/architecture/ambient-inputs-roadmap.md +306 -0
- data/docs/architecture/herb-roadmap.md +324 -0
- data/docs/architecture/identity-and-sharing.md +187 -0
- data/docs/architecture/query-dependencies.md +230 -0
- data/docs/cost-model-roadmap.md +703 -0
- data/docs/guides/getting-started.md +282 -0
- data/docs/handoff-2026-05-15.md +230 -0
- data/docs/production_roadmap.md +372 -0
- data/docs/shared-warm-scale-roadmap.md +214 -0
- data/docs/single-subscriber-cold-roadmap.md +192 -0
- data/docs/testing.md +113 -0
- data/lib/generators/upkeep/install/install_generator.rb +90 -0
- data/lib/generators/upkeep/install/templates/create_upkeep_subscriptions.rb.erb +31 -0
- data/lib/generators/upkeep/install/templates/subscription.js +107 -0
- data/lib/generators/upkeep/install/templates/upkeep.rb +6 -0
- data/lib/upkeep/active_record_query.rb +294 -0
- data/lib/upkeep/capture/request.rb +150 -0
- data/lib/upkeep/dag/subscription_shape.rb +244 -0
- data/lib/upkeep/dag.rb +370 -0
- data/lib/upkeep/delivery/action_cable_adapter.rb +43 -0
- data/lib/upkeep/delivery/async_dispatcher.rb +102 -0
- data/lib/upkeep/delivery/broadcast_transport.rb +89 -0
- data/lib/upkeep/delivery/transport.rb +194 -0
- data/lib/upkeep/delivery/turbo_streams.rb +275 -0
- data/lib/upkeep/delivery.rb +7 -0
- data/lib/upkeep/dependencies.rb +466 -0
- data/lib/upkeep/herb/developer_report.rb +116 -0
- data/lib/upkeep/herb/manifest_cache.rb +83 -0
- data/lib/upkeep/herb/manifest_diff.rb +183 -0
- data/lib/upkeep/herb/source_instrumenter.rb +84 -0
- data/lib/upkeep/herb/template_manifest.rb +377 -0
- data/lib/upkeep/invalidation/collection_append.rb +84 -0
- data/lib/upkeep/invalidation/collection_member_replace.rb +78 -0
- data/lib/upkeep/invalidation/collection_prepend.rb +84 -0
- data/lib/upkeep/invalidation/collection_remove.rb +57 -0
- data/lib/upkeep/invalidation/planner.rb +341 -0
- data/lib/upkeep/invalidation.rb +7 -0
- data/lib/upkeep/rails/action_view_capture.rb +765 -0
- data/lib/upkeep/rails/cable/channel.rb +108 -0
- data/lib/upkeep/rails/cable/subscriber_identity.rb +214 -0
- data/lib/upkeep/rails/cable.rb +4 -0
- data/lib/upkeep/rails/client_subscription.rb +37 -0
- data/lib/upkeep/rails/configuration.rb +57 -0
- data/lib/upkeep/rails/controller_runtime.rb +137 -0
- data/lib/upkeep/rails/install.rb +28 -0
- data/lib/upkeep/rails/railtie.rb +36 -0
- data/lib/upkeep/rails/replay.rb +176 -0
- data/lib/upkeep/rails/testing.rb +36 -0
- data/lib/upkeep/rails.rb +276 -0
- data/lib/upkeep/replay.rb +408 -0
- data/lib/upkeep/runtime.rb +1075 -0
- data/lib/upkeep/shared_streams.rb +72 -0
- data/lib/upkeep/subscriptions/active_record_store.rb +292 -0
- data/lib/upkeep/subscriptions/active_record_subscription_persistence.rb +291 -0
- data/lib/upkeep/subscriptions/active_registry.rb +93 -0
- data/lib/upkeep/subscriptions/async_durable_writer.rb +136 -0
- data/lib/upkeep/subscriptions/json_snapshot.rb +98 -0
- data/lib/upkeep/subscriptions/layered_reverse_index.rb +122 -0
- data/lib/upkeep/subscriptions/persistent_reverse_index.rb +144 -0
- data/lib/upkeep/subscriptions/registrar.rb +36 -0
- data/lib/upkeep/subscriptions/reverse_index.rb +294 -0
- data/lib/upkeep/subscriptions/shape.rb +116 -0
- data/lib/upkeep/subscriptions/store.rb +159 -0
- data/lib/upkeep/subscriptions.rb +7 -0
- data/lib/upkeep/targeting.rb +135 -0
- data/lib/upkeep/version.rb +5 -0
- data/lib/upkeep-rails.rb +3 -0
- data/lib/upkeep.rb +14 -0
- data/upkeep-rails.gemspec +53 -0
- metadata +296 -0
|
@@ -0,0 +1,1075 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_record"
|
|
4
|
+
require "active_support/current_attributes"
|
|
5
|
+
require "active_support/notifications"
|
|
6
|
+
require "digest"
|
|
7
|
+
require_relative "active_record_query"
|
|
8
|
+
|
|
9
|
+
module Upkeep
|
|
10
|
+
module Runtime
|
|
11
|
+
module Observation
|
|
12
|
+
THREAD_KEY = :upkeep_recorder
|
|
13
|
+
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
def capture_request(profile: false)
|
|
17
|
+
previous = Thread.current[THREAD_KEY]
|
|
18
|
+
recorder = Recorder.new(profile: profile)
|
|
19
|
+
Thread.current[THREAD_KEY] = recorder
|
|
20
|
+
|
|
21
|
+
result = yield(recorder)
|
|
22
|
+
recorder.flush_pending_dependencies
|
|
23
|
+
[result, recorder]
|
|
24
|
+
ensure
|
|
25
|
+
Thread.current[THREAD_KEY] = previous
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def capture_frame(frame_id, metadata = {})
|
|
29
|
+
recorder = Thread.current[THREAD_KEY]
|
|
30
|
+
return yield unless recorder
|
|
31
|
+
|
|
32
|
+
recorder.with_frame(frame_id, metadata) { yield }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def record_dependency(dependency)
|
|
36
|
+
Thread.current[THREAD_KEY]&.record_dependency(dependency)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def record_ambient_replay_input(source, key, value)
|
|
40
|
+
Thread.current[THREAD_KEY]&.record_ambient_replay_input(source, key, value)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def record_relation_provenance(collection, model_name:, analysis:)
|
|
44
|
+
Thread.current[THREAD_KEY]&.record_relation_provenance(collection, model_name: model_name, analysis: analysis)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def relation_provenance_for(collection)
|
|
48
|
+
Thread.current[THREAD_KEY]&.relation_provenance_for(collection)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def refuse_boundary(boundary)
|
|
52
|
+
Thread.current[THREAD_KEY]&.refuse_boundary(**boundary)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def recorder
|
|
56
|
+
Thread.current[THREAD_KEY]
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def recording?
|
|
60
|
+
!!Thread.current[THREAD_KEY]
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
RelationProvenance = Data.define(:model_name, :analysis) do
|
|
66
|
+
def primary_table = analysis.primary_table
|
|
67
|
+
def table_columns = analysis.table_columns
|
|
68
|
+
def coverage = analysis.coverage
|
|
69
|
+
def sql = analysis.sql
|
|
70
|
+
def primary_key = analysis.primary_key
|
|
71
|
+
def predicates = analysis.predicates
|
|
72
|
+
def appendable? = analysis.appendable?
|
|
73
|
+
def limit_value = analysis.limit_value
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
class Recorder
|
|
77
|
+
REQUEST_NODE_ID = :request
|
|
78
|
+
RefusedBoundary = Data.define(:reason, :message, :suggestions, :source)
|
|
79
|
+
|
|
80
|
+
attr_reader :graph, :refused_boundaries
|
|
81
|
+
|
|
82
|
+
def initialize(graph: nil, profile: false)
|
|
83
|
+
@frame_stack = []
|
|
84
|
+
@graph = graph || DAG::Graph.new
|
|
85
|
+
@profile = profile
|
|
86
|
+
@profile_timings = Hash.new(0.0)
|
|
87
|
+
@profile_counts = Hash.new(0)
|
|
88
|
+
@refused_boundaries = []
|
|
89
|
+
@ambient_replay_inputs_by_owner = Hash.new do |owners, owner_id|
|
|
90
|
+
owners[owner_id] = Hash.new { |sources, source| sources[source] = {} }
|
|
91
|
+
end
|
|
92
|
+
@pending_dependencies_by_owner = Hash.new { |owners, owner_id| owners[owner_id] = {} }
|
|
93
|
+
@relation_provenance_by_collection_id = {}
|
|
94
|
+
@graph.add_node(REQUEST_NODE_ID, kind: :request, payload: {}) unless @graph.node?(REQUEST_NODE_ID)
|
|
95
|
+
@subscription_shape_trace = DAG::SubscriptionShape::Trace.new(graph_version: @graph.version)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def self.from_h(snapshot)
|
|
99
|
+
snapshot = Dependencies.symbolize_keys(snapshot)
|
|
100
|
+
new(graph: DAG::Graph.from_h(snapshot.fetch(:graph)))
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def to_h(dependencies: :all)
|
|
104
|
+
flush_pending_dependencies
|
|
105
|
+
{ graph: graph.to_h(dependencies: dependencies) }
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def to_persistent_h
|
|
109
|
+
to_h(dependencies: :identity)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def with_frame(frame_id, metadata)
|
|
113
|
+
profile_count(:recorder_frame_count)
|
|
114
|
+
parent_id = nil
|
|
115
|
+
profile_timing(:recorder_frame_ms) do
|
|
116
|
+
invalidate_subscription_shape_trace_if_needed
|
|
117
|
+
parent_id = current_owner
|
|
118
|
+
@graph.add_node(frame_id, kind: :frame, payload: metadata)
|
|
119
|
+
@graph.add_edge(parent_id, frame_id, reason: :contains)
|
|
120
|
+
profile_timing(:recorder_shape_trace_ms) do
|
|
121
|
+
@subscription_shape_trace.record_frame(frame_id, metadata, parent_id: parent_id, graph_version: @graph.version)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
@frame_stack.push(frame_id)
|
|
125
|
+
begin
|
|
126
|
+
yield
|
|
127
|
+
ensure
|
|
128
|
+
flush_pending_dependencies(frame_id)
|
|
129
|
+
@frame_stack.pop
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def flush_pending_dependencies(owner_id = nil)
|
|
134
|
+
if owner_id
|
|
135
|
+
flush_pending_dependencies_for(owner_id)
|
|
136
|
+
else
|
|
137
|
+
@pending_dependencies_by_owner.keys.each { |pending_owner_id| flush_pending_dependencies_for(pending_owner_id) }
|
|
138
|
+
end
|
|
139
|
+
ensure
|
|
140
|
+
@pending_dependencies_by_owner.delete(owner_id) if owner_id
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def record_dependency(dependency)
|
|
144
|
+
profile_count(:recorder_dependency_count)
|
|
145
|
+
profile_timing(:recorder_dependency_ms) do
|
|
146
|
+
owner_id = current_owner
|
|
147
|
+
@pending_dependencies_by_owner[owner_id][dependency.cache_key] ||= dependency
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def subscription_shape(request_signature: nil)
|
|
152
|
+
flush_pending_dependencies
|
|
153
|
+
return @subscription_shape_trace.subscription_shape(request_signature: request_signature) if @subscription_shape_trace.covers?(@graph)
|
|
154
|
+
|
|
155
|
+
DAG::SubscriptionShape.from_graph(@graph, request_signature: request_signature)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def record_ambient_replay_input(source, key, value)
|
|
159
|
+
profile_count(:recorder_ambient_replay_input_count)
|
|
160
|
+
profile_timing(:recorder_ambient_replay_input_ms) do
|
|
161
|
+
@ambient_replay_inputs_by_owner[current_owner][source.to_sym][key.to_s] = value
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def ambient_replay_inputs_for(owner_id)
|
|
166
|
+
@ambient_replay_inputs_by_owner.fetch(owner_id, {}).each_with_object({}) do |(source, values), inputs|
|
|
167
|
+
inputs[source] = values.dup
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def record_relation_provenance(collection, model_name:, analysis:)
|
|
172
|
+
return unless collection && analysis
|
|
173
|
+
|
|
174
|
+
profile_count(:recorder_relation_provenance_count)
|
|
175
|
+
profile_timing(:recorder_relation_provenance_ms) do
|
|
176
|
+
@relation_provenance_by_collection_id[collection.object_id] =
|
|
177
|
+
RelationProvenance.new(model_name.to_s, analysis)
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def relation_provenance_for(collection)
|
|
182
|
+
@relation_provenance_by_collection_id[collection.object_id] if collection
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def refuse_boundary(reason:, message:, suggestions:, source:)
|
|
186
|
+
profile_count(:recorder_refused_boundary_count)
|
|
187
|
+
profile_timing(:recorder_refused_boundary_ms) do
|
|
188
|
+
boundary = RefusedBoundary.new(
|
|
189
|
+
reason.to_s,
|
|
190
|
+
message.to_s,
|
|
191
|
+
Array(suggestions).map(&:to_s),
|
|
192
|
+
source.to_s
|
|
193
|
+
)
|
|
194
|
+
return false if @refused_boundaries.include?(boundary)
|
|
195
|
+
|
|
196
|
+
@refused_boundaries << boundary
|
|
197
|
+
true
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def profile_timings
|
|
202
|
+
@profile_timings.transform_values { |value| value.round(3) }
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def profile_counts
|
|
206
|
+
@profile_counts.dup
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def reactive?
|
|
210
|
+
@refused_boundaries.empty?
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def current_frame
|
|
214
|
+
@frame_stack.last
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def current_owner
|
|
218
|
+
current_frame || REQUEST_NODE_ID
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def identity_profile(frame_id)
|
|
222
|
+
flush_pending_dependencies
|
|
223
|
+
identity_dependencies_for(frame_id).map(&:to_h)
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def identity_signature(frame_id)
|
|
227
|
+
flush_pending_dependencies
|
|
228
|
+
identity_dependencies = identity_dependencies_for(frame_id)
|
|
229
|
+
return "public" if identity_dependencies.empty?
|
|
230
|
+
|
|
231
|
+
Digest::SHA256.hexdigest(identity_dependencies.map(&:identity_key).sort_by(&:inspect).inspect)[0, 16]
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
private
|
|
235
|
+
|
|
236
|
+
def invalidate_subscription_shape_trace_if_needed
|
|
237
|
+
@subscription_shape_trace.invalidate! unless @subscription_shape_trace.synchronized_with?(@graph)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def flush_pending_dependencies_for(owner_id)
|
|
241
|
+
dependencies = @pending_dependencies_by_owner.delete(owner_id)
|
|
242
|
+
return unless dependencies&.any?
|
|
243
|
+
|
|
244
|
+
profile_timing(:recorder_dependency_ms) do
|
|
245
|
+
dependencies.each_value do |dependency|
|
|
246
|
+
profile_count(:recorder_dependency_flush_count)
|
|
247
|
+
invalidate_subscription_shape_trace_if_needed
|
|
248
|
+
if @graph.add_dependency(owner_id, dependency)
|
|
249
|
+
profile_timing(:recorder_shape_trace_ms) do
|
|
250
|
+
@subscription_shape_trace.record_dependency(owner_id, dependency, graph_version: @graph.version)
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def profile_timing(key)
|
|
258
|
+
return yield unless @profile
|
|
259
|
+
|
|
260
|
+
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
261
|
+
yield
|
|
262
|
+
ensure
|
|
263
|
+
if @profile && started_at
|
|
264
|
+
@profile_timings[key] += (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000.0
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def profile_count(key)
|
|
269
|
+
return unless @profile
|
|
270
|
+
|
|
271
|
+
@profile_counts[key] += 1
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def identity_dependencies_for(frame_id)
|
|
275
|
+
identity_dependency_owner_ids(frame_id)
|
|
276
|
+
.flat_map { |owner_id| @graph.dependencies_for(owner_id) }
|
|
277
|
+
.select(&:identity?)
|
|
278
|
+
.uniq(&:cache_key)
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def identity_dependency_owner_ids(frame_id)
|
|
282
|
+
owner_ids = @graph.contained_node_ids(frame_id)
|
|
283
|
+
frame = @graph.node(frame_id)
|
|
284
|
+
|
|
285
|
+
if frame.kind == :frame && frame.payload[:kind] == "page"
|
|
286
|
+
owner_ids.concat(@graph.ancestor_node_ids(frame_id))
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
owner_ids
|
|
290
|
+
rescue KeyError
|
|
291
|
+
owner_ids
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
module ChangeLog
|
|
296
|
+
THREAD_KEY = :upkeep_change_log_events
|
|
297
|
+
@events = []
|
|
298
|
+
@mutex = Mutex.new
|
|
299
|
+
|
|
300
|
+
module_function
|
|
301
|
+
|
|
302
|
+
def reset
|
|
303
|
+
@mutex.synchronize { @events = [] }
|
|
304
|
+
Thread.current[THREAD_KEY] = nil
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def record(event)
|
|
308
|
+
if (events = Thread.current[THREAD_KEY])
|
|
309
|
+
events << event
|
|
310
|
+
else
|
|
311
|
+
@mutex.synchronize { @events << event }
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def events
|
|
316
|
+
if (events = Thread.current[THREAD_KEY])
|
|
317
|
+
events
|
|
318
|
+
else
|
|
319
|
+
@mutex.synchronize { @events.dup }
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def drain
|
|
324
|
+
if (events = Thread.current[THREAD_KEY])
|
|
325
|
+
drained = events.dup
|
|
326
|
+
events.clear
|
|
327
|
+
drained
|
|
328
|
+
else
|
|
329
|
+
@mutex.synchronize do
|
|
330
|
+
events = @events
|
|
331
|
+
@events = []
|
|
332
|
+
events
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
def capture
|
|
338
|
+
previous = Thread.current[THREAD_KEY]
|
|
339
|
+
events = []
|
|
340
|
+
Thread.current[THREAD_KEY] = events
|
|
341
|
+
|
|
342
|
+
[yield, events.dup]
|
|
343
|
+
ensure
|
|
344
|
+
Thread.current[THREAD_KEY] = previous
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
module ChangeEvents
|
|
349
|
+
module_function
|
|
350
|
+
|
|
351
|
+
def active_record_commit(record)
|
|
352
|
+
return active_record_destroy(record) if record.destroyed?
|
|
353
|
+
|
|
354
|
+
attribute_changes = previous_changes(record.previous_changes)
|
|
355
|
+
|
|
356
|
+
{
|
|
357
|
+
type: created_record?(record, attribute_changes) ? "create" : "update",
|
|
358
|
+
table: record.class.table_name,
|
|
359
|
+
model: record.class.name,
|
|
360
|
+
id: record.id,
|
|
361
|
+
changed_attributes: attribute_changes.keys.sort,
|
|
362
|
+
old_values: attribute_changes.transform_values { |change| change.fetch(:old) },
|
|
363
|
+
new_values: attribute_changes.transform_values { |change| change.fetch(:new) },
|
|
364
|
+
attribute_changes: attribute_changes
|
|
365
|
+
}
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
def active_record_destroy(record)
|
|
369
|
+
old_values = record.attributes.transform_keys(&:to_s)
|
|
370
|
+
|
|
371
|
+
{
|
|
372
|
+
type: "destroy",
|
|
373
|
+
table: record.class.table_name,
|
|
374
|
+
model: record.class.name,
|
|
375
|
+
id: record.id,
|
|
376
|
+
changed_attributes: old_values.keys.sort,
|
|
377
|
+
old_values: old_values,
|
|
378
|
+
new_values: {},
|
|
379
|
+
attribute_changes: old_values.transform_values { |value| { old: value, new: nil } }
|
|
380
|
+
}
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
def active_record_update_columns(record, changed_attributes:, new_values: {})
|
|
384
|
+
changed_attributes = Array(changed_attributes).map(&:to_s).sort
|
|
385
|
+
new_values = new_values.transform_keys(&:to_s)
|
|
386
|
+
|
|
387
|
+
{
|
|
388
|
+
type: "update",
|
|
389
|
+
table: record.class.table_name,
|
|
390
|
+
model: record.class.name,
|
|
391
|
+
id: record.class.primary_key && record.public_send(record.class.primary_key),
|
|
392
|
+
changed_attributes: changed_attributes,
|
|
393
|
+
old_values: {},
|
|
394
|
+
new_values: new_values,
|
|
395
|
+
attribute_changes: changed_attributes.to_h do |attribute|
|
|
396
|
+
[attribute, { old: nil, new: new_values[attribute] }]
|
|
397
|
+
end
|
|
398
|
+
}
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
def bulk_update(table:, model:, changed_attributes:, predicate_sql:, predicate_coverage:, predicate_table_columns:, new_values: {})
|
|
402
|
+
changed_attributes = Array(changed_attributes).map(&:to_s).sort
|
|
403
|
+
new_values = new_values.transform_keys(&:to_s)
|
|
404
|
+
|
|
405
|
+
{
|
|
406
|
+
type: "bulk_update",
|
|
407
|
+
table: table,
|
|
408
|
+
model: model,
|
|
409
|
+
changed_attributes: changed_attributes,
|
|
410
|
+
old_values: {},
|
|
411
|
+
new_values: new_values,
|
|
412
|
+
attribute_changes: changed_attributes.to_h do |attribute|
|
|
413
|
+
[attribute, { old: nil, new: new_values[attribute] }]
|
|
414
|
+
end,
|
|
415
|
+
predicate_sql: predicate_sql,
|
|
416
|
+
predicate_coverage: predicate_coverage,
|
|
417
|
+
predicate_table_columns: predicate_table_columns
|
|
418
|
+
}
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
def bulk_delete(table:, model:, changed_attributes:, predicate_sql:, predicate_coverage:, predicate_table_columns:)
|
|
422
|
+
changed_attributes = Array(changed_attributes).map(&:to_s).sort
|
|
423
|
+
|
|
424
|
+
{
|
|
425
|
+
type: "bulk_delete",
|
|
426
|
+
table: table,
|
|
427
|
+
model: model,
|
|
428
|
+
changed_attributes: changed_attributes,
|
|
429
|
+
old_values: {},
|
|
430
|
+
new_values: {},
|
|
431
|
+
attribute_changes: changed_attributes.to_h { |attribute| [attribute, { old: nil, new: nil }] },
|
|
432
|
+
predicate_sql: predicate_sql,
|
|
433
|
+
predicate_coverage: predicate_coverage,
|
|
434
|
+
predicate_table_columns: predicate_table_columns
|
|
435
|
+
}
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
def previous_changes(changes)
|
|
439
|
+
changes.to_h.transform_keys(&:to_s).transform_values do |(old_value, new_value)|
|
|
440
|
+
{ old: old_value, new: new_value }
|
|
441
|
+
end
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
def created_record?(record, attribute_changes)
|
|
445
|
+
primary_key = record.class.primary_key
|
|
446
|
+
return false unless primary_key
|
|
447
|
+
|
|
448
|
+
primary_key_change = attribute_changes[primary_key.to_s]
|
|
449
|
+
primary_key_change && primary_key_change.fetch(:old).nil? && !primary_key_change.fetch(:new).nil?
|
|
450
|
+
end
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
module Ambient
|
|
454
|
+
module_function
|
|
455
|
+
|
|
456
|
+
def record_current_attribute(owner, name, value)
|
|
457
|
+
return unless Observation.recording?
|
|
458
|
+
|
|
459
|
+
dependency = Dependencies::CurrentAttribute.new(owner: owner, name: name, value: value)
|
|
460
|
+
Observation.record_dependency(dependency)
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
def record_session(key, value)
|
|
464
|
+
return unless Observation.recording?
|
|
465
|
+
|
|
466
|
+
dependency = Dependencies::SessionValue.new(key: key, value: value)
|
|
467
|
+
Observation.record_dependency(dependency)
|
|
468
|
+
Observation.record_ambient_replay_input(:session, key, value)
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
def record_cookie(key, value)
|
|
472
|
+
return unless Observation.recording?
|
|
473
|
+
|
|
474
|
+
dependency = Dependencies::CookieValue.new(key: key, value: value)
|
|
475
|
+
Observation.record_dependency(dependency)
|
|
476
|
+
Observation.record_ambient_replay_input(:cookie, key, value)
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
def record_request(key, value)
|
|
480
|
+
return unless Observation.recording?
|
|
481
|
+
|
|
482
|
+
dependency = Dependencies::RequestValue.new(key: key, value: value)
|
|
483
|
+
Observation.record_dependency(dependency)
|
|
484
|
+
Observation.record_ambient_replay_input(:request, key, value)
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
def record_warden_user(scope, user)
|
|
488
|
+
return unless Observation.recording?
|
|
489
|
+
|
|
490
|
+
dependency = Dependencies::WardenUser.new(scope: scope, user: user)
|
|
491
|
+
Observation.record_dependency(dependency)
|
|
492
|
+
end
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
class ObservedHash
|
|
496
|
+
def initialize(source:, values:)
|
|
497
|
+
@source = source
|
|
498
|
+
@values = values || {}
|
|
499
|
+
end
|
|
500
|
+
|
|
501
|
+
def [](key)
|
|
502
|
+
value = lookup(key)
|
|
503
|
+
record(key, value)
|
|
504
|
+
value
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
def fetch(key, *fallback)
|
|
508
|
+
if include_key?(key)
|
|
509
|
+
self[key]
|
|
510
|
+
elsif block_given?
|
|
511
|
+
yield key
|
|
512
|
+
elsif fallback.any?
|
|
513
|
+
fallback.first
|
|
514
|
+
else
|
|
515
|
+
@values.fetch(key)
|
|
516
|
+
end
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
def dig(first_key, *rest)
|
|
520
|
+
value = self[first_key]
|
|
521
|
+
rest.empty? || value.nil? ? value : value.dig(*rest)
|
|
522
|
+
end
|
|
523
|
+
|
|
524
|
+
private
|
|
525
|
+
|
|
526
|
+
def lookup(key)
|
|
527
|
+
return @values[key] if @values.key?(key)
|
|
528
|
+
return @values[key.to_s] if @values.key?(key.to_s)
|
|
529
|
+
return @values[key.to_sym] if key.respond_to?(:to_sym) && @values.key?(key.to_sym)
|
|
530
|
+
|
|
531
|
+
nil
|
|
532
|
+
end
|
|
533
|
+
|
|
534
|
+
def include_key?(key)
|
|
535
|
+
@values.key?(key) ||
|
|
536
|
+
@values.key?(key.to_s) ||
|
|
537
|
+
(key.respond_to?(:to_sym) && @values.key?(key.to_sym))
|
|
538
|
+
end
|
|
539
|
+
|
|
540
|
+
def record(key, value)
|
|
541
|
+
case @source
|
|
542
|
+
when :session
|
|
543
|
+
Ambient.record_session(key, value)
|
|
544
|
+
when :cookie
|
|
545
|
+
Ambient.record_cookie(key, value)
|
|
546
|
+
end
|
|
547
|
+
end
|
|
548
|
+
end
|
|
549
|
+
|
|
550
|
+
class ObservedRequest
|
|
551
|
+
def initialize(values)
|
|
552
|
+
@values = values || {}
|
|
553
|
+
end
|
|
554
|
+
|
|
555
|
+
def host = read(:host)
|
|
556
|
+
|
|
557
|
+
def subdomain = read(:subdomain)
|
|
558
|
+
|
|
559
|
+
def path = read(:path)
|
|
560
|
+
|
|
561
|
+
def fullpath = read(:fullpath)
|
|
562
|
+
|
|
563
|
+
def request_method = read(:request_method)
|
|
564
|
+
|
|
565
|
+
def user_agent = read(:user_agent)
|
|
566
|
+
|
|
567
|
+
def remote_ip = read(:remote_ip)
|
|
568
|
+
|
|
569
|
+
def params = read(:params)
|
|
570
|
+
|
|
571
|
+
def [](key)
|
|
572
|
+
read(key)
|
|
573
|
+
end
|
|
574
|
+
|
|
575
|
+
private
|
|
576
|
+
|
|
577
|
+
def read(key)
|
|
578
|
+
value = lookup(key)
|
|
579
|
+
Ambient.record_request(key, value)
|
|
580
|
+
value
|
|
581
|
+
end
|
|
582
|
+
|
|
583
|
+
def lookup(key)
|
|
584
|
+
return @values[key] if @values.key?(key)
|
|
585
|
+
return @values[key.to_s] if @values.key?(key.to_s)
|
|
586
|
+
return @values[key.to_sym] if key.respond_to?(:to_sym) && @values.key?(key.to_sym)
|
|
587
|
+
|
|
588
|
+
nil
|
|
589
|
+
end
|
|
590
|
+
end
|
|
591
|
+
|
|
592
|
+
class ObservedWarden
|
|
593
|
+
def initialize(users_by_scope)
|
|
594
|
+
@users_by_scope = users_by_scope || {}
|
|
595
|
+
end
|
|
596
|
+
|
|
597
|
+
def user(scope = :user, **options)
|
|
598
|
+
scope = options.fetch(:scope, scope)
|
|
599
|
+
value = @users_by_scope[scope] || @users_by_scope[scope.to_s] || @users_by_scope[scope.to_sym]
|
|
600
|
+
Ambient.record_warden_user(scope, value)
|
|
601
|
+
value
|
|
602
|
+
end
|
|
603
|
+
|
|
604
|
+
def authenticate(*args, **options)
|
|
605
|
+
user(extract_scope(args, options))
|
|
606
|
+
end
|
|
607
|
+
|
|
608
|
+
def authenticated?(*args, **options)
|
|
609
|
+
!user(extract_scope(args, options)).nil?
|
|
610
|
+
end
|
|
611
|
+
|
|
612
|
+
private
|
|
613
|
+
|
|
614
|
+
def extract_scope(args, options)
|
|
615
|
+
options.fetch(:scope) { args.first || :user }
|
|
616
|
+
end
|
|
617
|
+
end
|
|
618
|
+
|
|
619
|
+
module CurrentAttributesClassObserver
|
|
620
|
+
def attribute(*names, **options)
|
|
621
|
+
result = super
|
|
622
|
+
Runtime.wrap_current_attribute_readers(self, names)
|
|
623
|
+
result
|
|
624
|
+
end
|
|
625
|
+
end
|
|
626
|
+
|
|
627
|
+
module WardenObserver
|
|
628
|
+
def user(*args, **options, &block)
|
|
629
|
+
value = super
|
|
630
|
+
Runtime::Ambient.record_warden_user(warden_scope(args, options), value)
|
|
631
|
+
value
|
|
632
|
+
end
|
|
633
|
+
|
|
634
|
+
def authenticate(*args, **options, &block)
|
|
635
|
+
value = super
|
|
636
|
+
Runtime::Ambient.record_warden_user(warden_scope(args, options), value)
|
|
637
|
+
value
|
|
638
|
+
end
|
|
639
|
+
|
|
640
|
+
private
|
|
641
|
+
|
|
642
|
+
def warden_scope(args, options)
|
|
643
|
+
options.fetch(:scope) { args.first || :user }
|
|
644
|
+
end
|
|
645
|
+
end
|
|
646
|
+
|
|
647
|
+
module SessionObserver
|
|
648
|
+
def [](key)
|
|
649
|
+
value = super
|
|
650
|
+
Runtime::Ambient.record_session(key, value)
|
|
651
|
+
value
|
|
652
|
+
end
|
|
653
|
+
|
|
654
|
+
def fetch(key, *args, &block)
|
|
655
|
+
value = super
|
|
656
|
+
Runtime::Ambient.record_session(key, value)
|
|
657
|
+
value
|
|
658
|
+
end
|
|
659
|
+
end
|
|
660
|
+
|
|
661
|
+
module CookieObserver
|
|
662
|
+
def [](key)
|
|
663
|
+
value = super
|
|
664
|
+
Runtime::Ambient.record_cookie(key, value)
|
|
665
|
+
value
|
|
666
|
+
end
|
|
667
|
+
end
|
|
668
|
+
|
|
669
|
+
module RequestObserver
|
|
670
|
+
def host
|
|
671
|
+
value = super
|
|
672
|
+
Runtime::Ambient.record_request(:host, value)
|
|
673
|
+
value
|
|
674
|
+
end
|
|
675
|
+
|
|
676
|
+
def subdomain
|
|
677
|
+
value = super
|
|
678
|
+
Runtime::Ambient.record_request(:subdomain, value)
|
|
679
|
+
value
|
|
680
|
+
end
|
|
681
|
+
|
|
682
|
+
def path
|
|
683
|
+
value = super
|
|
684
|
+
Runtime::Ambient.record_request(:path, value)
|
|
685
|
+
value
|
|
686
|
+
end
|
|
687
|
+
|
|
688
|
+
def fullpath
|
|
689
|
+
value = super
|
|
690
|
+
Runtime::Ambient.record_request(:fullpath, value)
|
|
691
|
+
value
|
|
692
|
+
end
|
|
693
|
+
|
|
694
|
+
def request_method
|
|
695
|
+
value = super
|
|
696
|
+
Runtime::Ambient.record_request(:request_method, value)
|
|
697
|
+
value
|
|
698
|
+
end
|
|
699
|
+
|
|
700
|
+
def user_agent
|
|
701
|
+
value = super
|
|
702
|
+
Runtime::Ambient.record_request(:user_agent, value)
|
|
703
|
+
value
|
|
704
|
+
end
|
|
705
|
+
|
|
706
|
+
def remote_ip
|
|
707
|
+
value = super
|
|
708
|
+
Runtime::Ambient.record_request(:remote_ip, value)
|
|
709
|
+
value
|
|
710
|
+
end
|
|
711
|
+
|
|
712
|
+
def params
|
|
713
|
+
value = super
|
|
714
|
+
Runtime::Ambient.record_request(:params, value)
|
|
715
|
+
value
|
|
716
|
+
end
|
|
717
|
+
end
|
|
718
|
+
|
|
719
|
+
class Current
|
|
720
|
+
THREAD_KEY = :upkeep_current_user
|
|
721
|
+
|
|
722
|
+
class << self
|
|
723
|
+
def set(user:)
|
|
724
|
+
previous = Thread.current[THREAD_KEY]
|
|
725
|
+
Thread.current[THREAD_KEY] = user
|
|
726
|
+
yield
|
|
727
|
+
ensure
|
|
728
|
+
Thread.current[THREAD_KEY] = previous
|
|
729
|
+
end
|
|
730
|
+
|
|
731
|
+
def user
|
|
732
|
+
user = Thread.current[THREAD_KEY]
|
|
733
|
+
return user unless Observation.recording?
|
|
734
|
+
|
|
735
|
+
dependency = Dependencies::Identity.new(
|
|
736
|
+
source: "Current.user",
|
|
737
|
+
key: "id",
|
|
738
|
+
value: user&.id,
|
|
739
|
+
metadata: {
|
|
740
|
+
model: user&.class&.name,
|
|
741
|
+
table: user&.class&.table_name,
|
|
742
|
+
id: user&.id
|
|
743
|
+
}.compact
|
|
744
|
+
)
|
|
745
|
+
|
|
746
|
+
Observation.record_dependency(dependency)
|
|
747
|
+
user
|
|
748
|
+
end
|
|
749
|
+
end
|
|
750
|
+
end
|
|
751
|
+
|
|
752
|
+
module AttributeObserver
|
|
753
|
+
def _read_attribute(attr_name, &block)
|
|
754
|
+
value = super
|
|
755
|
+
return value unless Observation.recording?
|
|
756
|
+
|
|
757
|
+
dependency = Dependencies::ActiveRecordAttribute.new(
|
|
758
|
+
table: self.class.table_name,
|
|
759
|
+
model: self.class.name,
|
|
760
|
+
id: primary_key_value(attr_name, value),
|
|
761
|
+
attribute: attr_name.to_s
|
|
762
|
+
)
|
|
763
|
+
|
|
764
|
+
Observation.record_dependency(dependency)
|
|
765
|
+
|
|
766
|
+
value
|
|
767
|
+
end
|
|
768
|
+
|
|
769
|
+
private
|
|
770
|
+
|
|
771
|
+
def primary_key_value(attr_name, value)
|
|
772
|
+
primary_key = self.class.primary_key
|
|
773
|
+
return nil unless primary_key
|
|
774
|
+
return value if attr_name.to_s == primary_key.to_s
|
|
775
|
+
|
|
776
|
+
@attributes.fetch_value(primary_key)
|
|
777
|
+
rescue StandardError
|
|
778
|
+
nil
|
|
779
|
+
end
|
|
780
|
+
end
|
|
781
|
+
|
|
782
|
+
module PersistenceObserver
|
|
783
|
+
def update_columns(attributes)
|
|
784
|
+
new_values = upkeep_update_column_values(attributes)
|
|
785
|
+
changed_attributes = new_values.keys
|
|
786
|
+
|
|
787
|
+
super.tap do |result|
|
|
788
|
+
if result
|
|
789
|
+
ChangeLog.record(
|
|
790
|
+
ChangeEvents.active_record_update_columns(
|
|
791
|
+
self,
|
|
792
|
+
changed_attributes: changed_attributes,
|
|
793
|
+
new_values: new_values
|
|
794
|
+
)
|
|
795
|
+
)
|
|
796
|
+
end
|
|
797
|
+
end
|
|
798
|
+
end
|
|
799
|
+
|
|
800
|
+
private
|
|
801
|
+
|
|
802
|
+
def upkeep_update_column_values(attributes)
|
|
803
|
+
attributes.to_h.reject { |attribute, _value| attribute.to_s == "touch" }.transform_keys do |attribute|
|
|
804
|
+
self.class.attribute_aliases.fetch(attribute.to_s, attribute.to_s)
|
|
805
|
+
end
|
|
806
|
+
end
|
|
807
|
+
end
|
|
808
|
+
|
|
809
|
+
module RelationObserver
|
|
810
|
+
SUPPRESS_DEPENDENCY_KEY = :upkeep_runtime_relation_dependency_suppressed
|
|
811
|
+
|
|
812
|
+
def self.suppress_dependency_tracking
|
|
813
|
+
previous = Thread.current[SUPPRESS_DEPENDENCY_KEY]
|
|
814
|
+
Thread.current[SUPPRESS_DEPENDENCY_KEY] = true
|
|
815
|
+
yield
|
|
816
|
+
ensure
|
|
817
|
+
Thread.current[SUPPRESS_DEPENDENCY_KEY] = previous
|
|
818
|
+
end
|
|
819
|
+
|
|
820
|
+
def self.dependency_tracking_suppressed?
|
|
821
|
+
Thread.current[SUPPRESS_DEPENDENCY_KEY]
|
|
822
|
+
end
|
|
823
|
+
|
|
824
|
+
def exec_queries(...)
|
|
825
|
+
analysis = relation_analysis_for_observation
|
|
826
|
+
super.tap { |records| record_relation_provenance(records, analysis) }
|
|
827
|
+
end
|
|
828
|
+
|
|
829
|
+
def to_ary
|
|
830
|
+
super.tap { |records| record_relation_provenance(records, relation_analysis_for_observation) }
|
|
831
|
+
end
|
|
832
|
+
|
|
833
|
+
def to_a
|
|
834
|
+
to_ary
|
|
835
|
+
end
|
|
836
|
+
|
|
837
|
+
def pluck(*column_names)
|
|
838
|
+
record_query_dependency(column_names)
|
|
839
|
+
super
|
|
840
|
+
end
|
|
841
|
+
|
|
842
|
+
def update_all(updates)
|
|
843
|
+
analysis = ActiveRecordQuery.analyze(self, opaque_table_policy: :allow_table)
|
|
844
|
+
event = ChangeEvents.bulk_update(
|
|
845
|
+
table: klass.table_name,
|
|
846
|
+
model: klass.name,
|
|
847
|
+
changed_attributes: update_columns(updates),
|
|
848
|
+
predicate_sql: analysis.sql,
|
|
849
|
+
predicate_coverage: analysis.coverage.to_s,
|
|
850
|
+
predicate_table_columns: analysis.table_columns,
|
|
851
|
+
new_values: update_values(updates)
|
|
852
|
+
)
|
|
853
|
+
|
|
854
|
+
super.tap { ChangeLog.record(event) }
|
|
855
|
+
end
|
|
856
|
+
|
|
857
|
+
def delete_all
|
|
858
|
+
analysis = ActiveRecordQuery.analyze(self, opaque_table_policy: :allow_table)
|
|
859
|
+
event = ChangeEvents.bulk_delete(
|
|
860
|
+
table: klass.table_name,
|
|
861
|
+
model: klass.name,
|
|
862
|
+
changed_attributes: [klass.primary_key].compact,
|
|
863
|
+
predicate_sql: analysis.sql,
|
|
864
|
+
predicate_coverage: analysis.coverage.to_s,
|
|
865
|
+
predicate_table_columns: analysis.table_columns
|
|
866
|
+
)
|
|
867
|
+
|
|
868
|
+
super.tap { ChangeLog.record(event) }
|
|
869
|
+
end
|
|
870
|
+
|
|
871
|
+
private
|
|
872
|
+
|
|
873
|
+
def relation_analysis_for_observation
|
|
874
|
+
return unless Observation.recorder
|
|
875
|
+
return if RelationObserver.dependency_tracking_suppressed?
|
|
876
|
+
return @upkeep_relation_analysis if instance_variable_defined?(:@upkeep_relation_analysis)
|
|
877
|
+
|
|
878
|
+
@upkeep_relation_analysis = ActiveRecordQuery.analyze(self)
|
|
879
|
+
rescue ActiveRecordQuery::OpaqueRelationError => error
|
|
880
|
+
@upkeep_relation_analysis = nil
|
|
881
|
+
handle_opaque_relation_dependency(error)
|
|
882
|
+
nil
|
|
883
|
+
end
|
|
884
|
+
|
|
885
|
+
def record_relation_provenance(records, analysis)
|
|
886
|
+
Observation.record_relation_provenance(records, model_name: klass.name, analysis: analysis) if analysis
|
|
887
|
+
end
|
|
888
|
+
|
|
889
|
+
def record_query_dependency(column_names)
|
|
890
|
+
analysis = relation_analysis_for_observation
|
|
891
|
+
return unless analysis
|
|
892
|
+
|
|
893
|
+
Observation.record_dependency(
|
|
894
|
+
Dependencies::ActiveRecordQuery.new(
|
|
895
|
+
primary_table: analysis.primary_table,
|
|
896
|
+
table_columns: relation_table_columns(analysis, pluck_dependency_columns(column_names)),
|
|
897
|
+
coverage: analysis.coverage,
|
|
898
|
+
sql: analysis.sql,
|
|
899
|
+
predicates: analysis.predicates
|
|
900
|
+
)
|
|
901
|
+
)
|
|
902
|
+
rescue ActiveRecordQuery::OpaqueRelationError => error
|
|
903
|
+
handle_opaque_relation_dependency(error)
|
|
904
|
+
end
|
|
905
|
+
|
|
906
|
+
def relation_table_columns(analysis, extra_columns)
|
|
907
|
+
analysis.table_columns.merge(
|
|
908
|
+
klass.table_name => (analysis.table_columns.fetch(klass.table_name, []) + extra_columns).uniq.sort
|
|
909
|
+
)
|
|
910
|
+
end
|
|
911
|
+
|
|
912
|
+
def pluck_dependency_columns(column_names)
|
|
913
|
+
column_names.flatten.map do |column_name|
|
|
914
|
+
pluck_dependency_column(column_name)
|
|
915
|
+
end
|
|
916
|
+
end
|
|
917
|
+
|
|
918
|
+
def pluck_dependency_column(column_name)
|
|
919
|
+
case column_name
|
|
920
|
+
when Symbol
|
|
921
|
+
return column_name.to_s
|
|
922
|
+
when String
|
|
923
|
+
return column_name if klass.column_names.include?(column_name)
|
|
924
|
+
else
|
|
925
|
+
if column_name.respond_to?(:to_sym)
|
|
926
|
+
name = column_name.to_sym.to_s
|
|
927
|
+
return name if klass.column_names.include?(name)
|
|
928
|
+
end
|
|
929
|
+
|
|
930
|
+
if column_name.respond_to?(:name) && column_name.respond_to?(:relation)
|
|
931
|
+
name = column_name.name.to_s
|
|
932
|
+
return name if klass.column_names.include?(name)
|
|
933
|
+
end
|
|
934
|
+
end
|
|
935
|
+
|
|
936
|
+
raise ActiveRecordQuery::OpaqueRelationError.new(
|
|
937
|
+
self,
|
|
938
|
+
reasons: ["opaque pluck column #{column_name.inspect}"]
|
|
939
|
+
)
|
|
940
|
+
end
|
|
941
|
+
|
|
942
|
+
def handle_opaque_relation_dependency(error)
|
|
943
|
+
raise error if refused_boundary_behavior == :raise
|
|
944
|
+
|
|
945
|
+
payload = {
|
|
946
|
+
reason: "opaque_active_record_relation",
|
|
947
|
+
message: error.message,
|
|
948
|
+
suggestions: error.suggestions,
|
|
949
|
+
source: "active_record_relation"
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
if Observation.refuse_boundary(payload)
|
|
953
|
+
ActiveSupport::Notifications.instrument("refused_boundary.upkeep", payload)
|
|
954
|
+
warn_refused_boundary(payload)
|
|
955
|
+
end
|
|
956
|
+
end
|
|
957
|
+
|
|
958
|
+
def refused_boundary_behavior
|
|
959
|
+
if defined?(Upkeep::Rails) && Upkeep::Rails.respond_to?(:configuration)
|
|
960
|
+
Upkeep::Rails.configuration.refused_boundary_behavior
|
|
961
|
+
else
|
|
962
|
+
:raise
|
|
963
|
+
end
|
|
964
|
+
end
|
|
965
|
+
|
|
966
|
+
def warn_refused_boundary(payload)
|
|
967
|
+
return unless defined?(::Rails) && ::Rails.respond_to?(:logger) && ::Rails.logger
|
|
968
|
+
|
|
969
|
+
::Rails.logger.warn(
|
|
970
|
+
"Upkeep refused #{payload.fetch(:source)}: #{payload.fetch(:reason)}. " \
|
|
971
|
+
"#{payload.fetch(:suggestions).join(" ")}"
|
|
972
|
+
)
|
|
973
|
+
end
|
|
974
|
+
|
|
975
|
+
def update_columns(updates)
|
|
976
|
+
case updates
|
|
977
|
+
when Hash
|
|
978
|
+
updates.keys.map(&:to_s)
|
|
979
|
+
else
|
|
980
|
+
klass.column_names
|
|
981
|
+
end
|
|
982
|
+
end
|
|
983
|
+
|
|
984
|
+
def update_values(updates)
|
|
985
|
+
return {} unless updates.is_a?(Hash)
|
|
986
|
+
|
|
987
|
+
updates.transform_keys(&:to_s)
|
|
988
|
+
end
|
|
989
|
+
end
|
|
990
|
+
|
|
991
|
+
module Install
|
|
992
|
+
module_function
|
|
993
|
+
|
|
994
|
+
def call
|
|
995
|
+
return if @installed
|
|
996
|
+
|
|
997
|
+
install_current_attributes_observer
|
|
998
|
+
install_warden_observer
|
|
999
|
+
install_action_dispatch_observers
|
|
1000
|
+
|
|
1001
|
+
ActiveRecord::AttributeMethods::Read.prepend(AttributeObserver)
|
|
1002
|
+
ActiveRecord::Base.prepend(PersistenceObserver) unless ActiveRecord::Base < PersistenceObserver
|
|
1003
|
+
ActiveRecord::Relation.prepend(RelationObserver)
|
|
1004
|
+
|
|
1005
|
+
ActiveRecord::Base.after_commit do |record|
|
|
1006
|
+
ChangeLog.record(ChangeEvents.active_record_commit(record))
|
|
1007
|
+
end
|
|
1008
|
+
|
|
1009
|
+
@installed = true
|
|
1010
|
+
end
|
|
1011
|
+
|
|
1012
|
+
def install_current_attributes_observer
|
|
1013
|
+
singleton = class << ActiveSupport::CurrentAttributes; self; end
|
|
1014
|
+
singleton.prepend(CurrentAttributesClassObserver) unless singleton < CurrentAttributesClassObserver
|
|
1015
|
+
|
|
1016
|
+
ObjectSpace.each_object(Class) do |klass|
|
|
1017
|
+
next unless klass < ActiveSupport::CurrentAttributes
|
|
1018
|
+
|
|
1019
|
+
Runtime.wrap_current_attribute_readers(klass, current_attribute_names(klass))
|
|
1020
|
+
end
|
|
1021
|
+
end
|
|
1022
|
+
|
|
1023
|
+
def install_warden_observer
|
|
1024
|
+
return unless defined?(::Warden::Proxy)
|
|
1025
|
+
return if ::Warden::Proxy < WardenObserver
|
|
1026
|
+
|
|
1027
|
+
::Warden::Proxy.prepend(WardenObserver)
|
|
1028
|
+
end
|
|
1029
|
+
|
|
1030
|
+
def install_action_dispatch_observers
|
|
1031
|
+
if defined?(::ActionDispatch::Request::Session) && !(::ActionDispatch::Request::Session < SessionObserver)
|
|
1032
|
+
::ActionDispatch::Request::Session.prepend(SessionObserver)
|
|
1033
|
+
end
|
|
1034
|
+
|
|
1035
|
+
if defined?(::ActionDispatch::Cookies::CookieJar) && !(::ActionDispatch::Cookies::CookieJar < CookieObserver)
|
|
1036
|
+
::ActionDispatch::Cookies::CookieJar.prepend(CookieObserver)
|
|
1037
|
+
end
|
|
1038
|
+
|
|
1039
|
+
if defined?(::ActionDispatch::Request) && !(::ActionDispatch::Request < RequestObserver)
|
|
1040
|
+
::ActionDispatch::Request.prepend(RequestObserver)
|
|
1041
|
+
end
|
|
1042
|
+
end
|
|
1043
|
+
|
|
1044
|
+
def current_attribute_names(klass)
|
|
1045
|
+
if klass.respond_to?(:defaults)
|
|
1046
|
+
klass.defaults.keys
|
|
1047
|
+
else
|
|
1048
|
+
[]
|
|
1049
|
+
end
|
|
1050
|
+
end
|
|
1051
|
+
end
|
|
1052
|
+
|
|
1053
|
+
module_function
|
|
1054
|
+
|
|
1055
|
+
def wrap_current_attribute_readers(klass, names)
|
|
1056
|
+
wrapped = klass.instance_variable_get(:@upkeep_wrapped_current_attributes) || {}
|
|
1057
|
+
|
|
1058
|
+
names.each do |name|
|
|
1059
|
+
name = name.to_sym
|
|
1060
|
+
next if wrapped[name]
|
|
1061
|
+
next unless klass.method_defined?(name)
|
|
1062
|
+
|
|
1063
|
+
original_reader = klass.instance_method(name)
|
|
1064
|
+
klass.define_method(name) do
|
|
1065
|
+
value = original_reader.bind_call(self)
|
|
1066
|
+
Runtime::Ambient.record_current_attribute(klass.name || klass.inspect, name, value)
|
|
1067
|
+
value
|
|
1068
|
+
end
|
|
1069
|
+
wrapped[name] = true
|
|
1070
|
+
end
|
|
1071
|
+
|
|
1072
|
+
klass.instance_variable_set(:@upkeep_wrapped_current_attributes, wrapped)
|
|
1073
|
+
end
|
|
1074
|
+
end
|
|
1075
|
+
end
|