1
0
Fork 0
mirror of https://github.com/thoughtbot/factory_bot.git synced 2022-11-09 11:43:51 -05:00
thoughtbot--factory_bot/lib/factory_bot/definition.rb
Daniel Colson 33f22f8c5b Ensure enum traits only get expanded once
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)
2020-06-19 23:44:32 -04:00

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