fixture_kit 0.14.0 → 0.16.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: 1ec46a033864696a48fe4a196744b61dd142aeabee84a68f65554ccf698d4e72
4
- data.tar.gz: 5e41645bf7246d12f8a0bbecb4bbad7bcb6f1f2dddab685fc6c32b459c2a3550
3
+ metadata.gz: ad80802447ed709427be32a3e090483bb304c97fbd6dfe66f4964cf6a2d2d5bc
4
+ data.tar.gz: 7d6f2e056126234ff6fab78be3b16c77d3346432a7f3fc3bb1ae2cc1606d7f29
5
5
  SHA512:
6
- metadata.gz: fd821fd991c0ded4d4d10f963ece4cde8740c55c88fce238cf0033adbb791e2e64b50b3257c82a6fecd93e8810cd068e49329361b26661dd4072c1287c618ae3
7
- data.tar.gz: 416bed6e4889c63a5a860c55b45e3f525499682cd5367e712b365555361fd36bd561c3e0cd02259d32f969c9877a50d0f01c570f0159af68a01334f526968f2d
6
+ metadata.gz: a4750ef54340da7fa136af3c046297ecd44a0d9fa96386e9a37bbe070cc5a8746c08f8bfce63815b5618c5ee7412a649f689ad884f0900ad9deb404579437688
7
+ data.tar.gz: 4e65d6fc64f2b5bb051667ed4e71d8bd6e8d1cdb951d1cecf45a15f083561f7cad749efba1d00e88002912611dcff197bdf78465c48d6e51838b434e197c373b
@@ -1,14 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "active_support/core_ext/array/wrap"
4
-
5
3
  module FixtureKit
6
4
  class Cache
7
5
  ANONYMOUS_DIRECTORY = "_anonymous"
8
6
 
9
7
  include ConfigurationHelper
10
8
 
11
- attr_reader :fixture, :data
9
+ attr_reader :fixture, :content
12
10
 
13
11
  def initialize(fixture)
14
12
  @fixture = fixture
@@ -30,11 +28,11 @@ module FixtureKit
30
28
  end
31
29
 
32
30
  def exists?
33
- data || file_cache.exists?
31
+ content || file_cache.exists?
34
32
  end
35
33
 
36
34
  def clear_memory
37
- @data = nil
35
+ @content = nil
38
36
  end
39
37
 
40
38
  def load
@@ -42,88 +40,47 @@ module FixtureKit
42
40
  raise FixtureKit::CacheMissingError, "Cache does not exist for fixture '#{fixture.identifier}'"
43
41
  end
44
42
 
45
- @data ||= file_cache.read
46
- statements_by_connection(data.records).each do |connection, statements|
47
- connection.disable_referential_integrity do
48
- # execute_batch is private in current supported Rails versions.
49
- # This should be revisited when Rails 8.2 makes it public.
50
- connection.__send__(:execute_batch, statements, "FixtureKit Load")
51
- end
43
+ @content ||= file_cache.read
44
+
45
+ FixtureKit.runner.coders.each do |coder|
46
+ coder.mount(content.data_for(coder.class))
52
47
  end
53
48
 
54
- Repository.new(data.exposed)
49
+ Repository.new(content.exposed)
55
50
  end
56
51
 
57
52
  def save
58
53
  FixtureKit.runner.adapter.execute do |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)
