seed_dump 3.3.1 → 3.4.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.
@@ -6,9 +6,20 @@ class SeedDump
6
6
 
7
7
  models = retrieve_models(env) - retrieve_models_exclude(env)
8
8
 
9
- limit = retrieve_limit_value(env)
9
+ # Sort models by foreign key dependencies (issues #78, #83)
10
+ # This ensures models are dumped in the correct order so that
11
+ # seeds can be imported without foreign key violations.
12
+ models = sort_models_by_dependencies(models)
13
+
14
+ global_limit = retrieve_limit_value(env)
15
+ model_limits = retrieve_model_limits_value(env)
10
16
  append = retrieve_append_value(env)
11
17
  models.each do |model|
18
+ # Determine the limit to apply for this model:
19
+ # 1. Check MODEL_LIMITS for a per-model override
20
+ # 2. Fall back to global LIMIT
21
+ # 3. If neither, no limit is applied
22
+ limit = limit_for_model(model, model_limits, global_limit)
12
23
  model = model.limit(limit) if limit.present?
13
24
 
14
25
  SeedDump.dump(model,
@@ -16,7 +27,10 @@ class SeedDump
16
27
  batch_size: retrieve_batch_size_value(env),
17
28
  exclude: retrieve_exclude_value(env),
18
29
  file: retrieve_file_value(env),
19
- import: retrieve_import_value(env))
30
+ header: retrieve_header_value(env),
31
+ import: retrieve_import_value(env),
32
+ insert_all: retrieve_insert_all_value(env),
33
+ upsert_all: retrieve_upsert_all_value(env))
20
34
 
21
35
  append = true # Always append for every model after the first
22
36
  # (append for the first model is determined by
@@ -60,7 +74,7 @@ class SeedDump
60
74
  # model classes in the project.
61
75
  models = if models_env
62
76
  models_env.split(',')
63
- .collect {|x| x.strip.underscore.singularize.camelize.constantize }
77
+ .collect {|x| model_name_to_constant(x.strip) }
64
78
  else
65
79
  ActiveRecord::Base.descendants
66
80
  end
@@ -69,13 +83,171 @@ class SeedDump
69
83
  # Filter the set of models to exclude:
70
84
  # - The ActiveRecord::SchemaMigration model which is internal to Rails
71
85
  # and should not be part of the dumped data.
86
+ # - Classes that don't respond to table_exists? or exists? (e.g., abstract
87
+ # classes or non-model descendants of ActiveRecord::Base).
72
88
  # - Models that don't have a corresponding table in the database.
73
89
  # - Models whose corresponding database tables are empty.
74
90
  filtered_models = models.select do |model|
75
- !ACTIVE_RECORD_INTERNAL_MODELS.include?(model.to_s) && \
76
- model.table_exists? && \
77
- model.exists?
91
+ begin
92
+ !ACTIVE_RECORD_INTERNAL_MODELS.include?(model.to_s) && \
93
+ model.table_exists? && \
94
+ model.exists?
95
+ rescue NoMethodError
96
+ # Skip classes that don't properly respond to table_exists? or exists?
97
+ # This can happen with abstract classes or other non-model descendants
98
+ false
99
+ end
78
100
  end
