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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ad45a151930afb73f5c8ba3f22a93536046fa783d240ea703c248e5329d6ceae
4
- data.tar.gz: fdcf019b31154c187c5057df7990647e2102c173cc6310236866202d549e846c
3
+ metadata.gz: 6ccc3496f3e23da0877bac37e98d2fd325daf737f4d7bc2988d80156d202c248
4
+ data.tar.gz: 332a5f7824ec4c449ef052bf67616c71b7bca07749f69dcb9c1369afbb95f1a4
5
5
  SHA512:
6
- metadata.gz: 73e72901b28d7f55c7714cbb581b3fa4703533968d23b20c6d0e010be4001a7cc39655ad56726f125d7b655a9ad94ff770735a72eef54a50041b9f21a81511fd
7
- data.tar.gz: b104a22228a595e9cac03e8facb58ca9174b62d22d15cb347b4fd9ef6c5fb11de1187b3d05c1c8cd9bd71ae0b417d965e1af436a0c4af9efdd6f4f06e75649ba
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
@@ -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
- File.join(configuration.cache_path, "#{identifier}.json")
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
- @data || File.exist?(path)
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 ||= JSON.parse(File.read(path))
45
- statements_by_connection(@data.fetch("records")).each do |connection, statements|
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(@data.fetch("exposed"))
54
+ Repository.new(data.exposed)
54
55
  end
55
56
 
56
57
  def save
57
58
  FixtureKit.runner.adapter.execute do |context|
58
- models = SqlSubscriber.capture do
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
- "records" => generate_statements(models),
64
- "exposed" => build_exposed_mapping(fixture.definition.exposed)
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
- FileUtils.mkdir_p(File.dirname(path))
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 generate_statements(models)
75
- statements_by_model = {}
78
+ def file_cache
79
+ @file_cache ||= FileCache.new(
80
+ File.join(configuration.cache_path, "#{identifier}.json")
81
+ )
82
+ end
76
83
 
77
- models.each do |model|
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
- statements_by_model[model.name] = sql
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
- records.each_with_object({}) do |(model_name, sql), grouped|
118
- model = ActiveSupport::Inflector.constantize(model_name)
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, :source_location
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
@@ -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 cache(force: false)
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
@@ -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, &definition_block)
12
- self.fixture_kit_declaration = FixtureKit.runner.register(self, name, &definition_block)
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.cache
22
+ declaration.generate
21
23
  end
22
24
 
23
25
  super
26
+
27
+ declaration&.finish
24
28
  end
25
29
  end
26
30
 
@@ -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(scope, name_or_block)
13
- if @declarations.key?(scope)
14
- raise FixtureKit::MultipleFixtures, "cannot load multiple fixtures in the same context"
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(name_or_block)
21
- when Proc
22
- fetch_anonymous_fixture(scope, name_or_block)
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: #{name_or_block.class}"
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] ||= Fixture.new(name, load_named_definition(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, Definition.new(&definition))
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.transform_keys(&:to_sym)
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
- model = ActiveSupport::Inflector.constantize(record_info.fetch("model"))
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
@@ -27,12 +27,17 @@ module FixtureKit
27
27
  # end
28
28
  # end
29
29
  # end
30
- def fixture(name = nil, &definition_block)
31
- declaration = ::RSpec.configuration.fixture_kit.register(self, name, &definition_block)
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].cache
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
@@ -15,8 +15,8 @@ module FixtureKit
15
15
  @started = false
16
16
  end
17
17
 
18
- def register(scope, name = nil, &definition_block)
19
- registry.add(scope, normalize_registration(name, definition_block))
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
@@ -11,8 +11,8 @@ module FixtureKit
11
11
  @runner ||= Runner.new
12
12
  end
13
13
 
14
- def define(&block)
15
- Definition.new(&block)
14
+ def define(extends: nil, &block)
15
+ Definition.new(extends: extends, &block)
16
16
  end
17
17
 
18
18
  def reset
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module FixtureKit
4
- VERSION = "0.9.1"
4
+ VERSION = "0.11.0"
5
5
  end
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.9.1
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-24 00:00:00.000000000 Z
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