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.
@@ -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
- return nil if records.count == 0
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 = open_io(options)
9
-
10
- write_records_to_io(records, io, options)
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
- io.close if io.present?
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
- # We select only string attribute names to avoid conflict
22
- # with the composite_primary_keys gem (it returns composite
23
- # primary key attribute names as hashes).
24
- record.attributes.select {|key| key.is_a?(String) || key.is_a?(Symbol) }.each do |attribute, value|
25
- attribute_strings << dump_attribute_new(attribute, value, options) unless options[:exclude].include?(attribute.to_sym)
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(", ")}#{close_character}"
93
+ "#{open_character}#{attribute_strings.join(', ')}#{close_character}"
31
94
  end
32
95
 
33
- def dump_attribute_new(attribute, value, options)
34
- options[:import] ? value_to_s(value) : "#{attribute}: #{value_to_s(value)}"
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
- value = case value
39
- when BigDecimal, IPAddr
40
- value.to_s
41
- when Date, Time, DateTime
42
- value.to_s(:db)
43
- when Range
44
- range_to_string(value)
45
- when ->(v) { v.class.ancestors.map(&:to_s).include?('RGeo::Feature::Instance') }
46
- value.to_s
47
- else
48
- value
49
- end
50
-
51
- value.inspect
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
- from = object.begin.respond_to?(:infinite?) && object.begin.infinite? ? '' : object.begin
56
- to = object.end.respond_to?(:infinite?) && object.end.infinite? ? '' : object.end
57
- "[#{from},#{to}#{object.exclude_end? ? ')' : ']'}"
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
- mode = options[:append] ? 'a+' : 'w+'
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.new('', 'w+')
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
- options[:exclude] ||= [:id, :created_at, :updated_at]
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
- method = options[:import] ? 'import' : 'create!'
74
- io.write("#{model_for(records)}.#{method}(")
75
- if options[:import]
76
- io.write("[#{attribute_names(records, options).map {|name| name.to_sym.inspect}.join(', ')}], ")
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
- return unless options[:import] && options[:import].is_a?(Hash)
410
+ # Check if :import is a Hash containing options
411
+ return '' unless options[:import].is_a?(Hash) && options[:import].any?
104
412
 
105
- ', ' + options[:import].map { |key, value| "#{key}: #{value}" }.join(', ')
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
- attribute_names = if records.is_a?(ActiveRecord::Relation) || records.is_a?(Class)
110
- records.attribute_names
111
- else
112
- records[0].attribute_names
113
- end
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
- attribute_names.select {|name| !options[:exclude].include?(name.to_sym)}
435
+ # Filter out excluded attribute names
436
+ base_names.select { |name| !options[:exclude].include?(name.to_sym) }
116
437
  end
117
438
 
118
- def model_for(records)
119
- if records.is_a?(Class)
120
- records
121
- elsif records.respond_to?(:model)
122
- records.model
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
- records[0].class
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