active_version 2.1.0 → 2.1.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 89fbcf4b5aebb3bd2fcbbedfafb3f6575edd6f22571080ce310005f3e6f27a28
4
- data.tar.gz: 4a90af0cfcee066886ce989f11b8347303db945036ea57cc0a3f6d7b00bcfeee
3
+ metadata.gz: c3b099b1522fb5b1d087683cf7003c5f1156f7f0da8703691f89075b347183d5
4
+ data.tar.gz: 0d49734313021a4ef263a807361fbf1b5b1f5d564b693162ddfe0d17b2439768
5
5
  SHA512:
6
- metadata.gz: 965f5ffcea61f3d8024d9ba0aaa7a0e3c514b1ce7b48a6f01f89bec05469ab481e82d894e3527a6b35c5f0830fa2cbf8b1947a7936b96e187ad31a228848d333
7
- data.tar.gz: d8a5bc8d85a017998917086917d3a88c731cdc9bff9cab6ccf4cfe89d78c53d7b2bcd192ee31b4570a8fcbc532c67287e3399ebf89dd172806acd440fb1283b9
6
+ metadata.gz: 27d139f5b16f533cdf00e11ab4ce7f9b12830574eb4e1cd49c900ed7885447a22c6ea2507cdacdcbcdd1d32577bed4e369a950c8c3a588bb35584157290bfbde
7
+ data.tar.gz: 87808c1b4380abaf00d72f223a27d0010fc178c6433fa52acfe356c00a9461b7e870c31320b102faaddf20d0f37cbccd8368f7c3516117bea48c89cdb45906d8
data/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # CHANGELOG
2
2
 
3
+ ## 2.1.1 (2026-11-07)
4
+
5
+ - Use a single shallow `dup` of the merged global context when audit instance context is empty, instead of `dup.merge({})`.
6
+ - Encode audit/revision SQL and serializer JSON through `ActiveVersion.json_encode` (`JSON.generate`); use the same encoder in `with_transactional_context`.
7
+ - Cache `Query` `column_mapper` lookups per `(model, kind, field)`; share identity-map helpers; return the model class `none` when the version class is missing.
8
+ - Memoize `ActiveSupport::Notifications.listening?` per instrumentation event name (reset in specs).
9
+ - Merge audit batch context with `merge!` when the existing context is already a Hash; skip `stringify_keys` when keys are already strings.
10
+ - Remove `AV_PRESSURE_BUILD` / `pressure_build_phase_enabled?` from lib; drop the pressure-cooker harness and its episode docs.
11
+ - Maintainer load harness: `benchmarks/load.rb` and `benchmarks/pressure_probe.rb` with `make bench-load` / `make bench-fiber`.
12
+
3
13
  ## 2.1.0 (2026-10-29)
4
14
 
5
15
  - Record PostgreSQL audit updates as changed-field pairs and support quoted table and column names.
@@ -26,7 +26,7 @@ module ActiveVersion
26
26
  def dump(value)
27
27
  return value unless value.is_a?(Hash) || value.is_a?(Array)
28
28
 
29
- JSON.generate(value)
29
+ ActiveVersion.json_encode(value)
30
30
  end
31
31
  end
32
32
 
@@ -175,9 +175,11 @@ module ActiveVersion
175
175
  conn = audit_class.connection
176
176
  table_name = audit_class.table_name
177
177
  updates = []
178
- updates << "#{conn.quote_column_name(changes_column)} = #{conn.quote(JSON.generate(combined_changes))}"
178
+ enc_changes = ActiveVersion.json_encode(combined_changes)
179
+ updates << "#{conn.quote_column_name(changes_column)} = #{conn.quote(enc_changes)}"
179
180
  if combined_context.any?
180
- updates << "#{conn.quote_column_name(context_column)} = #{conn.quote(JSON.generate(combined_context))}"
181
+ enc_ctx = ActiveVersion.json_encode(combined_context)
182
+ updates << "#{conn.quote_column_name(context_column)} = #{conn.quote(enc_ctx)}"
181
183
  end
182
184
  target_id = conn.quote(combine_target.read_attribute(:id))
183
185
  sql = "UPDATE #{conn.quote_table_name(table_name)} SET #{updates.join(", ")} WHERE id = #{target_id}"
@@ -195,7 +197,7 @@ module ActiveVersion
195
197
  # Use raw SQL with proper escaping to bypass ActiveRecord's readonly checks
196
198
  conn = audit_class.connection
197
199
  table_name = audit_class.table_name
198
- empty_json_str = JSON.generate({})
200
+ empty_json_str = ActiveVersion.json_encode({})
199
201
  empty_json = conn.quote(empty_json_str)
200
202
  quoted_comment = conn.quote(combined_comment)
201
203
 
@@ -41,8 +41,13 @@ module ActiveVersion
41
41
  else
42
42
  {}
43
43
  end
44
- # Merge: global context first, then instance context (instance takes precedence)
45
- captured_context = global_context.dup.merge(instance_context)
44
+ # Merge: global context first, then instance context (instance takes precedence).
45
+ # `dup.merge({})` allocates twice; shallow `dup` alone matches semantics when instance is empty.
46
+ captured_context = if instance_context.empty?
47
+ global_context.dup
48
+ else
49
+ global_context.dup.merge!(instance_context)
50
+ end
46
51
 
