transmutation 0.6.1 → 0.7.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: 84b0d0743fade5189b104186c820f4e26cbe080e9d11a4897571bd8ddb385eb9
4
- data.tar.gz: 6e840a6211c0d8a323f3e0881722982c5de597a3234f77546ecd2161db9f159e
3
+ metadata.gz: 55d12ac9787b8517a2e9cc9a5b94a21877e040d74871aa419f7cb916a56b8466
4
+ data.tar.gz: 5e9ba27e9d68248e735db47f1bcfdf57224a19276ad9e6d4ad3261c5721b4919
5
5
  SHA512:
6
- metadata.gz: 36a016fa29e03c5bdd297bd162ad0c4d059b33ae0c723ea55bb97fd91450bcb5bf8c9f589cad9754a5cdf96ec794ed283f73530f5c6c1baad6022762458464f4
7
- data.tar.gz: e34f4863e99f1f60279a3bde9635c70a483d7488bc8238137da482c52fb9df86522d5c0b6dc7ed75f7ca70fd251182e553c055b4edf5e9c6e964128efb36bdfd
6
+ metadata.gz: 11f50b0905cb83e1abdbe37e7e7f8a5dc9ae021277fe931497255e169f6d23e74032befa82c5014402b30daf38bd536b4f015f40dcdcee7028b6dfc18ccef645
7
+ data.tar.gz: 19b616c7aa56b1fce83d1089df02d5ddeeb170d0a5c7b1259fd7264b8100dc57c20f0b2497592b880dbbaa306769e4abd271aae0b14474d88d0d44c3527a1780
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Transmutation
4
+ # @api private
5
+ #
6
+ # An association: resolves the associated object (via a block or a method on the object) and
7
+ # serializes it with the looked up serializer, one level deeper. Only rendered within `max_depth`.
8
+ class Association < Field
9
+ def initialize(name, namespace: nil, serializer: nil, **options, &block)
10
+ super(name, **options, &block)
11
+
12
+ @namespace = namespace
13
+ @serializer = serializer
14
+ end
15
+
16
+ def render?(serializer)
17
+ super && serializer.depth < serializer.max_depth
18
+ end
19
+
20
+ def value(serializer, options)
21
+ target = block ? serializer.instance_exec(&block) : serializer.object.send(name)
22
+
23
+ serializer.serialize(
24
+ target,
25
+ namespace: @namespace,
26
+ serializer: @serializer,
27
+ depth: serializer.depth + 1,
28
+ max_depth: serializer.max_depth,
29
+ context: serializer.context
30
+ ).as_json(options)
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Transmutation
4
+ # @api private
5
+ #
6
+ # A plain attribute: either a block evaluated in the serializer's context, or a method sent to the
7
+ # serialized object.
8
+ class Attribute < Field
9
+ def value(serializer, _options)
10
+ block ? serializer.instance_exec(&block) : serializer.object.send(name)
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Transmutation
4
+ # @api private
5
+ #
6
+ # Base class for a serializable field. Encapsulates the name, the precomputed JSON key, and the
7
+ # optional `if:`/`unless:` rendering conditions shared by attributes and associations.
8
+ class Field
9
+ attr_reader :name, :key
10
+
11
+ def initialize(name, **options, &block)
12
+ @name = name
13
+ @key = name.to_s.freeze
14
+ @block = block
15
+ @if = options[:if]
16
+ @unless = options[:unless]
17
+ end
18
+
19
+ # Whether this field should be rendered for the given serializer instance.
20
+ def render?(serializer)
21
+ return false if @if && !evaluate(@if, serializer)
22
+ return false if @unless && evaluate(@unless, serializer)
23
+
24
+ true
25
+ end
26
+
27
+ private
28
+
29
+ attr_reader :block
30
+
31
+ # A Symbol condition is sent to the serializer; anything else (e.g. a Proc) runs in its context.
32
+ def evaluate(condition, serializer)
33
+ condition.is_a?(Symbol) ? serializer.send(condition) : serializer.instance_exec(&condition)
34
+ end
35
+ end
36
+ end
@@ -19,11 +19,12 @@ module Transmutation
19
19
  namespace = kwargs.delete(:namespace)
20
20
  serializer = kwargs.delete(:serializer)
21
21
  max_depth = kwargs.delete(:max_depth) || Transmutation.max_depth
22
+ context = kwargs.delete(:context) { self }
22
23
 
23
24
  return super(**kwargs) unless json
