Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 31 additions & 15 deletions lib/active_model/entity/serializers/json.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since we are talking optimizations here, shall this “try this, then try that” be gone too?

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
Expand All @@ -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
Expand Down
146 changes: 146 additions & 0 deletions spec/performance/represent_spec.rb
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions spec/spec_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is it take much to run? maybe make them on by default?


# Disable RSpec exposing methods globally on `Module` and `main`
config.disable_monkey_patching!

Expand Down
Loading