diff --git a/lib/fixture_kit.rb b/lib/fixture_kit.rb index dfbe1ce..b79f06e 100644 --- a/lib/fixture_kit.rb +++ b/lib/fixture_kit.rb @@ -21,6 +21,8 @@ class CircularFixtureInheritance < Error; end autoload :Repository, File.expand_path("fixture_kit/repository", __dir__) autoload :SqlSubscriber, File.expand_path("fixture_kit/sql_subscriber", __dir__) autoload :Cache, File.expand_path("fixture_kit/cache", __dir__) + autoload :FileCache, File.expand_path("fixture_kit/file_cache", __dir__) + autoload :MemoryCache, File.expand_path("fixture_kit/memory_cache", __dir__) autoload :Runner, File.expand_path("fixture_kit/runner", __dir__) autoload :Adapter, File.expand_path("fixture_kit/adapter", __dir__) autoload :MinitestAdapter, File.expand_path("fixture_kit/adapters/minitest_adapter", __dir__) diff --git a/lib/fixture_kit/cache.rb b/lib/fixture_kit/cache.rb index 436b3ed..3596aa2 100644 --- a/lib/fixture_kit/cache.rb +++ b/lib/fixture_kit/cache.rb @@ -1,14 +1,10 @@ # frozen_string_literal: true -require "json" -require "fileutils" require "active_support/core_ext/array/wrap" -require "active_support/inflector" module FixtureKit class Cache ANONYMOUS_DIRECTORY = "_anonymous" - MemoryData = Data.define(:records, :exposed) include ConfigurationHelper @@ -19,7 +15,7 @@ def initialize(fixture) end def path - File.join(configuration.cache_path, "#{identifier}.json") + file_cache.path end def identifier @@ -34,7 +30,11 @@ def identifier end def exists? - data || File.exist?(path) + data || file_cache.exists? + end + + def clear_memory + @data = nil end def load @@ -42,7 +42,7 @@ def load raise FixtureKit::CacheMissingError, "Cache does not exist for fixture '#{fixture.identifier}'" end - @data ||= load_memory_data + @data ||= file_cache.read statements_by_connection(data.records).each do |connection, statements| connection.disable_referential_integrity do # execute_batch is private in current supported Rails versions. @@ -64,17 +64,23 @@ def save captured_models.concat(fixture.parent.cache.data.records.keys) end - @data = MemoryData.new( + @data = MemoryCache.new( records: generate_statements(captured_models), - exposed: build_exposed_mapping(fixture.definition.exposed) + exposed: file_cache.serialize_exposed(fixture.definition.exposed) ) end - save_file_data + file_cache.write(data) end private + def file_cache + @file_cache ||= FileCache.new( + File.join(configuration.cache_path, "#{identifier}.json") + ) + end + def generate_statements(models) models.uniq.each_with_object({}) do |model, statements| columns = model.column_names @@ -104,16 +110,6 @@ def build_insert_sql(table_name, columns, rows, connection) "INSERT INTO #{quoted_table} (#{quoted_columns.join(", ")}) VALUES #{rows.join(", ")}" end - def build_exposed_mapping(exposed) - exposed.each_with_object({}) do |(name, record), hash| - if record.is_a?(Array) - hash[name] = record.map { |record| { record.class => record.id } } - else - hash[name] = { record.class => record.id } - end - end - end - def statements_by_connection(records) deleted_tables = Set.new @@ -129,27 +125,5 @@ def statements_by_connection(records) grouped[connection] << sql if sql end end - - def load_memory_data - file_data = JSON.parse(File.read(path)) - records = file_data.fetch("records").transform_keys do |model_name| - ActiveSupport::Inflector.constantize(model_name) - end - - exposed = file_data.fetch("exposed").each_with_object({}) do |(name, value), hash| - if value.is_a?(Array) - hash[name.to_sym] = value.map { |r| { ActiveSupport::Inflector.constantize(r.keys.first) => r.values.first } } - else - hash[name.to_sym] = { ActiveSupport::Inflector.constantize(value.keys.first) => value.values.first } - end - end - - MemoryData.new(records: records, exposed: exposed) - end - - def save_file_data - FileUtils.mkdir_p(File.dirname(path)) - File.write(path, JSON.pretty_generate(data.to_h)) - end end end diff --git a/lib/fixture_kit/file_cache.rb b/lib/fixture_kit/file_cache.rb new file mode 100644 index 0000000..cfaaf5c --- /dev/null +++ b/lib/fixture_kit/file_cache.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require "json" +require "fileutils" +require "active_support/inflector" + +module FixtureKit + class FileCache + attr_reader :path + + def initialize(path) + @path = path + end + + def exists? + File.exist?(path) + end + + def read + file_data = JSON.parse(File.read(path)) + records = file_data.fetch("records").transform_keys do |model_name| + ActiveSupport::Inflector.constantize(model_name) + end + + exposed = file_data.fetch("exposed").each_with_object({}) do |(name, value), hash| + if value.is_a?(Array) + hash[name.to_sym] = value.map { |r| { ActiveSupport::Inflector.constantize(r.keys.first) => r.values.first } } + else + hash[name.to_sym] = { ActiveSupport::Inflector.constantize(value.keys.first) => value.values.first } + end + end + + MemoryCache.new(records: records, exposed: exposed) + end + + def write(data) + FileUtils.mkdir_p(File.dirname(path)) + File.write(path, JSON.pretty_generate(data.to_h)) + end + + def serialize_exposed(exposed) + exposed.each_with_object({}) do |(name, record), hash| + if record.is_a?(Array) + hash[name] = record.map { |record| { record.class => record.id } } + else + hash[name] = { record.class => record.id } + end + end + end + end +end diff --git a/lib/fixture_kit/fixture.rb b/lib/fixture_kit/fixture.rb index 40bd7a1..f737458 100644 --- a/lib/fixture_kit/fixture.rb +++ b/lib/fixture_kit/fixture.rb @@ -36,8 +36,16 @@ def mount emit(:cache_mounted) { @cache.load } end + def finish + @cache.clear_memory if anonymous? + end + private + def anonymous? + !identifier.is_a?(String) + end + def emit(event) cache_identifier = @cache.identifier unless block_given? diff --git a/lib/fixture_kit/memory_cache.rb b/lib/fixture_kit/memory_cache.rb new file mode 100644 index 0000000..b883603 --- /dev/null +++ b/lib/fixture_kit/memory_cache.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module FixtureKit + class MemoryCache + attr_reader :records, :exposed + + def initialize(records:, exposed:) + @records = records + @exposed = exposed + freeze + end + + def to_h + { records: records, exposed: exposed } + end + end +end diff --git a/lib/fixture_kit/minitest.rb b/lib/fixture_kit/minitest.rb index 1ddd48d..7f575f4 100644 --- a/lib/fixture_kit/minitest.rb +++ b/lib/fixture_kit/minitest.rb @@ -23,6 +23,8 @@ def run_suite(reporter, options = {}) end super + + declaration&.finish end end diff --git a/lib/fixture_kit/rspec.rb b/lib/fixture_kit/rspec.rb index 8ed8d16..00281da 100644 --- a/lib/fixture_kit/rspec.rb +++ b/lib/fixture_kit/rspec.rb @@ -35,6 +35,10 @@ def fixture(name = nil, extends: nil, &block) prepend_before(:context) do self.class.metadata[DECLARATION_METADATA_KEY].generate end + + append_after(:context) do + self.class.metadata[DECLARATION_METADATA_KEY].finish + end end end diff --git a/spec/unit/file_cache_spec.rb b/spec/unit/file_cache_spec.rb new file mode 100644 index 0000000..3bc9ccf --- /dev/null +++ b/spec/unit/file_cache_spec.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe FixtureKit::FileCache do + let(:cache_path) { Rails.root.join("tmp/cache/fixture_kit_file_cache_test").to_s } + let(:file_path) { File.join(cache_path, "test_fixture.json") } + let(:file_cache) { described_class.new(file_path) } + + before do + FileUtils.rm_rf(cache_path) + end + + after do + FileUtils.rm_rf(cache_path) + end + + describe "#path" do + it "returns the path provided at initialization" do + expect(file_cache.path).to eq(file_path) + end + end + + describe "#exists?" do + it "returns false when the file does not exist" do + expect(file_cache.exists?).to be(false) + end + + it "returns true when the file exists" do + FileUtils.mkdir_p(cache_path) + File.write(file_path, "{}") + + expect(file_cache.exists?).to be(true) + end + end + + describe "#write and #read" do + it "round-trips MemoryCache through JSON on disk" do + data = FixtureKit::MemoryCache.new( + records: { User => "INSERT INTO users (id, name) VALUES (1, 'Alice')" }, + exposed: { alice: { User => 1 } } + ) + + file_cache.write(data) + + expect(File.exist?(file_path)).to be(true) + result = file_cache.read + expect(result).to be_a(FixtureKit::MemoryCache) + expect(result.records).to eq({ User => "INSERT INTO users (id, name) VALUES (1, 'Alice')" }) + expect(result.exposed).to eq({ alice: { User => 1 } }) + end + + it "round-trips MemoryCache with nil sql values" do + data = FixtureKit::MemoryCache.new( + records: { User => nil }, + exposed: {} + ) + + file_cache.write(data) + result = file_cache.read + + expect(result.records).to eq({ User => nil }) + end + + it "round-trips MemoryCache with array exposed values" do + data = FixtureKit::MemoryCache.new( + records: {}, + exposed: { users: [{ User => 1 }, { User => 2 }] } + ) + + file_cache.write(data) + result = file_cache.read + + expect(result.exposed).to eq({ users: [{ User => 1 }, { User => 2 }] }) + end + + it "creates intermediate directories" do + nested_path = File.join(cache_path, "nested", "deep", "fixture.json") + nested_file_cache = described_class.new(nested_path) + data = FixtureKit::MemoryCache.new(records: {}, exposed: {}) + + nested_file_cache.write(data) + + expect(File.exist?(nested_path)).to be(true) + end + end + + describe "#serialize_exposed" do + it "converts ActiveRecord instances to class/id pairs" do + user = User.create!(name: "Alice", email: "alice-file-cache@example.com") + + result = file_cache.serialize_exposed({ alice: user }) + + expect(result).to eq({ alice: { User => user.id } }) + end + + it "converts arrays of ActiveRecord instances" do + alice = User.create!(name: "Alice", email: "alice-array@example.com") + bob = User.create!(name: "Bob", email: "bob-array@example.com") + + result = file_cache.serialize_exposed({ users: [alice, bob] }) + + expect(result).to eq({ users: [{ User => alice.id }, { User => bob.id }] }) + end + end +end diff --git a/spec/unit/fixture_cache_spec.rb b/spec/unit/fixture_cache_spec.rb index 073eced..8affba0 100644 --- a/spec/unit/fixture_cache_spec.rb +++ b/spec/unit/fixture_cache_spec.rb @@ -141,8 +141,9 @@ def identifier_for(identifier) fixture_cache.save - expect(File.exist?(fixture_cache.path)).to be(true) - data = JSON.parse(File.read(fixture_cache.path)) + path = fixture_cache.path + expect(File.exist?(path)).to be(true) + data = JSON.parse(File.read(path)) expect(data["records"]).to have_key("User") expect(data["exposed"]).to have_key("alice") end @@ -226,7 +227,7 @@ def identifier_for(identifier) end it "includes parent fixture model records when saving inherited fixtures" do - parent_cache_data = FixtureKit::Cache::MemoryData.new( + parent_cache_data = FixtureKit::MemoryCache.new( records: { User => nil }, exposed: {} ) @@ -257,6 +258,61 @@ def identifier_for(identifier) end end + describe "#clear_memory" do + it "nils out @data" do + cache.instance_variable_set(:@data, FixtureKit::MemoryCache.new(records: {}, exposed: {})) + + cache.clear_memory + + expect(cache.data).to be_nil + end + + it "still reports exists? as true when file cache is present" do + fixture_definition = FixtureKit::Definition.new do + alice = User.create!(name: "Alice", email: "alice-clear@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 + + expect(fixture_cache.data).to be_nil + expect(fixture_cache.exists?).to be(true) + end + + it "re-reads from file cache on next load" do + fixture_definition = FixtureKit::Definition.new do + alice = User.create!(name: "Alice", email: "alice-reread@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 + expect(fixture_cache.data).to be_nil + + User.delete_all + repository = fixture_cache.load + + expect(fixture_cache.data).not_to be_nil + expect(repository.alice).to be_a(User) + expect(repository.alice.name).to eq("Alice") + end + end + describe "#load" do it "documents that connection execute_batch is currently private" do connection = User.connection @@ -274,7 +330,7 @@ def identifier_for(identifier) allow(FixtureKit::Repository).to receive(:new).with({}).and_return(:repository) cache.instance_variable_set( :@data, - FixtureKit::Cache::MemoryData.new( + FixtureKit::MemoryCache.new( records: { User => user_sql, Project => project_sql, @@ -334,4 +390,5 @@ def identifier_for(identifier) end end + end diff --git a/spec/unit/fixture_spec.rb b/spec/unit/fixture_spec.rb index ba61955..c364126 100644 --- a/spec/unit/fixture_spec.rb +++ b/spec/unit/fixture_spec.rb @@ -128,6 +128,42 @@ end end + describe "#finish" do + it "clears memory for anonymous fixtures" do + anonymous_definition = FixtureKit::Definition.new {} + anonymous_scope = Class.new + allow(cache).to receive(:clear_memory) + fixture = described_class.new(anonymous_scope, anonymous_definition) + + fixture.finish + + expect(cache).to have_received(:clear_memory) + end + + it "does not clear memory for named fixtures" do + allow(cache).to receive(:clear_memory) + fixture = described_class.new("project_management", definition) + + fixture.finish + + expect(cache).not_to have_received(:clear_memory) + end + + it "allows a finished anonymous fixture to still be mounted from file cache" do + allow(cache).to receive(:clear_memory) + allow(cache).to receive(:exists?).and_return(true) + anonymous_definition = FixtureKit::Definition.new {} + anonymous_scope = Class.new + fixture = described_class.new(anonymous_scope, anonymous_definition) + + fixture.finish + result = fixture.mount + + expect(cache).to have_received(:clear_memory) + expect(result).to eq(:repository) + end + end + describe "#parent" do it "loads and memoizes parent fixture from registry" do parent_fixture = instance_double(FixtureKit::Fixture) diff --git a/spec/unit/memory_cache_spec.rb b/spec/unit/memory_cache_spec.rb new file mode 100644 index 0000000..088f3af --- /dev/null +++ b/spec/unit/memory_cache_spec.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe FixtureKit::MemoryCache do + describe "#initialize" do + it "stores records and exposed data" do + cache = described_class.new(records: { "User" => "SQL" }, exposed: { alice: 1 }) + + expect(cache.records).to eq({ "User" => "SQL" }) + expect(cache.exposed).to eq({ alice: 1 }) + end + + it "is frozen after initialization" do + cache = described_class.new(records: {}, exposed: {}) + + expect(cache).to be_frozen + end + end + + describe "#to_h" do + it "returns a hash of records and exposed" do + cache = described_class.new( + records: { "User" => "INSERT INTO users VALUES (1)" }, + exposed: { alice: { "User" => 1 } } + ) + + expect(cache.to_h).to eq({ + records: { "User" => "INSERT INTO users VALUES (1)" }, + exposed: { alice: { "User" => 1 } } + }) + end + end +end diff --git a/spec/unit/minitest_entrypoint_spec.rb b/spec/unit/minitest_entrypoint_spec.rb index 1325ad8..7bf3174 100644 --- a/spec/unit/minitest_entrypoint_spec.rb +++ b/spec/unit/minitest_entrypoint_spec.rb @@ -4,7 +4,7 @@ require "fixture_kit/minitest" RSpec.describe FixtureKit::Minitest::ClassMethods do - let(:fixture_declaration) { instance_double(FixtureKit::Fixture, generate: nil, mount: :repository) } + let(:fixture_declaration) { instance_double(FixtureKit::Fixture, generate: nil, mount: :repository, finish: nil) } let(:runner) do instance_double( FixtureKit::Runner, @@ -97,6 +97,37 @@ Minitest::Runnable.runnables.delete(test_case) end + it "calls finish on the declaration after tests run" do + allow(runner).to receive(:started?).and_return(true) + test_case = Class.new(ActiveSupport::TestCase) do + test "noop" do + assert true + end + end + test_case.fixture_kit_declaration = fixture_declaration + reporter = build_reporter + allow(test_case).to receive(:filter_runnable_methods).with({}).and_return(["test_noop"]) + + expect(fixture_declaration).to receive(:finish) + + test_case.run_suite(reporter, {}) + ensure + Minitest::Runnable.runnables.delete(test_case) + end + + it "calls finish even when there are no runnable methods" do + test_case = Class.new(ActiveSupport::TestCase) + test_case.fixture_kit_declaration = fixture_declaration + reporter = build_reporter + allow(test_case).to receive(:filter_runnable_methods).with({}).and_return([]) + + expect(fixture_declaration).to receive(:finish) + + test_case.run_suite(reporter, {}) + ensure + Minitest::Runnable.runnables.delete(test_case) + end + it "does not start or cache when there are no runnable methods" do test_case = Class.new(ActiveSupport::TestCase) test_case.fixture_kit_declaration = fixture_declaration diff --git a/spec/unit/rspec_entrypoint_spec.rb b/spec/unit/rspec_entrypoint_spec.rb index 74b6eef..a796f53 100644 --- a/spec/unit/rspec_entrypoint_spec.rb +++ b/spec/unit/rspec_entrypoint_spec.rb @@ -80,6 +80,17 @@ end end + describe "after(:context) hook" do + it "attaches an after context hook that calls finish on the declaration" do + group = build_group + group.fixture("project_management") + + expect(fixture_declaration).to receive(:finish) + + run_after_context_hook(group) + end + end + def build_group(parent_metadata = {}) Class.new do extend FixtureKit::RSpec::ClassMethods @@ -97,10 +108,24 @@ def build_group(parent_metadata = {}) define_singleton_method(:fixture_kit_before_context_hook) do @fixture_kit_before_context_hook end + + define_singleton_method(:append_after) do |scope, &block| + raise "Unexpected scope: #{scope}" unless scope == :context + + @fixture_kit_after_context_hook = block + end + + define_singleton_method(:fixture_kit_after_context_hook) do + @fixture_kit_after_context_hook + end end end def run_before_context_hook(group) group.new.instance_exec(&group.fixture_kit_before_context_hook) end + + def run_after_context_hook(group) + group.new.instance_exec(&group.fixture_kit_after_context_hook) + end end