fixture_farm 0.3.1 → 1.0.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: daad4947f4b3dd8d77b82001fbfae78d4b0dc3b94cd12c996b512ca858601765
4
- data.tar.gz: e6ab57fb99a71d94f479031c79cb1cb24b56aa48a66f10083d3c532699bbe894
3
+ metadata.gz: 728eb6b44d1deb4df01e4bc03795758f3846e169f6dc07f0f5c7a6a799881976
4
+ data.tar.gz: 59fe50410c6fd317537dd653001f8bd8e7b571b2375faa97d95e6dc41e188e76
5
5
  SHA512:
6
- metadata.gz: 3b6bc2b81e6c365c56d0de9b1077afeae0297826bdb4bad11303bacca060b27ea733fba751be6d25d8261bb5d192817d3e7d64c3bfc6ea29757365d0ed373d5f
7
- data.tar.gz: 7a05a132020e9792d8b41e65f9bbdf48c62d0e3801943f6a3f59ae186ed79343b01a3d0ac24c003164ad55534a3b97c35b654127ff2c6be932767cbb92abfb64
6
+ metadata.gz: cd4e4c6609afbe0a09413cf19d71eec01b9366f4e2d793190b8cb9b7aa86eecded615cbeae5daf31036cd3ca6a4bfea42c3bc568236aa9c4c3357b9e81157052
7
+ data.tar.gz: 2210cb7393ce70c90d3e0225ba28f9b87ee2f4b6d1d106ce029e352b475b6c1c3eed78c1af4bd15e864330101f0059a06ec2f5c65415faf1228a44e03bcf8693
data/README.md CHANGED
@@ -1,17 +1,19 @@
1
1
  # FixtureFarm
2
2
 
3
3
  This gem lets you do two things:
4
-
4
+ - record fixtures for a block of code (e.g. part of a test).
5
5
  - record fixtures as you browse.
6
- - record fixtures for a block of code (e.g. setup part of a test).
7
6
 
8
- Generated fixture that `belongs_to` a record from an existing fixture, will reference that fixture by name.
7
+ A few things to note:
8
+ - generated fixture names are based on their `belongs_to` fixture names.
9
+ - generated fixture that `belongs_to` a record from an existing fixture, will reference that fixture by name.
10
+ - models, destroyed during recording, will be removed from fixtures (if they were originally there).
11
+ - generated `ActiveStorage::Blob` fixtures file names, will be the same as fixture names (so they can be generated multiple times, without generating new file each time).
9
12
 
10
13
  ### Limitations
11
14
 
12
15
  - doesn't update fixtures
