seed_dump 3.4.0 → 3.4.1

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.
@@ -1,1114 +0,0 @@
1
- require 'spec_helper'
2
-
3
- describe SeedDump do
4
-
5
- # Helper for expected output based on default factory values (integer: 42)
6
- # Uses ISO 8601 format with timezone suffix (issue #111)
7
- def expected_output(include_id = false, id_offset = 0, count = 3)
8
- output = "Sample.create!([\n "
9
- data = []
10
- start_id = 1 + id_offset
11
- end_id = count + id_offset # Adjust end based on count
12
- (start_id..end_id).each do |i|
13
- # Expect integer: 42, ISO 8601 format with timezone
14
- data << "{#{include_id ? "id: #{i}, " : ''}string: \"string\", text: \"text\", integer: 42, float: 3.14, decimal: \"2.72\", datetime: \"1776-07-04T19:14:00Z\", time: \"2000-01-01T03:15:00Z\", date: \"1863-11-19\", binary: \"binary\", boolean: false}"
15
- end
16
- output + data.join(",\n ") + "\n])\n"
17
- end
18
-
19
- # Helper for activerecord-import output based on default factory values
20
- # Uses ISO 8601 format with timezone suffix (issue #111)
21
- def expected_import_output(exclude_id_timestamps = true)
22
- columns = if exclude_id_timestamps
23
- [:string, :text, :integer, :float, :decimal, :datetime, :time, :date, :binary, :boolean]
24
- else
25
- [:id, :string, :text, :integer, :float, :decimal, :datetime, :time, :date, :binary, :boolean, :created_at, :updated_at]
26
- end
27
- output = "Sample.import([#{columns.map(&:inspect).join(', ')}], [\n "
28
- data = []
29
- (1..3).each do |i|
30
- row = if exclude_id_timestamps
31
- # Expect integer: 42, ISO 8601 format with timezone
32
- ["string", "text", 42, 3.14, "2.72", "1776-07-04T19:14:00Z", "2000-01-01T03:15:00Z", "1863-11-19", "binary", false]
33
- else
34
- # Expect integer: 42, ISO 8601 format with timezone
35
- [i, "string", "text", 42, 3.14, "2.72", "1776-07-04T19:14:00Z", "2000-01-01T03:15:00Z", "1863-11-19", "binary", false, "1969-07-20T20:18:00Z", "1989-11-10T04:20:00Z"]
36
- end
37
- data << "[#{row.map(&:inspect).join(', ')}]"
38
- end
39
- output + data.join(",\n ") + "\n])\n"
40
- end
41
-
42
- # Helper for activerecord-import output with options
43
- # Uses ISO 8601 format with timezone suffix (issue #111)
44
- def expected_import_output_with_options
45
- columns = [:id, :string, :text, :integer, :float, :decimal, :datetime, :time, :date, :binary, :boolean, :created_at, :updated_at]
46
- output = "Sample.import([#{columns.map(&:inspect).join(', ')}], [\n "
47
- data = []
48
- (1..3).each do |i|
49
- # Expect integer: 42, ISO 8601 format with timezone
50
- row = [i, "string", "text", 42, 3.14, "2.72", "1776-07-04T19:14:00Z", "2000-01-01T03:15:00Z", "1863-11-19", "binary", false, "1969-07-20T20:18:00Z", "1989-11-10T04:20:00Z"]
51
- data << "[#{row.map(&:inspect).join(', ')}]"
52
- end
53
- output + data.join(",\n ") + "\n], validate: false)\n"
54
- end
55
-
56
- # Helper for insert_all output based on default factory values (issue #153)
57
- # Uses ISO 8601 format with timezone suffix (issue #111)
58
- def expected_insert_all_output(exclude_id_timestamps = true)
59
- output = "Sample.insert_all([\n "
60
- data = []
61
- (1..3).each do |i|
62
- row = if exclude_id_timestamps
63
- # Expect integer: 42, ISO 8601 format with timezone
64
- { string: "string", text: "text", integer: 42, float: 3.14, decimal: "2.72", datetime: "1776-07-04T19:14:00Z", time: "2000-01-01T03:15:00Z", date: "1863-11-19", binary: "binary", boolean: false }
65
- else
66
- { id: i, string: "string", text: "text", integer: 42, float: 3.14, decimal: "2.72", datetime: "1776-07-04T19:14:00Z", time: "2000-01-01T03:15:00Z", date: "1863-11-19", binary: "binary", boolean: false, created_at: "1969-07-20T20:18:00Z", updated_at: "1989-11-10T04:20:00Z" }
67
- end
68
- data << "{#{row.map { |k, v| "#{k}: #{v.inspect}" }.join(', ')}}"
69
- end
70
- output + data.join(",\n ") + "\n])\n"
71
- end
72
-
73
-
74
- describe '.dump' do
75
-
76
- context 'without file option' do
77
- before(:each) { FactoryBot.create_list(:sample, 3) } # Create 3 standard samples
78
- it 'should return the dump of the models passed in' do
79
- expect(SeedDump.dump(Sample)).to eq(expected_output) # Expects 3 standard samples
80
- end
81
- end
82
-
83
- context 'with file option' do
84
- let(:tempfile) { Tempfile.new(['seed_dump_test', '.rb']) }
85
- let(:filename) { tempfile.path }
86
-
87
- before(:each) { FactoryBot.create_list(:sample, 3) } # Create 3 standard samples
88
-
89
- after do
90
- tempfile.close
91
- tempfile.unlink
92
- end
93
-
94
- it 'should dump the models to the specified file' do
95
- SeedDump.dump(Sample, file: filename)
96
- expect(File.read(filename)).to eq(expected_output) # Expects 3 standard samples
97
- end
98
-
99
- context 'with append option' do
100
- it 'should append to the file rather than overwriting it' do
101
- # before(:each) creates 3 records
102
- SeedDump.dump(Sample, file: filename) # Dumps the 3 records
103
- # Second dump should dump the same 3 records again
104
- SeedDump.dump(Sample, file: filename, append: true)
105
- expect(File.read(filename)).to eq(expected_output + expected_output) # Expects 2 sets of 3 standard samples
106
- end
107
- end
108
-
109
- context 'with non-seekable files like /dev/stdout (issue #150)' do
110
- # Issue #150: Using w+ mode fails when writing to pipes because
111
- # pipes are not seekable. We should use w mode (write-only) instead.
112
- it 'should open files in write-only mode (w) not read+write mode (w+)' do
113
- # Verify File.open is called with 'w' mode, not 'w+'
114
- expect(File).to receive(:open).with(filename, 'w').and_call_original
115
- SeedDump.dump(Sample, file: filename)
116
- end
117
-
118
- it 'should open files in append mode (a) not read+append mode (a+)' do
119
- # Verify File.open is called with 'a' mode, not 'a+'
120
- expect(File).to receive(:open).with(filename, 'a').and_call_original
121
- SeedDump.dump(Sample, file: filename, append: true)
122
- end
123
- end
124
- end
125
-
126
- context 'ActiveRecord relation' do
127
- it 'should return nil if the count is 0' do
128
- expect(SeedDump.dump(EmptyModel)).to be_nil
129
- end
130
-
131
- context 'with an order parameter' do
132
- before(:each) do
133
- # Create samples with specific orderable values (0, 1, 2)
134
- 3.times { |i| FactoryBot.create(:sample, integer: i) }
135
- end
136
-
137
- it 'should dump the models in the specified order' do
138
- # Define expected output based on descending integer order (2, 1, 0)
139
- # Uses ISO 8601 format with timezone suffix (issue #111)
140
- expected_desc_output = "Sample.create!([\n "
141
- data = 2.downto(0).map do |i|
142
- "{string: \"string\", text: \"text\", integer: #{i}, float: 3.14, decimal: \"2.72\", datetime: \"1776-07-04T19:14:00Z\", time: \"2000-01-01T03:15:00Z\", date: \"1863-11-19\", binary: \"binary\", boolean: false}"
143
- end
144
- expected_desc_output += data.join(",\n ") + "\n])\n"
145
-
146
- expect(SeedDump.dump(Sample.order('integer DESC'))).to eq(expected_desc_output)
147
- end
148
- end
149
-
150
- context 'without an order parameter' do
151
- before(:each) { FactoryBot.create_list(:sample, 3) } # Create 3 standard samples
152
- it 'should dump the models sorted by primary key ascending' do
153
- expect(SeedDump.dump(Sample)).to eq(expected_output) # Expects 3 standard samples
154
- end
155
- end
156
-
157
- context 'with a limit parameter' do
158
- it 'should dump the number of models specified by the limit when the limit is smaller than the batch size' do
159
- # Create one sample record (integer will be 42 from factory)
160
- FactoryBot.create(:sample)
161
- # Expected output for a single record, ISO 8601 format with timezone
162
- expected_limit_1 = "Sample.create!([\n {string: \"string\", text: \"text\", integer: 42, float: 3.14, decimal: \"2.72\", datetime: \"1776-07-04T19:14:00Z\", time: \"2000-01-01T03:15:00Z\", date: \"1863-11-19\", binary: \"binary\", boolean: false}\n])\n"
163
- expect(SeedDump.dump(Sample.limit(1))).to eq(expected_limit_1)
164
- end
165
-
166
- it 'should dump the number of models specified by the limit when the limit is larger than the batch size but not a multiple of the batch size' do
167
- # Create 4 samples (integer will be 42 from factory)
168
- 4.times { FactoryBot.create(:sample) }
169
- # Expecting first 3 records with batch_size: 2 -> 2 create! calls
170
- # First batch: 2 records, Second batch: 1 record
171
- sample_data = "{string: \"string\", text: \"text\", integer: 42, float: 3.14, decimal: \"2.72\", datetime: \"1776-07-04T19:14:00Z\", time: \"2000-01-01T03:15:00Z\", date: \"1863-11-19\", binary: \"binary\", boolean: false}"
172
- expected_limit_3 = "Sample.create!([\n #{sample_data},\n #{sample_data}\n])\n"
173
- expected_limit_3 += "Sample.create!([\n #{sample_data}\n])\n"
174
-
175
- expect(SeedDump.dump(Sample.limit(3), batch_size: 2)).to eq(expected_limit_3)
176
- end
177
- end
178
- end
179
-
180
- context 'with a batch_size parameter' do
181
- before(:each) { FactoryBot.create_list(:sample, 3) } # Create 3 standard samples
182
- it 'should not raise an exception' do
183
- expect { SeedDump.dump(Sample, batch_size: 100) }.not_to raise_error
184
- end
185
-
186
- it 'should not cause records to not be dumped' do
187
- expect(SeedDump.dump(Sample, batch_size: 2)).to include('string: "string"')
188
- expect(SeedDump.dump(Sample, batch_size: 1)).to include('string: "string"')
189
- end
190
-
191
- it 'should output separate create! calls for each batch (issue #127)' do
192
- result = SeedDump.dump(Sample, batch_size: 2)
193
- # With 3 records and batch_size: 2, we should have 2 create! calls:
194
- # - First batch with 2 records
195
- # - Second batch with 1 record
196
- expect(result.scan(/Sample\.create!\(/).count).to eq(2)
197
- end
198
-
199
- it 'should output all records in a single call when batch_size is larger than record count' do
200
- result = SeedDump.dump(Sample, batch_size: 100)
201
- # With 3 records and batch_size: 100, we should have 1 create! call
202
- expect(result.scan(/Sample\.create!\(/).count).to eq(1)
203
- end
204
-
205
- it 'should output one create! call per record when batch_size is 1' do
206
- result = SeedDump.dump(Sample, batch_size: 1)
207
- # With 3 records and batch_size: 1, we should have 3 create! calls
208
- expect(result.scan(/Sample\.create!\(/).count).to eq(3)
209
- end
210
- end
211
-
212
- context 'Array' do
213
- before(:each) { FactoryBot.create_list(:sample, 3) } # Create 3 standard samples
214
- it 'should return the dump of the models passed in' do
215
- # With batch_size: 2 and 3 records, we get 2 create! calls
216
- result = SeedDump.dump(Sample.all.to_a, batch_size: 2)
217
- expect(result).to include('Sample.create!')
218
- expect(result.scan(/Sample\.create!\(/).count).to eq(2)
219
- end
220
-
221
- it 'should return nil if the array is empty' do
222
- expect(SeedDump.dump([])).to be_nil
223
- end
224
- end
225
-
226
- context 'with an exclude parameter' do
227
- before(:each) { FactoryBot.create_list(:sample, 3) } # Create 3 standard samples
228
- it 'should exclude the specified attributes from the dump' do
229
- # Uses ISO 8601 format with timezone suffix (issue #111)
230
- expected_excluded_output = "Sample.create!([\n {text: \"text\", integer: 42, decimal: \"2.72\", time: \"2000-01-01T03:15:00Z\", date: \"1863-11-19\", binary: \"binary\", boolean: false},\n {text: \"text\", integer: 42, decimal: \"2.72\", time: \"2000-01-01T03:15:00Z\", date: \"1863-11-19\", binary: \"binary\", boolean: false},\n {text: \"text\", integer: 42, decimal: \"2.72\", time: \"2000-01-01T03:15:00Z\", date: \"1863-11-19\", binary: \"binary\", boolean: false}\n])\n"
231
- expect(SeedDump.dump(Sample, exclude: [:id, :created_at, :updated_at, :string, :float, :datetime])).to eq(expected_excluded_output)
232
- end
233
- end
234
-
235
- context 'Range' do
236
- let(:range_sample_mock) do
237
- mock_class = Class.new do
238
- def self.name; "RangeSample"; end
239
- def self.<(other); other == ActiveRecord::Base; end
240
- def attributes
241
- {
242
- "range_with_end_included" => (1..3),
243
- "range_with_end_excluded" => (1...3),
244
- "positive_infinite_range" => (1..Float::INFINITY),
245
- "negative_infinite_range" => (-Float::INFINITY..1),
246
- "infinite_range" => (-Float::INFINITY..Float::INFINITY)
247
- }
248
- end
249
- def attribute_names; attributes.keys; end
250
- end
251
- Object.const_set("RangeSample", mock_class) unless defined?(RangeSample)
252
- RangeSample.new
253
- end
254
-
255
- it 'should dump an object with ranges' do
256
- expected_range_output = "RangeSample.create!([\n {range_with_end_included: \"[1,3]\", range_with_end_excluded: \"[1,3)\", positive_infinite_range: \"[1,]\", negative_infinite_range: \"[,1]\", infinite_range: \"[,]\"}\n])\n"
257
- expect(SeedDump.dump([range_sample_mock])).to eq(expected_range_output)
258
- end
259
- end
260
-
261
- context 'ActionText::Content (issue #154)' do
262
- # Mock ActionText::Content class to simulate ActionText behavior
263
- before(:all) do
264
- unless defined?(ActionText::Content)
265
- module ActionText
266
- class Content
267
- def initialize(html)
268
- @html = html
269
- end
270
-
271
- def to_s
272
- @html
273
- end
274
-
275
- def inspect
276
- "#<ActionText::Content \"#{@html[0..20]}...\">"
277
- end
278
- end
279
- end
280
- end
281
- end
282
-
283
- let(:action_text_sample_mock) do
284
- mock_class = Class.new do
285
- def self.name; "ActionTextSample"; end
286
- def self.<(other); other == ActiveRecord::Base; end
287
- def is_a?(klass)
288
- return true if klass == ActiveRecord::Base
289
- super
290
- end
291
- def class
292
- ActionTextSample
293
- end
294
- def attributes
295
- {
296
- "name" => "article",
297
- "body" => ActionText::Content.new("<div>Hello <strong>World</strong></div>")
298
- }
299
- end
300
- def attribute_names; attributes.keys; end
301
- end
302
- Object.const_set("ActionTextSample", mock_class) unless defined?(ActionTextSample)
303
- ActionTextSample.new
304
- end
305
-
306
- it 'should dump ActionText::Content as its HTML string representation' do
307
- result = SeedDump.dump([action_text_sample_mock], exclude: [])
308
- expect(result).to include('body: "<div>Hello <strong>World</strong></div>"')
309
- expect(result).not_to include('#<ActionText::Content')
310
- end
311
- end
312
-
313
- context 'table without primary key (issue #167)' do
314
- before(:each) do
315
- CampaignsManager.create!(campaign_id: 1, manager_id: 1)
316
- CampaignsManager.create!(campaign_id: 2, manager_id: 2)
317
- end
318
-
319
- it 'should dump records without raising an error' do
320
- expect { SeedDump.dump(CampaignsManager) }.not_to raise_error
321
- end
322
-
323
- it 'should return the dump of the models' do
324
- result = SeedDump.dump(CampaignsManager, exclude: [])
325
- expect(result).to include('CampaignsManager.create!')
326
- expect(result).to include('campaign_id: 1')
327
- expect(result).to include('campaign_id: 2')
328
- end
329
- end
330
-
331
- context 'model with default_scope using select (issue #165)' do
332
- before(:each) do
333
- ScopedSelectSample.unscoped.create!(name: 'test1', description: 'desc1')
334
- ScopedSelectSample.unscoped.create!(name: 'test2', description: 'desc2')
335
- end
336
-
337
- it 'should dump records without raising a COUNT error' do
338
- expect { SeedDump.dump(ScopedSelectSample) }.not_to raise_error
339
- end
340
-
341
- it 'should return the dump of the models' do
342
- result = SeedDump.dump(ScopedSelectSample)
343
- expect(result).to include('ScopedSelectSample.create!')
344
- expect(result).to include('name: "test1"')
345
- expect(result).to include('name: "test2"')
346
- end
347
- end
348
-
349
- context 'activerecord-import' do
350
- before(:each) { FactoryBot.create_list(:sample, 3) } # Create 3 standard samples
351
- it 'should dump in the activerecord-import format when import is true' do
352
- expect(SeedDump.dump(Sample, import: true, exclude: [])).to eq(expected_import_output(false))
353
- end
354
-
355
- it 'should omit excluded columns if they are specified' do
356
- expect(SeedDump.dump(Sample, import: true, exclude: [:id, :created_at, :updated_at])).to eq(expected_import_output(true))
357
- end
358
-
359
- context 'should add the params to the output if they are specified' do
360
- it 'should dump in the activerecord-import format when import is true' do
361
- expect(SeedDump.dump(Sample, import: { validate: false }, exclude: [])).to eq(expected_import_output_with_options)
362
- end
363
- end
364
- end
365
-
366
- context 'insert_all (issue #153)' do
367
- before(:each) { FactoryBot.create_list(:sample, 3) } # Create 3 standard samples
368
-
369
- it 'should dump in the insert_all format when insert_all option is true' do
370
- expect(SeedDump.dump(Sample, insert_all: true)).to eq(expected_insert_all_output(true))
371
- end
372
-
373
- it 'should include all columns when exclude is empty' do
374
- expect(SeedDump.dump(Sample, insert_all: true, exclude: [])).to eq(expected_insert_all_output(false))
375
- end
376
-
377
- it 'should use Hash syntax (not Array syntax like activerecord-import)' do
378
- result = SeedDump.dump(Sample, insert_all: true)
379
- # insert_all uses Hash format: {key: value, ...}
380
- expect(result).to include('string: "string"')
381
- expect(result).not_to include('[:string') # Not array format
382
- end
383
-
384
- it 'should not include column names header like activerecord-import does' do
385
- result = SeedDump.dump(Sample, insert_all: true)
386
- # activerecord-import format includes: Model.import([:col1, :col2], [...])
387
- # insert_all format is just: Model.insert_all([{...}, {...}])
388
- expect(result).not_to match(/insert_all\(\[:\w+/)
389
- end
390
- end
391
-
392
- context 'upsert_all (issue #104 - non-continuous IDs / foreign key preservation)' do
393
- # Issue #104: When rows are deleted from a parent table, re-importing seeds
394
- # can result in foreign key references pointing to wrong records because
395
- # auto-increment IDs change. upsert_all solves this by preserving original IDs.
396
- #
397
- # upsert_all uses Rails 6+ upsert_all which can set specific IDs, so foreign
398
- # key references remain valid after reimport.
399
- before(:each) { FactoryBot.create_list(:sample, 3) }
400
-
401
- it 'should dump in the upsert_all format when upsert_all option is true' do
402
- result = SeedDump.dump(Sample, upsert_all: true)
403
- expect(result).to include('Sample.upsert_all(')
404
- expect(result).to include('id: 1')
405
- expect(result).to include('id: 2')
406
- expect(result).to include('id: 3')
407
- end
408
-
409
- it 'should include id column by default (unlike other dump modes)' do
410
- # upsert_all needs IDs to preserve foreign key relationships
411
- result = SeedDump.dump(Sample, upsert_all: true)
412
- expect(result).to include('id: 1')
413
- end
414
-
415
- it 'should still exclude created_at and updated_at by default' do
416
- result = SeedDump.dump(Sample, upsert_all: true)
417
- expect(result).not_to include('created_at')
418
- expect(result).not_to include('updated_at')
419
- end
420
-
421
- it 'should use Hash syntax like insert_all' do
422
- result = SeedDump.dump(Sample, upsert_all: true)
423
- expect(result).to include('string: "string"')
424
- expect(result).not_to include('[:string')
425
- end
426
-
427
- it 'should handle custom exclude that includes :id' do
428
- # If user explicitly excludes :id, respect that
429
- result = SeedDump.dump(Sample, upsert_all: true, exclude: [:id, :created_at, :updated_at])
430
- expect(result).not_to include('id:')
431
- end
432
-
433
- it 'should output separate upsert_all calls for each batch' do
434
- result = SeedDump.dump(Sample, upsert_all: true, batch_size: 2)
435
- expect(result.scan(/Sample\.upsert_all\(/).count).to eq(2)
436
- end
437
-
438
- context 'with foreign key relationships' do
439
- # The main use case: preserving foreign key relationships across reimports
440
- before(:each) do
441
- # Clear the automatically created samples from the outer before block
442
- Sample.delete_all
443
-
444
- # Create authors with specific IDs that might have gaps
445
- author1 = Author.create!(name: 'First Author')
446
- author2 = Author.create!(name: 'Second Author')
447
-
448
- # Create books referencing these authors
449
- Book.create!(title: 'Book by First', author: author1)
450
- Book.create!(title: 'Book by Second', author: author2)
451
- end
452
-
453
- it 'should preserve author IDs so book foreign keys remain valid' do
454
- result = SeedDump.dump(Author, upsert_all: true)
455
- expect(result).to include('id: 1')
456
- expect(result).to include('id: 2')
457
- expect(result).to include('upsert_all')
458
- end
459
-
460
- it 'should preserve foreign key references in child records' do
461
- result = SeedDump.dump(Book, upsert_all: true)
462
- expect(result).to include('author_id: 1')
463
- expect(result).to include('author_id: 2')
464
- end
465
- end
466
- end
467
-
468
- context 'HABTM join models (issue #130)' do
469
- # Rails creates private constants like `Model::HABTM_OtherModels` for
470
- # has_and_belongs_to_many associations. These cannot be referenced directly
471
- # in seeds.rb because they're private. We need to use const_get instead.
472
- #
473
- # Instead of: Dealer::HABTM_UStations.create!([...])
474
- # We output: Dealer.const_get('HABTM_UStations').create!([...])
475
-
476
- let(:habtm_mock_class) do
477
- Class.new do
478
- def self.name; "Dealer::HABTM_UStations"; end
479
- def self.<(other); other == ActiveRecord::Base; end
480
- def self.to_s; name; end
481
- end
482
- end
483
-
484
- let(:habtm_mock) do
485
- klass = habtm_mock_class
486
- mock_instance = Object.new
487
- mock_instance.define_singleton_method(:class) { klass }
488
- mock_instance.define_singleton_method(:is_a?) do |other|
489
- other == ActiveRecord::Base || super(other)
490
- end
491
- mock_instance.define_singleton_method(:attributes) do
492
- { "dealer_id" => 1, "ustation_id" => 2 }
493
- end
494
- mock_instance.define_singleton_method(:attribute_names) do
495
- ["dealer_id", "ustation_id"]
496
- end
497
- mock_instance
498
- end
499
-
500
- it 'should output const_get format for HABTM models' do
501
- result = SeedDump.dump([habtm_mock], exclude: [])
502
- # Should use const_get to access the private constant
503
- expect(result).to include("Dealer.const_get('HABTM_UStations').create!")
504
- expect(result).not_to include("Dealer::HABTM_UStations.create!")
505
- end
506
-
507
- it 'should include the record data' do
508
- result = SeedDump.dump([habtm_mock], exclude: [])
509
- expect(result).to include("dealer_id: 1")
510
- expect(result).to include("ustation_id: 2")
511
- end
512
-
513
- it 'should produce output that can be evaluated without NameError' do
514
- # Create a class structure with private constant to test const_get works
515
- # Use a plain Ruby class (not ActiveRecord) to avoid polluting AR.descendants
516
- test_parent = Class.new
517
- Object.const_set('TestDealerParent', test_parent)
518
-
519
- habtm_class = Class.new
520
- TestDealerParent.const_set('HABTM_Stations', habtm_class)
521
- TestDealerParent.send(:private_constant, 'HABTM_Stations')
522
-
523
- begin
524
- # Verify that const_get can access the private constant
525
- resolved_class = TestDealerParent.const_get('HABTM_Stations')
526
- expect(resolved_class).to eq(habtm_class)
527
-
528
- # Verify that direct reference WOULD fail (proving we need const_get)
529
- # Error message varies by Ruby/Rails version:
530
- # - "private constant" in newer versions
531
- # - "uninitialized constant" in older versions (private constants appear uninitialized)
532
- expect { eval("TestDealerParent::HABTM_Stations") }.to raise_error(NameError)
533
-
534
- # Now test that our dump output format works with the mock
535
- result = SeedDump.dump([habtm_mock], exclude: [])
536
- expect(result).to include("Dealer.const_get('HABTM_UStations').create!")
537
-
538
- # Verify the generated model reference pattern is syntactically valid Ruby
539
- # that would resolve correctly (we can't actually eval it without Dealer existing)
540
- expect(result).to match(/\w+\.const_get\('\w+'\)\.create!/)
541
- ensure
542
- TestDealerParent.send(:remove_const, 'HABTM_Stations') if TestDealerParent.const_defined?('HABTM_Stations', false)
543
- Object.send(:remove_const, 'TestDealerParent') if defined?(TestDealerParent)
544
- end
545
- end
546
-
547
- context 'with nested namespace' do
548
- let(:nested_habtm_mock_class) do
549
- Class.new do
550
- def self.name; "Admin::Dealers::Dealer::HABTM_UStations"; end
551
- def self.<(other); other == ActiveRecord::Base; end
552
- def self.to_s; name; end
553
- end
554
- end
555
-
556
- let(:nested_habtm_mock) do
557
- klass = nested_habtm_mock_class
558
- mock_instance = Object.new
559
- mock_instance.define_singleton_method(:class) { klass }
560
- mock_instance.define_singleton_method(:is_a?) do |other|
561
- other == ActiveRecord::Base || super(other)
562
- end
563
- mock_instance.define_singleton_method(:attributes) do
564
- { "dealer_id" => 1, "ustation_id" => 2 }
565
- end
566
- mock_instance.define_singleton_method(:attribute_names) do
567
- ["dealer_id", "ustation_id"]
568
- end
569
- mock_instance
570
- end
571
-
572
- it 'should handle deeply nested namespaces' do
573
- result = SeedDump.dump([nested_habtm_mock], exclude: [])
574
- expect(result).to include("Admin::Dealers::Dealer.const_get('HABTM_UStations').create!")
575
- end
576
- end
577
- end
578
-
579
- context 'serialized Hash in text field (issue #105)' do
580
- it 'should dump serialized fields as valid Ruby that can be loaded' do
581
- SerializedSample.create!(
582
- name: 'test',
583
- metadata: { 'key' => 'value', 'number' => 42, 'nested' => { 'a' => 1 } }
584
- )
585
- result = SeedDump.dump(SerializedSample)
586
- expect(result).to include('SerializedSample.create!')
587
- expect(result).to include('name: "test"')
588
-
589
- # The metadata field should be dumped as a valid Ruby Hash literal
590
- # Not as the raw JSON string or malformed output
591
- # Ruby's Hash#inspect uses ' => ' with spaces
592
- expect(result).to include('metadata: {"key" => "value"')
593
- expect(result).to include('"number" => 42')
594
- expect(result).to include('"nested" => {"a" => 1}')
595
- end
596
-
597
- it 'should produce output that can be evaluated as valid Ruby' do
598
- SerializedSample.create!(
599
- name: 'test',
600
- metadata: { 'key' => 'value', 'number' => 42, 'nested' => { 'a' => 1 } }
601
- )
602
- result = SeedDump.dump(SerializedSample)
603
- # The dump should produce valid Ruby syntax
604
- expect { eval(result) rescue NameError }.not_to raise_error
605
- end
606
-
607
- it 'should handle DateTime objects in serialized Hashes as ISO 8601 strings' do
608
- # The original issue #105 was about DateTime objects inside serialized Hashes
609
- # being output as unquoted datetime objects like: 2016-05-25 17:00:00 UTC
610
- # which isn't valid Ruby syntax. With JSON serialization, Rails stores these
611
- # as ISO 8601 strings in the database, which should be dumped correctly.
612
- SerializedSample.create!(
613
- name: 'audit_log',
614
- metadata: {
615
- 'event' => 'update',
616
- 'changed_at' => Time.utc(2016, 5, 25, 17, 0, 0).iso8601,
617
- 'changes' => { 'status' => ['pending', 'completed'] }
618
- }
619
- )
620
- result = SeedDump.dump(SerializedSample)
621
-
622
- # Should include the datetime as a quoted string
623
- expect(result).to include('"changed_at" => "2016-05-25T17:00:00Z"')
624
- # The output should be valid Ruby
625
- expect { eval(result) rescue NameError }.not_to raise_error
626
- end
627
-
628
- context 'with Time objects nested in Hashes' do
629
- # This tests the core issue #105: Time objects inside Hashes produce
630
- # invalid Ruby when .inspect is called on the Hash.
631
- # e.g. {"changed_at" => 2016-05-25 17:00:00 UTC} is not valid Ruby
632
- let(:hash_with_time_mock) do
633
- mock_class = Class.new do
634
- def self.name; "HashWithTimeSample"; end
635
- def self.<(other); other == ActiveRecord::Base; end
636
- def is_a?(klass)
637
- return true if klass == ActiveRecord::Base
638
- super
639
- end
640
- def class
641
- HashWithTimeSample
642
- end
643
- def attributes
644
- {
645
- "name" => "audit_log",
646
- # This Hash contains actual Time objects, which would be
647
- # the case with YAML-serialized fields in older Rails
648
- "metadata" => {
649
- "event" => "update",
650
- "changed_at" => Time.utc(2016, 5, 25, 17, 0, 0),
651
- "changes" => { "status" => ["pending", "completed"] }
652
- }
653
- }
654
- end
655
- def attribute_names; attributes.keys; end
656
- end
657
- Object.const_set("HashWithTimeSample", mock_class) unless defined?(HashWithTimeSample)
658
- HashWithTimeSample.new
659
- end
660
-
661
- it 'should produce valid Ruby when Hash contains Time objects' do
662
- result = SeedDump.dump([hash_with_time_mock], exclude: [])
663
-
664
- # The output should be valid Ruby syntax - this is the core bug
665
- # Without the fix, this produces: metadata: {"changed_at" => 2016-05-25 17:00:00 UTC}
666
- # which is a SyntaxError
667
- expect { eval(result) rescue NameError }.not_to raise_error
668
- end
669
-
670
- it 'should convert Time objects inside Hashes to ISO 8601 format' do
671
- result = SeedDump.dump([hash_with_time_mock], exclude: [])
672
-
673
- # Time objects should be converted to ISO 8601 strings
674
- expect(result).to match(/"changed_at" => "2016-05-25T17:00:00(\+00:00|Z)"/)
675
- end
676
- end
677
-
678
- context 'with BigDecimal objects nested in Hashes' do
679
- let(:hash_with_bigdecimal_mock) do
680
- mock_class = Class.new do
681
- def self.name; "HashWithBigDecimalSample"; end
682
- def self.<(other); other == ActiveRecord::Base; end
683
- def is_a?(klass)
684
- return true if klass == ActiveRecord::Base
685
- super
686
- end
687
- def class
688
- HashWithBigDecimalSample
689
- end
690
- def attributes
691
- {
692
- "name" => "pricing",
693
- "data" => {
694
- "price" => BigDecimal("19.99"),
695
- "tax_rate" => BigDecimal("0.08"),
696
- "nested" => { "discount" => BigDecimal("5.00") }
697
- }
698
- }
699
- end
700
- def attribute_names; attributes.keys; end
701
- end
702
- Object.const_set("HashWithBigDecimalSample", mock_class) unless defined?(HashWithBigDecimalSample)
703
- HashWithBigDecimalSample.new
704
- end
705
-
706
- it 'should produce valid Ruby when Hash contains BigDecimal objects' do
707
- result = SeedDump.dump([hash_with_bigdecimal_mock], exclude: [])
708
-
709
- # The output should be valid Ruby syntax
710
- expect { eval(result) rescue NameError }.not_to raise_error
711
- end
712
-
713
- it 'should convert BigDecimal objects inside Hashes to string format' do
714
- result = SeedDump.dump([hash_with_bigdecimal_mock], exclude: [])
715
-
716
- # BigDecimal objects should be converted to strings
717
- expect(result).to include('"price" => "19.99"')
718
- expect(result).to include('"tax_rate" => "0.08"')
719
- expect(result).to include('"discount" => "5.0"')
720
- end
721
- end
722
-
723
- context 'with mixed types nested in Arrays' do
724
- let(:array_with_mixed_types_mock) do
725
- mock_class = Class.new do
726
- def self.name; "ArrayWithMixedTypesSample"; end
727
- def self.<(other); other == ActiveRecord::Base; end
728
- def is_a?(klass)
729
- return true if klass == ActiveRecord::Base
730
- super
731
- end
732
- def class
733
- ArrayWithMixedTypesSample
734
- end
735
- def attributes
736
- {
737
- "name" => "events",
738
- "timestamps" => [
739
- Time.utc(2016, 1, 1, 0, 0, 0),
740
- Time.utc(2016, 6, 15, 12, 30, 0),
741
- Time.utc(2016, 12, 31, 23, 59, 59)
742
- ],
743
- "prices" => [
744
- BigDecimal("10.00"),
745
- BigDecimal("20.50"),
746
- BigDecimal("30.99")
747
- ]
748
- }
749
- end
750
- def attribute_names; attributes.keys; end
751
- end
752
- Object.const_set("ArrayWithMixedTypesSample", mock_class) unless defined?(ArrayWithMixedTypesSample)
753
- ArrayWithMixedTypesSample.new
754
- end
755
-
756
- it 'should produce valid Ruby when Array contains Time/BigDecimal objects' do
757
- result = SeedDump.dump([array_with_mixed_types_mock], exclude: [])
758
-
759
- # The output should be valid Ruby syntax
760
- expect { eval(result) rescue NameError }.not_to raise_error
761
- end
762
-
763
- it 'should convert Time objects inside Arrays to ISO 8601 format' do
764
- result = SeedDump.dump([array_with_mixed_types_mock], exclude: [])
765
-
766
- expect(result).to include('"2016-01-01T00:00:00Z"')
767
- expect(result).to include('"2016-06-15T12:30:00Z"')
768
- expect(result).to include('"2016-12-31T23:59:59Z"')
769
- end
770
-
771
- it 'should convert BigDecimal objects inside Arrays to string format' do
772
- result = SeedDump.dump([array_with_mixed_types_mock], exclude: [])
773
-
774
- expect(result).to include('"10.0"')
775
- expect(result).to include('"20.5"')
776
- expect(result).to include('"30.99"')
777
- end
778
- end
779
- end
780
-
781
- context 'DateTime timezone preservation (issue #111)' do
782
- let(:datetime_sample_mock) do
783
- mock_class = Class.new do
784
- def self.name; "DateTimeSample"; end
785
- def self.<(other); other == ActiveRecord::Base; end
786
- def is_a?(klass)
787
- return true if klass == ActiveRecord::Base
788
- super
789
- end
790
- def class
791
- DateTimeSample
792
- end
793
- def attributes
794
- {
795
- "name" => "test",
796
- # UTC datetime - should preserve timezone info in dump
797
- "scheduled_at" => Time.utc(2016, 8, 12, 2, 20, 20)
798
- }
799
- end
800
- def attribute_names; attributes.keys; end
801
- end
802
- Object.const_set("DateTimeSample", mock_class) unless defined?(DateTimeSample)
803
- DateTimeSample.new
804
- end
805
-
806
- it 'should include timezone information in datetime dumps' do
807
- result = SeedDump.dump([datetime_sample_mock], exclude: [])
808
- # The datetime should include timezone info (UTC) so it can be reimported correctly
809
- # Format should be ISO 8601: "2016-08-12T02:20:20Z" or similar with timezone
810
- expect(result).to match(/scheduled_at: "2016-08-12T02:20:20(\+00:00|Z)"/)
811
- end
812
-
813
- it 'should preserve non-UTC timezone information' do
814
- # Create a mock with a non-UTC timezone
815
- non_utc_mock_class = Class.new do
816
- def self.name; "NonUtcSample"; end
817
- def self.<(other); other == ActiveRecord::Base; end
818
- def is_a?(klass)
819
- return true if klass == ActiveRecord::Base
820
- super
821
- end
822
- def class
823
- NonUtcSample
824
- end
825
- def attributes
826
- {
827
- "name" => "test",
828
- # Pacific time (-08:00)
829
- "scheduled_at" => Time.new(2016, 8, 12, 2, 20, 20, "-08:00")
830
- }
831
- end
832
- def attribute_names; attributes.keys; end
833
- end
834
- Object.const_set("NonUtcSample", non_utc_mock_class) unless defined?(NonUtcSample)
835
- non_utc_sample = NonUtcSample.new
836
-
837
- result = SeedDump.dump([non_utc_sample], exclude: [])
838
- # Should include the timezone offset
839
- expect(result).to match(/scheduled_at: "2016-08-12T02:20:20-08:00"/)
840
- end
841
- end
842
-
843
- context 'CarrierWave uploader columns (issue #117)' do
844
- # CarrierWave mounts uploaders on models which override the attribute getter.
845
- # When record.attributes is called, it may return nil or an uploader object
846
- # instead of the raw filename string. We need to detect this and extract the identifier.
847
- #
848
- # The issue reports that CarrierWave columns "always dump to 'nil'" - this happens
849
- # because record.attributes bypasses the CarrierWave getter and returns the raw
850
- # @attributes value, which may be nil even when the uploader has a file.
851
-
852
- before(:all) do
853
- # Mock CarrierWave::Uploader::Base if not already defined
854
- unless defined?(CarrierWave::Uploader::Base)
855
- module CarrierWave
856
- module Uploader
857
- class Base
858
- attr_reader :identifier
859
-
860
- def initialize(identifier)
861
- @identifier = identifier
862
- end
863
-
864
- def inspect
865
- "#<CarrierWave::Uploader::Base identifier=#{@identifier.inspect}>"
866
- end
867
-
868
- def to_s
869
- # CarrierWave's to_s returns the URL, not the identifier
870
- "/uploads/#{@identifier}"
871
- end
872
- end
873
- end
874
- end
875
- end
876
- end
877
-
878
- context 'when record.attributes returns nil but getter returns uploader (the reported bug)' do
879
- # This is the actual bug reported in issue #117:
880
- # record.attributes['avatar'] returns nil, but record.avatar returns an uploader
881
- # with an identifier. We need to call the getter to get the real value.
882
- let(:nil_attributes_mock) do
883
- uploader = CarrierWave::Uploader::Base.new("avatar123.jpg")
884
- mock_class = Class.new do
885
- def self.name; "NilAttributesSample"; end
886
- def self.<(other); other == ActiveRecord::Base; end
887
- def is_a?(klass)
888
- return true if klass == ActiveRecord::Base
889
- super
890
- end
891
- def class
892
- NilAttributesSample
893
- end
894
- end
895
-
896
- Object.const_set("NilAttributesSample", mock_class) unless defined?(NilAttributesSample)
897
- instance = NilAttributesSample.new
898
-
899
- # record.attributes returns nil for the avatar column
900
- instance.define_singleton_method(:attributes) do
901
- { "name" => "user1", "avatar" => nil }
902
- end
903
- instance.define_singleton_method(:attribute_names) { ["name", "avatar"] }
904
-
905
- # But record.avatar returns the uploader with the actual filename
906
- instance.define_singleton_method(:avatar) { uploader }
907
-
908
- instance
909
- end
910
-
911
- it 'should dump the uploader identifier even when attributes returns nil' do
912
- result = SeedDump.dump([nil_attributes_mock], exclude: [])
913
- # Should include the filename from the uploader, not nil
914
- expect(result).to include('avatar: "avatar123.jpg"')
915
- expect(result).not_to include('avatar: nil')
916
- end
917
-
918
- it 'should produce valid Ruby' do
919
- result = SeedDump.dump([nil_attributes_mock], exclude: [])
920
- expect { eval(result) rescue NameError }.not_to raise_error
921
- end
922
- end
923
-
924
- context 'when record.attributes returns an uploader object directly' do
925
- let(:uploader_in_attributes_mock) do
926
- mock_class = Class.new do
927
- def self.name; "UploaderInAttributesSample"; end
928
- def self.<(other); other == ActiveRecord::Base; end
929
- def is_a?(klass)
930
- return true if klass == ActiveRecord::Base
931
- super
932
- end
933
- def class
934
- UploaderInAttributesSample
935
- end
936
- def attributes
937
- {
938
- "name" => "user1",
939
- # CarrierWave uploader object in the attributes hash
940
- "avatar" => CarrierWave::Uploader::Base.new("avatar456.jpg")
941
- }
942
- end
943
- def attribute_names; attributes.keys; end
944
- end
945
- Object.const_set("UploaderInAttributesSample", mock_class) unless defined?(UploaderInAttributesSample)
946
- UploaderInAttributesSample.new
947
- end
948
-
949
- it 'should dump CarrierWave uploader columns as the identifier string' do
950
- result = SeedDump.dump([uploader_in_attributes_mock], exclude: [])
951
- # Should include the filename, not the uploader object's inspect output
952
- expect(result).to include('avatar: "avatar456.jpg"')
953
- expect(result).not_to include('#<CarrierWave')
954
- expect(result).not_to include('/uploads/')
955
- end
956
- end
957
-
958
- context 'with no file uploaded (nil identifier)' do
959
- let(:no_file_mock) do
960
- uploader = CarrierWave::Uploader::Base.new(nil)
961
- mock_class = Class.new do
962
- def self.name; "NoFileSample"; end
963
- def self.<(other); other == ActiveRecord::Base; end
964
- def is_a?(klass)
965
- return true if klass == ActiveRecord::Base
966
- super
967
- end
968
- def class
969
- NoFileSample
970
- end
971
- end
972
- Object.const_set("NoFileSample", mock_class) unless defined?(NoFileSample)
973
- instance = NoFileSample.new
974
- instance.define_singleton_method(:attributes) do
975
- { "name" => "user2", "avatar" => nil }
976
- end
977
- instance.define_singleton_method(:attribute_names) { ["name", "avatar"] }
978
- instance.define_singleton_method(:avatar) { uploader }
979
- instance
980
- end
981
-
982
- it 'should handle CarrierWave uploaders with nil identifier' do
983
- result = SeedDump.dump([no_file_mock], exclude: [])
984
- expect(result).to include('avatar: nil')
985
- expect(result).not_to include('#<CarrierWave')
986
- end
987
- end
988
- end
989
-
990
- context 'comment header (issue #126)' do
991
- # Issue #126: Add a comment header to the seed file showing that seed_dump
992
- # was used and what options were specified. This helps with traceability
993
- # and understanding how the seed file was generated.
994
-
995
- let(:tempfile) { Tempfile.new(['seed_dump_test', '.rb']) }
996
- let(:filename) { tempfile.path }
997
-
998
- before(:each) { FactoryBot.create_list(:sample, 2) }
999
-
1000
- after do
1001
- tempfile.close
1002
- tempfile.unlink
1003
- end
1004
-
1005
- it 'should add a comment header when header option is true' do
1006
- SeedDump.dump(Sample, file: filename, header: true)
1007
- content = File.read(filename)
1008
- expect(content).to start_with('# ')
1009
- expect(content).to include('Generated by seed_dump')
1010
- end
1011
-
1012
- it 'should include a timestamp in the header' do
1013
- SeedDump.dump(Sample, file: filename, header: true)
1014
- content = File.read(filename)
1015
- # Should include date in some format (YYYY-MM-DD)
1016
- expect(content).to match(/\d{4}-\d{2}-\d{2}/)
1017
- end
1018
-
1019
- it 'should include a copyable rake command' do
1020
- SeedDump.dump(Sample, file: filename, header: true, exclude: [:id, :created_at], batch_size: 100)
1021
- content = File.read(filename)
1022
- expect(content).to include('Rake command:')
1023
- expect(content).to include('rake db:seed:dump')
1024
- expect(content).to include('EXCLUDE=id,created_at')
1025
- expect(content).to include('BATCH_SIZE=100')
1026
- expect(content).to include('HEADER=true')
1027
- end
1028
-
1029
- it 'should include a copyable programmatic equivalent' do
1030
- SeedDump.dump(Sample, file: filename, header: true, exclude: [:id, :created_at], batch_size: 100)
1031
- content = File.read(filename)
1032
- expect(content).to include('Programmatic equivalent:')
1033
- expect(content).to include('SeedDump.dump(ModelName,')
1034
- expect(content).to include('exclude: [:id, :created_at]')
1035
- expect(content).to include('batch_size: 100')
1036
- end
1037
-
1038
- it 'should not add header when header option is false or not set' do
1039
- SeedDump.dump(Sample, file: filename)
1040
- content = File.read(filename)
1041
- expect(content).not_to start_with('# ')
1042
- expect(content).to start_with('Sample.create!')
1043
- end
1044
-
1045
- it 'should not add header when appending' do
1046
- SeedDump.dump(Sample, file: filename, header: true)
1047
- SeedDump.dump(Sample, file: filename, header: true, append: true)
1048
- content = File.read(filename)
1049
- # Should only have one header at the top
1050
- expect(content.scan(/Generated by seed_dump/).count).to eq(1)
1051
- end
1052
-
1053
- it 'should not add header when returning a string (no file option)' do
1054
- result = SeedDump.dump(Sample, header: true)
1055
- expect(result).not_to include('Generated by seed_dump')
1056
- expect(result).to start_with('Sample.create!')
1057
- end
1058
-
1059
- it 'should include FILE in rake command when non-default' do
1060
- custom_file = Tempfile.new(['custom_seeds', '.rb'])
1061
- begin
1062
- SeedDump.dump(Sample, file: custom_file.path, header: true)
1063
- content = File.read(custom_file.path)
1064
- expect(content).to include("FILE=#{custom_file.path}")
1065
- ensure
1066
- custom_file.close
1067
- custom_file.unlink
1068
- end
1069
- end
1070
-
1071
- it 'should not include FILE in rake command when using default db/seeds.rb' do
1072
- SeedDump.dump(Sample, file: filename, header: true)
1073
- content = File.read(filename)
1074
- # The rake command should include FILE= for non-default paths
1075
- # but our tempfile is non-default, so let's verify format is correct
1076
- expect(content).to include('rake db:seed:dump')
1077
- end
1078
-
1079
- it 'should include special options like INSERT_ALL and UPSERT_ALL' do
1080
- SeedDump.dump(Sample, file: filename, header: true, insert_all: true)
1081
- content = File.read(filename)
1082
- expect(content).to include('INSERT_ALL=true')
1083
- expect(content).to include('insert_all: true')
1084
- end
1085
- end
1086
-
1087
- context 'created_on/updated_on columns (issue #128)' do
1088
- # Rails supports both created_at/updated_at AND created_on/updated_on as
1089
- # timestamp columns. Both should be excluded by default since they're
1090
- # auto-generated by Rails and should not be manually seeded.
1091
-
1092
- before(:each) do
1093
- TimestampOnSample.create!(name: 'test1')
1094
- TimestampOnSample.create!(name: 'test2')
1095
- end
1096
-
1097
- it 'should exclude created_on and updated_on columns by default' do
1098
- result = SeedDump.dump(TimestampOnSample)
1099
- expect(result).to include('TimestampOnSample.create!')
1100
- expect(result).to include('name: "test1"')
1101
- expect(result).to include('name: "test2"')
1102
- # These columns should be excluded by default (like created_at/updated_at)
1103
- expect(result).not_to include('created_on')
1104
- expect(result).not_to include('updated_on')
1105
- end
1106
-
1107
- it 'should include created_on/updated_on when explicitly excluded from exclude list' do
1108
- result = SeedDump.dump(TimestampOnSample, exclude: [:id])
1109
- expect(result).to include('created_on')
1110
- expect(result).to include('updated_on')
1111
- end
1112
- end
1113
- end
1114
- end