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.
- checksums.yaml +4 -4
- data/.github/workflows/release.yml +38 -0
- data/Appraisals +47 -0
- data/Gemfile +23 -7
- data/README.md +53 -19
- data/VERSION +1 -1
- data/find_ruby_compat.sh +237 -0
- data/gemfiles/rails_6.1.gemfile +28 -0
- data/gemfiles/rails_6.1.gemfile.lock +150 -0
- data/gemfiles/rails_7.0.gemfile +28 -0
- data/gemfiles/rails_7.0.gemfile.lock +148 -0
- data/gemfiles/rails_7.1.gemfile +28 -0
- data/gemfiles/rails_7.1.gemfile.lock +161 -0
- data/gemfiles/rails_7.2.gemfile +28 -0
- data/gemfiles/rails_7.2.gemfile.lock +164 -0
- data/gemfiles/rails_8.0.gemfile +28 -0
- data/gemfiles/rails_8.0.gemfile.lock +163 -0
- data/lib/seed_dump/dump_methods/enumeration.rb +11 -3
- data/lib/seed_dump/dump_methods.rb +421 -64
- data/lib/seed_dump/environment.rb +294 -8
- data/seed_dump.gemspec +30 -29
- data/spec/dump_methods_spec.rb +1012 -74
- data/spec/environment_spec.rb +515 -46
- data/spec/factories/another_samples.rb +17 -10
- data/spec/factories/authors.rb +5 -0
- data/spec/factories/base_users.rb +16 -0
- data/spec/factories/books.rb +6 -0
- data/spec/factories/bosses.rb +5 -0
- data/spec/factories/reviews.rb +7 -0
- data/spec/factories/samples.rb +16 -12
- data/spec/factories/yet_another_samples.rb +17 -10
- data/spec/helpers.rb +169 -9
- data/spec/spec_helper.rb +28 -5
- metadata +46 -15
data/spec/dump_methods_spec.rb
CHANGED
|
@@ -2,175 +2,1113 @@ require 'spec_helper'
|
|
|
2
2
|
|
|
3
3
|
describe SeedDump do
|
|
4
4
|
|
|
5
|
-
|
|
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)
|
|
6
8
|
output = "Sample.create!([\n "
|
|
7
|
-
|
|
8
9
|
data = []
|
|
9
|
-
|
|
10
|
-
|
|
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}"
|
|
11
15
|
end
|
|
12
|
-
|
|
13
16
|
output + data.join(",\n ") + "\n])\n"
|
|
14
17
|
end
|
|
15
18
|
|
|
16
|
-
|
|
17
|
-
|
|
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
|
|
19
41
|
|
|
20
|
-
|
|
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
|
|
21
55
|
|
|
22
|
-
|
|
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(', ')}}"
|
|
23
69
|
end
|
|
70
|
+
output + data.join(",\n ") + "\n])\n"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
describe '.dump' do
|
|
24
75
|
|
|
25
76
|
context 'without file option' do
|
|
77
|
+
before(:each) { FactoryBot.create_list(:sample, 3) } # Create 3 standard samples
|
|
26
78
|
it 'should return the dump of the models passed in' do
|
|
27
|
-
SeedDump.dump(Sample).
|
|
79
|
+
expect(SeedDump.dump(Sample)).to eq(expected_output) # Expects 3 standard samples
|
|
28
80
|
end
|
|
29
81
|
end
|
|
30
82
|
|
|
31
83
|
context 'with file option' do
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
|
35
88
|
|
|
36
89
|
after do
|
|
37
|
-
|
|
90
|
+
tempfile.close
|
|
91
|
+
tempfile.unlink
|
|
38
92
|
end
|
|
39
93
|
|
|
40
94
|
it 'should dump the models to the specified file' do
|
|
41
|
-
SeedDump.dump(Sample, file:
|
|
42
|
-
|
|
43
|
-
File.open(@filename) { |file| file.read.should eq(expected_output) }
|
|
95
|
+
SeedDump.dump(Sample, file: filename)
|
|
96
|
+
expect(File.read(filename)).to eq(expected_output) # Expects 3 standard samples
|
|
44
97
|
end
|
|
45
98
|
|
|
46
99
|
context 'with append option' do
|
|
47
100
|
it 'should append to the file rather than overwriting it' do
|
|
48
|
-
|
|
49
|
-
SeedDump.dump(Sample, file:
|
|
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
|
|
50
117
|
|
|
51
|
-
|
|
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)
|
|
52
122
|
end
|
|
53
123
|
end
|
|
54
124
|
end
|
|
55
125
|
|
|
56
126
|
context 'ActiveRecord relation' do
|
|
57
127
|
it 'should return nil if the count is 0' do
|
|
58
|
-
SeedDump.dump(EmptyModel).
|
|
128
|
+
expect(SeedDump.dump(EmptyModel)).to be_nil
|
|
59
129
|
end
|
|
60
130
|
|
|
61
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
|
+
|
|
62
137
|
it 'should dump the models in the specified order' do
|
|
63
|
-
|
|
64
|
-
|
|
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"
|
|
65
145
|
|
|
66
|
-
SeedDump.dump(Sample.order('integer DESC')).
|
|
146
|
+
expect(SeedDump.dump(Sample.order('integer DESC'))).to eq(expected_desc_output)
|
|
67
147
|
end
|
|
68
148
|
end
|
|
69
149
|
|
|
70
150
|
context 'without an order parameter' do
|
|
151
|
+
before(:each) { FactoryBot.create_list(:sample, 3) } # Create 3 standard samples
|
|
71
152
|
it 'should dump the models sorted by primary key ascending' do
|
|
72
|
-
SeedDump.dump(Sample).
|
|
153
|
+
expect(SeedDump.dump(Sample)).to eq(expected_output) # Expects 3 standard samples
|
|
73
154
|
end
|
|
74
155
|
end
|
|
75
156
|
|
|
76
157
|
context 'with a limit parameter' do
|
|
77
158
|
it 'should dump the number of models specified by the limit when the limit is smaller than the batch size' do
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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)
|
|
81
164
|
end
|
|
82
165
|
|
|
83
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
|
|
84
|
-
|
|
167
|
+
# Create 4 samples (integer will be 42 from factory)
|
|
85
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"
|
|
86
174
|
|
|
87
|
-
SeedDump.dump(Sample.limit(3), batch_size: 2).
|
|
175
|
+
expect(SeedDump.dump(Sample.limit(3), batch_size: 2)).to eq(expected_limit_3)
|
|
88
176
|
end
|
|
89
177
|
end
|
|
90
178
|
end
|
|
91
179
|
|
|
92
180
|
context 'with a batch_size parameter' do
|
|
181
|
+
before(:each) { FactoryBot.create_list(:sample, 3) } # Create 3 standard samples
|
|
93
182
|
it 'should not raise an exception' do
|
|
94
|
-
SeedDump.dump(Sample, batch_size: 100)
|
|
183
|
+
expect { SeedDump.dump(Sample, batch_size: 100) }.not_to raise_error
|
|
95
184
|
end
|
|
96
185
|
|
|
97
186
|
it 'should not cause records to not be dumped' do
|
|
98
|
-
SeedDump.dump(Sample, batch_size: 2).
|
|
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
|
|
99
198
|
|
|
100
|
-
|
|
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)
|
|
101
209
|
end
|
|
102
210
|
end
|
|
103
211
|
|
|
104
212
|
context 'Array' do
|
|
213
|
+
before(:each) { FactoryBot.create_list(:sample, 3) } # Create 3 standard samples
|
|
105
214
|
it 'should return the dump of the models passed in' do
|
|
106
|
-
|
|
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)
|
|
107
219
|
end
|
|
108
220
|
|
|
109
221
|
it 'should return nil if the array is empty' do
|
|
110
|
-
SeedDump.dump([]).
|
|
222
|
+
expect(SeedDump.dump([])).to be_nil
|
|
111
223
|
end
|
|
112
224
|
end
|
|
113
225
|
|
|
114
226
|
context 'with an exclude parameter' do
|
|
227
|
+
before(:each) { FactoryBot.create_list(:sample, 3) } # Create 3 standard samples
|
|
115
228
|
it 'should exclude the specified attributes from the dump' do
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
SeedDump.dump(Sample, exclude: [:id, :created_at, :updated_at, :string, :float, :datetime]).
|
|
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)
|
|
119
232
|
end
|
|
120
233
|
end
|
|
121
234
|
|
|
122
235
|
context 'Range' do
|
|
123
|
-
|
|
124
|
-
|
|
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
|
|
125
254
|
|
|
126
|
-
|
|
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"')
|
|
127
346
|
end
|
|
128
347
|
end
|
|
129
348
|
|
|
130
349
|
context 'activerecord-import' do
|
|
350
|
+
before(:each) { FactoryBot.create_list(:sample, 3) } # Create 3 standard samples
|
|
131
351
|
it 'should dump in the activerecord-import format when import is true' do
|
|
132
|
-
SeedDump.dump(Sample, import: true, exclude: []).
|
|
133
|
-
Sample.import([:id, :string, :text, :integer, :float, :decimal, :datetime, :time, :date, :binary, :boolean, :created_at, :updated_at], [
|
|
134
|
-
[1, "string", "text", 42, 3.14, "2.72", "1776-07-04 19:14:00", "2000-01-01 03:15:00", "1863-11-19", "binary", false, "1969-07-20 20:18:00", "1989-11-10 04:20:00"],
|
|
135
|
-
[2, "string", "text", 42, 3.14, "2.72", "1776-07-04 19:14:00", "2000-01-01 03:15:00", "1863-11-19", "binary", false, "1969-07-20 20:18:00", "1989-11-10 04:20:00"],
|
|
136
|
-
[3, "string", "text", 42, 3.14, "2.72", "1776-07-04 19:14:00", "2000-01-01 03:15:00", "1863-11-19", "binary", false, "1969-07-20 20:18:00", "1989-11-10 04:20:00"]
|
|
137
|
-
])
|
|
138
|
-
RUBY
|
|
352
|
+
expect(SeedDump.dump(Sample, import: true, exclude: [])).to eq(expected_import_output(false))
|
|
139
353
|
end
|
|
140
354
|
|
|
141
355
|
it 'should omit excluded columns if they are specified' do
|
|
142
|
-
SeedDump.dump(Sample, import: true, exclude: [:id, :created_at, :updated_at]).
|
|
143
|
-
Sample.import([:string, :text, :integer, :float, :decimal, :datetime, :time, :date, :binary, :boolean], [
|
|
144
|
-
["string", "text", 42, 3.14, "2.72", "1776-07-04 19:14:00", "2000-01-01 03:15:00", "1863-11-19", "binary", false],
|
|
145
|
-
["string", "text", 42, 3.14, "2.72", "1776-07-04 19:14:00", "2000-01-01 03:15:00", "1863-11-19", "binary", false],
|
|
146
|
-
["string", "text", 42, 3.14, "2.72", "1776-07-04 19:14:00", "2000-01-01 03:15:00", "1863-11-19", "binary", false]
|
|
147
|
-
])
|
|
148
|
-
RUBY
|
|
356
|
+
expect(SeedDump.dump(Sample, import: true, exclude: [:id, :created_at, :updated_at])).to eq(expected_import_output(true))
|
|
149
357
|
end
|
|
150
358
|
|
|
151
359
|
context 'should add the params to the output if they are specified' do
|
|
152
360
|
it 'should dump in the activerecord-import format when import is true' do
|
|
153
|
-
SeedDump.dump(Sample, import: { validate: false }, exclude: []).
|
|
154
|
-
Sample.import([:id, :string, :text, :integer, :float, :decimal, :datetime, :time, :date, :binary, :boolean, :created_at, :updated_at], [
|
|
155
|
-
[1, "string", "text", 42, 3.14, "2.72", "1776-07-04 19:14:00", "2000-01-01 03:15:00", "1863-11-19", "binary", false, "1969-07-20 20:18:00", "1989-11-10 04:20:00"],
|
|
156
|
-
[2, "string", "text", 42, 3.14, "2.72", "1776-07-04 19:14:00", "2000-01-01 03:15:00", "1863-11-19", "binary", false, "1969-07-20 20:18:00", "1989-11-10 04:20:00"],
|
|
157
|
-
[3, "string", "text", 42, 3.14, "2.72", "1776-07-04 19:14:00", "2000-01-01 03:15:00", "1863-11-19", "binary", false, "1969-07-20 20:18:00", "1989-11-10 04:20:00"]
|
|
158
|
-
], validate: false)
|
|
159
|
-
RUBY
|
|
361
|
+
expect(SeedDump.dump(Sample, import: { validate: false }, exclude: [])).to eq(expected_import_output_with_options)
|
|
160
362
|
end
|
|
161
363
|
end
|
|
162
364
|
end
|
|
163
|
-
end
|
|
164
|
-
end
|
|
165
365
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
|
175
1113
|
end
|
|
176
1114
|
end
|