47
52
  self.audit_comment = nil
48
53
  self.audit_context = nil
@@ -102,8 +107,16 @@ module ActiveVersion
102
107
  batch_context = batch_state.dig(:options, :context)
103
108
  if batch_context.is_a?(Hash)
104
109
  merged_context = insert_attrs[context_column]
105
- merged_context = {} unless merged_context.is_a?(Hash)
106
- insert_attrs[context_column] = merged_context.merge(batch_context.stringify_keys)
110
+ batch_keys = if batch_context.keys.all? { |k| k.is_a?(String) }
111
+ batch_context
112
+ else
113
+ batch_context.stringify_keys
114
+ end
115
+ if merged_context.is_a?(Hash)
116
+ merged_context.merge!(batch_keys)
117
+ else
118
+ insert_attrs[context_column] = batch_keys
119
+ end
107
120
  end
108
121
  end
109
122
 
@@ -107,7 +107,9 @@ module ActiveVersion
107
107
  h = changes.each_with_object({}) do |(k, v), acc|
108
108
  acc[k] = v.last if v.is_a?(Array)
109
109
  acc[k] = v unless v.is_a?(Array)
110
- acc[k] = JSON.generate(acc[k]) if acc[k].is_a?(Hash) || acc[k].is_a?(Array)
110
+ if acc[k].is_a?(Hash) || acc[k].is_a?(Array)
111
+ acc[k] = ActiveVersion.json_encode(acc[k])
112
+ end
111
113
  end
112
114
  # Arel INSERT uses keys as column names; stringify so :action / mirror :attrs match
113
115
  # string keys from column_mapper and so symbol + string for the same column cannot
@@ -250,7 +250,7 @@ module ActiveVersion
250
250
  def prepare_sql_value(value)
251
251
  case value
252
252
  when Hash, Array
253
- JSON.generate(value)
253
+ ActiveVersion.json_encode(value)
254
254
  when Time, DateTime
255
255
  value.utc
256
256
  when Date
@@ -16,6 +16,11 @@ module ActiveVersion
16
16
  }.freeze
17
17
 
18
18
  class << self
19
+ # Clear memoized `listening?` probes (tests stub the notifier between examples).
20
+ def reset_listening_cache!
21
+ @listening_cache = nil
22
+ end
23
+
19
24
  # Instrument a translation creation
20
25
  def instrument_translation_created(translation, source)
21
26
  instrument_event(:translation_created) do
@@ -201,7 +206,10 @@ module ActiveVersion
201
206
  end
202
207
 
203
208
  def listening?(event_name)
204
- ActiveSupport::Notifications.notifier.listening?(event_name)
209
+ @listening_cache ||= {}
210
+ return @listening_cache[event_name] if @listening_cache.key?(event_name)
211
+
212
+ @listening_cache[event_name] = ActiveSupport::Notifications.notifier.listening?(event_name)
205
213
  rescue => e
206
214
  log_debug("listener probe failed for #{event_name}: #{e.class}: #{e.message}; assuming listeners may exist")
207
215
  true
@@ -2,6 +2,12 @@ module ActiveVersion
2
2
  # Unified query builder for version records
3
3
  module Query
4
4
  class << self
5
+ def column_for_cached(record_class, kind, field)
6
+ @column_for_query ||= {}
7
+ key = [record_class.name, kind, field]
8
+ @column_for_query[key] ||= ActiveVersion.column_mapper.column_for(record_class, kind, field)
9
+ end
10
+
5
11
  # Query audits for a record
6
12
  # @param record [ActiveRecord::Base] The record to query audits for
7
13
  # @param opts [Hash] Query options
@@ -10,18 +16,13 @@ module ActiveVersion
10
16
  # @return [ActiveRecord::Relation] Audit relation
11
17
  def audits(record, opts = {})
12
18
  audit_class = record.class.audit_class
13
- return audit_class.none unless audit_class
19
+ return record.class.none unless audit_class
14
20
 
15
- auditable_column = ActiveVersion.column_mapper.column_for(record.class, :audits, :auditable)
21
+ auditable_column = column_for_cached(record.class, :audits, :auditable)
16
22
  identity_map = if record.respond_to?(:active_version_audit_identity_map)
17
23
  record.active_version_audit_identity_map
18
24
  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
+ identity_map_for_auditable_column(record, auditable_column)
25
26
  end
26
27
 
27
28
  query = audit_class.where({"#{auditable_column}_type" => record.class.name}.merge(identity_map))
@@ -39,23 +40,18 @@ module ActiveVersion
39
40
  # @return [ActiveRecord::Relation] Translation relation
40
41
  def translations(record, opts = {})
41
42
  translation_class = record.class.translation_class
42
- return translation_class.none unless translation_class
43
+ return record.class.none unless translation_class
43
44
 
44
45
  identity_map = if record.respond_to?(:active_version_translation_identity_map)
45
46
  record.active_version_translation_identity_map
46
47
  else
