factory_seeder 0.1.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.
Files changed (38) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +111 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +445 -0
  5. data/app/assets/stylesheets/factory_seeder.css +637 -0
  6. data/app/controllers/factory_seeder/application_controller.rb +8 -0
  7. data/app/controllers/factory_seeder/custom_seeds_controller.rb +134 -0
  8. data/app/controllers/factory_seeder/dashboard_controller.rb +36 -0
  9. data/app/controllers/factory_seeder/factory_controller.rb +70 -0
  10. data/app/views/factory_seeder/custom_seeds/index.html.erb +51 -0
  11. data/app/views/factory_seeder/custom_seeds/show.html.erb +113 -0
  12. data/app/views/factory_seeder/dashboard/index.html.erb +99 -0
  13. data/app/views/factory_seeder/factory/index.html.erb +71 -0
  14. data/app/views/factory_seeder/factory/show.html.erb +108 -0
  15. data/app/views/factory_seeder/seeds/show.html.erb +2 -0
  16. data/app/views/layouts/factory_seeder/application.html.erb +25 -0
  17. data/bin/factory_seeder +27 -0
  18. data/config/factory_seeder.rb +24 -0
  19. data/config/routes.rb +20 -0
  20. data/lib/factory_seeder/asset_helper.rb +34 -0
  21. data/lib/factory_seeder/cli.rb +352 -0
  22. data/lib/factory_seeder/configuration.rb +32 -0
  23. data/lib/factory_seeder/custom_seed_loader.rb +39 -0
  24. data/lib/factory_seeder/engine.rb +16 -0
  25. data/lib/factory_seeder/execution_log_store.rb +48 -0
  26. data/lib/factory_seeder/factory_scanner.rb +149 -0
  27. data/lib/factory_seeder/loader.rb +26 -0
  28. data/lib/factory_seeder/rails_integration.rb +29 -0
  29. data/lib/factory_seeder/seed.rb +102 -0
  30. data/lib/factory_seeder/seed_builder.rb +67 -0
  31. data/lib/factory_seeder/seed_generator.rb +305 -0
  32. data/lib/factory_seeder/seed_manager.rb +128 -0
  33. data/lib/factory_seeder/seeder.rb +41 -0
  34. data/lib/factory_seeder/version.rb +5 -0
  35. data/lib/factory_seeder/web_interface.rb +119 -0
  36. data/lib/factory_seeder.rb +209 -0
  37. data/templates/seed_template.rb +84 -0
  38. metadata +276 -0
