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