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,258 @@
|
|
|
1
|
+
module ActiveVersion
|
|
2
|
+
module Translations
|
|
3
|
+
# Marker module for translation models
|
|
4
|
+
# Identifies a model as a translation record
|
|
5
|
+
module TranslationRecord
|
|
6
|
+
extend ActiveSupport::Concern
|
|
7
|
+
|
|
8
|
+
included do
|
|
9
|
+
class_attribute :active_version_translation_schema, instance_writer: false, default: {}
|
|
10
|
+
|
|
11
|
+
# Mark this as a translation record
|
|
12
|
+
def self.translation_record?
|
|
13
|
+
true
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Get source model name (e.g., "Post" from "PostTranslation")
|
|
17
|
+
def self.source_name
|
|
18
|
+
return @source_name if @source_name
|
|
19
|
+
return nil unless name
|
|
20
|
+
@source_name = name.underscore.gsub("_translation", "").to_sym
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Get source model class (lazy)
|
|
24
|
+
def self.source_class
|
|
25
|
+
@source_class ||= begin
|
|
26
|
+
klass = source_name.to_s.classify.safe_constantize
|
|
27
|
+
klass || raise(NameError, "Could not find source class #{source_name.to_s.classify}")
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Get foreign key name(s) (e.g., "post_id" or ["tenant_id", "post_id"])
|
|
32
|
+
def self.source_foreign_key
|
|
33
|
+
schema_foreign_key = (active_version_translation_schema || {})[:foreign_key]
|
|
34
|
+
if schema_foreign_key.present?
|
|
35
|
+
return schema_foreign_key.is_a?(Array) ? schema_foreign_key.map(&:to_s) : schema_foreign_key.to_s
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
model_name = source_name.to_s.classify
|
|
39
|
+
options = ActiveVersion.registry.config_for_model_name(model_name, :translations) || {}
|
|
40
|
+
options = ActiveVersion.registry.config_for(source_class, :translations) || {} if options.empty?
|
|
41
|
+
foreign_key = options[:foreign_key].presence || "#{source_name}_id"
|
|
42
|
+
foreign_key.is_a?(Array) ? foreign_key.map(&:to_s) : foreign_key.to_s
|
|
43
|
+
rescue NameError
|
|
44
|
+
"#{source_name}_id"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def self.source_primary_key
|
|
48
|
+
schema_identity_resolver = (active_version_translation_schema || {})[:identity_resolver]
|
|
49
|
+
if schema_identity_resolver.present?
|
|
50
|
+
return schema_identity_resolver.map(&:to_s) if schema_identity_resolver.is_a?(Array)
|
|
51
|
+
return schema_identity_resolver.to_s if schema_identity_resolver.is_a?(Symbol)
|
|
52
|
+
return schema_identity_resolver if schema_identity_resolver.is_a?(String) && schema_identity_resolver.present?
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
model_name = source_name.to_s.classify
|
|
56
|
+
options = ActiveVersion.registry.config_for_model_name(model_name, :translations) || {}
|
|
57
|
+
options = ActiveVersion.registry.config_for(source_class, :translations) || {} if options.empty?
|
|
58
|
+
|
|
59
|
+
resolver = options[:identity_resolver]
|
|
60
|
+
return resolver.map(&:to_s) if resolver.is_a?(Array)
|
|
61
|
+
return resolver.to_s if resolver.is_a?(Symbol)
|
|
62
|
+
return resolver if resolver.is_a?(String) && resolver.present?
|
|
63
|
+
|
|
64
|
+
nil
|
|
65
|
+
rescue NameError
|
|
66
|
+
nil
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def self.locale_column_name
|
|
70
|
+
schema_locale = (active_version_translation_schema || {})[:locale_column]
|
|
71
|
+
if schema_locale.present?
|
|
72
|
+
return schema_locale.to_sym
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
locale_column = ActiveVersion.column_mapper.column_for(source_class, :translations, :locale)
|
|
76
|
+
return locale_column if column_names.include?(locale_column.to_s)
|
|
77
|
+
|
|
78
|
+
ActiveVersion.config.translation_locale_column
|
|
79
|
+
rescue NameError, ActiveRecord::ConnectionNotDefined
|
|
80
|
+
ActiveVersion.config.translation_locale_column
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Set up belongs_to association (deferred until source class exists)
|
|
84
|
+
def self.setup_associations(force: false)
|
|
85
|
+
reflection = reflect_on_association(source_name)
|
|
86
|
+
return if @associations_setup && !force && Array(reflection&.foreign_key).map(&:to_s) == Array(source_foreign_key).map(&:to_s)
|
|
87
|
+
@associations_setup = true
|
|
88
|
+
|
|
89
|
+
assoc_options = {
|
|
90
|
+
foreign_key: source_foreign_key,
|
|
91
|
+
inverse_of: :translations,
|
|
92
|
+
touch: true
|
|
93
|
+
}
|
|
94
|
+
primary_key = source_primary_key
|
|
95
|
+
assoc_options[:primary_key] = primary_key if primary_key.present?
|
|
96
|
+
send(:belongs_to, source_name, **assoc_options)
|
|
97
|
+
|
|
98
|
+
# Validations
|
|
99
|
+
begin
|
|
100
|
+
locale_column = locale_column_name
|
|
101
|
+
validates locale_column, presence: true, uniqueness: {scope: Array(source_foreign_key)}
|
|
102
|
+
rescue NameError, ActiveRecord::ConnectionNotDefined
|
|
103
|
+
# Source class not yet defined, will be set up later
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Setup will be called when source class is available
|
|
108
|
+
def self.setup_locale_enum
|
|
109
|
+
return if @locale_enum_setup
|
|
110
|
+
return unless defined?(I18n) && I18n.respond_to?(:available_locales)
|
|
111
|
+
return unless source_name
|
|
112
|
+
|
|
113
|
+
@locale_enum_setup = true
|
|
114
|
+
begin
|
|
115
|
+
locale_column = locale_column_name
|
|
116
|
+
column = columns_hash[locale_column.to_s]
|
|
117
|
+
return unless column&.type == :integer
|
|
118
|
+
enum locale_column, I18n.available_locales.index_by(&:to_s)
|
|
119
|
+
rescue NameError, ActiveRecord::ConnectionNotDefined
|
|
120
|
+
# Source class not yet defined
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Scopes
|
|
125
|
+
scope :for_locale, ->(locale) {
|
|
126
|
+
return none unless source_name
|
|
127
|
+
locale_column = locale_column_name
|
|
128
|
+
where(locale_column => locale)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
# After create hook to update source version
|
|
132
|
+
after_create :update_source_version
|
|
133
|
+
after_create :instrument_translation_created
|
|
134
|
+
|
|
135
|
+
# After update hook to instrument translation updated
|
|
136
|
+
after_update :instrument_translation_updated
|
|
137
|
+
after_destroy :instrument_translation_destroyed
|
|
138
|
+
|
|
139
|
+
# Setup associations when class is loaded (only if name is available)
|
|
140
|
+
setup_associations if name
|
|
141
|
+
setup_locale_enum if name
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
class TranslationSchemaDSL
|
|
145
|
+
def initialize(klass)
|
|
146
|
+
@klass = klass
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def locale_column(value)
|
|
150
|
+
@klass.translation_locale_column(value)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def foreign_key(value)
|
|
154
|
+
@klass.translation_foreign_key(value)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def identity_resolver(value)
|
|
158
|
+
@klass.translation_identity_resolver(value)
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
class_methods do
|
|
163
|
+
def configure_translation(**options, &block)
|
|
164
|
+
apply_translation_configuration(**options)
|
|
165
|
+
TranslationSchemaDSL.new(self).instance_eval(&block) if block_given?
|
|
166
|
+
active_version_translation_schema
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def translation_locale_column(value = nil) = schema_option(:locale_column, value, cast: :symbol)
|
|
170
|
+
def translation_foreign_key(value = nil) = schema_option(:foreign_key, value, cast: :identity)
|
|
171
|
+
def translation_identity_resolver(value = nil) = schema_option(:identity_resolver, value, cast: :resolver)
|
|
172
|
+
|
|
173
|
+
def apply_translation_configuration(locale_column: nil, foreign_key: nil, identity_resolver: nil)
|
|
174
|
+
translation_locale_column(locale_column) if locale_column
|
|
175
|
+
translation_foreign_key(foreign_key) if foreign_key
|
|
176
|
+
translation_identity_resolver(identity_resolver) if identity_resolver
|
|
177
|
+
active_version_translation_schema
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
private
|
|
181
|
+
|
|
182
|
+
def schema_option(key, value, cast:)
|
|
183
|
+
schema = (active_version_translation_schema || {}).dup
|
|
184
|
+
return schema[key] if value.nil?
|
|
185
|
+
|
|
186
|
+
schema[key] = case cast
|
|
187
|
+
when :symbol
|
|
188
|
+
value.to_sym
|
|
189
|
+
when :identity
|
|
190
|
+
value.is_a?(Array) ? value.map(&:to_s) : value.to_s
|
|
191
|
+
when :resolver
|
|
192
|
+
if value.is_a?(Array)
|
|
193
|
+
value.map(&:to_s)
|
|
194
|
+
elsif value.is_a?(Symbol)
|
|
195
|
+
value.to_s
|
|
196
|
+
else
|
|
197
|
+
value
|
|
198
|
+
end
|
|
199
|
+
else
|
|
200
|
+
value
|
|
201
|
+
end
|
|
202
|
+
self.active_version_translation_schema = schema
|
|
203
|
+
schema[key]
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Check if attribute is present for locale
|
|
208
|
+
def attr_present_for_locale?(locale, attr_name, presence_check = nil)
|
|
209
|
+
return false unless self.class.source_name
|
|
210
|
+
|
|
211
|
+
begin
|
|
212
|
+
locale_column = self.class.locale_column_name
|
|
213
|
+
return false unless send(locale_column).to_s == locale.to_s
|
|
214
|
+
|
|
215
|
+
if presence_check
|
|
216
|
+
send(presence_check, attr_name)
|
|
217
|
+
else
|
|
218
|
+
send(attr_name).present?
|
|
219
|
+
end
|
|
220
|
+
rescue NameError
|
|
221
|
+
# Source class not yet defined, check locale directly
|
|
222
|
+
return false unless respond_to?(:locale)
|
|
223
|
+
return false unless self.locale.to_s == locale.to_s
|
|
224
|
+
send(attr_name).present?
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# Get source version (for versioning of versions)
|
|
229
|
+
def source_version
|
|
230
|
+
send(self.class.source_name)
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
private
|
|
234
|
+
|
|
235
|
+
def update_source_version
|
|
236
|
+
source = send(self.class.source_name)
|
|
237
|
+
return unless source
|
|
238
|
+
|
|
239
|
+
# Update source's updated_at if it has translations
|
|
240
|
+
if source.respond_to?(:update_default_translation)
|
|
241
|
+
source.update_default_translation
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def instrument_translation_created
|
|
246
|
+
ActiveVersion::Instrumentation.instrument_translation_created(self, source_version)
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def instrument_translation_updated
|
|
250
|
+
ActiveVersion::Instrumentation.instrument_translation_updated(self, source_version)
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def instrument_translation_destroyed
|
|
254
|
+
ActiveVersion::Instrumentation.instrument_translation_destroyed(self, source_version)
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
end
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
require "active_version/translations/translation_record"
|
|
2
|
+
require "active_version/translations/has_translations"
|
|
3
|
+
|
|
4
|
+
module ActiveVersion
|
|
5
|
+
module Translations
|
|
6
|
+
# Translations module for ActiveVersion
|
|
7
|
+
# Provides locale-based versioning functionality
|
|
8
|
+
end
|
|
9
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
module ActiveVersion
|
|
2
|
+
# Registry for tracking versioned models and their configuration
|
|
3
|
+
class VersionRegistry
|
|
4
|
+
def initialize
|
|
5
|
+
@models = {}
|
|
6
|
+
@version_classes = {}
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
# Register a model with versioning
|
|
10
|
+
# Detects conflicts when re-registering with different options or duplicate registrations
|
|
11
|
+
def register(model_class, version_type, options = {})
|
|
12
|
+
key = registry_key(model_class, version_type)
|
|
13
|
+
|
|
14
|
+
# Check for existing registration
|
|
15
|
+
if @models.key?(key)
|
|
16
|
+
existing = @models[key]
|
|
17
|
+
|
|
18
|
+
# Detect option conflicts
|
|
19
|
+
if existing[:options] != options
|
|
20
|
+
warn "[ActiveVersion] Re-registering #{model_class.name} with :#{version_type} " \
|
|
21
|
+
"with different options. Previous: #{existing[:options].inspect}, " \
|
|
22
|
+
"New: #{options.inspect}. This may indicate a configuration issue."
|
|
23
|
+
else
|
|
24
|
+
# Same options - likely a double include, but not necessarily a problem
|
|
25
|
+
# Log at debug level if needed
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
@models[key] = {
|
|
30
|
+
model_class: model_class,
|
|
31
|
+
version_type: version_type,
|
|
32
|
+
options: options,
|
|
33
|
+
registered_at: Time.current
|
|
34
|
+
}
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Get version class for a model and version type
|
|
38
|
+
def version_class_for(model_class, version_type)
|
|
39
|
+
key = registry_key(model_class, version_type)
|
|
40
|
+
@version_classes[key]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Register a version class
|
|
44
|
+
def register_version_class(model_class, version_type, version_class)
|
|
45
|
+
key = registry_key(model_class, version_type)
|
|
46
|
+
@version_classes[key] = version_class
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Check if a model is registered for versioning
|
|
50
|
+
def registered?(model_class, version_type)
|
|
51
|
+
key = registry_key(model_class, version_type)
|
|
52
|
+
@models.key?(key)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Get all registered models for a version type
|
|
56
|
+
def models_for_version_type(version_type)
|
|
57
|
+
@models.select { |_k, v| v[:version_type] == version_type }
|
|
58
|
+
.map { |_k, v| v[:model_class] }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Get configuration for a model and version type
|
|
62
|
+
def config_for(model_class, version_type)
|
|
63
|
+
key = registry_key(model_class, version_type)
|
|
64
|
+
@models[key]&.fetch(:options, {})
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Get configuration by model class name and version type.
|
|
68
|
+
# Useful while constants are still being wired and only the intended class
|
|
69
|
+
# name is known.
|
|
70
|
+
def config_for_model_name(model_name, version_type)
|
|
71
|
+
key = :"#{model_name}:#{version_type}"
|
|
72
|
+
@models[key]&.fetch(:options, {})
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Clear all registrations (useful for testing)
|
|
76
|
+
def clear!
|
|
77
|
+
@models.clear
|
|
78
|
+
@version_classes.clear
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
def registry_key(model_class, version_type)
|
|
84
|
+
:"#{model_class.name}:#{version_type}"
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
require "active_support"
|
|
2
|
+
begin
|
|
3
|
+
require "active_record"
|
|
4
|
+
rescue LoadError
|
|
5
|
+
# ActiveRecord is optional at runtime.
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
require "active_version/version"
|
|
9
|
+
require "active_version/configuration"
|
|
10
|
+
require "active_version/column_mapper"
|
|
11
|
+
require "active_version/version_registry"
|
|
12
|
+
require "active_version/instrumentation"
|
|
13
|
+
require "active_version/runtime"
|
|
14
|
+
|
|
15
|
+
# Main entry point for ActiveVersion
|
|
16
|
+
module ActiveVersion
|
|
17
|
+
class Error < StandardError; end
|
|
18
|
+
class ConfigurationError < Error; end
|
|
19
|
+
class VersionNotFoundError < Error; end
|
|
20
|
+
class ReadonlyVersionError < Error; end
|
|
21
|
+
class FutureTimeError < Error; end
|
|
22
|
+
class DeletedColumnError < Error; end
|
|
23
|
+
|
|
24
|
+
extend ActiveSupport::Autoload
|
|
25
|
+
|
|
26
|
+
autoload :Adapters
|
|
27
|
+
autoload :Translations
|
|
28
|
+
autoload :Revisions
|
|
29
|
+
autoload :Audits
|
|
30
|
+
autoload :Database
|
|
31
|
+
autoload :Sharding
|
|
32
|
+
autoload :Query
|
|
33
|
+
autoload :Migrators
|
|
34
|
+
autoload :Runtime
|
|
35
|
+
|
|
36
|
+
# Load translations module
|
|
37
|
+
require "active_version/translations"
|
|
38
|
+
# Load revisions module
|
|
39
|
+
require "active_version/revisions"
|
|
40
|
+
# Load audits module
|
|
41
|
+
require "active_version/audits"
|
|
42
|
+
|
|
43
|
+
# Load ActiveRecord adapters (they use ActiveSupport.on_load, so safe to require)
|
|
44
|
+
begin
|
|
45
|
+
# Ensure base adapter is loaded first
|
|
46
|
+
require "active_version/adapters/active_record/base"
|
|
47
|
+
require "active_version/adapters/active_record/translations"
|
|
48
|
+
require "active_version/adapters/active_record/revisions"
|
|
49
|
+
require "active_version/adapters/active_record/audits"
|
|
50
|
+
rescue LoadError
|
|
51
|
+
# Adapters may not be available in all environments
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
begin
|
|
55
|
+
require "active_version/adapters/sequel"
|
|
56
|
+
rescue LoadError
|
|
57
|
+
# Sequel adapter is optional at runtime.
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Global configuration
|
|
61
|
+
def self.config
|
|
62
|
+
@config ||= Configuration.new
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def self.configure
|
|
66
|
+
yield config if block_given?
|
|
67
|
+
config
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Convenience methods for accessing configuration
|
|
71
|
+
def self.auditing_enabled
|
|
72
|
+
config.auditing_enabled
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def self.auditing_enabled=(value)
|
|
76
|
+
config.auditing_enabled = value
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Runtime adapter access (ActiveRecord by default).
|
|
80
|
+
def self.runtime_adapter
|
|
81
|
+
Runtime.adapter
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def self.runtime_adapter=(adapter)
|
|
85
|
+
Runtime.adapter = adapter
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def self.reset_runtime_adapter!
|
|
89
|
+
Runtime.reset_adapter!
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Context management (like audited)
|
|
93
|
+
class RequestStore < ActiveSupport::CurrentAttributes
|
|
94
|
+
attribute :version_context
|
|
95
|
+
attribute :audited_user
|
|
96
|
+
attribute :request_uuid
|
|
97
|
+
attribute :remote_address
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def self.context
|
|
101
|
+
# Merge persistent context with request-scoped context
|
|
102
|
+
persistent = store_get(:active_version_persistent_context) || {}
|
|
103
|
+
request_scoped = RequestStore.version_context || {}
|
|
104
|
+
persistent.merge(request_scoped)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def self.context=(value)
|
|
108
|
+
raise ConfigurationError, "context must be a hash" unless value.is_a?(Hash)
|
|
109
|
+
RequestStore.version_context = value
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Transaction-aware context (uses PostgreSQL session variables)
|
|
113
|
+
# Accepts either a hash as first argument or keyword arguments
|
|
114
|
+
def self.with_context(context = nil, transactional: true, **kwargs, &block)
|
|
115
|
+
raise ArgumentError, "with_context requires a block" unless block_given?
|
|
116
|
+
|
|
117
|
+
# If context is nil but kwargs are provided, use kwargs as context
|
|
118
|
+
# If context is provided, use it (and ignore kwargs)
|
|
119
|
+
# If both are nil/empty, use empty hash
|
|
120
|
+
context_hash = if context.nil? && kwargs.any?
|
|
121
|
+
kwargs
|
|
122
|
+
elsif context.is_a?(Hash)
|
|
123
|
+
context
|
|
124
|
+
elsif context.nil?
|
|
125
|
+
{}
|
|
126
|
+
else
|
|
127
|
+
raise ArgumentError, "context must be a hash or keyword arguments"
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
if transactional_context_supported?
|
|
131
|
+
# Use PostgreSQL session variables for transaction-aware context
|
|
132
|
+
with_transactional_context(context_hash, &block)
|
|
133
|
+
else
|
|
134
|
+
# Fallback to thread-local context
|
|
135
|
+
with_thread_local_context(context_hash, &block)
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Persistent context (connection-level, persists across operations)
|
|
140
|
+
def self.with_context!(context)
|
|
141
|
+
raise ArgumentError, "context must be a hash" unless context.is_a?(Hash)
|
|
142
|
+
|
|
143
|
+
if store_get(:active_version_in_block)
|
|
144
|
+
raise Error, "with_context! cannot be called from within a with_context block"
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
store_set(:active_version_persistent_context, context)
|
|
148
|
+
nil
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Clear persistent context
|
|
152
|
+
def self.clear_context!
|
|
153
|
+
store_delete(:active_version_persistent_context)
|
|
154
|
+
store_set(:active_version_context_depth, 0)
|
|
155
|
+
store_set(:active_version_in_block, false)
|
|
156
|
+
nil
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def self.store_get(key)
|
|
160
|
+
if config.execution_scope == :thread
|
|
161
|
+
Thread.current.thread_variable_get(key)
|
|
162
|
+
elsif Fiber.current.respond_to?(:[])
|
|
163
|
+
Fiber.current[key]
|
|
164
|
+
else
|
|
165
|
+
Thread.current[key]
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def self.store_set(key, value)
|
|
170
|
+
if config.execution_scope == :thread
|
|
171
|
+
Thread.current.thread_variable_set(key, value)
|
|
172
|
+
elsif Fiber.current.respond_to?(:[]=)
|
|
173
|
+
Fiber.current[key] = value
|
|
174
|
+
else
|
|
175
|
+
Thread.current[key] = value
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def self.store_delete(key)
|
|
180
|
+
store_set(key, nil)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def self.store_keys
|
|
184
|
+
if config.execution_scope == :thread
|
|
185
|
+
Thread.current.thread_variables
|
|
186
|
+
elsif Thread.current.respond_to?(:keys)
|
|
187
|
+
Thread.current.keys
|
|
188
|
+
else
|
|
189
|
+
[]
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def self.clear_scoped_keys!(pattern)
|
|
194
|
+
store_keys.grep(pattern).each { |key| store_delete(key) }
|
|
195
|
+
end
|
|
196
|
+
public_class_method :store_get, :store_set, :store_delete, :clear_scoped_keys!
|
|
197
|
+
|
|
198
|
+
def self.enter_context_block!
|
|
199
|
+
depth = store_get(:active_version_context_depth).to_i + 1
|
|
200
|
+
store_set(:active_version_context_depth, depth)
|
|
201
|
+
store_set(:active_version_in_block, depth.positive?)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def self.leave_context_block!
|
|
205
|
+
depth = store_get(:active_version_context_depth).to_i - 1
|
|
206
|
+
depth = 0 if depth.negative?
|
|
207
|
+
store_set(:active_version_context_depth, depth)
|
|
208
|
+
store_set(:active_version_in_block, depth.positive?)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def self.with_transactional_context(context, &block)
|
|
212
|
+
connection = Runtime.adapter.base_connection
|
|
213
|
+
old_context = self.context.dup
|
|
214
|
+
old_block_context = store_get(:active_version_block_context)
|
|
215
|
+
enter_context_block!
|
|
216
|
+
|
|
217
|
+
# Set PostgreSQL session variable
|
|
218
|
+
if connection.open_transactions.positive?
|
|
219
|
+
encoded_context = connection.quote(ActiveSupport::JSON.encode(context))
|
|
220
|
+
connection.execute("SET LOCAL active_version.context = #{encoded_context}")
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Also update thread-local for immediate access
|
|
224
|
+
self.context = old_context.merge(context)
|
|
225
|
+
store_set(:active_version_block_context, context)
|
|
226
|
+
|
|
227
|
+
yield
|
|
228
|
+
ensure
|
|
229
|
+
# Context is automatically cleared on transaction rollback
|
|
230
|
+
# But we still restore thread-local context
|
|
231
|
+
self.context = old_context
|
|
232
|
+
store_set(:active_version_block_context, old_block_context)
|
|
233
|
+
leave_context_block!
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def self.transactional_context_supported?
|
|
237
|
+
connection = Runtime.adapter.base_connection
|
|
238
|
+
Runtime.supports_transactional_context?(connection)
|
|
239
|
+
rescue *Runtime.active_record_connection_errors
|
|
240
|
+
false
|
|
241
|
+
rescue
|
|
242
|
+
false
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def self.time_parser
|
|
246
|
+
zone = Time.zone if Time.respond_to?(:zone)
|
|
247
|
+
zone || Time
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def self.with_thread_local_context(context, &block)
|
|
251
|
+
old_context = self.context.dup
|
|
252
|
+
old_block_context = store_get(:active_version_block_context)
|
|
253
|
+
enter_context_block!
|
|
254
|
+
self.context = old_context.merge(context)
|
|
255
|
+
store_set(:active_version_block_context, context)
|
|
256
|
+
yield
|
|
257
|
+
ensure
|
|
258
|
+
self.context = old_context
|
|
259
|
+
store_set(:active_version_block_context, old_block_context)
|
|
260
|
+
leave_context_block!
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# Disable versioning globally
|
|
264
|
+
def self.without_auditing
|
|
265
|
+
auditing_was_enabled = auditing_enabled
|
|
266
|
+
disable_auditing
|
|
267
|
+
yield
|
|
268
|
+
ensure
|
|
269
|
+
enable_auditing if auditing_was_enabled
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def self.disable_auditing
|
|
273
|
+
self.auditing_enabled = false
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def self.enable_auditing
|
|
277
|
+
self.auditing_enabled = true
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# Version registry access
|
|
281
|
+
def self.registry
|
|
282
|
+
@registry ||= VersionRegistry.new
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# Column mapper access
|
|
286
|
+
def self.column_mapper
|
|
287
|
+
@column_mapper ||= ColumnMapper.new
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# Parse time from various formats
|
|
291
|
+
# Converts Numeric (Unix timestamp), String, Date, Time, or other objects to Time
|
|
292
|
+
# @param time [Numeric, String, Date, Time, Object] Time value in various formats
|
|
293
|
+
# @return [Time] Time object
|
|
294
|
+
def self.parse_time(time)
|
|
295
|
+
parser = time_parser
|
|
296
|
+
case time
|
|
297
|
+
when Numeric then parser.at(time)
|
|
298
|
+
when String then parser.parse(time)
|
|
299
|
+
when Date then time.to_time
|
|
300
|
+
when Time then time
|
|
301
|
+
else parser.parse(time.to_s)
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# Parse time and return Time object (alias for clarity)
|
|
306
|
+
def self.parse_time_to_time(time)
|
|
307
|
+
parse_time(time)
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# Connection access is intentionally application-owned.
|
|
311
|
+
# ActiveVersion does not route between shards/connections.
|
|
312
|
+
# These methods remain as pass-through helpers.
|
|
313
|
+
def self.connection_for(model_class, version_type)
|
|
314
|
+
:default
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def self.adapter_for(model_class, version_type)
|
|
318
|
+
Runtime.adapter.connection_for(model_class, version_type)
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def self.with_connection(model_class, version_type, &block)
|
|
322
|
+
yield(Runtime.adapter.connection_for(model_class, version_type))
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
# Load Rails integration if available
|
|
327
|
+
if defined?(Rails)
|
|
328
|
+
require "active_version/railtie"
|
|
329
|
+
end
|