Skip to content
Open
33 changes: 28 additions & 5 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,17 @@ name: Ruby
on:
push:
branches:
- "*"
- "**"

jobs:
build:
runs-on: ubuntu-latest
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
name: Ruby ${{ matrix.ruby }}
# ruby-head is a moving target; allow its toolchain breakages (e.g. native gems
# failing to compile against nightly) to fail without turning the whole run red.
continue-on-error: ${{ matrix.ruby == 'ruby-head' }}
strategy:
fail-fast: false
matrix:
Expand All @@ -23,7 +26,7 @@ jobs:

steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v5

- name: Set up Ruby
uses: ruby/setup-ruby@v1
Expand All @@ -44,7 +47,7 @@ jobs:
name: Benchmarks
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v5

- name: Set up Ruby
uses: ruby/setup-ruby@v1
Expand All @@ -53,9 +56,29 @@ jobs:
bundler-cache: true
working-directory: benchmarks

- name: Run benchmarks
# Export main's library so the harness can load it alongside head — in an isolated module,
# see benchmark.rb — and benchmark both revisions side by side in a single process.
- name: Fetch main revision of the library
run: |
git fetch --depth=1 origin main
mkdir -p "$RUNNER_TEMP/transmutation-main"
git archive FETCH_HEAD lib | tar -x -C "$RUNNER_TEMP/transmutation-main"

- name: Run benchmarks (interpreter)
working-directory: benchmarks
env:
TRANSMUTATION_MAIN_LIB: ${{ runner.temp }}/transmutation-main/lib
run: |
echo "# Benchmarks (interpreter) — head vs main" >> "$GITHUB_STEP_SUMMARY"
bundle exec ruby benchmark.rb | tee -a "$GITHUB_STEP_SUMMARY"

- name: Run benchmarks (YJIT)
working-directory: benchmarks
run: bundle exec ruby benchmark.rb | tee -a "$GITHUB_STEP_SUMMARY"
env:
TRANSMUTATION_MAIN_LIB: ${{ runner.temp }}/transmutation-main/lib
run: |
echo "# Benchmarks (YJIT) — head vs main" >> "$GITHUB_STEP_SUMMARY"
bundle exec ruby --yjit benchmark.rb | tee -a "$GITHUB_STEP_SUMMARY"

# The release workflow seems to have some issues. I'll reinstate it when I have time to fix it.
# The failing check on `main` doesn't inspire confidence.
Expand Down
67 changes: 63 additions & 4 deletions benchmarks/benchmark.rb
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,65 @@

organisations = [organisation] + 29.times.map { Organisation.new(id: _1 + 2, name: "Example Inc. #{_1 + 2}") }

# Optionally load another revision of transmutation (e.g. main) into an isolated namespace so it can
# be benchmarked side by side with the working tree (head) in a single process. This relies on Ruby's
# experimental Namespace feature (Ruby 4.0+, RUBY_NAMESPACE=1); if it is unavailable or fails to load,
# we simply skip the comparison and benchmark head on its own.
# The gem's source files in load order. transmutation.rb itself is skipped: it boots Zeitwerk, which
# would bind the top-level ::Transmutation constant and defeat the isolation, so we recreate its
# module-level setup inline instead.
TRANSMUTATION_MAIN_SOURCES = %w[
transmutation/class_attributes
transmutation/serialization/lookup/serializer_not_found
transmutation/serialization/lookup
transmutation/serialization/rendering
transmutation/serialization
transmutation/serializer
transmutation/object_serializer
].freeze

def load_transmutation_main
lib = ENV.fetch("TRANSMUTATION_MAIN_LIB", nil)
return unless lib

# Evaluate the library's source as strings into an anonymous module. A string `module_eval` nests
# under the receiver, so every `module Transmutation` lands in `wrapper::Transmutation` — a copy
# fully isolated from the working tree's top-level `Transmutation`, with no source rewriting.
wrapper = Module.new
TRANSMUTATION_MAIN_SOURCES.each do |source|
wrapper.module_eval(File.read(File.join(lib, "#{source}.rb")), "#{source}.rb")

next unless source.end_with?("class_attributes")

wrapper.module_eval(<<~RUBY, "transmutation_main_bootstrap.rb")
module Transmutation
extend ClassAttributes
class_attribute :max_depth, default: 1
class Error < StandardError; end
end
RUBY
end

# Load the benchmark's own serializers into the same isolated copy.
Dir[File.expand_path("lib/serializers/transmutation/*.rb", __dir__)].sort.each do |file|
wrapper.module_eval(File.read(file), file)
end

# Naming the module (a top-level constant) lets the serializer's namespace-based association lookup
# resolve its sibling serializers, exactly as it does for the working-tree copy.
Object.const_set(:TransmutationMain, wrapper::Transmutation)
rescue StandardError, ScriptError => e
warn "Skipping `transmutation (main)` comparison: #{e.class}: #{e.message}\n#{e.backtrace&.first(5)&.join("\n")}"
nil
end

TRANSMUTATION_MAIN = load_transmutation_main
warn(TRANSMUTATION_MAIN ? "Benchmarking head vs main." : "Benchmarking head only.")