65
- end
66
-
67
- @data = MemoryCache.new(
68
- records: generate_statements(captured_models),
54
+ @content = MemoryCache.new(
55
+ data: evaluate(FixtureKit.runner.coders, context),
69
56
  exposed: file_cache.serialize_exposed(fixture.definition.exposed)
70
57
  )
71
58
  end
72
59
 
73
- file_cache.write(data)
60
+ file_cache.write(content)
74
61
  end
75
62
 
76
63
  private
77
64
 
78
- def file_cache
79
- @file_cache ||= FileCache.new(
80
- File.join(configuration.cache_path, "#{identifier}.json")
81
- )
82
- end
65
+ def evaluate(coders, context, data = {}, &block)
66
+ if coders.empty?
67
+ fixture.definition.evaluate(context, parent: fixture.parent&.mount)
68
+ else
69
+ coder, *remaining_coders = coders
83
70
 
84
- def generate_statements(models)
85
- models.uniq.each_with_object({}) do |model, statements|
86
- columns = model.column_names
87
-
88
- rows = []
89
- model.unscoped.order(:id).find_each do |record|
90
- row_values = columns.map do |col|
91
- value = record.read_attribute_before_type_cast(col)
92
- model.connection.quote(value)
93
- end
94
- rows << "(#{row_values.join(", ")})"
71
+ parent_data = fixture.parent ? fixture.parent.cache.content.data_for(coder.class) : nil
72
+ data[coder.class] = coder.generate(parent_data: parent_data) do
73
+ evaluate(remaining_coders, context, data, &block)
95
74
  end
96
-
97
- sql = rows.empty? ? nil : build_insert_sql(model.table_name, columns, rows, model.connection)
98
- statements[model] = sql
99
75
  end
100
- end
101
-
102
- def build_delete_sql(model)
103
- "DELETE FROM #{model.quoted_table_name}"
104
- end
105
-
106
- def build_insert_sql(table_name, columns, rows, connection)
107
- quoted_table = connection.quote_table_name(table_name)
108
- quoted_columns = columns.map { |c| connection.quote_column_name(c) }
109
76
 
110
- "INSERT INTO #{quoted_table} (#{quoted_columns.join(", ")}) VALUES #{rows.join(", ")}"
77
+ data
111
78
  end
112
79
 
113
- def statements_by_connection(records)
114
- deleted_tables = Set.new
115
-
116
- records.each_with_object({}) do |(model, sql), grouped|
117
- connection = model.connection
118
- grouped[connection] ||= []
119
-
120
- table_key = [connection, model.table_name]
121
- if deleted_tables.add?(table_key)
122
- grouped[connection] << build_delete_sql(model)
123
- end
124
-
125
- grouped[connection] << sql if sql
126
- end
80
+ def file_cache
81
+ @file_cache ||= FileCache.new(
82
+ File.join(configuration.cache_path, "#{identifier}.json")
83
+ )
127
84
  end
128
85
  end
129
86
  end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FixtureKit
4
+ class Coder
5
+ def generate(parent_data: nil, &block)
6
+ raise NotImplementedError, "#{self.class} must implement #generate"
7
+ end
8
+
9
+ def mount(data)
10
+ raise NotImplementedError, "#{self.class} must implement #mount"
11
+ end
12
+
13
+ def encode(data)
14
+ data
15
+ end
16
+
17
+ def decode(data)
18
+ data
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/notifications"
4
+ require "active_support/inflector"
5
+
6
+ module FixtureKit
7
+ class ActiveRecordCoder < FixtureKit::Coder
8
+ EVENT = "sql.active_record"
9
+ NAME_PATTERN = /\A(?<model_name>.+?) (?:(?:Bulk )?(?:Insert|Upsert)|Create|Destroy|(?:Update|Delete)(?: All)?)\z/
10
+
11
+ def generate(parent_data: nil, &block)
12
+ captured_models = Set.new
13
+ subscriber = lambda do |_event_name, _start, _finish, _id, payload|
14
+ name = payload[:name].to_s
15
+ model_name = name[NAME_PATTERN, :model_name]
16
+ next unless model_name
17
+
18
+ klass = ActiveSupport::Inflector.safe_constantize(model_name)
19
+ next unless klass.is_a?(Class) && klass < ActiveRecord::Base
20
+
21
+ captured_models.add(klass)
22
+ end
23
+
24
+ ActiveSupport::Notifications.subscribed(subscriber, EVENT, monotonic: true, &block)
25
+
26
+ captured_models.map! { |model| base_table_model(model) }
27
+ captured_models.merge(parent_data.keys) if parent_data
28
+
29
+ generate_statements(captured_models)
30
+ end
31
+
32
+ def mount(data)
33
+ models_by_pool(data).each do |pool, models|
34
+ pool.with_connection do |connection|
35
+ statements = models.flat_map do |model|
36
+ [build_delete_sql(connection, model.table_name), data[model]].compact
37
+ end
38
+
39
+ connection.disable_referential_integrity do
40
+ # execute_batch is private in current supported Rails versions.
41
+ # This should be revisited when Rails 8.2 makes it public.
42
+ connection.__send__(:execute_batch, statements, "FixtureKit Insert")
43
+ end
44
+
45
+ verify_foreign_keys!(connection)
46
+
47
+ # Replayed INSERTs use explicit PKs, which Postgres sequences do not
48
+ # observe. Re-sync the sequence so subsequent Model.create calls don't
49
+ # collide with an id we just inserted. No-op on adapters whose PK
50
+ # generators advance from explicit-id INSERTs (MySQL, SQLite).
51
+ reset_primary_key_sequences(connection, models.map(&:table_name))
52
+ end
53
+ end
54
+ end
55
+
56
+ def decode(data)
57
+ data.transform_keys do |model_name|
58
+ ActiveSupport::Inflector.constantize(model_name)
59
+ end
60
+ end
61
+
62
+ private
63
+
64
+ def base_table_model(model)
65
+ model = model.superclass until model.superclass == ActiveRecord::Base || model.superclass.abstract_class?
66
+ model
67
+ end
68
+
69
+ def generate_statements(models)
70
+ models.each_with_object({}) do |model, statements|
71
+ columns = insertable_columns(model)
72
+ column_names = columns.map(&:name)
73
+
74
+ rows = []
75
+ model.unscoped.order(:id).find_each do |record|
76
+ row_values = column_names.map do |col|
77
+ value = record.read_attribute_before_type_cast(col)
78
+ model.connection.quote(value)
79
+ end
80
+ rows << "(#{row_values.join(", ")})"
81
+ end
82
+
83
+ sql = rows.empty? ? nil : build_insert_sql(model.table_name, column_names, rows, model.connection)
84
+ statements[model] = sql
85
+ end
86
+ end
87
+
88
+ def insertable_columns(model)
89
+ supports_virtual = model.connection.supports_virtual_columns?
90
+ model.columns.reject { |c| supports_virtual && c.virtual? }
91
+ end
92
+
93
+ def build_delete_sql(connection, table_name)
94
+ "DELETE FROM #{connection.quote_table_name(table_name)}"
95
+ end
96
+
97
+ def build_insert_sql(table_name, columns, rows, connection)
98
+ quoted_table = connection.quote_table_name(table_name)
99
+ quoted_columns = columns.map { |c| connection.quote_column_name(c) }
100
+
101
+ "INSERT INTO #{quoted_table} (#{quoted_columns.join(", ")}) VALUES #{rows.join(", ")}"
102
+ end
103
+
104
+ def verify_foreign_keys!(connection)
105
+ return unless ActiveRecord.verify_foreign_keys_for_fixtures
106
+
107
+ begin
108
+ connection.check_all_foreign_keys_valid!
109
+ rescue ActiveRecord::StatementInvalid => e
110
+ raise FixtureKit::Error,
111
+ "Foreign key violations found in cached fixture data. The cache may be " \
112
+ "stale relative to your current schema or fixture definitions. " \
113
+ "Original error:\n\n#{e.message}"
114
+ end
115
+ end
116
+
117
+ def reset_primary_key_sequences(connection, tables)
118
+ # Rails main (>= 8.2) batches the reset in one round-trip per connection.
119
+ # Older versions fall back to one query per table.
120
+ if connection.respond_to?(:reset_column_sequences!)
121
+ connection.reset_column_sequences!(tables.map { |t| [t] })
122
+ elsif connection.respond_to?(:reset_pk_sequence!)
123
+ tables.each { |t| connection.reset_pk_sequence!(t) }
124
+ end
125
+ end
126
+
127
+ def models_by_pool(data)
128
+ seen = Set.new
129
+
130
+ data.each_with_object({}) do |(model, _), grouped|
131
+ pool = model.connection_pool
132
+ next unless seen.add?([pool, model.table_name])
133
+
134
+ grouped[pool] ||= []
135
+ grouped[pool] << model
136
+ end
137
+ end
138
+ end
139
+ end
@@ -4,14 +4,19 @@ module FixtureKit
4
4
  class Configuration
5
5
  attr_accessor :fixture_path
6
6
  attr_accessor :cache_path
7
- attr_reader :adapter_options, :callbacks
7
+ attr_reader :adapter_options, :callbacks, :coders
8
8
 
9
9
  def initialize
10
10
  @fixture_path = "fixture_kit"
11
11
  @cache_path = "tmp/cache/fixture_kit"
12
- @adapter_class = FixtureKit::MinitestAdapter
12
+ @adapter_class = MinitestAdapter
13
13
  @adapter_options = {}
14
14
  @callbacks = Callbacks.new
15
+ @coders = Set.new([ActiveRecordCoder])
16
+ end
17
+
18
+ def register(coder)
19
+ @coders.add(coder)
15
20
  end
16
21
 
17
22
  def adapter(adapter_class = nil, **options)
@@ -17,12 +17,14 @@ module FixtureKit
17
17
  end
18
18
 
19
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)
20
+ content = JSON.parse(File.read(path))
21
+
22
+ data = content.fetch("data").to_h do |coder_name, coder_data|
23
+ coder = coder_for(coder_name)
24
+ [coder.class, coder.decode(coder_data)]
23
25
  end