101
+
102
+ # Deduplicate HABTM models that share the same table (issues #26, #114).
103
+ # Rails creates two auto-generated models for each HABTM association
104
+ # (e.g., User::HABTM_Roles and Role::HABTM_Users) that both point to
105
+ # the same join table. We only want to dump one of them.
106
+ deduped_habtm = deduplicate_habtm_models(filtered_models)
107
+
108
+ # Deduplicate STI models that share the same table (issue #120).
109
+ # With STI, subclasses (e.g., AdminUser < User) share the same table as
110
+ # their parent. We only want to dump the base class, which will include
111
+ # all records including subclass records with proper type discrimination.
112
+ deduplicate_sti_models(deduped_habtm)
113
+ end
114
+
115
+ # Internal: Deduplicates HABTM models that share the same table.
116
+ #
117
+ # When using has_and_belongs_to_many, Rails creates auto-generated models
118
+ # like User::HABTM_Roles and Role::HABTM_Users that both reference the same
119
+ # join table. Without deduplication, the join table data would be dumped twice.
120
+ #
121
+ # models - Array of ActiveRecord model classes.
122
+ #
123
+ # Returns the Array with duplicate HABTM models removed.
124
+ def deduplicate_habtm_models(models)
125
+ habtm, non_habtm = models.partition { |m| m.to_s.include?('HABTM_') }
126
+ non_habtm + habtm.uniq(&:table_name)
127
+ end
128
+
129
+ # Internal: Deduplicates STI models that share the same table.
130
+ #
131
+ # With Single Table Inheritance, subclasses like AdminUser < User share the
132
+ # same database table as their parent. Without deduplication, each STI class
133
+ # would be dumped separately, creating duplicate records.
134
+ #
135
+ # The solution is to keep only the base class for each STI hierarchy, which
136
+ # will include all records (base and subclass) with proper type discrimination.
137
+ #
138
+ # models - Array of ActiveRecord model classes.
139
+ #
140
+ # Returns the Array with STI subclasses removed (only base classes kept).
141
+ def deduplicate_sti_models(models)
142
+ models.select do |model|
143
+ # Keep the model only if it IS its own base class
144
+ # For STI subclasses, base_class returns the parent (e.g., AdminUser.base_class => User)
145
+ # For non-STI models, base_class returns self
146
+ model.base_class == model
147
+ end
148
+ end
149
+
150
+ # Internal: Sorts models by foreign key dependencies using topological sort.
151
+ #
152
+ # Models with foreign keys (belongs_to associations) depend on the models
153
+ # they reference. This method ensures that referenced models are dumped
154
+ # before the models that depend on them, preventing foreign key violations
155
+ # when importing seeds (issues #78, #83).
156
+ #
157
+ # For example, if Book belongs_to Author, Author will be sorted before Book.
158
+ #
159
+ # Uses Kahn's algorithm for topological sorting. If there are circular
160
+ # dependencies, the remaining models are appended in their original order.
161
+ #
162
+ # models - Array of ActiveRecord model classes to sort.
163
+ #
164
+ # Returns a new Array with models sorted by dependencies (dependencies first).
165
+ def sort_models_by_dependencies(models)
166
+ return models if models.empty?
167
+
168
+ # Build a lookup for models by table name for faster dependency resolution
169
+ model_by_table = models.each_with_object({}) do |model, hash|
170
+ hash[model.table_name] = model
171
+ end
172
+
173
+ # Build dependency graph: model -> models it depends on (via belongs_to)
174
+ dependencies = {}
175
+ models.each do |model|
176
+ dependencies[model] = find_model_dependencies(model, model_by_table)
177
+ end
178
+
179
+ # Topological sort using Kahn's algorithm
180
+ topological_sort(models, dependencies)
181
+ end
182
+
183
+ # Internal: Finds the models that a given model depends on via belongs_to.
184
+ #
185
+ # model - The ActiveRecord model class to find dependencies for.
186
+ # model_by_table - Hash mapping table names to model classes.
187
+ #
188
+ # Returns an Array of model classes that this model depends on.
189
+ def find_model_dependencies(model, model_by_table)
190
+ deps = []
191
+
192
+ # Check belongs_to associations for foreign key dependencies
193
+ model.reflect_on_all_associations(:belongs_to).each do |assoc|
194
+ # Get the table name this association points to
195
+ # Use the association's class_name if available, otherwise infer from name
196
+ begin
197
+ referenced_class = assoc.klass
198
+ referenced_table = referenced_class.table_name
199
+
200
+ # Only add as dependency if it's in our set of models to dump
201
+ if model_by_table.key?(referenced_table)
202
+ dep_model = model_by_table[referenced_table]
203
+ deps << dep_model unless dep_model == model
204
+ end
205
+ rescue NameError, ArgumentError
206
+ # Skip if we can't resolve the class (e.g., polymorphic without type)
207
+ next
208
+ end
209
+ end
210
+
211
+ deps.uniq
212
+ end
213
+
214
+ # Internal: Performs topological sort on models based on their dependencies.
215
+ #
216
+ # Uses Kahn's algorithm:
217
+ # 1. Find all models with no dependencies (no incoming edges)
218
+ # 2. Add them to the result and remove them from the graph
219
+ # 3. Repeat until all models are sorted or a cycle is detected
220
+ #
221
+ # models - Array of model classes.
222
+ # dependencies - Hash mapping each model to its dependencies.
223
+ #
224
+ # Returns an Array of models in topologically sorted order.
225
+ def topological_sort(models, dependencies)
226
+ result = []
227
+ remaining = models.dup
228
+
229
+ # Calculate in-degree (number of models depending on each model)
230
+ # We need to track which models are "ready" (all their dependencies satisfied)
231
+ while remaining.any?
232
+ # Find models whose dependencies have all been processed
233
+ ready = remaining.select do |model|
234
+ dependencies[model].all? { |dep| result.include?(dep) }
235
+ end
236
+
237
+ if ready.empty?
238
+ # Circular dependency detected - add remaining in original order
239
+ result.concat(remaining)
240
+ break
241
+ end
242
+
243
+ # Add ready models to result (maintain relative order for stability)
244
+ ready.each do |model|
245
+ result << model
246
+ remaining.delete(model)
247
+ end
248
+ end
249
+
250
+ result
79
251
  end
