Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions lib/fixture_kit/cache.rb
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ def load
raise FixtureKit::CacheMissingError, "Cache does not exist for fixture '#{fixture.identifier}'"
end

@content ||= file_cache.read
ensure_content

FixtureKit.runner.coders.each do |coder|
coder.mount(content.data_for(coder.class))
Expand All @@ -49,6 +49,15 @@ def load
Repository.new(content.exposed)
end

# Lazily loads @content from the file cache. Used when content is needed in
# memory without a full mount (e.g. when a child fixture is being saved and
# needs the parent's coder data, while the parent itself was already cached
# to disk in a previous process and has not yet been mounted). Raises if the
# cache file is absent or unreadable — callers must guarantee existence.
def ensure_content

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can this be just be named content?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

content is already an attr_reader (line 9) with the opposite contract: it returns @content directly — nil when nothing is mounted, never touching disk and never raising. exists? depends on that (content || file_cache.exists?), and a few specs assert it directly (expect(cache.content).to be_nil after clear_memory).

ensure_content is the lazy sibling — @content ||= file_cache.read — which memoizes and raises Errno::ENOENT when the file is missing. I named it for that side effect so the pure reader stays distinguishable from the disk-loading one.

I can collapse the two into a single lazy content if you'd prefer one method (pointing exists? at @content and reworking the nil-after-clear_memory specs), just flagging it's a behavior merge rather than only a rename. Happy to go either way — let me know.

@content ||= file_cache.read
end

def save
FixtureKit.runner.adapter.execute do |context|
@content = MemoryCache.new(
Expand All @@ -68,7 +77,7 @@ def evaluate(coders, context, data = {}, &block)
else
coder, *remaining_coders = coders

parent_data = fixture.parent ? fixture.parent.cache.content.data_for(coder.class) : nil
parent_data = fixture.parent ? fixture.parent.cache.ensure_content.data_for(coder.class) : nil
data[coder.class] = coder.generate(parent_data: parent_data) do
evaluate(remaining_coders, context, data, &block)
end
Expand Down
88 changes: 87 additions & 1 deletion spec/unit/fixture_cache_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,7 @@ def identifier_for(identifier)
data: { FixtureKit::ActiveRecordCoder => { User => nil } },
exposed: {}
)
parent_cache = instance_double(FixtureKit::Cache, content: parent_cache_data)
parent_cache = instance_double(FixtureKit::Cache, ensure_content: parent_cache_data)
parent_fixture = instance_double(FixtureKit::Fixture, cache: parent_cache)
allow(parent_fixture).to receive(:mount) do
User.create!(name: "Parent Owner", email: "parent-owner@example.com")
Expand All @@ -256,6 +256,53 @@ def identifier_for(identifier)
data = JSON.parse(File.read(child_cache.path))
expect(data["data"]["FixtureKit::ActiveRecordCoder"].keys).to include("User", "Project")
end

it "loads parent content from disk when child saves without parent in memory" do
# Reproduces the "data_for for nil" scenario: a parent fixture has
# already been persisted to disk (previous process), then a child
# fixture must be generated in a new process where
# parent.cache.content is nil.
parent_definition = FixtureKit::Definition.new do
User.create!(name: "Parent Owner", email: "parent-owner-cross-process@example.com")
end
parent_fixture = instance_double(
FixtureKit::Fixture,
identifier: "parent_fixture",
definition: parent_definition,
parent: nil
)
parent_cache = described_class.new(parent_fixture)
parent_cache.save
parent_cache.clear_memory # simulates a fresh process: @content = nil but the file exists

# Precondition: confirms we are really in the "cache on disk, nothing
# in memory" state — this is what triggered the original bug.
expect(parent_cache.content).to be_nil

allow(parent_fixture).to receive(:cache).and_return(parent_cache)
# The mount stub does NOT recreate User (parent_cache.save already left
# it in the DB). As a result, User can only appear in the child cache
# via parent_data.keys — a faux fix that returned parent_data: nil
# would make "User" disappear from the assertion below.
allow(parent_fixture).to receive(:mount).and_return(FixtureKit::Repository.new({}))

child_definition = FixtureKit::Definition.new do
owner = User.find_by!(email: "parent-owner-cross-process@example.com")
Project.create!(name: "Cross-process project", owner: owner)
end
child_fixture = instance_double(
FixtureKit::Fixture,
identifier: "child_fixture",
definition: child_definition,
parent: parent_fixture
)
child_cache = described_class.new(child_fixture)

expect { child_cache.save }.not_to raise_error

data = JSON.parse(File.read(child_cache.path))
expect(data["data"]["FixtureKit::ActiveRecordCoder"].keys).to include("User", "Project")
end
end

describe "#clear_memory" do
Expand Down Expand Up @@ -313,6 +360,45 @@ def identifier_for(identifier)
end
end

describe "#ensure_content" do
it "returns the memoized @content without touching disk" do
in_memory = FixtureKit::MemoryCache.new(data: {}, exposed: {})
cache.instance_variable_set(:@content, in_memory)

# No file on disk — if ensure_content read from disk we would get
# Errno::ENOENT. The fact that it returns without error proves it is
# serving the memoized version.
expect(File.exist?(cache.path)).to be(false)
expect(cache.ensure_content).to be(in_memory)
end

it "re-reads from disk after clear_memory" do
fixture_definition = FixtureKit::Definition.new do
alice = User.create!(name: "Alice", email: "alice-ensure@example.com")
expose(alice: alice)
end
fixture_double = instance_double(
FixtureKit::Fixture,
identifier: fixture_name,
definition: fixture_definition,
parent: nil
)
fixture_cache = described_class.new(fixture_double)
fixture_cache.save
fixture_cache.clear_memory

reloaded = fixture_cache.ensure_content

expect(reloaded).to be_a(FixtureKit::MemoryCache)
expect(reloaded.data_for(FixtureKit::ActiveRecordCoder)).to have_key(User)
end

it "raises when the cache file is absent" do
expect(File.exist?(cache.path)).to be(false)
expect { cache.ensure_content }.to raise_error(Errno::ENOENT)
end
end

describe "#load" do
it "documents that connection execute_batch is currently private" do
connection = User.connection
Expand Down