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,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,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
|