80
252
 
81
253
  # Internal: Returns a Boolean indicating whether the value for the "APPEND"
@@ -92,13 +264,66 @@ class SeedDump
92
264
  parse_boolean_value(env['IMPORT'])
93
265
  end
94
266
 
267
+ # Internal: Returns a Boolean indicating whether the value for the "INSERT_ALL"
268
+ # key in the given Hash is equal to the String "true" (ignoring case),
269
+ # false if no value exists. INSERT_ALL uses Rails 6+ insert_all for faster
270
+ # bulk inserts that bypass validations and callbacks.
271
+ def retrieve_insert_all_value(env)
272
+ parse_boolean_value(env['INSERT_ALL'])
273
+ end
274
+
275
+ # Internal: Returns a Boolean indicating whether the value for the "UPSERT_ALL"
276
+ # key in the given Hash is equal to the String "true" (ignoring case),
277
+ # false if no value exists. UPSERT_ALL uses Rails 6+ upsert_all to preserve
278
+ # original record IDs, which fixes foreign key reference issues when parent
279
+ # records have been deleted (issue #104).
280
+ def retrieve_upsert_all_value(env)
281
+ parse_boolean_value(env['UPSERT_ALL'])
282
+ end
283
+
284
+ # Internal: Returns a Boolean indicating whether the value for the "HEADER"
285
+ # key in the given Hash is equal to the String "true" (ignoring case),
286
+ # false if no value exists. HEADER adds a comment at the top of the seed file
287
+ # showing when and how it was generated for traceability (issue #126).
288
+ def retrieve_header_value(env)
289
+ parse_boolean_value(env['HEADER'])
290
+ end
291
+
95
292
  # Internal: Retrieves an Array of Class constants parsed from the value for
96
293
  # the "MODELS_EXCLUDE" key in the given Hash, and an empty Array if such
97
294
  # key exists.
98
295
  def retrieve_models_exclude(env)
99
296
  env['MODELS_EXCLUDE'].to_s
100
297
  .split(',')
101
- .collect { |x| x.strip.underscore.singularize.camelize.constantize }
298
+ .collect { |x| model_name_to_constant(x.strip) }
299
+ end
300
+
301
+ # Internal: Converts a model name string to a constant.
302
+ #
303
+ # This method handles the issue where model names ending in 's' (like "Boss")
304
+ # were incorrectly singularized to "Bos" by older Rails versions (issue #121).
305
+ #
306
+ # The strategy is:
307
+ # 1. Try camelized form first (handles "Boss", "boss", "user_profile")
308
+ # 2. Fall back to underscore.singularize.camelize for plural table names
309
+ #
310
+ # model_name - String name of the model (e.g., "Boss", "boss", "users")
311
+ #
312
+ # Returns the Class constant for the model.
313
+ # Raises NameError if the model cannot be found.
314
+ def model_name_to_constant(model_name)
315
+ # First, try the camelized version directly
316
+ # This handles: "Boss" -> Boss, "boss" -> Boss, "user_profile" -> UserProfile
317
+ camelized = model_name.camelize
318
+ begin
319
+ return camelized.constantize
320
+ rescue NameError
321
+ # Fall through to try singularized version
322
+ end
323
+
324
+ # Fall back to traditional approach for plural names
325
+ # This handles: "users" -> User, "bosses" -> Boss
326
+ model_name.underscore.singularize.camelize.constantize
102
327
  end
103
328
 
104
329
  # Internal: Retrieves an Integer from the value for the "LIMIT" key in the
@@ -107,10 +332,71 @@ class SeedDump
107
332
  retrieve_integer_value('LIMIT', env)
108
333
  end
109
334
 
