fixture_kit 0.2.0 → 0.3.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 311fd09f37153fae9d8a09e3e41357c38f18e1e343794a758ae943dfdca3c40a
4
- data.tar.gz: ee4257fc3c840f809a25204f300c766f872688c5c7417dc22155f001ba424882
3
+ metadata.gz: 23151d16a20be7d13a1c4a15ce4dca10a2c742ba68a1d43af339bdad65490679
4
+ data.tar.gz: 1c9da63eae90bbe6843d204d138fafbf3b933f0ea4fd9f42c4108331509f472b
5
5
  SHA512:
6
- metadata.gz: bd8c58de66c08eeceab67e4f0b83a8a95d25291401272e96b51725db2b7a9f2b2592c24f1d6f6cb7b4f6b4716fa93fc768c4fc81afcf1974b9339e14b1b61fa9
7
- data.tar.gz: 8d335927771e40d35142a69ee9bf911fcfb6c660af6cde28269a16643e86a468c017cf2f54e362f09d90a78bfeffe6320071d0230f6240969fc495de0e91c8f2
6
+ metadata.gz: 3b75d5d28b153bb082f8097309c39c6d350633faac76e1b30e5ea7413cf3bccfa9b93a3941f6eff607306f43cd53a7bc5420fd1d331fc99c66ede324f82e3902
7
+ data.tar.gz: 92d48a55f2a92f4f1673a890e9bba8ce6695204b7a020e3240a9db21217747c3d0f7446297706cc634a15ea3246e29d0b0adcad870152cd449056c67b75cae45
data/README.md CHANGED
@@ -8,7 +8,7 @@ Test data setup is slow. Every `Model.create!` or `FactoryBot.create` hits the d
8
8
 
9
9
  ## The Solution
10
10
 
11
- FixtureKit caches database records as raw SQL INSERT statements. On first use, it executes your fixture definition, captures the resulting database state, and generates optimized batch INSERT statements. Subsequent loads replay these statements directlyno ORM overhead, no callbacks, just fast SQL.
11
+ FixtureKit caches database records as raw SQL INSERT statements. It executes your fixture definition once, captures the resulting database state, and generates optimized batch INSERT statements. Fixture loads then replay these statements directly: no ORM overhead, no callbacks, just fast SQL.
12
12
 
13
13
  Combined with RSpec's transactional fixtures, each test runs in a transaction that rolls back—so cached data can be reused across tests without cleanup.
14
14
 
@@ -26,7 +26,7 @@ end
26
26
 
27
27
  ### 1. Define a Fixture
28
28
 
29
- Create fixture files in `spec/fixture_kit/`. Use whatever method you prefer to create records—FixtureKit doesn't care.
29
+ Create fixture files in `spec/fixture_kit/` (or `test/fixture_kit/` for test-unit/minitest-style setups). Use whatever method you prefer to create records.
30
30
 
31
31
  **Using ActiveRecord directly:**
32
32
 
@@ -117,6 +117,8 @@ RSpec.configure do |config|
117
117
  end
118
118
  ```
119
119
 
120
+ When you call `fixture "name"` in an example group, FixtureKit registers that fixture with its runner.
121
+
120
122
  ## Configuration
121
123
 
122
124
  ```ruby
@@ -128,27 +130,35 @@ FixtureKit.configure do |config|
128
130
  # Where cache files are stored (default: tmp/cache/fixture_kit)
129
131
  config.cache_path = Rails.root.join("tmp/cache/fixture_kit").to_s
130
132
 
131
- # Whether to regenerate caches on every run (default: true)
132
- config.autogenerate = true
133
+ # Wrapper used to isolate generation work (default: FixtureKit::TestCase::Isolator)
134
+ # config.isolator = FixtureKit::TestCase::Isolator
135
+ # config.isolator = FixtureKit::RSpec::Isolator
133
136
 
134
- # Optional: customize how pregeneration is wrapped.
135
- # Default is FixtureKit::TestCase::Generator.
136
- # config.generator = FixtureKit::TestCase::Generator
137
+ # Optional callback, called once when a fixture cache is first generated.
138
+ # Receives the fixture name as a String.
139
+ # config.on_cache = ->(fixture_name) { puts "cached #{fixture_name}" }
137
140
  end
138
141
  ```
139
142
 
140
- Custom generators should subclass `FixtureKit::Generator` and implement `#run`.
141
- `#run` receives the pregeneration block and should execute it in whatever lifecycle you need.
143
+ Custom isolators should subclass `FixtureKit::Isolator` and implement `#run`.
144
+ `#run` receives the generation block and should execute it in whatever lifecycle you need.
145
+
146
+ By default, FixtureKit uses `FixtureKit::TestCase::Isolator`, which runs generation inside an internal `ActiveSupport::TestCase` and removes that harness case from minitest runnables.
142
147
 
