fixture_kit 0.9.1 → 0.11.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 +20 -0
- data/lib/fixture_kit/analyzer/ast_factory_detector.rb +64 -0
- data/lib/fixture_kit/analyzer/file_result.rb +28 -0
- data/lib/fixture_kit/analyzer/group_analyzer.rb +65 -0
- data/lib/fixture_kit/analyzer/let_definition.rb +32 -0
- data/lib/fixture_kit/analyzer/runner.rb +97 -0
- data/lib/fixture_kit/analyzer/text_formatter.rb +43 -0
- data/lib/fixture_kit/analyzer.rb +34 -0
- data/lib/fixture_kit/cache.rb +31 -33
- data/lib/fixture_kit/definition.rb +10 -6
- data/lib/fixture_kit/file_cache.rb +51 -0
- data/lib/fixture_kit/fixture.rb +16 -2
- data/lib/fixture_kit/memory_cache.rb +17 -0
- data/lib/fixture_kit/minitest.rb +7 -3
- data/lib/fixture_kit/registry.rb +30 -13
- data/lib/fixture_kit/repository.rb +2 -3
- data/lib/fixture_kit/rspec.rb +8 -3
- data/lib/fixture_kit/runner.rb +2 -13
- data/lib/fixture_kit/singleton.rb +2 -2
- data/lib/fixture_kit/version.rb +1 -1
- data/lib/fixture_kit.rb +3 -0
- metadata +11 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6ccc3496f3e23da0877bac37e98d2fd325daf737f4d7bc2988d80156d202c248
|
|
4
|
+
data.tar.gz: 332a5f7824ec4c449ef052bf67616c71b7bca07749f69dcb9c1369afbb95f1a4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: aaab87039e3f040b6b1f200a2e853e46263449f2bc900f664d167b009adffb2eccd48fc9080051b67dc6ad8c1e12f3aca4796db45ced24f63ad35f671cddb7f6
|
|
7
|
+
data.tar.gz: 55d185c158111d9a892ceb558af729f48a2708c4975e1365a411bdbbb6a6e26d776fd511bc9505dc818aa4e4747062acb5b51b514c3efe43351ff342e036a3e9
|
data/README.md
CHANGED
|
@@ -89,6 +89,26 @@ end
|
|
|
89
89
|
- ActiveRecord >= 8.0
|
|
90
90
|
- ActiveSupport >= 8.0
|
|
91
91
|
|
|
92
|
+
## Releasing
|
|
93
|
+
|
|
94
|
+
1. Bump the version in `lib/fixture_kit/version.rb`
|
|
95
|
+
2. Update lockfiles:
|
|
96
|
+
```sh
|
|
97
|
+
bundle install
|
|
98
|
+
cd spec/dummy && bundle install && cd ../..
|
|
99
|
+
bundle exec appraisal install
|
|
100
|
+
```
|
|
101
|
+
3. Commit and push to main:
|
|
102
|
+
```sh
|
|
103
|
+
git add lib/fixture_kit/version.rb spec/dummy/Gemfile.lock gemfiles/*.gemfile.lock
|
|
104
|
+
git commit -m "Release vX.Y.Z"
|
|
105
|
+
git push
|
|
106
|
+
```
|
|
107
|
+
4. Create a GitHub release:
|
|
108
|
+
```sh
|
|
109
|
+
gh release create vX.Y.Z --title "vX.Y.Z" --target main --generate-notes
|
|
110
|
+
```
|
|
111
|
+
|
|
92
112
|
## License
|
|
93
113
|
|
|
94
114
|
MIT. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
require "set"
|
|
5
|
+
|
|
6
|
+
module FixtureKit
|
|
7
|
+
module Analyzer
|
|
8
|
+
class AstFactoryDetector
|
|
9
|
+
FACTORY_METHODS = %w[create build build_stubbed create_list build_list].to_set.freeze
|
|
10
|
+
|
|
11
|
+
def initialize
|
|
12
|
+
@file_cache = {}
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Returns array of factory name strings, e.g. ["company", "employee"]
|
|
16
|
+
def detect(mod, method_name)
|
|
17
|
+
meth = mod.instance_method(method_name)
|
|
18
|
+
file, line = meth.source_location
|
|
19
|
+
return [] unless file && File.exist?(file)
|
|
20
|
+
|
|
21
|
+
ast = parse_file(file)
|
|
22
|
+
block = find_block_at_line(ast, line)
|
|
23
|
+
return [] unless block
|
|
24
|
+
|
|
25
|
+
collect_factory_calls(block)
|
|
26
|
+
rescue
|
|
27
|
+
[]
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def parse_file(path)
|
|
33
|
+
@file_cache[path] ||= Prism.parse(File.read(path)).value
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Find the block node that starts at the target line.
|
|
37
|
+
def find_block_at_line(node, target_line)
|
|
38
|
+
if node.is_a?(Prism::BlockNode) && node.location.start_line == target_line
|
|
39
|
+
return node
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
node.child_nodes.compact.each do |child|
|
|
43
|
+
found = find_block_at_line(child, target_line)
|
|
44
|
+
return found if found
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
nil
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Walk an AST subtree collecting factory call argument symbols.
|
|
51
|
+
def collect_factory_calls(node)
|
|
52
|
+
results = []
|
|
53
|
+
|
|
54
|
+
if node.is_a?(Prism::CallNode) && FACTORY_METHODS.include?(node.name.to_s)
|
|
55
|
+
first_arg = node.arguments&.arguments&.first
|
|
56
|
+
results << first_arg.value if first_arg.is_a?(Prism::SymbolNode)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
node.child_nodes.compact.each { |child| results.concat(collect_factory_calls(child)) }
|
|
60
|
+
results
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FixtureKit
|
|
4
|
+
module Analyzer
|
|
5
|
+
class FileResult
|
|
6
|
+
attr_reader :file, :total_examples, :lets
|
|
7
|
+
|
|
8
|
+
def initialize(file:, total_examples:, lets:)
|
|
9
|
+
@file = file
|
|
10
|
+
@total_examples = total_examples
|
|
11
|
+
@lets = lets.sort_by { |l| -l.example_count }
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def max_reuse
|
|
15
|
+
lets.first&.example_count || 0
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def to_h
|
|
19
|
+
{
|
|
20
|
+
file: file,
|
|
21
|
+
total_examples: total_examples,
|
|
22
|
+
max_reuse: max_reuse,
|
|
23
|
+
lets: lets.map(&:to_h),
|
|
24
|
+
}
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FixtureKit
|
|
4
|
+
module Analyzer
|
|
5
|
+
class GroupAnalyzer
|
|
6
|
+
def initialize(detector: AstFactoryDetector.new)
|
|
7
|
+
@detector = detector
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
# Check if a group directly defines a let with this name
|
|
11
|
+
def group_defines_let?(group, let_name)
|
|
12
|
+
own_mod = group.const_get(:LetDefinitions, false)
|
|
13
|
+
own_mod.instance_methods(false).include?(let_name.to_sym)
|
|
14
|
+
rescue NameError
|
|
15
|
+
false
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Count examples that inherit a let defined at `group`, stopping at overrides
|
|
19
|
+
def examples_using_let(group, let_name)
|
|
20
|
+
count = group.examples.count
|
|
21
|
+
group.children.each do |child|
|
|
22
|
+
next if group_defines_let?(child, let_name)
|
|
23
|
+
count += examples_using_let(child, let_name)
|
|
24
|
+
end
|
|
25
|
+
count
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Total examples in group + all descendants
|
|
29
|
+
def total_examples(group)
|
|
30
|
+
group.examples.count + group.children.sum { |c| total_examples(c) }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Walk tree, collect factory lets
|
|
34
|
+
def analyze(group, results = [])
|
|
35
|
+
own_mod = begin
|
|
36
|
+
group.const_get(:LetDefinitions, false)
|
|
37
|
+
rescue NameError
|
|
38
|
+
nil
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
if own_mod
|
|
42
|
+
own_mod.instance_methods(false).each do |method_name|
|
|
43
|
+
factories = @detector.detect(own_mod, method_name)
|
|
44
|
+
next if factories.empty?
|
|
45
|
+
|
|
46
|
+
loc = own_mod.instance_method(method_name).source_location
|
|
47
|
+
example_count = examples_using_let(group, method_name.to_s)
|
|
48
|
+
|
|
49
|
+
results << LetDefinition.new(
|
|
50
|
+
name: method_name.to_s,
|
|
51
|
+
factories: factories,
|
|
52
|
+
example_count: example_count,
|
|
53
|
+
file: loc&.first,
|
|
54
|
+
line: loc&.last,
|
|
55
|
+
group_description: group.description.to_s[0, 80],
|
|
56
|
+
)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
group.children.each { |child| analyze(child, results) }
|
|
61
|
+
results
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FixtureKit
|
|
4
|
+
module Analyzer
|
|
5
|
+
class LetDefinition
|
|
6
|
+
attr_reader :name, :factories, :example_count, :file, :line, :group_description
|
|
7
|
+
|
|
8
|
+
def initialize(name:, factories:, example_count:, file:, line:, group_description:)
|
|
9
|
+
@name = name
|
|
10
|
+
@factories = factories
|
|
11
|
+
@example_count = example_count
|
|
12
|
+
@file = file
|
|
13
|
+
@line = line
|
|
14
|
+
@group_description = group_description
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def defined_at
|
|
18
|
+
"#{file}:#{line}"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def to_h
|
|
22
|
+
{
|
|
23
|
+
let_name: name,
|
|
24
|
+
factories: factories,
|
|
25
|
+
example_count: example_count,
|
|
26
|
+
defined_at: defined_at,
|
|
27
|
+
group: group_description,
|
|
28
|
+
}
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module FixtureKit
|
|
6
|
+
module Analyzer
|
|
7
|
+
class Runner
|
|
8
|
+
def initialize(
|
|
9
|
+
format: ENV.fetch("ANALYZER_FORMAT", "text"),
|
|
10
|
+
limit: ENV.fetch("ANALYZER_LIMIT", "50").to_i,
|
|
11
|
+
lets_per_file: ENV.fetch("ANALYZER_LETS_PER_FILE", "8").to_i,
|
|
12
|
+
min_reuse: ENV.fetch("ANALYZER_MIN_REUSE", "2").to_i,
|
|
13
|
+
output_path: ENV["ANALYZER_OUTPUT"]
|
|
14
|
+
)
|
|
15
|
+
@format = format
|
|
16
|
+
@limit = limit
|
|
17
|
+
@lets_per_file = lets_per_file
|
|
18
|
+
@min_reuse = min_reuse
|
|
19
|
+
@output_path = output_path
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def run(example_groups)
|
|
23
|
+
return if example_groups.empty?
|
|
24
|
+
|
|
25
|
+
total = example_groups.length
|
|
26
|
+
$stderr.puts "[fixture_kit:analyzer] Analyzing #{total} top-level example groups..."
|
|
27
|
+
|
|
28
|
+
analyzer = GroupAnalyzer.new
|
|
29
|
+
|
|
30
|
+
by_file = Hash.new { |h, k| h[k] = {lets: [], total_examples: 0} }
|
|
31
|
+
|
|
32
|
+
example_groups.each_with_index do |group, idx|
|
|
33
|
+
file = group.metadata[:file_path] || group.metadata[:absolute_file_path] || "unknown"
|
|
34
|
+
if (idx + 1) % 500 == 0 || idx + 1 == total
|
|
35
|
+
$stderr.puts "[fixture_kit:analyzer] Processing group #{idx + 1}/#{total} (#{file})"
|
|
36
|
+
end
|
|
37
|
+
by_file[file][:total_examples] += analyzer.total_examples(group)
|
|
38
|
+
analyzer.analyze(group, by_file[file][:lets])
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
results = by_file.map do |file, data|
|
|
42
|
+
FileResult.new(file: file, total_examples: data[:total_examples], lets: data[:lets])
|
|
43
|
+
end.sort_by { |r| -r.max_reuse }
|
|
44
|
+
|
|
45
|
+
output(results)
|
|
46
|
+
results
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Install the at_exit hook that runs the analyzer after rspec --dry-run
|
|
50
|
+
def self.install_rspec_hook!
|
|
51
|
+
load_start = Time.now
|
|
52
|
+
|
|
53
|
+
progress_thread = Thread.new do
|
|
54
|
+
last_count = 0
|
|
55
|
+
loop do
|
|
56
|
+
sleep 5
|
|
57
|
+
current = ::RSpec.world.example_groups.length rescue 0
|
|
58
|
+
if current != last_count
|
|
59
|
+
$stderr.puts "[fixture_kit:analyzer] Loading... #{current} top-level groups found (#{(Time.now - load_start).round(1)}s)"
|
|
60
|
+
last_count = current
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
progress_thread.abort_on_exception = false
|
|
65
|
+
|
|
66
|
+
at_exit do
|
|
67
|
+
progress_thread.kill
|
|
68
|
+
next unless defined?(::RSpec) && ::RSpec.world.example_groups.any?
|
|
69
|
+
|
|
70
|
+
$stderr.puts "[fixture_kit:analyzer] Spec loading complete: #{::RSpec.world.example_groups.length} groups in #{(Time.now - load_start).round(1)}s"
|
|
71
|
+
new.run(::RSpec.world.example_groups)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def output(results)
|
|
78
|
+
case @format
|
|
79
|
+
when "json"
|
|
80
|
+
json = JSON.pretty_generate(results.map(&:to_h))
|
|
81
|
+
$stdout.puts json
|
|
82
|
+
else
|
|
83
|
+
TextFormatter.new(
|
|
84
|
+
limit: @limit,
|
|
85
|
+
lets_per_file: @lets_per_file,
|
|
86
|
+
min_reuse: @min_reuse,
|
|
87
|
+
).render(results)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
if @output_path
|
|
91
|
+
File.write(@output_path, JSON.pretty_generate(results.map(&:to_h)))
|
|
92
|
+
$stderr.puts "[fixture_kit:analyzer] Full JSON written to: #{@output_path}"
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FixtureKit
|
|
4
|
+
module Analyzer
|
|
5
|
+
class TextFormatter
|
|
6
|
+
def initialize(limit:, lets_per_file:, min_reuse:, io: $stdout)
|
|
7
|
+
@limit = limit
|
|
8
|
+
@lets_per_file = lets_per_file
|
|
9
|
+
@min_reuse = min_reuse
|
|
10
|
+
@io = io
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def render(results)
|
|
14
|
+
filtered = results.select { |r| r.max_reuse >= @min_reuse }
|
|
15
|
+
|
|
16
|
+
@io.puts
|
|
17
|
+
@io.puts "=" * 110
|
|
18
|
+
@io.puts "FACTORY LET REUSE (ranked by highest single-let example count)"
|
|
19
|
+
@io.puts "=" * 110
|
|
20
|
+
@io.puts
|
|
21
|
+
@io.puts "#{results.length} spec files analyzed, #{filtered.length} with max reuse >= #{@min_reuse}"
|
|
22
|
+
|
|
23
|
+
filtered.first(@limit).each_with_index do |result, idx|
|
|
24
|
+
@io.puts
|
|
25
|
+
@io.puts "#{idx + 1}. #{result.file}"
|
|
26
|
+
@io.puts " examples: #{result.total_examples} | factory lets: #{result.lets.length} | max reuse: #{result.max_reuse}"
|
|
27
|
+
@io.puts " " + "-" * 95
|
|
28
|
+
|
|
29
|
+
result.lets.first(@lets_per_file).each do |l|
|
|
30
|
+
@io.puts Kernel.format(" let(:%s) examples: %d factories: %s",
|
|
31
|
+
l.name, l.example_count, l.factories.join(", "))
|
|
32
|
+
@io.puts " group: #{l.group_description}"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
remaining = result.lets.length - @lets_per_file
|
|
36
|
+
@io.puts " ... +#{remaining} more" if remaining > 0
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
@io.puts
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# FixtureKit::Analyzer — Find high-ROI FixtureKit optimization targets.
|
|
4
|
+
#
|
|
5
|
+
# Analyzes RSpec specs via --dry-run to find factory_bot `let` blocks
|
|
6
|
+
# and rank them by how many examples each `let` is shared across.
|
|
7
|
+
# The more examples share a let, the bigger the speedup from caching
|
|
8
|
+
# it with FixtureKit.
|
|
9
|
+
#
|
|
10
|
+
# Usage:
|
|
11
|
+
# rspec --dry-run --require fixture_kit/analyzer [spec files or dirs...]
|
|
12
|
+
#
|
|
13
|
+
# Options (via env vars):
|
|
14
|
+
# ANALYZER_FORMAT=text|json Output format (default: text)
|
|
15
|
+
# ANALYZER_LIMIT=N Max files to show (default: 50)
|
|
16
|
+
# ANALYZER_LETS_PER_FILE=N Max lets to show per file (default: 8)
|
|
17
|
+
# ANALYZER_MIN_REUSE=N Only show files with max reuse >= N (default: 2)
|
|
18
|
+
# ANALYZER_OUTPUT=path Write JSON to file (default: none)
|
|
19
|
+
|
|
20
|
+
module FixtureKit
|
|
21
|
+
module Analyzer
|
|
22
|
+
autoload :AstFactoryDetector, File.expand_path("analyzer/ast_factory_detector", __dir__)
|
|
23
|
+
autoload :GroupAnalyzer, File.expand_path("analyzer/group_analyzer", __dir__)
|
|
24
|
+
autoload :LetDefinition, File.expand_path("analyzer/let_definition", __dir__)
|
|
25
|
+
autoload :FileResult, File.expand_path("analyzer/file_result", __dir__)
|
|
26
|
+
autoload :TextFormatter, File.expand_path("analyzer/text_formatter", __dir__)
|
|
27
|
+
autoload :Runner, File.expand_path("analyzer/runner", __dir__)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Auto-hook into RSpec when loaded via --require
|
|
32
|
+
if defined?(RSpec)
|
|
33
|
+
FixtureKit::Analyzer::Runner.install_rspec_hook!
|
|
34
|
+
end
|
data/lib/fixture_kit/cache.rb
CHANGED
|
@@ -1,9 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "json"
|
|
4
|
-
require "fileutils"
|
|
5
3
|
require "active_support/core_ext/array/wrap"
|
|
6
|
-
require "active_support/inflector"
|
|
7
4
|
|
|
8
5
|
module FixtureKit
|
|
9
6
|
class Cache
|
|
@@ -11,14 +8,14 @@ module FixtureKit
|
|
|
11
8
|
|
|
12
9
|
include ConfigurationHelper
|
|
13
10
|
|
|
14
|
-
attr_reader :fixture
|
|
11
|
+
attr_reader :fixture, :data
|
|
15
12
|
|
|
16
13
|
def initialize(fixture)
|
|
17
14
|
@fixture = fixture
|
|
18
15
|
end
|
|
19
16
|
|
|
20
17
|
def path
|
|
21
|
-
|
|
18
|
+
file_cache.path
|
|
22
19
|
end
|
|
23
20
|
|
|
24
21
|
def identifier
|
|
@@ -33,7 +30,11 @@ module FixtureKit
|
|
|
33
30
|
end
|
|
34
31
|
|
|
35
32
|
def exists?
|
|
36
|
-
|
|
33
|
+
data || file_cache.exists?
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def clear_memory
|
|
37
|
+
@data = nil
|
|
37
38
|
end
|
|
38
39
|
|
|
39
40
|
def load
|
|
@@ -41,8 +42,8 @@ module FixtureKit
|
|
|
41
42
|
raise FixtureKit::CacheMissingError, "Cache does not exist for fixture '#{fixture.identifier}'"
|
|
42
43
|
end
|
|
43
44
|
|
|
44
|
-
@data ||=
|
|
45
|
-
statements_by_connection(
|
|
45
|
+
@data ||= file_cache.read
|
|
46
|
+
statements_by_connection(data.records).each do |connection, statements|
|
|
46
47
|
connection.disable_referential_integrity do
|
|
47
48
|
# execute_batch is private in current supported Rails versions.
|
|
48
49
|
# This should be revisited when Rails 8.2 makes it public.
|
|
@@ -50,31 +51,38 @@ module FixtureKit
|
|
|
50
51
|
end
|
|
51
52
|
end
|
|
52
53
|
|
|
53
|
-
Repository.new(
|
|
54
|
+
Repository.new(data.exposed)
|
|
54
55
|
end
|
|
55
56
|
|
|
56
57
|
def save
|
|
57
58
|
FixtureKit.runner.adapter.execute do |context|
|
|
58
|
-
|
|
59
|
-
fixture.definition.evaluate(context)
|
|
59
|
+
captured_models = SqlSubscriber.capture do
|
|
60
|
+
fixture.definition.evaluate(context, parent: fixture.parent&.mount)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
if fixture.parent
|
|
64
|
+
captured_models.concat(fixture.parent.cache.data.records.keys)
|
|
60
65
|
end
|
|
61
66
|
|
|
62
|
-
@data =
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
67
|
+
@data = MemoryCache.new(
|
|
68
|
+
records: generate_statements(captured_models),
|
|
69
|
+
exposed: file_cache.serialize_exposed(fixture.definition.exposed)
|
|
70
|
+
)
|
|
66
71
|
end
|
|
67
72
|
|
|
68
|
-
|
|
69
|
-
File.write(path, JSON.pretty_generate(@data))
|
|
73
|
+
file_cache.write(data)
|
|
70
74
|
end
|
|
71
75
|
|
|
72
76
|
private
|
|
73
77
|
|
|
74
|
-
def
|
|
75
|
-
|
|
78
|
+
def file_cache
|
|
79
|
+
@file_cache ||= FileCache.new(
|
|
80
|
+
File.join(configuration.cache_path, "#{identifier}.json")
|
|
81
|
+
)
|
|
82
|
+
end
|
|
76
83
|
|
|
77
|
-
|
|
84
|
+
def generate_statements(models)
|
|
85
|
+
models.uniq.each_with_object({}) do |model, statements|
|
|
78
86
|
columns = model.column_names
|
|
79
87
|
|
|
80
88
|
rows = []
|
|
@@ -87,10 +95,8 @@ module FixtureKit
|
|
|
87
95
|
end
|
|
88
96
|
|
|
89
97
|
sql = rows.empty? ? nil : build_insert_sql(model.table_name, columns, rows, model.connection)
|
|
90
|
-
|
|
98
|
+
statements[model] = sql
|
|
91
99
|
end
|
|
92
|
-
|
|
93
|
-
statements_by_model
|
|
94
100
|
end
|
|
95
101
|
|
|
96
102
|
def build_delete_sql(model)
|
|
@@ -104,18 +110,10 @@ module FixtureKit
|
|
|
104
110
|
"INSERT INTO #{quoted_table} (#{quoted_columns.join(", ")}) VALUES #{rows.join(", ")}"
|
|
105
111
|
end
|
|
106
112
|
|
|
107
|
-
def build_exposed_mapping(exposed)
|
|
108
|
-
exposed.each_with_object({}) do |(name, value), hash|
|
|
109
|
-
was_array = value.is_a?(Array)
|
|
110
|
-
records = Array.wrap(value).map { |record| { "model" => record.class.name, "id" => record.id } }
|
|
111
|
-
hash[name] = was_array ? records : records.first
|
|
112
|
-
end
|
|
113
|
-
end
|
|
114
|
-
|
|
115
113
|
def statements_by_connection(records)
|
|
116
114
|
deleted_tables = Set.new
|
|
117
|
-
|
|
118
|
-
|
|
115
|
+
|
|
116
|
+
records.each_with_object({}) do |(model, sql), grouped|
|
|
119
117
|
connection = model.connection
|
|
120
118
|
grouped[connection] ||= []
|
|
121
119
|
|
|
@@ -2,16 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
module FixtureKit
|
|
4
4
|
class Definition
|
|
5
|
-
attr_reader :exposed, :
|
|
5
|
+
attr_reader :exposed, :extends
|
|
6
6
|
|
|
7
|
-
def initialize(&definition)
|
|
7
|
+
def initialize(extends: nil, &definition)
|
|
8
8
|
@definition = definition
|
|
9
|
-
@source_location = definition.source_location
|
|
10
9
|
@exposed = {}
|
|
10
|
+
@extends = extends
|
|
11
11
|
end
|
|
12
12
|
|
|
13
|
-
def evaluate(context)
|
|
14
|
-
context.singleton_class.prepend(mixin)
|
|
13
|
+
def evaluate(context, parent: nil)
|
|
14
|
+
context.singleton_class.prepend(mixin(parent))
|
|
15
15
|
context.instance_exec(&@definition)
|
|
16
16
|
end
|
|
17
17
|
|
|
@@ -27,13 +27,17 @@ module FixtureKit
|
|
|
27
27
|
|
|
28
28
|
private
|
|
29
29
|
|
|
30
|
-
def mixin
|
|
30
|
+
def mixin(parent)
|
|
31
31
|
definition = self
|
|
32
32
|
|
|
33
33
|
Module.new do
|
|
34
34
|
define_method(:expose) do |**records|
|
|
35
35
|
definition.expose(**records)
|
|
36
36
|
end
|
|
37
|
+
|
|
38
|
+
define_method(:parent) do
|
|
39
|
+
parent
|
|
40
|
+
end
|
|
37
41
|
end
|
|
38
42
|
end
|
|
39
43
|
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "active_support/inflector"
|
|
6
|
+
|
|
7
|
+
module FixtureKit
|
|
8
|
+
class FileCache
|
|
9
|
+
attr_reader :path
|
|
10
|
+
|
|
11
|
+
def initialize(path)
|
|
12
|
+
@path = path
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def exists?
|
|
16
|
+
File.exist?(path)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def read
|
|
20
|
+
file_data = JSON.parse(File.read(path))
|
|
21
|
+
records = file_data.fetch("records").transform_keys do |model_name|
|
|
22
|
+
ActiveSupport::Inflector.constantize(model_name)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
exposed = file_data.fetch("exposed").each_with_object({}) do |(name, value), hash|
|
|
26
|
+
if value.is_a?(Array)
|
|
27
|
+
hash[name.to_sym] = value.map { |r| { ActiveSupport::Inflector.constantize(r.keys.first) => r.values.first } }
|
|
28
|
+
else
|
|
29
|
+
hash[name.to_sym] = { ActiveSupport::Inflector.constantize(value.keys.first) => value.values.first }
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
MemoryCache.new(records: records, exposed: exposed)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def write(data)
|
|
37
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
38
|
+
File.write(path, JSON.pretty_generate(data.to_h))
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def serialize_exposed(exposed)
|
|
42
|
+
exposed.each_with_object({}) do |(name, record), hash|
|
|
43
|
+
if record.is_a?(Array)
|
|
44
|
+
hash[name] = record.map { |record| { record.class => record.id } }
|
|
45
|
+
else
|
|
46
|
+
hash[name] = { record.class => record.id }
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
data/lib/fixture_kit/fixture.rb
CHANGED
|
@@ -6,17 +6,23 @@ module FixtureKit
|
|
|
6
6
|
class Fixture
|
|
7
7
|
include ConfigurationHelper
|
|
8
8
|
|
|
9
|
-
attr_reader :identifier, :definition
|
|
9
|
+
attr_reader :identifier, :definition, :parent, :cache
|
|
10
10
|
|
|
11
11
|
def initialize(identifier, definition)
|
|
12
12
|
@identifier = identifier
|
|
13
13
|
@definition = definition
|
|
14
14
|
@cache = Cache.new(self)
|
|
15
|
+
|
|
16
|
+
if definition.extends
|
|
17
|
+
@parent = FixtureKit.runner.registry.add(definition.extends)
|
|
18
|
+
end
|
|
15
19
|
end
|
|
16
20
|
|
|
17
|
-
def
|
|
21
|
+
def generate(force: false)
|
|
18
22
|
return if @cache.exists? && !force
|
|
19
23
|
|
|
24
|
+
parent&.generate
|
|
25
|
+
|
|
20
26
|
emit(:cache_save)
|
|
21
27
|
emit(:cache_saved) { @cache.save }
|
|
22
28
|
end
|
|
@@ -30,8 +36,16 @@ module FixtureKit
|
|
|
30
36
|
emit(:cache_mounted) { @cache.load }
|
|
31
37
|
end
|
|
32
38
|
|
|
39
|
+
def finish
|
|
40
|
+
@cache.clear_memory if anonymous?
|
|
41
|
+
end
|
|
42
|
+
|
|
33
43
|
private
|
|
34
44
|
|
|
45
|
+
def anonymous?
|
|
46
|
+
!identifier.is_a?(String)
|
|
47
|
+
end
|
|
48
|
+
|
|
35
49
|
def emit(event)
|
|
36
50
|
cache_identifier = @cache.identifier
|
|
37
51
|
unless block_given?
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FixtureKit
|
|
4
|
+
class MemoryCache
|
|
5
|
+
attr_reader :records, :exposed
|
|
6
|
+
|
|
7
|
+
def initialize(records:, exposed:)
|
|
8
|
+
@records = records
|
|
9
|
+
@exposed = exposed
|
|
10
|
+
freeze
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def to_h
|
|
14
|
+
{ records: records, exposed: exposed }
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
data/lib/fixture_kit/minitest.rb
CHANGED
|
@@ -8,8 +8,10 @@ module FixtureKit
|
|
|
8
8
|
DECLARATION_CLASS_ATTRIBUTE = :fixture_kit_declaration
|
|
9
9
|
|
|
10
10
|
module ClassMethods
|
|
11
|
-
def fixture(name = nil, &
|
|
12
|
-
|
|
11
|
+
def fixture(name = nil, extends: nil, &block)
|
|
12
|
+
definition = Definition.new(extends: extends, &block) if block_given?
|
|
13
|
+
declaration = FixtureKit.runner.register(name || definition, self)
|
|
14
|
+
self.fixture_kit_declaration = declaration
|
|
13
15
|
end
|
|
14
16
|
|
|
15
17
|
def run_suite(reporter, options = {})
|
|
@@ -17,10 +19,12 @@ module FixtureKit
|
|
|
17
19
|
if declaration && !filter_runnable_methods(options).empty?
|
|
18
20
|
runner = FixtureKit.runner
|
|
19
21
|
runner.start unless runner.started?
|
|
20
|
-
declaration.
|
|
22
|
+
declaration.generate
|
|
21
23
|
end
|
|
22
24
|
|
|
23
25
|
super
|
|
26
|
+
|
|
27
|
+
declaration&.finish
|
|
24
28
|
end
|
|
25
29
|
end
|
|
26
30
|
|
data/lib/fixture_kit/registry.rb
CHANGED
|
@@ -7,22 +7,27 @@ module FixtureKit
|
|
|
7
7
|
def initialize
|
|
8
8
|
@declarations = {}
|
|
9
9
|
@fixtures = {}
|
|
10
|
+
@resolving = []
|
|
10
11
|
end
|
|
11
12
|
|
|
12
|
-
def add(
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
end
|
|
16
|
-
|
|
17
|
-
@declarations[scope] =
|
|
18
|
-
case name_or_block
|
|
13
|
+
def add(name_or_definition, scope = nil)
|
|
14
|
+
fixture =
|
|
15
|
+
case name_or_definition
|
|
19
16
|
when String
|
|
20
|
-
fetch_named_fixture(
|
|
21
|
-
when
|
|
22
|
-
|
|
17
|
+
fetch_named_fixture(name_or_definition)
|
|
18
|
+
when Definition
|
|
19
|
+
raise ArgumentError, "scope is required for anonymous fixture declarations" unless scope
|
|
20
|
+
fetch_anonymous_fixture(scope, name_or_definition)
|
|
23
21
|
else
|
|
24
|
-
raise FixtureKit::InvalidFixtureDeclaration, "unsupported fixture declaration type: #{
|
|
22
|
+
raise FixtureKit::InvalidFixtureDeclaration, "unsupported fixture declaration type: #{name_or_definition.class}"
|
|
25
23
|
end
|
|
24
|
+
|
|
25
|
+
if scope
|
|
26
|
+
raise FixtureKit::MultipleFixtures, "cannot load multiple fixtures in the same context" if @declarations.key?(scope)
|
|
27
|
+
@declarations[scope] = fixture
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
fixture
|
|
26
31
|
end
|
|
27
32
|
|
|
28
33
|
def fixtures
|
|
@@ -32,11 +37,23 @@ module FixtureKit
|
|
|
32
37
|
private
|
|
33
38
|
|
|
34
39
|
def fetch_named_fixture(name)
|
|
35
|
-
@fixtures[name]
|
|
40
|
+
return @fixtures[name] if @fixtures.key?(name)
|
|
41
|
+
|
|
42
|
+
if @resolving.include?(name)
|
|
43
|
+
chain = @resolving + [name]
|
|
44
|
+
start = chain.index(name)
|
|
45
|
+
raise FixtureKit::CircularFixtureInheritance,
|
|
46
|
+
"circular fixture inheritance detected: #{chain[start..].join(" -> ")}"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
@resolving.push(name)
|
|
50
|
+
@fixtures[name] = Fixture.new(name, load_named_definition(name))
|
|
51
|
+
@resolving.pop
|
|
52
|
+
@fixtures[name]
|
|
36
53
|
end
|
|
37
54
|
|
|
38
55
|
def fetch_anonymous_fixture(scope, definition)
|
|
39
|
-
@fixtures[scope] ||= Fixture.new(scope,
|
|
56
|
+
@fixtures[scope] ||= Fixture.new(scope, definition)
|
|
40
57
|
end
|
|
41
58
|
|
|
42
59
|
def load_named_definition(name)
|
|
@@ -5,7 +5,7 @@ require "active_support/inflector"
|
|
|
5
5
|
module FixtureKit
|
|
6
6
|
class Repository
|
|
7
7
|
def initialize(exposed_records)
|
|
8
|
-
@records = exposed_records
|
|
8
|
+
@records = exposed_records
|
|
9
9
|
@loaded_records = {}
|
|
10
10
|
define_readers
|
|
11
11
|
end
|
|
@@ -33,8 +33,7 @@ module FixtureKit
|
|
|
33
33
|
end
|
|
34
34
|
|
|
35
35
|
def load_record(record_info)
|
|
36
|
-
|
|
37
|
-
model.find_by(id: record_info.fetch("id"))
|
|
36
|
+
record_info.keys.first.find_by(id: record_info.values.first)
|
|
38
37
|
end
|
|
39
38
|
end
|
|
40
39
|
end
|
data/lib/fixture_kit/rspec.rb
CHANGED
|
@@ -27,12 +27,17 @@ module FixtureKit
|
|
|
27
27
|
# end
|
|
28
28
|
# end
|
|
29
29
|
# end
|
|
30
|
-
def fixture(name = nil, &
|
|
31
|
-
|
|
30
|
+
def fixture(name = nil, extends: nil, &block)
|
|
31
|
+
definition = Definition.new(extends: extends, &block) if block_given?
|
|
32
|
+
declaration = ::RSpec.configuration.fixture_kit.register(name || definition, self)
|
|
32
33
|
metadata[DECLARATION_METADATA_KEY] = declaration
|
|
33
34
|
|
|
34
35
|
prepend_before(:context) do
|
|
35
|
-
self.class.metadata[DECLARATION_METADATA_KEY].
|
|
36
|
+
self.class.metadata[DECLARATION_METADATA_KEY].generate
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
append_after(:context) do
|
|
40
|
+
self.class.metadata[DECLARATION_METADATA_KEY].finish
|
|
36
41
|
end
|
|
37
42
|
end
|
|
38
43
|
end
|
data/lib/fixture_kit/runner.rb
CHANGED
|
@@ -15,8 +15,8 @@ module FixtureKit
|
|
|
15
15
|
@started = false
|
|
16
16
|
end
|
|
17
17
|
|
|
18
|
-
def register(
|
|
19
|
-
registry.add(
|
|
18
|
+
def register(name_or_definition, scope)
|
|
19
|
+
registry.add(name_or_definition, scope)
|
|
20
20
|
end
|
|
21
21
|
|
|
22
22
|
def start
|
|
@@ -43,16 +43,5 @@ module FixtureKit
|
|
|
43
43
|
def preserve_cache?
|
|
44
44
|
ENV[PRESERVE_CACHE_ENV_KEY].to_s.match?(/\A(1|true|yes)\z/i)
|
|
45
45
|
end
|
|
46
|
-
|
|
47
|
-
def normalize_registration(name, definition_block)
|
|
48
|
-
if name && definition_block
|
|
49
|
-
raise FixtureKit::InvalidFixtureDeclaration, "cannot provide both fixture name and definition block"
|
|
50
|
-
end
|
|
51
|
-
|
|
52
|
-
name_or_block = name || definition_block
|
|
53
|
-
return name_or_block if name_or_block
|
|
54
|
-
|
|
55
|
-
raise FixtureKit::InvalidFixtureDeclaration, "must provide fixture name or definition block"
|
|
56
|
-
end
|
|
57
46
|
end
|
|
58
47
|
end
|
data/lib/fixture_kit/version.rb
CHANGED
data/lib/fixture_kit.rb
CHANGED
|
@@ -8,6 +8,7 @@ module FixtureKit
|
|
|
8
8
|
class CacheMissingError < Error; end
|
|
9
9
|
class FixtureDefinitionNotFound < Error; end
|
|
10
10
|
class RunnerAlreadyStartedError < Error; end
|
|
11
|
+
class CircularFixtureInheritance < 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__)
|
|
@@ -20,6 +21,8 @@ module FixtureKit
|
|
|
20
21
|
autoload :Repository, File.expand_path("fixture_kit/repository", __dir__)
|
|
21
22
|
autoload :SqlSubscriber, File.expand_path("fixture_kit/sql_subscriber", __dir__)
|
|
22
23
|
autoload :Cache, File.expand_path("fixture_kit/cache", __dir__)
|
|
24
|
+
autoload :FileCache, File.expand_path("fixture_kit/file_cache", __dir__)
|
|
25
|
+
autoload :MemoryCache, File.expand_path("fixture_kit/memory_cache", __dir__)
|
|
23
26
|
autoload :Runner, File.expand_path("fixture_kit/runner", __dir__)
|
|
24
27
|
autoload :Adapter, File.expand_path("fixture_kit/adapter", __dir__)
|
|
25
28
|
autoload :MinitestAdapter, File.expand_path("fixture_kit/adapters/minitest_adapter", __dir__)
|
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.11.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-26 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: activesupport
|
|
@@ -151,12 +151,21 @@ files:
|
|
|
151
151
|
- lib/fixture_kit/adapter.rb
|
|
152
152
|
- lib/fixture_kit/adapters/minitest_adapter.rb
|
|
153
153
|
- lib/fixture_kit/adapters/rspec_adapter.rb
|
|
154
|
+
- lib/fixture_kit/analyzer.rb
|
|
155
|
+
- lib/fixture_kit/analyzer/ast_factory_detector.rb
|
|
156
|
+
- lib/fixture_kit/analyzer/file_result.rb
|
|
157
|
+
- lib/fixture_kit/analyzer/group_analyzer.rb
|
|
158
|
+
- lib/fixture_kit/analyzer/let_definition.rb
|
|
159
|
+
- lib/fixture_kit/analyzer/runner.rb
|
|
160
|
+
- lib/fixture_kit/analyzer/text_formatter.rb
|
|
154
161
|
- lib/fixture_kit/cache.rb
|
|
155
162
|
- lib/fixture_kit/callbacks.rb
|
|
156
163
|
- lib/fixture_kit/configuration.rb
|
|
157
164
|
- lib/fixture_kit/configuration_helper.rb
|
|
158
165
|
- lib/fixture_kit/definition.rb
|
|
166
|
+
- lib/fixture_kit/file_cache.rb
|
|
159
167
|
- lib/fixture_kit/fixture.rb
|
|
168
|
+
- lib/fixture_kit/memory_cache.rb
|
|
160
169
|
- lib/fixture_kit/minitest.rb
|
|
161
170
|
- lib/fixture_kit/registry.rb
|
|
162
171
|
- lib/fixture_kit/repository.rb
|