GemBenchmarks.report output: false do
group("Attributes") do
example("transmutation") { Transmutation::OrganisationSerializer.new(organisation).to_json }
example("transmutation (HEAD)") { Transmutation::OrganisationSerializer.new(organisation).to_json }
example("transmutation (main)") { TRANSMUTATION_MAIN::OrganisationSerializer.new(organisation).to_json } if TRANSMUTATION_MAIN
example("panko_serializer") { PankoSerializer::OrganisationSerializer.new.serialize_to_json(organisation) }
example("jbuilder") { Jbuilder.encode { |json| json.instance_eval(organisation_jbuilder_template); json.target! } }
example("representable") { Representable::OrganisationRepresenter.new(organisation).to_json }
Expand All @@ -47,7 +103,8 @@
end

group("Has One / Belongs To") do
example("transmutation") { Transmutation::PostSerializer.new(post).to_json }
example("transmutation (HEAD)") { Transmutation::PostSerializer.new(post).to_json }
example("transmutation (main)") { TRANSMUTATION_MAIN::PostSerializer.new(post).to_json } if TRANSMUTATION_MAIN
example("panko_serializer") { PankoSerializer::PostSerializer.new(except: { user: [:posts] }).serialize_to_json(post) }
example("jbuilder") { Jbuilder.encode { |json| json.instance_eval(post_jbuilder_template); json.target! } }
example("representable") { Representable::PostRepresenter.new(post).to_json }
Expand All @@ -57,7 +114,8 @@
end

group("Has Many") do
example("transmutation") { Transmutation::UserSerializer.new(user).to_json }
example("transmutation (HEAD)") { Transmutation::UserSerializer.new(user).to_json }
example("transmutation (main)") { TRANSMUTATION_MAIN::UserSerializer.new(user).to_json } if TRANSMUTATION_MAIN
example("panko_serializer") { PankoSerializer::UserSerializer.new.serialize_to_json(user) }
example("jbuilder") { Jbuilder.encode { |json| json.instance_eval(user_jbuilder_template); json.target! } }
example("representable") { Representable::UserRepresenter.new(user).to_json }
Expand All @@ -67,7 +125,8 @@
end

group("Collection") do
example("transmutation") { organisations.map { Transmutation::OrganisationSerializer.new(_1) }.to_json }
example("transmutation (HEAD)") { organisations.map { Transmutation::OrganisationSerializer.new(_1) }.to_json }
example("transmutation (main)") { organisations.map { TRANSMUTATION_MAIN::OrganisationSerializer.new(_1) }.to_json } if TRANSMUTATION_MAIN
example("panko_serializer") { Panko::ArraySerializer.new(organisations, each_serializer: PankoSerializer::OrganisationSerializer).to_json }
example("jbuilder") { Jbuilder.encode { |json| json.instance_eval(organisations_jbuilder_template); json.target! } }
example("representable") { Representable::OrganisationRepresenter.for_collection.new(organisations).to_json }
Expand Down
4 changes: 3 additions & 1 deletion benchmarks/lib/gem_benchmarks.rb
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,9 @@ class Example
def initialize(gem_name, **config, &block)
@gem_name = gem_name
@config = config
@label = if config[:style] == :markdown
@label = if specs.nil?
gem_name
elsif config[:style] == :markdown
"[#{gem_name} #{gem_version}](#{gem_url})"
else
"#{gem_name} #{gem_version}"
Expand Down
30 changes: 27 additions & 3 deletions lib/transmutation/serialization.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,7 @@ module Serialization
#
# @return [Transmutation::Serializer] The serialized object. This will respond to `#as_json` and `#to_json`.
def serialize(object, namespace: nil, serializer: nil, depth: 0, max_depth: Transmutation.max_depth)
if object.respond_to?(:map) && !object.respond_to?(:to_hash)
return object.map { |item| serialize(item, namespace:, serializer:, depth:, max_depth:) }
end
return serialize_collection(object, namespace:, serializer:, depth:, max_depth:) if collection?(object)

lookup_serializer(object, namespace:, serializer:).new(object, depth:, max_depth:)
end
Expand Down Expand Up @@ -50,6 +48,32 @@ def namespace
@namespace ||= self.class.name.to_s[0, self.class.name.rindex("::") || 0]
end

private

def collection?(object)
object.respond_to?(:map) && !object.respond_to?(:to_hash)
end

# Serialize each item in a collection.
#
# Items of the same class resolve to the same serializer, so the lookup is memoised on the item's
# class and reused across siblings rather than rebuilding the cache key (and hashing it) per item.
def serialize_collection(collection, namespace:, serializer:, depth:, max_depth:)
serializer_class = nil
serialized_class = nil

collection.map do |item|
next serialize(item, namespace:, serializer:, depth:, max_depth:) if collection?(item)

if item.class != serialized_class
serializer_class = lookup_serializer(item, namespace:, serializer:)
serialized_class = item.class
end

serializer_class.new(item, depth:, max_depth:)
end
end

private_class_method def self.included(base)
base.include(Rendering) if base.method_defined?(:render)
end
Expand Down
57 changes: 48 additions & 9 deletions lib/transmutation/serializer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -32,18 +32,20 @@ def to_json(options = {})

