【问题标题】:How do I force one field in Ruby's CSV output to be wrapped with double-quotes?如何强制 Ruby 的 CSV 输出中的一个字段用双引号括起来?
【发布时间】:2011-01-31 19:01:42
【问题描述】:

我正在使用 Ruby 的内置 CSV 生成一些 CSV 输出。一切正常,但客户希望输出中的名称字段包含双引号,因此输出看起来像输入文件。例如,输入看起来像这样:

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 方法中将字段用双引号括起来,这会创建一个传递给 CSV 的 "Firstname Lastname" 字段,但 CSV 嘲笑我的微不足道的尝试并输出 """Firstname Lastname"""。这是正确的做法,因为它转义了双引号,所以这不起作用。

然后我尝试在open 方法中设置CSV 的:force_quotes => true,该方法输出的双引号会按预期包含所有字段,但客户不喜欢这样,这也是我的预期。所以,那也没用。

我查看了 Table 和 Row 文档,似乎没有任何东西可以让我访问“生成字符串字段”方法,或者设置“对于字段 n 始终使用引用”标志的方法。

我将深入研究源代码,看看是否有一些超级秘密的调整,或者是否有办法对 CSV 进行猴子补丁并按照我的意愿弯曲它,但想知道是否有人有一些特殊知识或有之前遇到过这个。

而且,是的,我知道我可以推出自己的 CSV 输出,但我不想重新发明经过充分测试的轮子。而且,我也知道 FasterCSV;这现在是我正在使用的 Ruby 1.9.2 的一部分,所以明确地使用 FasterCSV 并没有给我带来什么特别的东西。另外,我没有使用 Rails,也不打算在 Rails 中重写它,所以除非你有一种使用 Rails 的一小部分实现它的可爱方法,否则不要打扰。我会否决任何使用这些方式的建议,只是因为你没有费心读到这里。

【问题讨论】:

标签: ruby csv


【解决方案1】:

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 时会发生变化。

【讨论】:

  • "...第一个整数值最终列为字符串...",正如您所说,它将值更改为字符串。另外,我需要一个特定的字段总是用引号括起来,而不是每个字段。
【解决方案2】:

这篇文章很老了,但我不敢相信没有人想到这一点。

为什么不这样做:

csv = CSV.generate :quote_char => "\0" do |csv|

其中 \0 是一个空字符,然后只需在需要它们的每个字段中添加引号:

csv << [product.upc, "\"" + product.name + "\"" # ...

最后你可以做一个

csv.gsub!(/\0/, '')

【讨论】:

  • 除了另一个答案已经提出了这种解决方案的事实之外,但是如果您正在处理巨大的 CSV 文件,特别是如果它必须被缓冲或重新加载以修复黑客。 CSV 一开始就是一种丑陋的格式。最好解决根本原因,即使这意味着触摸或覆盖某些代码,因为这样该过程才能正常工作。
  • 但是,如果你从 Google 来到这里,在事实发生多年之后,并且不想与古板的 CSV 类战斗(为什么 TF 他们不能只测试字符串长度是否为 @987654325 @,而不是 == 1?),并且拥有不幸已经改变每个字段的代码的所有权,这是一个 Just Works 的 hack。
【解决方案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(",")}}

【讨论】:

  • 您不能将转换器应用于generate 方法,可以吗? OP 询问有关编写 CSV 文件的问题。
  • @jwadsa​​ck 添加了一行将数组写入文件。
  • 但是对于一个带有逗号的字段,这不会失败吗?
【解决方案4】:

嗯,有一种方法可以做到这一点,但它不像我希望 CSV 代码允许的那样干净。

我必须对 CSV 进行子类化,然后覆盖 CSV::Row.&lt;&lt;= 方法并添加另一个方法 forced_quote_fields= 以定义我想要强制引用的字段,并从其他方法中提取两个 lambda。至少它适用于我想要的:

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

【讨论】:

  • 从 Ruby 2.1.0、CSV 2.4.8 开始,此代码现在在 init_separators 方法中,但非常相似。通过对该方法进行猴子修补,这可能会确保其他方法(如 generate_line)也将使用正确的引用。
  • 从 Ruby 3 开始,CSV 已使用 write_converters 选项修复了该错误,因此我可以在没有任何错误的情况下运行此错误 CSV.generate_line(["a", 12, nil], write_nil_value: "NaN", write_converters: -&gt;(value) {"=#{value}"}) 在某些情况下可能会有所帮助。
【解决方案5】:

看起来除了猴子修补/重写之外,现有的 CSV 实现没有任何方法可以做到这一点。

但是,假设您可以完全控制源数据,您可以这样做:

  1. 在每一行的相关字段末尾附加一个自定义字符串包括一个逗号(即永远不会在数据中自然找到的字符串);可能类似于“FORCE_COMMAS,”。
  2. 生成 CSV 输出。
  3. 现在您的字段的每一行都有带引号的 CSV 输出,删除自定义字符串:csv.gsub!(/FORCE_COMMAS,/, "")
  4. 客户感到温暖和模糊。

【讨论】:

  • 一旦获得输出CSV,by_col!方法也可能有帮助!
  • 它不是很优雅,也没有遵循 Ruby 的方式。我想我会再次深入研究源代码。
【解决方案6】:

正如@jwadsa​​ck 所提到的,CSV 在 Ruby 2.1 中发生了一些变化,但是这里是 @the-tin-man 的 MyCSV 的工作版本。位修改,您通过选项设置了forced_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's 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's 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

【讨论】:

    【解决方案7】:

    已经很长时间了,但是自从 CSV library 已被修补以来,如果有人现在面临这个问题,这可能会对他们有所帮助:

    require 'csv'
    
    # puts CSV::VERSION # this should be 3.1.9+
    
    headers = ['id', 'ip', 'name', 'foo', 'bar']
    data = [
    [1, '1.1.1.1','Firstname Lastname','more','fields'],
    [2, '2.2.2.2','Firstname Lastname, Jr.','more','fields']
    ]
    
    quoter = Proc.new do |field, field_meta|
     # the index starts at zero, that's why the third field would be 2:
     field = '"' + field + '"' if field_meta.index == 2 && fields_meta.index > 1
     field = '"' + field + '"' if field.is_a?(String) && field.include?(',')
     # ^ CSV format needs to escape fields containing comma(s): ,
     field
    end
    
    file = CSV.generate(headers: true, quote_char: '', write_converters: quoter) do |csv|
        csv << headers
        data.each { |row| csv << row }
    end
    
    puts file
    

    输出将是:

    id,ip,name,foo,bar
    1,1.1.1.1,"Firstname Lastname",more,fields
    2,2.2.2.2,"Firstname Lastname, Jr.",more,fields
    

    【讨论】:

      猜你喜欢
      • 2022-01-26
      • 1970-01-01
      • 2014-02-20
      • 2011-02-14
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      相关资源
      最近更新 更多