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
|
@@ -1,129 +1,486 @@
|
|
|
1
|
+
require 'bigdecimal'
|
|
2
|
+
require 'ipaddr'
|
|
3
|
+
|
|
4
|
+
require 'active_support/core_ext/object/try'
|
|
5
|
+
require 'active_support/core_ext/time/conversions'
|
|
6
|
+
require 'active_support/core_ext/date/conversions'
|
|
7
|
+
require 'active_support/core_ext/date_time/conversions'
|
|
8
|
+
|
|
1
9
|
class SeedDump
|
|
10
|
+
# Provides the core logic for dumping records.
|
|
2
11
|
module DumpMethods
|
|
3
12
|
include Enumeration
|
|
4
13
|
|
|
14
|
+
# Dumps a collection of records to a string or file.
|
|
15
|
+
#
|
|
16
|
+
# @param records [ActiveRecord::Relation, Class, Array<ActiveRecord::Base>] The records to dump.
|
|
17
|
+
# @param options [Hash] Options for dumping.
|
|
18
|
+
# @option options [String] :file Path to the output file. If nil, returns a string.
|
|
19
|
+
# @option options [Boolean] :append Append to the file instead of overwriting. Default: false.
|
|
20
|
+
# @option options [Integer] :batch_size Number of records per batch. Default: 1000.
|
|
21
|
+
# @option options [Array<Symbol>] :exclude Attributes to exclude. Default: [:id, :created_at, :updated_at, :created_on, :updated_on].
|
|
22
|
+
# @option options [Boolean, Hash] :import Use activerecord-import format. If Hash, passed as options to import. Default: false.
|
|
23
|
+
# @option options [Boolean] :insert_all Use Rails 6+ insert_all format for faster bulk inserts. Default: false.
|
|
24
|
+
# @option options [Boolean] :upsert_all Use Rails 6+ upsert_all format to preserve IDs and fix foreign key issues (issue #104). Default: false.
|
|
25
|
+
# @return [String, nil] The dump string if :file is nil, otherwise nil.
|
|
5
26
|
def dump(records, options = {})
|
|
6
|
-
|
|
27
|
+
# Handle potential empty input gracefully
|
|
28
|
+
# Use unscope(:select) for AR relations to avoid issues with default_scope
|
|
29
|
+
# that selects specific columns, which would cause COUNT(col1, col2, ...) errors
|
|
30
|
+
record_count = if records.respond_to?(:unscope)
|
|
31
|
+
records.unscope(:select).count
|
|
32
|
+
elsif records.respond_to?(:count)
|
|
33
|
+
records.count
|
|
34
|
+
elsif records.respond_to?(:empty?)
|
|
35
|
+
records.empty? ? 0 : 1
|
|
36
|
+
else
|
|
37
|
+
records.size
|
|
38
|
+
end
|
|
39
|
+
return nil if record_count == 0
|
|
7
40
|
|
|
8
|
-
io =
|
|
9
|
-
|
|
10
|
-
|
|
41
|
+
io = nil
|
|
42
|
+
begin
|
|
43
|
+
io = open_io(options)
|
|
44
|
+
write_records_to_io(records, io, options)
|
|
11
45
|
|
|
46
|
+
# If no file option was given (meaning we used StringIO), read the content
|
|
47
|
+
if options[:file].blank? # Check if :file option is nil or empty
|
|
48
|
+
io.rewind
|
|
49
|
+
io.read
|
|
50
|
+
else
|
|
51
|
+
# If a file option was given, return nil as the file was written directly
|
|
52
|
+
nil
|
|
53
|
+
end
|
|
12
54
|
ensure
|
|
13
|
-
|
|
55
|
+
# Ensure the IO object is closed if it's a File object
|
|
56
|
+
io.close if io.is_a?(File) && io.respond_to?(:close) && !io.closed?
|
|
57
|
+
end
|
|
14
58
|
end
|
|
15
59
|
|
|
16
60
|
private
|
|
17
61
|
|
|
62
|
+
# Dumps a single record to its string representation.
|
|
63
|
+
#
|
|
64
|
+
# @param record [ActiveRecord::Base] The record to dump.
|
|
65
|
+
# @param options [Hash] Dumping options (see #dump).
|
|
66
|
+
# @return [String] The string representation of the record.
|
|
18
67
|
def dump_record(record, options)
|
|
19
68
|
attribute_strings = []
|
|
20
69
|
|
|
21
|
-
#
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
70
|
+
# Ensure attributes is a Hash-like object responding to #each
|
|
71
|
+
unless record.respond_to?(:attributes) && record.attributes.respond_to?(:each)
|
|
72
|
+
raise ArgumentError, "Record object does not have an 'attributes' method returning an iterable collection."
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
record.attributes.each do |attribute, value|
|
|
76
|
+
# Ensure attribute key is usable (String or Symbol)
|
|
77
|
+
attr_sym = attribute.to_sym
|
|
78
|
+
# Exclude attributes specified in the options
|
|
79
|
+
next if options[:exclude].include?(attr_sym)
|
|
80
|
+
|
|
81
|
+
# Handle CarrierWave uploader columns (issue #117)
|
|
82
|
+
# record.attributes may return nil for uploader columns even when a file exists,
|
|
83
|
+
# because CarrierWave overrides the getter method but attributes bypasses it.
|
|
84
|
+
# Check if the attribute has a CarrierWave uploader and use its identifier instead.
|
|
85
|
+
actual_value = carrierwave_value(record, attribute, value)
|
|
86
|
+
|
|
87
|
+
attribute_strings << dump_attribute(attribute, actual_value, options)
|
|
26
88
|
end
|
|
27
89
|
|
|
90
|
+
# Determine the appropriate characters based on import option
|
|
28
91
|
open_character, close_character = options[:import] ? ['[', ']'] : ['{', '}']
|
|
29
92
|
|
|
30
|
-
"#{open_character}#{attribute_strings.join(
|
|
93
|
+
"#{open_character}#{attribute_strings.join(', ')}#{close_character}"
|
|
31
94
|
end
|
|
32
95
|
|
|
33
|
-
|
|
34
|
-
|
|
96
|
+
# Extracts the correct value for CarrierWave uploader columns (issue #117).
|
|
97
|
+
# CarrierWave overrides attribute getters, but record.attributes bypasses them,
|
|
98
|
+
# potentially returning nil even when a file exists. This method checks if the
|
|
99
|
+
# attribute has a CarrierWave uploader and returns the identifier (filename) instead.
|
|
100
|
+
#
|
|
101
|
+
# @param record [ActiveRecord::Base] The record being dumped.
|
|
102
|
+
# @param attribute [String] The attribute name.
|
|
103
|
+
# @param value [Object] The value from record.attributes.
|
|
104
|
+
# @return [Object] The identifier string if CarrierWave uploader, otherwise original value.
|
|
105
|
+
def carrierwave_value(record, attribute, value)
|
|
106
|
+
# If CarrierWave is not loaded, return the original value
|
|
107
|
+
return value unless defined?(CarrierWave::Uploader::Base)
|
|
108
|
+
|
|
109
|
+
# Check if calling the attribute getter returns a CarrierWave uploader
|
|
110
|
+
# This handles the case where record.attributes returns nil but the uploader has a file
|
|
111
|
+
if record.respond_to?(attribute)
|
|
112
|
+
getter_value = record.public_send(attribute)
|
|
113
|
+
if getter_value.is_a?(CarrierWave::Uploader::Base)
|
|
114
|
+
return getter_value.identifier
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# If the value itself is an uploader object (e.g., from a custom attributes method),
|
|
119
|
+
# extract the identifier
|
|
120
|
+
if value.is_a?(CarrierWave::Uploader::Base)
|
|
121
|
+
return value.identifier
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
value
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Formats a single attribute key-value pair or just the value for dumping.
|
|
128
|
+
#
|
|
129
|
+
# @param attribute [String, Symbol] The attribute name.
|
|
130
|
+
# @param value [Object] The attribute value.
|
|
131
|
+
# @param options [Hash] Dumping options.
|
|
132
|
+
# @return [String] The formatted attribute string.
|
|
133
|
+
def dump_attribute(attribute, value, options)
|
|
134
|
+
formatted_value = value_to_s(value)
|
|
135
|
+
# If importing, just output the value; otherwise, output key: value
|
|
136
|
+
options[:import] ? formatted_value : "#{attribute}: #{formatted_value}"
|
|
35
137
|
end
|
|
36
138
|
|
|
139
|
+
# Converts a value to its string representation suitable for seeding.
|
|
140
|
+
# Handles various data types like BigDecimal, IPAddr, Date/Time, Range, and RGeo.
|
|
141
|
+
# Also recursively processes Hash and Array values to handle nested types (issue #105).
|
|
142
|
+
#
|
|
143
|
+
# @param value [Object] The value to convert.
|
|
144
|
+
# @return [String] The inspected string representation of the value.
|
|
37
145
|
def value_to_s(value)
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
146
|
+
formatted_value = case value
|
|
147
|
+
when BigDecimal, IPAddr
|
|
148
|
+
# Use standard to_s for these types
|
|
149
|
+
value.to_s
|
|
150
|
+
when ->(v) { defined?(ActionText::Content) && v.is_a?(ActionText::Content) }
|
|
151
|
+
# ActionText::Content should be dumped as its HTML string (issue #154)
|
|
152
|
+
value.to_s
|
|
153
|
+
when Date, Time, DateTime
|
|
154
|
+
# Use ISO 8601 format to preserve timezone information (issue #111)
|
|
155
|
+
# This prevents timestamp shifts when reimporting seeds on machines
|
|
156
|
+
# with different timezones
|
|
157
|
+
value.iso8601
|
|
158
|
+
when Range
|
|
159
|
+
# Convert range to a specific string format
|
|
160
|
+
range_to_string(value)
|
|
161
|
+
when ->(v) { defined?(RGeo::Feature::Instance) && v.is_a?(RGeo::Feature::Instance) }
|
|
162
|
+
# Handle RGeo geometry types if RGeo is loaded
|
|
163
|
+
value.to_s # RGeo objects often have a suitable WKT representation via to_s
|
|
164
|
+
when Hash
|
|
165
|
+
# Recursively process Hash values to handle nested types (issue #105)
|
|
166
|
+
# This ensures Time objects inside Hashes are converted to ISO 8601
|
|
167
|
+
normalize_hash_for_dump(value)
|
|
168
|
+
when Array
|
|
169
|
+
# Recursively process Array elements to handle nested types (issue #105)
|
|
170
|
+
normalize_array_for_dump(value)
|
|
171
|
+
else
|
|
172
|
+
# For other types, use the value directly (inspect will handle basic types)
|
|
173
|
+
value
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Use inspect to get a string representation suitable for Ruby code
|
|
177
|
+
# (e.g., strings are quoted, nil becomes "nil").
|
|
178
|
+
formatted_value.inspect
|
|
52
179
|
end
|
|
53
180
|
|
|
181
|
+
# Converts a Range object to a string representation like "[start,end]" or "[start,end)".
|
|
182
|
+
# Handles infinite ranges gracefully.
|
|
183
|
+
#
|
|
184
|
+
# @param object [Range] The range to convert.
|
|
185
|
+
# @return [String] The string representation of the range.
|
|
54
186
|
def range_to_string(object)
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
187
|
+
# Determine start: empty string if negative infinity, otherwise the beginning value.
|
|
188
|
+
from = object.begin.respond_to?(:infinite?) && object.begin.infinite? && object.begin < 0 ? '' : object.begin
|
|
189
|
+
# Determine end: empty string if positive infinity, otherwise the ending value.
|
|
190
|
+
to = object.end.respond_to?(:infinite?) && object.end.infinite? && object.end > 0 ? '' : object.end
|
|
191
|
+
# Determine closing bracket: ')' if end is excluded, ']' otherwise.
|
|
192
|
+
bracket = object.exclude_end? ? ')' : ']'
|
|
193
|
+
|
|
194
|
+
"[#{from},#{to}#{bracket}"
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Recursively normalizes a Hash for dumping, converting any embedded
|
|
198
|
+
# Time/DateTime/Date/BigDecimal values to strings that produce valid Ruby.
|
|
199
|
+
# This fixes issue #105 where serialized Hashes containing Time objects
|
|
200
|
+
# would produce invalid Ruby like: {"changed_at" => 2016-05-25 17:00:00 UTC}
|
|
201
|
+
#
|
|
202
|
+
# @param hash [Hash] The hash to normalize.
|
|
203
|
+
# @return [Hash] A new hash with normalized values.
|
|
204
|
+
def normalize_hash_for_dump(hash)
|
|
205
|
+
hash.transform_values { |v| normalize_value_for_dump(v) }
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Recursively normalizes an Array for dumping, converting any embedded
|
|
209
|
+
# Time/DateTime/Date/BigDecimal values to strings that produce valid Ruby.
|
|
210
|
+
#
|
|
211
|
+
# @param array [Array] The array to normalize.
|
|
212
|
+
# @return [Array] A new array with normalized values.
|
|
213
|
+
def normalize_array_for_dump(array)
|
|
214
|
+
array.map { |v| normalize_value_for_dump(v) }
|
|
58
215
|
end
|
|
59
216
|
|
|
217
|
+
# Normalizes a single value for embedding in a Hash or Array dump.
|
|
218
|
+
# Converts types that don't have valid .inspect output to strings.
|
|
219
|
+
#
|
|
220
|
+
# @param value [Object] The value to normalize.
|
|
221
|
+
# @return [Object] The normalized value.
|
|
222
|
+
def normalize_value_for_dump(value)
|
|
223
|
+
case value
|
|
224
|
+
when BigDecimal, IPAddr
|
|
225
|
+
value.to_s
|
|
226
|
+
when Date, Time, DateTime
|
|
227
|
+
value.iso8601
|
|
228
|
+
when Hash
|
|
229
|
+
normalize_hash_for_dump(value)
|
|
230
|
+
when Array
|
|
231
|
+
normalize_array_for_dump(value)
|
|
232
|
+
else
|
|
233
|
+
value
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# Opens an IO object for writing (either a File or StringIO).
|
|
238
|
+
#
|
|
239
|
+
# @param options [Hash] Options containing :file and :append keys.
|
|
240
|
+
# @return [IO] The opened IO object (File or StringIO).
|
|
60
241
|
def open_io(options)
|
|
61
242
|
if options[:file].present?
|
|
62
|
-
|
|
63
|
-
|
|
243
|
+
# Open file in append ('a') or write ('w') mode
|
|
244
|
+
# Note: We use write-only modes (not 'a+' or 'w+') because read capability
|
|
245
|
+
# requires the file to be seekable, which fails for pipes like /dev/stdout
|
|
246
|
+
# when output is redirected (issue #150)
|
|
247
|
+
mode = options[:append] ? 'a' : 'w'
|
|
64
248
|
File.open(options[:file], mode)
|
|
65
249
|
else
|
|
66
|
-
StringIO
|
|
250
|
+
# Use StringIO for in-memory operations with a mutable string
|
|
251
|
+
StringIO.new(+'', 'w+')
|
|
67
252
|
end
|
|
68
253
|
end
|
|
69
254
|
|
|
255
|
+
# Writes a comment header to the IO stream if the :header option is true,
|
|
256
|
+
# a file is being written (not StringIO), and we're not appending.
|
|
257
|
+
# This helps with traceability by showing when and how the seed file was generated (issue #126).
|
|
258
|
+
#
|
|
259
|
+
# @param io [IO] The IO object to write to.
|
|
260
|
+
# @param options [Hash] Dumping options.
|
|
261
|
+
# @return [void]
|
|
262
|
+
def write_header_comment(io, options)
|
|
263
|
+
# Only write header if:
|
|
264
|
+
# 1. header option is true
|
|
265
|
+
# 2. Writing to a file (not returning a string)
|
|
266
|
+
# 3. Not appending (header should only appear at the top of the file)
|
|
267
|
+
return unless options[:header] && options[:file].present? && !options[:append]
|
|
268
|
+
|
|
269
|
+
timestamp = Time.now.strftime('%Y-%m-%d %H:%M:%S')
|
|
270
|
+
|
|
271
|
+
io.write("# Generated by seed_dump on #{timestamp}\n")
|
|
272
|
+
io.write("#\n")
|
|
273
|
+
io.write("# Rake command:\n")
|
|
274
|
+
io.write("# #{build_rake_command(options)}\n")
|
|
275
|
+
io.write("#\n")
|
|
276
|
+
io.write("# Programmatic equivalent:\n")
|
|
277
|
+
io.write("# #{build_programmatic_command(options)}\n")
|
|
278
|
+
io.write("#\n\n")
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# Builds an equivalent rake command string from the options hash.
|
|
282
|
+
#
|
|
283
|
+
# @param options [Hash] The options used for dumping.
|
|
284
|
+
# @return [String] A rake command that would produce the same output.
|
|
285
|
+
def build_rake_command(options)
|
|
286
|
+
parts = ['rake db:seed:dump']
|
|
287
|
+
|
|
288
|
+
# Map option keys to environment variable names
|
|
289
|
+
env_vars = []
|
|
290
|
+
env_vars << "FILE=#{options[:file]}" if options[:file].present? && options[:file] != 'db/seeds.rb'
|
|
291
|
+
env_vars << 'HEADER=true' # Always include since we're generating a header
|
|
292
|
+
|
|
293
|
+
if options[:exclude].is_a?(Array) && options[:exclude].any?
|
|
294
|
+
env_vars << "EXCLUDE=#{options[:exclude].join(',')}"
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
env_vars << "BATCH_SIZE=#{options[:batch_size]}" if options[:batch_size]
|
|
298
|
+
env_vars << 'IMPORT=true' if options[:import]
|
|
299
|
+
env_vars << 'INSERT_ALL=true' if options[:insert_all]
|
|
300
|
+
env_vars << 'UPSERT_ALL=true' if options[:upsert_all]
|
|
301
|
+
|
|
302
|
+
(parts + env_vars).join(' ')
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# Builds an equivalent programmatic SeedDump.dump call from the options hash.
|
|
306
|
+
#
|
|
307
|
+
# @param options [Hash] The options used for dumping.
|
|
308
|
+
# @return [String] A Ruby code snippet that would produce the same output.
|
|
309
|
+
def build_programmatic_command(options)
|
|
310
|
+
# Build options hash for display, excluding nil/false/default values
|
|
311
|
+
display_options = {}
|
|
312
|
+
display_options[:file] = options[:file] if options[:file].present?
|
|
313
|
+
display_options[:header] = true
|
|
314
|
+
|
|
315
|
+
if options[:exclude].is_a?(Array) && options[:exclude].any?
|
|
316
|
+
display_options[:exclude] = options[:exclude]
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
display_options[:batch_size] = options[:batch_size] if options[:batch_size]
|
|
320
|
+
display_options[:import] = true if options[:import]
|
|
321
|
+
display_options[:insert_all] = true if options[:insert_all]
|
|
322
|
+
display_options[:upsert_all] = true if options[:upsert_all]
|
|
323
|
+
|
|
324
|
+
options_str = display_options.map do |key, value|
|
|
325
|
+
formatted_value = case value
|
|
326
|
+
when Array
|
|
327
|
+
"[#{value.map(&:inspect).join(', ')}]"
|
|
328
|
+
when String
|
|
329
|
+
value.inspect
|
|
330
|
+
else
|
|
331
|
+
value.inspect
|
|
332
|
+
end
|
|
333
|
+
"#{key}: #{formatted_value}"
|
|
334
|
+
end.join(', ')
|
|
335
|
+
|
|
336
|
+
"SeedDump.dump(ModelName, #{options_str})"
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
# Writes the records to the given IO object, handling batching and formatting.
|
|
340
|
+
#
|
|
341
|
+
# Each batch is written as a separate method call (create!, import, insert_all, or upsert_all).
|
|
342
|
+
# This addresses issue #127 where BATCH_SIZE was not producing separate calls.
|
|
343
|
+
#
|
|
344
|
+
# @param records [ActiveRecord::Relation, Class, Array<ActiveRecord::Base>] The records to write.
|
|
345
|
+
# @param io [IO] The IO object to write to (File or StringIO).
|
|
346
|
+
# @param options [Hash] Dumping options.
|
|
347
|
+
# @return [void] This method now only writes to the IO. Reading happens in #dump.
|
|
70
348
|
def write_records_to_io(records, io, options)
|
|
71
|
-
|
|
349
|
+
# Write header comment if requested (issue #126)
|
|
350
|
+
write_header_comment(io, options)
|
|
351
|
+
|
|
352
|
+
# Set default excluded attributes if not provided
|
|
353
|
+
# For upsert_all, we need to include :id by default to preserve foreign key relationships
|
|
354
|
+
# (issue #104), so we use a different default exclude list
|
|
355
|
+
if options[:upsert_all] && !options.key?(:exclude)
|
|
356
|
+
options[:exclude] = [:created_at, :updated_at, :created_on, :updated_on]
|
|
357
|
+
else
|
|
358
|
+
options[:exclude] ||= [:id, :created_at, :updated_at, :created_on, :updated_on]
|
|
359
|
+
end
|
|
360
|
+
# Ensure exclude is an array of symbols
|
|
361
|
+
options[:exclude] = options[:exclude].map(&:to_sym)
|
|
72
362
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
363
|
+
# Determine the model class
|
|
364
|
+
model_klass = model_for(records)
|
|
365
|
+
unless model_klass
|
|
366
|
+
# Raise ArgumentError if model cannot be determined
|
|
367
|
+
raise ArgumentError, "Could not determine model class from records."
|
|
77
368
|
end
|
|
78
|
-
io.write("[\n ")
|
|
79
369
|
|
|
370
|
+
# Determine the method call ('import', 'insert_all', 'upsert_all', or 'create!')
|
|
371
|
+
method = if options[:import]
|
|
372
|
+
'import'
|
|
373
|
+
elsif options[:insert_all]
|
|
374
|
+
'insert_all'
|
|
375
|
+
elsif options[:upsert_all]
|
|
376
|
+
'upsert_all'
|
|
377
|
+
else
|
|
378
|
+
'create!'
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
# Prepare import column header if using activerecord-import
|
|
382
|
+
import_header = if options[:import]
|
|
383
|
+
column_names = attribute_names(records, options).map { |name| name.to_sym.inspect }
|
|
384
|
+
"[#{column_names.join(', ')}], "
|
|
385
|
+
else
|
|
386
|
+
''
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
# Choose the appropriate enumeration method based on record type
|
|
80
390
|
enumeration_method = if records.is_a?(ActiveRecord::Relation) || records.is_a?(Class)
|
|
81
391
|
:active_record_enumeration
|
|
82
392
|
else
|
|
83
393
|
:enumerable_enumeration
|
|
84
394
|
end
|
|
85
395
|
|
|
396
|
+
# Process records in batches, writing each batch as a separate method call
|
|
86
397
|
send(enumeration_method, records, io, options) do |record_strings, last_batch|
|
|
398
|
+
# Write complete method call for this batch
|
|
399
|
+
io.write("#{model_name_for_output(model_klass)}.#{method}(#{import_header}[\n ")
|
|
87
400
|
io.write(record_strings.join(",\n "))
|
|
88
|
-
|
|
89
|
-
io.write(",\n ") unless last_batch
|
|
90
|
-
end
|
|
91
|
-
|
|
92
|
-
io.write("\n]#{active_record_import_options(options)})\n")
|
|
93
|
-
|
|
94
|
-
if options[:file].present?
|
|
95
|
-
nil
|
|
96
|
-
else
|
|
97
|
-
io.rewind
|
|
98
|
-
io.read
|
|
401
|
+
io.write("\n]#{active_record_import_options(options)})\n")
|
|
99
402
|
end
|
|
100
403
|
end
|
|
101
404
|
|
|
405
|
+
# Generates the string for activerecord-import options, if provided.
|
|
406
|
+
#
|
|
407
|
+
# @param options [Hash] Dumping options, potentially containing :import hash.
|
|
408
|
+
# @return [String] Formatted options string or empty string.
|
|
102
409
|
def active_record_import_options(options)
|
|
103
|
-
|
|
410
|
+
# Check if :import is a Hash containing options
|
|
411
|
+
return '' unless options[:import].is_a?(Hash) && options[:import].any?
|
|
104
412
|
|
|
105
|
-
|
|
413
|
+
# Format the options hash into a string like ", key1: value1, key2: value2"
|
|
414
|
+
options_string = options[:import].map { |key, value| "#{key}: #{value.inspect}" }.join(', ')
|
|
415
|
+
", #{options_string}" # Prepend comma and space
|
|
106
416
|
end
|
|
107
417
|
|
|
418
|
+
# Gets the attribute names for the records, excluding specified ones.
|
|
419
|
+
#
|
|
420
|
+
# @param records [ActiveRecord::Relation, Class, Array<ActiveRecord::Base>] The records source.
|
|
421
|
+
# @param options [Hash] Options containing :exclude array.
|
|
422
|
+
# @return [Array<String>] Filtered attribute names.
|
|
108
423
|
def attribute_names(records, options)
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
424
|
+
# Get attribute names from the model or the first record
|
|
425
|
+
base_names = if records.is_a?(ActiveRecord::Relation) || records.is_a?(Class)
|
|
426
|
+
model = model_for(records)
|
|
427
|
+
# Ensure model is not nil before calling attribute_names
|
|
428
|
+
model&.respond_to?(:attribute_names) ? model.attribute_names : []
|
|
429
|
+
elsif records.is_a?(Array) && records.first.respond_to?(:attribute_names)
|
|
430
|
+
records.first.attribute_names
|
|
431
|
+
else
|
|
432
|
+
[] # Cannot determine attribute names
|
|
433
|
+
end
|
|
114
434
|
|
|
115
|
-
|
|
435
|
+
# Filter out excluded attribute names
|
|
436
|
+
base_names.select { |name| !options[:exclude].include?(name.to_sym) }
|
|
116
437
|
end
|
|
117
438
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
439
|
+
# Formats the model name for output in the generated seed file.
|
|
440
|
+
# For HABTM join models (which are private constants), uses const_get syntax
|
|
441
|
+
# to avoid NameError when the seeds file is loaded.
|
|
442
|
+
#
|
|
443
|
+
# @param model_klass [Class] The model class.
|
|
444
|
+
# @return [String] The formatted model name for use in seeds.rb.
|
|
445
|
+
def model_name_for_output(model_klass)
|
|
446
|
+
model_name = model_klass.to_s
|
|
447
|
+
|
|
448
|
+
# Check if this is an HABTM join model (contains ::HABTM_)
|
|
449
|
+
# e.g., "Dealer::HABTM_UStations" -> "Dealer.const_get('HABTM_UStations')"
|
|
450
|
+
if model_name.include?('::HABTM_')
|
|
451
|
+
# Split on the last ::HABTM_ to handle nested namespaces
|
|
452
|
+
# e.g., "Foo::Bar::HABTM_Bazs" -> parent="Foo::Bar", habtm_name="HABTM_Bazs"
|
|
453
|
+
parts = model_name.rpartition('::')
|
|
454
|
+
parent = parts[0] # Everything before the last ::
|
|
455
|
+
habtm_name = parts[2] # The HABTM_* part
|
|
456
|
+
"#{parent}.const_get('#{habtm_name}')"
|
|
123
457
|
else
|
|
124
|
-
|
|
458
|
+
model_name
|
|
125
459
|
end
|
|
126
460
|
end
|
|
127
461
|
|
|
462
|
+
# Determines the ActiveRecord model class from the given records source.
|
|
463
|
+
#
|
|
464
|
+
# @param records [ActiveRecord::Relation, Class, Array<ActiveRecord::Base>] The records source.
|
|
465
|
+
# @return [Class, nil] The model class or nil if indeterminable.
|
|
466
|
+
def model_for(records)
|
|
467
|
+
if records.is_a?(Class) && records < ActiveRecord::Base
|
|
468
|
+
records # It's the model class itself
|
|
469
|
+
elsif records.respond_to?(:klass) # ActiveRecord::Relation often uses .klass
|
|
470
|
+
records.klass
|
|
471
|
+
elsif records.respond_to?(:model) # Some older versions might use .model
|
|
472
|
+
records.model
|
|
473
|
+
elsif records.is_a?(Array) && !records.empty?
|
|
474
|
+
# Check if the first element is an AR::Base instance or the RangeSample mock
|
|
475
|
+
first_element = records.first
|
|
476
|
+
if first_element.is_a?(ActiveRecord::Base) || (first_element.respond_to?(:class) && first_element.class.name == "RangeSample")
|
|
477
|
+
first_element.class
|
|
478
|
+
else
|
|
479
|
+
nil # Cannot determine model from array elements
|
|
480
|
+
end
|
|
481
|
+
else
|
|
482
|
+
nil # Could not determine model
|
|
483
|
+
end
|
|
484
|
+
end
|
|
128
485
|
end
|
|
129
486
|
end
|