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,287 @@
|
|
|
1
|
+
require "active_version/revisions/sql_builder"
|
|
2
|
+
|
|
3
|
+
module ActiveVersion
|
|
4
|
+
module Revisions
|
|
5
|
+
# Marker module for revision models
|
|
6
|
+
# Identifies a model as a revision record
|
|
7
|
+
module RevisionRecord
|
|
8
|
+
extend ActiveSupport::Concern
|
|
9
|
+
include SQLBuilder
|
|
10
|
+
|
|
11
|
+
included do
|
|
12
|
+
class_attribute :active_version_revision_schema, instance_writer: false, default: {}
|
|
13
|
+
|
|
14
|
+
# Mark this as a revision record
|
|
15
|
+
def self.revision_record?
|
|
16
|
+
true
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Get source model name (e.g., "Post" from "PostRevision")
|
|
20
|
+
def self.source_name
|
|
21
|
+
return @source_name if @source_name
|
|
22
|
+
return nil unless name
|
|
23
|
+
@source_name = name.underscore.gsub("_revision", "").to_sym
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Get source model class (lazy)
|
|
27
|
+
def self.source_class
|
|
28
|
+
@source_class ||= begin
|
|
29
|
+
klass = source_name.to_s.classify.safe_constantize
|
|
30
|
+
klass || raise(NameError, "Could not find source class #{source_name.to_s.classify}")
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Get foreign key name(s) (e.g., "post_id" or ["tenant_id", "post_id"])
|
|
35
|
+
def self.source_foreign_key
|
|
36
|
+
schema_foreign_key = (active_version_revision_schema || {})[:foreign_key]
|
|
37
|
+
if schema_foreign_key.present?
|
|
38
|
+
return schema_foreign_key.is_a?(Array) ? schema_foreign_key.map(&:to_s) : schema_foreign_key.to_s
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
model_name = source_name.to_s.classify
|
|
42
|
+
options = ActiveVersion.registry.config_for_model_name(model_name, :revisions) || {}
|
|
43
|
+
options = ActiveVersion.registry.config_for(source_class, :revisions) || {} if options.empty?
|
|
44
|
+
foreign_key = options[:foreign_key].presence || "#{source_name}_id"
|
|
45
|
+
foreign_key.is_a?(Array) ? foreign_key.map(&:to_s) : foreign_key.to_s
|
|
46
|
+
rescue NameError
|
|
47
|
+
"#{source_name}_id"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def self.source_primary_key
|
|
51
|
+
schema_identity_resolver = (active_version_revision_schema || {})[:identity_resolver]
|
|
52
|
+
if schema_identity_resolver.present?
|
|
53
|
+
return schema_identity_resolver.map(&:to_s) if schema_identity_resolver.is_a?(Array)
|
|
54
|
+
return schema_identity_resolver.to_s if schema_identity_resolver.is_a?(Symbol)
|
|
55
|
+
return schema_identity_resolver if schema_identity_resolver.is_a?(String) && schema_identity_resolver.present?
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
model_name = source_name.to_s.classify
|
|
59
|
+
options = ActiveVersion.registry.config_for_model_name(model_name, :revisions) || {}
|
|
60
|
+
options = ActiveVersion.registry.config_for(source_class, :revisions) || {} if options.empty?
|
|
61
|
+
|
|
62
|
+
resolver = options[:identity_resolver]
|
|
63
|
+
return resolver.map(&:to_s) if resolver.is_a?(Array)
|
|
64
|
+
return resolver.to_s if resolver.is_a?(Symbol)
|
|
65
|
+
return resolver if resolver.is_a?(String) && resolver.present?
|
|
66
|
+
|
|
67
|
+
nil
|
|
68
|
+
rescue NameError
|
|
69
|
+
nil
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def self.revision_column_for(concept)
|
|
73
|
+
schema = active_version_revision_schema || {}
|
|
74
|
+
schema_key = :"#{concept}_column"
|
|
75
|
+
return schema[schema_key].to_sym if schema[schema_key].present?
|
|
76
|
+
|
|
77
|
+
case concept
|
|
78
|
+
when :version
|
|
79
|
+
ActiveVersion.config.revision_version_column
|
|
80
|
+
end
|
|
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: :revisions,
|
|
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
|
+
version_column = revision_column_for(:version)
|
|
101
|
+
unless column_names.include?(version_column.to_s)
|
|
102
|
+
fallback_column = column_names.find { |name| name.end_with?("version") }
|
|
103
|
+
version_column = fallback_column.to_sym if fallback_column
|
|
104
|
+
end
|
|
105
|
+
validates version_column, presence: true, uniqueness: {scope: Array(source_foreign_key)} if version_column
|
|
106
|
+
rescue NameError, ActiveRecord::ConnectionNotDefined
|
|
107
|
+
# Source class not yet defined, will be set up later
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Instrumentation
|
|
112
|
+
after_create :instrument_revision_created
|
|
113
|
+
|
|
114
|
+
# Readonly enforcement - revisions are readonly once persisted
|
|
115
|
+
# Use a flag to allow callbacks to raise custom errors instead of ActiveRecord::ReadOnlyRecord.
|
|
116
|
+
attr_accessor :_allow_update_for_readonly_check
|
|
117
|
+
|
|
118
|
+
def readonly?
|
|
119
|
+
return false if _allow_update_for_readonly_check
|
|
120
|
+
persisted?
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
before_update :raise_readonly_error, if: :persisted?
|
|
124
|
+
before_destroy :raise_readonly_error, if: :persisted?
|
|
125
|
+
|
|
126
|
+
# Override save methods to set flag before ActiveRecord checks readonly?
|
|
127
|
+
def save(*args, **kwargs, &block)
|
|
128
|
+
self._allow_update_for_readonly_check = true if persisted?
|
|
129
|
+
if kwargs.empty? && args.length == 1 && args[0].is_a?(Hash)
|
|
130
|
+
kwargs = args.pop
|
|
131
|
+
end
|
|
132
|
+
if kwargs.any?
|
|
133
|
+
super(**kwargs, &block)
|
|
134
|
+
else
|
|
135
|
+
super(*args, &block)
|
|
136
|
+
end
|
|
137
|
+
ensure
|
|
138
|
+
self._allow_update_for_readonly_check = false
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def save!(*args, **kwargs, &block)
|
|
142
|
+
self._allow_update_for_readonly_check = true if persisted?
|
|
143
|
+
if kwargs.empty? && args.length == 1 && args[0].is_a?(Hash)
|
|
144
|
+
kwargs = args.pop
|
|
145
|
+
end
|
|
146
|
+
if kwargs.any?
|
|
147
|
+
super(**kwargs, &block)
|
|
148
|
+
else
|
|
149
|
+
super(*args, &block)
|
|
150
|
+
end
|
|
151
|
+
ensure
|
|
152
|
+
self._allow_update_for_readonly_check = false
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def destroy(*args, **kwargs, &block)
|
|
156
|
+
self._allow_update_for_readonly_check = true if persisted?
|
|
157
|
+
if kwargs.any?
|
|
158
|
+
super(**kwargs, &block)
|
|
159
|
+
else
|
|
160
|
+
super(*args, &block)
|
|
161
|
+
end
|
|
162
|
+
ensure
|
|
163
|
+
self._allow_update_for_readonly_check = false
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Rollback handling
|
|
167
|
+
after_rollback :clear_rolled_back_revisions
|
|
168
|
+
|
|
169
|
+
# Scopes
|
|
170
|
+
scope :latest, -> {
|
|
171
|
+
version_column = ActiveVersion.column_mapper.column_for(source_class, :revisions, :version)
|
|
172
|
+
order(version_column => :desc).limit(1)
|
|
173
|
+
}
|
|
174
|
+
scope :oldest, -> {
|
|
175
|
+
version_column = ActiveVersion.column_mapper.column_for(source_class, :revisions, :version)
|
|
176
|
+
order(version_column => :asc).limit(1)
|
|
177
|
+
}
|
|
178
|
+
scope :at_version, ->(version) {
|
|
179
|
+
version_column = ActiveVersion.column_mapper.column_for(source_class, :revisions, :version)
|
|
180
|
+
where(version_column => version)
|
|
181
|
+
}
|
|
182
|
+
scope :ascending, -> {
|
|
183
|
+
version_column = ActiveVersion.column_mapper.column_for(source_class, :revisions, :version)
|
|
184
|
+
order(version_column => :asc)
|
|
185
|
+
}
|
|
186
|
+
scope :descending, -> {
|
|
187
|
+
version_column = ActiveVersion.column_mapper.column_for(source_class, :revisions, :version)
|
|
188
|
+
order(version_column => :desc)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
# Setup associations when class is loaded (only if name is available)
|
|
192
|
+
setup_associations if name
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
class RevisionSchemaDSL
|
|
196
|
+
def initialize(klass)
|
|
197
|
+
@klass = klass
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def version_column(value)
|
|
201
|
+
@klass.revision_version_column(value)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def foreign_key(value)
|
|
205
|
+
@klass.revision_foreign_key(value)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def identity_resolver(value)
|
|
209
|
+
@klass.revision_identity_resolver(value)
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
class_methods do
|
|
214
|
+
def configure_revision(**options, &block)
|
|
215
|
+
apply_revision_configuration(**options)
|
|
216
|
+
RevisionSchemaDSL.new(self).instance_eval(&block) if block_given?
|
|
217
|
+
active_version_revision_schema
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def revision_version_column(value = nil) = schema_option(:version_column, value, cast: :symbol)
|
|
221
|
+
def revision_foreign_key(value = nil) = schema_option(:foreign_key, value, cast: :identity)
|
|
222
|
+
def revision_identity_resolver(value = nil) = schema_option(:identity_resolver, value, cast: :resolver)
|
|
223
|
+
|
|
224
|
+
def apply_revision_configuration(version_column: nil, foreign_key: nil, identity_resolver: nil)
|
|
225
|
+
revision_version_column(version_column) if version_column
|
|
226
|
+
revision_foreign_key(foreign_key) if foreign_key
|
|
227
|
+
revision_identity_resolver(identity_resolver) if identity_resolver
|
|
228
|
+
active_version_revision_schema
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
private
|
|
232
|
+
|
|
233
|
+
def schema_option(key, value, cast:)
|
|
234
|
+
schema = (active_version_revision_schema || {}).dup
|
|
235
|
+
return schema[key] if value.nil?
|
|
236
|
+
|
|
237
|
+
schema[key] = case cast
|
|
238
|
+
when :symbol
|
|
239
|
+
value.to_sym
|
|
240
|
+
when :identity
|
|
241
|
+
value.is_a?(Array) ? value.map(&:to_s) : value.to_s
|
|
242
|
+
when :resolver
|
|
243
|
+
if value.is_a?(Array)
|
|
244
|
+
value.map(&:to_s)
|
|
245
|
+
elsif value.is_a?(Symbol)
|
|
246
|
+
value.to_s
|
|
247
|
+
else
|
|
248
|
+
value
|
|
249
|
+
end
|
|
250
|
+
else
|
|
251
|
+
value
|
|
252
|
+
end
|
|
253
|
+
self.active_version_revision_schema = schema
|
|
254
|
+
schema[key]
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Get source record
|
|
259
|
+
def source
|
|
260
|
+
send(self.class.source_name)
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def attributes
|
|
264
|
+
attrs = super
|
|
265
|
+
filter = instance_variable_get(:@active_version_attributes_filter)
|
|
266
|
+
return attrs unless filter
|
|
267
|
+
attrs.slice(*filter)
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
private
|
|
271
|
+
|
|
272
|
+
def instrument_revision_created
|
|
273
|
+
ActiveVersion::Instrumentation.instrument_revision_created(self, source)
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def raise_readonly_error
|
|
277
|
+
raise ActiveVersion::ReadonlyVersionError,
|
|
278
|
+
"#{self.class.name} records are readonly once persisted"
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def clear_rolled_back_revisions
|
|
282
|
+
# Clear association cache if this revision was rolled back
|
|
283
|
+
source&.revisions&.reset
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
end
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
module ActiveVersion
|
|
2
|
+
module Revisions
|
|
3
|
+
# SQL builder for batch revision 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 revisions
|
|
29
|
+
# @param records [Array] Array of ActiveRecord instances
|
|
30
|
+
# @param options [Hash] Options for batch generation
|
|
31
|
+
# @option options [Integer] :version Version number to use
|
|
32
|
+
# @option options [Boolean] :combine (true) Combine into single INSERT statement
|
|
33
|
+
# @option options [Boolean] :force (false) Include non-dirty records
|
|
34
|
+
# @option options [Boolean] :allow_saved (false) Alias for force semantics
|
|
35
|
+
# @return [String] SQL statement(s) for batch insert
|
|
36
|
+
def batch_insert_sql(records = nil, options = {}, &block)
|
|
37
|
+
records, options = normalize_batch_arguments(records, options)
|
|
38
|
+
captured_values = []
|
|
39
|
+
block_consumed = false
|
|
40
|
+
|
|
41
|
+
if block_given? && Array(records).flatten.compact.empty?
|
|
42
|
+
collected = []
|
|
43
|
+
captured_values = capture_revision_values(options) do
|
|
44
|
+
if block.arity == 1
|
|
45
|
+
yield(BatchCollector.new(collected))
|
|
46
|
+
else
|
|
47
|
+
yield
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
records = collected
|
|
51
|
+
block_consumed = true
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
if Array(records).flatten.compact.empty? && captured_values.any?
|
|
55
|
+
revision_class = self
|
|
56
|
+
version_column = revision_class.revision_column_for(:version)
|
|
57
|
+
conflict_target = Array(revision_class.source_foreign_key) + [version_column]
|
|
58
|
+
|
|
59
|
+
if options[:combine] != false
|
|
60
|
+
return build_combined_insert_sql(
|
|
61
|
+
revision_class,
|
|
62
|
+
captured_values,
|
|
63
|
+
upsert: options[:upsert] == true,
|
|
64
|
+
conflict_target: conflict_target
|
|
65
|
+
)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
return captured_values.map do |values|
|
|
69
|
+
build_single_insert_sql(
|
|
70
|
+
revision_class,
|
|
71
|
+
values,
|
|
72
|
+
upsert: options[:upsert] == true,
|
|
73
|
+
conflict_target: conflict_target
|
|
74
|
+
)
|
|
75
|
+
end.join(";\n")
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
records = if block_consumed
|
|
79
|
+
Array(records).flatten.compact
|
|
80
|
+
else
|
|
81
|
+
resolve_batch_records(records, &block)
|
|
82
|
+
end
|
|
83
|
+
return "" if records.empty?
|
|
84
|
+
|
|
85
|
+
revision_class = records.first.class.revision_class
|
|
86
|
+
return "" unless revision_class
|
|
87
|
+
|
|
88
|
+
version = options[:version] || 1
|
|
89
|
+
upsert = options[:upsert] == true
|
|
90
|
+
foreign_keys = Array(revision_class.source_foreign_key)
|
|
91
|
+
version_column = ActiveVersion.column_mapper.column_for(records.first.class, :revisions, :version)
|
|
92
|
+
|
|
93
|
+
# Build values for each record
|
|
94
|
+
values_list = records.map do |record|
|
|
95
|
+
build_batch_revision_values(record, revision_class, foreign_keys, version_column, version, options)
|
|
96
|
+
end.compact
|
|
97
|
+
|
|
98
|
+
return "" if values_list.empty?
|
|
99
|
+
|
|
100
|
+
# Combine into single INSERT with multiple VALUES
|
|
101
|
+
if options[:combine] != false
|
|
102
|
+
build_combined_insert_sql(
|
|
103
|
+
revision_class,
|
|
104
|
+
values_list,
|
|
105
|
+
upsert: upsert,
|
|
106
|
+
conflict_target: foreign_keys + [version_column]
|
|
107
|
+
)
|
|
108
|
+
else
|
|
109
|
+
# Return separate INSERT statements
|
|
110
|
+
values_list.map do |values|
|
|
111
|
+
build_single_insert_sql(
|
|
112
|
+
revision_class,
|
|
113
|
+
values,
|
|
114
|
+
upsert: upsert,
|
|
115
|
+
conflict_target: foreign_keys + [version_column]
|
|
116
|
+
)
|
|
117
|
+
end.join(";\n")
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Execute batch insert SQL for revisions.
|
|
122
|
+
# Supports the same arguments and block semantics as batch_insert_sql.
|
|
123
|
+
# @return [Integer] 0 when nothing to insert, otherwise adapter execute result
|
|
124
|
+
def batch_insert(records = nil, options = {}, &block)
|
|
125
|
+
sql = batch_insert_sql(records, options, &block)
|
|
126
|
+
return 0 if sql.empty?
|
|
127
|
+
|
|
128
|
+
connection.execute(sql)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Generate SQL for batch upsert of revisions.
|
|
132
|
+
# Uses (source_foreign_key, version_column) conflict target.
|
|
133
|
+
def batch_upsert_sql(records, options = {})
|
|
134
|
+
batch_insert_sql(records, options.merge(upsert: true))
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
private
|
|
138
|
+
|
|
139
|
+
def normalize_batch_arguments(records, options)
|
|
140
|
+
if records.is_a?(Hash) && options.empty?
|
|
141
|
+
[nil, records]
|
|
142
|
+
else
|
|
143
|
+
[records, options]
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def resolve_batch_records(records, &block)
|
|
148
|
+
return Array(records).flatten.compact unless block_given?
|
|
149
|
+
|
|
150
|
+
collected = []
|
|
151
|
+
if block.arity == 1
|
|
152
|
+
yield(BatchCollector.new(collected))
|
|
153
|
+
else
|
|
154
|
+
yield
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
Array(collected).flatten.compact
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def capture_revision_values(options = {})
|
|
161
|
+
previous_state = ActiveVersion.store_get(:active_version_revision_batch_state)
|
|
162
|
+
state = {
|
|
163
|
+
target_revision_class: self,
|
|
164
|
+
options: options,
|
|
165
|
+
values: [],
|
|
166
|
+
version_tracker: {}
|
|
167
|
+
}
|
|
168
|
+
ActiveVersion.store_set(:active_version_revision_batch_state, state)
|
|
169
|
+
yield
|
|
170
|
+
state[:values]
|
|
171
|
+
ensure
|
|
172
|
+
ActiveVersion.store_set(:active_version_revision_batch_state, previous_state)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def build_batch_revision_values(record, revision_class, foreign_keys, version_column, version, options = {})
|
|
176
|
+
return nil unless record.changed? || options[:force] || options[:allow_saved]
|
|
177
|
+
|
|
178
|
+
# Get all attributes except metadata
|
|
179
|
+
attrs = record.attributes.except("id", "created_at", "updated_at")
|
|
180
|
+
identity_map = if record.respond_to?(:active_version_revision_identity_map)
|
|
181
|
+
record.active_version_revision_identity_map
|
|
182
|
+
else
|
|
183
|
+
keys = Array(foreign_keys).map(&:to_s)
|
|
184
|
+
if keys.length == 1
|
|
185
|
+
{keys.first => record.id}
|
|
186
|
+
else
|
|
187
|
+
values = Array(record.class.primary_key).map { |column| record[column] }
|
|
188
|
+
keys.zip(values).to_h
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
attrs.merge!(identity_map)
|
|
192
|
+
attrs[version_column] = version
|
|
193
|
+
attrs[:created_at] = Time.current
|
|
194
|
+
attrs[:updated_at] = Time.current
|
|
195
|
+
|
|
196
|
+
attrs
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def build_combined_insert_sql(revision_class, values_list, upsert: false, conflict_target: [])
|
|
200
|
+
return "" if values_list.empty?
|
|
201
|
+
|
|
202
|
+
# Get all columns from all values
|
|
203
|
+
all_columns = values_list.flat_map(&:keys).uniq
|
|
204
|
+
connection = revision_class.connection
|
|
205
|
+
table_name = connection.quote_table_name(revision_class.table_name)
|
|
206
|
+
column_list = all_columns.map { |col| connection.quote_column_name(col) }.join(", ")
|
|
207
|
+
|
|
208
|
+
values_sql = values_list.map do |values|
|
|
209
|
+
row_values = all_columns.map { |col| connection.quote(prepare_sql_value(values[col])) }.join(", ")
|
|
210
|
+
"(#{row_values})"
|
|
211
|
+
end.join(", ")
|
|
212
|
+
|
|
213
|
+
sql = "INSERT INTO #{table_name} (#{column_list}) VALUES #{values_sql}"
|
|
214
|
+
return sql unless upsert
|
|
215
|
+
|
|
216
|
+
updatable_columns = all_columns.map(&:to_s) - conflict_target.map(&:to_s) - ["id", "created_at"]
|
|
217
|
+
if updatable_columns.empty?
|
|
218
|
+
"#{sql} ON CONFLICT (#{conflict_target.map { |col| connection.quote_column_name(col) }.join(", ")}) DO NOTHING"
|
|
219
|
+
else
|
|
220
|
+
assignments = updatable_columns.map do |col|
|
|
221
|
+
qcol = connection.quote_column_name(col)
|
|
222
|
+
"#{qcol} = EXCLUDED.#{qcol}"
|
|
223
|
+
end.join(", ")
|
|
224
|
+
"#{sql} ON CONFLICT (#{conflict_target.map { |col| connection.quote_column_name(col) }.join(", ")}) DO UPDATE SET #{assignments}"
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def build_single_insert_sql(revision_class, values, upsert: false, conflict_target: [])
|
|
229
|
+
stmt = Arel::InsertManager.new
|
|
230
|
+
table = Arel::Table.new(revision_class.table_name)
|
|
231
|
+
stmt.into(table)
|
|
232
|
+
|
|
233
|
+
values.keys.each { |key| stmt.columns << table[key] }
|
|
234
|
+
stmt.values = stmt.create_values(values.values.map { |v| prepare_sql_value(v) })
|
|
235
|
+
sql = stmt.to_sql
|
|
236
|
+
return sql unless upsert
|
|
237
|
+
|
|
238
|
+
connection = revision_class.connection
|
|
239
|
+
updatable_columns = values.keys.map(&:to_s) - conflict_target.map(&:to_s) - ["id", "created_at"]
|
|
240
|
+
if updatable_columns.empty?
|
|
241
|
+
"#{sql} ON CONFLICT (#{conflict_target.map { |col| connection.quote_column_name(col) }.join(", ")}) DO NOTHING"
|
|
242
|
+
else
|
|
243
|
+
assignments = updatable_columns.map do |col|
|
|
244
|
+
qcol = connection.quote_column_name(col)
|
|
245
|
+
"#{qcol} = EXCLUDED.#{qcol}"
|
|
246
|
+
end.join(", ")
|
|
247
|
+
"#{sql} ON CONFLICT (#{conflict_target.map { |col| connection.quote_column_name(col) }.join(", ")}) DO UPDATE SET #{assignments}"
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def prepare_sql_value(value)
|
|
252
|
+
case value
|
|
253
|
+
when Hash, Array
|
|
254
|
+
value.to_json
|
|
255
|
+
when Time, DateTime
|
|
256
|
+
value.utc
|
|
257
|
+
when Date
|
|
258
|
+
value.to_time.utc
|
|
259
|
+
else
|
|
260
|
+
value
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
require "active_version/revisions/revision_record"
|
|
2
|
+
require "active_version/revisions/has_revisions"
|
|
3
|
+
require "active_version/revisions/sql_builder"
|
|
4
|
+
|
|
5
|
+
module ActiveVersion
|
|
6
|
+
module Revisions
|
|
7
|
+
# Revisions module for ActiveVersion
|
|
8
|
+
# Provides version-based snapshot functionality
|
|
9
|
+
end
|
|
10
|
+
end
|