13
- - doesn't delete fixtures
14
- - assumes that all serialized attributes are json (so that at least ActiveStorage::Blob metadata is correctly represented; it really should be Rails serializing attributes according to their respective coders when inserting fixtures into the database, but, alas, this isn't happening)
16
+ - assumes that all serialized attributes are json (so that at least ActiveStorage::Blob metadata is correctly represented; it really should be Rails serializing attributes according to their respective coders when inserting fixtures into the database, but, alas, this isn't how it works)
15
17
 
16
18
  ## Installation
17
19
 
@@ -66,11 +68,11 @@ To record in tests, wrap some code in `record_new_fixtures` block. For example:
66
68
  include FixtureFarm::TestHelper
67
69
 
68
70
  test 'some stuff does the right thing' do
69
- record_new_fixtures do |stop_recording|
71
+ record_new_fixtures do |recorder|
70
72
  user = User.create!(name: 'Bob')
71
73
  post = user.posts.create!(title: 'Stuff')
72
74
 
73
- stop_recording.call
75
+ recorder.stop!
74
76
 
75
77
  assert_difference 'user.published_posts.size' do
76
78
  post.publish!
data/Rakefile CHANGED
@@ -1,8 +1,10 @@
1
- require "bundler/setup"
1
+ # frozen_string_literal: true
2
2
 
3
- require "bundler/gem_tasks"
3
+ require 'bundler/setup'
4
4
 
5
- require "rake/testtask"
5
+ require 'bundler/gem_tasks'
6
+
7
+ require 'rake/testtask'
6
8
 
7
9
  Rake::TestTask.new(:test) do |t|
8
10
  t.libs << 'test'
data/bin/fixture_farm.rb CHANGED
@@ -11,9 +11,15 @@ end
11
11
  case ARGV[0]
12
12
  when 'record'
13
13
  FixtureFarm::FixtureRecorder.start_recording_session!(ARGV[1])
14
- puts 'Recording fixtures' + (ARGV[1].nil? ? '' : " with prefix #{ARGV[1]}")
14
+ puts "Recording fixtures#{" with prefix #{ARGV[1]}" unless ARGV[1].nil?}"
15
15
  when 'status'
16
- puts "Recording is #{FixtureFarm::FixtureRecorder.recording_session_in_progress? ? 'on' : 'off'}"
16
+ if FixtureFarm::FixtureRecorder.recording_session_in_progress?
17
+ puts 'Recording is on'
18
+ elsif (error = FixtureFarm::FixtureRecorder.last_session_error)
19
+ puts "Recording is off (#{error})"
20
+ else
21
+ puts 'Recording is off'
22
+ end
17
23
  when 'stop'
18
24
  FixtureFarm::FixtureRecorder.stop_recording_session!
19
25
  puts 'Stopped recording'
@@ -17,14 +17,12 @@ module FixtureFarm
17
17
  existing_fixtures_file_path || candidate_fixtures_file_path
18
18
  end
19
19
 
20
+ private
21
+
20
22
  def candidate_fixtures_file_path
21
23
  klass = self.class
22
- loop do
23
- path = Rails.root.join('test', 'fixtures', "#{klass.to_s.underscore.pluralize}.yml")
24
- return path if klass >= ActiveRecord::Base || !klass.columns.map(&:name).include?(klass.inheritance_column)
25
24
 
26
- klass = klass.superclass
27
- end
25
+ Rails.root.join('test', 'fixtures', "#{klass.to_s.underscore.pluralize}.yml")
28
26
  end
29
27
 
30
28
  def existing_fixtures_file_path
@@ -32,6 +30,7 @@ module FixtureFarm
32
30
 
33
31
  while klass < ActiveRecord::Base
34
32
  path = Rails.root.join('test', 'fixtures', "#{klass.to_s.underscore.pluralize}.yml")
33
+
35
34
  return path if File.exist?(path)
36
35
 
37
36
  klass = klass.superclass
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "fixture_farm/hook"
3
+ require 'fixture_farm/hook'
4
4
 
5
5
  module FixtureFarm
6
6
  module ControllerHook
@@ -1,89 +1,161 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module FixtureFarm
4
- mattr_accessor :low_priority_parent_model_for_naming, default: -> { false }
4
+
5
+ mattr_accessor :low_priority_parent_model_for_naming
5
6
 
6
7
  class FixtureRecorder
7
- STORE_PATH = Rails.root.join('tmp', 'fixture_farm_store.json')
8
+ attr_accessor :new_blob_file_paths
9
+
10
+ def self.store_path
11
+ Rails.root.join('tmp', 'fixture_farm_store.json')
12
+ end
13
+
14
+ def store_path
15
+ self.class.store_path
16
+ end
8
17
 
9
18
  def initialize(fixture_name_prefix, new_models = [])
10
19
  @fixture_name_prefix = fixture_name_prefix
11
20
  @new_models = new_models
21
+ @deleted_models = {}
12
22
  @initial_now = Time.zone.now
13
- @ignore_while_tree_walking = Set.new
23
+ @named_new_fixtures = {}
24
+ @existing_fixtures_cache = {}
14
25
  end
15
26
 
16
27
  def self.resume_recording_session
17
28
  start_recording_session! unless recording_session_in_progress?
18
29
 
19
- recording_session = JSON.load_file(STORE_PATH, permitted_classes: [ActiveSupport::HashWithIndifferentAccess])
30
+ recording_session = load_recording_session
20
31
 
21
32
  new_models = recording_session['new_models'].map do |(class_name, id)|
22
33
  class_name.constantize.find(id)
23
34
  end
24
35
 
25
36
  new(recording_session['fixture_name_prefix'], new_models)
37
+ rescue ActiveRecord::RecordNotFound
38
+ # External interference with database (e.g. fixtures:load)
39
+ recording_session['error'] = 'database was externally modified/reset'
40
+ File.write(store_path, recording_session.to_json)
41
+ nil
26
42
  end
27
43
 
28
44
  def self.start_recording_session!(fixture_name_prefix)
29
- File.write(STORE_PATH, {
45
+ File.write(store_path, {
30
46
  fixture_name_prefix: fixture_name_prefix,
31
47
  new_models: []
32
48
  }.to_json)
33
49
  end
34
50
 
35
51
  def self.stop_recording_session!
36
- FileUtils.rm_f(STORE_PATH)
52
+ FileUtils.rm_f(store_path)
37
53
  end
38
54
 
39
55
  def self.recording_session_in_progress?
40
- File.exist?(STORE_PATH)
56
+ recording_session = load_recording_session
57
+ return false unless recording_session
58
+
59
+ !recording_session['error']
60
+ end
61
+
62
+ def self.load_recording_session
63
+ return nil unless File.exist?(store_path)
64
+
65
+ JSON.load_file(store_path, permitted_classes: [ActiveSupport::HashWithIndifferentAccess])
66
+ end
67
+
68
+ def self.last_session_error
69
+ recording_session = load_recording_session
70
+ return nil unless recording_session
71
+
72
+ recording_session['error']
41
73
  end
42
74
 
43
75
  def record_new_fixtures
44
- stopped = false
76
+ @stopped = false
45
77
 
46
- subscriber = ActiveSupport::Notifications.subscribe 'sql.active_record' do |event|
78
+ @subscriber = ActiveSupport::Notifications.subscribe 'sql.active_record' do |event|
47
79
  payload = event.payload
48
80
 
49
- next unless payload[:name] =~ /([:\w]+) Create/
81
+ if payload[:name] =~ /([:\w]+) Create/
82
+ new_fixture_class_name = Regexp.last_match(1)
50
83
 
51
- new_fixture_class_name = Regexp.last_match(1)
84
+ payload[:connection].transaction_manager.current_transaction.records.reject(&:persisted?).reject(&:destroyed?).each do |model_instance|
85
+ next if new_fixture_class_name != model_instance.class.name
52
86
 
53
- payload[:connection].transaction_manager.current_transaction.records.reject(&:persisted?).reject(&:destroyed?).each do |model_instance|
54
- next if new_fixture_class_name != model_instance.class.name
87
+ @new_models << model_instance
88
+ end
89
+ elsif payload[:name] =~ /([:\w]+) Destroy/
90
+ payload[:connection].transaction_manager.current_transaction.records.each do |model|
91
+ fixture_name = existing_fixture_name(model)
55
92
 
56
- @new_models << model_instance
93
+ @deleted_models[fixture_name] = model if fixture_name
94
+ end
57
95
  end
58
96
  end
59
97
 
60
- yield lambda {
61
- ActiveSupport::Notifications.unsubscribe(subscriber)
62
- stopped = true
63
- reload_models
64
- update_fixture_files(named_new_fixtures)
65
- }
98
+ yield self
66
99
 
67
- unless stopped
68
- reload_models
69
- update_fixture_files(named_new_fixtures)
70
- end
100
+ stop! unless @stopped
71
101
  ensure
72
- ActiveSupport::Notifications.unsubscribe(subscriber)
102
+ ActiveSupport::Notifications.unsubscribe(@subscriber)
103
+ end
104
+
105
+ def stop!
106
+ ActiveSupport::Notifications.unsubscribe(@subscriber)
107
+ @stopped = true
108
+ reload_new_models
109
+ rename_active_storage_blobs_for_idempotency
110
+ delete_fixtures_for_deleted_models
111
+ update_fixture_files(named_new_fixtures)
73
112
  end
74
113
 
75
114
  def update_recording_session
76
115
  return unless FixtureRecorder.recording_session_in_progress?
77
116
 
78
- File.write(STORE_PATH, {
117
+ File.write(store_path, {
79
118
  fixture_name_prefix: @fixture_name_prefix,
80
119
  new_models: @new_models.map { |model| [model.class.name, model.id] }
81
120
  }.to_json)
82
121
  end
83
122
 
123
+ def named_new_fixtures
124
+ @new_models.uniq.each do |model_instance|
125
+ ensure_new_fixture_name(model_instance)
126
+ end
127
+
128
+ @named_new_fixtures
129
+ end
130
+
84
131
  private
85
132
 
86
- def reload_models
133
+ def rename_active_storage_blobs_for_idempotency
134
+ self.new_blob_file_paths = named_new_fixtures.filter_map do |fixture_name, model|
135
+ next unless model.is_a?(ActiveStorage::Blob)
136
+
137
+ rename_blob_file_for_idempotency(fixture_name, model)
138
+ end
139
+ end
140
+
141
+ def rename_blob_file_for_idempotency(fixture_name, blob)
142
+ old_key = blob.key
143
+ new_key = fixture_name
144
+
145
+ blob.update!(key: new_key)
146
+
147
+ from_path = Rails.root.join('storage', old_key[0..1], old_key[2..3], old_key)
148
+ to_dir = Rails.root.join('storage', new_key[0..1], new_key[2..3])
149
+ to_path = to_dir.join(new_key)
150
+
151
+ `mkdir -p #{to_dir}`
152
+
153
+ `mv #{from_path} #{to_path}`
154
+
155
+ to_path
156
+ end
157
+
158
+ def reload_new_models
87
159
  @new_models = @new_models.map do |model_instance|
88
160
  # reload in case model was updated after initial create
89
161
  model_instance.reload
@@ -94,28 +166,6 @@ module FixtureFarm
94
166
  end.compact
95
167
  end
96
168
 
97
- def named_new_fixtures
98
- @named_new_fixtures ||= begin
99
- (@new_models - @ignore_while_tree_walking.to_a).uniq(&:id).each_with_object({}) do |model_instance, named_new_fixtures|
100
- @ignore_while_tree_walking.add(model_instance)
101
-
102
- new_fixture_name = [
103
- @fixture_name_prefix,
104
- first_belongs_to_fixture_name(model_instance),
105
- "#{model_instance.class.name.underscore.split('/').last}_1"
106
- ].select(&:present?).join('_')
107
-
108
- while named_new_fixtures[new_fixture_name]
109
- new_fixture_name = new_fixture_name.sub(/_(\d+)$/, "_#{Regexp.last_match(1).to_i + 1}")
110
- end
111
-
112
- named_new_fixtures[new_fixture_name] = model_instance
113
-
114
- @ignore_while_tree_walking.delete(model_instance)
115
- end
116
- end
117
- end
118
-
119
169
  def first_belongs_to_fixture_name(model_instance)
120
170
  low_priority_name = nil
121
171
 
@@ -124,13 +174,13 @@ module FixtureFarm
124
174
 
125
175
  next unless associated_model_instance
126
176
 
127
- if (associated_model_instance_fixture_name = fixture_name(associated_model_instance))
128
- if FixtureFarm.low_priority_parent_model_for_naming.call(associated_model_instance)
129
- low_priority_name = associated_model_instance_fixture_name
130
- else
131
- return associated_model_instance_fixture_name
132
- end
177
+ next unless (associated_model_instance_fixture_name = ensure_new_fixture_name(associated_model_instance))
178
+
179
+ unless FixtureFarm.low_priority_parent_model_for_naming&.call(associated_model_instance)
180
+ return associated_model_instance_fixture_name
133
181
  end
182
+
183
+ low_priority_name = associated_model_instance_fixture_name
134
184
  end
135
185
 
136
186
  low_priority_name
@@ -216,7 +266,7 @@ module FixtureFarm
216
266
  if value.to_datetime.minute == 59
217
267
  value += 1.minute
218
268
  value = value.beginning_of_hour
219
- elsif value.to_datetime.minute == 1 || value.to_datetime.minute == 0
269
+ elsif [1, 0].include?(value.to_datetime.minute)
220
270
  value = value.beginning_of_hour
221
271
  end
222
272
  value
@@ -275,10 +325,74 @@ module FixtureFarm
275
325
  end.except(:value_rest)
276
326
  end
277
327
 
328
+ def delete_fixtures_for_deleted_models
329
+ # TODO: optimize
330
+ @deleted_models.each do |fixture_name, deleted_model|
331
+ fixtures_file_path = deleted_model.fixtures_file_path
332
+
333
+ fixtures = YAML.load_file(fixtures_file_path, permitted_classes: [ActiveSupport::HashWithIndifferentAccess]) || {}
334
+
335
+ if fixtures.delete(fixture_name)
336
+ if fixtures.empty?
337
+ File.delete(fixtures_file_path)
338
+ else
339
+ File.open(fixtures_file_path, 'w') do |file|
340
+ yaml = YAML.dump(fixtures).gsub(/\n(?=[^\s])/, "\n\n").delete_prefix("---\n\n")
341
+ file.write(yaml)
342
+ end
343
+ end
344
+ end
345
+ end
346
+ end
347
+
348
+ def existing_fixtures_for_model(model_instance)
349
+ model_class = model_instance.class
350
+
351
+ return @existing_fixtures_cache[model_class] if @existing_fixtures_cache.key?(model_class)
352
+
353
+ fixtures_file_path = model_instance.fixtures_file_path
354
+
355
+ @existing_fixtures_cache[model_class] = if File.exist?(fixtures_file_path)
356
+ YAML.load_file(
357
+ fixtures_file_path,
358
+ permitted_classes: [ActiveSupport::HashWithIndifferentAccess]
359
+ ) || {}
360
+ else
361
+ {}
362
+ end
363
+ end
364
+
365
+ def ensure_new_fixture_name(model_instance)
366
+ fixture_name(model_instance) || begin
367
+ existing_fixtures = existing_fixtures_for_model(model_instance)
368
+
369
+ new_fixture_name = [
370
+ first_belongs_to_fixture_name(model_instance).presence || @fixture_name_prefix,
371
+ "#{model_instance.class.name.underscore.split('/').last}_1"
372
+ ].select(&:present?).join('_')
373
+
374
+ while @named_new_fixtures[new_fixture_name] || existing_fixtures[new_fixture_name] && !@deleted_models[new_fixture_name]
375
+ new_fixture_name = new_fixture_name.sub(/_(\d+)$/, "_#{Regexp.last_match(1).to_i + 1}")
376
+ end
377
+
378
+ @named_new_fixtures[new_fixture_name] = model_instance
379
+
380
+ new_fixture_name
381
+ end
382
+ end
383
+
384
+ def existing_fixture_name(model_instance)
385
+ existing_fixtures = existing_fixtures_for_model(model_instance)
386
+
387
+ existing_fixtures.keys.find do |key|
388
+ ActiveRecord::FixtureSet.identify(key) == model_instance.id
389
+ end
390
+ end
391
+
278
392
  def fixture_name(model_instance)
279
- named_new_fixtures.find do |_, fixture_model|
393
+ @named_new_fixtures.find do |_, fixture_model|
280
394
  fixture_model.id == model_instance.id
281
- end&.first || model_instance.fixture_name
395
+ end&.first || existing_fixture_name(model_instance)
282
396
  end
283
397
  end
284
398
  end
@@ -1,14 +1,18 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "fixture_farm/fixture_recorder"
3
+ require 'fixture_farm/fixture_recorder'
4
4
 
5
5
  module FixtureFarm
6
6
  module Hook
7
7
  def record_new_fixtures(&block)
8
8
  fixture_recorder = FixtureRecorder.resume_recording_session
9
- fixture_recorder.record_new_fixtures { block.call }
10
- ensure
11
- fixture_recorder.update_recording_session
9
+ return unless fixture_recorder # Bail if session was stopped due to error
10
+
11
+ begin
12
+ fixture_recorder.record_new_fixtures { block.call }
13
+ ensure
14
+ fixture_recorder.update_recording_session
15
+ end
12
16
  end
13
17
 
14
18
  private
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "fixture_farm/fixture_recorder"
3
+ require 'fixture_farm/fixture_recorder'
4
4
 
5
5
  module FixtureFarm
6
6
  module TestHelper
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module FixtureFarm
2
- VERSION = '0.3.1'
4
+ VERSION = '1.0.0'
3
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: fixture_farm
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.1
4
+ version: 1.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - artemave
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-07-09 00:00:00.000000000 Z
11
+ date: 2025-07-28 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -52,6 +52,7 @@ licenses:
52
52
  metadata:
53
53
  homepage_uri: https://github.com/featurist/fixture_farm
54
54
  source_code_uri: https://github.com/featurist/fixture_farm.git
55
+ rubygems_mfa_required: 'true'
55
56
  post_install_message:
56
57
  rdoc_options: []
57
58
  require_paths: