paper_trail-human 0.3.0 → 0.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a4ca501d7c667357906931d0e4460948d92dcd0b8595f1162b0a25d980650c72
4
- data.tar.gz: 87e69c7b0461fcf423fece4f6f8f6471214861f0fd2300ad484795a3eacc19c6
3
+ metadata.gz: 4dea24fd23784bda5b3b0f6d704f5f3a53a2ab00c8ed8500fe69a1b432eb3f47
4
+ data.tar.gz: 6c074d6f13efd43f93e1c201e36d9310420095c516980bc80c74dc6116551105
5
5
  SHA512:
6
- metadata.gz: d16233ba896c6f145177f3852b763aa7f8390cd12b68ae2077e2d9b7287e7cbb93d0944f940fafc77241161a5e5a0db764d160b75fde29338cc6e14143e14165
7
- data.tar.gz: 342d5cdf3e9742e00bc58dab8ccfaca91b3a47e990d4dcfde4bc4b184c54e1a7f42af4830548f74bc444b2fd5ffcc8213759255950bc205049522595d9b4cdce
6
+ metadata.gz: bf9745f8aba245105b94e41f5739ec4358d657311d7cc9b5527db3353ff1e217243955f3f033cd576f98fd0d8fa0a6079f927496f101a4be31813bc734ac75bd
7
+ data.tar.gz: e5fb5fc24f36be842103d727af163f64545c0208229178dd7bc93f2559b09022d5177cffb4c82e448aed78a21a5564a6ff79376875184c9501b298685ff37e37
data/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.4.0] - 2026-05-31
4
+
5
+ ### Added
6
+ - Custom resolver registration via `config.register_resolver(:type, MyResolver)`
7
+ - Custom formatter registration via `config.register_formatter(:type, MyFormatter)`
8
+ - Lazy loading of adapters with `autoload` (faster boot for apps using few resolvers)
9
+
10
+ ### Changed
11
+ - Extracted `Adapters::Resolvers` and `Adapters::Formatters` into autoload modules
12
+ - `FORMATTERS` constant uses string references for deferred loading
13
+
14
+ ## [0.3.1] - 2026-05-31
15
+
16
+ ### Fixed
17
+ - N+1 queries on `item_name` in `format_collection` and `timeline` (batch preloads records)
18
+ - `Number` resolver with `format: :currency` and `unit: nil` no longer produces leading space
19
+ - `format_collection` with `as:` option now joins entries with separators (double newline for text/markdown, newline for HTML)
20
+
21
+ ### Changed
22
+ - Extracted `ItemNameLoader` and `RelationLoader` from `BatchPresenter` for better cohesion
23
+
3
24
  ## [0.3.0] - 2026-05-30
4
25
 
5
26
  ### Added
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PaperTrail
4
+ module Human
5
+ module Adapters
6
+ module Formatters
7
+ autoload :Text, 'paper_trail/human/adapters/formatters/text'
8
+ autoload :Markdown, 'paper_trail/human/adapters/formatters/markdown'
9
+ autoload :Html, 'paper_trail/human/adapters/formatters/html'
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PaperTrail
4
+ module Human
5
+ module Adapters
6
+ module Resolvers
7
+ autoload :Relation, 'paper_trail/human/adapters/resolvers/relation'
8
+ autoload :Enum, 'paper_trail/human/adapters/resolvers/enum'
9
+ autoload :Boolean, 'paper_trail/human/adapters/resolvers/boolean'
10
+ autoload :Custom, 'paper_trail/human/adapters/resolvers/custom'
11
+ autoload :Text, 'paper_trail/human/adapters/resolvers/text'
12
+ autoload :Date, 'paper_trail/human/adapters/resolvers/date'
13
+ autoload :Number, 'paper_trail/human/adapters/resolvers/number'
14
+ end
15
+ end
16
+ end
17
+ end
@@ -7,7 +7,7 @@ module PaperTrail
7
7
 
8
8
  attr_accessor :whodunnit_resolver, :extend_version_model, :field_name_resolver,
9
9
  :translate_events, :after_format
10
- attr_reader :ignored_fields
10
+ attr_reader :ignored_fields, :custom_resolvers, :custom_formatters
11
11
 
