fixture_kit 0.1.2 → 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 +4 -4
- data/README.md +32 -32
- data/lib/fixture_kit/cache.rb +132 -0
- data/lib/fixture_kit/configuration.rb +4 -4
- data/lib/fixture_kit/{fixture_context.rb → definition.rb} +8 -9
- data/lib/fixture_kit/fixture.rb +29 -8
- data/lib/fixture_kit/{generator.rb → isolator.rb} +2 -2
- data/lib/fixture_kit/registry.rb +34 -0
- data/lib/fixture_kit/{fixture_set.rb → repository.rb} +1 -1
- data/lib/fixture_kit/rspec/{generator.rb → isolator.rb} +2 -2
- data/lib/fixture_kit/rspec.rb +20 -28
- data/lib/fixture_kit/runner.rb +45 -0
- data/lib/fixture_kit/singleton.rb +8 -17
- data/lib/fixture_kit/sql_subscriber.rb +29 -0
- data/lib/fixture_kit/test_case/{generator.rb → isolator.rb} +1 -1
- data/lib/fixture_kit/test_case.rb +1 -1
- data/lib/fixture_kit/version.rb +1 -1
- data/lib/fixture_kit.rb +9 -7
- metadata +11 -12
- data/lib/fixture_kit/fixture_cache.rb +0 -179
- data/lib/fixture_kit/fixture_registry.rb +0 -54
- data/lib/fixture_kit/fixture_runner.rb +0 -104
- data/lib/fixture_kit/rspec/declaration.rb +0 -17
- data/lib/fixture_kit/sql_capture.rb +0 -37
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 23151d16a20be7d13a1c4a15ce4dca10a2c742ba68a1d43af339bdad65490679
|
|
4
|
+
data.tar.gz: 1c9da63eae90bbe6843d204d138fafbf3b933f0ea4fd9f42c4108331509f472b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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
|
|
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
|
|
|
@@ -104,7 +104,7 @@ RSpec.describe Book do
|
|
|
104
104
|
end
|
|
105
105
|
```
|
|
106
106
|
|
|
107
|
-
`fixture` returns a `
|
|
107
|
+
`fixture` returns a `Repository` and exposes records as methods (for example, `fixture.owner`).
|
|
108
108
|
|
|
109
109
|
### 3. Configure RSpec
|
|
110
110
|
|
|
@@ -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
|
-
#
|
|
132
|
-
config.
|
|
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
|
|
135
|
-
#
|
|
136
|
-
# config.
|
|
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
|
|
141
|
-
`#run` receives the
|
|
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
|
-
|
|
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
|
-
|
|
150
|
+
## Lifecycle
|
|
146
151
|
|
|
147
|
-
|
|
152
|
+
Fixture generation is managed by `FixtureKit::Runner`.
|
|
148
153
|
|
|
149
|
-
|
|
154
|
+
With `fixture_kit/rspec`:
|
|
150
155
|
|
|
151
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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. **
|
|
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. **
|
|
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. **
|
|
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
|
|
228
|
+
Caches are cleared at runner start unless `FIXTURE_KIT_PRESERVE_CACHE` is truthy.
|
|
229
229
|
|
|
230
230
|
## Multi-Database Support
|
|
231
231
|
|
|
@@ -0,0 +1,132 @@
|
|
|
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 Cache
|
|
10
|
+
attr_reader :fixture
|
|
11
|
+
|
|
12
|
+
def initialize(fixture, definition)
|
|
13
|
+
@fixture = fixture
|
|
14
|
+
@definition = definition
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def path
|
|
18
|
+
File.join(FixtureKit.configuration.cache_path, "#{fixture.name}.json")
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def exists?
|
|
22
|
+
@data || File.exist?(path)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def load
|
|
26
|
+
unless exists?
|
|
27
|
+
raise FixtureKit::CacheMissingError, "Cache does not exist for fixture '#{fixture.name}'"
|
|
28
|
+
end
|
|
29
|
+
|
|
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
|
|
35
|
+
|
|
36
|
+
build_repository(@data.fetch("exposed"))
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def save
|
|
40
|
+
FixtureKit.configuration.isolator.run do
|
|
41
|
+
models = SqlSubscriber.capture do
|
|
42
|
+
@definition.evaluate
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
@data = {
|
|
46
|
+
"records" => generate_statements(models),
|
|
47
|
+
"exposed" => build_exposed_mapping(@definition.exposed)
|
|
48
|
+
}
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
52
|
+
File.write(path, JSON.pretty_generate(@data))
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Query exposed records from the database and return a Repository.
|
|
56
|
+
def build_repository(exposed)
|
|
57
|
+
exposed_records = exposed.each_with_object({}) do |(name, value), hash|
|
|
58
|
+
was_array = value.is_a?(Array)
|
|
59
|
+
records = Array.wrap(value).map { |record_info| find_exposed_record(record_info.fetch("model"), record_info.fetch("id"), name) }
|
|
60
|
+
hash[name.to_sym] = was_array ? records : records.first
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
Repository.new(exposed_records)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def find_exposed_record(model_name, id, exposed_name)
|
|
69
|
+
model = ActiveSupport::Inflector.constantize(model_name)
|
|
70
|
+
model.find(id)
|
|
71
|
+
rescue ActiveRecord::RecordNotFound
|
|
72
|
+
raise FixtureKit::ExposedRecordNotFound,
|
|
73
|
+
"Could not find #{model_name} with id=#{id} for exposed record '#{exposed_name}' in fixture '#{@fixture.name}'"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def generate_statements(models)
|
|
77
|
+
statements_by_model = {}
|
|
78
|
+
|
|
79
|
+
models.each do |model|
|
|
80
|
+
columns = model.column_names
|
|
81
|
+
|
|
82
|
+
rows = []
|
|
83
|
+
model.order(:id).find_each do |record|
|
|
84
|
+
row_values = columns.map do |col|
|
|
85
|
+
value = record.read_attribute_before_type_cast(col)
|
|
86
|
+
model.connection.quote(value)
|
|
87
|
+
end
|
|
88
|
+
rows << "(#{row_values.join(", ")})"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
next if rows.empty?
|
|
92
|
+
|
|
93
|
+
sql = build_insert_sql(model.table_name, columns, rows, model.connection)
|
|
94
|
+
statements_by_model[model.name] = sql
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
statements_by_model
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def build_insert_sql(table_name, columns, rows, connection)
|
|
101
|
+
quoted_table = connection.quote_table_name(table_name)
|
|
102
|
+
quoted_columns = columns.map { |c| connection.quote_column_name(c) }
|
|
103
|
+
|
|
104
|
+
sql = "INSERT INTO #{quoted_table} (#{quoted_columns.join(", ")}) VALUES #{rows.join(", ")}"
|
|
105
|
+
|
|
106
|
+
add_conflict_handling(sql, connection)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def add_conflict_handling(sql, connection)
|
|
110
|
+
adapter_name = connection.adapter_name.downcase
|
|
111
|
+
|
|
112
|
+
case adapter_name
|
|
113
|
+
when /sqlite/
|
|
114
|
+
sql.sub(/\AINSERT INTO/i, "INSERT OR IGNORE INTO")
|
|
115
|
+
when /postgresql/, /postgis/
|
|
116
|
+
"#{sql} ON CONFLICT DO NOTHING"
|
|
117
|
+
when /mysql/, /trilogy/
|
|
118
|
+
sql.sub(/\AINSERT INTO/i, "INSERT IGNORE INTO")
|
|
119
|
+
else
|
|
120
|
+
sql
|
|
121
|
+
end
|
|
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
|
|
131
|
+
end
|
|
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 :
|
|
8
|
-
attr_accessor :
|
|
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
|
-
@
|
|
14
|
-
@
|
|
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
|
|
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,
|
|
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
|
data/lib/fixture_kit/fixture.rb
CHANGED
|
@@ -2,17 +2,38 @@
|
|
|
2
2
|
|
|
3
3
|
module FixtureKit
|
|
4
4
|
class Fixture
|
|
5
|
-
attr_reader :name, :
|
|
5
|
+
attr_reader :name, :path
|
|
6
6
|
|
|
7
|
-
def initialize(name,
|
|
8
|
-
@name = name
|
|
9
|
-
@
|
|
7
|
+
def initialize(name, path)
|
|
8
|
+
@name = name
|
|
9
|
+
@path = path
|
|
10
|
+
@cache = Cache.new(self, definition)
|
|
10
11
|
end
|
|
11
12
|
|
|
12
|
-
def
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
|
|
5
|
+
module FixtureKit
|
|
6
|
+
class Registry
|
|
7
|
+
def initialize(configuration)
|
|
8
|
+
@configuration = configuration
|
|
9
|
+
@registry = {}
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def add(name)
|
|
13
|
+
return @registry[name] if @registry.key?(name)
|
|
14
|
+
|
|
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}'"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
@registry[name] = Fixture.new(name, file_path)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def fixtures
|
|
25
|
+
@registry.values
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def fixture_file_path(name)
|
|
31
|
+
File.expand_path(File.join(@configuration.fixture_path, "#{name}.rb"))
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
module FixtureKit
|
|
4
4
|
module RSpec
|
|
5
|
-
class
|
|
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::
|
|
38
|
+
"FixtureKit::RSpec::Isolator",
|
|
39
39
|
[],
|
|
40
40
|
[]
|
|
41
41
|
)
|
data/lib/fixture_kit/rspec.rb
CHANGED
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "fixture_kit"
|
|
4
|
-
require_relative "rspec/declaration"
|
|
5
|
-
require_relative "rspec/generator"
|
|
6
4
|
|
|
7
5
|
module FixtureKit
|
|
8
6
|
module RSpec
|
|
7
|
+
autoload :Isolator, File.expand_path("rspec/isolator", __dir__)
|
|
8
|
+
|
|
9
9
|
DECLARATION_METADATA_KEY = :fixture_kit_declaration
|
|
10
|
-
PRESERVE_CACHE_ENV_KEY = "FIXTURE_KIT_PRESERVE_CACHE"
|
|
11
10
|
|
|
12
11
|
# Class methods (extended via config.extend)
|
|
13
12
|
module ClassMethods
|
|
@@ -31,45 +30,38 @@ module FixtureKit
|
|
|
31
30
|
# end
|
|
32
31
|
# end
|
|
33
32
|
def fixture(name)
|
|
34
|
-
metadata[
|
|
33
|
+
metadata[DECLARATION_METADATA_KEY] = ::RSpec.configuration.fixture_kit.register(name)
|
|
35
34
|
end
|
|
36
35
|
end
|
|
37
36
|
|
|
38
37
|
# Instance methods (included via config.include)
|
|
39
38
|
module InstanceMethods
|
|
40
|
-
# Returns the
|
|
39
|
+
# Returns the Repository for the current example's fixture.
|
|
41
40
|
# Access exposed records as methods: fixture.alice, fixture.posts
|
|
42
41
|
def fixture
|
|
43
42
|
@_fixture_kit_fixture_set || raise("No fixture declared for this example group. Use `fixture \"name\"` in your describe/context block.")
|
|
44
43
|
end
|
|
45
44
|
end
|
|
46
|
-
end
|
|
47
|
-
end
|
|
48
45
|
|
|
49
|
-
|
|
50
|
-
|
|
46
|
+
def self.configure!(config)
|
|
47
|
+
config.add_setting(:fixture_kit, default: FixtureKit.runner)
|
|
48
|
+
FixtureKit.configuration.isolator = Isolator
|
|
51
49
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
config.extend FixtureKit::RSpec::ClassMethods
|
|
55
|
-
config.include FixtureKit::RSpec::InstanceMethods
|
|
50
|
+
config.extend ClassMethods
|
|
51
|
+
config.include InstanceMethods, DECLARATION_METADATA_KEY
|
|
56
52
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
end
|
|
53
|
+
# Load declared fixtures at the beginning of each example.
|
|
54
|
+
# Runs inside transactional fixtures and before user-defined before hooks.
|
|
55
|
+
config.prepend_before(:example, DECLARATION_METADATA_KEY) do |example|
|
|
56
|
+
@_fixture_kit_fixture_set = example.metadata[DECLARATION_METADATA_KEY].mount
|
|
57
|
+
end
|
|
63
58
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
config.before(:suite) do
|
|
68
|
-
if FixtureKit.configuration.autogenerate
|
|
69
|
-
preserve_cache = ENV[FixtureKit::RSpec::PRESERVE_CACHE_ENV_KEY].to_s.match?(/\A(1|true|yes)\z/i)
|
|
70
|
-
FixtureKit::FixtureCache.clear unless preserve_cache
|
|
71
|
-
else
|
|
72
|
-
FixtureKit::FixtureCache.pregenerate_all
|
|
59
|
+
config.append_before(:suite) do
|
|
60
|
+
config.fixture_kit.start
|
|
61
|
+
end
|
|
73
62
|
end
|
|
74
63
|
end
|
|
75
64
|
end
|
|
65
|
+
|
|
66
|
+
# Configure RSpec integration
|
|
67
|
+
FixtureKit::RSpec.configure!(RSpec.configuration)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module FixtureKit
|
|
6
|
+
class Runner
|
|
7
|
+
PRESERVE_CACHE_ENV_KEY = "FIXTURE_KIT_PRESERVE_CACHE"
|
|
8
|
+
|
|
9
|
+
attr_reader :configuration, :registry
|
|
10
|
+
|
|
11
|
+
def initialize
|
|
12
|
+
@configuration = Configuration.new
|
|
13
|
+
@registry = Registry.new(configuration)
|
|
14
|
+
@started = false
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def register(name)
|
|
18
|
+
registry.add(name).tap do |fixture|
|
|
19
|
+
fixture.cache if started?
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def start
|
|
24
|
+
raise RunnerAlreadyStartedError, "FixtureKit::Runner has already been started" if started?
|
|
25
|
+
@started = true
|
|
26
|
+
|
|
27
|
+
clear_cache unless preserve_cache?
|
|
28
|
+
registry.fixtures.each(&:cache)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def started?
|
|
32
|
+
@started
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def clear_cache
|
|
38
|
+
FileUtils.rm_rf(configuration.cache_path)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def preserve_cache?
|
|
42
|
+
ENV[PRESERVE_CACHE_ENV_KEY].to_s.match?(/\A(1|true|yes)\z/i)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
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
|
-
|
|
11
|
+
runner.configuration
|
|
14
12
|
end
|
|
15
13
|
|
|
16
|
-
def
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
fixture
|
|
18
|
+
def define(&block)
|
|
19
|
+
Definition.new(&block)
|
|
27
20
|
end
|
|
28
21
|
|
|
29
22
|
def reset
|
|
30
|
-
@
|
|
31
|
-
FixtureRegistry.reset
|
|
32
|
-
FixtureCache.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
|
data/lib/fixture_kit/version.rb
CHANGED
data/lib/fixture_kit.rb
CHANGED
|
@@ -6,19 +6,21 @@ module FixtureKit
|
|
|
6
6
|
class DuplicateNameError < Error; end
|
|
7
7
|
class CacheMissingError < Error; end
|
|
8
8
|
class PregenerationError < Error; end
|
|
9
|
+
class FixtureDefinitionNotFound < Error; end
|
|
9
10
|
class ExposedRecordNotFound < Error; end
|
|
11
|
+
class RunnerAlreadyStartedError < Error; end
|
|
10
12
|
|
|
11
13
|
autoload :VERSION, File.expand_path("fixture_kit/version", __dir__)
|
|
12
14
|
autoload :Configuration, File.expand_path("fixture_kit/configuration", __dir__)
|
|
13
15
|
autoload :Singleton, File.expand_path("fixture_kit/singleton", __dir__)
|
|
14
16
|
autoload :Fixture, File.expand_path("fixture_kit/fixture", __dir__)
|
|
15
|
-
autoload :
|
|
16
|
-
autoload :
|
|
17
|
-
autoload :
|
|
18
|
-
autoload :
|
|
19
|
-
autoload :
|
|
20
|
-
autoload :
|
|
21
|
-
autoload :
|
|
17
|
+
autoload :Definition, File.expand_path("fixture_kit/definition", __dir__)
|
|
18
|
+
autoload :Registry, File.expand_path("fixture_kit/registry", __dir__)
|
|
19
|
+
autoload :Repository, File.expand_path("fixture_kit/repository", __dir__)
|
|
20
|
+
autoload :SqlSubscriber, File.expand_path("fixture_kit/sql_subscriber", __dir__)
|
|
21
|
+
autoload :Cache, File.expand_path("fixture_kit/cache", __dir__)
|
|
22
|
+
autoload :Runner, File.expand_path("fixture_kit/runner", __dir__)
|
|
23
|
+
autoload :Isolator, File.expand_path("fixture_kit/isolator", __dir__)
|
|
22
24
|
autoload :TestCase, File.expand_path("fixture_kit/test_case", __dir__)
|
|
23
25
|
|
|
24
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.
|
|
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-
|
|
11
|
+
date: 2026-02-22 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: activesupport
|
|
@@ -134,21 +134,20 @@ files:
|
|
|
134
134
|
- LICENSE
|
|
135
135
|
- README.md
|
|
136
136
|
- lib/fixture_kit.rb
|
|
137
|
+
- lib/fixture_kit/cache.rb
|
|
137
138
|
- lib/fixture_kit/configuration.rb
|
|
139
|
+
- lib/fixture_kit/definition.rb
|
|
138
140
|
- lib/fixture_kit/fixture.rb
|
|
139
|
-
- lib/fixture_kit/
|
|
140
|
-
- lib/fixture_kit/
|
|
141
|
-
- lib/fixture_kit/
|
|
142
|
-
- lib/fixture_kit/fixture_runner.rb
|
|
143
|
-
- lib/fixture_kit/fixture_set.rb
|
|
144
|
-
- lib/fixture_kit/generator.rb
|
|
141
|
+
- lib/fixture_kit/isolator.rb
|
|
142
|
+
- lib/fixture_kit/registry.rb
|
|
143
|
+
- lib/fixture_kit/repository.rb
|
|
145
144
|
- lib/fixture_kit/rspec.rb
|
|
146
|
-
- lib/fixture_kit/rspec/
|
|
147
|
-
- lib/fixture_kit/
|
|
145
|
+
- lib/fixture_kit/rspec/isolator.rb
|
|
146
|
+
- lib/fixture_kit/runner.rb
|
|
148
147
|
- lib/fixture_kit/singleton.rb
|
|
149
|
-
- lib/fixture_kit/
|
|
148
|
+
- lib/fixture_kit/sql_subscriber.rb
|
|
150
149
|
- lib/fixture_kit/test_case.rb
|
|
151
|
-
- lib/fixture_kit/test_case/
|
|
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,179 +0,0 @@
|
|
|
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
|
|
@@ -1,54 +0,0 @@
|
|
|
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
|
|
@@ -1,104 +0,0 @@
|
|
|
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 the configured generator lifecycle.
|
|
31
|
-
# The default generator uses ActiveRecord::TestFixtures transactions.
|
|
32
|
-
# Entry points (like `fixture_kit/rspec`) can install richer generators.
|
|
33
|
-
# Always regenerates the cache, even if one exists
|
|
34
|
-
def generate_cache_only
|
|
35
|
-
# Clear any existing cache for this fixture
|
|
36
|
-
FixtureCache.clear(@fixture_name.to_s)
|
|
37
|
-
|
|
38
|
-
FixtureKit.configuration.generator.run do
|
|
39
|
-
execute_and_cache
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
true
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
private
|
|
46
|
-
|
|
47
|
-
def execute_and_cache
|
|
48
|
-
fixture = FixtureRegistry.find(@fixture_name)
|
|
49
|
-
raise ArgumentError, "Fixture '#{@fixture_name}' not found" unless fixture
|
|
50
|
-
|
|
51
|
-
# Start capturing SQL
|
|
52
|
-
capture = SqlCapture.new
|
|
53
|
-
capture.start
|
|
54
|
-
|
|
55
|
-
# Execute fixture definition - returns exposed records hash
|
|
56
|
-
exposed = fixture.execute
|
|
57
|
-
|
|
58
|
-
# Stop capturing and get affected models with their connections
|
|
59
|
-
models_with_connections = capture.stop
|
|
60
|
-
|
|
61
|
-
# Save cache
|
|
62
|
-
@cache.save(
|
|
63
|
-
models_with_connections: models_with_connections,
|
|
64
|
-
exposed_mapping: build_exposed_mapping(exposed)
|
|
65
|
-
)
|
|
66
|
-
|
|
67
|
-
# Return FixtureSet from the exposed records
|
|
68
|
-
FixtureSet.new(exposed)
|
|
69
|
-
end
|
|
70
|
-
|
|
71
|
-
def execute_from_cache
|
|
72
|
-
@cache.load
|
|
73
|
-
|
|
74
|
-
# Execute cached SQL statements by model
|
|
75
|
-
@cache.records.each do |model_name, sql|
|
|
76
|
-
next if sql.nil? || sql.empty?
|
|
77
|
-
|
|
78
|
-
model = ActiveSupport::Inflector.constantize(model_name)
|
|
79
|
-
connection = model.connection
|
|
80
|
-
connection.execute(sql)
|
|
81
|
-
end
|
|
82
|
-
|
|
83
|
-
# Query exposed records and build FixtureSet
|
|
84
|
-
@cache.build_fixture_set
|
|
85
|
-
end
|
|
86
|
-
|
|
87
|
-
def build_exposed_mapping(exposed)
|
|
88
|
-
mapping = {}
|
|
89
|
-
|
|
90
|
-
exposed.each do |name, record_or_records|
|
|
91
|
-
if record_or_records.is_a?(Array)
|
|
92
|
-
mapping[name.to_s] = record_or_records.map do |record|
|
|
93
|
-
{ "model" => record.class.name, "id" => record.id }
|
|
94
|
-
end
|
|
95
|
-
else
|
|
96
|
-
record = record_or_records
|
|
97
|
-
mapping[name.to_s] = { "model" => record.class.name, "id" => record.id }
|
|
98
|
-
end
|
|
99
|
-
end
|
|
100
|
-
|
|
101
|
-
mapping
|
|
102
|
-
end
|
|
103
|
-
end
|
|
104
|
-
end
|
|
@@ -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.to_s
|
|
10
|
-
end
|
|
11
|
-
|
|
12
|
-
def fixture_set
|
|
13
|
-
FixtureKit::FixtureRegistry.load_fixture(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
|