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,191 @@
1
+ module ActiveVersion
2
+ module Migrators
3
+ # Base migrator for converting from other versioning libraries
4
+ class Base
5
+ AUDIT_STORAGES = %i[json_column yaml_column mirror_columns].freeze
6
+
7
+ class << self
8
+ # Migrate data from another library
9
+ # @param model_class [Class] ActiveRecord model class
10
+ # @param options [Hash] Migration options
11
+ # @return [Integer] Number of records migrated
12
+ def migrate(model_class, options = {})
13
+ raise NotImplementedError, "Subclasses must implement migrate"
14
+ end
15
+
16
+ # Migration helper for creating audit tables
17
+ def create_audit_table(table_name, options = {})
18
+ table_options = options.dup
19
+ storage = normalize_audit_storage(table_options.delete(:storage))
20
+ mirror_columns = normalize_mirror_columns(table_options.delete(:mirror_columns))
21
+ changes_column = (table_options.delete(:changes_column) || :audited_changes).to_sym
22
+ context_column = (table_options.delete(:context_column) || :audited_context).to_sym
23
+
24
+ create_table_with_plan(
25
+ table_name,
26
+ table_options,
27
+ audit_table_plan(
28
+ table_name: table_name,
29
+ storage: storage,
30
+ mirror_columns: mirror_columns,
31
+ changes_column: changes_column,
32
+ context_column: context_column
33
+ )
34
+ )
35
+ end
36
+
37
+ # Migration helper for creating revision tables
38
+ def create_revision_table(table_name, options = {})
39
+ create_table_with_plan(table_name, options.dup, revision_table_plan(table_name))
40
+ end
41
+
42
+ # Migration helper for creating translation tables
43
+ def create_translation_table(table_name, options = {})
44
+ create_table_with_plan(table_name, options.dup, translation_table_plan(table_name))
45
+ end
46
+
47
+ protected
48
+
49
+ # Get source records to migrate
50
+ def source_records(model_class, options = {})
51
+ model_class.all
52
+ end
53
+
54
+ # Create audit from source data
55
+ def create_audit(record, audit_data, audit_class)
56
+ audit_class.create!(audit_data)
57
+ end
58
+
59
+ # Create revision from source data
60
+ def create_revision(record, revision_data, revision_class)
61
+ revision_class.create!(revision_data)
62
+ end
63
+
64
+ # Create translation from source data
65
+ def create_translation(record, translation_data, translation_class)
66
+ translation_class.create!(translation_data)
67
+ end
68
+
69
+ private
70
+
71
+ def create_table_with_plan(table_name, table_options, plan)
72
+ ActiveRecord::Schema.define do
73
+ create_table table_name, **table_options do |t|
74
+ plan.fetch(:columns).each { |column_builder| column_builder.call(t) }
75
+ end
76
+
77
+ plan.fetch(:indexes).each do |(columns, index_options)|
78
+ add_index table_name, columns, **index_options
79
+ end
80
+ end
81
+ end
82
+
83
+ def audit_table_plan(table_name:, storage:, mirror_columns:, changes_column:, context_column:)
84
+ payload_type = payload_column_type_for(storage)
85
+ columns = [
86
+ ->(t) { t.references :auditable, polymorphic: true, null: false, index: false },
87
+ ->(t) { t.string :action, null: false },
88
+ ->(t) { t.integer :version, null: false },
89
+ ->(t) { t.references :user, polymorphic: true },
90
+ ->(t) { t.references :associated, polymorphic: true },
91
+ ->(t) { t.text :comment },
92
+ ->(t) { t.string :remote_address },
93
+ ->(t) { t.string :request_uuid }
94
+ ]
95
+
96
+ if storage == :mirror_columns
97
+ mirror_columns.each do |column_name, type|
98
+ columns << ->(t) { t.public_send(type, column_name) }
99
+ end
100
+ else
101
+ columns << ->(t) { t.public_send(payload_type, context_column) }
102
+ columns << ->(t) { t.public_send(payload_type, changes_column) }
103
+ end
104
+
105
+ columns << ->(t) { t.timestamps }
106
+
107
+ {
108
+ columns: columns,
109
+ indexes: [
110
+ [[:auditable_type, :auditable_id, :version], {unique: true, name: "index_#{table_name}_on_auditable_and_version"}]
111
+ ]
112
+ }
113
+ end
114
+
115
+ def revision_table_plan(table_name)
116
+ {
117
+ columns: [
118
+ ->(t) { t.references :source, polymorphic: true, null: false, index: false },
119
+ ->(t) { t.integer :version, null: false },
120
+ ->(t) { t.text :comment },
121
+ ->(t) { t.timestamps }
122
+ ],
123
+ indexes: [
124
+ [[:source_type, :source_id, :version], {unique: true, name: "index_#{table_name}_on_source_and_version"}]
125
+ ]
126
+ }
127
+ end
128
+
129
+ def translation_table_plan(table_name)
130
+ {
131
+ columns: [
132
+ ->(t) { t.references :source, polymorphic: true, null: false, index: false },
133
+ ->(t) { t.string :locale, null: false },
134
+ ->(t) { t.timestamps }
135
+ ],
136
+ indexes: [
137
+ [[:source_type, :source_id, :locale], {unique: true, name: "index_#{table_name}_on_source_and_locale"}]
138
+ ]
139
+ }
140
+ end
141
+
142
+ def normalize_audit_storage(value)
143
+ storage = (value || ActiveVersion.config.audit_storage).to_sym
144
+ return storage if AUDIT_STORAGES.include?(storage)
145
+
146
+ raise ActiveVersion::ConfigurationError,
147
+ "Unknown audit storage #{storage.inspect}. Expected one of: #{AUDIT_STORAGES.join(", ")}"
148
+ end
149
+
150
+ def normalize_mirror_columns(value)
151
+ case value
152
+ when Hash
153
+ value.each_with_object({}) do |(column_name, type), result|
154
+ result[column_name.to_sym] = (type || :text).to_sym
155
+ end
156
+ when Array
157
+ value.each_with_object({}) do |column_name, result|
158
+ result[column_name.to_sym] = :text
159
+ end
160
+ when nil
161
+ {}
162
+ else
163
+ raise ArgumentError, "mirror_columns must be a Hash, Array, or nil"
164
+ end
165
+ end
166
+
167
+ def payload_column_type_for(storage)
168
+ return :text if storage == :yaml_column
169
+ return :text unless storage == :json_column
170
+ return :jsonb if adapter_supports_native_type?(:jsonb)
171
+ return :json if adapter_supports_native_type?(:json)
172
+
173
+ :text
174
+ end
175
+
176
+ def adapter_supports_native_type?(type)
177
+ native_types = current_connection&.native_database_types
178
+ native_types.is_a?(Hash) && native_types.key?(type)
179
+ end
180
+
181
+ def current_connection
182
+ return unless defined?(::ActiveRecord::Base)
183
+
184
+ ::ActiveRecord::Base.connection
185
+ rescue *ActiveVersion::Runtime.active_record_connection_errors
186
+ nil
187
+ end
188
+ end
189
+ end
190
+ end
191
+ end
@@ -0,0 +1,8 @@
1
+ require "active_version/migrators/base"
2
+ require "active_version/migrators/audited"
3
+
4
+ module ActiveVersion
5
+ module Migrators
6
+ # Migration helpers for transitioning from other versioning libraries
7
+ end
8
+ end
@@ -0,0 +1,105 @@
1
+ module ActiveVersion
2
+ # Unified query builder for version records
3
+ module Query
4
+ class << self
5
+ # Query audits for a record
6
+ # @param record [ActiveRecord::Base] The record to query audits for
7
+ # @param opts [Hash] Query options
8
+ # @option opts [Array] :preload Associations to preload
9
+ # @option opts [Hash] :order_by Order specification
10
+ # @return [ActiveRecord::Relation] Audit relation
11
+ def audits(record, opts = {})
12
+ audit_class = record.class.audit_class
13
+ return audit_class.none unless audit_class
14
+
15
+ auditable_column = ActiveVersion.column_mapper.column_for(record.class, :audits, :auditable)
16
+ identity_map = if record.respond_to?(:active_version_audit_identity_map)
17
+ record.active_version_audit_identity_map
18
+ else
19
+ primary_keys = Array(record.class.primary_key).map(&:to_s)
20
+ if primary_keys.one?
21
+ {"#{auditable_column}_id" => record.id}
22
+ else
23
+ primary_keys.zip(primary_keys.map { |column| record[column] }).to_h
24
+ end
25
+ end
26
+
27
+ query = audit_class.where({"#{auditable_column}_type" => record.class.name}.merge(identity_map))
28
+
29
+ query = query.preload(opts[:preload]) if opts[:preload]
30
+ query = query.order(opts[:order_by]) if opts[:order_by]
31
+
32
+ query
33
+ end
34
+
35
+ # Query translations for a record
36
+ # @param record [ActiveRecord::Base] The record to query translations for
37
+ # @param opts [Hash] Query options
38
+ # @option opts [String, Symbol] :locale Locale to filter by
39
+ # @return [ActiveRecord::Relation] Translation relation
40
+ def translations(record, opts = {})
41
+ translation_class = record.class.translation_class
42
+ return translation_class.none unless translation_class
43
+
44
+ identity_map = if record.respond_to?(:active_version_translation_identity_map)
45
+ record.active_version_translation_identity_map
46
+ else
47
+ keys = Array(translation_class.source_foreign_key).map(&:to_s)
48
+ if keys.one?
49
+ {keys.first => record.id}
50
+ else
51
+ values = Array(record.class.primary_key).map { |column| record[column] }
52
+ keys.zip(values).to_h
53
+ end
54
+ end
55
+ query = translation_class.where(identity_map)
56
+
57
+ if opts[:locale]
58
+ locale_column = ActiveVersion.column_mapper.column_for(record.class, :translations, :locale)
59
+ query = query.where(locale_column => opts[:locale])
60
+ end
61
+
62
+ query
63
+ end
64
+
65
+ # Query revisions for a record
66
+ # @param record [ActiveRecord::Base] The record to query revisions for
67
+ # @param opts [Hash] Query options
68
+ # @option opts [Integer] :version Version number to filter by
69
+ # @return [ActiveRecord::Relation] Revision relation
70
+ def revisions(record, opts = {})
71
+ revision_class = record.class.revision_class
72
+ return revision_class.none unless revision_class
73
+
74
+ identity_map = if record.respond_to?(:active_version_revision_identity_map)
75
+ record.active_version_revision_identity_map
76
+ else
77
+ keys = Array(revision_class.source_foreign_key).map(&:to_s)
78
+ if keys.one?
79
+ {keys.first => record.id}
80
+ else
81
+ values = Array(record.class.primary_key).map { |column| record[column] }
82
+ keys.zip(values).to_h
83
+ end
84
+ end
85
+ query = revision_class.where(identity_map)
86
+
87
+ if opts[:version]
88
+ version_column = ActiveVersion.column_mapper.column_for(record.class, :revisions, :version)
89
+ query = query.where(version_column => opts[:version])
90
+ end
91
+
92
+ version_column = ActiveVersion.column_mapper.column_for(record.class, :revisions, :version)
93
+ query.order(version_column => :asc)
94
+ end
95
+
96
+ private
97
+
98
+ def log_debug(message)
99
+ if defined?(Rails) && Rails.respond_to?(:logger)
100
+ Rails.logger&.debug("[ActiveVersion::Query] #{message}")
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,17 @@
1
+ module ActiveVersion
2
+ # Rails integration
3
+ class Railtie < Rails::Railtie
4
+ # Load ActiveVersion after ActiveRecord is loaded
5
+ config.after_initialize do
6
+ ActiveVersion.config.validate!
7
+ end
8
+
9
+ # Expose configuration in Rails config
10
+ config.active_version = ActiveVersion.config
11
+
12
+ # Add rake tasks
13
+ rake_tasks do
14
+ load "active_version/tasks/active_version.rake"
15
+ end
16
+ end
17
+ end