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 +4 -4
- data/README.md +9 -7
- data/Rakefile +5 -3
- data/bin/fixture_farm.rb +8 -2
- data/lib/fixture_farm/active_record_extension.rb +4 -5
- data/lib/fixture_farm/controller_hook.rb +1 -1
- data/lib/fixture_farm/fixture_recorder.rb +172 -58
- data/lib/fixture_farm/hook.rb +8 -4
- data/lib/fixture_farm/test_helper.rb +1 -1
- data/lib/fixture_farm/version.rb +3 -1
- metadata +3 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 728eb6b44d1deb4df01e4bc03795758f3846e169f6dc07f0f5c7a6a799881976
|
4
|
+
data.tar.gz: 59fe50410c6fd317537dd653001f8bd8e7b571b2375faa97d95e6dc41e188e76
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
|
-
|
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
|
-
-
|
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 |
|
71
|
+
record_new_fixtures do |recorder|
|
70
72
|
user = User.create!(name: 'Bob')
|
71
73
|
post = user.posts.create!(title: 'Stuff')
|
72
74
|
|
73
|
-
|
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
|
-
|
1
|
+
# frozen_string_literal: true
|
2
2
|
|
3
|
-
require
|
3
|
+
require 'bundler/setup'
|
4
4
|
|
5
|
-
require
|
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
|
14
|
+
puts "Recording fixtures#{" with prefix #{ARGV[1]}" unless ARGV[1].nil?}"
|
15
15
|
when 'status'
|
16
|
-
|
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
|
-
|
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,89 +1,161 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
module FixtureFarm
|
4
|
-
|
4
|
+
|
5
|
+
mattr_accessor :low_priority_parent_model_for_naming
|
5
6
|
|
6
7
|
class FixtureRecorder
|
7
|
-
|
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
|
-
@
|
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 =
|
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(
|
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(
|
52
|
+
FileUtils.rm_f(store_path)
|
37
53
|
end
|
38
54
|
|
39
55
|
def self.recording_session_in_progress?
|
40
|
-
|
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
|
-
|
81
|
+
if payload[:name] =~ /([:\w]+) Create/
|
82
|
+
new_fixture_class_name = Regexp.last_match(1)
|
50
83
|
|
51
|
-
|
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
|
-
|
54
|
-
|
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
|
-
|
93
|
+
@deleted_models[fixture_name] = model if fixture_name
|
94
|
+
end
|
57
95
|
end
|
58
96
|
end
|
59
97
|
|
60
|
-
yield
|
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(
|
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
|
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
|
-
|
128
|
-
|
129
|
-
|
130
|
-
|
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
|
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
|
395
|
+
end&.first || existing_fixture_name(model_instance)
|
282
396
|
end
|
283
397
|
end
|
284
398
|
end
|
data/lib/fixture_farm/hook.rb
CHANGED
@@ -1,14 +1,18 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
require
|
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
|
10
|
-
|
11
|
-
|
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
|
data/lib/fixture_farm/version.rb
CHANGED
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.
|
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-
|
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:
|