12
12
  def initialize
13
13
  @model_configs = {}
@@ -17,6 +17,8 @@ module PaperTrail
17
17
  @translate_events = false
18
18
  @extend_version_model = false
19
19
  @after_format = nil
20
+ @custom_resolvers = {}
21
+ @custom_formatters = {}
20
22
  @mutex = Mutex.new
21
23
  end
22
24
 
@@ -30,6 +32,14 @@ module PaperTrail
30
32
  @mutex.synchronize { @model_configs[model_name.to_s] = model_config.freeze }
31
33
  end
32
34
 
35
+ def register_resolver(type, klass)
36
+ @mutex.synchronize { @custom_resolvers[type.to_sym] = klass }
37
+ end
38
+
39
+ def register_formatter(type, klass)
40
+ @mutex.synchronize { @custom_formatters[type.to_sym] = klass }
41
+ end
42
+
33
43
  def config_for(model_name)
34
44
  @model_configs[model_name.to_s]
35
45
  end
@@ -26,7 +26,8 @@ module PaperTrail
26
26
  model_config,
27
27
  version.item_type,
28
28
  field_name_resolver: @configuration.field_name_resolver,
29
- preloaded: preloaded
29
+ preloaded: preloaded,
30
+ custom_resolvers: @configuration.custom_resolvers
30
31
  )
31
32
 
32
33
  result = {
@@ -14,11 +14,12 @@ module PaperTrail
14
14
  number: 'PaperTrail::Human::Adapters::Resolvers::Number'
15
15
  }.freeze
16
16
 
17
- def initialize(model_config, item_type, field_name_resolver: nil, preloaded: nil)
17
+ def initialize(model_config, item_type, field_name_resolver: nil, preloaded: nil, custom_resolvers: {})
18
18
  @model_config = model_config
19
19
  @item_type = item_type
20
20
  @field_name_resolver = field_name_resolver
21
21
  @preloaded = preloaded || {}
22
+ @custom_resolvers = custom_resolvers
22
23
  end
23
24
 
24
25
  def call(field_name, previous_value, new_value)
@@ -43,16 +44,23 @@ module PaperTrail
43
44
  def build_resolver(config, field_name = nil)
44
45
  return nil unless config
45
46
 
46
- class_name = RESOLVER_MAP[config[:type]]
47
- raise Error, "Unknown resolver type: #{config[:type]}" unless class_name
47
+ klass = resolve_class(config[:type])
48
+ raise Error, "Unknown resolver type: #{config[:type]}" unless klass
48
49
 
49
- klass = Object.const_get(class_name)
50
50
  opts = config[:options]
51
51
  opts = opts.merge(cache: relation_cache(config)) if config[:type] == :relation
52
52
  opts = opts.merge(field: field_name) if config[:type] == :enum && opts[:from_model]
53
53
  klass.new(**opts)
54
54
  end
55
55
 
56
+ def resolve_class(type)
57
+ if @custom_resolvers.key?(type)
58
+ @custom_resolvers[type]
59
+ elsif RESOLVER_MAP.key?(type)
60
+ Object.const_get(RESOLVER_MAP[type])
61
+ end
62
+ end
63
+
56
64
  def relation_cache(config)
57
65
  class_name = config[:options][:class_name] || config[:options][:class].to_s
58
66
  attribute = config[:options][:attribute] || :name
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PaperTrail
4
+ module Human
5
+ module Core
6
+ class ItemNameLoader
7
+ def initialize(configuration)
8
+ @configuration = configuration
9
+ end
10
+
11
+ def preload(versions)
12
+ grouped = group_batchable(versions)
13
+
14
+ grouped.each_with_object({}) do |(item_type, ids), cache|
15
+ load_names(item_type, ids, cache)
16
+ end
17
+ end
18
+
19
+ def resolve(version, cache)
20
+ model_config = @configuration.config_for(version.item_type)
21
+ return nil unless model_config&.item_name_attribute
22
+
23
+ attr = model_config.item_name_attribute
24
+ return attr.call(version) if attr.respond_to?(:call)
25
+
26
+ cache[[version.item_type, version.item_id]]
27
+ end
28
+
29
+ private
30
+
31
+ def group_batchable(versions)
32
+ versions.each_with_object({}) do |version, grouped|
33
+ model_config = @configuration.config_for(version.item_type)
34
+ next unless model_config&.item_name_attribute
35
+ next if model_config.item_name_attribute.respond_to?(:call)
36
+
37
+ (grouped[version.item_type] ||= Set.new).add(version.item_id)
38
+ end
39
+ end
40
+
41
+ def load_names(item_type, ids, cache)
42
+ attr = @configuration.config_for(item_type).item_name_attribute
43
+ klass = Object.const_get(item_type)
44
+ klass.where(id: ids.to_a).each do |record|
45
+ cache[[item_type, record.id]] = record.public_send(attr)
46
+ end
47
+ rescue NameError
48
+ nil
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -11,12 +11,7 @@ module PaperTrail
11
11
 