24
26
 
25
- exposed = file_data.fetch("exposed").each_with_object({}) do |(name, value), hash|
27
+ exposed = content.fetch("exposed").each_with_object({}) do |(name, value), hash|
26
28
  if value.is_a?(Array)
27
29
  hash[name.to_sym] = value.map { |r| { ActiveSupport::Inflector.constantize(r.keys.first) => r.values.first } }
28
30
  else
@@ -30,12 +32,20 @@ module FixtureKit
30
32
  end
31
33
  end
32
34
 
33
- MemoryCache.new(records: records, exposed: exposed)
35
+ MemoryCache.new(data: data, exposed: exposed)
34
36
  end
35
37
 
36
38
  def write(data)
39
+ content = {
40
+ data: data.data.to_h do |coder_class, coder_data|
41
+ coder = coder_for(coder_class.name)
42
+ [coder.class, coder.encode(coder_data)]
43
+ end,
44
+ exposed: data.exposed,
45
+ }
46
+
37
47
  FileUtils.mkdir_p(File.dirname(path))
38
- File.write(path, JSON.pretty_generate(data.to_h))
48
+ File.write(path, JSON.pretty_generate(content))
39
49
  end
40
50
 
41
51
  def serialize_exposed(exposed)
@@ -47,5 +57,12 @@ module FixtureKit
47
57
  end
