fixture_kit 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 88808419ae66dfd1982be86a60e46c0b866e6e509ef50469e912a9906f99892f
4
+ data.tar.gz: 6a4f8cf65a36c0aff62abc8cf51dbcb58709a083cf30a5699ad1d21533968c44
5
+ SHA512:
6
+ metadata.gz: facc136d48b32856bb74f7bae845f0c9cadbf4ef46797ff0a64884b042c9b20383dfb3cfcfdf17f46462b6b46fc1104bcd2e7f111dfd11b1371c055654b32da7
7
+ data.tar.gz: 72da841bb3e72a48f074c3f03468781e0039af6f417776d354c5249cd33591a232caf09eb408c3810e7a419b8485c6985141e277d7e3128555a185a881453ea1
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Gusto
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,229 @@
1
+ # FixtureKit
2
+
3
+ Fast test fixtures with SQL caching.
4
+
5
+ ## The Problem
6
+
7
+ Test data setup is slow. Every `Model.create!` or `FactoryBot.create` hits the database, and complex test scenarios can require dozens of inserts per test.
8
+
9
+ ## The Solution
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 directly—no ORM overhead, no callbacks, just fast SQL.
12
+
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
+
15
+ ## Installation
16
+
17
+ Add to your Gemfile:
18
+
19
+ ```ruby
20
+ group :test do
21
+ gem "fixture_kit"
22
+ end
23
+ ```
24
+
25
+ ## Quick Start
26
+
27
+ ### 1. Define a Fixture
28
+
29
+ Create fixture files in `spec/fixture_kit/`. Use whatever method you prefer to create records—FixtureKit doesn't care.
30
+
31
+ **Using ActiveRecord directly:**
32
+
33
+ ```ruby
34
+ # spec/fixture_kit/bookstore.rb
35
+ FixtureKit.define do
36
+ store = Store.create!(name: "Powell's Books")
37
+ owner = User.create!(name: "Alice", email: "alice@example.com", store: store)
38
+
39
+ books = 3.times.map do |i|
40
+ Book.create!(title: "Book #{i + 1}", store: store)
41
+ end
42
+
43
+ featured = Book.create!(title: "Dune", store: store, featured: true)
44
+
45
+ expose(store: store, owner: owner, books: books, featured: featured)
46
+ end
47
+ ```
48
+
49
+ **Using FactoryBot:**
50
+
51
+ ```ruby
52
+ # spec/fixture_kit/bookstore.rb
53
+ FixtureKit.define do
54
+ store = FactoryBot.create(:store, name: "Powell's Books")
55
+ owner = FactoryBot.create(:user, :admin, store: store)
56
+ books = FactoryBot.create_list(:book, 3, store: store)
57
+ featured = FactoryBot.create(:book, :bestseller, store: store, title: "Dune")
58
+
59
+ expose(store: store, owner: owner, books: books, featured: featured)
60
+ end
61
+ ```
62
+
63
+ The filename determines the fixture name—no need to pass a name to `define`.
64
+
65
+ You can call `expose` multiple times to organize your setup code:
66
+
67
+ ```ruby
68
+ FixtureKit.define do
69
+ # Set up users
70
+ admin = User.create!(name: "Admin", role: "admin")
71
+ member = User.create!(name: "Member", role: "member")
72
+ expose(admin: admin, member: member)
73
+
74
+ # Set up projects
75
+ project = Project.create!(name: "Website", owner: admin)
76
+ expose(project: project)
77
+
78
+ # Set up tasks
79
+ tasks = 3.times.map { |i| Task.create!(title: "Task #{i + 1}", project: project) }
80
+ expose(tasks: tasks)
81
+ end
82
+ ```
83
+
84
+ Exposing the same name twice raises `FixtureKit::DuplicateNameError`.
85
+
86
+ ### 2. Use in Tests
87
+
88
+ ```ruby
89
+ # spec/models/book_spec.rb
90
+ RSpec.describe Book do
91
+ fixture "bookstore"
92
+
93
+ it "belongs to a store" do
94
+ expect(fixture.featured.store).to eq(fixture.store)
95
+ end
96
+
97
+ it "has multiple books" do
98
+ expect(fixture.books.size).to eq(3)
99
+ end
100
+
101
+ it "supports hash-style access" do
102
+ expect(fixture[:owner]).to eq(fixture.owner)
103
+ end
104
+ end
105
+ ```
106
+
107
+ ### 3. Configure RSpec
108
+
109
+ ```ruby
110
+ # spec/rails_helper.rb
111
+ require "fixture_kit/rspec"
112
+
113
+ RSpec.configure do |config|
114
+ config.use_transactional_fixtures = true
115
+ end
116
+ ```
117
+
118
+ ## Configuration
119
+
120
+ ```ruby
121
+ # spec/support/fixture_kit.rb
122
+ FixtureKit.configure do |config|
123
+ # Where fixture definitions live (default: spec/fixture_kit)
124
+ config.fixture_path = Rails.root.join("spec/fixture_kit").to_s
125
+
126
+ # Where cache files are stored (default: tmp/cache/fixture_kit)
127
+ config.cache_path = Rails.root.join("tmp/cache/fixture_kit").to_s
128
+
129
+ # Whether to regenerate caches on every run (default: true)
130
+ config.autogenerate = true
131
+ end
132
+ ```
133
+
134
+ ### Autogenerate
135
+
136
+ 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.
137
+
138
+ When `autogenerate` is `false`, FixtureKit pre-generates all fixture caches at suite start. This happens in rolled-back transactions so no data persists to the database. Any fixtures that already have caches are skipped. This mode is useful for CI where you want consistent, predictable cache generation.
139
+
140
+ ### Preserving Cache Locally
141
+
142
+ If you want to skip cache clearing at suite start (e.g., to reuse caches across test runs during local development), set the `FIXTURE_KIT_PRESERVE_CACHE` environment variable:
143
+
144
+ ```bash
145
+ FIXTURE_KIT_PRESERVE_CACHE=1 bundle exec rspec
146
+ ```
147
+
148
+ This is useful when you're iterating on tests and your fixture definitions haven't changed.
149
+
150
+ ### CI Setup
151
+
152
+ For CI, set `autogenerate` to `false`. FixtureKit will automatically generate any missing caches at suite start:
153
+
154
+ ```ruby
155
+ FixtureKit.configure do |config|
156
+ config.autogenerate = !ENV["CI"]
157
+ end
158
+ ```
159
+
160
+ 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.
161
+
162
+ ## Nested Fixtures
163
+
164
+ Organize fixtures in subdirectories:
165
+
166
+ ```ruby
167
+ # spec/fixture_kit/teams/sales.rb
168
+ FixtureKit.define do
169
+ # ...
170
+ end
171
+ ```
172
+
173
+ ```ruby
174
+ fixture "teams/sales"
175
+ ```
176
+
177
+ ## How It Works
178
+
179
+ 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).
180
+
181
+ 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.
182
+
183
+ 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.
184
+
185
+ 4. **Transaction isolation**: RSpec's `use_transactional_fixtures` wraps each test in a transaction that rolls back, so data doesn't persist between tests.
186
+
187
+ ### Cache Format
188
+
189
+ Caches are stored as JSON files in `tmp/cache/fixture_kit/`:
190
+
191
+ ```json
192
+ {
193
+ "records": {
194
+ "User": "INSERT OR IGNORE INTO users (id, name, email) VALUES (1, 'Alice', 'alice@example.com'), (2, 'Bob', 'bob@example.com')",
195
+ "Project": "INSERT OR IGNORE INTO projects (id, name, user_id) VALUES (1, 'Website', 1)"
196
+ },
197
+ "exposed": {
198
+ "alice": { "model": "User", "id": 1 },
199
+ "bob": { "model": "User", "id": 2 },
200
+ "project": { "model": "Project", "id": 1 }
201
+ }
202
+ }
203
+ ```
204
+
205
+ - **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.
206
+ - **exposed**: Maps fixture accessor names to their model class and ID for querying after cache replay
207
+
208
+ ## Cache Management
209
+
210
+ Delete the cache directory to force regeneration:
211
+ ```bash
212
+ rm -rf tmp/cache/fixture_kit
213
+ ```
214
+
215
+ Caches are automatically cleared at suite start when `autogenerate` is enabled, so manual clearing is rarely needed.
216
+
217
+ ## Multi-Database Support
218
+
219
+ FixtureKit automatically handles multiple databases. Records are stored by model name in the cache, and when replaying, FixtureKit uses each model's database connection to execute the INSERT statements. This means records are automatically inserted into the correct database without any additional configuration.
220
+
221
+ ## Requirements
222
+
223
+ - Ruby >= 3.3
224
+ - ActiveRecord >= 8.0
225
+ - ActiveSupport >= 8.0
226
+
227
+ ## License
228
+
229
+ MIT License. See [LICENSE](LICENSE) for details.
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FixtureKit
4
+ class Configuration
5
+ attr_writer :fixture_path
6
+ attr_writer :cache_path
7
+ attr_accessor :autogenerate
8
+
9
+ def initialize
10
+ @fixture_path = nil
11
+ @cache_path = nil
12
+ @autogenerate = true
13
+ end
14
+
15
+ def fixture_path
16
+ @fixture_path ||= detect_fixture_path
17
+ end
18
+
19
+ def cache_path
20
+ @cache_path ||= detect_cache_path
21
+ end
22
+
23
+ private
24
+
25
+ def detect_fixture_path
26
+ if defined?(RSpec)
27
+ "spec/fixture_kit"
28
+ elsif defined?(Minitest)
29
+ "test/fixture_kit"
30
+ elsif Dir.exist?("spec")
31
+ "spec/fixture_kit"
32
+ elsif Dir.exist?("test")
33
+ "test/fixture_kit"
34
+ else
35
+ "spec/fixture_kit"
36
+ end
37
+ end
38
+
39
+ def detect_cache_path
40
+ "tmp/cache/fixture_kit"
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FixtureKit
4
+ class Fixture
5
+ attr_reader :name, :block
6
+
7
+ def initialize(name, &block)
8
+ @name = name.to_sym
9
+ @block = block
10
+ end
11
+
12
+ def execute
13
+ context = FixtureContext.new
14
+ context.instance_eval(&block) if block
15
+ context.exposed
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,179 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+ require "active_support/core_ext/array/wrap"
6
+ require "active_support/inflector"
7
+
8
+ module FixtureKit
9
+ class FixtureCache
10
+ # In-memory cache to avoid re-reading/parsing JSON for every test
11
+ @memory_cache = {}
12
+
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.to_s)
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
+ # Pre-generate caches for all fixtures.
38
+ # Each fixture is generated in a transaction that rolls back, so no data persists.
39
+ def pregenerate_all
40
+ fixture_path = FixtureKit.configuration.fixture_path
41
+
42
+ # First, load all fixture files to register them
43
+ FixtureRegistry.load_definitions(fixture_path)
44
+
45
+ # Then iterate over the registry and generate caches
46
+ FixtureRegistry.all_names.each do |name|
47
+ runner = FixtureRunner.new(name)
48
+ runner.generate_cache_only
49
+ end
50
+ end
51
+ end
52
+
53
+ attr_reader :records, :exposed
54
+
55
+ def initialize(fixture_name)
56
+ @fixture_name = fixture_name.to_s
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")
64
+ end
65
+
66
+ def exists?
67
+ # Check in-memory cache first, then disk
68
+ self.class.memory_cache.key?(@fixture_name) || File.exist?(cache_file_path)
69
+ end
70
+
71
+ 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
78
+ end
79
+
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
89
+
90
+ true
91
+ end
92
+
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
+ }
103
+
104
+ # Store in memory cache
105
+ self.class.memory_cache[@fixture_name] = data
106
+
107
+ File.write(cache_file_path, JSON.pretty_generate(data))
108
+ end
109
+
110
+ # Query exposed records from the database and return a FixtureSet
111
+ def build_fixture_set
112
+ exposed_records = @exposed.each_with_object({}) do |(name, value), hash|
113
+ was_array = value.is_a?(Array)
114
+ records = Array.wrap(value).map { |record_info| find_exposed_record(record_info.fetch("model"), record_info.fetch("id"), name) }
115
+ hash[name.to_sym] = was_array ? records : records.first
116
+ end
117
+
118
+ FixtureSet.new(exposed_records)
119
+ end
120
+
121
+ private
122
+
123
+ def find_exposed_record(model_name, id, exposed_name)
124
+ model = ActiveSupport::Inflector.constantize(model_name)
125
+ model.find(id)
126
+ rescue ActiveRecord::RecordNotFound
127
+ raise FixtureKit::ExposedRecordNotFound,
128
+ "Could not find #{model_name} with id=#{id} for exposed record '#{exposed_name}' in fixture '#{@fixture_name}'"
129
+ end
130
+
131
+ def generate_statements(models_with_connections)
132
+ statements_by_model = {}
133
+
134
+ models_with_connections.each do |model, connection|
135
+ columns = model.column_names
136
+
137
+ rows = []
138
+ model.order(:id).find_each do |record|
139
+ row_values = columns.map do |col|
140
+ value = record.read_attribute_before_type_cast(col)
141
+ connection.quote(value)
142
+ end
143
+ rows << "(#{row_values.join(", ")})"
144
+ end
145
+
146
+ next if rows.empty?
147
+
148
+ sql = build_insert_sql(model.table_name, columns, rows, connection)
149
+ statements_by_model[model.name] = sql
150
+ end
151
+
152
+ statements_by_model
153
+ end
154
+
155
+ def build_insert_sql(table_name, columns, rows, connection)
156
+ quoted_table = connection.quote_table_name(table_name)
157
+ quoted_columns = columns.map { |c| connection.quote_column_name(c) }
158
+
159
+ sql = "INSERT INTO #{quoted_table} (#{quoted_columns.join(", ")}) VALUES #{rows.join(", ")}"
160
+
161
+ add_conflict_handling(sql, connection)
162
+ end
163
+
164
+ def add_conflict_handling(sql, connection)
165
+ adapter_name = connection.adapter_name.downcase
166
+
167
+ case adapter_name
168
+ when /sqlite/
169
+ sql.sub(/\AINSERT INTO/i, "INSERT OR IGNORE INTO")
170
+ when /postgresql/, /postgis/
171
+ "#{sql} ON CONFLICT DO NOTHING"
172
+ when /mysql/, /trilogy/
173
+ sql.sub(/\AINSERT INTO/i, "INSERT IGNORE INTO")
174
+ else
175
+ sql
176
+ end
177
+ end
178
+ end
179
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FixtureKit
4
+ class FixtureContext
5
+ attr_reader :exposed
6
+
7
+ def initialize
8
+ @exposed = {}
9
+ end
10
+
11
+ def expose(**records)
12
+ records.each do |name, record|
13
+ name = name.to_sym
14
+
15
+ 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
21
+ end
22
+
23
+ @exposed[name] = record
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FixtureKit
4
+ module FixtureRegistry
5
+ class << self
6
+ def register(fixture)
7
+ fixtures[fixture.name.to_s] = fixture
8
+ end
9
+
10
+ def find(name)
11
+ fixtures[name.to_s]
12
+ end
13
+
14
+ def all_names
15
+ fixtures.keys
16
+ end
17
+
18
+ # Load all fixture definition files from the given path.
19
+ # Uses `load` instead of `require` to ensure fixtures are registered
20
+ # even if the files were previously required (e.g., after a reset).
21
+ def load_definitions(fixture_path)
22
+ Dir.glob(File.join(fixture_path, "**/*.rb")).each do |file|
23
+ load file
24
+ end
25
+ end
26
+
27
+ def reset
28
+ @fixtures = nil
29
+ end
30
+
31
+ # Load a fixture's records into the database and return a FixtureSet.
32
+ # Uses cached INSERT statements if available, otherwise executes fixture and caches.
33
+ def load_fixture(name)
34
+ name = name.to_s
35
+
36
+ # Load the file on-demand if fixture not yet registered
37
+ unless find(name)
38
+ fixture_path = FixtureKit.configuration.fixture_path
39
+ file_path = File.expand_path(File.join(fixture_path, "#{name}.rb"))
40
+ load file_path
41
+ end
42
+
43
+ runner = FixtureRunner.new(name)
44
+ runner.run
45
+ end
46
+
47
+ private
48
+
49
+ def fixtures
50
+ @fixtures ||= {}
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/inflector"
4
+
5
+ module FixtureKit
6
+ class FixtureRunner
7
+ def initialize(fixture_name)
8
+ @fixture_name = fixture_name.to_sym
9
+ @cache = FixtureCache.new(@fixture_name)
10
+ end
11
+
12
+ def run
13
+ if @cache.exists?
14
+ execute_from_cache
15
+ elsif FixtureKit.configuration.autogenerate
16
+ execute_and_cache
17
+ else
18
+ raise FixtureKit::CacheMissingError, <<~ERROR
19
+ Cache not found for fixture '#{@fixture_name}'.
20
+
21
+ Run your tests with autogenerate enabled to generate the cache:
22
+ FixtureKit.configuration.autogenerate = true
23
+
24
+ Or generate caches by running your test suite once with autogenerate enabled.
25
+ ERROR
26
+ end
27
+ end
28
+
29
+ # Generate cache only (used for pregeneration in before(:suite))
30
+ # Wraps execution in a transaction that rolls back, so no data persists
31
+ # Always regenerates the cache, even if one exists
32
+ def generate_cache_only
33
+ # Clear any existing cache for this fixture
34
+ FixtureCache.clear(@fixture_name.to_s)
35
+
36
+ ActiveRecord::Base.transaction do
37
+ execute_and_cache
38
+ raise ActiveRecord::Rollback
39
+ end
40
+
41
+ true
42
+ end
43
+
44
+ private
45
+
46
+ def execute_and_cache
47
+ fixture = FixtureRegistry.find(@fixture_name)
48
+ raise ArgumentError, "Fixture '#{@fixture_name}' not found" unless fixture
49
+
50
+ # Start capturing SQL
51
+ capture = SqlCapture.new
52
+ capture.start
53
+
54
+ # Execute fixture definition - returns exposed records hash
55
+ exposed = fixture.execute
56
+
57
+ # Stop capturing and get affected models with their connections
58
+ models_with_connections = capture.stop
59
+
60
+ # Save cache
61
+ @cache.save(
62
+ models_with_connections: models_with_connections,
63
+ exposed_mapping: build_exposed_mapping(exposed)
64
+ )
65
+
66
+ # Return FixtureSet from the exposed records
67
+ FixtureSet.new(exposed)
68
+ end
69
+
70
+ def execute_from_cache
71
+ @cache.load
72
+
73
+ # Execute cached SQL statements by model
74
+ @cache.records.each do |model_name, sql|
75
+ next if sql.nil? || sql.empty?
76
+
77
+ model = ActiveSupport::Inflector.constantize(model_name)
78
+ connection = model.connection
79
+ connection.execute(sql)
80
+ end
81
+
82
+ # Query exposed records and build FixtureSet
83
+ @cache.build_fixture_set
84
+ end
85
+
86
+ def build_exposed_mapping(exposed)
87
+ mapping = {}
88
+
89
+ exposed.each do |name, record_or_records|
90
+ if record_or_records.is_a?(Array)
91
+ mapping[name.to_s] = record_or_records.map do |record|
92
+ { "model" => record.class.name, "id" => record.id }
93
+ end
94
+ else
95
+ record = record_or_records
96
+ mapping[name.to_s] = { "model" => record.class.name, "id" => record.id }
97
+ end
98
+ end
99
+
100
+ mapping
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FixtureKit
4
+ class FixtureSet
5
+ def initialize(exposed_records)
6
+ @records = exposed_records
7
+ define_accessors
8
+ end
9
+
10
+ def [](name)
11
+ @records[name.to_sym]
12
+ end
13
+
14
+ def to_h
15
+ @records.dup
16
+ end
17
+
18
+ private
19
+
20
+ def define_accessors
21
+ @records.each do |name, value|
22
+ define_singleton_method(name) { value }
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fixture_kit"
4
+
5
+ module FixtureKit
6
+ module RSpec
7
+ # Class methods (extended via config.extend)
8
+ module ClassMethods
9
+ # Declare which fixture to use for this example group.
10
+ # Can be overridden in nested groups (like `let`).
11
+ #
12
+ # Example:
13
+ # RSpec.describe User do
14
+ # fixture "basic_users"
15
+ #
16
+ # it "has users" do
17
+ # expect(fixture.alice).to be_present
18
+ # end
19
+ #
20
+ # context "with admins" do
21
+ # fixture "admin_users" # Override
22
+ #
23
+ # it "has admin" do
24
+ # expect(fixture.admin).to be_present
25
+ # end
26
+ # end
27
+ # end
28
+ def fixture(name)
29
+ metadata[:fixture_name] = name.to_s
30
+ end
31
+ end
32
+
33
+ # Instance methods (included via config.include)
34
+ module InstanceMethods
35
+ # Returns the FixtureSet for the current example's fixture.
36
+ # Access exposed records as methods: fixture.alice, fixture.posts
37
+ def fixture
38
+ @_fixture_loaded ||= begin
39
+ fixture_name = self.class.metadata[:fixture_name]
40
+ raise "No fixture declared for this example group. Use `fixture \"name\"` in your describe/context block." unless fixture_name
41
+
42
+ FixtureKit::FixtureRegistry.load_fixture(fixture_name)
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
48
+
49
+ # Configure RSpec integration
50
+ RSpec.configure do |config|
51
+ config.extend FixtureKit::RSpec::ClassMethods
52
+ config.include FixtureKit::RSpec::InstanceMethods
53
+
54
+ # Setup caches at suite start based on autogenerate setting
55
+ # - autogenerate=true: Clear all caches (unless FIXTURE_KIT_PRESERVE_CACHE is set)
56
+ # - autogenerate=false: Pre-generate all caches so tests don't fail
57
+ config.before(:suite) do
58
+ if FixtureKit.configuration.autogenerate
59
+ preserve_cache = ENV["FIXTURE_KIT_PRESERVE_CACHE"].to_s.match?(/\A(1|true|yes)\z/i)
60
+ FixtureKit::FixtureCache.clear unless preserve_cache
61
+ else
62
+ FixtureKit::FixtureCache.pregenerate_all
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ module FixtureKit
6
+ module Singleton
7
+ def configure
8
+ @configuration = Configuration.new
9
+ yield(@configuration) if block_given?
10
+ self
11
+ end
12
+
13
+ def configuration
14
+ @configuration ||= Configuration.new
15
+ end
16
+
17
+ def define(&block)
18
+ caller_file = File.expand_path(caller_locations(1, 1).first.path)
19
+ fixture_path = File.expand_path(configuration.fixture_path)
20
+
21
+ # "/abs/path/spec/fixtures/teams/basic.rb" -> "teams/basic"
22
+ relative_path = Pathname.new(caller_file).relative_path_from(Pathname.new(fixture_path))
23
+ name = relative_path.to_s.sub(/\.rb$/, "")
24
+
25
+ fixture = Fixture.new(name, &block)
26
+ FixtureRegistry.register(fixture)
27
+ fixture
28
+ end
29
+
30
+ def reset
31
+ @configuration = nil
32
+ FixtureRegistry.reset
33
+ FixtureCache.clear_memory_cache
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,37 @@
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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FixtureKit
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FixtureKit
4
+ class Error < StandardError; end
5
+ class DuplicateFixtureError < Error; end
6
+ class DuplicateNameError < Error; end
7
+ class CacheMissingError < Error; end
8
+ class ExposedRecordNotFound < Error; end
9
+
10
+ autoload :VERSION, File.expand_path("fixture_kit/version", __dir__)
11
+ autoload :Configuration, File.expand_path("fixture_kit/configuration", __dir__)
12
+ autoload :Singleton, File.expand_path("fixture_kit/singleton", __dir__)
13
+ autoload :Fixture, File.expand_path("fixture_kit/fixture", __dir__)
14
+ autoload :FixtureContext, File.expand_path("fixture_kit/fixture_context", __dir__)
15
+ autoload :FixtureRegistry, File.expand_path("fixture_kit/fixture_registry", __dir__)
16
+ autoload :FixtureSet, File.expand_path("fixture_kit/fixture_set", __dir__)
17
+ autoload :SqlCapture, File.expand_path("fixture_kit/sql_capture", __dir__)
18
+ autoload :FixtureCache, File.expand_path("fixture_kit/fixture_cache", __dir__)
19
+ autoload :FixtureRunner, File.expand_path("fixture_kit/fixture_runner", __dir__)
20
+
21
+ extend Singleton
22
+ end
metadata ADDED
@@ -0,0 +1,160 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: fixture_kit
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Ngan Pham
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-02-19 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activesupport
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '8.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '8.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: activerecord
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '8.0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '8.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: irb
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rake
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: rspec-rails
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: sqlite3
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: '0'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: '0'
97
+ - !ruby/object:Gem::Dependency
98
+ name: railties
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - ">="
102
+ - !ruby/object:Gem::Version
103
+ version: '0'
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - ">="
109
+ - !ruby/object:Gem::Version
110
+ version: '0'
111
+ description: FixtureKit provides lightning-fast test setup by caching database records.
112
+ Define fixtures using any tool (FactoryBot, raw ActiveRecord, etc.), and FixtureKit
113
+ caches the SQL to replay in subsequent test runs.
114
+ email:
115
+ - ngan.pham@gusto.com
116
+ executables: []
117
+ extensions: []
118
+ extra_rdoc_files: []
119
+ files:
120
+ - LICENSE
121
+ - README.md
122
+ - lib/fixture_kit.rb
123
+ - lib/fixture_kit/configuration.rb
124
+ - lib/fixture_kit/fixture.rb
125
+ - lib/fixture_kit/fixture_cache.rb
126
+ - lib/fixture_kit/fixture_context.rb
127
+ - lib/fixture_kit/fixture_registry.rb
128
+ - lib/fixture_kit/fixture_runner.rb
129
+ - lib/fixture_kit/fixture_set.rb
130
+ - lib/fixture_kit/rspec.rb
131
+ - lib/fixture_kit/singleton.rb
132
+ - lib/fixture_kit/sql_capture.rb
133
+ - lib/fixture_kit/version.rb
134
+ homepage: https://github.com/Gusto/fixture_kit
135
+ licenses:
136
+ - MIT
137
+ metadata:
138
+ homepage_uri: https://github.com/Gusto/fixture_kit
139
+ source_code_uri: https://github.com/Gusto/fixture_kit
140
+ changelog_uri: https://github.com/Gusto/fixture_kit/releases
141
+ post_install_message:
142
+ rdoc_options: []
143
+ require_paths:
144
+ - lib
145
+ required_ruby_version: !ruby/object:Gem::Requirement
146
+ requirements:
147
+ - - ">="
148
+ - !ruby/object:Gem::Version
149
+ version: 3.3.0
150
+ required_rubygems_version: !ruby/object:Gem::Requirement
151
+ requirements:
152
+ - - ">="
153
+ - !ruby/object:Gem::Version
154
+ version: '0'
155
+ requirements: []
156
+ rubygems_version: 3.5.22
157
+ signing_key:
158
+ specification_version: 4
159
+ summary: Fast test fixtures with SQL caching
160
+ test_files: []