From 8b539b4bc29e5d2455bc88c828ef9e63e837fba7 Mon Sep 17 00:00:00 2001 From: HolyWalley Date: Sat, 8 Aug 2026 16:51:15 +0200 Subject: [PATCH 1/4] [BAC-1490] Serialize pre-cast values with serialize_cast_value MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `represent` called `type.serialize_with_options` on every leaf, which re-runs the full cast: `Boolean#serialize` does a `FALSE_VALUES` Set lookup per value, `Float#serialize` re-runs `Helpers::Numeric#cast`, and so on. When the source is one of our own instances that work is redundant — the values were already cast by these exact types on assignment. ActiveModel models this as `serialize_cast_value` (already used in `attribute.rb`). Compatibility is resolved once at plan-compile time rather than per value, since `SerializeCastValue.serialize` re-derives it behind a `rescue`. The guard is `instance_of?(self)`, not `!is_a?(Hash)`: an ActiveRecord model is not a Hash either, but its values were cast by AR's types, not ours. Custom serializers are excluded in the plan because their output never went through the type. Nested :entity/:array types are not compatible and keep recursing as before. Entity object graph in spec/performance: 33.8ms -> 27.4ms min over 5 runs. Hash payloads are unchanged, as they must be — raw input still needs coercion. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PWj42911KiUWpDA4BkpLPh --- lib/active_model/entity/serializers/json.rb | 46 +++++++++++++++++---- 1 file changed, 37 insertions(+), 9 deletions(-) diff --git a/lib/active_model/entity/serializers/json.rb b/lib/active_model/entity/serializers/json.rb index e36e376..55cacaf 100644 --- a/lib/active_model/entity/serializers/json.rb +++ b/lib/active_model/entity/serializers/json.rb @@ -51,11 +51,12 @@ def represent(object_or_hash, options = {}) # 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) - 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 + # Values read off one of our own instances were already cast by these exact + # types, so +serialize_cast_value+ is valid for them and skips the redundant + # re-casting work +serialize+ would redo on every field. Anything else -- + # a Hash, an ActiveRecord model, an OpenStruct -- carries values this class + # never cast, so it keeps the full +serialize+ path. + build_representation(object_or_hash, options, entity_options, object_or_hash.instance_of?(self)) end # Default options for representing an entity. @@ -66,19 +67,46 @@ def default_represent_options private + # +pre_cast+ says whether the source's values already went through these types. + # +serialize_cast_value+ is a no-op for +nil+ on every compatible type, so no + # nil guard is needed here. + def build_representation(source, options, entity_options, pre_cast) + represent_plan(entity_options[:camelize]).each_with_object({}) do |(json_name, name, type, custom_serializer, cast_value_serializable), memo| + value = custom_serializer ? custom_serializer.call(source, entity_options) : fetch_field_value(source, name) + + memo[json_name] = if pre_cast && cast_value_serializable + type.serialize_cast_value(value) + else + type.serialize_with_options(value, options) + end + end + end + # 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. + # per camelization mode: one + # [json_name, name, type, custom_serializer, cast_value_serializable] tuple per + # attribute, so name camelization, serializer lookups and the + # serialize_cast_value compatibility check 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 + custom_serializer = custom_serializers[name] - [json_name, name, type, custom_serializers[name]] + [json_name, name, type, custom_serializer, !custom_serializer && cast_value_serializable?(type)] end end + + # Whether +type+ opted into ActiveModel's "this value was already cast by me" + # protocol. Resolved once at plan-compile time; +SerializeCastValue.serialize+ + # answers the same question but re-derives it (behind a +rescue+) per value. + # Nested :entity/:array types are not compatible and keep recursing normally. + def cast_value_serializable?(type) + type.respond_to?(:itself_if_serialize_cast_value_compatible) && + type.equal?(type.itself_if_serialize_cast_value_compatible) + end end end end From d56d9cd7c808764dfe08b3c642a872524790a3ef Mon Sep 17 00:00:00 2001 From: HolyWalley Date: Sat, 8 Aug 2026 17:05:56 +0200 Subject: [PATCH 2/4] [BAC-1490] Freeze the default represent options hash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `default_represent_options` returned a fresh `{ camelize: true }` on every call, and every nested entity calls it once — 10,001 hash allocations to represent the 5000-product performance fixture, ~28% of all allocations for that call, purely to read one key. Ruby 3.4's opt_hash_freeze makes a frozen static hash literal allocation-free: the same object comes back every time. Ruby 3.2/3.3 (still supported by the gemspec) get one wasted `freeze` call and no benefit, but no regression either. Preferred over memoizing the result, which would have broken the documented ability to override this method dynamically. The method is still called on every invocation, so overrides behave exactly as before. The hash reaches `serializes` blocks as `entity_options`. Nothing in the gem mutates it, but a block that did would now raise FrozenError rather than silently mutating a throwaway hash. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PWj42911KiUWpDA4BkpLPh --- lib/active_model/entity/serializers/json.rb | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/active_model/entity/serializers/json.rb b/lib/active_model/entity/serializers/json.rb index 55cacaf..56f629d 100644 --- a/lib/active_model/entity/serializers/json.rb +++ b/lib/active_model/entity/serializers/json.rb @@ -61,8 +61,12 @@ def represent(object_or_hash, options = {}) # Default options for representing an entity. # Override this method to provide custom default options for Entity + # + # Frozen so that Ruby 3.4+ (opt_hash_freeze) hands back the same object every + # call instead of allocating one. Every nested entity calls this once, so a + # large payload was allocating a hash per nested object just to read :camelize. def default_represent_options - { camelize: true } + { camelize: true }.freeze end private From 36156a757d968c74cecfce8e31acd54ed1a3966a Mon Sep 17 00:00:00 2001 From: HolyWalley Date: Sat, 8 Aug 2026 17:18:51 +0200 Subject: [PATCH 3/4] [BAC-1490] Inline the field read and drop each_with_object Two per-iteration costs in the represent loop, both multiplied by every attribute of every nested entity (~55k iterations for the 5000-product perf fixture): `each_with_object` allocates one object per call more than a plain `each` with an explicit memo hash, and is ~29% slower on this loop shape. That is once per nested entity, so it was 10,001 allocations on the fixture. `fetch_field_value` re-tested `is_a?(Hash)` for every field even though the answer is fixed for the whole call, and re-derived `name.to_sym` on each symbol key lookup. Hoisting the test out of the loop and interning the symbol once at plan-compile time is ~37% faster for symbol-keyed hashes and ~45% for object sources. String-keyed hashes hit on the first lookup and never consult the symbol, so they are unaffected. String keys still take precedence, matching `fetch_field_value`. `fetch_field_value` itself is public API and stays; it is just no longer on the hot path. spec/performance, min of 5 runs: hash payload 23.6ms -> 20.2ms entity object graph 26.3ms -> 21.6ms allocations 25004 -> 15003 per represent Serialized output is byte-identical (verified by hashing the full 942KB JSON). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PWj42911KiUWpDA4BkpLPh --- lib/active_model/entity/serializers/json.rb | 48 ++++++++++++++------- 1 file changed, 33 insertions(+), 15 deletions(-) diff --git a/lib/active_model/entity/serializers/json.rb b/lib/active_model/entity/serializers/json.rb index 56f629d..3c7f5ec 100644 --- a/lib/active_model/entity/serializers/json.rb +++ b/lib/active_model/entity/serializers/json.rb @@ -74,24 +74,41 @@ def default_represent_options # +pre_cast+ says whether the source's values already went through these types. # +serialize_cast_value+ is a no-op for +nil+ on every compatible type, so no # nil guard is needed here. + # + # This deliberately inlines +fetch_field_value+ rather than calling it: the + # Hash-vs-object test is constant for the whole call but would otherwise be + # re-run once per attribute of every nested entity. +each+ with an explicit + # memo is used over +each_with_object+, which allocates an extra object per + # call -- once per nested entity, so it adds up on large payloads. def build_representation(source, options, entity_options, pre_cast) - represent_plan(entity_options[:camelize]).each_with_object({}) do |(json_name, name, type, custom_serializer, cast_value_serializable), memo| - value = custom_serializer ? custom_serializer.call(source, entity_options) : fetch_field_value(source, name) - - memo[json_name] = if pre_cast && cast_value_serializable - type.serialize_cast_value(value) - else - type.serialize_with_options(value, options) - end + memo = {} + from_hash = source.is_a?(Hash) + + represent_plan(entity_options[:camelize]).each do |row| + json_name, name, sym, type, custom_serializer, cast_value_serializable = row + + value = if custom_serializer + custom_serializer.call(source, entity_options) + elsif from_hash + # String key wins, matching +fetch_field_value+; the pre-interned + # symbol is only consulted when the string key is absent. + (found = source[name]).nil? ? source[sym] : found + else + source.send(sym) + end + + memo[json_name] = pre_cast && cast_value_serializable ? type.serialize_cast_value(value) : type.serialize_with_options(value, options) end + + memo end - # Compiled once per exact class (class-level ivars are not inherited) and - # per camelization mode: one - # [json_name, name, type, custom_serializer, cast_value_serializable] tuple per - # attribute, so name camelization, serializer lookups and the - # serialize_cast_value compatibility check happen once instead of on every - # +represent+ call. + # Compiled once per exact class (class-level ivars are not inherited) and per + # camelization mode: one + # [json_name, name, sym, type, custom_serializer, cast_value_serializable] + # tuple per attribute, so name camelization, symbol interning, serializer + # lookups and the serialize_cast_value compatibility check happen once instead + # of on every +represent+ call. def represent_plan(camelize) plans = (@represent_plans ||= {}) @@ -99,7 +116,8 @@ def represent_plan(camelize) json_name = camelize ? name.camelcase(:lower) : name custom_serializer = custom_serializers[name] - [json_name, name, type, custom_serializer, !custom_serializer && cast_value_serializable?(type)] + [json_name, name, name.to_sym, type, custom_serializer, + !custom_serializer && cast_value_serializable?(type)] end end From c77f57b31eb2f0141baa1b4d2673acc2e5a71cbe Mon Sep 17 00:00:00 2001 From: HolyWalley Date: Sat, 8 Aug 2026 17:40:01 +0200 Subject: [PATCH 4/4] [BAC-1490] Represent arrays of entities in one pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Type::Array` serialized an array of entities by calling `Type::Entity#serialize_with_options` per element, which called `represent` per element, which re-ran the whole preamble — options resolution, the `custom_serializers` lookup and the compiled plan lookup — for all 5000 elements even though every one of them produces the same answer. `represent_all` resolves that once and loops. Only `instance_of?` and the Hash test stay per element, since those genuinely vary: heterogeneous arrays, nil holes and raw hashes all keep behaving exactly as before. Measured per element on a small entity, the removed method frames are worth nothing (1151ns vs 1163ns — Ruby calls are cheap); the hoisted preamble is the entire win, 1209ns -> 973ns. `build_representation` now takes the plan as an argument so `represent` and `represent_all` share one loop body rather than duplicating it. The dispatch in `Type::Array` checks the exact class, not `is_a?`: a `Type::Entity` subclass may override `serialize_with_options`, and routing around it would silently change behaviour. spec/performance, min of 5 runs: hash payload 20.7ms -> 18.9ms entity object graph 22.5ms -> 21.0ms Flat ~8% from arrays of 5 upward; a 1-element array is neutral. Allocations are unchanged (15003) — this removes work, not objects. Serialized output is byte-identical, verified by hashing the full 942KB JSON and by diffing 12 edge cases (nil holes, empty and nil arrays, string- and symbol-keyed hashes, subclass instances, mixed entity/hash arrays, custom serializers, camelize: false). `represent_all` is public, so collections built by hand outside an entity attribute can use it directly. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PWj42911KiUWpDA4BkpLPh --- lib/active_model/entity/serializers/json.rb | 24 ++++++++++++++--- lib/active_model/entity/type/array.rb | 10 ++++++- .../entity/serializers/json_spec.rb | 27 +++++++++++++++++++ 3 files changed, 57 insertions(+), 4 deletions(-) diff --git a/lib/active_model/entity/serializers/json.rb b/lib/active_model/entity/serializers/json.rb index 3c7f5ec..1571ed7 100644 --- a/lib/active_model/entity/serializers/json.rb +++ b/lib/active_model/entity/serializers/json.rb @@ -56,7 +56,25 @@ def represent(object_or_hash, options = {}) # re-casting work +serialize+ would redo on every field. Anything else -- # a Hash, an ActiveRecord model, an OpenStruct -- carries values this class # never cast, so it keeps the full +serialize+ path. - build_representation(object_or_hash, options, entity_options, object_or_hash.instance_of?(self)) + build_representation(object_or_hash, options, entity_options, + object_or_hash.instance_of?(self), represent_plan(entity_options[:camelize])) + end + + # Represents a whole collection in one pass. The resolved options, the custom + # serializer lookup and the compiled plan are the same for every element, so + # they are computed once here rather than once per element. +:array+ attributes + # of entities route through this, so nested collections get it automatically. + def represent_all(sources, options = {}) + entity_options = options.empty? ? default_represent_options : default_represent_options.merge(options) + plan = represent_plan(entity_options[:camelize]) + has_custom_serializers = custom_serializers.any? + + sources.map do |source| + next nil if source.nil? + + source = source.with_indifferent_access if has_custom_serializers && source.is_a?(Hash) + build_representation(source, options, entity_options, source.instance_of?(self), plan) + end end # Default options for representing an entity. @@ -80,11 +98,11 @@ def default_represent_options # re-run once per attribute of every nested entity. +each+ with an explicit # memo is used over +each_with_object+, which allocates an extra object per # call -- once per nested entity, so it adds up on large payloads. - def build_representation(source, options, entity_options, pre_cast) + def build_representation(source, options, entity_options, pre_cast, plan) memo = {} from_hash = source.is_a?(Hash) - represent_plan(entity_options[:camelize]).each do |row| + plan.each do |row| json_name, name, sym, type, custom_serializer, cast_value_serializable = row value = if custom_serializer diff --git a/lib/active_model/entity/type/array.rb b/lib/active_model/entity/type/array.rb index 2ed2d4e..0c02400 100644 --- a/lib/active_model/entity/type/array.rb +++ b/lib/active_model/entity/type/array.rb @@ -49,7 +49,15 @@ def serialize(value) def serialize_with_options(value, options = {}) return nil if value.nil? - value.map { element_type.serialize_with_options(_1, options) } + element = element_type + + # An array of entities resolves the represent plan and options once for the + # whole collection instead of once per element. The check is deliberately on + # the exact class: a Type::Entity subclass may override serialize_with_options, + # and routing around it would silently change behaviour. + return element.entity_type.represent_all(value, options) if element.instance_of?(::ActiveModel::Entity::Type::Entity) + + value.map { element.serialize_with_options(_1, options) } end def element_type diff --git a/spec/active_model/entity/serializers/json_spec.rb b/spec/active_model/entity/serializers/json_spec.rb index 8b65de1..d1302ae 100644 --- a/spec/active_model/entity/serializers/json_spec.rb +++ b/spec/active_model/entity/serializers/json_spec.rb @@ -116,4 +116,31 @@ class Person }) end end + + context "representing a collection" do + it "represents every element, mixing entities, hashes and nils" do + json = SerializersTest::Role.represent_all( + [SerializersTest::Role.new(field_name: "nom"), nil, { field_name: "prenom" }], { hidden: true } + ) + + expect(json.map { _1&.deep_symbolize_keys }).to eq([ + { fieldName: "nom", fieldNameUpcase: "NOM", fieldNameWithOptions: "***" }, + nil, + { fieldName: "prenom", fieldNameUpcase: "PRENOM", fieldNameWithOptions: "***" } + ]) + end + + it "matches what represent produces element by element" do + sources = [SerializersTest::Role.new(field_name: "nom"), { field_name: "prenom" }] + + expect(SerializersTest::Role.represent_all(sources, { hidden: true })) + .to eq(sources.map { SerializersTest::Role.represent(_1, { hidden: true }) }) + end + + it "passes options through and honours camelize: false" do + json = SerializersTest::Role.represent_all([{ field_name: "nom" }], { camelize: false }) + + expect(json).to eq([{ "field_name" => "nom", "field_name_upcase" => "NOM", "field_name_with_options" => "nom" }]) + end + end end