48
58
  end
49
59
  end
60
+
61
+ private
62
+
63
+ def coder_for(class_name)
64
+ @coder_for ||= FixtureKit.runner.coders.index_by { |c| c.class.name }
65
+ @coder_for.fetch(class_name)
66
+ end
50
67
  end
51
68
  end
@@ -2,16 +2,16 @@
2
2
 
3
3
  module FixtureKit
4
4
  class MemoryCache
5
- attr_reader :records, :exposed
5
+ attr_reader :data, :exposed
6
6
 
7
- def initialize(records:, exposed:)
8
- @records = records
7
+ def initialize(data:, exposed:)
8
+ @data = data
9
9
  @exposed = exposed
10
10
  freeze
11
11
  end
12
12
 
13
- def to_h
14
- { records: records, exposed: exposed }
13
+ def data_for(coder_class)
14
+ data.fetch(coder_class)
15
15
  end
16
16
  end
17
17
  end
@@ -30,6 +30,10 @@ module FixtureKit
30
30
  @adapter ||= configuration.adapter.new(configuration.adapter_options)
31
31
  end
32
32
 
33
+ def coders
34
+ @coders ||= configuration.coders.map(&:new)
35
+ end
36
+
33
37
  def started?
34
38
  @started
35
39
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module FixtureKit
4
- VERSION = "0.14.0"
4
+ VERSION = "0.16.0"
5
5
  end
