mirror of
https://github.com/thoughtbot/factory_bot.git
synced 2022-11-09 11:43:51 -05:00
33f22f8c5b
Fixes #1411 Before this commit, anybody with Active Record enum attributes using factory_bot 6.0 would have seen an increase in memory every time they used any trait. This behavior can be seen with the following benchmark: ```rb require "bundler/inline" gemfile(true) do source "https://rubygems.org" git_source(:github) { |repo| "https://github.com/#{repo}.git" } gem "factory_bot", "~> 6.0.1" gem "activerecord" gem "sqlite3" end require "active_record" require "factory_bot" require "logger" ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:") ActiveRecord::Base.logger = Logger.new(STDOUT) ActiveRecord::Schema.define do create_table :posts, force: true do |t| t.integer :status end end class Post < ActiveRecord::Base enum status: ("a".."z").to_a end FactoryBot.define do factory :post do trait :x end end require "benchmark" TIMES = 50 FactoryBot.build(:post) Benchmark.bm do |bm| bm.report("false, false") do FactoryBot.automatically_define_enum_traits = false TIMES.times do FactoryBot.build(:post) end end bm.report("true, false") do FactoryBot.automatically_define_enum_traits = true TIMES.times do FactoryBot.build(:post) end end bm.report("false, true") do FactoryBot.automatically_define_enum_traits = false TIMES.times do FactoryBot.build(:post, :x) end end bm.report("true, true") do FactoryBot.automatically_define_enum_traits = true TIMES.times do FactoryBot.build(:post, :x) end end end ``` The first three cases work as expected, but in the last case, with ` FactoryBot.automatically_define_enum_traits = true` and the `:x` trait passed in at run time, the behavior before this commit was as follows: 1. When passing in traits, the [factory runner] calls [factory.with_traits], which clones the factory and then applies the traits to the clone. 2. Cloning the factory also clones the factory's definition 3. The cloned definition retains some of the state of the original definition, most notably [`@defined_traits` and `@registered_enums`][shared state], but it sets `@compiled` back to false. 4. When the [definition recompiles], it [re-registers enums] into `@register_enums`, duplicating what is already there. For each registered enum, it [adds the relevant traits] to @defined_traits, again duplicating what was already there. 5. This gets worse and worse every time a trait gets used, increasing the size of `@defined_traits` exponentially until the program grids to a halt. With this commit, we keep an additional piece of state to ensure we only register and apply enum traits in the original definition. When any cloned definitions recompile, they will skip these steps. [factory runner]:0785796f08/lib/factory_bot/factory_runner.rb (L16-L18)
[factory.with_traits]:0785796f08/lib/factory_bot/factory.rb (L93-L97)
[shared state]:0785796f08/lib/factory_bot/definition.rb (L10-L11)
[definition recompiles]:0785796f08/lib/factory_bot/definition.rb (L47-L49)
[re-registers enums]:0785796f08/lib/factory_bot/definition.rb (L146-L148)
[adds the relevant traits]:0785796f08/lib/factory_bot/definition.rb (L150-L153)
170 lines
4 KiB
Ruby
170 lines
4 KiB
Ruby
module FactoryBot
|
|
# @api private
|
|
class Definition
|
|
attr_reader :defined_traits, :declarations, :name, :registered_enums
|
|
|
|
def initialize(name, base_traits = [])
|
|
@name = name
|
|
@declarations = DeclarationList.new(name)
|
|
@callbacks = []
|
|
@defined_traits = Set.new
|
|
@registered_enums = []
|
|
@to_create = nil
|
|
@base_traits = base_traits
|
|
@additional_traits = []
|
|
@constructor = nil
|
|
@attributes = nil
|
|
@compiled = false
|
|
@expanded_enum_traits = false
|
|
end
|
|
|
|
delegate :declare_attribute, to: :declarations
|
|
|
|
def attributes
|
|
@attributes ||= AttributeList.new.tap do |attribute_list|
|
|
attribute_lists = aggregate_from_traits_and_self(:attributes) { declarations.attributes }
|
|
attribute_lists.each do |attributes|
|
|
attribute_list.apply_attributes attributes
|
|
end
|
|
end
|
|
end
|
|
|
|
def to_create(&block)
|
|
if block_given?
|
|
@to_create = block
|
|
else
|
|
aggregate_from_traits_and_self(:to_create) { @to_create }.last
|
|
end
|
|
end
|
|
|
|
def constructor
|
|
aggregate_from_traits_and_self(:constructor) { @constructor }.last
|
|
end
|
|
|
|
def callbacks
|
|
aggregate_from_traits_and_self(:callbacks) { @callbacks }
|
|
end
|
|
|
|
def compile(klass = nil)
|
|
unless @compiled
|
|
expand_enum_traits(klass) unless klass.nil?
|
|
|
|
declarations.attributes
|
|
|
|
defined_traits.each do |defined_trait|
|
|
base_traits.each { |bt| bt.define_trait defined_trait }
|
|
additional_traits.each { |at| at.define_trait defined_trait }
|
|
end
|
|
|
|
@compiled = true
|
|
end
|
|
end
|
|
|
|
def overridable
|
|
declarations.overridable
|
|
self
|
|
end
|
|
|
|
def inherit_traits(new_traits)
|
|
@base_traits += new_traits
|
|
end
|
|
|
|
def append_traits(new_traits)
|
|
@additional_traits += new_traits
|
|
end
|
|
|
|
def add_callback(callback)
|
|
@callbacks << callback
|
|
end
|
|
|
|
def skip_create
|
|
@to_create = ->(instance) {}
|
|
end
|
|
|
|
def define_trait(trait)
|
|
@defined_traits.add(trait)
|
|
end
|
|
|
|
def register_enum(enum)
|
|
@registered_enums << enum
|
|
end
|
|
|
|
def define_constructor(&block)
|
|
@constructor = block
|
|
end
|
|
|
|
def before(*names, &block)
|
|
callback(*names.map { |name| "before_#{name}" }, &block)
|
|
end
|
|
|
|
def after(*names, &block)
|
|
callback(*names.map { |name| "after_#{name}" }, &block)
|
|
end
|
|
|
|
def callback(*names, &block)
|
|
names.each do |name|
|
|
add_callback(Callback.new(name, block))
|
|
end
|
|
end
|
|
|
|
private
|
|
|
|
def base_traits
|
|
@base_traits.map { |name| trait_by_name(name) }
|
|
end
|
|
|
|
def additional_traits
|
|
@additional_traits.map { |name| trait_by_name(name) }
|
|
end
|
|
|
|
def trait_by_name(name)
|
|
trait_for(name) || Internal.trait_by_name(name)
|
|
end
|
|
|
|
def trait_for(name)
|
|
@defined_traits_by_name ||= defined_traits.each_with_object({}) { |t, memo| memo[t.name] ||= t }
|
|
@defined_traits_by_name[name.to_s]
|
|
end
|
|
|
|
def initialize_copy(source)
|
|
super
|
|
@attributes = nil
|
|
@compiled = false
|
|
@defined_traits_by_name = nil
|
|
end
|
|
|
|
def aggregate_from_traits_and_self(method_name, &block)
|
|
compile
|
|
|
|
[
|
|
base_traits.map(&method_name),
|
|
instance_exec(&block),
|
|
additional_traits.map(&method_name)
|
|
].flatten.compact
|
|
end
|
|
|
|
def expand_enum_traits(klass)
|
|
return if @expanded_enum_traits
|
|
|
|
if automatically_register_defined_enums?(klass)
|
|
automatically_register_defined_enums(klass)
|
|
end
|
|
|
|
registered_enums.each do |enum|
|
|
traits = enum.build_traits(klass)
|
|
traits.each { |trait| define_trait(trait) }
|
|
end
|
|
|
|
@expanded_enum_traits = true
|
|
end
|
|
|
|
def automatically_register_defined_enums(klass)
|
|
klass.defined_enums.each_key { |name| register_enum(Enum.new(name)) }
|
|
end
|
|
|
|
def automatically_register_defined_enums?(klass)
|
|
FactoryBot.automatically_define_enum_traits &&
|
|
klass.respond_to?(:defined_enums)
|
|
end
|
|
end
|
|
end
|