@@ -0,0 +1,305 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FactorySeeder
4
+ class SeedGenerator
5
+ def initialize
6
+ @generated_records = []
7
+ @defined_seeds = {}
8
+ end
9
+
10
+ def preview(factory_name, count = 1, traits = [], attributes = {})
11
+ traits = traits.map(&:to_sym)
12
+ filtered_attributes = filter_association_attributes(factory_name, attributes)
13
+
14
+ preview_data = []
15
+ count.times do |i|
16
+ begin
17
+ # Build without saving
18
+ record = FactoryBot.build(factory_name, *traits, filtered_attributes)
19
+ rescue NoMethodError => e
20
+ # Check if this is the specific error about calling a method on CollectionProxy
21
+ raise unless e.message.include?('CollectionProxy') && e.message.include?('undefined method')
22
+
23
+ method_name = e.message.match(/undefined method `(\w+)'/)&.[](1)
24
+ # Try to identify which attribute might be causing the issue
25
+ problematic_attrs = attributes.select { |k, v| v.to_s == method_name || k.to_s == method_name }
26
+ if problematic_attrs.any?
27
+ raise NoMethodError,
28
+ "#{e.message}. This might be caused by passing '#{problematic_attrs.keys.first}' as an attribute. Collection associations (has_many, has_and_belongs_to_many) cannot be set directly via attributes."
29
+ else
30
+ raise NoMethodError,
31
+ "#{e.message}. This might be caused by an attribute that matches an association name. Try removing association-related attributes from your attributes hash."
32
+ end
33
+ end
34
+
35
+ preview_data << {
36
+ index: i + 1,
37
+ attributes: record.attributes,
38
+ associations: extract_associations(record)
39
+ }
40
+ rescue StandardError => e
41
+ preview_data << {
42
+ index: i + 1,
43
+ error: e.message
44
+ }
45
+ end
46
+
47
+ {
48
+ factory: factory_name,
49
+ count: count,
50
+ traits: traits,
51
+ attributes: attributes,
52
+ preview: preview_data
53
+ }
54
+ end
55
+
56
+ def generate(factory_name, count = 1, traits = [], attributes = {}, strategy = 'create')
57
+ traits = traits.map(&:to_sym)
58
+ filtered_attributes = filter_association_attributes(factory_name, attributes)
59
+
60
+ FactorySeeder.clear_execution_logs!
61
+ FactorySeeder.log_info("Starting #{factory_name} generation", count: count, traits: traits, strategy: strategy)
62
+
63
+ generated_count = 0
64
+ errors = []
65
+
66
+ count.times do |i|
67
+ begin
68
+ if strategy == 'create'
69
+ record = FactoryBot.create(factory_name, *traits, filtered_attributes)
70
+ else
71
+ record = FactoryBot.build(factory_name, *traits, filtered_attributes)
72
+ record.save! if record.respond_to?(:save!)
73
+ end
74
+ rescue NoMethodError => e
75
+ # Check if this is the specific error about calling a method on CollectionProxy
76
+ raise unless e.message.include?('CollectionProxy') && e.message.include?('undefined method')
77
+
78
+ method_name = e.message.match(/undefined method `(\w+)'/)&.[](1)
79
+ # Try to identify which attribute might be causing the issue
80
+ problematic_attrs = attributes.select { |k, v| v.to_s == method_name || k.to_s == method_name }
81
+ if problematic_attrs.any?
82
+ raise NoMethodError,
83
+ "#{e.message}. This might be caused by passing '#{problematic_attrs.keys.first}' as an attribute. Collection associations (has_many, has_and_belongs_to_many) cannot be set directly via attributes."
84
+ else
85
+ raise NoMethodError,
86
+ "#{e.message}. This might be caused by an attribute that matches an association name. Try removing association-related attributes from your attributes hash."
87
+ end
88
+ end
89
+
90
+ @generated_records << {
91
+ factory: factory_name,
92
+ record: record,
93
+ traits: traits,
94
+ attributes: attributes,
95
+ strategy: strategy
96
+ }
97
+
98
+ generated_count += 1
99
+
100
+ FactorySeeder.log("✅ Generated #{factory_name} ##{i + 1}", level: :success)
101
+ puts "✅ Generated #{factory_name} ##{i + 1}" if FactorySeeder.configuration.verbose
102
+ rescue StandardError => e
103
+ error_msg = "Failed to generate #{factory_name} ##{i + 1}: #{e.message}"
104
+ errors << error_msg
105
+ FactorySeeder.log(error_msg, level: :error)
106
+ puts "❌ #{error_msg}" if FactorySeeder.configuration.verbose
107
+ end
108
+
109
+ FactorySeeder.log_info('Completed generation', generated_count: generated_count, errors: errors.count)
110
+ logs = FactorySeeder.normalized_logs(FactorySeeder.execution_logs)
111
+ FactorySeeder.clear_execution_logs!
112
+
113
+ {
114
+ factory: factory_name,
115
+ generated_records: @generated_records,
116
+ count: generated_count,
117
+ requested_count: count,
118
+ traits: traits,
119
+ attributes: attributes,
120
+ strategy: strategy,
121
+ errors: errors,
122
+ logs: logs
123
+ }
124
+ end
125
+
126
+ def summary
127
+ return 'No records generated' if @generated_records.empty?
128
+
129
+ summary = "📊 Generation Summary:\n"
130
+ summary += "#{'=' * 50}\n"
131
+
132
+ by_factory = @generated_records.group_by { |r| r[:factory] }
133
+
134
+ by_factory.each do |factory_name, records|
135
+ summary += "\n🏭 #{factory_name}: #{records.count} records\n"
136
+ records.each_with_index do |record, index|
137
+ summary += " #{index + 1}. Strategy: #{record[:strategy]}"
138
+ summary += ", Traits: #{record[:traits].join(', ')}" if record[:traits].any?
139
+ summary += ", Attributes: #{record[:attributes]}" if record[:attributes].any?
140
+ summary += "\n"
141
+ end
142
+ end
143
+
144
+ summary
145
+ end
146
+
147
+ def define_seed(name, &block)
148
+ @defined_seeds[name.to_sym] = block
149
+ end
150
+
151
+ def run_seed(name)
152
+ seed_name = name.to_sym
153
+ unless @defined_seeds.key?(seed_name)
154
+ raise "Seed '#{name}' not found. Available seeds: #{@defined_seeds.keys.join(', ')}"
155
+ end
156
+
157
+ @defined_seeds[seed_name].call(self)
158
+ end
159
+
160
+ def list_seeds
161
+ @defined_seeds.keys
162
+ end
163
+
164
+ def has_seed?(name)
165
+ @defined_seeds.key?(name.to_sym)
166
+ end
167
+
168
+ def run_all_seeds
169
+ puts '🌱 Running all defined seeds...'
170
+ @defined_seeds.each do |name, block|
171
+ puts "\n--- Running seed: #{name} ---"
172
+ block.call(self)
173
+ end
174
+ puts "\n✅ All seeds completed successfully"
175
+ end
176
+
177
+ private
178
+
179
+ def filter_association_attributes(factory_name, attributes)
180
+ return attributes if attributes.empty?
181
+
182
+ begin
183
+ # Get the factory
184
+ factory = FactoryBot.factories.find { |f| f.name.to_s == factory_name.to_s }
185
+ return attributes unless factory
186
+
187
+ # Get associations from factory definition
188
+ factory_associations = []
189
+ factory.definition.declarations.each do |declaration|
190
+ factory_associations << declaration.name.to_s if declaration.is_a?(FactoryBot::Declaration::Association)
191
+ end
192
+
193
+ # Get the model class from the factory
194
+ model_class = factory.build_class
195
+ model_associations = []
196
+
197
+ if model_class.respond_to?(:reflect_on_all_associations)
198
+ # Get all has_many and has_and_belongs_to_many associations from the model
199
+ model_associations = model_class.reflect_on_all_associations.select do |assoc|
200
+ %i[has_many has_and_belongs_to_many].include?(assoc.macro)
201
+ end.map { |assoc| assoc.name.to_s }
202
+ end
203
+
204
+ # Combine factory and model associations
205
+ all_collection_associations = (factory_associations + model_associations).uniq
206
+
207
+ # Get all associations (including belongs_to and has_one) for additional safety
208
+ all_associations = []
209
+ if model_class.respond_to?(:reflect_on_all_associations)
210
+ all_associations = model_class.reflect_on_all_associations.map { |assoc| assoc.name.to_s }
211
+ end
212
+ all_associations = (all_associations + factory_associations).uniq
213
+
214
+ # Filter out attributes that match collection associations
215
+ filtered = attributes.dup
216
+ all_collection_associations.each do |assoc_name|
217
+ filtered.delete(assoc_name.to_sym)
218
+ filtered.delete(assoc_name.to_s)
219
+ end
220
+
221
+ # Additional safety: filter out any attribute whose value is a symbol
222
+ # and whose key matches an association name (to prevent method calls on associations)
223
+ filtered_before_symbol_check = filtered.dup
224
+ filtered.delete_if do |key, value|
225
+ key_str = key.to_s
226
+ # If the value is a symbol and the key matches an association name,
227
+ # FactoryBot might try to call that symbol as a method on the association
228
+ if value.is_a?(Symbol) && all_associations.include?(key_str)
229
+ if FactorySeeder.configuration.verbose
230
+ puts "🔍 Filtering attribute '#{key_str}' (value: #{value}) - matches association name"
231
+ end
232
+ true
233
+ # Also filter if key looks like an association name (plural, ends with _ids, etc.)
234
+ elsif value.is_a?(Symbol) && (
235
+ key_str.end_with?('_ids') ||
236
+ (key_str.pluralize == key_str && key_str.singularize != key_str)
237
+ )
238
+ # Double-check if it's actually an association
239
+ if model_class.respond_to?(:reflect_on_all_associations)
240
+ association = model_class.reflect_on_association(key_str.singularize.to_sym) ||
241
+ model_class.reflect_on_association(key_str.to_sym)
242
+ if !association.nil?
243
+ if FactorySeeder.configuration.verbose
244
+ puts "🔍 Filtering attribute '#{key_str}' (value: #{value}) - detected as association"
245
+ end
246
+ true
247
+ else
248
+ false
249
+ end
250
+ else
251
+ false
252
+ end
253
+ else
254
+ false
255
+ end
256
+ end
257
+
258
+ if FactorySeeder.configuration.verbose && filtered_before_symbol_check != filtered
259
+ removed = filtered_before_symbol_check.keys - filtered.keys
260
+ puts "🔍 Filtered out #{removed.count} association-related attributes: #{removed.join(', ')}" if removed.any?
261
+ end
262
+
263
+ filtered
264
+ rescue StandardError => e
265
+ # If we can't determine associations, try a more aggressive filter
266
+ # Filter out any attribute that might be problematic
267
+ filtered = attributes.dup
268
+
269
+ # Remove any attribute whose value is a symbol (which FactoryBot might try to call as a method)
270
+ # but only if we're not sure it's safe
271
+ filtered.delete_if do |key, value|
272
+ # If value is a symbol and key looks like it could be an association name
273
+ value.is_a?(Symbol) && (
274
+ key.to_s.end_with?('_ids') ||
275
+ key.to_s.pluralize == key.to_s ||
276
+ key.to_s.singularize != key.to_s
277
+ )
278
+ end
279
+
280
+ if FactorySeeder.configuration.verbose
281
+ puts "⚠️ Warning: Could not fully filter association attributes: #{e.message}"
282
+ end
283
+ filtered
284
+ end
285
+ end
286
+
287
+ def extract_associations(record)
288
+ associations = {}
289
+
290
+ record.class.reflect_on_all_associations.each do |association|
291
+ if association.macro == :belongs_to
292
+ associated_record = record.send(association.name)
293
+ associations[association.name] = associated_record&.id if associated_record
294
+ elsif association.macro == :has_many
295
+ associated_records = record.send(association.name)
296
+ associations[association.name] = associated_records.map(&:id) if associated_records.any?
297
+ end
298
+ rescue StandardError => e
299
+ associations[association.name] = "Error: #{e.message}"
300
+ end
301
+
302
+ associations
303
+ end
304
+ end
305
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FactorySeeder
4
+ class SeedManager
5
+ attr_reader :seeds
6
+
7
+ def initialize
8
+ @seeds = {}
9
+ end
10
+
11
+ def register(seed)
12
+ raise ArgumentError, 'Seed must be a FactorySeeder::Seed instance' unless seed.is_a?(Seed)
13
+
14
+ @seeds[seed.name] = seed
15
+ end
16
+
17
+ def define(name, builder_block = nil, &execution_block)
18
+ builder = SeedBuilder.new(name)
19
+ builder_block&.call(builder)
20
+ seed = builder.build(&execution_block)
21
+ register(seed)
22
+ seed
23
+ end
24
+
25
+ def find(name)
26
+ @seeds[name.to_sym]
27
+ end
28
+
29
+ def exists?(name)
30
+ @seeds.key?(name.to_sym)
31
+ end
32
+
33
+ def list
34
+ @seeds.values
35
+ end
36
+
37
+ def list_names
38
+ @seeds.keys
39
+ end
40
+
41
+ def run(name, **kwargs)
42
+ seed = find(name)
43
+ raise ArgumentError, "Seed '#{name}' not found" unless seed
44
+
45
+ FactorySeeder.clear_execution_logs!
46
+ FactorySeeder.log_info("Running custom seed '#{name}'", kwargs: kwargs)
47
+
48
+ begin
49
+ result = seed.call(**kwargs)
50
+ FactorySeeder.log_success("Seed '#{name}' completed", result: result)
51
+ logs = FactorySeeder.normalized_logs(FactorySeeder.execution_logs)
52
+ FactorySeeder.clear_execution_logs!
53
+ {
54
+ success: true,
55
+ seed_name: name,
56
+ result: result,
57
+ message: "Seed '#{name}' executed successfully",
58
+ logs: logs
59
+ }
60
+ rescue StandardError => e
61
+ FactorySeeder.log("Seed '#{name}' failed: #{e.message}", level: :error)
62
+ logs = FactorySeeder.normalized_logs(FactorySeeder.execution_logs)
63
+ FactorySeeder.clear_execution_logs!
64
+ {
65
+ success: false,
66
+ seed_name: name,
67
+ error: e.message,
68
+ message: "Seed '#{name}' failed: #{e.message}",
69
+ logs: logs
70
+ }
71
+ end
72
+ end
73
+
74
+ def run_all(**global_kwargs)
75
+ results = []
76
+
77
+ @seeds.each_key do |name|
78
+ result = run(name, **global_kwargs)
79
+ results << result
80
+ end
81
+
82
+ {
83
+ total_seeds: @seeds.count,
84
+ successful: results.count { |r| r[:success] },
85
+ failed: results.count { |r| !r[:success] },
86
+ results: results
87
+ }
88
+ end
89
+
90
+ def validate_seed(name, **kwargs)
91
+ seed = find(name)
92
+ raise ArgumentError, "Seed '#{name}' not found" unless seed
93
+
94
+ seed.validate_parameters!(kwargs)
95
+ true
96
+ rescue StandardError
97
+ false
98
+ end
99
+
100
+ def get_seed_info(name)
101
+ seed = find(name)
102
+ return nil unless seed
103
+
104
+ seed.to_h
105
+ end
106
+
107
+ def search(query)
108
+ query = query.to_s.downcase
109
+ @seeds.values.select do |seed|
110
+ seed.name.to_s.downcase.include?(query) ||
111
+ seed.description.downcase.include?(query) ||
112
+ seed.parameter_names.any? { |param| param.to_s.downcase.include?(query) }
113
+ end
114
+ end
115
+
116
+ def clear
117
+ @seeds.clear
118
+ end
119
+
120
+ def count
121
+ @seeds.count
122
+ end
123
+
124
+ def empty?
125
+ @seeds.empty?
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FactorySeeder
4
+ class Seeder
5
+ attr_reader :seeds
6
+
7
+ def initialize
8
+ @seeds = {}
9
+ end
10
+
11
+ def seed(name, &block)
12
+ @seeds[name.to_sym] = block
13
+ end
14
+
15
+ def run(name, **kwargs)
16
+ name = to_sym(name)
17
+ seed = @seeds[name]
18
+
19
+ raise ArgumentError, "Seed #{name} not defined" unless seed
20
+
21
+ result = @seeds[name].call(**kwargs)
22
+ if result
23
+ puts "🌱 Seed #{name} generated successfully"
24
+ true
25
+ else
26
+ puts "⚠️ Seed #{name} failed"
27
+ false
28
+ end
29
+ end
30
+
31
+ private
32
+
33
+ def to_sym(name)
34
+ if name.is_a?(Symbol)
35
+ name
36
+ else
37
+ name.to_sym
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FactorySeeder
4
+ VERSION = '0.1.0'
5
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'sinatra/base'
4
+ require 'sinatra/reloader'
5
+
6
+ module FactorySeeder
7
+ class WebInterface < Sinatra::Base
8
+ configure do
9
+ set :public_folder, File.join(File.dirname(__FILE__), '..', '..', 'public')
10
+ set :views, File.join(File.dirname(__FILE__), '..', '..', 'views')
11
+ enable :reloader
12
+ set :bind, '0.0.0.0'
13
+ end
14
+
15
+ before do
16
+ FactorySeeder.reload!
17
+ end
18
+
19
+ get '/' do
20
+ @factories = FactorySeeder.scan_factories
21
+ erb :index
22
+ end
23
+
24
+ get '/factory/:name' do
25
+ @factory_name = params[:name]
26
+ @factories = FactorySeeder.scan_factories
27
+ @factory = @factories[@factory_name]
28
+
29
+ if @factory
30
+ erb :factory_detail
31
+ else
32
+ status 404
33
+ 'Factory not found'
34
+ end
35
+ end
36
+
37
+ post '/generate' do
38
+ content_type :json
39
+
40
+ # Parse JSON body if present
41
+ if request.content_type == 'application/json'
42
+ data = JSON.parse(request.body.read)
43
+ factory_name = data['factory']
44
+ count = data['count'].to_i
45
+ traits = data['traits']
46
+ attributes = data['attributes'] || {}
47
+ else
48
+ factory_name = params[:factory]
49
+ count = params[:count].to_i
50
+ traits = params[:traits]
51
+ attributes = params[:attributes] || {}
52
+ end
53
+
54
+ # Parse traits if it's a string
55
+ traits = if traits.is_a?(String) && !traits.empty?
56
+ traits.split(',').map(&:strip)
57
+ elsif traits.is_a?(Array)
58
+ traits.flatten.map(&:strip)
59
+ else
60
+ []
61
+ end
62
+
63
+ begin
64
+ generator = SeedGenerator.new
65
+ result = generator.generate(factory_name, count, traits, attributes)
66
+
67
+ if result[:errors].any?
68
+ { success: false, error: result[:errors].join(', ') }.to_json
69
+ else
70
+ { success: true, message: "Created #{result[:count]} #{factory_name} records" }.to_json
71
+ end
72
+ rescue StandardError => e
73
+ { success: false, error: e.message }.to_json
74
+ end
75
+ end
76
+
77
+ get '/api/factories' do
78
+ content_type :json
79
+ FactorySeeder.scan_factories.to_json
80
+ end
81
+
82
+ get '/api/factory/:name/preview' do
83
+ content_type :json
84
+
85
+ factory_name = params[:name]
86
+ traits = params[:traits]
87
+ attributes = params[:attributes] || {}
88
+
89
+ # Parse traits if it's a string
90
+ traits = if traits.is_a?(String)
91
+ traits.split(',').map(&:strip).map(&:to_sym)
92
+ elsif traits.is_a?(Array)
93
+ traits.flatten.map(&:strip).map(&:to_sym)
94
+ else
95
+ []
96
+ end
97
+
98
+ # Parse attributes if it's a string
99
+ if attributes.is_a?(String) && !attributes.empty?
100
+ begin
101
+ attributes = JSON.parse(attributes)
102
+ rescue JSON::ParserError
103
+ attributes = {}
104
+ end
105
+ end
106
+
107
+ begin
108
+ sample = FactoryBot.build(factory_name, *traits, attributes)
109
+
110
+ {
111
+ success: true,
112
+ attributes: sample.attributes.reject { |k, _| %w[id created_at updated_at].include?(k) }
113
+ }.to_json
114
+ rescue StandardError => e
115
+ { success: false, error: e.message }.to_json
116
+ end
117
+ end
118
+ end
119
+ end