data/lib/fixture_kit.rb CHANGED
@@ -20,7 +20,6 @@ module FixtureKit
20
20
  autoload :Definition, File.expand_path("fixture_kit/definition", __dir__)
21
21
  autoload :Registry, File.expand_path("fixture_kit/registry", __dir__)
22
22
  autoload :Repository, File.expand_path("fixture_kit/repository", __dir__)
23
- autoload :SqlSubscriber, File.expand_path("fixture_kit/sql_subscriber", __dir__)
24
23
  autoload :Cache, File.expand_path("fixture_kit/cache", __dir__)
25
24
  autoload :FileCache, File.expand_path("fixture_kit/file_cache", __dir__)
26
25
  autoload :MemoryCache, File.expand_path("fixture_kit/memory_cache", __dir__)
@@ -28,6 +27,8 @@ module FixtureKit
28
27
  autoload :Adapter, File.expand_path("fixture_kit/adapter", __dir__)
29
28
  autoload :MinitestAdapter, File.expand_path("fixture_kit/adapters/minitest_adapter", __dir__)
30
29
  autoload :RSpecAdapter, File.expand_path("fixture_kit/adapters/rspec_adapter", __dir__)
30
+ autoload :Coder, File.expand_path("fixture_kit/coder", __dir__)
31
+ autoload :ActiveRecordCoder, File.expand_path("fixture_kit/coders/active_record_coder", __dir__)
31
32
 
32
33
  extend Singleton
33
34
  end
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.14.0
4
+ version: 0.16.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-04-07 00:00:00.000000000 Z
11
+ date: 2026-05-06 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -160,6 +160,8 @@ files:
160
160
  - lib/fixture_kit/analyzer/text_formatter.rb
161
161
  - lib/fixture_kit/cache.rb
162
162
  - lib/fixture_kit/callbacks.rb
163
+ - lib/fixture_kit/coder.rb
164
+ - lib/fixture_kit/coders/active_record_coder.rb
163
165
  - lib/fixture_kit/configuration.rb
164
166
  - lib/fixture_kit/configuration_helper.rb
165
167
  - lib/fixture_kit/definition.rb
@@ -173,7 +175,6 @@ files:
173
175
  - lib/fixture_kit/rspec.rb
174
176
  - lib/fixture_kit/runner.rb
175
177
  - lib/fixture_kit/singleton.rb
176
- - lib/fixture_kit/sql_subscriber.rb
177
178
  - lib/fixture_kit/version.rb
178
179
  homepage: https://github.com/Gusto/fixture_kit
179
180
  licenses:
@@ -1,32 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "active_support/notifications"
4
- require "active_support/inflector"
5
-
6
- module FixtureKit
7
- class SqlSubscriber
8
- EVENT = "sql.active_record"
9
- NAME_PATTERN = /\A(?<model_name>.+?) (?:(?:Bulk )?(?:Insert|Upsert)|Create|Destroy|(?:Update|Delete)(?: All)?)\z/
10
-
11
- def self.capture(&block)
12
- models = Set.new
13
- subscriber = lambda do |_event_name, _start, _finish, _id, payload|
14
- name = payload[:name].to_s
15
- model_name = name[NAME_PATTERN, :model_name]
16
- next unless model_name
17
-
18
- models.add(ActiveSupport::Inflector.constantize(model_name))
19
- end
20
-
21
- ActiveSupport::Notifications.subscribed(subscriber, EVENT, monotonic: true, &block)
22
-
23
- models.map { |model| base_table_model(model) }.uniq
24
- end
25
-
26
- def self.base_table_model(model)
27
- model = model.superclass until model.superclass == ActiveRecord::Base || model.superclass.abstract_class?
28
- model
29
- end
30
- private_class_method :base_table_model
31
- end
32
- end