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,263 @@
1
+ module ActiveVersion
2
+ module Audits
3
+ # SQL builder for batch audit operations
4
+ module SQLBuilder
5
+ extend ActiveSupport::Concern
6
+
7
+ module ClassMethods
8
+ BatchCollector = Struct.new(:records) do
9
+ def <<(record)
10
+ records << record
11
+ self
12
+ end
13
+
14
+ def add(record)
15
+ self << record
16
+ end
17
+
18
+ def concat(list)
19
+ records.concat(Array(list))
20
+ self
21
+ end
22
+
23
+ def to_a
24
+ records
25
+ end
26
+ end
27
+
28
+ # Generate SQL for batch insert of audits
29
+ # @param records [Array] Array of ActiveRecord instances to audit
30
+ # @param options [Hash] Options for batch generation
31
+ # @option options [Boolean] :combine (true) Combine into single INSERT statement
32
+ # @option options [Hash] :context Additional context to merge with each audit
33
+ # @return [String] SQL statement(s) for batch insert
34
+ def batch_insert_sql(records = nil, options = {}, &block)
35
+ records, options = normalize_batch_arguments(records, options)
36
+ captured_values = []
37
+
38
+ if block_given? && block.arity == 0 && Array(records).flatten.compact.empty?
39
+ captured_values = capture_audit_values(options) { yield }
40
+ end
41
+
42
+ if captured_values.any?
43
+ if options[:combine] != false
44
+ return build_combined_insert_sql(self, captured_values)
45
+ end
46
+
47
+ return captured_values.map { |values| build_single_insert_sql(self, values) }.join(";\n")
48
+ end
49
+
50
+ records = resolve_batch_records(records, &block)
51
+ return "" if records.empty?
52
+
53
+ # Get audit class from first record
54
+ first_record = records.first
55
+ return "" unless first_record
56
+
57
+ audit_class = first_record.class.audit_class
58
+ return "" unless audit_class
59
+
60
+ version_tracker = {}
61
+
62
+ # Build values for each record
63
+ values_list = records.map do |record|
64
+ build_batch_audit_values(record, audit_class, options, version_tracker)
65
+ end.compact
66
+
67
+ return "" if values_list.empty?
68
+
69
+ # Combine into single INSERT with multiple VALUES
70
+ if options[:combine] != false
71
+ build_combined_insert_sql(audit_class, values_list)
72
+ else
73
+ # Return separate INSERT statements
74
+ values_list.map do |values|
75
+ build_single_insert_sql(audit_class, values)
76
+ end.join(";\n")
77
+ end
78
+ end
79
+
80
+ # Execute batch insert SQL for audits.
81
+ # Supports the same arguments and block semantics as batch_insert_sql.
82
+ # @return [Integer] 0 when nothing to insert, otherwise adapter execute result
83
+ def batch_insert(records = nil, options = {}, &block)
84
+ sql = batch_insert_sql(records, options, &block)
85
+ return 0 if sql.empty?
86
+
87
+ connection.execute(sql)
88
+ end
89
+
90
+ private
91
+
92
+ def normalize_batch_arguments(records, options)
93
+ if records.is_a?(Hash) && options.empty?
94
+ [nil, records]
95
+ else
96
+ [records, options]
97
+ end
98
+ end
99
+
100
+ def resolve_batch_records(records, &block)
101
+ return Array(records).flatten.compact unless block_given?
102
+
103
+ collected = []
104
+ if block.arity == 1
105
+ yield(BatchCollector.new(collected))
106
+ else
107
+ yield
108
+ end
109
+
110
+ Array(collected).flatten.compact
111
+ end
112
+
113
+ def capture_audit_values(options = {})
114
+ previous_state = ActiveVersion.store_get(:active_version_audit_batch_state)
115
+ state = {
116
+ target_audit_class: self,
117
+ options: options,
118
+ values: [],
119
+ version_tracker: {}
120
+ }
121
+ ActiveVersion.store_set(:active_version_audit_batch_state, state)
122
+ yield
123
+ state[:values]
124
+ ensure
125
+ ActiveVersion.store_set(:active_version_audit_batch_state, previous_state)
126
+ end
127
+
128
+ def build_batch_audit_values(record, audit_class, options, version_tracker)
129
+ # Allow building values even if record hasn't changed (for testing/documentation)
130
+ return nil unless record.changed? || options[:force] || options[:allow_saved]
131
+
132
+ # Determine action
133
+ action = if record.new_record?
134
+ "create"
135
+ elsif options[:destroy]
136
+ "destroy"
137
+ else
138
+ "update"
139
+ end
140
+
141
+ # Get audited changes
142
+ changes = record.send(:audited_changes) if record.respond_to?(:audited_changes, true)
143
+ changes ||= record.changes
144
+
145
+ # Build base attributes
146
+ attrs = {
147
+ action: action,
148
+ audited_changes: changes
149
+ }
150
+ attrs[:comment] = record.audit_comment if record.respond_to?(:audit_comment)
151
+
152
+ # Merge context
153
+ global_context = ActiveVersion.context || {}
154
+ instance_context = record.audit_context if record.respond_to?(:audit_context)
155
+ context = global_context.merge(instance_context || {})
156
+ context.merge!(options[:context] || {})
157
+ attrs[:audited_context] = context if context.any?
158
+
159
+ # Set polymorphic association
160
+ auditable_column = ActiveVersion.column_mapper.column_for(record.class, :audits, :auditable)
161
+ identity_map = if record.respond_to?(:active_version_audit_identity_map)
162
+ record.active_version_audit_identity_map
163
+ elsif record.respond_to?(:active_version_auditable_id_value)
164
+ {"#{auditable_column}_id" => record.active_version_auditable_id_value}
165
+ else
166
+ {"#{auditable_column}_id" => record.id}
167
+ end
168
+ return nil if identity_map.values.any?(&:nil?)
169
+ attrs.merge!(identity_map)
170
+ attrs["#{auditable_column}_type"] = record.class.name
171
+
172
+ # Set version
173
+ version_column = ActiveVersion.column_mapper.column_for(record.class, :audits, :version)
174
+ attrs[version_column] = next_batch_version(
175
+ audit_class,
176
+ version_column,
177
+ "#{auditable_column}_type",
178
+ identity_map,
179
+ attrs["#{auditable_column}_type"],
180
+ version_tracker
181
+ )
182
+
183
+ # Set user if available
184
+ user_column = ActiveVersion.column_mapper.column_for(record.class, :audits, :user)
185
+ if user_column && defined?(ActiveVersion::RequestStore) && ActiveVersion::RequestStore.audited_user
186
+ user = ActiveVersion::RequestStore.audited_user
187
+ if user.respond_to?(:id)
188
+ attrs[user_column] = user.id
189
+ if user_column.to_s.end_with?("_id") && user.respond_to?(:class)
190
+ type_column = user_column.to_s.gsub("_id", "_type")
191
+ attrs[type_column] = user.class.name
192
+ end
193
+ end
194
+ # If user doesn't have id method, skip setting user column
195
+ end
196
+
197
+ # Set timestamps
198
+ attrs[:created_at] = Time.current
199
+ attrs[:updated_at] = Time.current
200
+
201
+ attrs
202
+ end
203
+
204
+ def next_batch_version(audit_class, version_column, type_column, identity_map, auditable_type, version_tracker)
205
+ normalized_identity_map = identity_map.transform_keys(&:to_s).sort.to_h
206
+ cache_key = [auditable_type, normalized_identity_map]
207
+ previous_version = version_tracker[cache_key]
208
+ if previous_version
209
+ version_tracker[cache_key] = previous_version + 1
210
+ return version_tracker[cache_key]
211
+ end
212
+
213
+ max_version = audit_class
214
+ .where(normalized_identity_map.merge(type_column => auditable_type))
215
+ .maximum(version_column)
216
+ .to_i
217
+
218
+ version_tracker[cache_key] = max_version + 1
219
+ end
220
+
221
+ def build_combined_insert_sql(audit_class, values_list)
222
+ return "" if values_list.empty?
223
+
224
+ # Get all columns from all values
225
+ all_columns = values_list.flat_map(&:keys).uniq
226
+ connection = audit_class.connection
227
+ table_name = connection.quote_table_name(audit_class.table_name)
228
+ column_list = all_columns.map { |col| connection.quote_column_name(col) }.join(", ")
229
+
230
+ values_sql = values_list.map do |values|
231
+ row_values = all_columns.map { |col| connection.quote(prepare_sql_value(values[col])) }.join(", ")
232
+ "(#{row_values})"
233
+ end.join(", ")
234
+
235
+ "INSERT INTO #{table_name} (#{column_list}) VALUES #{values_sql}"
236
+ end
237
+
238
+ def build_single_insert_sql(audit_class, values)
239
+ stmt = Arel::InsertManager.new
240
+ table = Arel::Table.new(audit_class.table_name)
241
+ stmt.into(table)
242
+
243
+ values.keys.each { |key| stmt.columns << table[key] }
244
+ stmt.values = stmt.create_values(values.values.map { |v| prepare_sql_value(v) })
245
+ stmt.to_sql
246
+ end
247
+
248
+ def prepare_sql_value(value)
249
+ case value
250
+ when Hash, Array
251
+ value.to_json
252
+ when Time, DateTime
253
+ value.utc
254
+ when Date
255
+ value.to_time.utc
256
+ else
257
+ value
258
+ end
259
+ end
260
+ end
261
+ end
262
+ end
263
+ end
@@ -0,0 +1,10 @@
1
+ require "active_version/audits/audit_record"
2
+ require "active_version/audits/has_audits"
3
+ require "active_version/audits/sql_builder"
4
+
5
+ module ActiveVersion
6
+ module Audits
7
+ # Audits module for ActiveVersion
8
+ # Provides change tracking and evidence functionality
9
+ end
10
+ end
@@ -0,0 +1,92 @@
1
+ module ActiveVersion
2
+ # Maps versioning concepts to actual column names
3
+ # Allows developers to configure any column name to any concept
4
+ class ColumnMapper
5
+ def initialize
6
+ @mappings = {}
7
+ end
8
+
9
+ # Register a column mapping for a model and version type
10
+ def register(model_class, version_type, concept, column_name)
11
+ key = mapping_key(model_class, version_type, concept)
12
+ @mappings[key] = column_name.to_sym
13
+ end
14
+
15
+ # Get column name for a concept
16
+ def column_for(model_class, version_type, concept)
17
+ key = mapping_key(model_class, version_type, concept)
18
+ @mappings[key] || default_column_for(version_type, concept)
19
+ end
20
+
21
+ # Get all mappings for a model and version type
22
+ def mappings_for(model_class, version_type)
23
+ prefix = "#{model_class.name}:#{version_type}:"
24
+ @mappings.select { |k, _v| k.to_s.start_with?(prefix) }
25
+ .transform_keys { |k| k.to_s.sub(prefix, "").to_sym }
26
+ end
27
+
28
+ private
29
+
30
+ def mapping_key(model_class, version_type, concept)
31
+ :"#{model_class.name}:#{version_type}:#{concept}"
32
+ end
33
+
34
+ def default_column_for(version_type, concept)
35
+ case version_type
36
+ when :translations
37
+ default_translation_column(concept)
38
+ when :revisions
39
+ default_revision_column(concept)
40
+ when :audits
41
+ default_audit_column(concept)
42
+ else
43
+ raise ConfigurationError, "Unknown version type: #{version_type.inspect}"
44
+ end
45
+ end
46
+
47
+ def default_translation_column(concept)
48
+ case concept
49
+ when :locale
50
+ ActiveVersion.config.translation_locale_column
51
+ else
52
+ raise ConfigurationError, "Unknown translation concept: #{concept.inspect}"
53
+ end
54
+ end
55
+
56
+ def default_revision_column(concept)
57
+ case concept
58
+ when :version
59
+ ActiveVersion.config.revision_version_column
60
+ else
61
+ raise ConfigurationError, "Unknown revision concept: #{concept.inspect}"
62
+ end
63
+ end
64
+
65
+ def default_audit_column(concept)
66
+ case concept
67
+ when :action
68
+ ActiveVersion.config.audit_action_column
69
+ when :changes
70
+ ActiveVersion.config.audit_changes_column
71
+ when :context
72
+ ActiveVersion.config.audit_context_column
73
+ when :comment
74
+ ActiveVersion.config.audit_comment_column
75
+ when :version
76
+ ActiveVersion.config.audit_version_column
77
+ when :user
78
+ ActiveVersion.config.audit_user_column
79
+ when :auditable
80
+ ActiveVersion.config.audit_auditable_column
81
+ when :associated
82
+ ActiveVersion.config.audit_associated_column
83
+ when :remote_address
84
+ ActiveVersion.config.audit_remote_address_column
85
+ when :request_uuid
86
+ ActiveVersion.config.audit_request_uuid_column
87
+ else
88
+ raise ConfigurationError, "Unknown audit concept: #{concept.inspect}"
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,124 @@
1
+ module ActiveVersion
2
+ # Global configuration for ActiveVersion
3
+ class Configuration
4
+ attr_accessor :auditing_enabled
5
+ attr_accessor :current_user_method
6
+ attr_accessor :ignored_attributes
7
+ attr_accessor :ignored_default_callbacks
8
+ attr_accessor :store_synthesized_enums
9
+ attr_accessor :execution_scope
10
+
11
+ # Translation defaults
12
+ attr_accessor :translation_locale_column
13
+ attr_accessor :translation_default_locale
14
+
15
+ # Revision defaults
16
+ attr_accessor :revision_version_column
17
+ attr_accessor :revision_foreign_key_suffix
18
+
19
+ # Audit defaults
20
+ attr_accessor :default_audit_class
21
+ attr_accessor :audit_storage
22
+ attr_accessor :audit_action_column
23
+ attr_accessor :audit_changes_column
24
+ attr_accessor :audit_context_column
25
+ attr_accessor :audit_comment_column
26
+ attr_accessor :audit_version_column
27
+ attr_accessor :audit_user_column
28
+ attr_accessor :audit_auditable_column
29
+ attr_accessor :audit_associated_column
30
+ attr_accessor :audit_remote_address_column
31
+ attr_accessor :audit_request_uuid_column
32
+ attr_accessor :max_audits
33
+ attr_accessor :max_revisions
34
+ attr_accessor :return_self_if_no_revisions
35
+ attr_accessor :return_self_if_no_audits
36
+ attr_accessor :audit_error_behavior
37
+ attr_accessor :revision_error_behavior
38
+ attr_accessor :debounce_time
39
+
40
+ def initialize
41
+ # Global settings
42
+ @auditing_enabled = true
43
+ @current_user_method = :current_user
44
+ @ignored_attributes = %w[lock_version created_at updated_at created_on updated_on]
45
+ @ignored_default_callbacks = []
46
+ @store_synthesized_enums = false
47
+ @execution_scope = :fiber
48
+
49
+ # Translation defaults
50
+ @translation_locale_column = :locale
51
+ @translation_default_locale = :en
52
+
53
+ # Revision defaults
54
+ @revision_version_column = :version
55
+ @revision_foreign_key_suffix = "_id"
56
+
57
+ # Audit defaults
58
+ @default_audit_class = nil # When set, has_audits without :as uses this when ModelAudit is not defined
59
+ @audit_storage = :json_column
60
+ @audit_action_column = :action
61
+ @audit_changes_column = :audited_changes
62
+ @audit_context_column = :audited_context
63
+ @audit_comment_column = :comment
64
+ @audit_version_column = :version
65
+ @audit_user_column = :user_id
66
+ @audit_auditable_column = :auditable
67
+ @audit_associated_column = :associated
68
+ @audit_remote_address_column = :remote_address
69
+ @audit_request_uuid_column = :request_uuid
70
+ @max_audits = nil
71
+ @max_revisions = nil
72
+ @return_self_if_no_revisions = false
73
+ @return_self_if_no_audits = false
74
+ @audit_error_behavior = :exception
75
+ @revision_error_behavior = :exception
76
+ @debounce_time = nil # Time in seconds to merge revisions within window
77
+ end
78
+
79
+ # Validate configuration
80
+ def validate!
81
+ validate_storage_type!
82
+ validate_execution_scope!
83
+ validate_column_names!
84
+ end
85
+
86
+ private
87
+
88
+ def validate_storage_type!
89
+ unless %i[json_column yaml_column mirror_columns].include?(@audit_storage)
90
+ raise ConfigurationError,
91
+ "audit_storage must be :json_column, :yaml_column, or :mirror_columns, got: #{@audit_storage.inspect}"
92
+ end
93
+ end
94
+
95
+ def validate_execution_scope!
96
+ return if %i[fiber thread].include?(@execution_scope)
97
+
98
+ raise ConfigurationError,
99
+ "execution_scope must be :fiber or :thread, got: #{@execution_scope.inspect}"
100
+ end
101
+
102
+ def validate_column_names!
103
+ # Ensure column names are symbols or strings
104
+ column_attrs = [
105
+ :translation_locale_column,
106
+ :revision_version_column,
107
+ :audit_action_column,
108
+ :audit_changes_column,
109
+ :audit_context_column,
110
+ :audit_comment_column,
111
+ :audit_version_column,
112
+ :audit_user_column
113
+ ]
114
+
115
+ column_attrs.each do |attr|
116
+ value = instance_variable_get(:"@#{attr}")
117
+ unless value.is_a?(Symbol) || value.is_a?(String)
118
+ raise ConfigurationError,
119
+ "#{attr} must be a Symbol or String, got: #{value.class}"
120
+ end
121
+ end
122
+ end
123
+ end
124
+ end