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