24
25
  return super(**kwargs, json:) unless should_serialize
25
26
 
26
- super(**kwargs, json: serialize(json, namespace:, serializer:, max_depth:))
27
+ super(**kwargs, json: serialize(json, namespace:, serializer:, max_depth:, context:))
27
28
  end
28
29
  end
29
30
  end
@@ -8,14 +8,14 @@ module Transmutation
8
8
  # @param namespace [String, Symbol, Module] The namespace to lookup the serializer in.
9
9
  # @param serializer [String, Symbol, Class] The serializer to use.
10
10
  # @param max_depth [Integer] The maximum depth of nested associations to serialize.
11
+ # @param context [Object] Arbitrary context made available to the serializer as `#context`.
12
+ # Defaults to the caller (e.g. the controller), so serializers can reach `context.current_user`.
11
13
  #
12
14
  # @return [Transmutation::Serializer] The serialized object. This will respond to `#as_json` and `#to_json`.
13
- def serialize(object, namespace: nil, serializer: nil, depth: 0, max_depth: Transmutation.max_depth)
14
- if object.respond_to?(:map) && !object.respond_to?(:to_hash)
15
- return object.map { |item| serialize(item, namespace:, serializer:, depth:, max_depth:) }
16
- end
15
+ def serialize(object, namespace: nil, serializer: nil, depth: 0, max_depth: Transmutation.max_depth, context: self)
16
+ return serialize_collection(object, namespace:, serializer:, depth:, max_depth:, context:) if collection?(object)
17
17
 
18
- lookup_serializer(object, namespace:, serializer:).new(object, depth:, max_depth:)
18
+ lookup_serializer(object, namespace:, serializer:).new(object, depth:, max_depth:, context:)
19
19
  end
20
20
 
21
21
  # Lookup the serializer for the given object.
@@ -50,6 +50,32 @@ module Transmutation
50
50
  @namespace ||= self.class.name.to_s[0, self.class.name.rindex("::") || 0]
51
51
  end
52
52
 
53
+ private
54
+
55
+ def collection?(object)
56
+ object.respond_to?(:map) && !object.respond_to?(:to_hash)
57
+ end
58
+
59
+ # Serialize each item in a collection.
60
+ #
61
+ # Items of the same class resolve to the same serializer, so the lookup is memoised on the item's
62
+ # class and reused across siblings rather than rebuilding the cache key (and hashing it) per item.
63
+ def serialize_collection(collection, namespace:, serializer:, depth:, max_depth:, context:)
64
+ serializer_class = nil
65
+ serialized_class = nil
66
+
67
+ collection.map do |item|
68
+ next serialize(item, namespace:, serializer:, depth:, max_depth:, context:) if collection?(item)
69
+
70
+ if item.class != serialized_class
71
+ serializer_class = lookup_serializer(item, namespace:, serializer:)
72
+ serialized_class = item.class
73
+ end
74
+
75
+ serializer_class.new(item, depth:, max_depth:, context:)
76
+ end
77
+ end
78
+
53
79
  private_class_method def self.included(base)
54
80
  base.include(Rendering) if base.method_defined?(:render)
55
81
  end
@@ -20,10 +20,11 @@ module Transmutation
20
20
 
21
21
  include Transmutation::Serialization
22
22
 
23
- def initialize(object, depth: 0, max_depth: 1)
23
+ def initialize(object, depth: 0, max_depth: 1, context: nil)
24
24
  @object = object
25
25
  @depth = depth
26
26
  @max_depth = max_depth
27
+ @context = context
27
28
  end
28
29
 
29
30
  def to_json(options = {})
@@ -31,12 +32,8 @@ module Transmutation
31
32
  end
32
33
 
33
34
  def as_json(options = {})
34
- attributes_config.each_with_object({}) do |(attr_name, attr_options), hash|
35
- if attr_options[:association]
36
- hash[attr_name.to_s] = instance_exec(&attr_options[:block]).as_json(options) if @depth + 1 <= @max_depth
37
- else
38
- hash[attr_name.to_s] = attr_options[:block] ? instance_exec(&attr_options[:block]) : object.send(attr_name)
39
- end
35
+ fields.each_with_object({}) do |field, hash|
36
+ hash[field.key] = field.value(self, options) if field.render?(self)
40
37
  end
41
38
  end
42
39
 
@@ -44,6 +41,9 @@ module Transmutation
44
41
  # Define an attribute to be serialized
