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 +4 -4
- data/CHANGELOG.md +10 -0
- data/lib/active_version/audits/audit_record/serializers.rb +1 -1
- data/lib/active_version/audits/has_audits/audit_combiner.rb +5 -3
- data/lib/active_version/audits/has_audits/audit_writer.rb +17 -4
- data/lib/active_version/audits/has_audits/change_filters.rb +3 -1
- data/lib/active_version/audits/sql_builder.rb +1 -1
- data/lib/active_version/instrumentation.rb +9 -1
- data/lib/active_version/query.rb +36 -25
- data/lib/active_version/revisions/has_revisions.rb +1 -1
- data/lib/active_version/revisions/sql_builder.rb +1 -1
- data/lib/active_version/version.rb +1 -1
- data/lib/active_version.rb +9 -1
- metadata +3 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c3b099b1522fb5b1d087683cf7003c5f1156f7f0da8703691f89075b347183d5
|
|
4
|
+
data.tar.gz: 0d49734313021a4ef263a807361fbf1b5b1f5d564b693162ddfe0d17b2439768
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
@@ -175,9 +175,11 @@ module ActiveVersion
|
|
|
175
175
|
conn = audit_class.connection
|
|
176
176
|
table_name = audit_class.table_name
|
|
177
177
|
updates = []
|
|
178
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
106
|
-
|
|
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
|
-
|
|
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
|
|
@@ -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
|
-
|
|
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
|
data/lib/active_version/query.rb
CHANGED
|
@@ -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
|
|
19
|
+
return record.class.none unless audit_class
|
|
14
20
|
|
|
15
|
-
auditable_column =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
data/lib/active_version.rb
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
163
|
+
version: 2.2.9
|
|
164
164
|
- !ruby/object:Gem::Dependency
|
|
165
165
|
name: prosopite
|
|
166
166
|
requirement: !ruby/object:Gem::Requirement
|