143
- ### Autogenerate
148
+ When using `fixture_kit/rspec`, FixtureKit sets `FixtureKit::RSpec::Isolator`. It runs generation inside an internal RSpec example, and uses a null reporter so harness runs do not count toward suite example totals.
144
149
 
145
- When `autogenerate` is `true` (the default), FixtureKit clears all caches at the start of each test run, then regenerates them on first use. Subsequent tests that use the same fixture reuse the cache from earlier in the run. This ensures your test data always matches your fixture definitions.
150
+ ## Lifecycle
146
151
 
147
- When `autogenerate` is `false`, FixtureKit pre-generates all fixture caches at suite start. This runs through the configured `generator`, and still rolls back database changes.
152
+ Fixture generation is managed by `FixtureKit::Runner`.
148
153
 
149
- By default, FixtureKit uses `FixtureKit::TestCase::Generator`, which runs pregeneration inside an internal `ActiveSupport::TestCase` so setup/teardown hooks and transactional fixture behavior run as expected. The internal test case is removed from Minitest runnables, so it does not count toward suite totals.
154
+ With `fixture_kit/rspec`:
150
155
 
151
- When using `fixture_kit/rspec`, FixtureKit sets `FixtureKit::RSpec::Generator` as the generator. This runs pregeneration inside an internal RSpec example so your normal `before`/`around`/`after` hooks apply. The internal example uses a null reporter, so it does not count toward suite example totals.
156
+ 1. `fixture "name"` registers the fixture with the runner during spec file load.
157
+ 2. In `before(:suite)`, runner `start`:
158
+ - clears `cache_path` (unless preserve-cache is enabled),
159
+ - generates caches for all already-registered fixtures.
160
+ 3. If new spec files are loaded later (for example, queue-mode CI runners), newly registered fixtures are generated immediately because the runner has already started.
161
+ 4. At example runtime, fixture mounting loads from cache.
152
162
 
153
163
  ### Preserving Cache Locally
154
164
 