335
+ # Internal: Parses the MODEL_LIMITS environment variable into a Hash.
336
+ #
337
+ # MODEL_LIMITS allows per-model limit overrides to prevent LIMIT from
338
+ # breaking associations (issue #142). Format: "Model1:limit1,Model2:limit2"
339
+ #
340
+ # A limit of 0 means "unlimited" (dump all records for that model).
341
+ #
342
+ # Example: MODEL_LIMITS="Teacher:0,Student:50"
343
+ # - Teacher: dumps all records (0 = unlimited)
344
+ # - Student: dumps 50 records
345
+ # - Other models: fall back to global LIMIT or dump all if no LIMIT set
346
+ #
347
+ # env - Hash of environment variables.
348
+ #
349
+ # Returns a Hash mapping model names (String) to limits (Integer), or
350
+ # empty Hash if MODEL_LIMITS is not set.
351
+ def retrieve_model_limits_value(env)
352
+ return {} unless env['MODEL_LIMITS']
353
+
354
+ env['MODEL_LIMITS'].split(',').each_with_object({}) do |pair, hash|
355
+ model_name, limit = pair.split(':').map(&:strip)
356
+ hash[model_name] = limit.to_i if model_name && limit
357
+ end
358
+ end
359
+
360
+ # Internal: Determines the limit to apply for a given model.
361
+ #
362
+ # Precedence:
363
+ # 1. Per-model limit from MODEL_LIMITS (0 means unlimited)
364
+ # 2. Global LIMIT
365
+ # 3. nil (no limit, dump all records)
366
+ #
367
+ # model - The ActiveRecord model class.
368
+ # model_limits - Hash of per-model limits from MODEL_LIMITS.
369
+ # global_limit - The global LIMIT value (Integer or nil).
370
+ #
371
+ # Returns an Integer limit or nil if no limit should be applied.
372
+ def limit_for_model(model, model_limits, global_limit)
373
+ model_name = model.to_s
374
+
375
+ if model_limits.key?(model_name)
376
+ limit = model_limits[model_name]
377
+ # 0 means unlimited - return nil to skip applying limit
378
+ limit == 0 ? nil : limit
379
+ else
380
+ global_limit
381
+ end
382
+ end
383
+
110
384
  # Internal: Retrieves an Array of Symbols from the value for the "EXCLUDE"
111
385
  # key from the given Hash, and nil if no such key exists.
386
+ #
387
+ # If INCLUDE_ALL is set to 'true', returns an empty array to disable
388
+ # the default exclusion of id, created_at, updated_at columns. This provides
389
+ # a cleaner alternative to EXCLUDE="" (issue #147).
390
+ #
391
+ # Note that explicit EXCLUDE values take precedence over INCLUDE_ALL.
112
392
  def retrieve_exclude_value(env)
113
- env['EXCLUDE'] ? env['EXCLUDE'].split(',').map {|e| e.strip.to_sym} : nil
393
+ if env['EXCLUDE']
394
+ env['EXCLUDE'].split(',').map { |e| e.strip.to_sym }
395
+ elsif parse_boolean_value(env['INCLUDE_ALL'])
396
+ []
397
+ else
398
+ nil
399
+ end
114
400
  end
115
401
 
116
402
  # Internal: Retrieves the value for the "FILE" key from the given Hash, and
data/seed_dump.gemspec CHANGED
@@ -2,28 +2,41 @@
2
2
  # DO NOT EDIT THIS FILE DIRECTLY
3
3
  # Instead, edit Jeweler::Tasks in Rakefile, and run 'rake gemspec'
4
4
  # -*- encoding: utf-8 -*-
5
- # stub: seed_dump 3.3.1 ruby lib
5
+ # stub: seed_dump 3.4.0 ruby lib
6
6
 
7
7
  Gem::Specification.new do |s|
8
8
  s.name = "seed_dump".freeze
9
- s.version = "3.3.1"
9
+ s.version = "3.4.0".freeze
10
10
 
11
11
  s.required_rubygems_version = Gem::Requirement.new(">= 0".freeze) if s.respond_to? :required_rubygems_version=
12
12
  s.require_paths = ["lib".freeze]
13
13
  s.authors = ["Rob Halff".freeze, "Ryan Oblak".freeze]
14
- s.date = "2018-05-08"
14
+ s.date = "1980-01-02"
15
15
  s.description = "Dump (parts) of your database to db/seeds.rb to get a headstart creating a meaningful seeds.rb file".freeze
16
16
  s.email = "rroblak@gmail.com".freeze
17
17
  s.extra_rdoc_files = [
18
18
  "README.md"
19
19
  ]
