smarter_csv 1.18.0 → 1.19.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/CHANGELOG.md +85 -0
- data/CONTRIBUTORS.md +2 -1
- data/README.md +24 -1
- data/UPGRADING.md +22 -7
- data/docs/_introduction.md +22 -0
- data/docs/bad_row_quarantine.md +3 -1
- data/docs/basic_read_api.md +1 -1
- data/docs/data_transformations.md +6 -2
- data/docs/header_transformations.md +3 -1
- data/docs/migrating_from_csv.md +1 -1
- data/docs/options.md +3 -3
- data/docs/real_world_csv.md +1 -0
- data/docs/upgrade_path.json +35 -2
- data/docs/upgrade_wizard.html +16 -3
- data/ext/smarter_csv/cpu_flags.rb +55 -0
- data/ext/smarter_csv/extconf.rb +23 -2
- data/ext/smarter_csv/smarter_csv.c +153 -57
- data/lib/smarter_csv/hash_transformations.rb +16 -15
- data/lib/smarter_csv/header_transformations.rb +14 -1
- data/lib/smarter_csv/headers.rb +16 -1
- data/lib/smarter_csv/parser.rb +59 -11
- data/lib/smarter_csv/reader.rb +126 -62
- data/lib/smarter_csv/reader_options.rb +44 -6
- data/lib/smarter_csv/version.rb +1 -1
- data/lib/smarter_csv/writer.rb +3 -3
- metadata +4 -3
data/lib/smarter_csv/parser.rb
CHANGED
|
@@ -169,6 +169,24 @@ module SmarterCSV
|
|
|
169
169
|
def parse_line_to_hash_ruby(line, headers, options, has_quotes = false)
|
|
170
170
|
return [nil, 0] if line.nil?
|
|
171
171
|
|
|
172
|
+
# A line with invalid bytes for its encoding (typically Latin-1 data mislabeled as
|
|
173
|
+
# UTF-8) would make the encoding-aware operations below (split, strip!, ...) raise
|
|
174
|
+
# ArgumentError. Like the C path, we parse leniently and preserve the field's raw
|
|
175
|
+
# bytes: process the line as BINARY, then re-tag each field with the original
|
|
176
|
+
# encoding. Only relabels — never transcodes. Every byte sequence is valid BINARY,
|
|
177
|
+
# so the recursive call cannot take this branch again.
|
|
178
|
+
unless line.valid_encoding?
|
|
179
|
+
original_encoding = line.encoding
|
|
180
|
+
binary_options = options.dup
|
|
181
|
+
%i[col_sep quote_char row_sep].each do |opt|
|
|
182
|
+
binary_options[opt] = options[opt].dup.force_encoding(Encoding::BINARY) if options[opt].is_a?(String)
|
|
183
|
+
end
|
|
184
|
+
hash, data_size = parse_line_to_hash_ruby(line.dup.force_encoding(Encoding::BINARY), headers, binary_options, has_quotes)
|
|
185
|
+
# skip empty strings: relabeling "" is a no-op, and the shared EMPTY_STRING is frozen
|
|
186
|
+
hash&.transform_values! { |v| v.is_a?(String) && !v.empty? ? v.force_encoding(original_encoding) : v }
|
|
187
|
+
return [hash, data_size]
|
|
188
|
+
end
|
|
189
|
+
|
|
172
190
|
# Chomp trailing row separator
|
|
173
191
|
line = line.chomp(options[:row_sep]) if options[:row_sep]
|
|
174
192
|
|
|
@@ -176,6 +194,14 @@ module SmarterCSV
|
|
|
176
194
|
strip = options[:strip_whitespace]
|
|
177
195
|
prefix = options[:missing_header_prefix]
|
|
178
196
|
|
|
197
|
+
# headers: { only: } SHORT-CUT (mirrors the C path's early exit): stop parsing right
|
|
198
|
+
# after the last wanted column and ignore everything behind it — extra columns are
|
|
199
|
+
# not discovered (no :column_N growth) and even an unclosed quote in an unwanted
|
|
200
|
+
# trailing column is ignored. _early_exit_after is the 0-based index of the last
|
|
201
|
+
# wanted column (set by the reader for only: without missing_headers: :raise).
|
|
202
|
+
early_exit = options[:_early_exit_after]
|
|
203
|
+
max_fields = early_exit && early_exit >= 0 ? early_exit + 1 : nil
|
|
204
|
+
|
|
179
205
|
# Optimization #11: for unquoted lines, build the hash in one pass directly
|
|
180
206
|
# from String#split — no intermediate array returned from parse_csv_line_ruby
|
|
181
207
|
# and no second iteration to convert array → hash. Saves one Array allocation
|
|
@@ -187,7 +213,14 @@ module SmarterCSV
|
|
|
187
213
|
# (default), v.empty? after strip catches both empty and whitespace-only
|
|
188
214
|
# fields without a regex. Most impactful on sparse files (many empty fields).
|
|
189
215
|
unless has_quotes || col_sep == ' '
|
|
190
|
-
|
|
216
|
+
if max_fields
|
|
217
|
+
# limited split: at most max_fields + 1 elements, the last being the unparsed
|
|
218
|
+
# remainder of the line — drop it, it is behind the last wanted column
|
|
219
|
+
fields = line.split(col_sep, max_fields + 1)
|
|
220
|
+
fields.pop if fields.size == max_fields + 1
|
|
221
|
+
else
|
|
222
|
+
fields = line.split(col_sep, -1)
|
|
223
|
+
end
|
|
191
224
|
n = fields.size
|
|
192
225
|
|
|
193
226
|
if options[:remove_empty_hashes]
|
|
@@ -205,7 +238,10 @@ module SmarterCSV
|
|
|
205
238
|
fields.each_with_index do |v, i| # C-level iteration, faster than Ruby while counter loop
|
|
206
239
|
next if remove_empty && v.empty?
|
|
207
240
|
|
|
208
|
-
|
|
241
|
+
# Empty values become the ONE shared frozen empty string (same design as the
|
|
242
|
+
# C path): the fresh "" from split dies in the next minor GC instead of being
|
|
243
|
+
# retained per empty field in the results.
|
|
244
|
+
hash[i < headers.size ? headers[i] : :"#{prefix}#{i + 1}"] = v.empty? ? EMPTY_STRING : v
|
|
209
245
|
end
|
|
210
246
|
|
|
211
247
|
unless remove_empty
|
|
@@ -216,7 +252,9 @@ module SmarterCSV
|
|
|
216
252
|
end
|
|
217
253
|
|
|
218
254
|
# Quoted/complex path: parse into elements array, then build hash.
|
|
219
|
-
|
|
255
|
+
# max_fields makes parse_csv_line_ruby stop scanning after the last wanted column
|
|
256
|
+
# (same short-cut as the C path's early exit).
|
|
257
|
+
elements, data_size = parse_csv_line_ruby(line, options, max_fields, has_quotes)
|
|
220
258
|
return [nil, -1] if data_size == -1 # unclosed quote at EOL → caller stitches next line
|
|
221
259
|
|
|
222
260
|
# Optimization #6: elements are always String or nil from parse_csv_line_ruby,
|
|
@@ -236,7 +274,10 @@ module SmarterCSV
|
|
|
236
274
|
hash = {}
|
|
237
275
|
i = 0
|
|
238
276
|
while i < n
|
|
239
|
-
|
|
277
|
+
v = elements[i]
|
|
278
|
+
# Empty values become the ONE shared frozen empty string (same design as the C path)
|
|
279
|
+
v = EMPTY_STRING if v.is_a?(String) && v.empty?
|
|
280
|
+
hash[i < headers.size ? headers[i] : :"#{prefix}#{i + 1}"] = v
|
|
240
281
|
i += 1
|
|
241
282
|
end
|
|
242
283
|
|
|
@@ -320,12 +361,15 @@ module SmarterCSV
|
|
|
320
361
|
row_sep = options[:row_sep]
|
|
321
362
|
row_sep_size = row_sep.is_a?(String) ? row_sep.size : 0
|
|
322
363
|
|
|
323
|
-
# Optimization #1: for the common single-
|
|
324
|
-
#
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
364
|
+
# Optimization #1: for the common single-BYTE separator, use direct
|
|
365
|
+
# byte comparison instead of allocating a substring via line[i...i+n].
|
|
366
|
+
# The gate must be on bytesize, not size: a one-character multi-byte separator
|
|
367
|
+
# (e.g. 'é') would be scanned by its first byte only, which also occurs as the
|
|
368
|
+
# lead byte of other characters — the character-level path below handles it.
|
|
369
|
+
if col_sep.bytesize == 1
|
|
370
|
+
# Optimization #13: byte-level indexing for single-byte separator.
|
|
371
|
+
# quote_char is validated to be single-byte at option parsing time.
|
|
372
|
+
# UTF-8 multi-byte continuation bytes (0x80–0xBF) never
|
|
329
373
|
# alias ASCII delimiter bytes (0x00–0x7F), so byte scanning is safe for
|
|
330
374
|
# UTF-8 strings with ASCII delimiters — no String allocation per character.
|
|
331
375
|
col_sep_byte = col_sep.getbyte(0)
|
|
@@ -356,8 +400,12 @@ module SmarterCSV
|
|
|
356
400
|
# unquoted (field_started && !in_quotes), remaining quotes are literal and
|
|
357
401
|
# cannot affect parser state — jump directly to the next col_sep.
|
|
358
402
|
# Mirrors Opt #10 for the unquoted side of the same trade-off.
|
|
403
|
+
# byteindex requires the byte offset to be on a character boundary — after
|
|
404
|
+
# stepping over the first byte of a multi-byte character, i is mid-character
|
|
405
|
+
# (a UTF-8 continuation byte, 0b10xxxxxx), so fall back to the byte loop there.
|
|
406
|
+
# (The Opt #10 quote jump can't be mid-character: i is always at quote_byte + 1.)
|
|
359
407
|
elsif quote_boundary_standard && field_started && !in_quotes
|
|
360
|
-
next_sep = if BYTEINDEX_AVAILABLE
|
|
408
|
+
next_sep = if BYTEINDEX_AVAILABLE && (line.getbyte(i) & 0xC0) != 0x80
|
|
361
409
|
line.byteindex(col_sep, i)
|
|
362
410
|
else
|
|
363
411
|
j = i
|
data/lib/smarter_csv/reader.rb
CHANGED
|
@@ -75,12 +75,16 @@ module SmarterCSV
|
|
|
75
75
|
def each
|
|
76
76
|
return enum_for(:each) unless block_given?
|
|
77
77
|
|
|
78
|
-
# Force row-by-row mode regardless of chunk_size setting
|
|
78
|
+
# Force row-by-row mode regardless of chunk_size setting.
|
|
79
|
+
# The explicit begin/ensure keeps the restore off the enum_for path above,
|
|
80
|
+
# where original_chunk_size was never captured (it would restore nil).
|
|
79
81
|
original_chunk_size = @options[:chunk_size]
|
|
80
82
|
@options[:chunk_size] = nil
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
83
|
+
begin
|
|
84
|
+
process { |row_array, _| yield row_array.first }
|
|
85
|
+
ensure
|
|
86
|
+
@options[:chunk_size] = original_chunk_size
|
|
87
|
+
end
|
|
84
88
|
end
|
|
85
89
|
|
|
86
90
|
# Yields each chunk as Array<Hash> plus its 0-based chunk index.
|
|
@@ -236,6 +240,15 @@ module SmarterCSV
|
|
|
236
240
|
|
|
237
241
|
@quote_escaping_auto = options[:quote_escaping] == :auto
|
|
238
242
|
@use_acceleration = options[:acceleration] && has_acceleration
|
|
243
|
+
# The C ParseContext stores separators and the extra-column prefix in fixed-size
|
|
244
|
+
# buffers (col_sep 7 bytes, row_sep 15, missing_header_prefix 63) and would
|
|
245
|
+
# silently truncate anything longer — fall back to the pure-Ruby parser for such
|
|
246
|
+
# exotic options; it handles any length.
|
|
247
|
+
if @use_acceleration
|
|
248
|
+
@use_acceleration = false if options[:col_sep].is_a?(String) && options[:col_sep].bytesize > 7
|
|
249
|
+
@use_acceleration = false if options[:row_sep].is_a?(String) && options[:row_sep].bytesize > 15
|
|
250
|
+
@use_acceleration = false if options[:missing_header_prefix].is_a?(String) && options[:missing_header_prefix].bytesize > 63
|
|
251
|
+
end
|
|
239
252
|
|
|
240
253
|
# The single options hash used on the hot path — for :auto we always try backslash
|
|
241
254
|
# first (C downgrades to RFC internally via Opt #5 when no backslash is found).
|
|
@@ -254,8 +267,10 @@ module SmarterCSV
|
|
|
254
267
|
# Key-cleanup flags — computed once, checked per row via cheap ivar reads.
|
|
255
268
|
# hash.delete(nil) / hash.delete('') only occur when key_mapping maps a header to nil/"".
|
|
256
269
|
# hash.delete(:"") also catches empty headers produced by ,, in the CSV.
|
|
257
|
-
|
|
258
|
-
@
|
|
270
|
+
# A nil header (key_mapping to nil, or nil in user_provided_headers) drops the column
|
|
271
|
+
@delete_nil_keys = !!options[:key_mapping] || @headers.include?(nil)
|
|
272
|
+
# Empty header keys are :"" with symbol keys, '' with strings_as_keys / keep_original_headers
|
|
273
|
+
@delete_empty_keys = !!options[:key_mapping] || @headers.include?(:"") || @headers.include?('')
|
|
259
274
|
|
|
260
275
|
# Cache field_size_limit as an ivar (nil when unset → one nil-check per row, no method calls).
|
|
261
276
|
@field_size_limit = options[:field_size_limit]
|
|
@@ -406,24 +421,18 @@ module SmarterCSV
|
|
|
406
421
|
hash.delete(nil)
|
|
407
422
|
hash.delete('')
|
|
408
423
|
end
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
if options[:remove_empty_values]
|
|
413
|
-
hash.delete_if do |_k, v|
|
|
414
|
-
str_val = v.is_a?(String) ? v : (v.is_a?(Numeric) ? v.to_s : nil)
|
|
415
|
-
str_val && matcher.match?(str_val)
|
|
416
|
-
end
|
|
417
|
-
else
|
|
418
|
-
hash.each_key do |k|
|
|
419
|
-
v = hash[k]
|
|
420
|
-
str_val = v.is_a?(String) ? v : (v.is_a?(Numeric) ? v.to_s : nil)
|
|
421
|
-
hash[k] = nil if str_val && matcher.match?(str_val)
|
|
422
|
-
end
|
|
423
|
-
end
|
|
424
|
+
if @delete_empty_keys
|
|
425
|
+
hash.delete(:"")
|
|
426
|
+
hash.delete('')
|
|
424
427
|
end
|
|
425
428
|
|
|
426
|
-
if options[:
|
|
429
|
+
if options[:nil_values_matching]
|
|
430
|
+
# The C parser deferred numeric conversion and zero-removal (see
|
|
431
|
+
# defer_value_transforms_to_ruby in the extension), so run the full Ruby
|
|
432
|
+
# pipeline: nil-matching on the raw strings first, then zero-removal,
|
|
433
|
+
# numeric conversion, and value_converters — the pure-Ruby-path order.
|
|
434
|
+
hash = hash_transformations(hash, options)
|
|
435
|
+
elsif options[:value_converters]
|
|
427
436
|
options[:value_converters].each do |key, converter|
|
|
428
437
|
hash[key] = converter.respond_to?(:convert) ? converter.convert(hash[key]) : converter.call(hash[key]) if hash.key?(key)
|
|
429
438
|
end
|
|
@@ -632,7 +641,22 @@ module SmarterCSV
|
|
|
632
641
|
return false unless line.include?(options[:quote_char])
|
|
633
642
|
|
|
634
643
|
if options[:quote_boundary] == :standard
|
|
635
|
-
|
|
644
|
+
case options[:quote_escaping]
|
|
645
|
+
when :backslash
|
|
646
|
+
detect_multiline_strict(line, options, true)
|
|
647
|
+
when :auto
|
|
648
|
+
if line.include?('\\')
|
|
649
|
+
# :auto parses with backslash semantics first and retries with RFC semantics
|
|
650
|
+
# (see @hot_path_options / @quote_escaping_double) — the row is only still
|
|
651
|
+
# open if BOTH interpretations leave the quote open, mirroring the dual
|
|
652
|
+
# counting in the non-strict branch below.
|
|
653
|
+
detect_multiline_strict(line, options, true) && detect_multiline_strict(line, options, false)
|
|
654
|
+
else
|
|
655
|
+
detect_multiline_strict(line, options, false)
|
|
656
|
+
end
|
|
657
|
+
else
|
|
658
|
+
detect_multiline_strict(line, options, false)
|
|
659
|
+
end
|
|
636
660
|
elsif options[:quote_escaping] == :auto
|
|
637
661
|
escaped_count, rfc_count = count_quote_chars_auto(line, options[:quote_char], options[:col_sep])
|
|
638
662
|
# If backslash-aware count is even → line is self-contained either way
|
|
@@ -657,7 +681,7 @@ module SmarterCSV
|
|
|
657
681
|
# - inside an unquoted field: jump directly to next col_sep via C-level byteindex
|
|
658
682
|
# This makes detect_multiline_strict competitive with parse_csv_line_ruby on the same
|
|
659
683
|
# content, enabling it to serve as a cheap gate in the stitch loop (Opt #18).
|
|
660
|
-
def detect_multiline_strict(line, options)
|
|
684
|
+
def detect_multiline_strict(line, options, allow_escaped_quotes = options[:quote_escaping] == :backslash)
|
|
661
685
|
col_sep = options[:col_sep]
|
|
662
686
|
quote = options[:quote_char]
|
|
663
687
|
strip = options[:strip_whitespace]
|
|
@@ -667,9 +691,26 @@ module SmarterCSV
|
|
|
667
691
|
row_sep_size = row_sep.is_a?(String) ? row_sep.size : 0
|
|
668
692
|
in_quotes = false
|
|
669
693
|
field_started = false
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
694
|
+
# The gate must agree with the parser, or the stitch loop keeps accumulating a row
|
|
695
|
+
# the parser would have closed and fabricates "Unclosed quoted field" at EOF. Two
|
|
696
|
+
# parser behaviors must therefore be modeled here exactly:
|
|
697
|
+
# - a doubled quote inside a quoted field ("" → ") takes precedence over the
|
|
698
|
+
# closing-quote check when another byte follows the pair (parser.rb, issue #334);
|
|
699
|
+
# - with allow_escaped_quotes, a quote preceded by an odd number of backslashes is
|
|
700
|
+
# escaped → literal, never a closing quote (detect_multiline passes the flag per
|
|
701
|
+
# quote_escaping mode; :auto runs both interpretations).
|
|
702
|
+
|
|
703
|
+
# Walk the same string the parser parses: parse_line_to_hash_ruby chomps the trailing
|
|
704
|
+
# row separator (String#chomp — for "\n" that also removes a trailing "\r\n" or "\r")
|
|
705
|
+
# before parsing. Without this, a terminal doubled quote or a CRLF line ending flips
|
|
706
|
+
# the pair-precedence / close-quote decisions at end-of-line.
|
|
707
|
+
line = line.chomp(row_sep) if row_sep.is_a?(String)
|
|
708
|
+
|
|
709
|
+
if col_sep.bytesize == 1
|
|
710
|
+
# Fast path: byte-level scanning with byteindex skip-ahead (Opt #17).
|
|
711
|
+
# Gated on bytesize, not size: a one-character multi-byte separator (e.g. 'é')
|
|
712
|
+
# must take the character-level path below — byte scanning would match its
|
|
713
|
+
# first byte inside other characters sharing that lead byte.
|
|
673
714
|
col_sep_byte = col_sep.getbyte(0)
|
|
674
715
|
quote_byte = quote.getbyte(0)
|
|
675
716
|
row_sep_bytesize = row_sep.is_a?(String) ? row_sep.bytesize : 0
|
|
@@ -697,7 +738,10 @@ module SmarterCSV
|
|
|
697
738
|
# Opt #12 mirror: unquoted field in progress — jump to next col_sep using C-level
|
|
698
739
|
# byteindex (MRI Ruby ≥ 3.2). Fallback for older Ruby / JRuby: manual getbyte loop —
|
|
699
740
|
# kept inline for the same reason as the Opt #10 mirror above.
|
|
700
|
-
|
|
741
|
+
# byteindex requires a character-boundary offset — after stepping over the first
|
|
742
|
+
# byte of a multi-byte character, i is mid-character (a UTF-8 continuation byte),
|
|
743
|
+
# so use the byte loop there.
|
|
744
|
+
next_sep = if byteindex_available && (line.getbyte(i) & 0xC0) != 0x80
|
|
701
745
|
line.byteindex(col_sep, i)
|
|
702
746
|
else
|
|
703
747
|
j = i
|
|
@@ -716,15 +760,28 @@ module SmarterCSV
|
|
|
716
760
|
field_started = false
|
|
717
761
|
elsif b == quote_byte
|
|
718
762
|
if in_quotes
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
763
|
+
escaped = false
|
|
764
|
+
if allow_escaped_quotes
|
|
765
|
+
k = i - 1
|
|
766
|
+
k -= 1 while k >= 0 && line.getbyte(k) == 0x5C # '\\'
|
|
767
|
+
escaped = (i - 1 - k).odd?
|
|
768
|
+
end
|
|
769
|
+
unless escaped # escaped quote → literal, stays inside the quoted field
|
|
770
|
+
next_i = i + 1
|
|
771
|
+
if next_i + 1 < bytesize && line.getbyte(next_i) == quote_byte
|
|
772
|
+
# doubled quote ("" → ") with another byte following: consume the pair,
|
|
773
|
+
# stay inside the quoted field (precedence over the closing-quote check;
|
|
774
|
+
# terminal "" keeps the parser's lenient close — see parse_csv_line_ruby)
|
|
775
|
+
i = next_i
|
|
776
|
+
# closing quote: only valid if followed by col_sep, row_sep, or end of line
|
|
777
|
+
elsif next_i >= bytesize ||
|
|
778
|
+
line.getbyte(next_i) == col_sep_byte ||
|
|
779
|
+
(row_sep_bytesize > 0 && line.byteslice(next_i, row_sep_bytesize) == row_sep)
|
|
780
|
+
in_quotes = false
|
|
781
|
+
field_started = true
|
|
782
|
+
end
|
|
783
|
+
# else: quote inside quoted field → literal
|
|
726
784
|
end
|
|
727
|
-
# else: quote inside quoted field → literal (handles "" doubling)
|
|
728
785
|
elsif !field_started # at field boundary: open quoted field
|
|
729
786
|
in_quotes = true
|
|
730
787
|
field_started = true
|
|
@@ -754,15 +811,27 @@ module SmarterCSV
|
|
|
754
811
|
|
|
755
812
|
if line[i] == quote
|
|
756
813
|
if in_quotes
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
814
|
+
escaped = false
|
|
815
|
+
if allow_escaped_quotes
|
|
816
|
+
k = i - 1
|
|
817
|
+
k -= 1 while k >= 0 && line[k] == '\\'
|
|
818
|
+
escaped = (i - 1 - k).odd?
|
|
819
|
+
end
|
|
820
|
+
unless escaped # escaped quote → literal, stays inside the quoted field
|
|
821
|
+
next_i = i + 1
|
|
822
|
+
if next_i + 1 < line_size && line[next_i] == quote
|
|
823
|
+
# doubled quote ("" → ") with another character following: consume the
|
|
824
|
+
# pair, stay inside the quoted field (see byte path above)
|
|
825
|
+
i = next_i
|
|
826
|
+
# closing quote: only valid if followed by col_sep, row_sep, or end of line
|
|
827
|
+
elsif next_i >= line_size ||
|
|
828
|
+
line[next_i...next_i + col_sep_size] == col_sep ||
|
|
829
|
+
(row_sep_size > 0 && line[next_i...next_i + row_sep_size] == row_sep)
|
|
830
|
+
in_quotes = false
|
|
831
|
+
field_started = true
|
|
832
|
+
end
|
|
833
|
+
# else: quote inside quoted field → literal
|
|
764
834
|
end
|
|
765
|
-
# else: quote inside quoted field → literal (handles "" doubling)
|
|
766
835
|
elsif !field_started # at field boundary: open quoted field
|
|
767
836
|
in_quotes = true
|
|
768
837
|
field_started = true
|
|
@@ -793,7 +862,9 @@ module SmarterCSV
|
|
|
793
862
|
def blank?(value)
|
|
794
863
|
case value
|
|
795
864
|
when String
|
|
796
|
-
|
|
865
|
+
# A string with invalid bytes for its encoding would make the regex raise —
|
|
866
|
+
# and it necessarily contains non-blank bytes, so it is not blank.
|
|
867
|
+
value.empty? || (value.valid_encoding? && BLANK_RE.match?(value))
|
|
797
868
|
when NilClass
|
|
798
869
|
true
|
|
799
870
|
when Array
|
|
@@ -845,25 +916,18 @@ module SmarterCSV
|
|
|
845
916
|
hash.delete(nil)
|
|
846
917
|
hash.delete('')
|
|
847
918
|
end
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
if (matcher = options[:nil_values_matching])
|
|
852
|
-
if options[:remove_empty_values]
|
|
853
|
-
hash.delete_if do |_k, v|
|
|
854
|
-
str_val = v.is_a?(String) ? v : (v.is_a?(Numeric) ? v.to_s : nil)
|
|
855
|
-
str_val && matcher.match?(str_val)
|
|
856
|
-
end
|
|
857
|
-
else
|
|
858
|
-
hash.each_key do |k|
|
|
859
|
-
v = hash[k]
|
|
860
|
-
str_val = v.is_a?(String) ? v : (v.is_a?(Numeric) ? v.to_s : nil)
|
|
861
|
-
hash[k] = nil if str_val && matcher.match?(str_val)
|
|
862
|
-
end
|
|
863
|
-
end
|
|
919
|
+
if @delete_empty_keys
|
|
920
|
+
hash.delete(:"")
|
|
921
|
+
hash.delete('')
|
|
864
922
|
end
|
|
865
923
|
|
|
866
|
-
if options[:
|
|
924
|
+
if options[:nil_values_matching]
|
|
925
|
+
# The C parser deferred numeric conversion and zero-removal (see
|
|
926
|
+
# defer_value_transforms_to_ruby in the extension), so run the full Ruby
|
|
927
|
+
# pipeline: nil-matching on the raw strings first, then zero-removal,
|
|
928
|
+
# numeric conversion, and value_converters — the pure-Ruby-path order.
|
|
929
|
+
hash = hash_transformations(hash, options)
|
|
930
|
+
elsif options[:value_converters]
|
|
867
931
|
options[:value_converters].each do |key, converter|
|
|
868
932
|
hash[key] = converter.respond_to?(:convert) ? converter.convert(hash[key]) : converter.call(hash[key]) if hash.key?(key)
|
|
869
933
|
end
|
|
@@ -129,20 +129,53 @@ module SmarterCSV
|
|
|
129
129
|
warn "DEPRECATION WARNING: 'except_headers:' is deprecated. Use 'headers: { except: [...] }' instead." unless @options[:verbose] == :quiet
|
|
130
130
|
end
|
|
131
131
|
|
|
132
|
-
# Normalize only_headers/except_headers to arrays of
|
|
132
|
+
# Normalize only_headers/except_headers to arrays of the row-key type (internal names,
|
|
133
|
+
# read by the C extension too): with strings_as_keys / keep_original_headers the row
|
|
134
|
+
# keys are Strings, otherwise Symbols — the selectors must match to select anything.
|
|
135
|
+
string_keys = @options[:strings_as_keys] || @options[:keep_original_headers]
|
|
133
136
|
if @options[:only_headers]
|
|
134
137
|
values = Array(@options[:only_headers])
|
|
135
138
|
bad = values.reject { |v| v.is_a?(Symbol) || v.is_a?(String) }
|
|
136
139
|
raise SmarterCSV::ValidationError, "headers: { only: } elements must be String or Symbol, got: #{bad.map(&:class).uniq.inspect}" if bad.any?
|
|
137
140
|
|
|
138
|
-
@options[:only_headers] = values.map(&:to_sym)
|
|
141
|
+
@options[:only_headers] = string_keys ? values.map(&:to_s) : values.map(&:to_sym)
|
|
139
142
|
end
|
|
140
143
|
if @options[:except_headers]
|
|
141
144
|
values = Array(@options[:except_headers])
|
|
142
145
|
bad = values.reject { |v| v.is_a?(Symbol) || v.is_a?(String) }
|
|
143
146
|
raise SmarterCSV::ValidationError, "headers: { except: } elements must be String or Symbol, got: #{bad.map(&:class).uniq.inspect}" if bad.any?
|
|
144
147
|
|
|
145
|
-
@options[:except_headers] = values.map(&:to_sym)
|
|
148
|
+
@options[:except_headers] = string_keys ? values.map(&:to_s) : values.map(&:to_sym)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# The Hash form of convert_values_to_numeric accepts EXACTLY ONE of only:/except:,
|
|
152
|
+
# with field name(s) (String/Symbol or an Array of them) as the value. Anything else
|
|
153
|
+
# is an invalid declaration → ValidationError, instead of silently picking a behavior
|
|
154
|
+
# (the C and Ruby paths used to disagree on these shapes). Values are normalized to
|
|
155
|
+
# the row-key type, like headers: { only: } above.
|
|
156
|
+
if (cvn = @options[:convert_values_to_numeric]).is_a?(Hash)
|
|
157
|
+
unless (cvn.keys - %i[only except]).empty?
|
|
158
|
+
raise SmarterCSV::ValidationError, "convert_values_to_numeric: only the keys only:/except: are accepted, got: #{cvn.keys.inspect}"
|
|
159
|
+
end
|
|
160
|
+
if cvn.key?(:only) && cvn.key?(:except)
|
|
161
|
+
raise SmarterCSV::ValidationError, "convert_values_to_numeric: cannot use only: and except: at the same time"
|
|
162
|
+
end
|
|
163
|
+
unless cvn.key?(:only) || cvn.key?(:except)
|
|
164
|
+
raise SmarterCSV::ValidationError, "convert_values_to_numeric: the Hash form requires only: or except: with field name(s)"
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
list_key = cvn.key?(:only) ? :only : :except
|
|
168
|
+
values = cvn[list_key].is_a?(Array) ? cvn[list_key] : [cvn[list_key]]
|
|
169
|
+
if values.empty?
|
|
170
|
+
raise SmarterCSV::ValidationError, "convert_values_to_numeric: #{list_key}: must not be empty — expects field name(s)"
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
bad = values.reject { |v| v.is_a?(Symbol) || v.is_a?(String) }
|
|
174
|
+
unless bad.empty?
|
|
175
|
+
raise SmarterCSV::ValidationError, "convert_values_to_numeric: #{list_key}: expects field name(s) (String or Symbol), got: #{bad.map(&:class).uniq.inspect}"
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
@options[:convert_values_to_numeric] = { list_key => string_keys ? values.map(&:to_s) : values.map(&:to_sym) }
|
|
146
179
|
end
|
|
147
180
|
|
|
148
181
|
# Deprecation: remove_values_matching → nil_values_matching
|
|
@@ -195,7 +228,10 @@ module SmarterCSV
|
|
|
195
228
|
errors = []
|
|
196
229
|
errors << "invalid row_sep" if keys.include?(:row_sep) && !option_valid?(options[:row_sep])
|
|
197
230
|
errors << "invalid col_sep" if keys.include?(:col_sep) && !option_valid?(options[:col_sep])
|
|
198
|
-
|
|
231
|
+
# quote_char has no auto-detection — :auto is only valid for row_sep and col_sep
|
|
232
|
+
if keys.include?(:quote_char) && !(options[:quote_char].is_a?(String) && !options[:quote_char].empty?)
|
|
233
|
+
errors << "invalid quote_char"
|
|
234
|
+
end
|
|
199
235
|
if keys.include?(:quote_char) && options[:quote_char].is_a?(String) && options[:quote_char].bytesize > 1
|
|
200
236
|
errors << "invalid quote_char: must be a single byte (got #{options[:quote_char].inspect})"
|
|
201
237
|
end
|
|
@@ -261,9 +297,11 @@ module SmarterCSV
|
|
|
261
297
|
warn "WARNING: buffer_size (#{options[:buffer_size]}) < auto_row_sep_chars (#{arc}); bumping buffer_size to #{bumped}" unless quiet
|
|
262
298
|
options[:buffer_size] = bumped
|
|
263
299
|
end
|
|
300
|
+
# field_size_limit is overrun protection (a hard upper bound against runaway fields),
|
|
301
|
+
# not per-field validation — small values make no sense and are rejected.
|
|
264
302
|
fsl = options[:field_size_limit]
|
|
265
|
-
unless fsl.nil? || (fsl.is_a?(Integer) && fsl
|
|
266
|
-
errors << "invalid field_size_limit: must be nil or
|
|
303
|
+
unless fsl.nil? || (fsl.is_a?(Integer) && fsl >= 4096)
|
|
304
|
+
errors << "invalid field_size_limit: must be nil or an Integer >= 4096 (got #{fsl.inspect})"
|
|
267
305
|
end
|
|
268
306
|
obr = options[:on_bad_row]
|
|
269
307
|
unless %i[raise skip collect].include?(obr) || obr.respond_to?(:call)
|
data/lib/smarter_csv/version.rb
CHANGED
data/lib/smarter_csv/writer.rb
CHANGED
|
@@ -204,12 +204,12 @@ module SmarterCSV
|
|
|
204
204
|
str = field.to_s
|
|
205
205
|
return str if @disable_auto_quoting && !force_quotes
|
|
206
206
|
|
|
207
|
-
#
|
|
207
|
+
# quote fields if we force that, or if the field contains the col_sep, row_sep, or quote_char
|
|
208
208
|
contains_special_char = str.match(@quote_regex)
|
|
209
209
|
if force_quotes || contains_special_char
|
|
210
|
-
str = str.gsub(@quote_char, @escaped_quote_char) if contains_special_char # escape
|
|
210
|
+
str = str.gsub(@quote_char, @escaped_quote_char) if contains_special_char # escape the quote_char
|
|
211
211
|
|
|
212
|
-
"
|
|
212
|
+
"#{@quote_char}#{str}#{@quote_char}"
|
|
213
213
|
else
|
|
214
214
|
str
|
|
215
215
|
end
|
metadata
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: smarter_csv
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.19.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Tilo Sloboda
|
|
8
8
|
bindir: bin
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-
|
|
10
|
+
date: 2026-08-10 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: bigdecimal
|
|
@@ -83,6 +83,7 @@ files:
|
|
|
83
83
|
- docs/upgrade_wizard.html
|
|
84
84
|
- docs/value_converters.md
|
|
85
85
|
- docs/warnings.md
|
|
86
|
+
- ext/smarter_csv/cpu_flags.rb
|
|
86
87
|
- ext/smarter_csv/extconf.rb
|
|
87
88
|
- ext/smarter_csv/smarter_csv.c
|
|
88
89
|
- ext/smarter_csv/vendor/LICENSE-fast_float-MIT
|
|
@@ -140,7 +141,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
140
141
|
- !ruby/object:Gem::Version
|
|
141
142
|
version: '0'
|
|
142
143
|
requirements: []
|
|
143
|
-
rubygems_version:
|
|
144
|
+
rubygems_version: 4.0.17
|
|
144
145
|
specification_version: 4
|
|
145
146
|
summary: Fastest end-to-end CSV ingestion for Ruby with smart defaults and Rails-ready
|
|
146
147
|
hash output
|