factory_bot_caching 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,34 @@
1
+ # The MIT License (MIT)
2
+ #
3
+ # Copyright (c) 2017-2018 Avant
4
+ #
5
+ # Author Tim Mertens
6
+ #
7
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
8
+ # of this software and associated documentation files (the "Software"), to deal
9
+ # in the Software without restriction, including without limitation the rights
10
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11
+ # copies of the Software, and to permit persons to whom the Software is
12
+ # furnished to do so, subject to the following conditions:
13
+ #
14
+ # The above copyright notice and this permission notice shall be included in
15
+ # all copies or substantial portions of the Software.
16
+ #
17
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
23
+ # THE SOFTWARE.
24
+
25
+ require 'factory_girl'
26
+
27
+ require 'factory_bot_caching/version'
28
+ require 'factory_bot_caching/caching_factory_runner'
29
+
30
+ module FactoryBotCaching
31
+ def self.initialize
32
+ FactoryGirl::FactoryRunner.prepend(FactoryBotCaching::CachingFactoryRunner)
33
+ end
34
+ end
@@ -0,0 +1,55 @@
1
+ # The MIT License (MIT)
2
+ #
3
+ # Copyright (c) 2017-2018 Avant
4
+ #
5
+ # Author Tim Mertens
6
+ #
7
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
8
+ # of this software and associated documentation files (the "Software"), to deal
9
+ # in the Software without restriction, including without limitation the rights
10
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11
+ # copies of the Software, and to permit persons to whom the Software is
12
+ # furnished to do so, subject to the following conditions:
13
+ #
14
+ # The above copyright notice and this permission notice shall be included in
15
+ # all copies or substantial portions of the Software.
16
+ #
17
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
23
+ # THE SOFTWARE.
24
+
25
+ require_relative 'factory_cache'
26
+
27
+ module FactoryBotCaching
28
+ class CacheManager
29
+ def self.instance
30
+ @instance ||= self.new
31
+ end
32
+
33
+ def initialize
34
+ @factory_cache = Hash.new do |hash, key|
35
+ hash[key] = FactoryCache.new(factory_name: key)
36
+ end
37
+ end
38
+
39
+ def reset_cache
40
+ factory_cache.each_value(&:reset)
41
+ end
42
+
43
+ def reset_cache_counter
44
+ factory_cache.each_value(&:reset_counter)
45
+ end
46
+
47
+ def fetch(name:, overrides:, traits:, &block)
48
+ factory_cache[name].fetch(overrides: overrides, traits: traits, &block)
49
+ end
50
+
51
+ private
52
+
53
+ attr_reader :factory_cache
54
+ end
55
+ end
@@ -0,0 +1,43 @@
1
+ # The MIT License (MIT)
2
+ #
3
+ # Copyright (c) 2017-2018 Avant
4
+ #
5
+ # Author Tim Mertens
6
+ #
7
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
8
+ # of this software and associated documentation files (the "Software"), to deal
9
+ # in the Software without restriction, including without limitation the rights
10
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11
+ # copies of the Software, and to permit persons to whom the Software is
12
+ # furnished to do so, subject to the following conditions:
13
+ #
14
+ # The above copyright notice and this permission notice shall be included in
15
+ # all copies or substantial portions of the Software.
16
+ #
17
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
23
+ # THE SOFTWARE.
24
+
25
+ require_relative 'cache_manager'
26
+ require_relative 'module_methods'
27
+
28
+ module FactoryBotCaching
29
+ module CachingFactoryRunner
30
+ def run(runner_strategy = @strategy, &block)
31
+ # We have to define the cache here so that it has access to super.
32
+ if FactoryBotCaching.enabled? && runner_strategy == :create
33
+ CacheManager.instance.fetch(name: @name,
34
+ overrides: @overrides,
35
+ traits: @traits) do
36
+ super
37
+ end
38
+ else
39
+ super
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,56 @@
1
+ # The MIT License (MIT)
2
+ #
3
+ # Copyright (c) 2017-2018 Avant
4
+ #
5
+ # Author Tim Mertens
6
+ #
7
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
8
+ # of this software and associated documentation files (the "Software"), to deal
9
+ # in the Software without restriction, including without limitation the rights
10
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11
+ # copies of the Software, and to permit persons to whom the Software is
12
+ # furnished to do so, subject to the following conditions:
13
+ #
14
+ # The above copyright notice and this permission notice shall be included in
15
+ # all copies or substantial portions of the Software.
16
+ #
17
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
23
+ # THE SOFTWARE.
24
+
25
+ module FactoryBotCaching
26
+ class Config
27
+ FIFTEEN_MINUTES_IN_SECONDS = 900
28
+
29
+ def initialize
30
+ @factory_caching_enabled = false
31
+ @custom_cache_key = nil
32
+ @cache_timeout = FIFTEEN_MINUTES_IN_SECONDS
33
+ end
34
+
35
+ attr_reader :factory_caching_enabled, :custom_cache_key, :cache_timeout
36
+ alias_method :factory_caching_enabled?, :factory_caching_enabled
37
+
38
+ def cache_timeout=(seconds)
39
+ raise ArgumentError, 'Cache timeout must be an Integer!' unless seconds.is_a?(Integer)
40
+ @cache_timeout = seconds
41
+ end
42
+
43
+ def enable_factory_caching
44
+ @factory_caching_enabled = true
45
+ end
46
+
47
+ def disable_factory_caching
48
+ @factory_caching_enabled = false
49
+ end
50
+
51
+ def custom_cache_key=(block)
52
+ raise ArgumentError, 'The custom cache key must be a Proc!' unless block.instance_of?(Proc)
53
+ @custom_cache_key = block
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,54 @@
1
+ # The MIT License (MIT)
2
+ #
3
+ # Copyright (c) 2017-2018 Avant
4
+ #
5
+ # Author Tim Mertens
6
+ #
7
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
8
+ # of this software and associated documentation files (the "Software"), to deal
9
+ # in the Software without restriction, including without limitation the rights
10
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11
+ # copies of the Software, and to permit persons to whom the Software is
12
+ # furnished to do so, subject to the following conditions:
13
+ #
14
+ # The above copyright notice and this permission notice shall be included in
15
+ # all copies or substantial portions of the Software.
16
+ #
17
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
23
+ # THE SOFTWARE.
24
+
25
+ require_relative 'factory_record_cache'
26
+ require 'i18n'
27
+
28
+ module FactoryBotCaching
29
+ class CustomizedCache
30
+ def initialize(build_class:, cache_key_generator:)
31
+ @cache_key_generator = cache_key_generator
32
+ @cache = Hash.new do |hash, key|
33
+ hash[key] = FactoryRecordCache.new(build_class: build_class)
34
+ end
35
+ end
36
+
37
+ def fetch(overrides:, traits:, &block)
38
+ customized_cache.fetch(overrides: overrides, traits: traits, &block)
39
+ end
40
+
41
+ def reset_counters
42
+ cache.each_value(&:reset_counters)
43
+ end
44
+
45
+ private
46
+
47
+ attr_reader :cache, :cache_key_generator
48
+
49
+ def customized_cache
50
+ key = cache_key_generator.call
51
+ cache[key]
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,123 @@
1
+ # The MIT License (MIT)
2
+ #
3
+ # Copyright (c) 2017-2018 Avant
4
+ #
5
+ # Author Tim Mertens
6
+ #
7
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
8
+ # of this software and associated documentation files (the "Software"), to deal
9
+ # in the Software without restriction, including without limitation the rights
10
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11
+ # copies of the Software, and to permit persons to whom the Software is
12
+ # furnished to do so, subject to the following conditions:
13
+ #
14
+ # The above copyright notice and this permission notice shall be included in
15
+ # all copies or substantial portions of the Software.
16
+ #
17
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
23
+ # THE SOFTWARE.
24
+
25
+ require_relative 'customized_cache'
26
+
27
+ module FactoryBotCaching
28
+ class FactoryCache
29
+ def initialize(factory_name:)
30
+ @factory_name = factory_name
31
+ @build_class = FactoryGirl.factory_by_name(factory_name).build_class
32
+ @cache = new_cache(@build_class)
33
+ @cachable_overrides = []
34
+ collect_uncachable_traits
35
+ end
36
+
37
+ attr_reader :factory_name
38
+
39
+ def fetch(overrides:, traits:, &block)
40
+ key = { overrides: overrides, traits: traits}
41
+ if should_cache?(key)
42
+ cache.fetch(key, &block)
43
+ else
44
+ block.call
45
+ end
46
+ end
47
+
48
+ def reset
49
+ @cache = new_cache(build_class)
50
+ end
51
+
52
+ def reset_counter
53
+ cache.reset_counters
54
+ end
55
+
56
+ private
57
+
58
+ attr_reader :cache, :build_class
59
+
60
+ def new_cache(build_class)
61
+ if FactoryBotCaching.configuration.custom_cache_key.nil?
62
+ FactoryRecordCache.new(build_class: build_class)
63
+ else
64
+ CustomizedCache.new(
65
+ build_class: build_class,
66
+ cache_key_generator: FactoryBotCaching.configuration.custom_cache_key)
67
+ end
68
+ end
69
+
70
+ # Collect a list of traits that are considered 'uncachable' if passed in the overrides list.
71
+ # We collect two lists - belongs_to associations, which have a high probability to be overridden in
72
+ # a factory call and should be compared against first, followed by uncommon_associations
73
+ def collect_uncachable_traits
74
+ return unless build_class < ActiveRecord::Base
75
+
76
+ @common_associations = []
77
+ @uncommon_associations = []
78
+
79
+ reflections = build_class.reflect_on_all_associations
80
+ reflections.each do |reflection|
81
+ if reflection.macro == :belongs_to
82
+ @common_associations << reflection.name.to_sym
83
+ # In rails land, some foreign keys are symbols, some strings; coerce them here:
84
+ @common_associations << reflection.foreign_key.to_sym
85
+ else
86
+ @uncommon_associations << reflection.name.to_sym
87
+ end
88
+ end
89
+ end
90
+
91
+ attr_reader :common_associations, :uncommon_associations, :cachable_overrides
92
+
93
+ # Determine whether or not an overridden value in a factory call is cacheable.
94
+ # We use some caching and perform comparisons in order of probability which nets a 70x improvement in
95
+ # performance over previous versions of this validation.
96
+ #
97
+ # For example:
98
+ # FactoryGirl.create(:customer, name: 'John Doe', email: 'john.doe@example.test')
99
+ # FactoryGirl.create(:customer, name: 'John Doe', address_id: 123)
100
+ #
101
+ # In the above examples, `address_id` is a passed in association and should not be cached.
102
+ #
103
+ # @param override_sym [Symbol]
104
+ # @return [Boolean] true if the overridden value is cacheable, false otherwise
105
+ def cacheable_override?(override_sym)
106
+ # Search in order of probability to reduce lookup times:
107
+ return true if cachable_overrides.include?(override_sym)
108
+ return false if common_associations.include?(override_sym)
109
+ return false if uncommon_associations.include?(override_sym)
110
+ cachable_overrides << override_sym
111
+ true
112
+ end
113
+
114
+ # Skip caching for factories that are called with passed in associations, as it mutates the association and persisted record
115
+ def should_cache?(key)
116
+ return false unless build_class < ActiveRecord::Base
117
+
118
+ key[:overrides].all? do |key, v|
119
+ cacheable_override?(key.to_sym)
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,109 @@
1
+ # The MIT License (MIT)
2
+ #
3
+ # Copyright (c) 2017-2018 Avant
4
+ #
5
+ # Author Tim Mertens
6
+ #
7
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
8
+ # of this software and associated documentation files (the "Software"), to deal
9
+ # in the Software without restriction, including without limitation the rights
10
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11
+ # copies of the Software, and to permit persons to whom the Software is
12
+ # furnished to do so, subject to the following conditions:
13
+ #
14
+ # The above copyright notice and this permission notice shall be included in
15
+ # all copies or substantial portions of the Software.
16
+ #
17
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
23
+ # THE SOFTWARE.
24
+
25
+ require_relative 'immutable_iterator'
26
+ require_relative 'module_methods'
27
+
28
+ module FactoryBotCaching
29
+ class FactoryRecordCache
30
+ CacheEntry = Struct.new(:created_at, :identifier)
31
+
32
+ def initialize(build_class:)
33
+ @build_class = build_class
34
+ @cache = Hash.new do |factory_hash, key|
35
+ factory_hash[key] = []
36
+ end
37
+ reset_counters
38
+ end
39
+
40
+ def fetch(overrides:, traits:, &block)
41
+ key = {overrides: overrides, traits: traits}
42
+ now = Time.now
43
+ enumerator = enumerator_for(key, at: now)
44
+
45
+ enumerator.until_end do |entry|
46
+ # Entries are sorted by created at, so we can break as soon as we see an entry created_at after now
47
+ break if entry.created_at > now
48
+ record = build_class.find_by(build_class.primary_key => entry.identifier)
49
+ return record unless record.nil?
50
+ end
51
+
52
+ cache_new_record(key, &block)
53
+ end
54
+
55
+ def reset_counters
56
+ @enumerator_cache = Hash.new do |enumerator_hash, key|
57
+ enumerator_hash[key] = ImmutableIterator.new(cache[key])
58
+ end
59
+ end
60
+
61
+ private
62
+
63
+ attr_reader :build_class, :cache, :enumerator_cache
64
+
65
+ def enumerator_for(key, at:)
66
+ enumerator = enumerator_cache[key]
67
+ boundary_time = lookback_start_time(time: at)
68
+ enumerator.fast_forward { |entry| entry.created_at > boundary_time }
69
+ enumerator
70
+ end
71
+
72
+ def lookback_start_time(time: Time.now)
73
+ time - FactoryBotCaching.configuration.cache_timeout
74
+ end
75
+
76
+ def cache_new_record(cache_key, &block)
77
+ new_record = process_block(&block)
78
+ primary_key = new_record.class.primary_key
79
+
80
+ entry = CacheEntry.new(Time.now, new_record[primary_key])
81
+ cached_records = cache[cache_key]
82
+
83
+ insert_at_index = cached_records.find_index { |record| record.created_at > entry.created_at }
84
+ if insert_at_index.nil?
85
+ cached_records << entry
86
+ else
87
+ cached_records.insert(insert_at_index, entry)
88
+ end
89
+
90
+ new_record
91
+ end
92
+
93
+ def process_block(&block)
94
+ record = nil
95
+
96
+ Thread.new do
97
+ ActiveRecord::Base.connection_pool.with_connection do |conn|
98
+ conn.execute("SET statement_timeout = '20s'")
99
+ record = FactoryBotCaching.without_caching(&block)
100
+ end
101
+ end.join
102
+ record
103
+ rescue => e
104
+ # Append the backtrace of the current thread to the backtrace of the joined thread
105
+ e.set_backtrace(e.backtrace + caller)
106
+ raise e
107
+ end
108
+ end
109
+ end