glib-web 7.0.0.beta1 → 7.0.1.beta1
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 +4 -4
- data/app/controllers/concerns/glib/auth/policy.rb +5 -1
- data/app/controllers/concerns/glib/auth/token_authenticatable.rb +37 -0
- data/app/controllers/glib/api_controller.rb +52 -0
- data/app/helpers/glib/json_ui/view_builder/fields.rb +133 -4
- data/app/jobs/glib/purge_unattached_job.rb +32 -0
- data/app/models/concerns/glib/snapshot_v2.rb +485 -0
- data/app/models/glib/model.rb +40 -0
- data/app/models/glib/plain_model.rb +23 -0
- data/app/models/snapshot_blob_reference.rb +20 -0
- data/app/views/json_ui/garage/test_page/_header.json.jbuilder +1 -0
- data/app/views/json_ui/garage/test_page/dialog.json.jbuilder +21 -0
- data/app/views/json_ui/garage/test_page/fields_change_trigger.json.jbuilder +128 -0
- data/app/views/json_ui/garage/test_page/fields_sign.json.jbuilder +49 -1
- data/lib/generators/glib/snapshot_v2_generator.rb +54 -0
- data/lib/generators/templates/snapshot_v2/add_snapshot_v2_columns.rb +18 -0
- data/lib/generators/templates/snapshot_v2/create_snapshot_blob_references.rb +10 -0
- data/lib/glib/engine.rb +32 -0
- data/lib/glib/purge_unattached/config.rb +38 -0
- data/lib/glib/test/full_suite_rubocop.rb +87 -0
- data/lib/glib/test/rubocop_guards.rb +42 -0
- data/lib/glib/test_helpers.rb +9 -6
- data/lib/glib-web.rb +4 -2
- metadata +29 -2
- data/lib/glib/test/changed_files_rubocop.rb +0 -55
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
require 'active_snapshot'
|
|
2
|
+
require 'hashdiff'
|
|
3
|
+
|
|
4
|
+
# V2 snapshot concern adapted from mapping-web. Promotes action/version/diff/source from the
|
|
5
|
+
# monolithic `metadata` JSON to real columns, enabling indexed JSONB queries and a `source`
|
|
6
|
+
# attribution concept. Defaults to unlimited retention (max_snapshots nil).
|
|
7
|
+
#
|
|
8
|
+
# Patches applied on top of mapping-web's SnapshotV2:
|
|
9
|
+
# 1. ActiveStorage support in check_snapshot_changed (ported from V1 gem).
|
|
10
|
+
# 2. Nil has_one safe-nav in check_snapshot_changed.
|
|
11
|
+
# 3. has_one wrapping in diff_associations (V2 only tested with has_many in mapping-web).
|
|
12
|
+
# 4. ActiveStorage in diff_associations (convert Attached::One/Many to attachment records).
|
|
13
|
+
# 5. same_as_before? derives from the computed diff instead of the instance-local
|
|
14
|
+
# `snapshot_changed` flag (mapping-web's flag-gating silently dropped a target's
|
|
15
|
+
# snapshot when taken after the owner's -- see "Snapshot ordering" below, issue #481).
|
|
16
|
+
#
|
|
17
|
+
# ## Snapshot ordering: owner vs association targets
|
|
18
|
+
#
|
|
19
|
+
# Owner and association-target snapshots may be taken in ANY order within a request.
|
|
20
|
+
# `same_as_before?` decides from persisted state, so a target snapshotted after its owner
|
|
21
|
+
# (through the replaced association target) still records its version.
|
|
22
|
+
#
|
|
23
|
+
# The one residual ordering caveat: `diff` compares against the instance's IN-MEMORY
|
|
24
|
+
# attributes, so unsaved changes live only on the instance that holds them. If a target is
|
|
25
|
+
# modified in memory without saving, snapshot it via a local captured BEFORE the owner's
|
|
26
|
+
# `glib_create_snapshot!` (whose `with_lock` reload replaces the association targets) --
|
|
27
|
+
# reading the target back through the association snapshots the persisted state instead.
|
|
28
|
+
#
|
|
29
|
+
# `snapshot_changed` / `attributes_before_save` remain writable instance accessors for
|
|
30
|
+
# call-site use, but they are no longer consulted for the skip decision.
|
|
31
|
+
#
|
|
32
|
+
# To get all records of a model, you can use the following query:
|
|
33
|
+
# MyModel.joins(:snapshots).distinct
|
|
34
|
+
module Glib
|
|
35
|
+
module SnapshotV2
|
|
36
|
+
extend ActiveSupport::Concern
|
|
37
|
+
|
|
38
|
+
# The CRUD backbone covers most flows; the approval-gate admin actions are distinct
|
|
39
|
+
# audit events (request_changes / approve / undo_approve) that the timeline UI labels
|
|
40
|
+
# off the action string, so they earn their own slots here rather than being collapsed
|
|
41
|
+
# into :update (which would lose the "what happened" signal in the audit trail).
|
|
42
|
+
KNOWN_ACTIONS = [:create, :update, :destroy, :request_changes, :approve, :undo_approve].freeze
|
|
43
|
+
|
|
44
|
+
included do
|
|
45
|
+
include ::ActiveSnapshot
|
|
46
|
+
|
|
47
|
+
attr_accessor :snapshot_changed
|
|
48
|
+
attr_accessor :attributes_before_save
|
|
49
|
+
|
|
50
|
+
# Save-boundary state capture for the snapshot diff engine -- not a hidden side
|
|
51
|
+
# effect. Records the pre-save DB state into `attributes_before_save` and
|
|
52
|
+
# computes `snapshot_changed` so the diff (see `diff` / `same_as_before?`) has a
|
|
53
|
+
# "before" to compare against. `attributes_in_database` is only readable inside
|
|
54
|
+
# the save boundary, and this is a generic concern mixed into many models, so it
|
|
55
|
+
# can't be hoisted to an individual save site the way notification callbacks were.
|
|
56
|
+
# It only sets instance state (no writes, no notifications) -- the actual snapshot
|
|
57
|
+
# row is written by the explicit, call-site-visible `save_with_snapshot` /
|
|
58
|
+
# `glib_create_snapshot!` paths, which is where the acting user is known.
|
|
59
|
+
before_save do # rubocop:disable DevDoc/Rails/AvoidRailsCallbacks
|
|
60
|
+
# Only set snapshot_changed if not already set (handles case where before_save
|
|
61
|
+
# is called multiple times - e.g., when associated records are saved before parent)
|
|
62
|
+
if snapshot_changed.nil?
|
|
63
|
+
self.attributes_before_save = attributes_in_database
|
|
64
|
+
self.snapshot_changed = check_snapshot_changed
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def check_snapshot_changed
|
|
70
|
+
return true if changed?
|
|
71
|
+
|
|
72
|
+
associations_for_snapshot.each do |association_name|
|
|
73
|
+
association = self.class.reflect_on_association(association_name)
|
|
74
|
+
association_value = public_send(association_name)
|
|
75
|
+
|
|
76
|
+
# ActiveStorage associations (has_one_attached / has_many_attached) are not
|
|
77
|
+
# reflected as standard AR macros; handle them before the case block.
|
|
78
|
+
if active_storage_association?(association, association_value)
|
|
79
|
+
active_storage_records(association_value).each do |record|
|
|
80
|
+
return true if record.changed?
|
|
81
|
+
end
|
|
82
|
+
next
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
raise "Invalid association: #{association_name}" if association.nil?
|
|
86
|
+
|
|
87
|
+
case association.macro
|
|
88
|
+
when :has_many
|
|
89
|
+
records = association_value
|
|
90
|
+
records.each do |record|
|
|
91
|
+
return true if record.changed?
|
|
92
|
+
end
|
|
93
|
+
if new_record? && records.count > 0
|
|
94
|
+
return true
|
|
95
|
+
end
|
|
96
|
+
when :has_one, :belongs_to
|
|
97
|
+
return true if association_value&.changed?
|
|
98
|
+
else
|
|
99
|
+
raise "Unexpected association macro: #{association.macro}"
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
false
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# information need to be store:
|
|
107
|
+
# - action: create, update, destroy
|
|
108
|
+
# - track changes
|
|
109
|
+
def glib_create_snapshot!(action, source, user: nil)
|
|
110
|
+
action_sym = action.to_sym
|
|
111
|
+
raise "unknown action: #{action}" unless KNOWN_ACTIONS.include?(action_sym)
|
|
112
|
+
raise 'source must be present' if source.blank?
|
|
113
|
+
raise 'user must be present when source is "user"' if source == 'user' && user.nil?
|
|
114
|
+
|
|
115
|
+
# Lock is required to prevent race conditions when calculating version number.
|
|
116
|
+
if (result =
|
|
117
|
+
with_lock do
|
|
118
|
+
version = last_version + 1
|
|
119
|
+
calculated_diff = diff
|
|
120
|
+
|
|
121
|
+
snapshot_obj = {
|
|
122
|
+
identifier: snapshot_identifier(version),
|
|
123
|
+
user: user,
|
|
124
|
+
metadata: {} # Keep for potential future use, but main data in columns
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
# Don't create version if same as before. Reuses `calculated_diff` instead of
|
|
128
|
+
# recomputing it inside `same_as_before?`.
|
|
129
|
+
unless same_as_before?(calculated_diff)
|
|
130
|
+
# No nil guard: active_snapshot's `create_snapshot!` raises on failure and
|
|
131
|
+
# always returns the snapshot.
|
|
132
|
+
snapshot = create_snapshot!(**snapshot_obj)
|
|
133
|
+
# update_columns is the second half of the insert, not a domain update: the
|
|
134
|
+
# gem's create_snapshot! signature can't set these V2-promoted columns, and
|
|
135
|
+
# no validation/callback (gem or initializer) concerns them. Runs inside
|
|
136
|
+
# with_lock's transaction, so no half-written row ever commits.
|
|
137
|
+
snapshot.update_columns( # rubocop:disable DevDoc/Rails/AvoidBypassingValidation
|
|
138
|
+
action: action.to_s,
|
|
139
|
+
version: version,
|
|
140
|
+
diff: calculated_diff,
|
|
141
|
+
source: source
|
|
142
|
+
)
|
|
143
|
+
record_blob_references(snapshot, calculated_diff)
|
|
144
|
+
snapshot
|
|
145
|
+
end
|
|
146
|
+
end)
|
|
147
|
+
# Cleanup operations - run outside lock and transaction
|
|
148
|
+
remove_old_snapshot
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
result
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def glib_revert_snapshot!(version)
|
|
155
|
+
snapshot = snapshots.find_by!(identifier: snapshot_identifier(version))
|
|
156
|
+
snapshot.restore!
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def snapshot_identifier(version)
|
|
160
|
+
"#{self.class.to_s.underscore}_#{id}_version_#{version}"
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def interval_from_prev_version
|
|
164
|
+
return nil if snapshot_prev.nil?
|
|
165
|
+
|
|
166
|
+
updated_at - snapshot_prev.created_at
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def diff(snapshot = snapshot_prev)
|
|
170
|
+
default_ignored_keys = ['updated_at', 'created_at']
|
|
171
|
+
ignore_keys = build_ignore_keys(default_ignored_keys)
|
|
172
|
+
|
|
173
|
+
item, associations = fetch_snapshot_items(snapshot)
|
|
174
|
+
|
|
175
|
+
{
|
|
176
|
+
'item' => ::Hashdiff.diff(
|
|
177
|
+
normalize_attributes_for_diff(item.attributes.except(*ignore_keys)),
|
|
178
|
+
normalize_attributes_for_diff(attributes.except(*ignore_keys))
|
|
179
|
+
),
|
|
180
|
+
'associations' => diff_associations(associations, default_ignored_keys)
|
|
181
|
+
}
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def snapshot_prev
|
|
185
|
+
snapshots.order(version: :desc).first
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Decides whether `glib_create_snapshot!` should skip the version write. Derived from
|
|
189
|
+
# the computed diff (persisted state), never from the instance-local `snapshot_changed`
|
|
190
|
+
# flag: the flag dies with its instance, and an owner's snapshot replaces association
|
|
191
|
+
# targets with fresh instances (`with_lock`'s reload clears the association cache, then
|
|
192
|
+
# the diff walk re-reads the targets), so flag-gating used to silently drop a target's
|
|
193
|
+
# follow-up snapshot taken through the association (issue #481). For a first snapshot
|
|
194
|
+
# (no previous version) the diff runs current state against `attributes_before_save || {}`.
|
|
195
|
+
def same_as_before?(computed_diff = diff)
|
|
196
|
+
item_unchanged = computed_diff['item'].blank?
|
|
197
|
+
assoc_diff = computed_diff['associations']
|
|
198
|
+
associations_unchanged = assoc_diff.nil? || assoc_diff.values.all?(&:blank?)
|
|
199
|
+
|
|
200
|
+
item_unchanged && associations_unchanged
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def last_version
|
|
204
|
+
max_version = snapshots.maximum(:version)
|
|
205
|
+
return 0 if max_version.blank?
|
|
206
|
+
|
|
207
|
+
max_version
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def remove_old_snapshot
|
|
211
|
+
return if max_snapshots.nil?
|
|
212
|
+
|
|
213
|
+
newest_ids = snapshots.order(version: :desc, id: :desc).limit(max_snapshots).ids
|
|
214
|
+
return unless newest_ids.size >= max_snapshots
|
|
215
|
+
|
|
216
|
+
snapshots.where.not(id: newest_ids).destroy_all
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def watched_keys_for_snapshot
|
|
220
|
+
raise NotImplementedError, "please add method 'watched_keys_for_snapshot' to #{self.class}"
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def associations_for_snapshot
|
|
224
|
+
children_to_snapshot.keys.filter { |key| respond_to?(key) }
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def max_snapshots
|
|
228
|
+
nil
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Return number of days to retain snapshot content, or nil for unlimited.
|
|
232
|
+
def content_retention_days
|
|
233
|
+
nil
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
private
|
|
237
|
+
# Truncate Time/TimeWithZone values to the JSON encoding precision so that
|
|
238
|
+
# reified (JSON-round-tripped) attributes compare equal to live DB values
|
|
239
|
+
# that carry microsecond precision. Without this, Hashdiff reports a
|
|
240
|
+
# spurious change for every datetime field on every re-snapshot.
|
|
241
|
+
#
|
|
242
|
+
# The divisor is derived from ActiveSupport::JSON::Encoding.time_precision so a consumer who
|
|
243
|
+
# raises it above the Rails default of 3 (ms) is not over-truncated — mirrors V1's
|
|
244
|
+
# Glib::Snapshot#normalize_attributes / snapshot_time_divisor exactly. no-op when the
|
|
245
|
+
# precision is already 6 (microseconds).
|
|
246
|
+
def normalize_attributes_for_diff(attrs)
|
|
247
|
+
divisor = snapshot_time_divisor
|
|
248
|
+
attrs.transform_values do |v|
|
|
249
|
+
if (v.is_a?(Time) || v.is_a?(ActiveSupport::TimeWithZone)) && divisor > 1
|
|
250
|
+
v.change(usec: v.usec - v.usec % divisor)
|
|
251
|
+
else
|
|
252
|
+
v
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def snapshot_time_divisor
|
|
258
|
+
10 ** (6 - ActiveSupport::JSON::Encoding.time_precision)
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def build_ignore_keys(default_ignored_keys)
|
|
262
|
+
ignore_keys = default_ignored_keys
|
|
263
|
+
|
|
264
|
+
unless watched_keys_for_snapshot.nil?
|
|
265
|
+
ignored_keys_for_snapshot = attributes.except(*watched_keys_for_snapshot.map(&:to_s)).keys
|
|
266
|
+
ignore_keys = (default_ignored_keys + ignored_keys_for_snapshot.map(&:to_s)).uniq
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
ignore_keys
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def fetch_snapshot_items(snapshot)
|
|
273
|
+
if snapshot.present?
|
|
274
|
+
snapshot.fetch_reified_items
|
|
275
|
+
else
|
|
276
|
+
[OpenStruct.new(attributes: attributes_before_save || {}), {}]
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def diff_associations(associations, default_ignored_keys)
|
|
281
|
+
associations_for_snapshot.reduce({}) do |prev, association_name|
|
|
282
|
+
before_records = associations[association_name] || []
|
|
283
|
+
|
|
284
|
+
records = send(association_name)
|
|
285
|
+
|
|
286
|
+
# ActiveStorage: convert Attached::One/Many to their underlying attachment records.
|
|
287
|
+
association = self.class.reflect_on_association(association_name)
|
|
288
|
+
if active_storage_association?(association, records)
|
|
289
|
+
records = active_storage_records(records)
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# has_one wrapping: V2 tested only with has_many in mapping-web; a has_one returns
|
|
293
|
+
# a single record (or nil), not a collection. Wrap it so the diff logic below works.
|
|
294
|
+
unless records.respond_to?(:each)
|
|
295
|
+
records = [records].compact
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# id is the universal snapshot identity here: the diff below matches records
|
|
299
|
+
# by id (index_by(&:id)), so this order only pins stable diff-path indices.
|
|
300
|
+
# created_at isn't guaranteed across every association this generic concern
|
|
301
|
+
# snapshots (ActiveStorage, through-tables) and can be backdated, so id — not
|
|
302
|
+
# created_at — is the right deterministic key. Generic engine, hence the disable.
|
|
303
|
+
now_records = records.respond_to?(:order) ? records.order(id: :asc) : records.to_a.sort_by { |r| r.id.to_i } # rubocop:disable DevDoc/Rails/AvoidOrderingById
|
|
304
|
+
|
|
305
|
+
association_diff = diff_single_association(
|
|
306
|
+
association_name,
|
|
307
|
+
before_records,
|
|
308
|
+
now_records,
|
|
309
|
+
default_ignored_keys
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
prev.merge(association_name.to_s => association_diff)
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def diff_single_association(_association_name, before_records, now_records, default_ignored_keys)
|
|
317
|
+
association_ignored_keys = build_association_ignore_keys(now_records.first, default_ignored_keys)
|
|
318
|
+
|
|
319
|
+
if before_records.blank?
|
|
320
|
+
return ::Hashdiff.diff([], now_records.map { |record| normalize_attributes_for_diff(record.attributes.except(*association_ignored_keys)) })
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
# Create hash lookups to avoid N+1 queries
|
|
324
|
+
now_records_by_id = now_records.index_by(&:id)
|
|
325
|
+
before_records_by_id = before_records.index_by(&:id)
|
|
326
|
+
|
|
327
|
+
# Detect deleted and updated records
|
|
328
|
+
deleted_or_updated = before_records.map.with_index do |record_before, index|
|
|
329
|
+
record_now = now_records_by_id[record_before.id]
|
|
330
|
+
if record_now.blank?
|
|
331
|
+
['-', "[#{index}]", normalize_attributes_for_diff(record_before.attributes.except(*association_ignored_keys))]
|
|
332
|
+
else
|
|
333
|
+
before_attrs = normalize_attributes_for_diff(record_before.attributes.except(*association_ignored_keys))
|
|
334
|
+
now_attrs = normalize_attributes_for_diff(record_now.attributes.except(*association_ignored_keys))
|
|
335
|
+
next if Hashdiff.diff(before_attrs, now_attrs).blank?
|
|
336
|
+
|
|
337
|
+
['~', "[#{index}]", before_attrs, now_attrs]
|
|
338
|
+
end
|
|
339
|
+
end.compact_blank
|
|
340
|
+
|
|
341
|
+
# Detect added records
|
|
342
|
+
added = now_records.map.with_index do |record_now, index|
|
|
343
|
+
record_before = before_records_by_id[record_now.id]
|
|
344
|
+
if record_before.blank?
|
|
345
|
+
['+', "[#{index}]", normalize_attributes_for_diff(record_now.attributes.except(*association_ignored_keys))]
|
|
346
|
+
end
|
|
347
|
+
end.compact_blank
|
|
348
|
+
|
|
349
|
+
deleted_or_updated + added
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
def build_association_ignore_keys(first_record, default_ignored_keys)
|
|
353
|
+
if first_record.present? && !active_storage_record?(first_record) && !first_record.respond_to?(:watched_keys_for_snapshot)
|
|
354
|
+
raise NotImplementedError, "please add method 'watched_keys_for_snapshot' to #{first_record.class}"
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
if active_storage_record?(first_record)
|
|
358
|
+
default_ignored_keys
|
|
359
|
+
elsif first_record.try(:watched_keys_for_snapshot).nil?
|
|
360
|
+
default_ignored_keys
|
|
361
|
+
else
|
|
362
|
+
assoc_ignore_keys = first_record.attributes.except(*first_record.watched_keys_for_snapshot.map(&:to_s)).keys.map(&:to_s)
|
|
363
|
+
(default_ignored_keys + assoc_ignore_keys).uniq
|
|
364
|
+
end
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
# --- Blob reference tracking ---
|
|
368
|
+
#
|
|
369
|
+
# When a snapshot diff includes attachment changes, extract the blob_ids and persist them as
|
|
370
|
+
# SnapshotBlobReference records. This prevents Glib::PurgeUnattachedJob from deleting old file
|
|
371
|
+
# versions that are still visible in the snapshot timeline.
|
|
372
|
+
#
|
|
373
|
+
# Opt-in: the host app must run the gem's `create_snapshot_blob_references` migration. The
|
|
374
|
+
# `table_exists?` guard makes this a silent no-op for consumers that skip the migration, so
|
|
375
|
+
# `include Glib::SnapshotV2` never crashes a model whose owner only wants the diff/timeline
|
|
376
|
+
# half.
|
|
377
|
+
#
|
|
378
|
+
# Only file-bearing associations are processed — see `file_bearing_association?`. This avoids
|
|
379
|
+
# the over-eager extraction the original code shipped with (it grabbed `id` from *every*
|
|
380
|
+
# association's payload, rescued only by a downstream existence filter that masks the mistake
|
|
381
|
+
# until a non-file record's PK happens to collide with a real blob PK, at which point a
|
|
382
|
+
# spurious reference silently "protects" an unrelated blob from purge).
|
|
383
|
+
def record_blob_references(snapshot, calculated_diff)
|
|
384
|
+
return unless SnapshotBlobReference.table_exists?
|
|
385
|
+
|
|
386
|
+
associations = calculated_diff['associations']
|
|
387
|
+
return if associations.blank?
|
|
388
|
+
|
|
389
|
+
blob_ids = Set.new
|
|
390
|
+
associations.each do |assoc_name, changes|
|
|
391
|
+
next if changes.blank?
|
|
392
|
+
next unless file_bearing_association?(assoc_name.to_s)
|
|
393
|
+
|
|
394
|
+
changes.each do |tuple|
|
|
395
|
+
# tuple format: [op, index, payload] or [op, index, from, to]
|
|
396
|
+
extract_blob_ids_from_payload(tuple[2], blob_ids)
|
|
397
|
+
to_payload = tuple[3]
|
|
398
|
+
extract_blob_ids_from_payload(to_payload, blob_ids) if to_payload
|
|
399
|
+
end
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
return if blob_ids.empty?
|
|
403
|
+
|
|
404
|
+
# Belt-and-suspenders: file_bearing_association? is the primary defense (non-file
|
|
405
|
+
# associations never reach here), but a blob could still be purged between diff-compute
|
|
406
|
+
# and reference-write inside the with_lock block. Filter to existing blobs to avoid an
|
|
407
|
+
# FK violation on snapshot_blob_references.blob_id. :: prefix — see
|
|
408
|
+
# file_bearing_association? for the Glib::ActiveStorage shadowing rationale.
|
|
409
|
+
existing_blob_ids = ::ActiveStorage::Blob.where(id: blob_ids.to_a).pluck(:id)
|
|
410
|
+
existing_blob_ids.each do |blob_id|
|
|
411
|
+
SnapshotBlobReference.create!(snapshot: snapshot, blob_id: blob_id)
|
|
412
|
+
rescue ActiveRecord::RecordNotUnique
|
|
413
|
+
# Already referenced (e.g. same blob_id in both from and to of an update) — safe to skip.
|
|
414
|
+
end
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
# An association is "file-bearing" if its diff payloads carry ActiveStorage blob/attachment
|
|
418
|
+
# row data. Two shapes:
|
|
419
|
+
# 1. Direct ActiveStorage macros: has_one_attached / has_many_attached
|
|
420
|
+
# (e.g. OrganizationDocument#file, V2Post#photos). NOTE: these create NO AR reflection
|
|
421
|
+
# under their own name — they're detected by their value type (Attached::One/Many),
|
|
422
|
+
# the same value-based detection check_snapshot_changed and diff_associations use
|
|
423
|
+
# (active_storage_association?).
|
|
424
|
+
# 2. Through associations whose terminal klass is ActiveStorage::Blob or ::Attachment
|
|
425
|
+
# (e.g. Submission's snapshot_document_blobs: has_many ..., through: ..., source: :blob).
|
|
426
|
+
# These have real reflections with a blob/attachment klass.
|
|
427
|
+
# Non-file associations (has_many :comments, has_one :profile, etc.) are skipped so their
|
|
428
|
+
# record PKs never enter the blob-id extraction path.
|
|
429
|
+
#
|
|
430
|
+
# The :: prefix on ActiveStorage constants is REQUIRED: inside `module Glib`, a bare
|
|
431
|
+
# `ActiveStorage` resolves lexically to Glib::ActiveStorage (the gem's dynamic-text mirror
|
|
432
|
+
# subclass of ::ActiveStorage::Blob), and class equality against it would always be false.
|
|
433
|
+
def file_bearing_association?(assoc_name)
|
|
434
|
+
reflection = self.class.reflect_on_association(assoc_name)
|
|
435
|
+
association_value = public_send(assoc_name)
|
|
436
|
+
|
|
437
|
+
return true if active_storage_association?(reflection, association_value)
|
|
438
|
+
|
|
439
|
+
klass = reflection&.klass
|
|
440
|
+
klass && [::ActiveStorage::Blob, ::ActiveStorage::Attachment].include?(klass)
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
def extract_blob_ids_from_payload(payload, blob_ids)
|
|
444
|
+
return unless payload.is_a?(Hash)
|
|
445
|
+
|
|
446
|
+
# Old snapshots diff ActiveStorage::Attachment rows (blob_id key);
|
|
447
|
+
# new snapshots diff ActiveStorage::Blob rows directly (id key = Blob PK).
|
|
448
|
+
blob_id = payload['blob_id'] || payload[:blob_id] || payload['id'] || payload[:id]
|
|
449
|
+
blob_ids.add(blob_id) if blob_id.present?
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
# --- ActiveStorage helpers (ported from V1 gem) ---
|
|
453
|
+
|
|
454
|
+
def active_storage_association?(association, association_value)
|
|
455
|
+
macro = association&.macro
|
|
456
|
+
return true if [:has_many_attached, :has_one_attached].include?(macro)
|
|
457
|
+
return false if association_value.nil?
|
|
458
|
+
|
|
459
|
+
# :: prefix — see file_bearing_association? for the Glib::ActiveStorage shadowing
|
|
460
|
+
# rationale. Without it, `defined?(Glib::ActiveStorage::Attached)` is nil and the
|
|
461
|
+
# is_a? checks below silently never run (the respond_to? fallback masked this).
|
|
462
|
+
if defined?(::ActiveStorage::Attached)
|
|
463
|
+
return true if association_value.is_a?(::ActiveStorage::Attached::Many) || association_value.is_a?(::ActiveStorage::Attached::One)
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
association_value.respond_to?(:attachments) || association_value.respond_to?(:attachment)
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
def active_storage_records(value)
|
|
470
|
+
if value.respond_to?(:attachments)
|
|
471
|
+
value.attachments
|
|
472
|
+
elsif value.respond_to?(:attachment)
|
|
473
|
+
Array(value.attachment).compact
|
|
474
|
+
else
|
|
475
|
+
[]
|
|
476
|
+
end
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
def active_storage_record?(record)
|
|
480
|
+
return false if record.nil?
|
|
481
|
+
|
|
482
|
+
record.class.name.start_with?('ActiveStorage::')
|
|
483
|
+
end
|
|
484
|
+
end
|
|
485
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
module Glib
|
|
2
|
+
# Base class for table-less FORM-BACKED domain objects — the ActiveModel
|
|
3
|
+
# counterpart of Glib::ApplicationRecord. Apps inherit through a thin
|
|
4
|
+
# `ApplicationModel < Glib::Model` root (parallel to ApplicationRecord), so
|
|
5
|
+
# app code always reads `< ApplicationModel`.
|
|
6
|
+
#
|
|
7
|
+
# Choosing a base for a class under app/models (the three-way rule; see
|
|
8
|
+
# dev-doc best_practices/backend/en/03_model.md item 9):
|
|
9
|
+
#
|
|
10
|
+
# ApplicationRecord — the class is persisted (it has a table).
|
|
11
|
+
# ApplicationModel — table-less, but it BACKS A FORM/REQUEST: it is built
|
|
12
|
+
# from params (`assign_attributes(create_params)`),
|
|
13
|
+
# validates one user transaction, reports errors back
|
|
14
|
+
# to the dialog, and pairs by name with a controller
|
|
15
|
+
# and policy. (This class.)
|
|
16
|
+
# PlainModel — neither: plain domain logic with no framework
|
|
17
|
+
# machinery — catalogs, derivations, tallies,
|
|
18
|
+
# write-orchestration helpers (see Glib::PlainModel).
|
|
19
|
+
#
|
|
20
|
+
# If you are reaching for a bare `include ActiveModel::Model`, you want
|
|
21
|
+
# ApplicationModel instead — the shared mechanics live here.
|
|
22
|
+
class Model
|
|
23
|
+
include ActiveModel::Model
|
|
24
|
+
|
|
25
|
+
# Declares multi-select id-list attributes: a reader plus a normalizing
|
|
26
|
+
# writer that drops the blank entries browsers submit for empty
|
|
27
|
+
# multi-selects and coerces the surviving ids to integers.
|
|
28
|
+
#
|
|
29
|
+
# attr_id_list :member_ids, :assignee_group_ids
|
|
30
|
+
def self.attr_id_list(*attr_names)
|
|
31
|
+
attr_names.each do |attr_name|
|
|
32
|
+
attr_reader attr_name
|
|
33
|
+
|
|
34
|
+
define_method("#{attr_name}=") do |ids|
|
|
35
|
+
instance_variable_set("@#{attr_name}", Array(ids).map(&:presence).compact.map(&:to_i))
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
module Glib
|
|
2
|
+
# Marker base for PLAIN domain objects: domain logic under app/models that
|
|
3
|
+
# neither persists (no table — not ApplicationRecord) nor backs a form or
|
|
4
|
+
# request (no params/validations/errors — not ApplicationModel). Catalogs,
|
|
5
|
+
# derivations, tallies, write-orchestration helpers. Apps inherit through a
|
|
6
|
+
# thin `PlainModel < Glib::PlainModel` root; the base-choosing guide lives on
|
|
7
|
+
# Glib::Model.
|
|
8
|
+
#
|
|
9
|
+
# Deliberately empty: what plain domain objects share is a CONTRACT, not
|
|
10
|
+
# mechanics —
|
|
11
|
+
# - domain logic only: no external I/O (HTTP clients and provider SDKs
|
|
12
|
+
# belong in app/services adapters);
|
|
13
|
+
# - no enqueues (`perform_later`/`deliver_later` are the calling
|
|
14
|
+
# controller's or job's trigger);
|
|
15
|
+
# - writes, when any, go through the ActiveRecord models whose invariants
|
|
16
|
+
# they are.
|
|
17
|
+
# Inheriting this class is the author's declaration that the contract holds.
|
|
18
|
+
# The moment a subclass wants shared BEHAVIOR from here, that behavior
|
|
19
|
+
# belongs in Glib::Model or a concern instead — this base stays a
|
|
20
|
+
# classification, so that inheriting it keeps meaning exactly one thing.
|
|
21
|
+
class PlainModel
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Top-level (NOT namespaced under Glib::) by design — see issue #960's hard constraint:
|
|
2
|
+
#
|
|
3
|
+
# The gem's SnapshotBlobReference must stay a top-level model on table
|
|
4
|
+
# `snapshot_blob_references` — not namespaced. Drop-in compatibility holds only
|
|
5
|
+
# because `isolate_namespace` is off in lib/glib/engine.rb. Namespacing it (e.g.
|
|
6
|
+
# Glib::SnapshotBlobReference → table `glib_snapshot_blob_references`) or enabling
|
|
7
|
+
# isolate_namespace orphans every existing consumer's table + rows and forces a
|
|
8
|
+
# rename/data migration.
|
|
9
|
+
#
|
|
10
|
+
# This file lives at `app/models/snapshot_blob_reference.rb` (not under `app/models/glib/`)
|
|
11
|
+
# so the Rails autoloader resolves the constant as top-level `SnapshotBlobReference`, which
|
|
12
|
+
# in turn maps to the un-prefixed table name `snapshot_blob_references`. The host app's
|
|
13
|
+
# existing `SnapshotBlobReference` model can be deleted verbatim when it bumps the gem.
|
|
14
|
+
#
|
|
15
|
+
# Inherits from the host's `ApplicationRecord` (the standard since Rails 5.0) so the model
|
|
16
|
+
# shares the primary DB connection — same pattern as every other top-level host model.
|
|
17
|
+
class SnapshotBlobReference < ApplicationRecord
|
|
18
|
+
belongs_to :snapshot, class_name: 'ActiveSnapshot::Snapshot'
|
|
19
|
+
belongs_to :blob, class_name: 'ActiveStorage::Blob'
|
|
20
|
+
end
|
|
@@ -89,6 +89,27 @@ page.body(
|
|
|
89
89
|
end
|
|
90
90
|
)
|
|
91
91
|
res.spacer height: 4
|
|
92
|
+
res.button(
|
|
93
|
+
text: 'Dialog alert onClose',
|
|
94
|
+
onClick: ->(action) do
|
|
95
|
+
action.dialogs_alert(
|
|
96
|
+
message: 'Close me',
|
|
97
|
+
onClose: ->(subaction) do
|
|
98
|
+
subaction.dialogs_show(
|
|
99
|
+
content: ->(dialog) do
|
|
100
|
+
dialog.body(
|
|
101
|
+
padding: glib_json_padding_body,
|
|
102
|
+
childViews: ->(sbody) do
|
|
103
|
+
sbody.h1 text: 'onClose fired'
|
|
104
|
+
end
|
|
105
|
+
)
|
|
106
|
+
end
|
|
107
|
+
)
|
|
108
|
+
end
|
|
109
|
+
)
|
|
110
|
+
end
|
|
111
|
+
)
|
|
112
|
+
res.spacer height: 4
|
|
92
113
|
res.button(
|
|
93
114
|
text: 'Dialog notification',
|
|
94
115
|
onClick: ->(action) do
|