Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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
5 changes: 3 additions & 2 deletions docs/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,14 +188,15 @@ Default values:

Subclass `FixtureKit::Coder` and implement:

`#generate(parent_data: nil, &block)`
`#generate(&block)`
- Called once when fixture cache is being built.
- Set up observation, then call the block to evaluate the user's fixture definition (and any inner coders).
- Return data to be cached for this coder. Will be passed to `#encode` before serialization.
- `parent_data` is the cached data from the same coder on the parent fixture when `extends:` is used; `nil` otherwise.
- For inherited fixtures (`extends:`), the parent is mounted via `#mount` inside the block. A coder that needs the parent's contribution should record what it replays during `#mount` and fold it into its result here (see `ActiveRecordCoder`). The previous `parent_data:` keyword has been removed.

`#mount(data)`
- Called once per test mount with the data this coder produced. Re-create the state on the test database.
- Also runs while a child fixture is being generated (the parent is mounted inside the child's `#generate` block). Coders that need their replayed models reflected in the child cache record them here.

`#encode(data)`
- Convert the in-memory representation produced by `#generate` to a JSON-serializable form. Default: identity.
Expand Down
6 changes: 4 additions & 2 deletions lib/fixture_kit/cache.rb
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,10 @@ def evaluate(coders, context, data = {}, &block)
else
coder, *remaining_coders = coders

parent_data = fixture.parent ? fixture.parent.cache.content.data_for(coder.class) : nil
data[coder.class] = coder.generate(parent_data: parent_data) do
# No parent_data lookup: the parent's models are recaptured during
# parent.mount (called in the leaf branch above) by the coder itself.
# See ActiveRecordCoder#generate / #mount.
data[coder.class] = coder.generate do
evaluate(remaining_coders, context, data, &block)
end
end
Expand Down
2 changes: 1 addition & 1 deletion lib/fixture_kit/coder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

module FixtureKit
class Coder
def generate(parent_data: nil, &block)
def generate(&block)
raise NotImplementedError, "#{self.class} must implement #generate"
end

Expand Down
19 changes: 17 additions & 2 deletions lib/fixture_kit/coders/active_record_coder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,14 @@ class ActiveRecordCoder < FixtureKit::Coder
EVENT = "sql.active_record"
NAME_PATTERN = /\A(?<model_name>.+?) (?:(?:Bulk )?(?:Insert|Upsert)|Create|Destroy|(?:Update|Delete)(?: All)?)\z/

def generate(parent_data: nil, &block)
def generate(&block)
# DRAFT: instead of taking parent_data, we recapture the parent's models
# from #mount, which runs (via parent.mount) inside &block. This relies on
# the runner reusing one coder instance across generate and mount, and on
# the ordering "mount happens inside this block". Reset per generate so a
# previous run's models don't leak in.
@replayed_models = Set.new

captured_models = Set.new
subscriber = lambda do |_event_name, _start, _finish, _id, payload|
name = payload[:name].to_s
Expand All @@ -24,12 +31,20 @@ def generate(parent_data: nil, &block)
ActiveSupport::Notifications.subscribed(subscriber, EVENT, monotonic: true, &block)

captured_models.map! { |model| base_table_model(model) }
captured_models.merge(parent_data.keys) if parent_data
captured_models.merge(@replayed_models)

generate_statements(captured_models)
ensure
@replayed_models = nil
end

def mount(data)
# Replayed INSERTs are tagged "FixtureKit Insert" below, so the #generate
# subscriber cannot see them. When a child fixture is being generated, the
# parent is mounted inside the generate block; record the models we replay
# so #generate can fold them into the child's captured set.
@replayed_models&.merge(data.keys)

models_by_pool(data).each do |pool, models|
pool.with_connection do |connection|
statements = models.flat_map do |model|
Expand Down
15 changes: 10 additions & 5 deletions spec/unit/coders/active_record_coder_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -103,11 +103,16 @@ def upsert_all_options(model)
expect(coder.generate { user.destroy! }[User]).to be_nil
end

it "includes models from parent_data that were not directly captured" do
User.create!(name: "Alice", email: "alice-parent@example.com")

parent_data = { User => "INSERT INTO users ..." }
result = coder.generate(parent_data: parent_data) do
it "folds models replayed via #mount into the captured set" do
alice = User.create!(name: "Alice", email: "alice-parent@example.com")
# A real serialized payload for the users table (one INSERT for Alice).
user_payload = coder.generate { User.where(id: alice.id).update_all(name: "Alice") }

# parent.mount runs inside the generate block. Its replayed INSERT is
# tagged "FixtureKit Insert", so generate's subscriber never sees User —
# it can only enter the result via @replayed_models, recorded by #mount.
result = coder.generate do
coder.mount(user_payload)
Project.create!(name: "Project", owner: User.find_by!(email: "alice-parent@example.com"))
end

Expand Down
105 changes: 105 additions & 0 deletions spec/unit/fixture_cache_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,111 @@ def identifier_for(identifier)
end
end

describe "parent model propagation via #mount (draft: no parent_data)" do
# End-to-end exercise of the accumulator approach: a real parent cache is
# saved, then "a fresh process" clears it from memory, and the child is
# saved with the parent mounted from disk. The parent's models can only
# reach the child cache via @replayed_models (recorded in
# ActiveRecordCoder#mount), because the replayed INSERTs are tagged
# "FixtureKit Insert" and the child's subscriber never sees them. None of
# the child definitions below recreate the parent's models with a captured
# write, so the assertions are load-bearing.
def propagate(parent_id:, child_id:, parent_definition:, child_definition:)
parent_fixture = instance_double(
FixtureKit::Fixture, identifier: parent_id, definition: parent_definition, parent: nil
)
parent_cache = described_class.new(parent_fixture)
parent_cache.save
parent_cache.clear_memory # fresh process: @content nil, file on disk

allow(parent_fixture).to receive(:cache).and_return(parent_cache)
# Real mount: replays the parent's rows through ActiveRecordCoder#mount,
# which records the models in @replayed_models.
allow(parent_fixture).to receive(:mount) { parent_cache.load }

child_fixture = instance_double(
FixtureKit::Fixture, identifier: child_id, definition: child_definition, parent: parent_fixture
)
child_cache = described_class.new(child_fixture)
child_cache.save

JSON.parse(File.read(child_cache.path))["data"]["FixtureKit::ActiveRecordCoder"]
end

it "carries a basic parent model into the child cache across processes" do
parent_definition = FixtureKit::Definition.new do
User.create!(name: "Owner", email: "owner-c@example.com")
end
child_definition = FixtureKit::Definition.new do
owner = User.find_by!(email: "owner-c@example.com")
Project.create!(name: "Child Project", owner: owner)
end

data = propagate(
parent_id: "parent_basic", child_id: "child_basic",
parent_definition: parent_definition, child_definition: child_definition
)

expect(data.keys).to include("User", "Project")
end

it "collapses STI parent models to their base table and carries them" do
parent_definition = FixtureKit::Definition.new do
User.create!(name: "STI Owner", email: "sti-owner@example.com")
Car.create!(name: "Beetle", year: 1968)
end
child_definition = FixtureKit::Definition.new do
owner = User.find_by!(email: "sti-owner@example.com")
Project.create!(name: "STI Child Project", owner: owner)
end

data = propagate(
parent_id: "parent_sti", child_id: "child_sti",
parent_definition: parent_definition, child_definition: child_definition
)

expect(data.keys).to include("User", "Vehicle", "Project")
expect(data.keys).not_to include("Car") # collapsed to the base table model
end

it "carries parent models from a second connection pool" do
parent_definition = FixtureKit::Definition.new do
user = User.create!(name: "MP Owner", email: "mp-owner@example.com")
ActivityLog.create!(external_user_id: user.id, action: "signed_in")
end
child_definition = FixtureKit::Definition.new do
owner = User.find_by!(email: "mp-owner@example.com")
Project.create!(name: "Multipool Child Project", owner: owner)
end

data = propagate(
parent_id: "parent_multipool", child_id: "child_multipool",
parent_definition: parent_definition, child_definition: child_definition
)

expect(data.keys).to include("User", "ActivityLog", "Project")
end

it "carries a parent model whose cached sql is nil (delete-only)" do
parent_definition = FixtureKit::Definition.new do
User.create!(name: "Del Owner", email: "del-owner@example.com")
Phone.create!(name: "Doomed").destroy! # STI subclass; base table is gadgets
end
child_definition = FixtureKit::Definition.new do
owner = User.find_by!(email: "del-owner@example.com")
Project.create!(name: "Delete Child Project", owner: owner)
end

data = propagate(
parent_id: "parent_delete", child_id: "child_delete",
parent_definition: parent_definition, child_definition: child_definition
)

expect(data.keys).to include("User", "Gadget", "Project")
expect(data["Gadget"]).to be_nil # carried forward even with no rows to insert
end
end

describe "#clear_memory" do
it "nils out @content" do
cache.instance_variable_set(:@content, FixtureKit::MemoryCache.new(data: {}, exposed: {}))
Expand Down