Как заставить одно поле в Ruby CSV файле обернуться двойными кавычками?
Я генерирую некоторый вывод CSV, используя встроенный CSV файл Ruby. Все работает нормально, но клиент хочет, чтобы поле имени на выходе содержало двойные кавычки, поэтому результат выглядит как входной файл. Например, ввод выглядит примерно так:
1,1.1.1.1,"Firstname Lastname",more,fields
2,2.2.2.2,"Firstname Lastname, Jr.",more,fields
Результат CSV, который является правильным, выглядит так:
1,1.1.1.1,Firstname Lastname,more,fields
2,2.2.2.2,"Firstname Lastname, Jr.",more,fields
Я знаю, что CSV делает все правильно, не дублируя третье поле только потому, что он имеет встроенные пробелы и обертывает поле двойными кавычками, когда он имеет встроенную запятую. То, что я хотел бы сделать, чтобы помочь клиенту чувствовать себя теплым и нечетким, говорит, что CSV всегда дублирует третье поле.
Я попробовал обернуть поле в двойных кавычках в моем методе to_a
, который создает поле "Firstname Lastname"
, передаваемое в CSV, но CSV смеялся над моей попыткой и выходом """Firstname Lastname"""
. Это правильная вещь, потому что она избегает двойных кавычек, так что это не сработало.
Затем я попытался установить CSV :force_quotes => true
в методе open
, который выводит двойные кавычки, обертывая все поля, как ожидалось, но клиенту это не понравилось, что я ожидал также. Таким образом, это тоже не сработало.
Я просмотрел документы Table и Row, и ничто не показывало мне доступ к методу "сгенерировать строковое поле" или к способу установки флага "для поля n всегда использовать кавычки".
Я собираюсь погрузиться в источник, чтобы увидеть, есть ли какие-то сверхсекретные твики, или если есть способ обезглавить CSV и сгинуть его, чтобы сделать мою волю, но задавался вопросом, есть ли у кого-то особые знания или перед этим пробегите.
И, да, я знаю, что могу свернуть свой собственный выход CSV, но я предпочитаю не изобретать проверенные колеса. И я также знаю о FasterCSV; Это теперь часть Ruby 1.9.2, которую я использую, поэтому явное использование FasterCSV не приносит мне ничего особенного. Кроме того, я не использую Rails и не собираюсь переписывать его в Rails, поэтому, если у вас нет симпатичного способа его реализации с использованием небольшого подмножества Rails, не беспокойтесь. Я буду рекомендовать любые рекомендации, чтобы использовать любой из этих способов только потому, что вы не потрудились прочитать это далеко.
Ответы
Ответ 1
Ну, есть способ сделать это, но это было не так чисто, как я надеялся, что CSV-код мог бы позволить.
Мне пришлось подклассировать CSV, затем переопределить метод CSV::Row.<<=
и добавить еще один метод forced_quote_fields=
, чтобы дать возможность определить поля, которые я хочу использовать для принудительного цитирования, плюс вытащить два лямбда из других методов. По крайней мере, он работает для того, что я хочу:
require 'csv'
class MyCSV < CSV
def <<(row)
# make sure headers have been assigned
if header_row? and [Array, String].include? @use_headers.class
parse_headers # won't read data for Array or String
self << @headers if @write_headers
end
# handle CSV::Row objects and Hashes
row = case row
when self.class::Row then row.fields
when Hash then @headers.map { |header| row[header] }
else row
end
@headers = row if header_row?
@lineno += 1
@do_quote ||= lambda do |field|
field = String(field)
encoded_quote = @quote_char.encode(field.encoding)
encoded_quote +
field.gsub(encoded_quote, encoded_quote * 2) +
encoded_quote
end
@quotable_chars ||= encode_str("\r\n", @col_sep, @quote_char)
@forced_quote_fields ||= []
@my_quote_lambda ||= lambda do |field, index|
if field.nil? # represent +nil+ fields as empty unquoted fields
""
else
field = String(field) # Stringify fields
# represent empty fields as empty quoted fields
if (
field.empty? or
field.count(@quotable_chars).nonzero? or
@forced_quote_fields.include?(index)
)
@do_quote.call(field)
else
field # unquoted field
end
end
end
output = row.map.with_index(&@my_quote_lambda).join(@col_sep) + @row_sep # quote and separate
if (
@io.is_a?(StringIO) and
output.encoding != raw_encoding and
(compatible_encoding = Encoding.compatible?(@io.string, output))
)
@io = StringIO.new(@io.string.force_encoding(compatible_encoding))
@io.seek(0, IO::SEEK_END)
end
@io << output
self # for chaining
end
alias_method :add_row, :<<
alias_method :puts, :<<
def forced_quote_fields=(indexes=[])
@forced_quote_fields = indexes
end
end
Это код. Вызов:
data = [
%w[1 2 3],
[ 2, 'two too', 3 ],
[ 3, 'two, too', 3 ]
]
quote_fields = [1]
puts "Ruby version: #{ RUBY_VERSION }"
puts "Quoting fields: #{ quote_fields.join(', ') }", "\n"
csv = MyCSV.generate do |_csv|
_csv.forced_quote_fields = quote_fields
data.each do |d|
_csv << d
end
end
puts csv
приводит к:
# >> Ruby version: 1.9.2
# >> Quoting fields: 1
# >>
# >> 1,"2",3
# >> 2,"two too",3
# >> 3,"two, too",3
Ответ 2
Этот пост старый, но я не могу поверить, что никто не думал об этом.
Почему бы не сделать:
csv = CSV.generate :quote_char => "\0" do |csv|
где\0 - нулевой символ, тогда просто добавляйте кавычки в каждое поле, где они необходимы:
csv << [product.upc, "\"" + product.name + "\"" # ...
Затем в конце вы можете сделать
csv.gsub!(/\0/, '')
Ответ 3
Я сомневаюсь, что это поможет клиенту чувствовать себя теплым и нечетким после всего этого времени, но это, похоже, работает:
require 'csv'
#prepare a lambda which converts field with index 2
quote_col2 = lambda do |field, fieldinfo|
# fieldinfo has a line- ,header- and index-method
if fieldinfo.index == 2 && !field.start_with?('"') then
'"' + field + '"'
else
field
end
end
# specify above lambda as one of the converters
csv = CSV.read("test1.csv", :converters => [quote_col2])
p csv
# => [["aaa", "bbb", "\"ccc\"", "ddd"], ["fff", "ggg", "\"hhh\"", "iii"]]
File.open("test1.txt","w"){|out| csv.each{|line|out.puts line.join(",")}}
Ответ 4
Не похоже, чтобы какой-либо способ сделать это с существующей реализацией CSV, не связанной с переделкой/переписыванием обезьян.
Однако, если вы полностью контролируете исходные данные, вы можете сделать это:
- добавьте пользовательскую строку , включая запятую (т.е. ту, которая никогда не будет естественным образом найдена в данных) до конца соответствующего поля для каждой строки; возможно, что-то вроде " FORCE_COMMAS,".
- Создайте вывод CSV.
- Теперь, когда у вас есть вывод CSV с кавычками в каждой строке для вашего поля, удалите пользовательскую строку:
csv.gsub!(/FORCE_COMMAS,/, "")
- Клиент чувствует себя теплым и нечетким.
Ответ 5
CSV
имеет параметр force_quotes
, который заставит его процитировать все поля (возможно, он не был там, когда вы отправили это изначально). Я понимаю, что это не совсем то, что вы предлагали, но это меньше патчей обезьян.
2.1.0 :008 > puts CSV.generate_line [1,'1.1.1.1','Firstname Lastname','more','fields']
1,1.1.1.1,Firstname Lastname,more,fields
2.1.0 :009 > puts CSV.generate_line [1,'1.1.1.1','Firstname Lastname','more','fields'], force_quotes: true
"1","1.1.1.1","Firstname Lastname","more","fields"
Недостатком является то, что первое целочисленное значение заканчивается в виде строки, что меняет ситуацию при импорте в Excel.
Ответ 6
CSV немного изменился в Ruby 2.1, как упоминалось в @jwadsack, однако здесь рабочая версия @the-tin-man MyCSV. Бит изменен, вы устанавливаете принудительные_quote_fields через параметры.
MyCSV.generate(forced_quote_fields: [1]) do |_csv|...
Измененный код
require 'csv'
class MyCSV < CSV
def <<(row)
# make sure headers have been assigned
if header_row? and [Array, String].include? @use_headers.class
parse_headers # won't read data for Array or String
self << @headers if @write_headers
end
# handle CSV::Row objects and Hashes
row = case row
when self.class::Row then row.fields
when Hash then @headers.map { |header| row[header] }
else row
end
@headers = row if header_row?
@lineno += 1
output = row.map.with_index(&@quote).join(@col_sep) + @row_sep # quote and separate
if @io.is_a?(StringIO) and
output.encoding != (encoding = raw_encoding)
if @force_encoding
output = output.encode(encoding)
elsif (compatible_encoding = Encoding.compatible?(@io.string, output))
@io.set_encoding(compatible_encoding)
@io.seek(0, IO::SEEK_END)
end
end
@io << output
self # for chaining
end
def init_separators(options)
# store the selected separators
@col_sep = options.delete(:col_sep).to_s.encode(@encoding)
@row_sep = options.delete(:row_sep) # encode after resolving :auto
@quote_char = options.delete(:quote_char).to_s.encode(@encoding)
@forced_quote_fields = options.delete(:forced_quote_fields) || []
if @quote_char.length != 1
raise ArgumentError, ":quote_char has to be a single character String"
end
#
# automatically discover row separator when requested
# (not fully encoding safe)
#
if @row_sep == :auto
if [ARGF, STDIN, STDOUT, STDERR].include?(@io) or
(defined?(Zlib) and @io.class == Zlib::GzipWriter)
@row_sep = $INPUT_RECORD_SEPARATOR
else
begin
#
# remember where we were (pos() will raise an exception if @io is pipe
# or not opened for reading)
#
saved_pos = @io.pos
while @row_sep == :auto
#
# if we run out of data, it probably a single line
# (ensure will set default value)
#
break unless sample = @io.gets(nil, 1024)
# extend sample if we're unsure of the line ending
if sample.end_with? encode_str("\r")
sample << (@io.gets(nil, 1) || "")
end
# try to find a standard separator
if sample =~ encode_re("\r\n?|\n")
@row_sep = $&
break
end
end
# tricky seek() clone to work around GzipReader lack of seek()
@io.rewind
# reset back to the remembered position
while saved_pos > 1024 # avoid loading a lot of data into memory
@io.read(1024)
saved_pos -= 1024
end
@io.read(saved_pos) if saved_pos.nonzero?
rescue IOError # not opened for reading
# do nothing: ensure will set default
rescue NoMethodError # Zlib::GzipWriter doesn't have some IO methods
# do nothing: ensure will set default
rescue SystemCallError # pipe
# do nothing: ensure will set default
ensure
#
# set default if we failed to detect
# (stream not opened for reading, a pipe, or a single line of data)
#
@row_sep = $INPUT_RECORD_SEPARATOR if @row_sep == :auto
end
end
end
@row_sep = @row_sep.to_s.encode(@encoding)
# establish quoting rules
@force_quotes = options.delete(:force_quotes)
do_quote = lambda do |field|
field = String(field)
encoded_quote = @quote_char.encode(field.encoding)
encoded_quote +
field.gsub(encoded_quote, encoded_quote * 2) +
encoded_quote
end
quotable_chars = encode_str("\r\n", @col_sep, @quote_char)
@quote = if @force_quotes
do_quote
else
lambda do |field, index|
if field.nil? # represent +nil+ fields as empty unquoted fields
""
else
field = String(field) # Stringify fields
# represent empty fields as empty quoted fields
if field.empty? or
field.count(quotable_chars).nonzero? or
@forced_quote_fields.include?(index)
do_quote.call(field)
else
field # unquoted field
end
end
end
end
end
end