active_version 1.0.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 +36 -0
- data/LICENSE.md +21 -0
- data/README.md +492 -0
- data/SECURITY.md +29 -0
- data/lib/active_version/adapters/active_record/audits.rb +36 -0
- data/lib/active_version/adapters/active_record/base.rb +37 -0
- data/lib/active_version/adapters/active_record/revisions.rb +49 -0
- data/lib/active_version/adapters/active_record/translations.rb +45 -0
- data/lib/active_version/adapters/active_record.rb +10 -0
- data/lib/active_version/adapters/sequel/versioning.rb +282 -0
- data/lib/active_version/adapters/sequel.rb +9 -0
- data/lib/active_version/adapters.rb +5 -0
- data/lib/active_version/audits/audit_record/callbacks.rb +180 -0
- data/lib/active_version/audits/audit_record/serializers.rb +49 -0
- data/lib/active_version/audits/audit_record.rb +522 -0
- data/lib/active_version/audits/has_audits/audit_callbacks.rb +46 -0
- data/lib/active_version/audits/has_audits/audit_combiner.rb +212 -0
- data/lib/active_version/audits/has_audits/audit_writer.rb +282 -0
- data/lib/active_version/audits/has_audits/change_filters.rb +114 -0
- data/lib/active_version/audits/has_audits/database_adapter_helper.rb +86 -0
- data/lib/active_version/audits/has_audits.rb +891 -0
- data/lib/active_version/audits/sql_builder.rb +263 -0
- data/lib/active_version/audits.rb +10 -0
- data/lib/active_version/column_mapper.rb +92 -0
- data/lib/active_version/configuration.rb +124 -0
- data/lib/active_version/database/triggers/postgresql.rb +243 -0
- data/lib/active_version/database.rb +7 -0
- data/lib/active_version/instrumentation.rb +226 -0
- data/lib/active_version/migrators/audited.rb +84 -0
- data/lib/active_version/migrators/base.rb +191 -0
- data/lib/active_version/migrators.rb +8 -0
- data/lib/active_version/query.rb +105 -0
- data/lib/active_version/railtie.rb +17 -0
- data/lib/active_version/revisions/has_revisions/revision_manipulation.rb +499 -0
- data/lib/active_version/revisions/has_revisions/revision_queries.rb +182 -0
- data/lib/active_version/revisions/has_revisions.rb +443 -0
- data/lib/active_version/revisions/revision_record.rb +287 -0
- data/lib/active_version/revisions/sql_builder.rb +266 -0
- data/lib/active_version/revisions.rb +10 -0
- data/lib/active_version/runtime.rb +148 -0
- data/lib/active_version/sharding/connection_router.rb +20 -0
- data/lib/active_version/sharding.rb +7 -0
- data/lib/active_version/tasks/active_version.rake +29 -0
- data/lib/active_version/translations/has_translations.rb +350 -0
- data/lib/active_version/translations/translation_record.rb +258 -0
- data/lib/active_version/translations.rb +9 -0
- data/lib/active_version/version.rb +3 -0
- data/lib/active_version/version_registry.rb +87 -0
- data/lib/active_version.rb +329 -0
- data/lib/generators/active_version/audits/audits_generator.rb +65 -0
- data/lib/generators/active_version/audits/templates/audit_model.rb.erb +16 -0
- data/lib/generators/active_version/audits/templates/migration_jsonb.rb.erb +33 -0
- data/lib/generators/active_version/audits/templates/migration_table.rb.erb +34 -0
- data/lib/generators/active_version/install/install_generator.rb +19 -0
- data/lib/generators/active_version/install/templates/initializer.rb.erb +38 -0
- data/lib/generators/active_version/revisions/revisions_generator.rb +71 -0
- data/lib/generators/active_version/revisions/templates/backfill_migration.rb.erb +19 -0
- data/lib/generators/active_version/revisions/templates/migration.rb.erb +20 -0
- data/lib/generators/active_version/revisions/templates/revision_model.rb.erb +8 -0
- data/lib/generators/active_version/translations/templates/migration.rb.erb +16 -0
- data/lib/generators/active_version/translations/templates/translation_model.rb.erb +15 -0
- data/lib/generators/active_version/translations/translations_generator.rb +73 -0
- data/lib/generators/active_version/triggers/templates/migration.rb.erb +100 -0
- data/lib/generators/active_version/triggers/triggers_generator.rb +74 -0
- data/sig/active_version/advanced.rbs +51 -0
- data/sig/active_version/audits.rbs +128 -0
- data/sig/active_version/configuration.rbs +38 -0
- data/sig/active_version/core.rbs +53 -0
- data/sig/active_version/instrumentation.rbs +17 -0
- data/sig/active_version/registry_and_mapping.rbs +18 -0
- data/sig/active_version/revisions.rbs +70 -0
- data/sig/active_version/runtime.rbs +29 -0
- data/sig/active_version/translations.rbs +43 -0
- data/sig/active_version.rbs +3 -0
- metadata +443 -0
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
require "active_version/revisions/has_revisions/revision_queries"
|
|
2
|
+
require "active_version/revisions/has_revisions/revision_manipulation"
|
|
3
|
+
|
|
4
|
+
module ActiveVersion
|
|
5
|
+
module Revisions
|
|
6
|
+
# Concern for models that have revisions
|
|
7
|
+
module HasRevisions
|
|
8
|
+
extend ActiveSupport::Concern
|
|
9
|
+
include RevisionQueries
|
|
10
|
+
include RevisionManipulation
|
|
11
|
+
|
|
12
|
+
included do
|
|
13
|
+
# Class methods
|
|
14
|
+
def self.revision_record?
|
|
15
|
+
false
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Get revision class name
|
|
19
|
+
def self.revision_class
|
|
20
|
+
# Check if a custom revision class was specified
|
|
21
|
+
@revision_class ||= if @custom_revision_class
|
|
22
|
+
apply_revision_table_name!(@custom_revision_class)
|
|
23
|
+
else
|
|
24
|
+
class_name = "#{name}Revision"
|
|
25
|
+
klass = class_name.safe_constantize || begin
|
|
26
|
+
table_based_name = "#{table_name.to_s.classify}Revision"
|
|
27
|
+
table_based_name.safe_constantize || raise(NameError, "Could not find revision class #{class_name}")
|
|
28
|
+
end
|
|
29
|
+
apply_revision_table_name!(klass)
|
|
30
|
+
klass
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Get revision class name as string
|
|
35
|
+
def self.revision_class_name
|
|
36
|
+
revision_class.name.to_s
|
|
37
|
+
rescue NameError
|
|
38
|
+
"#{name}Revision"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Class attributes for revision options
|
|
42
|
+
class_attribute :revision_options, instance_writer: false
|
|
43
|
+
|
|
44
|
+
# Set up associations (deferred to avoid constantize errors during module inclusion)
|
|
45
|
+
def self.setup_revision_associations
|
|
46
|
+
return if @revision_associations_setup
|
|
47
|
+
|
|
48
|
+
begin
|
|
49
|
+
inverse = nil
|
|
50
|
+
begin
|
|
51
|
+
inverse_name = name.underscore.to_sym
|
|
52
|
+
revision_klass = revision_class
|
|
53
|
+
register_revision_column_mappings_from_destination(revision_klass)
|
|
54
|
+
revision_klass.setup_associations(force: true) if revision_klass.respond_to?(:setup_associations)
|
|
55
|
+
if revision_klass.respond_to?(:source_name) && revision_klass.source_name == inverse_name
|
|
56
|
+
inverse = inverse_name
|
|
57
|
+
end
|
|
58
|
+
rescue NameError
|
|
59
|
+
inverse = nil
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
assoc_options = {
|
|
63
|
+
class_name: revision_class_name,
|
|
64
|
+
dependent: :delete_all,
|
|
65
|
+
inverse_of: inverse || false
|
|
66
|
+
}
|
|
67
|
+
assoc_options[:foreign_key] = revision_klass.source_foreign_key if revision_klass&.respond_to?(:source_foreign_key)
|
|
68
|
+
resolver = revision_klass&.source_primary_key
|
|
69
|
+
if resolver.is_a?(Array)
|
|
70
|
+
assoc_options[:primary_key] = resolver.map(&:to_s)
|
|
71
|
+
elsif resolver.is_a?(String) && resolver.present?
|
|
72
|
+
assoc_options[:primary_key] = resolver
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
has_many :revisions, **assoc_options
|
|
76
|
+
@revision_associations_setup = true
|
|
77
|
+
rescue NameError
|
|
78
|
+
# Revision class not yet defined, will be set up later
|
|
79
|
+
@revision_associations_setup = false
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Call setup after class is fully loaded
|
|
84
|
+
setup_revision_associations if name
|
|
85
|
+
|
|
86
|
+
# Rollback handling
|
|
87
|
+
after_rollback :clear_rolled_back_revisions
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
module ClassMethods
|
|
91
|
+
# Declare that a model has revisions
|
|
92
|
+
def has_revisions(options = {})
|
|
93
|
+
# Store custom revision class if specified
|
|
94
|
+
if options[:as]
|
|
95
|
+
@custom_revision_class = options[:as]
|
|
96
|
+
options = options.except(:as)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Normalize and store options
|
|
100
|
+
self.revision_options = normalize_revision_options(options)
|
|
101
|
+
|
|
102
|
+
# Register options before resolving revision FK so custom foreign_key
|
|
103
|
+
# is visible while associations are being built.
|
|
104
|
+
ActiveVersion.registry.register(self, :revisions, revision_options)
|
|
105
|
+
|
|
106
|
+
# Ensure association is set up after custom class option is known.
|
|
107
|
+
@revision_associations_setup = false
|
|
108
|
+
setup_revision_associations if respond_to?(:setup_revision_associations)
|
|
109
|
+
|
|
110
|
+
# Initialize enabled state (default to true)
|
|
111
|
+
@class_revision_enabled = true
|
|
112
|
+
|
|
113
|
+
# Set up callbacks based on options
|
|
114
|
+
setup_revision_callbacks(revision_options)
|
|
115
|
+
|
|
116
|
+
# Register with version registry (idempotent)
|
|
117
|
+
ActiveVersion.registry.register(self, :revisions, revision_options)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Check if model has revisions configured
|
|
121
|
+
def has_revisions?
|
|
122
|
+
revision_options.present?
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Normalize revision options
|
|
126
|
+
def normalize_revision_options(options)
|
|
127
|
+
{
|
|
128
|
+
on: Array.wrap(options[:on] || [:update]),
|
|
129
|
+
if: options[:if],
|
|
130
|
+
unless: options[:unless],
|
|
131
|
+
auto: options.fetch(:auto, true),
|
|
132
|
+
only: Array.wrap(options[:only] || []).map(&:to_s),
|
|
133
|
+
except: Array.wrap(options[:except] || []).map(&:to_s),
|
|
134
|
+
foreign_key: normalize_identity_columns(options[:foreign_key]),
|
|
135
|
+
identity_resolver: options[:identity_resolver],
|
|
136
|
+
table_name: options[:table_name]
|
|
137
|
+
}
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def normalize_identity_columns(value)
|
|
141
|
+
return nil if value.nil?
|
|
142
|
+
return value.map(&:to_s) if value.is_a?(Array)
|
|
143
|
+
|
|
144
|
+
value.to_s
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def apply_revision_table_name!(klass)
|
|
148
|
+
options = ActiveVersion.registry.config_for(self, :revisions) || {}
|
|
149
|
+
custom_table_name = options[:table_name]
|
|
150
|
+
return klass unless custom_table_name && klass.respond_to?(:table_name=)
|
|
151
|
+
|
|
152
|
+
klass.table_name = custom_table_name.to_s
|
|
153
|
+
klass
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def register_revision_column_mappings_from_destination(revision_klass)
|
|
157
|
+
return unless revision_klass.respond_to?(:revision_column_for)
|
|
158
|
+
version_column = revision_klass.revision_column_for(:version)
|
|
159
|
+
return unless version_column
|
|
160
|
+
ActiveVersion.column_mapper.register(self, :revisions, :version, version_column)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Set up callbacks based on options
|
|
164
|
+
def setup_revision_callbacks(options)
|
|
165
|
+
# Remove existing callbacks first if they exist
|
|
166
|
+
if respond_to?(:_update_callbacks)
|
|
167
|
+
callbacks = _update_callbacks.select { |cb| cb.filter == :create_revision_before_update }
|
|
168
|
+
callbacks.each { |cb| skip_callback(:update, :before, :create_revision_before_update) }
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# If auto is false or on is empty, don't install callbacks automatically
|
|
172
|
+
return if options[:auto] == false || options[:on] == []
|
|
173
|
+
|
|
174
|
+
# Install callbacks for specified events
|
|
175
|
+
if options[:on].include?(:update)
|
|
176
|
+
before_update :create_revision_before_update, if: :should_create_revision?
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Manual callback installation methods
|
|
181
|
+
def revision_on_update
|
|
182
|
+
before_update :create_revision_before_update, if: :should_create_revision?
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Create snapshots for all records
|
|
186
|
+
def create_snapshots(opts = {})
|
|
187
|
+
scope = opts[:only_missing] ? where.missing(:revisions) : all
|
|
188
|
+
|
|
189
|
+
scope.find_each do |record|
|
|
190
|
+
record.create_snapshot!(opts)
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Disable revisions for a block
|
|
195
|
+
def without_revisions
|
|
196
|
+
callbacks = if respond_to?(:_update_callbacks)
|
|
197
|
+
_update_callbacks.select { |cb| cb.filter == :create_revision_before_update }
|
|
198
|
+
else
|
|
199
|
+
[]
|
|
200
|
+
end
|
|
201
|
+
callback_installed = callbacks.any?
|
|
202
|
+
|
|
203
|
+
skip_callback(:update, :before, :create_revision_before_update) if callback_installed
|
|
204
|
+
yield
|
|
205
|
+
ensure
|
|
206
|
+
# Restore callback if it was set up
|
|
207
|
+
if callback_installed && revision_options && revision_options[:auto] != false && revision_options[:on].include?(:update)
|
|
208
|
+
set_callback(:update, :before, :create_revision_before_update, if: :should_create_revision?)
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Enable revisions for a block
|
|
213
|
+
def with_revisions
|
|
214
|
+
revision_was_enabled = class_revision_enabled?
|
|
215
|
+
enable_revisions
|
|
216
|
+
yield
|
|
217
|
+
ensure
|
|
218
|
+
disable_revisions unless revision_was_enabled
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def class_revision_enabled?
|
|
222
|
+
@class_revision_enabled != false
|
|
223
|
+
end
|
|
224
|
+
public :class_revision_enabled?
|
|
225
|
+
|
|
226
|
+
private
|
|
227
|
+
|
|
228
|
+
def disable_revisions
|
|
229
|
+
@class_revision_enabled = false
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def enable_revisions
|
|
233
|
+
@class_revision_enabled = true
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# Generate SQL for a single revision insert/upsert.
|
|
238
|
+
# Useful for delayed write pipelines where revision rows are inserted later.
|
|
239
|
+
def revision_sql(version: nil, upsert: false, use_old_values: false, only: nil, except: nil, timestamp: Time.current)
|
|
240
|
+
return "" unless persisted?
|
|
241
|
+
|
|
242
|
+
version_column = revision_version_column
|
|
243
|
+
revision_class = self.class.revision_class
|
|
244
|
+
|
|
245
|
+
base_attrs = snapshot_base_attributes(use_old_values)
|
|
246
|
+
snapshot_attrs = if only
|
|
247
|
+
base_attrs.slice(*Array.wrap(only).map(&:to_s))
|
|
248
|
+
elsif except
|
|
249
|
+
base_attrs.except(*Array.wrap(except).map(&:to_s))
|
|
250
|
+
else
|
|
251
|
+
base_attrs
|
|
252
|
+
end
|
|
253
|
+
snapshot_attrs.delete_if { |k, _v| deleted_column?(k) }
|
|
254
|
+
|
|
255
|
+
next_version = version || (current_version + 1)
|
|
256
|
+
revision_attrs = snapshot_attrs.merge(
|
|
257
|
+
active_version_revision_identity_map.transform_keys(&:to_s)
|
|
258
|
+
).merge(
|
|
259
|
+
version_column.to_s => next_version,
|
|
260
|
+
"created_at" => timestamp,
|
|
261
|
+
"updated_at" => timestamp
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
connection = revision_class.connection
|
|
265
|
+
table_name = connection.quote_table_name(revision_class.table_name)
|
|
266
|
+
columns = revision_attrs.keys
|
|
267
|
+
column_list = columns.map { |col| connection.quote_column_name(col) }.join(", ")
|
|
268
|
+
values_list = columns.map { |col| connection.quote(revision_sql_value(revision_attrs[col])) }.join(", ")
|
|
269
|
+
|
|
270
|
+
sql = "INSERT INTO #{table_name} (#{column_list}) VALUES (#{values_list})"
|
|
271
|
+
return sql unless upsert
|
|
272
|
+
|
|
273
|
+
conflict_cols = revision_identity_columns.map(&:to_s) + [version_column.to_s]
|
|
274
|
+
updatable_columns = columns - conflict_cols - ["id", "created_at"]
|
|
275
|
+
if updatable_columns.empty?
|
|
276
|
+
"#{sql} ON CONFLICT (#{conflict_cols.map { |col| connection.quote_column_name(col) }.join(", ")}) DO NOTHING"
|
|
277
|
+
else
|
|
278
|
+
assignments = updatable_columns.map do |col|
|
|
279
|
+
qcol = connection.quote_column_name(col)
|
|
280
|
+
"#{qcol} = EXCLUDED.#{qcol}"
|
|
281
|
+
end.join(", ")
|
|
282
|
+
"#{sql} ON CONFLICT (#{conflict_cols.map { |col| connection.quote_column_name(col) }.join(", ")}) DO UPDATE SET #{assignments}"
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
private
|
|
287
|
+
|
|
288
|
+
public
|
|
289
|
+
|
|
290
|
+
def active_version_revision_identity_map
|
|
291
|
+
columns = revision_identity_columns
|
|
292
|
+
values = active_version_revision_identity_values
|
|
293
|
+
|
|
294
|
+
case values
|
|
295
|
+
when Hash
|
|
296
|
+
values.transform_keys(&:to_s).slice(*columns)
|
|
297
|
+
when Array
|
|
298
|
+
columns.zip(values).to_h
|
|
299
|
+
else
|
|
300
|
+
{columns.first => values}
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def active_version_revision_identity_values
|
|
305
|
+
resolver = self.class.revision_options && self.class.revision_options[:identity_resolver]
|
|
306
|
+
return default_revision_identity_values if resolver.nil?
|
|
307
|
+
|
|
308
|
+
case resolver
|
|
309
|
+
when Proc
|
|
310
|
+
resolver.arity.zero? ? instance_exec(&resolver) : resolver.call(self)
|
|
311
|
+
when Array
|
|
312
|
+
resolver.map { |column| public_send(column) }
|
|
313
|
+
else
|
|
314
|
+
public_send(resolver)
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
def revision_identity_columns
|
|
319
|
+
Array(self.class.revision_class.source_foreign_key).map(&:to_s)
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def default_revision_identity_values
|
|
323
|
+
columns = revision_identity_columns
|
|
324
|
+
return id if columns.one?
|
|
325
|
+
|
|
326
|
+
source_primary_key_columns.map { |column| self[column] }
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def revision_sql_value(value)
|
|
330
|
+
case value
|
|
331
|
+
when Hash, Array
|
|
332
|
+
value.to_json
|
|
333
|
+
when Time, DateTime
|
|
334
|
+
value.utc
|
|
335
|
+
when Date
|
|
336
|
+
value.to_time.utc
|
|
337
|
+
else
|
|
338
|
+
value
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
# Get the effective version column for revisions.
|
|
343
|
+
# Respects custom column mappings but falls back to the default
|
|
344
|
+
# revision version column when the mapped column does not exist
|
|
345
|
+
# in the revision table. This mirrors the behavior used for
|
|
346
|
+
# translations and makes custom mappings opt‑in at the schema level.
|
|
347
|
+
def revision_version_column
|
|
348
|
+
column = ActiveVersion.column_mapper.column_for(self.class, :revisions, :version)
|
|
349
|
+
|
|
350
|
+
revision_class = self.class.revision_class
|
|
351
|
+
unless revision_class.column_names.include?(column.to_s)
|
|
352
|
+
column = ActiveVersion.config.revision_version_column
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
column
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
def should_create_revision?
|
|
359
|
+
# Check basic conditions
|
|
360
|
+
# In before_update callbacks, we're already in an update, so assume there are changes
|
|
361
|
+
# The changes hash might be empty at this point, but we're updating so there must be changes
|
|
362
|
+
unless persisted?
|
|
363
|
+
return false
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
# Check class-level enabled state (default to true if not set)
|
|
367
|
+
class_enabled = if self.class.instance_variable_defined?(:@class_revision_enabled)
|
|
368
|
+
self.class.instance_variable_get(:@class_revision_enabled)
|
|
369
|
+
else
|
|
370
|
+
true # Default to enabled
|
|
371
|
+
end
|
|
372
|
+
return false unless class_enabled != false
|
|
373
|
+
|
|
374
|
+
# Check global enabled state
|
|
375
|
+
# Note: We use config.auditing_enabled for both audits and revisions
|
|
376
|
+
return false unless ActiveVersion.config.auditing_enabled
|
|
377
|
+
|
|
378
|
+
# Get revision options (with defaults if not set)
|
|
379
|
+
options = self.class.revision_options || {auto: true, on: [:update]}
|
|
380
|
+
|
|
381
|
+
# Check if/unless conditions
|
|
382
|
+
return false unless run_conditional_check(options[:if])
|
|
383
|
+
return false unless run_conditional_check(options[:unless], matching: false)
|
|
384
|
+
|
|
385
|
+
true
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
def run_conditional_check(condition, matching: true)
|
|
389
|
+
return true if condition.blank?
|
|
390
|
+
return condition.call(self) == matching if condition.respond_to?(:call)
|
|
391
|
+
return send(condition) == matching if respond_to?(condition.to_sym, true)
|
|
392
|
+
true
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
def create_revision_before_update
|
|
396
|
+
pointer = instance_variable_get(:@active_version_pointer)
|
|
397
|
+
if pointer
|
|
398
|
+
version_column = revision_version_column
|
|
399
|
+
# Classic linear undo/redo behavior: editing after undo drops forward history.
|
|
400
|
+
revisions_scope.where("#{version_column} > ?", pointer).delete_all
|
|
401
|
+
revisions.reset
|
|
402
|
+
remove_instance_variable(:@active_version_pointer)
|
|
403
|
+
end
|
|
404
|
+
# Check if we should create revision
|
|
405
|
+
return unless should_create_revision?
|
|
406
|
+
return if latest_revision_matches_current_state?
|
|
407
|
+
|
|
408
|
+
result = create_snapshot!(use_old_values: true)
|
|
409
|
+
|
|
410
|
+
# Ensure revision is persisted and visible in association
|
|
411
|
+
unless result.persisted?
|
|
412
|
+
error_msg = "Failed to create revision: #{result.errors.full_messages.join(", ")}" if result.errors.any?
|
|
413
|
+
error_msg ||= "Revision was not persisted after save!"
|
|
414
|
+
error_msg += "\nRevision: #{result.inspect}"
|
|
415
|
+
error_msg += "\nRevision valid?: #{result.valid?}"
|
|
416
|
+
raise error_msg
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
# Clear association cache to ensure revision is visible
|
|
420
|
+
revisions.reset
|
|
421
|
+
result
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
def clear_rolled_back_revisions
|
|
425
|
+
revisions.reset
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
def latest_revision_matches_current_state?
|
|
429
|
+
version_column = revision_version_column
|
|
430
|
+
latest = revisions_scope.order(version_column => :desc).first
|
|
431
|
+
return false unless latest
|
|
432
|
+
|
|
433
|
+
base_attrs = snapshot_base_attributes(true)
|
|
434
|
+
revision_payload_columns.all? do |column|
|
|
435
|
+
next true if deleted_column?(column)
|
|
436
|
+
next true unless base_attrs.key?(column.to_s)
|
|
437
|
+
|
|
438
|
+
latest.read_attribute(column.to_s) == base_attrs[column.to_s]
|
|
439
|
+
end
|
|
440
|
+
end
|
|
441
|
+
end
|
|
442
|
+
end
|
|
443
|
+
end
|