@@ -158,19 +168,9 @@ If you want to skip cache clearing at suite start (e.g., to reuse caches across
158
168
  FIXTURE_KIT_PRESERVE_CACHE=1 bundle exec rspec
159
169
  ```
160
170
 
161
- This is useful when you're iterating on tests and your fixture definitions haven't changed.
162
-
163
- ### CI Setup
164
-
165
- For CI, set `autogenerate` to `false`. FixtureKit will automatically generate any missing caches at suite start:
171
+ Truthy values are case-insensitive: `1`, `true`, `yes`.
166
172
 
167
- ```ruby
168
- FixtureKit.configure do |config|
169
- config.autogenerate = !ENV["CI"]
170
- end
171
- ```
172
-
173
- This means CI "just works" - no need to pre-generate caches or commit them to the repository. The first test run will generate all caches, and subsequent runs (if caches are preserved between builds) will reuse them.
173
+ This is useful when you're iterating on tests and your fixture definitions haven't changed.
174
174
 
175
175
  ## Nested Fixtures
176
176
 
@@ -189,11 +189,11 @@ fixture "teams/sales"
189
189
 
190
190
  ## How It Works
191
191
 
192
- 1. **First load (cache miss)**: FixtureKit executes your definition block, subscribes to `sql.active_record` notifications to track which tables received INSERTs, queries all records from those tables, and generates batch INSERT statements with conflict handling (`INSERT OR IGNORE` for SQLite, `ON CONFLICT DO NOTHING` for PostgreSQL, `INSERT IGNORE` for MySQL).
192
+ 1. **Cache generation**: FixtureKit executes your definition block inside the configured isolator, subscribes to `sql.active_record` notifications to track inserted models, queries those model tables, and generates batch INSERT statements with conflict handling (`INSERT OR IGNORE` for SQLite, `ON CONFLICT DO NOTHING` for PostgreSQL, `INSERT IGNORE` for MySQL).
193
193
 
194
- 2. **Subsequent loads (cache hit)**: FixtureKit loads the cached JSON file and executes the raw SQL INSERT statements directly. No ORM instantiation, no callbacks—just fast SQL execution.
194
+ 2. **Mounting**: FixtureKit loads the cached JSON file and executes the raw SQL INSERT statements directly. No ORM instantiation, no callbacks.
195
195
 
196
- 3. **In-memory caching**: Once a cache file is parsed, the data is stored in memory. Multiple tests using the same fixture within a single test run don't re-read or re-parse the JSON file.
196
+ 3. **Repository build**: FixtureKit resolves exposed records by model + id and returns a `Repository` for method-based access.
197
197
 
198
198
  4. **Transaction isolation**: RSpec's `use_transactional_fixtures` wraps each test in a transaction that rolls back, so data doesn't persist between tests.
199
199
 
@@ -216,7 +216,7 @@ Caches are stored as JSON files in `tmp/cache/fixture_kit/`:
216
216
  ```
217
217
 
218
218
  - **records**: Maps model names to their INSERT statements. Using model names (not table names) allows FixtureKit to use the correct database connection for multi-database setups.
219
- - **exposed**: Maps fixture accessor names to their model class and ID for querying after cache replay
219
+ - **exposed**: Maps fixture accessor names to their model class and ID for querying after cache replay.
220
220
 
221
221
  ## Cache Management
222
222
 
@@ -225,7 +225,7 @@ Delete the cache directory to force regeneration:
225
225
  rm -rf tmp/cache/fixture_kit
226
226
  ```
227
227
 
228
- Caches are automatically cleared at suite start when `autogenerate` is enabled, so manual clearing is rarely needed.
228
+ Caches are cleared at runner start unless `FIXTURE_KIT_PRESERVE_CACHE` is truthy.
229
229
 
230
230
  ## Multi-Database Support
231
231
 
@@ -7,109 +7,54 @@ require "active_support/inflector"
7
7
 
8
8
  module FixtureKit
9
9
  class Cache
10
- # In-memory cache to avoid re-reading/parsing JSON for every test
11
- @memory_cache = {}
10
+ attr_reader :fixture
12
11
 
13
- class << self
14
- attr_accessor :memory_cache
15
-
16
- def clear_memory_cache(fixture_name = nil)
17
- if fixture_name
18
- @memory_cache.delete(fixture_name)
19
- else
20
- @memory_cache.clear
21
- end
22
- end
23
-
24
- # Clear fixture cache (both memory and disk)
25
- def clear(fixture_name = nil)
26
- clear_memory_cache(fixture_name)
27
-
28
- cache_path = FixtureKit.configuration.cache_path
29
- if fixture_name
30
- cache_file = File.join(cache_path, "#{fixture_name}.json")
31
- FileUtils.rm_f(cache_file)
32
- else
33
- FileUtils.rm_rf(cache_path)
34
- end
35
- end
36
-
37
- # Generate caches for all fixtures.
38
- # Each fixture is generated in a transaction that rolls back, so no data persists.
39
- def generate_all
40
- Registry.load_definitions
41
- Registry.fixtures.each { |fixture| generate(fixture.name) }
42
- end
43
-
44
- def generate(fixture_name)
45
- clear(fixture_name)
46
-
47
- FixtureKit.configuration.generator.run do
48
- Runner.run(fixture_name, force: true)
49
- end
50
- end
12
+ def initialize(fixture, definition)
13
+ @fixture = fixture
14
+ @definition = definition
51
15
  end
52
16
 
53
- attr_reader :records, :exposed
54
-
55
- def initialize(fixture_name)
56
- @fixture_name = fixture_name
57
- @records = {}
58
- @exposed = {}
59
- end
60
-
61
- def cache_file_path
62
- cache_path = FixtureKit.configuration.cache_path
63
- File.join(cache_path, "#{@fixture_name}.json")
17
+ def path
18
+ File.join(FixtureKit.configuration.cache_path, "#{fixture.name}.json")
64
19
  end
65
20
 
66
21
  def exists?
67
- # Check in-memory cache first, then disk
68
- self.class.memory_cache.key?(@fixture_name) || File.exist?(cache_file_path)
22
+ @data || File.exist?(path)
69
23
  end
70
24
 
71
25
  def load
72
- # Check in-memory cache first
73
- if self.class.memory_cache.key?(@fixture_name)
74
- data = self.class.memory_cache[@fixture_name]
75
- @records = data.fetch("records")
76
- @exposed = data.fetch("exposed")
77
- return true
26
+ unless exists?
27
+ raise FixtureKit::CacheMissingError, "Cache does not exist for fixture '#{fixture.name}'"
78
28
  end
79
29
 
80
- # Fall back to disk
81
- return false unless File.exist?(cache_file_path)
82
-
83
- data = JSON.parse(File.read(cache_file_path))
84
- @records = data.fetch("records")
85
- @exposed = data.fetch("exposed")
86
-
87
- # Store in memory for subsequent loads
88
- self.class.memory_cache[@fixture_name] = data
30
+ @data ||= JSON.parse(File.read(path))
31
+ @data.fetch("records").each do |model_name, sql|
32
+ model = ActiveSupport::Inflector.constantize(model_name)
33
+ model.connection.execute(sql)
34
+ end
89
35
 
90
- true
36
+ build_repository(@data.fetch("exposed"))
91
37
  end
92
38
 
93
- def save(models_with_connections:, exposed_mapping:)
94
- @records = generate_statements(models_with_connections)
95
- @exposed = exposed_mapping
96
-
97
- FileUtils.mkdir_p(File.dirname(cache_file_path))
98
-
99
- data = {
100
- "records" => @records,
101
- "exposed" => @exposed
102
- }
39
+ def save
40
+ FixtureKit.configuration.isolator.run do
41
+ models = SqlSubscriber.capture do
42
+ @definition.evaluate
43
+ end
103
44
 
104
- # Store in memory cache
105
- self.class.memory_cache[@fixture_name] = data
45
+ @data = {
46
+ "records" => generate_statements(models),
47
+ "exposed" => build_exposed_mapping(@definition.exposed)
48
+ }
49
+ end
106
50
 
107
- File.write(cache_file_path, JSON.pretty_generate(data))
51
+ FileUtils.mkdir_p(File.dirname(path))
52
+ File.write(path, JSON.pretty_generate(@data))
108
53
  end
109
54
 
110
55
  # Query exposed records from the database and return a Repository.
111
- def build_repository
112
- exposed_records = @exposed.each_with_object({}) do |(name, value), hash|
56
+ def build_repository(exposed)
57
+ exposed_records = exposed.each_with_object({}) do |(name, value), hash|
113
58
  was_array = value.is_a?(Array)
114
59
  records = Array.wrap(value).map { |record_info| find_exposed_record(record_info.fetch("model"), record_info.fetch("id"), name) }
115
60
  hash[name.to_sym] = was_array ? records : records.first
@@ -125,27 +70,27 @@ module FixtureKit
125
70
  model.find(id)
126
71
  rescue ActiveRecord::RecordNotFound
127
72
  raise FixtureKit::ExposedRecordNotFound,
128
- "Could not find #{model_name} with id=#{id} for exposed record '#{exposed_name}' in fixture '#{@fixture_name}'"
73
+ "Could not find #{model_name} with id=#{id} for exposed record '#{exposed_name}' in fixture '#{@fixture.name}'"
129
74
  end
130
75
 
131
- def generate_statements(models_with_connections)
76
+ def generate_statements(models)
132
77
  statements_by_model = {}
133
78
 
134
- models_with_connections.each do |model, connection|
79
+ models.each do |model|
135
80
  columns = model.column_names
136
81
 
137
82
  rows = []
138
83
  model.order(:id).find_each do |record|
139
84
  row_values = columns.map do |col|
140
85
  value = record.read_attribute_before_type_cast(col)
141
- connection.quote(value)
86
+ model.connection.quote(value)
142
87
  end
143
88
  rows << "(#{row_values.join(", ")})"
144
89
  end
145
90
 
146
91
  next if rows.empty?
147
92
 
148
- sql = build_insert_sql(model.table_name, columns, rows, connection)
93
+ sql = build_insert_sql(model.table_name, columns, rows, model.connection)
149
94
  statements_by_model[model.name] = sql
150
95
  end
151
96
 
@@ -175,5 +120,13 @@ module FixtureKit
175
120
  sql
176
121
  end
177
122
  end
123
+
124
+ def build_exposed_mapping(exposed)
125
+ exposed.each_with_object({}) do |(name, value), hash|
126
+ was_array = value.is_a?(Array)
127
+ records = Array.wrap(value).map { |record| { "model" => record.class.name, "id" => record.id } }
128
+ hash[name] = was_array ? records : records.first
129
+ end
130
+ end
178
131
  end
179
132
  end
@@ -4,14 +4,14 @@ module FixtureKit
4
4
  class Configuration
5
5
  attr_writer :fixture_path
6
6
  attr_writer :cache_path
7
- attr_accessor :generator
8
- attr_accessor :autogenerate
7
+ attr_accessor :isolator
8
+ attr_accessor :on_cache
9
9
 
10
10
  def initialize
11
11
  @fixture_path = nil
12
12
  @cache_path = nil
13
- @generator = FixtureKit::TestCase::Generator
14
- @autogenerate = true
13
+ @isolator = FixtureKit::TestCase::Isolator
14
+ @on_cache = nil
15
15
  end
16
16
 
17
17
  def fixture_path
@@ -1,23 +1,22 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module FixtureKit
4
- class DefinitionContext
4
+ class Definition
5
5
  attr_reader :exposed
6
6
 
7
- def initialize
7
+ def initialize(&definition)
8
+ @definition = definition
8
9
  @exposed = {}
9
10
  end
10
11
 
12
+ def evaluate
13
+ instance_eval(&@definition)
14
+ end
15
+
11
16
  def expose(**records)
12
17
  records.each do |name, record|
13
- name = name.to_sym
14
-
15
18
  if @exposed.key?(name)
16
- raise FixtureKit::DuplicateNameError, <<~ERROR
17
- Duplicate expose name :#{name}
18
-
19
- A record with this name has already been exposed in this fixture.
20
- ERROR
19
+ raise FixtureKit::DuplicateNameError, "Name #{name} already exposed"
21
20
  end
22
21
 
23
22
  @exposed[name] = record
@@ -2,17 +2,38 @@
2
2
 
3
3
  module FixtureKit
4
4
  class Fixture
5
- attr_reader :name, :block
5
+ attr_reader :name, :path
6
6
 
7
- def initialize(name, &block)
7
+ def initialize(name, path)
8
8
  @name = name
9
- @block = block
9
+ @path = path
10
+ @cache = Cache.new(self, definition)
10
11
  end
11
12
 
12
- def execute
13
- context = DefinitionContext.new
14
- context.instance_eval(&block) if block
15
- context.exposed
13
+ def cache(force: false)
14
+ already_cached = @cache.exists?
15
+ return if already_cached && !force
16
+
17
+ @cache.save
18
+ FixtureKit.configuration.on_cache&.call(name) unless already_cached
19
+ end
20
+
21
+ def mount
22
+ unless @cache.exists?
23
+ raise FixtureKit::CacheMissingError, "Cache does not exist for fixture '#{name}'"
24
+ end
25
+
26
+ @cache.load
27
+ end
28
+
29
+ private
30
+
31
+ def definition
32
+ @definition ||= begin
33
+ definition = eval(File.read(@path), TOPLEVEL_BINDING.dup, @path)
34
+ raise FixtureKit::FixtureDefinitionNotFound, "Could not find fixture definition at '#{@path}'" unless definition.is_a?(Definition)
35
+ definition
36
+ end
16
37
  end
17
38
  end
18
39
  end
@@ -1,8 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module FixtureKit
4
- # Base class for fixture cache generators.
5
- class Generator
4
+ # Base class for fixture cache isolators.
5
+ class Isolator
6
6
  def self.run(&block)
7
7
  new.run(&block)
8
8
  end
@@ -1,58 +1,34 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module FixtureKit
4
- module Registry
5
- class << self
6
- def fetch(name)
7
- fixture = find(name)
8
- return fixture if fixture
9
-
10
- file_path = fixture_file_path(name)
11
- unless File.file?(file_path)
12
- raise FixtureKit::FixtureDefinitionNotFound,
13
- "Could not find fixture definition file for '#{name}' at '#{file_path}'"
14
- end
15
-
16
- load file_path
17
- find(name)
18
- end
3
+ require "pathname"
19
4
 
20
- def find(name)
21
- registry[name]
22
- end
5
+ module FixtureKit
6
+ class Registry
7
+ def initialize(configuration)
8
+ @configuration = configuration
9
+ @registry = {}
10
+ end
23
11
 
24
- def fixtures
25
- registry.values
26
- end
12
+ def add(name)
13
+ return @registry[name] if @registry.key?(name)
27
14
 
28
- def register(fixture)
29
- registry[fixture.name] = fixture
15
+ file_path = fixture_file_path(name)
16
+ unless File.file?(file_path)
17
+ raise FixtureKit::FixtureDefinitionNotFound,
18
+ "Could not find fixture definition file for '#{name}' at '#{file_path}'"
30
19
  end
31
20
 
32
- # Load all fixture definition files.
33
- # Uses `load` instead of `require` to ensure fixtures are registered
34
- # even if the files were previously required (e.g., after a reset).
35
- def load_definitions
36
- fixture_path = FixtureKit.configuration.fixture_path
37
- Dir.glob(File.join(fixture_path, "**/*.rb")).each do |file|
38
- load file
39
- end
40
- end
41
-
42
- def reset
43
- @registry = nil
44
- end
21
+ @registry[name] = Fixture.new(name, file_path)
22
+ end
45
23
 
46
- private
24
+ def fixtures
25
+ @registry.values
26
+ end
47
27
 
48
- def fixture_file_path(name)
49
- fixture_path = FixtureKit.configuration.fixture_path
50
- File.expand_path(File.join(fixture_path, "#{name}.rb"))
51
- end
28
+ private
52
29
 
53
- def registry
54
- @registry ||= {}
55
- end
30
+ def fixture_file_path(name)
31
+ File.expand_path(File.join(@configuration.fixture_path, "#{name}.rb"))
56
32
  end
57
33
  end
58
34
  end
@@ -2,7 +2,7 @@
2
2
 
3
3
  module FixtureKit
4
4
  module RSpec
5
- class Generator < FixtureKit::Generator
5
+ class Isolator < FixtureKit::Isolator
6
6
  def run(&block)
7
7
  previous_example = ::RSpec.current_example
8
8
  previous_scope = ::RSpec.current_scope
@@ -35,7 +35,7 @@ module FixtureKit
35
35
  def build_example_group
36
36
  ::RSpec::Core::ExampleGroup.subclass(
37
37
  ::RSpec::Core::ExampleGroup,
38
- "FixtureKit::RSpec::Generator",
38
+ "FixtureKit::RSpec::Isolator",
39
39
  [],
40
40
  []
41
41
  )
@@ -4,11 +4,9 @@ require "fixture_kit"
4
4
 
5
5
  module FixtureKit
6
6
  module RSpec
7
- autoload :Declaration, File.expand_path("rspec/declaration", __dir__)
8
- autoload :Generator, File.expand_path("rspec/generator", __dir__)
7
+ autoload :Isolator, File.expand_path("rspec/isolator", __dir__)
9
8
 
10
9
  DECLARATION_METADATA_KEY = :fixture_kit_declaration
11
- PRESERVE_CACHE_ENV_KEY = "FIXTURE_KIT_PRESERVE_CACHE"
12
10
 
13
11
  # Class methods (extended via config.extend)
14
12
  module ClassMethods
@@ -32,7 +30,7 @@ module FixtureKit
32
30
  # end
33
31
  # end
34
32
  def fixture(name)
35
- metadata[DECLARATION_METADATA_KEY] = Declaration.new(name)
33
+ metadata[DECLARATION_METADATA_KEY] = ::RSpec.configuration.fixture_kit.register(name)
36
34
  end
37
35
  end
38
36
 
@@ -46,41 +44,22 @@ module FixtureKit
46
44
  end
47
45
 
48
46
  def self.configure!(config)
49
- FixtureKit.configuration.generator = Generator
47
+ config.add_setting(:fixture_kit, default: FixtureKit.runner)
48
+ FixtureKit.configuration.isolator = Isolator
49
+
50
50
  config.extend ClassMethods
51
- config.include InstanceMethods
51
+ config.include InstanceMethods, DECLARATION_METADATA_KEY
52
52
 
53
53
  # Load declared fixtures at the beginning of each example.
54
54
  # Runs inside transactional fixtures and before user-defined before hooks.
55
55
  config.prepend_before(:example, DECLARATION_METADATA_KEY) do |example|
56
- declaration = example.metadata[DECLARATION_METADATA_KEY]
57
- @_fixture_kit_fixture_set = declaration.fixture_set
56
+ @_fixture_kit_fixture_set = example.metadata[DECLARATION_METADATA_KEY].mount
58
57
  end
59
58
 
60
- # Setup caches at suite start only when at least one fixture-backed
61
- # example exists in the loaded suite.
62
- config.when_first_matching_example_defined(DECLARATION_METADATA_KEY) do
63
- config.before(:suite) do
64
- if FixtureKit.configuration.autogenerate
65
- preserve_cache = ENV[PRESERVE_CACHE_ENV_KEY].to_s.match?(/\A(1|true|yes)\z/i)
66
- Cache.clear unless preserve_cache
67
- else
68
- fixture_names_for_loaded_examples.each do |fixture_name|
69
- Cache.generate(fixture_name)
70
- end
71
- end
72
- end
59
+ config.append_before(:suite) do
60
+ config.fixture_kit.start
73
61
  end
74
62
  end
75
-
76
- def self.fixture_names_for_loaded_examples
77
- ::RSpec.world.filtered_examples.each_value.with_object(Set.new) do |examples, names|
78
- examples.each do |example|
79
- declaration = example.metadata[DECLARATION_METADATA_KEY]
80
- names << declaration.name if declaration
81
- end
82
- end.to_a
83
- end
84
63
  end
85
64
  end
86
65
 
@@ -1,93 +1,45 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "active_support/inflector"
3
+ require "fileutils"
4
4
 
5
5
  module FixtureKit
6
6
  class Runner
7
- def self.run(fixture_name, force: false)
8
- new(fixture_name).run(force: force)
9
- end
10
-
11
- def initialize(fixture_name)
12
- @fixture_name = fixture_name
13
- @cache = Cache.new(@fixture_name)
14
- end
7
+ PRESERVE_CACHE_ENV_KEY = "FIXTURE_KIT_PRESERVE_CACHE"
15
8
 
16
- def run(force: false)
17
- if force
18
- execute_and_cache
19
- elsif @cache.exists?
20
- execute_from_cache
21
- elsif FixtureKit.configuration.autogenerate
22
- execute_and_cache
23
- else
24
- raise FixtureKit::CacheMissingError, <<~ERROR
25
- Cache not found for fixture '#{@fixture_name}'.
9
+ attr_reader :configuration, :registry
26
10
 
27
- Run your tests with autogenerate enabled to generate the cache:
28
- FixtureKit.configuration.autogenerate = true
11
+ def initialize
12
+ @configuration = Configuration.new
13
+ @registry = Registry.new(configuration)
14
+ @started = false
15
+ end
29
16
 
30
- Or generate caches by running your test suite once with autogenerate enabled.
31
- ERROR
17
+ def register(name)
18
+ registry.add(name).tap do |fixture|
19
+ fixture.cache if started?
32
20
  end
33
21
  end
34
22
 
35
- private
36
-
37
- def execute_and_cache
38
- fixture = Registry.fetch(@fixture_name)
39
-
40
- # Start capturing SQL
41
- capture = SqlCapture.new
42
- capture.start
43
-
44
- # Execute fixture definition - returns exposed records hash
45
- exposed = fixture.execute
46
-
47
- # Stop capturing and get affected models with their connections
48
- models_with_connections = capture.stop
23
+ def start
24
+ raise RunnerAlreadyStartedError, "FixtureKit::Runner has already been started" if started?
25
+ @started = true
49
26
 
50
- # Save cache
51
- @cache.save(
52
- models_with_connections: models_with_connections,
53
- exposed_mapping: build_exposed_mapping(exposed)
54
- )
55
-
56
- # Return Repository from the exposed records
57
- Repository.new(exposed)
27
+ clear_cache unless preserve_cache?
28
+ registry.fixtures.each(&:cache)
58
29
  end
59
30
 
60
- def execute_from_cache
61
- @cache.load
62
-
63
- # Execute cached SQL statements by model
64
- @cache.records.each do |model_name, sql|
65
- next if sql.nil? || sql.empty?
66
-
67
- model = ActiveSupport::Inflector.constantize(model_name)
68
- connection = model.connection
69
- connection.execute(sql)
70
- end
71
-
72
- # Query exposed records and build Repository.
73
- @cache.build_repository
31
+ def started?
32
+ @started
74
33
  end
75
34
 
76
- def build_exposed_mapping(exposed)
77
- mapping = {}
35
+ private
78
36
 
79
- exposed.each do |name, record_or_records|
80
- if record_or_records.is_a?(Array)
81
- mapping[name.to_s] = record_or_records.map do |record|
82
- { "model" => record.class.name, "id" => record.id }
83
- end
84
- else
85
- record = record_or_records
86
- mapping[name.to_s] = { "model" => record.class.name, "id" => record.id }
87
- end
88
- end
37
+ def clear_cache
38
+ FileUtils.rm_rf(configuration.cache_path)
39
+ end
89
40
 
90
- mapping
41
+ def preserve_cache?
42
+ ENV[PRESERVE_CACHE_ENV_KEY].to_s.match?(/\A(1|true|yes)\z/i)
91
43
  end
92
44
  end
93
45
  end
@@ -1,35 +1,26 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "pathname"
4
-
5
3
  module FixtureKit
6
4
  module Singleton
7
5
  def configure
8
- yield(configuration) if block_given?
6
+ yield(runner.configuration) if block_given?
9
7
  self
10
8
  end
11
9
 
12
10
  def configuration
13
- @configuration ||= Configuration.new
11
+ runner.configuration
14
12
  end
15
13
 
16
- def define(&block)
17
- caller_file = File.expand_path(caller_locations(1, 1).first.path)
18
- fixture_path = File.expand_path(configuration.fixture_path)
19
-
20
- # "/abs/path/spec/fixtures/teams/basic.rb" -> "teams/basic"
21
- relative_path = Pathname.new(caller_file).relative_path_from(Pathname.new(fixture_path))
22
- name = relative_path.to_s.sub(/\.rb$/, "")
14
+ def runner
15
+ @runner ||= Runner.new
16
+ end
23
17
 
24
- fixture = Fixture.new(name, &block)
25
- Registry.register(fixture)
26
- fixture
18
+ def define(&block)
19
+ Definition.new(&block)
27
20
  end
28
21
 
29
22
  def reset
30
- @configuration = nil
31
- Registry.reset
32
- Cache.clear_memory_cache
23
+ @runner = nil
33
24
  end
34
25
  end
35
26
  end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/notifications"
4
+ require "active_support/inflector"
5
+
6
+ module FixtureKit
7
+ class SqlSubscriber
8
+ EVENT = "sql.active_record"
9
+
10
+ def self.capture(&block)
11
+ models = Set.new
12
+ subscriber = lambda do |_event_name, _start, _finish, _id, payload|
13
+ sql = payload[:sql]
14
+ next unless sql =~ /\AINSERT INTO/i
15
+
16
+ # payload[:name] is like "User Create" - extract model name
17
+ name = payload[:name]
18
+ next unless name&.end_with?(" Create")
19
+
20
+ model_name = name.sub(/ Create\z/, "")
21
+ models.add(ActiveSupport::Inflector.constantize(model_name))
22
+ end
23
+
24
+ ActiveSupport::Notifications.subscribed(subscriber, EVENT, monotonic: true, &block)
25
+
26
+ models.to_a
27
+ end
28
+ end
29
+ end
@@ -5,7 +5,7 @@ require "active_record/fixtures"
5
5
 
6
6
  module FixtureKit
7
7
  module TestCase
8
- class Generator < FixtureKit::Generator
8
+ class Isolator < FixtureKit::Isolator
9
9
  TEST_METHOD_NAME = "test_fixture_kit_cache_pregeneration"
10
10
 
11
11
  def run(&block)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module FixtureKit
4
4
  module TestCase
5
- autoload :Generator, File.expand_path("test_case/generator", __dir__)
5
+ autoload :Isolator, File.expand_path("test_case/isolator", __dir__)
6
6
  end
7
7
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module FixtureKit
4
- VERSION = "0.2.0"
4
+ VERSION = "0.3.0"
5
5
  end
data/lib/fixture_kit.rb CHANGED
@@ -8,18 +8,19 @@ module FixtureKit
8
8
  class PregenerationError < Error; end
9
9
  class FixtureDefinitionNotFound < Error; end
10
10
  class ExposedRecordNotFound < Error; end
11
+ class RunnerAlreadyStartedError < Error; end
11
12
 
12
13
  autoload :VERSION, File.expand_path("fixture_kit/version", __dir__)
13
14
  autoload :Configuration, File.expand_path("fixture_kit/configuration", __dir__)
14
15
  autoload :Singleton, File.expand_path("fixture_kit/singleton", __dir__)
15
16
  autoload :Fixture, File.expand_path("fixture_kit/fixture", __dir__)
16
- autoload :DefinitionContext, File.expand_path("fixture_kit/definition_context", __dir__)
17
+ autoload :Definition, File.expand_path("fixture_kit/definition", __dir__)
17
18
  autoload :Registry, File.expand_path("fixture_kit/registry", __dir__)
18
19
  autoload :Repository, File.expand_path("fixture_kit/repository", __dir__)
19
- autoload :SqlCapture, File.expand_path("fixture_kit/sql_capture", __dir__)
20
+ autoload :SqlSubscriber, File.expand_path("fixture_kit/sql_subscriber", __dir__)
20
21
  autoload :Cache, File.expand_path("fixture_kit/cache", __dir__)
21
22
  autoload :Runner, File.expand_path("fixture_kit/runner", __dir__)
22
- autoload :Generator, File.expand_path("fixture_kit/generator", __dir__)
23
+ autoload :Isolator, File.expand_path("fixture_kit/isolator", __dir__)
23
24
  autoload :TestCase, File.expand_path("fixture_kit/test_case", __dir__)
24
25
 
25
26
  extend Singleton
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: fixture_kit
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ngan Pham
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-02-21 00:00:00.000000000 Z
11
+ date: 2026-02-22 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -136,19 +136,18 @@ files:
136
136
  - lib/fixture_kit.rb
137
137
  - lib/fixture_kit/cache.rb
138
138
  - lib/fixture_kit/configuration.rb
139
- - lib/fixture_kit/definition_context.rb
139
+ - lib/fixture_kit/definition.rb
140
140
  - lib/fixture_kit/fixture.rb
141
- - lib/fixture_kit/generator.rb
141
+ - lib/fixture_kit/isolator.rb
142
142
  - lib/fixture_kit/registry.rb
143
143
  - lib/fixture_kit/repository.rb
144
144
  - lib/fixture_kit/rspec.rb
145
- - lib/fixture_kit/rspec/declaration.rb
146
- - lib/fixture_kit/rspec/generator.rb
145
+ - lib/fixture_kit/rspec/isolator.rb
147
146
  - lib/fixture_kit/runner.rb
148
147
  - lib/fixture_kit/singleton.rb
149
- - lib/fixture_kit/sql_capture.rb
148
+ - lib/fixture_kit/sql_subscriber.rb
150
149
  - lib/fixture_kit/test_case.rb
151
- - lib/fixture_kit/test_case/generator.rb
150
+ - lib/fixture_kit/test_case/isolator.rb
152
151
  - lib/fixture_kit/version.rb
153
152
  homepage: https://github.com/Gusto/fixture_kit
154
153
  licenses:
@@ -1,17 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module FixtureKit
4
- module RSpec
5
- class Declaration
6
- attr_reader :name
7
-
8
- def initialize(name)
9
- @name = name
10
- end
11
-
12
- def fixture_set
13
- FixtureKit::Runner.run(name)
14
- end
15
- end
16
- end
17
- end
@@ -1,37 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "active_support/notifications"
4
- require "active_support/inflector"
5
-
6
- module FixtureKit
7
- class SqlCapture
8
- SQL_EVENT = "sql.active_record"
9
-
10
- def initialize
11
- @models = {} # { Model => connection }
12
- @subscription = nil
13
- end
14
-
15
- def start
16
- @subscription = ActiveSupport::Notifications.subscribe(SQL_EVENT) do |*, payload|
17
- next unless payload[:sql] =~ /\AINSERT INTO/i
18
-
19
- # payload[:name] is like "User Create" - extract model name
20
- name = payload[:name]
21
- next unless name&.end_with?(" Create")
22
-
23
- model_name = name.sub(/ Create\z/, "")
24
- model = ActiveSupport::Inflector.constantize(model_name)
25
- @models[model] ||= payload[:connection]
26
- rescue NameError
27
- # Skip if model can't be found
28
- end
29
- end
30
-
31
- def stop
32
- ActiveSupport::Notifications.unsubscribe(@subscription) if @subscription
33
- @subscription = nil
34
- @models
35
- end
36
- end
37
- end