def as_json(options = {})
attributes_config.each_with_object({}) do |(attr_name, attr_options), hash|
if attr_options[:association]
hash[attr_name.to_s] = instance_exec(&attr_options[:block]).as_json(options) if @depth + 1 <= @max_depth
else
hash[attr_name.to_s] = attr_options[:block] ? instance_exec(&attr_options[:block]) : object.send(attr_name)
end
next if attr_options[:conditional] && !render_field?(attr_options)
next if attr_options[:association] && @depth + 1 > @max_depth

hash[attr_name.to_s] = field_value(attr_name, attr_options, options)
end
end

class << self
# Define an attribute to be serialized
#
# @param attribute_name [Symbol] The name of the attribute to serialize
# @param if [Symbol, Proc] Only include the attribute when the condition evaluates truthy
# @param unless [Symbol, Proc] Exclude the attribute when the condition evaluates truthy
# - A Symbol is sent to the serializer instance, a Proc is evaluated in its context
# @yield [object] The block to call to get the value of the attribute
# - The block is called in the context of the serializer instance
#
Expand All @@ -54,9 +56,11 @@ class << self
# attribute :full_name do
# "#{object.first_name} #{object.last_name}".strip
# end
#
# attribute :email, if: :admin?
# end
def attribute(attribute_name, &block)
attributes_config[attribute_name] = { block: }
def attribute(attribute_name, **options, &block)
attributes_config[attribute_name] = field_config({ block: }, options)
end

# Define an association to be serialized
Expand All @@ -78,14 +82,14 @@ def attribute(attribute_name, &block)
# object.posts.archived
# end
# end
def association(association_name, namespace: nil, serializer: nil, &custom_block)
def association(association_name, namespace: nil, serializer: nil, **options, &custom_block)
block = lambda do
association_instance = custom_block ? instance_exec(&custom_block) : object.send(association_name)

serialize(association_instance, namespace:, serializer:, depth: @depth + 1, max_depth: @max_depth)
end

attributes_config[association_name] = { block:, association: true }
attributes_config[association_name] = field_config({ block:, association: true }, options)
end

# Shorthand for defining multiple attributes
Expand Down Expand Up @@ -119,6 +123,18 @@ def associations(*association_names, **, &)
alias belongs_to associations
alias has_one associations
alias has_many associations

private

# Merge conditional rendering options into a field's config.
#
# The `:conditional` flag lets {#as_json} skip condition evaluation entirely for fields that
# don't declare `if:`/`unless:`, keeping the common path a single hash lookup.
def field_config(config, options)
return config unless options[:if] || options[:unless]

config.merge(if: options[:if], unless: options[:unless], conditional: true)
end
end

private
Expand All @@ -127,6 +143,29 @@ def associations(*association_names, **, &)

attr_reader :object

# Resolve the value for a field: a serialized association, a block result, or a method call.
def field_value(attr_name, attr_options, options)
return instance_exec(&attr_options[:block]).as_json(options) if attr_options[:association]
return instance_exec(&attr_options[:block]) if attr_options[:block]

object.send(attr_name)
end

# Evaluate the `if:`/`unless:` conditions for a field.
#
# Only called for fields flagged `:conditional`, so unconditional fields incur no overhead.
def render_field?(attr_options)
return false if attr_options[:if] && !evaluate_condition(attr_options[:if])
return false if attr_options[:unless] && evaluate_condition(attr_options[:unless])

true
end

# A Symbol condition is sent to the serializer; a Proc (or other callable) is run in its context.
def evaluate_condition(condition)
condition.is_a?(Symbol) ? send(condition) : instance_exec(&condition)
end

private_class_method def self.inherited(subclass)
super
subclass.attributes_config = attributes_config.dup
Expand Down
32 changes: 32 additions & 0 deletions spec/lib/transmutation/serialization_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,38 @@ def last_name = "Doe"
expect(serialize).to be_an_instance_of(Transmutation::ObjectSerializer)
end
end

context "when given a collection" do
before do
stub_const("Api::V1::Admin::Chat::UserSerializer", Class.new(Transmutation::Serializer))
end

it "serializes every item with the looked up serializer" do
collection = [object, Object.const_get(object_class_name).new]

serialized = caller.serialize(collection)

expect(serialized.map(&:class)).to eq([Api::V1::Admin::Chat::UserSerializer] * 2)
end

it "serializes each item with the serializer for its own class" do
other_class = Class.new
stub_const("Chat::Admin", other_class)
stub_const("Api::V1::Admin::Chat::AdminSerializer", Class.new(Transmutation::Serializer))

serialized = caller.serialize([object, other_class.new])

expect(serialized.map(&:class)).to eq(
[Api::V1::Admin::Chat::UserSerializer, Api::V1::Admin::Chat::AdminSerializer]
)
end

it "serializes nested collections" do
serialized = caller.serialize([[object]])

expect(serialized[0]).to all(be_an_instance_of(Api::V1::Admin::Chat::UserSerializer))
end
end
end

describe ".max_depth" do
Expand Down
Loading