45
42
  #
46
43
  # @param attribute_name [Symbol] The name of the attribute to serialize
44
+ # @param if [Symbol, Proc] Only include the attribute when the condition evaluates truthy
45
+ # @param unless [Symbol, Proc] Exclude the attribute when the condition evaluates truthy
46
+ # - A Symbol is sent to the serializer instance, a Proc is evaluated in its context
47
47
  # @yield [object] The block to call to get the value of the attribute
48
48
  # - The block is called in the context of the serializer instance
49
49
  #
@@ -54,9 +54,11 @@ module Transmutation
54
54
  # attribute :full_name do
55
55
  # "#{object.first_name} #{object.last_name}".strip
56
56
  # end
57
+ #
58
+ # attribute :email, if: :admin?
57
59
  # end
58
- def attribute(attribute_name, &block)
59
- attributes_config[attribute_name] = { block: }
60
+ def attribute(attribute_name, **options, &block)
61
+ fields[attribute_name] = Attribute.new(attribute_name, **options, &block)
60
62
  end
61
63
 
62
64
  # Define an association to be serialized
@@ -66,6 +68,8 @@ module Transmutation
66
68
  # @param association_name [Symbol] The name of the association to serialize
67
69
  # @param namespace [String, Symbol, Module] The namespace to lookup the association's serializer in
68
70
  # @param serializer [String, Symbol, Class] The serializer to use for the association's serialization
71
+ # @param if [Symbol, Proc] Only include the association when the condition evaluates truthy
72
+ # @param unless [Symbol, Proc] Exclude the association when the condition evaluates truthy
69
73
  # @yield [object] The block to call to get the value of the association
70
74
  # - The block is called in the context of the serializer instance
71
75
  # - The return value from the block is automatically serialized
@@ -78,14 +82,8 @@ module Transmutation
78
82
  # object.posts.archived
79
83
  # end
80
84
  # end
81
- def association(association_name, namespace: nil, serializer: nil, &custom_block)
82
- block = lambda do
83
- association_instance = custom_block ? instance_exec(&custom_block) : object.send(association_name)
84
-
85
- serialize(association_instance, namespace:, serializer:, depth: @depth + 1, max_depth: @max_depth)
86
- end
87
-
88
- attributes_config[association_name] = { block:, association: true }
85
+ def association(association_name, namespace: nil, serializer: nil, **options, &block)
86
+ fields[association_name] = Association.new(association_name, namespace:, serializer:, **options, &block)
89
87
  end
90
88
 
91
89
  # Shorthand for defining multiple attributes
@@ -121,15 +119,24 @@ module Transmutation
121
119
  alias has_many associations
122
120
  end
123
121
 
122
+ # @return [Object] the object being serialized
123
+ # @return [Integer] depth the current nesting depth
124
+ # @return [Integer] max_depth the maximum nesting depth to serialize
125
+ # @return [Object, nil] context the caller-supplied context (defaults to the caller of `serialize`)
126
+ attr_reader :object, :depth, :max_depth, :context
127
+
124
128
  private
125
129
 
126
- class_attribute :attributes_config, instance_writer: false, default: {}
130
+ class_attribute :fields, instance_accessor: false, default: {}
127
131
 
128
- attr_reader :object
132
+ # The fields declared on this serializer, in declaration order.
133
+ def fields
134
+ self.class.fields.values
135
+ end
129
136
 
130
137
  private_class_method def self.inherited(subclass)
131
138
  super
132
- subclass.attributes_config = attributes_config.dup
139
+ subclass.fields = fields.dup
133
140
  end
134
141
  end
135
142
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Transmutation
4
- VERSION = "0.6.1"
4
+ VERSION = "0.7.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: transmutation
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.1
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - nitemaeric
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: exe
11
11
  cert_chain: []
12
- date: 2026-04-04 00:00:00.000000000 Z
12
+ date: 2026-07-09 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: activesupport
@@ -48,7 +48,10 @@ extensions: []
48
48
  extra_rdoc_files: []
49
49
  files:
50
50
  - lib/transmutation.rb
51
+ - lib/transmutation/association.rb
52
+ - lib/transmutation/attribute.rb
51
53
  - lib/transmutation/class_attributes.rb
54
+ - lib/transmutation/field.rb
52
55
  - lib/transmutation/object_serializer.rb
53
56
  - lib/transmutation/serialization.rb
54
57
  - lib/transmutation/serialization/lookup.rb