diff --git a/lib/active_model/entity/serializers/json.rb b/lib/active_model/entity/serializers/json.rb index b95c89b..e36e376 100644 --- a/lib/active_model/entity/serializers/json.rb +++ b/lib/active_model/entity/serializers/json.rb @@ -26,33 +26,33 @@ def inherited(subclass) # @param block [Proc] The block to use for serialization. Object or hash and options are passed as arguments. def serializes(attribute, &block) custom_serializers[attribute.to_s] = block + @represent_plans = nil + end + + # Invalidate the compiled represent plans when a new attribute is defined. + def attribute(...) + @represent_plans = nil + super end def fetch_field_value(object_or_hash, name) if object_or_hash.is_a?(Hash) - object_or_hash[name] + value = object_or_hash[name] + value.nil? ? object_or_hash[name.to_sym] : value else object_or_hash.send(name) end end def represent(object_or_hash, options = {}) - entity_options = default_represent_options.merge(options) - - camelize = entity_options[:camelize] - - object_or_hash = object_or_hash.with_indifferent_access if object_or_hash.is_a?(Hash) - - attribute_types.each_with_object({}) do |(name, type), memo| - json_name = camelize ? name.camelcase(:lower) : name + entity_options = options.empty? ? default_represent_options : default_represent_options.merge(options) - custom_serializer = custom_serializers[name] + # Custom serializer blocks receive the source hash itself and may look fields + # up by string or symbol, so they keep getting an indifferent-access copy. + object_or_hash = object_or_hash.with_indifferent_access if custom_serializers.any? && object_or_hash.is_a?(Hash) - if custom_serializer.present? - custom_serializer.call(object_or_hash, entity_options) - else - fetch_field_value(object_or_hash, name) - end => value + represent_plan(entity_options[:camelize]).each_with_object({}) do |(json_name, name, type, custom_serializer), memo| + value = custom_serializer ? custom_serializer.call(object_or_hash, entity_options) : fetch_field_value(object_or_hash, name) memo[json_name] = type.serialize_with_options(value, options) end @@ -63,6 +63,22 @@ def represent(object_or_hash, options = {}) def default_represent_options { camelize: true } end + + private + + # Compiled once per exact class (class-level ivars are not inherited) and + # per camelization mode: one [json_name, name, type, custom_serializer] + # tuple per attribute, so name camelization and serializer lookups happen + # once instead of on every +represent+ call. + def represent_plan(camelize) + plans = (@represent_plans ||= {}) + + plans[camelize ? :camelized : :plain] ||= attribute_types.map do |name, type| + json_name = camelize ? name.camelcase(:lower) : name + + [json_name, name, type, custom_serializers[name]] + end + end end end end diff --git a/spec/performance/represent_spec.rb b/spec/performance/represent_spec.rb new file mode 100644 index 0000000..b13df1d --- /dev/null +++ b/spec/performance/represent_spec.rb @@ -0,0 +1,146 @@ +# frozen_string_literal: true + +# Entities used by the representation performance spec. +# +# Excluded from the default suite — run with: +# PERFORMANCE=1 bundle exec rspec spec/performance +# +# The graph mirrors a realistically "wide" payload: +# Catalog (root entity) +# └── products: array of 5000 Product entities, each with +# ├── regular fields (integer, string, float, boolean, datetime, array of strings) +# └── dimensions: a nested Dimensions entity with a few fields +module RepresentPerformanceTest + # How many nested entities the root entity carries. + NESTED_ENTITIES_COUNT = 5_000 + + # Generous per-call budget for representing the whole catalog. A healthy run is + # ~200ms on a laptop; the budget only guards against catastrophic regressions + # (e.g. accidentally quadratic serialization) without flaking on slow hardware. + TIME_BUDGET_SECONDS = 2.0 + + class Dimensions + include ActiveModel::Entity + + attribute :width, :float + attribute :height, :float + attribute :unit, :string + end + + class Product + include ActiveModel::Entity + + attribute :id, :integer + attribute :name, :string + attribute :price, :float + attribute :in_stock, :boolean + attribute :created_at, :datetime + attribute :tags, :array, of: :string + attribute :dimensions, :entity, class_name: "RepresentPerformanceTest::Dimensions" + end + + class Catalog + include ActiveModel::Entity + + attribute :id, :integer + attribute :name, :string + attribute :products, :array, of: "RepresentPerformanceTest::Product" + end +end + +RSpec.describe "ActiveModel::Entity representation performance", :performance do + let(:items_count) { RepresentPerformanceTest::NESTED_ENTITIES_COUNT } + let(:created_at) { Time.utc(2024, 6, 1, 12, 30) } + + def represent_catalog(source) + RepresentPerformanceTest::Catalog.represent(source) + end + + def measure_represent(source, iterations: 5, warmup: 2) + warmup.times { represent_catalog(source) } + + Array.new(iterations) do + started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC) + represent_catalog(source) + Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at + end + end + + def report_timings(label, timings) + milliseconds = timings.map { _1 * 1000 } + average = milliseconds.sum / milliseconds.size + stats = "min #{milliseconds.min.round(1)}ms / avg #{average.round(1)}ms / max #{milliseconds.max.round(1)}ms over #{milliseconds.size} runs" + + RSpec.configuration.reporter.message("Catalog.represent (#{label}, #{items_count} products): #{stats}") + end + + shared_examples "a large catalog representation" do |label| + it "represents every nested entity" do + json = represent_catalog(source) + + expect(json["products"].length).to eq(items_count) + expect(json["products"].first).to eq( + "id" => 0, + "name" => "Product 0", + "price" => 0.0, + "inStock" => true, + "createdAt" => created_at, + "tags" => %w[tag-0 featured], + "dimensions" => { "width" => 10.0, "height" => 20.0, "unit" => "cm" } + ) + end + + it "stays within the time budget" do + timings = measure_represent(source) + + report_timings(label, timings) + + # The fastest run is the least noisy statistic on shared CI hardware. + expect(timings.min).to be < RepresentPerformanceTest::TIME_BUDGET_SECONDS + end + end + + context "when representing a hash payload" do + let(:source) do + { + id: 1, + name: "Catalog", + products: Array.new(items_count) do |index| + { + id: index, + name: "Product #{index}", + price: index * 1.5, + in_stock: index.even?, + created_at:, + tags: ["tag-#{index % 10}", "featured"], + dimensions: { width: 10.0 + index, height: 20.0 + index, unit: "cm" } + } + end + } + end + + include_examples "a large catalog representation", "hash payload" + end + + context "when representing an entity object graph" do + let(:source) do + RepresentPerformanceTest::Catalog.new( + id: 1, + name: "Catalog", + products: Array.new(items_count) do |index| + RepresentPerformanceTest::Product.new( + id: index, + name: "Product #{index}", + price: index * 1.5, + in_stock: index.even?, + created_at:, + tags: ["tag-#{index % 10}", "featured"], + dimensions: RepresentPerformanceTest::Dimensions.new(width: 10.0 + index, height: 20.0 + index, unit: "cm") + ) + end + ) + end + + include_examples "a large catalog representation", "entity object graph" + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 592f574..e1d30c4 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -6,6 +6,9 @@ # Enable flags like --only-failures and --next-failure config.example_status_persistence_file_path = ".rspec_status" + # Performance specs are opt-in: PERFORMANCE=1 bundle exec rspec spec/performance + config.filter_run_excluding :performance unless ENV["PERFORMANCE"] + # Disable RSpec exposing methods globally on `Module` and `main` config.disable_monkey_patching!