47
48
  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
49
+ identity_map_for_foreign_keys(record, keys)
54
50
  end
55
51
  query = translation_class.where(identity_map)
56
52
 
57
53
  if opts[:locale]
58
- locale_column = ActiveVersion.column_mapper.column_for(record.class, :translations, :locale)
54
+ locale_column = column_for_cached(record.class, :translations, :locale)
59
55
  query = query.where(locale_column => opts[:locale])
60
56
  end
61
57
 
@@ -69,29 +65,44 @@ module ActiveVersion
69
65
  # @return [ActiveRecord::Relation] Revision relation
70
66
  def revisions(record, opts = {})
71
67
  revision_class = record.class.revision_class
72
- return revision_class.none unless revision_class
68
+ return record.class.none unless revision_class
73
69
 
74
70
  identity_map = if record.respond_to?(:active_version_revision_identity_map)
75
71
  record.active_version_revision_identity_map
76
72
  else
77
73
  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
74
+ identity_map_for_foreign_keys(record, keys)
84
75
  end
85
76
  query = revision_class.where(identity_map)
86
77
 
87
78
  if opts[:version]
88
- version_column = ActiveVersion.column_mapper.column_for(record.class, :revisions, :version)
79
+ version_column = column_for_cached(record.class, :revisions, :version)
89
80
  query = query.where(version_column => opts[:version])
90
81
  end
91
82
 
92
- version_column = ActiveVersion.column_mapper.column_for(record.class, :revisions, :version)
83
+ version_column = column_for_cached(record.class, :revisions, :version)
93
84
  query.order(version_column => :asc)
94
85
  end
86
+
87
+ private
88
+
89
+ def identity_map_for_foreign_keys(record, foreign_keys)
90
+ if foreign_keys.one?
91
+ {foreign_keys.first => record.id}
92
+ else
93
+ values = Array(record.class.primary_key).map { |column| record[column] }
94
+ foreign_keys.zip(values).to_h
95
+ end
96
+ end
97
+
98
+ def identity_map_for_auditable_column(record, auditable_column)
99
+ primary_keys = Array(record.class.primary_key).map(&:to_s)
100
+ if primary_keys.one?
101
+ {"#{auditable_column}_id" => record.id}
102
+ else
103
+ primary_keys.zip(primary_keys.map { |column| record[column] }).to_h
104
+ end
105
+ end
95
106
  end
96
107
  end
97
108
  end
@@ -334,7 +334,7 @@ module ActiveVersion
334
334
  def revision_sql_value(value)
335
335
  case value
336
336
  when Hash, Array
337
- JSON.generate(value)
337
+ ActiveVersion.json_encode(value)
338
338
  when Time, DateTime
339
339
  value.utc
340
340
  when Date
@@ -253,7 +253,7 @@ module ActiveVersion
253
253
  def prepare_sql_value(value)
254
254
  case value
255
255
  when Hash, Array
256
- JSON.generate(value)
256
+ ActiveVersion.json_encode(value)
257
257
  when Time, DateTime
258
258
  value.utc
259
259
  when Date
@@ -1,3 +1,3 @@
1
1
  module ActiveVersion
2
- VERSION = "2.1.0"
2
+ VERSION = "2.1.1"
3
3
  end
@@ -1,4 +1,5 @@
1
1
  require "logger"
2
+ require "json"
2
3
  require "active_support"
3
4
  begin
4
5
  require "active_record"
@@ -17,6 +18,12 @@ require "active_version/unique_version_collision"
17
18
  # Main entry point for ActiveVersion
18
19
  module ActiveVersion
19
20
  class Error < StandardError; end
21
+
22
+ # Stdlib JSON encoding helper (hot paths share this entry point).
23
+ def self.json_encode(value)
24
+ JSON.generate(value)
25
+ end
26
+
20
27
  class ConfigurationError < Error; end
21
28
  class VersionNotFoundError < Error; end
22
29
  class ReadonlyVersionError < Error; end
@@ -229,7 +236,8 @@ module ActiveVersion
229
236
 
230
237
  # Set PostgreSQL session variable
231
238
  if connection.open_transactions.positive?
232
- encoded_context = connection.quote(ActiveSupport::JSON.encode(context))
239
+ payload = json_encode(context)
240
+ encoded_context = connection.quote(payload)
233
241
  connection.execute("SET LOCAL active_version.context = #{encoded_context}")
234
242
  end
235
243
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: active_version
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.1.0
4
+ version: 2.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrei Makarov
@@ -153,14 +153,14 @@ dependencies:
153
153
  requirements:
154
154
  - - ">="
155
155
  - !ruby/object:Gem::Version
156
- version: 2.2.0
156
+ version: 2.2.9
157
157
  type: :development
158
158
  prerelease: false
159
159
  version_requirements: !ruby/object:Gem::Requirement
160
160
  requirements:
161
161
  - - ">="
162
162
  - !ruby/object:Gem::Version
163
- version: 2.2.0
163
+ version: 2.2.9
164
164
  - !ruby/object:Gem::Dependency
165
165
  name: prosopite
166
166
  requirement: !ruby/object:Gem::Requirement