diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index c8f8335..ad1b0a5 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -3,7 +3,7 @@ name: Ruby on: push: branches: - - "*" + - "**" jobs: build: @@ -11,6 +11,9 @@ jobs: 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: @@ -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 @@ -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 @@ -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. diff --git a/benchmarks/benchmark.rb b/benchmarks/benchmark.rb index 23c4a2d..f643931 100644 --- a/benchmarks/benchmark.rb +++ b/benchmarks/benchmark.rb @@ -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 } @@ -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 } @@ -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 } @@ -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 } diff --git a/benchmarks/lib/gem_benchmarks.rb b/benchmarks/lib/gem_benchmarks.rb index 2361fb3..49ff063 100644 --- a/benchmarks/lib/gem_benchmarks.rb +++ b/benchmarks/lib/gem_benchmarks.rb @@ -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}" diff --git a/lib/transmutation/serialization.rb b/lib/transmutation/serialization.rb index f634e26..728f9f5 100644 --- a/lib/transmutation/serialization.rb +++ b/lib/transmutation/serialization.rb @@ -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 @@ -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 diff --git a/lib/transmutation/serializer.rb b/lib/transmutation/serializer.rb index 3126864..4c3ea30 100644 --- a/lib/transmutation/serializer.rb +++ b/lib/transmutation/serializer.rb @@ -32,11 +32,10 @@ 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 @@ -44,6 +43,9 @@ 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 # @@ -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 @@ -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 @@ -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 @@ -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 diff --git a/spec/lib/transmutation/serialization_spec.rb b/spec/lib/transmutation/serialization_spec.rb index 5300365..2b2c4a0 100644 --- a/spec/lib/transmutation/serialization_spec.rb +++ b/spec/lib/transmutation/serialization_spec.rb @@ -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 diff --git a/spec/lib/transmutation/serializer_spec.rb b/spec/lib/transmutation/serializer_spec.rb index 7e64d96..84a002e 100644 --- a/spec/lib/transmutation/serializer_spec.rb +++ b/spec/lib/transmutation/serializer_spec.rb @@ -37,4 +37,106 @@ def initialize(first_name:, last_name:) expect(json.to_json).to eq("{\"first_name\":\"John\"}") end end + + describe "conditional rendering" do + context "with a Symbol condition" do + let(:example_serializer) do + Class.new(Transmutation::Serializer) do + attribute :first_name + attribute :last_name, if: :admin? + + def admin? + object.first_name == "John" + end + end + end + + it "includes the attribute when the condition is truthy" do + expect(json.as_json).to eq({ "first_name" => "John", "last_name" => "Doe" }) + end + + it "excludes the attribute when the condition is falsy" do + object = ExampleObject.new(first_name: "Jane", last_name: "Doe") + + expect(example_serializer.new(object).as_json).to eq({ "first_name" => "Jane" }) + end + end + + context "with a Proc condition" do + let(:example_serializer) do + Class.new(Transmutation::Serializer) do + attribute :first_name + attribute :last_name, if: -> { object.last_name == "Doe" } + end + end + + it "evaluates the proc in the serializer's context" do + expect(json.as_json).to eq({ "first_name" => "John", "last_name" => "Doe" }) + end + + it "excludes the attribute when the proc is falsy" do + object = ExampleObject.new(first_name: "John", last_name: "Smith") + + expect(example_serializer.new(object).as_json).to eq({ "first_name" => "John" }) + end + end + + context "with an unless condition" do + let(:example_serializer) do + Class.new(Transmutation::Serializer) do + attribute :first_name + attribute :last_name, unless: :hidden? + + def hidden? + object.first_name == "John" + end + end + end + + it "excludes the attribute when the condition is truthy" do + expect(json.as_json).to eq({ "first_name" => "John" }) + end + + it "includes the attribute when the condition is falsy" do + object = ExampleObject.new(first_name: "Jane", last_name: "Doe") + + expect(example_serializer.new(object).as_json).to eq({ "first_name" => "Jane", "last_name" => "Doe" }) + end + end + + context "with both if and unless conditions" do + let(:example_serializer) do + Class.new(Transmutation::Serializer) do + attribute :first_name + attribute :last_name, if: -> { object.first_name == "John" }, unless: -> { object.last_name == "Smith" } + end + end + + it "includes the attribute only when if is truthy and unless is falsy" do + expect(json.as_json).to eq({ "first_name" => "John", "last_name" => "Doe" }) + end + + it "excludes the attribute when unless is truthy" do + object = ExampleObject.new(first_name: "John", last_name: "Smith") + + expect(example_serializer.new(object).as_json).to eq({ "first_name" => "John" }) + end + end + + context "when applied via the attributes shorthand" do + let(:example_serializer) do + Class.new(Transmutation::Serializer) do + attributes :first_name, :last_name, if: :admin? + + def admin? + false + end + end + end + + it "applies the condition to every attribute" do + expect(json.as_json).to eq({}) + end + end + end end