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.
Files changed (76) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +36 -0
  3. data/LICENSE.md +21 -0
  4. data/README.md +492 -0
  5. data/SECURITY.md +29 -0
  6. data/lib/active_version/adapters/active_record/audits.rb +36 -0
  7. data/lib/active_version/adapters/active_record/base.rb +37 -0
  8. data/lib/active_version/adapters/active_record/revisions.rb +49 -0
  9. data/lib/active_version/adapters/active_record/translations.rb +45 -0
  10. data/lib/active_version/adapters/active_record.rb +10 -0
  11. data/lib/active_version/adapters/sequel/versioning.rb +282 -0
  12. data/lib/active_version/adapters/sequel.rb +9 -0
  13. data/lib/active_version/adapters.rb +5 -0
  14. data/lib/active_version/audits/audit_record/callbacks.rb +180 -0
  15. data/lib/active_version/audits/audit_record/serializers.rb +49 -0
  16. data/lib/active_version/audits/audit_record.rb +522 -0
  17. data/lib/active_version/audits/has_audits/audit_callbacks.rb +46 -0
  18. data/lib/active_version/audits/has_audits/audit_combiner.rb +212 -0
  19. data/lib/active_version/audits/has_audits/audit_writer.rb +282 -0
  20. data/lib/active_version/audits/has_audits/change_filters.rb +114 -0
  21. data/lib/active_version/audits/has_audits/database_adapter_helper.rb +86 -0
  22. data/lib/active_version/audits/has_audits.rb +891 -0
  23. data/lib/active_version/audits/sql_builder.rb +263 -0
  24. data/lib/active_version/audits.rb +10 -0
  25. data/lib/active_version/column_mapper.rb +92 -0
  26. data/lib/active_version/configuration.rb +124 -0
  27. data/lib/active_version/database/triggers/postgresql.rb +243 -0
  28. data/lib/active_version/database.rb +7 -0
  29. data/lib/active_version/instrumentation.rb +226 -0
  30. data/lib/active_version/migrators/audited.rb +84 -0
  31. data/lib/active_version/migrators/base.rb +191 -0
  32. data/lib/active_version/migrators.rb +8 -0
  33. data/lib/active_version/query.rb +105 -0
  34. data/lib/active_version/railtie.rb +17 -0
  35. data/lib/active_version/revisions/has_revisions/revision_manipulation.rb +499 -0
  36. data/lib/active_version/revisions/has_revisions/revision_queries.rb +182 -0
  37. data/lib/active_version/revisions/has_revisions.rb +443 -0
  38. data/lib/active_version/revisions/revision_record.rb +287 -0
  39. data/lib/active_version/revisions/sql_builder.rb +266 -0
  40. data/lib/active_version/revisions.rb +10 -0
  41. data/lib/active_version/runtime.rb +148 -0
  42. data/lib/active_version/sharding/connection_router.rb +20 -0
  43. data/lib/active_version/sharding.rb +7 -0
  44. data/lib/active_version/tasks/active_version.rake +29 -0
  45. data/lib/active_version/translations/has_translations.rb +350 -0
  46. data/lib/active_version/translations/translation_record.rb +258 -0
  47. data/lib/active_version/translations.rb +9 -0
  48. data/lib/active_version/version.rb +3 -0
  49. data/lib/active_version/version_registry.rb +87 -0
  50. data/lib/active_version.rb +329 -0
  51. data/lib/generators/active_version/audits/audits_generator.rb +65 -0
  52. data/lib/generators/active_version/audits/templates/audit_model.rb.erb +16 -0
  53. data/lib/generators/active_version/audits/templates/migration_jsonb.rb.erb +33 -0
  54. data/lib/generators/active_version/audits/templates/migration_table.rb.erb +34 -0
  55. data/lib/generators/active_version/install/install_generator.rb +19 -0
  56. data/lib/generators/active_version/install/templates/initializer.rb.erb +38 -0
  57. data/lib/generators/active_version/revisions/revisions_generator.rb +71 -0
  58. data/lib/generators/active_version/revisions/templates/backfill_migration.rb.erb +19 -0
  59. data/lib/generators/active_version/revisions/templates/migration.rb.erb +20 -0
  60. data/lib/generators/active_version/revisions/templates/revision_model.rb.erb +8 -0
  61. data/lib/generators/active_version/translations/templates/migration.rb.erb +16 -0
  62. data/lib/generators/active_version/translations/templates/translation_model.rb.erb +15 -0
  63. data/lib/generators/active_version/translations/translations_generator.rb +73 -0
  64. data/lib/generators/active_version/triggers/templates/migration.rb.erb +100 -0
  65. data/lib/generators/active_version/triggers/triggers_generator.rb +74 -0
  66. data/sig/active_version/advanced.rbs +51 -0
  67. data/sig/active_version/audits.rbs +128 -0
  68. data/sig/active_version/configuration.rbs +38 -0
  69. data/sig/active_version/core.rbs +53 -0
  70. data/sig/active_version/instrumentation.rbs +17 -0
  71. data/sig/active_version/registry_and_mapping.rbs +18 -0
  72. data/sig/active_version/revisions.rbs +70 -0
  73. data/sig/active_version/runtime.rbs +29 -0
  74. data/sig/active_version/translations.rbs +43 -0
  75. data/sig/active_version.rbs +3 -0
  76. 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