20
20
  s.files = [
21
+ ".github/workflows/release.yml",
21
22
  ".rspec",
23
+ "Appraisals",
22
24
  "Gemfile",
23
25
  "MIT-LICENSE",
24
26
  "README.md",
25
27
  "Rakefile",
26
28
  "VERSION",
29
+ "find_ruby_compat.sh",
30
+ "gemfiles/rails_6.1.gemfile",
31
+ "gemfiles/rails_6.1.gemfile.lock",
32
+ "gemfiles/rails_7.0.gemfile",
33
+ "gemfiles/rails_7.0.gemfile.lock",
34
+ "gemfiles/rails_7.1.gemfile",
35
+ "gemfiles/rails_7.1.gemfile.lock",
36
+ "gemfiles/rails_7.2.gemfile",
37
+ "gemfiles/rails_7.2.gemfile.lock",
38
+ "gemfiles/rails_8.0.gemfile",
39
+ "gemfiles/rails_8.0.gemfile.lock",
27
40
  "lib/seed_dump.rb",
28
41
  "lib/seed_dump/dump_methods.rb",
29
42
  "lib/seed_dump/dump_methods/enumeration.rb",
@@ -34,6 +47,11 @@ Gem::Specification.new do |s|
34
47
  "spec/dump_methods_spec.rb",
35
48
  "spec/environment_spec.rb",
36
49
  "spec/factories/another_samples.rb",
50
+ "spec/factories/authors.rb",
51
+ "spec/factories/base_users.rb",
52
+ "spec/factories/books.rb",
53
+ "spec/factories/bosses.rb",
54
+ "spec/factories/reviews.rb",
37
55
  "spec/factories/samples.rb",
38
56
  "spec/factories/yet_another_samples.rb",
39
57
  "spec/helpers.rb",
@@ -41,34 +59,17 @@ Gem::Specification.new do |s|
41
59
  ]
42
60
  s.homepage = "https://github.com/rroblak/seed_dump".freeze
43
61
  s.licenses = ["MIT".freeze]
44
- s.rubygems_version = "2.7.6".freeze
62
+ s.rubygems_version = "3.6.9".freeze
45
63
  s.summary = "{Seed Dumper for Rails}".freeze
46
64
 
47
- if s.respond_to? :specification_version then
48
- s.specification_version = 4
65
+ s.specification_version = 4
49
66
 
50
- if Gem::Version.new(Gem::VERSION) >= Gem::Version.new('1.2.0') then
51
- s.add_runtime_dependency(%q<activesupport>.freeze, [">= 4"])
52
- s.add_runtime_dependency(%q<activerecord>.freeze, [">= 4"])
53
- s.add_development_dependency(%q<byebug>.freeze, ["~> 2.0"])
54
- s.add_development_dependency(%q<factory_bot>.freeze, ["~> 4.8.2"])
55
- s.add_development_dependency(%q<activerecord-import>.freeze, ["~> 0.4"])
56
- s.add_development_dependency(%q<jeweler>.freeze, ["~> 2.0"])
57
- else
58
- s.add_dependency(%q<activesupport>.freeze, [">= 4"])
59
- s.add_dependency(%q<activerecord>.freeze, [">= 4"])
60
- s.add_dependency(%q<byebug>.freeze, ["~> 2.0"])
61
- s.add_dependency(%q<factory_bot>.freeze, ["~> 4.8.2"])
62
- s.add_dependency(%q<activerecord-import>.freeze, ["~> 0.4"])
63
- s.add_dependency(%q<jeweler>.freeze, ["~> 2.0"])
64
- end
65
- else
66
- s.add_dependency(%q<activesupport>.freeze, [">= 4"])
67
- s.add_dependency(%q<activerecord>.freeze, [">= 4"])
68
- s.add_dependency(%q<byebug>.freeze, ["~> 2.0"])
69
- s.add_dependency(%q<factory_bot>.freeze, ["~> 4.8.2"])
70
- s.add_dependency(%q<activerecord-import>.freeze, ["~> 0.4"])
71
- s.add_dependency(%q<jeweler>.freeze, ["~> 2.0"])
72
- end
67
+ s.add_runtime_dependency(%q<activesupport>.freeze, [">= 4".freeze])
68
+ s.add_runtime_dependency(%q<activerecord>.freeze, [">= 4".freeze])
69
+ s.add_runtime_dependency(%q<seed_dump>.freeze, [">= 0".freeze])
70
+ s.add_development_dependency(%q<byebug>.freeze, ["~> 11.1".freeze])
71
+ s.add_development_dependency(%q<factory_bot>.freeze, ["~> 6.1".freeze])
72
+ s.add_development_dependency(%q<activerecord-import>.freeze, ["~> 0.28".freeze])
73
+ s.add_development_dependency(%q<jeweler>.freeze, ["~> 2.3".freeze])
73
74
  end
74
75