12
12
  def call(version, only: nil, except: nil)
13
13
  changes = @change_extractor.call(version)
14
- model_config = @configuration.config_for(version.item_type)
15
- formatter = FieldFormatter.new(
16
- model_config,
17
- version.item_type,
18
- field_name_resolver: @configuration.field_name_resolver
19
- )
14
+ formatter = build_formatter(version)
20
15
 
21
16
  result = {
22
17
  user: @configuration.resolve_whodunnit(version.whodunnit),
@@ -35,6 +30,15 @@ module PaperTrail
35
30
 
36
31
  private
37
32
 
33
+ def build_formatter(version)
34
+ model_config = @configuration.config_for(version.item_type)
35
+ FieldFormatter.new(
36
+ model_config, version.item_type,
37
+ field_name_resolver: @configuration.field_name_resolver,
38
+ custom_resolvers: @configuration.custom_resolvers
39
+ )
40
+ end
41
+
38
42
  def build_fields(changes, formatter, event, only: nil, except: nil)
39
43
  changes
40
44
  .reject { |field, _| @configuration.ignored_fields.include?(field.to_s) }
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PaperTrail
4
+ module Human
5
+ module Core
6
+ class RelationLoader
7
+ def initialize(configuration)
8
+ @configuration = configuration
9
+ end
10
+
11
+ def preload(versions_data)
12
+ relation_fields = collect_relation_fields(versions_data)
13
+ return {} if relation_fields.empty?
14
+
15
+ relation_fields.each_with_object({}) do |(key, ids), cache|
16
+ class_name, attribute = key
17
+ cache[key] = load_records(class_name, attribute, ids)
18
+ end
19
+ end
20
+
21
+ private
22
+
23
+ def collect_relation_fields(versions_data)
24
+ result = Hash.new { |h, k| h[k] = Set.new }
25
+
26
+ versions_data.each do |version, changes|
27
+ collect_from_version(result, version, changes)
28
+ end
29
+
30
+ result
31
+ end
32
+
33
+ def collect_from_version(result, version, changes)
34
+ model_config = @configuration.config_for(version.item_type)
35
+ return unless model_config
36
+
37
+ changes.each do |field, values|
38
+ field_cfg = model_config.fields[field.to_s]
39
+ next unless field_cfg && field_cfg[:type] == :relation
40
+
41
+ key = relation_key(field_cfg)
42
+ Array(values).compact.each { |v| result[key].add(v) }
43
+ end
44
+ end
45
+
46
+ def relation_key(field_cfg)
47
+ class_name = field_cfg[:options][:class_name] || field_cfg[:options][:class].to_s
48
+ attribute = field_cfg[:options][:attribute] || :name
49
+ [class_name, attribute]
50
+ end
51
+
52
+ def load_records(class_name, attribute, ids)
53
+ klass = Object.const_get(class_name)
54
+ klass.where(id: ids.to_a).to_h { |record| [record.id, record.public_send(attribute)] }
55
+ rescue NameError
56
+ {}
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module PaperTrail
4
4
  module Human
5
- VERSION = '0.3.0'
5
+ VERSION = '0.4.0'
6
6
  end
7
7
  end
@@ -9,16 +9,8 @@ require_relative 'human/core/presenter'
9
9
  require_relative 'human/core/batch_presenter'
10
10
  require_relative 'human/core/timeline'
11
11
  require_relative 'human/ports/resolver'
12
- require_relative 'human/adapters/resolvers/relation'
13
- require_relative 'human/adapters/resolvers/enum'
14
- require_relative 'human/adapters/resolvers/boolean'
15
- require_relative 'human/adapters/resolvers/custom'
16
- require_relative 'human/adapters/resolvers/text'
17
- require_relative 'human/adapters/resolvers/date'
18
- require_relative 'human/adapters/resolvers/number'
19
- require_relative 'human/adapters/formatters/text'
20
- require_relative 'human/adapters/formatters/markdown'
21
- require_relative 'human/adapters/formatters/html'
12
+ require_relative 'human/adapters/resolvers'
13
+ require_relative 'human/adapters/formatters'
22
14
 
23
15
  module PaperTrail
24
16
  module Human
@@ -28,9 +20,9 @@ module PaperTrail
28
20
  private_constant :MUTEX
29
21
 
30
22
  FORMATTERS = {
31
- text: Adapters::Formatters::Text,
32
- markdown: Adapters::Formatters::Markdown,
33
- html: Adapters::Formatters::Html
23
+ text: 'PaperTrail::Human::Adapters::Formatters::Text',
24
+ markdown: 'PaperTrail::Human::Adapters::Formatters::Markdown',
25
+ html: 'PaperTrail::Human::Adapters::Formatters::Html'
34
26
  }.freeze
35
27
  private_constant :FORMATTERS
36
28
 
@@ -57,18 +49,21 @@ module PaperTrail
57
49
  as ? results.map { |r| formatter(as).call(r) } : results
58
50
  end
59
51
 
52
+ def timeline(versions, group_by: :day, only: nil, except: nil)
53
+ Core::Timeline.new(configuration).call(versions, group_by: group_by, only: only, except: except)
54
+ end
55
+
60
56
  private
61
57
 
62
58
  def formatter(type)
63
- klass = FORMATTERS[type.to_sym]
64
- raise Error, "Unknown format: #{type}. Available: #{FORMATTERS.keys.join(', ')}" unless klass
59
+ sym = type.to_sym
60
+ klass = configuration.custom_formatters[sym] || FORMATTERS[sym]
61
+ available = (FORMATTERS.keys + configuration.custom_formatters.keys).join(', ')
62
+ raise Error, "Unknown format: #{type}. Available: #{available}" unless klass
65
63
 
64
+ klass = Object.const_get(klass) if klass.is_a?(String)
66
65
  klass.new
67
66
  end
68
-
69
- def timeline(versions, group_by: :day, only: nil, except: nil)
70
- Core::Timeline.new(configuration).call(versions, group_by: group_by, only: only, except: except)
71
- end
72
67
  end
73
68
  end
74
69
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: paper_trail-human
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Gabriel
@@ -52,9 +52,11 @@ files:
52
52
  - lib/generators/paper_trail/human/templates/initializer.rb
53
53
  - lib/paper_trail-human.rb
54
54
  - lib/paper_trail/human.rb
55
+ - lib/paper_trail/human/adapters/formatters.rb
55
56
  - lib/paper_trail/human/adapters/formatters/html.rb
56
57
  - lib/paper_trail/human/adapters/formatters/markdown.rb
57
58
  - lib/paper_trail/human/adapters/formatters/text.rb
59
+ - lib/paper_trail/human/adapters/resolvers.rb
58
60
  - lib/paper_trail/human/adapters/resolvers/boolean.rb
59
61
  - lib/paper_trail/human/adapters/resolvers/custom.rb
60
62
  - lib/paper_trail/human/adapters/resolvers/date.rb
@@ -67,7 +69,9 @@ files:
67
69
  - lib/paper_trail/human/core/change_extractor.rb
68
70
  - lib/paper_trail/human/core/event_translator.rb
69
71
  - lib/paper_trail/human/core/field_formatter.rb
72
+ - lib/paper_trail/human/core/item_name_loader.rb
70
73
  - lib/paper_trail/human/core/presenter.rb
74
+ - lib/paper_trail/human/core/relation_loader.rb
71
75
  - lib/paper_trail/human/core/timeline.rb
72
76
  - lib/paper_trail/human/ports/resolver.rb
73
77
  - lib/paper_trail/human/railtie.rb