How to generate JSON data incremental? - ruby

I have a data file about 1000000 lines, 300+ Mb. I want to convert it to a JSON file.
JSON.generate and Hash.merge can help generate JSON, but I need to wait for the program to generate one whole hash. That costs too much time.
I want to write JSON to file incrementally.
Here is my code:
require 'yajl/json_gem'
my_hash = {}
fd1 = File.open("foo.json", "w")
fd2 = File.open("foo.log")
fd2.each_line do | line |
l = fd2.lineno
remote_addr = line.split(" ")[0]
time_local = line.split("]")[0].split("[")[1]
item = {l => {:remote_addr => remote_addr, :time_local => time_local}}
# {
# 1: {
# remote_addr: "1.2.3.4",
# time_local: ""
# },
# 2: {
# ...
# },
# ...
# }
my_hash.merge!(item)
end
fd2.close
fd1.puts JSON.generate(my_hash)
fd1.close
Here is my data:
// access.log
1.2.3.4 - - [02/Apr/2014:03:23:06 +0800] "GET /index" 200 1 "http://foo" "Mozilla/5.0" "-"
Any ideas? Thanks in advance.
--
edit:
Actually, I'd better convert raw data like this:
[
{
id: "1",
remote_addr: "1.2.3.4",
time_local: "02/Apr/2014:03:23:06 +0800"
},
{
...
},
{
...
}
]

This is basic code to generate a valid JSON output based on the sample line:
require 'json'
lines = [
'1.2.3.4 - - [02/Apr/2014:03:23:06 +0800] "GET /index" 200 1 "http://foo" "Mozilla/5.0" "-"',
'1.2.3.5 - - [03/Apr/2014:03:23:06 +0800] "GET /index" 200 1 "http://foo" "Mozilla/5.0" "-"'
]
lines.each_with_index do |line, l|
puts '{' if l == 0
remote_addr, time_local = /^(\S+) .+ \[(.+)\]/.match(line).captures
print '"%d":{"remote_addr":"%s","time_local":"%s"}' % [l + 1, remote_addr, time_local]
puts ',' if l + 1 < lines.size
end
puts "\n}"
# >> {
# >> "1":{"remote_addr":"1.2.3.4","time_local":"02/Apr/2014:03:23:06 +0800"},
# >> "2":{"remote_addr":"1.2.3.5","time_local":"03/Apr/2014:03:23:06 +0800"}
# >> }
You'll need to convert the code to read and write files, or maybe read a single file and redirect the output to capture it. You'll also need to figure out how to determine the number of lines in a file to make it possible to find the places to output the commas. It's easy, and I know examples of doing that exist here on Stack Overflow.
In your original code you need to use the block form for open, rather than assigning to a variable and then explicitly closing the files; Using the block form of open is the Ruby-way. Also, use File.foreach to read through a file line-by-line.

#theTinMan posted a good answer, I will just add that a little more generic solution for a list of JSON object is to construct the list yourself, but let JSON generate each item:
fd1.puts '{'
fd1 = File.open("foo.json", "w")
fd2 = File.open("foo.log")
first_line = true
fd2.each_line do | line |
fd1.puts(',') unless first_line
first_line = false
l = fd2.lineno
remote_addr = line.split(" ")[0]
time_local = line.split("]")[0].split("[")[1]
fd1.print "\"#{l}\": #{JSON.generate(:remote_addr => remote_addr, :time_local => time_local)}"
end
fd1.puts "\n}"

Related

Ruby script for json formatter [duplicate]

This question already has answers here:
How do I push unique copies of array into another array?
(2 answers)
Ruby Arrays: Why is the value I print correct, but the value I push incorrect?
(1 answer)
Closed 4 months ago.
I am making json formater for sonarqube, here is my script:
require 'json'
tr_report = File.open('./tes.json').read
tr_report.gsub!(/\r\n?/, "\n")
sq_generic_format = {'issues' => []}
sq_issue_format = {
'engineId' => '', # CONST='brakeman'
'ruleId' => '', #[check_name (warning_code)] warning_type [confidence]
'severity':'MAJOR', # MAJOR
'type':'VULNERABILITY', # CONST='VULNERABILITY'
'primaryLocation' => {},
'effortMinutes' => 0, #CONST=0
}
primary_location_format = {
'message' => '', # message + CONST='\nCode:' + code + CONST='\nUser Input:' + user_input + CONST='\nLink: ' + link
'filePath' => '', # file
'textRange' => {}
}
text_range_format = {
'startLine' => 1,# line
'endLine' => 1,# line
'startColumn' => 0,
'endColumn' => 1
}
issues = []
tr_report.each_line do |line|
tr_data = JSON.parse(line)
# puts tr_data
# puts parsed["SourceMetadata"]["Data"]["Filesystem"]["file"]
issue = sq_issue_format
issue['engineId'] = 'trufflehog'
issue['ruleId'] = 'Sensitive Data Exposure - %s' %[tr_data['Raw']]
issue['severity'] = 'MAJOR' # MAJOR
issue['type'] = 'VULNERABILITY' # CONST='VULNERABILITY'
issue['effortMinutes'] = 0
issue['primaryLocation'] = {}
# filling up nested data lvl1 ^
primary_location = primary_location_format
primary_location['message'] = 'Sensitive Data Exposure'
primary_location['filePath'] = tr_data["SourceMetadata"]["Data"]["Filesystem"]["file"] # file
primary_location['textRange'] = {}
# filling up nested data lvl2 ^
text_range = text_range_format
# text_range['startLine'] = w['line']
# text_range['endLine'] = w['line']
# sticking all together
primary_location['textRange'] = text_range
issue['primaryLocation'] = primary_location
issues.append(issue)
end
# puts issues
sq_generic_format['issues'] = issues
puts JSON.dump(sq_generic_format)
File.write('./trufflehog-sq-report.json', JSON.dump(sq_generic_format))
and here is my jsonline tes.json:
{"SourceMetadata":{"Data":{"Filesystem":{"file":"../ruby/railsgoat/dependency-check-report.html"}}},"SourceID":15,"SourceType":15,"SourceName":"trufflehog - filesystem","DetectorType":9,"DetectorName":"Gitlab","Verified":false,"Raw":"vulnerable-to-driveby-","Redacted":"","ExtraData":null,"StructuredData":null}
{"SourceMetadata":{"Data":{"Filesystem":{"file":"../ruby/railsgoat/dependency-check-report.html"}}},"SourceID":15,"SourceType":15,"SourceName":"trufflehog - filesystem","DetectorType":800,"DetectorName":"Atera","Verified":false,"Raw":"39a6bda16ef9583fba2696cc3efde0da","Redacted":"","ExtraData":null,"StructuredData":null}
But everytime I try run it, I always got the first line of the parse, I cant get the next line and make the result redundace. how to capture my parse for the next line? and not just the first line.
Also i make a simple script to parse the jsonl, and it successfully like my expected. here is the script:
require 'json'
text=File.open('tes.json').read
text.gsub!(/\r\n?/, "\n")
text.each_line do |line|
parsed = JSON.parse(line)
puts parsed["Raw"]
end
result:
vulnerable-to-driveby-
39a6bda16ef9583fba2696cc3efde0da
The current result: its parse just only the first line,
expected result: I got all of the parse properly.
My expected result for my formatter script:
{"issues":[{"engineId":"trufflehog","ruleId":"Sensitive Data Exposure - vulnerable-to-driveby-","severity":"MAJOR","type":"VULNERABILITY","primaryLocation":{"message":"Sensitive Data Exposure","filePath":"../ruby/railsgoat/dependency-check-report.html","textRange":{"startLine":1,"endLine":1,"startColumn":0,"endColumn":1}},"effortMinutes":0,"severity":"MAJOR","type":"VULNERABILITY"},{"engineId":"trufflehog","ruleId":"Sensitive Data Exposure - 39a6bda16ef9583fba2696cc3efde0da","severity":"MAJOR","type":"VULNERABILITY","primaryLocation":{"message":"Sensitive Data Exposure","filePath":"../ruby/railsgoat/dependency-check-report.html","textRange":{"startLine":1,"endLine":1,"startColumn":0,"endColumn":1}},"effortMinutes":0,"severity":"MAJOR","type":"VULNERABILITY"}]}
and here what i got right now:
{"issues":[{"engineId":"trufflehog","ruleId":"Sensitive Data Exposure - 39a6bda16ef9583fba2696cc3efde0da","severity":"MAJOR","type":"VULNERABILITY","primaryLocation":{"message":"Sensitive Data Exposure","filePath":"../ruby/railsgoat/dependency-check-report.html","textRange":{"startLine":1,"endLine":1,"startColumn":0,"endColumn":1}},"effortMinutes":0,"severity":"MAJOR","type":"VULNERABILITY"},{"engineId":"trufflehog","ruleId":"Sensitive Data Exposure - 39a6bda16ef9583fba2696cc3efde0da","severity":"MAJOR","type":"VULNERABILITY","primaryLocation":{"message":"Sensitive Data Exposure","filePath":"../ruby/railsgoat/dependency-check-report.html","textRange":{"startLine":1,"endLine":1,"startColumn":0,"endColumn":1}},"effortMinutes":0,"severity":"MAJOR","type":"VULNERABILITY"}]}
PS: see the ruleId for the difference.
First, I ran your json through a formatter and it was reported as invalid. If you're going to have multiple objects you should use an array. So I've adjusted it to be: [{...},{...}]. (this is because JSON expects there to only be 1 root eleemnt.)
I think it's easiest to then say, you're doing the work that the JSON.parser is already meant to do. You can iterate through the object directly off the parser: JSON.parse(File.read("/tmp/tes.json")).map{ |obj| obj["Raw"] }
This gives me the results of => ["vulnerable-to-driveby-", "39a6bda16ef9583fba2696cc3efde0da"]

How to read multiple XML files then output to multiple CSV files with the same XML filenames

I am trying to parse multiple XML files then output them into CSV files to list out the proper rows and columns.
I was able to do so by processing one file at a time by defining the filename, and specifically output them into a defined output file name:
File.open('H:/output/xmloutput.csv','w')
I would like to write into multiple files and make their name the same as the XML filenames without hard coding it. I tried doing it multiple ways but have had no luck so far.
Sample XML:
<?xml version="1.0" encoding="UTF-8"?>
<record:root>
<record:Dataload_Request>
<record:name>Bob Chuck</record:name>
<record:Address_Data>
<record:Street_Address>123 Main St</record:Street_Address>
<record:Postal_Code>12345</record:Postal_Code>
</record:Address_Data>
<record:Age>45</record:Age>
</record:Dataload_Request>
</record:root>
Here is what I've tried:
require 'nokogiri'
require 'set'
files = ''
input_folder = "H:/input"
output_folder = "H:/output"
if input_folder[input_folder.length-1,1] == '/'
input_folder = input_folder[0,input_folder.length-1]
end
if output_folder[output_folder.length-1,1] != '/'
output_folder = output_folder + '/'
end
files = Dir[input_folder + '/*.xml'].sort_by{ |f| File.mtime(f)}
file = File.read(input_folder + '/' + files)
doc = Nokogiri::XML(file)
record = {} # hashes
keys = Set.new
records = [] # array
csv = ""
doc.traverse do |node|
value = node.text.gsub(/\n +/, '')
if node.name != "text" # skip these nodes: if class isnt text then skip
if value.length > 0 # skip empty nodes
key = node.name.gsub(/wd:/,'').to_sym
if key == :Dataload_Request && !record.empty?
records << record
record = {}
elsif key[/^root$|^document$/]
# neglect these keys
else
key = node.name.gsub(/wd:/,'').to_sym
# in case our value is html instead of text
record[key] = Nokogiri::HTML.parse(value).text
# add to our key set only if not already in the set
keys << key
end
end
end
end
# build our csv
File.open('H:/output/.*csv', 'w') do |file|
file.puts %Q{"#{keys.to_a.join('","')}"}
records.each do |record|
keys.each do |key|
file.write %Q{"#{record[key]}",}
end
file.write "\n"
end
print ''
print 'output files ready!'
print ''
end
I have been getting 'read memory': no implicit conversion of Array into String (TypeError) and other errors.
Here's a quick peer-review of your code, something like you'd get in a corporate environment...
Instead of writing:
input_folder = "H:/input"
input_folder[input_folder.length-1,1] == '/' # => false
Consider doing it using the -1 offset from the end of the string to access the character:
input_folder[-1] # => "t"
That simplifies your logic making it more readable because it's lacking unnecessary visual noise:
input_folder[-1] == '/' # => false
See [] and []= in the String documentation.
This looks like a bug to me:
files = Dir[input_folder + '/*.xml'].sort_by{ |f| File.mtime(f)}
file = File.read(input_folder + '/' + files)
files is an array of filenames. input_folder + '/' + files is appending an array to a string:
foo = ['1', '2'] # => ["1", "2"]
'/parent/' + foo # =>
# ~> -:9:in `+': no implicit conversion of Array into String (TypeError)
# ~> from -:9:in `<main>'
How you want to deal with that is left as an exercise for the programmer.
doc.traverse do |node|
is icky because it sidesteps the power of Nokogiri being able to search for a particular tag using accessors. Very rarely do we need to iterate over a document tag by tag, usually only when we're peeking at its structure and layout. traverse is slower so use it as a very last resort.
length is nice but isn't needed when checking whether a string has content:
value = 'foo'
value.length > 0 # => true
value > '' # => true
value = ''
value.length > 0 # => false
value > '' # => false
Programmers coming from Java like to use the accessors but I like being lazy, probably because of my C and Perl backgrounds.
Be careful with sub and gsub as they don't do what you're thinking they do. Both expect a regular expression, but will take a string which they do a escape on before beginning their scan.
You're passing in a regular expression, which is OK in this case, but it could cause unexpected problems if you don't remember all the rules for pattern matching and that gsub scans until the end of the string:
foo = 'wd:barwd:' # => "wd:barwd:"
key = foo.gsub(/wd:/,'') # => "bar"
In general I recommend people think a couple times before using regular expressions. I've seen some gaping holes opened up in logic written by fairly advanced programmers because they didn't know what the engine was going to do. They're wonderfully powerful, but need to be used surgically, not as a universal solution.
The same thing happens with a string, because gsub doesn't know when to quit:
key = foo.gsub('wd:','') # => "bar"
So, if you're looking to change just the first instance use sub:
key = foo.sub('wd:','') # => "barwd:"
I'd do it a little differently though.
foo = 'wd:bar'
I can check to see what the first three characters are:
foo[0,3] # => "wd:"
Or I can replace them with something else using string indexing:
foo[0,3] = ''
foo # => "bar"
There's more but I think that's enough for now.
You should use Ruby's CSV class. Also, you don't need to do any string matching or regex stuff. Use Nokogiri to target elements. If you know the node names in the XML will be consistent it should be pretty simple. I'm not exactly sure if this is the output you want, but this should get you in the right direction:
require 'nokogiri'
require 'csv'
def xml_to_csv(filename)
xml_str = File.read(filename)
xml_str.gsub!('record:','') # remove the record: namespace
doc = Nokogiri::XML xml_str
csv_filename = filename.gsub('.xml', '.csv')
CSV.open(csv_filename, 'wb' ) do |row|
row << ['name', 'street_address', 'postal_code', 'age']
row << [
doc.xpath('//name').text,
doc.xpath('//Street_Address').text,
doc.xpath('//Postal_Code').text,
doc.xpath('//Age').text,
]
end
end
# iterate over all xml files
Dir.glob('*.xml').each { |filename| xml_to_csv(filename) }

Finding certain ruby word in txt file

I am trying to create a ruby tool that goes through a file looking for a certain string, and if it finds that word than it stores it in a variable. If NOT then it prints “word not found” on the console. Is this possible? How can i code this?
You can use File#open method and readlinesmethod like this.
test.txt
This is a test string.
Lorem imsum.
Nope.
code
def get_string_from_file(string, file_path)
File.open(file_path) do |f|
f.readlines.each { |line| return string if line.include?(string) }
end
nil
end
file_path = './test.txt'
var = get_string_from_file('Lorem', file_path)
puts var || "word not found"
# => "Lorem"
var = get_string_from_file('lorem', file_path)
puts var || "word not found"
# => "word not found"
I hope this heps.
Here's few examples of how you could find a certain word in a text file using IO from the Ruby core: http://ruby-doc.org/core-2.3.1/
In find_word_in_text_file.rb:
# SETUP
#
filename1 = 'file1.txt'
filename2 = 'file2.txt'
body1 = <<~EOS
PHRASES
beside the point
irrelevant.
case in point
an instance or example that illustrates what is being discussed: the “green revolution” in agriculture is a good case in point.
get the point
understand or accept the validity of someone's idea or argument: I get the point about not sending rejections.
make one's point
put across a proposition clearly and convincingly.
make a point of
make a special and noticeable effort to do (a specified thing): she made a point of taking a walk each day.
EOS
body2 = <<~EOS
nothing to see here
or here
or here
EOS
# write body to file
File.open(filename1, 'w+') {|f| f.write(body1)}
# write file without matching word
File.open(filename2, 'w+') {|f| f.write(body2)}
# METHODS
#
# 1) search entire file as one string
def file_as_string_rx(filename, string)
# http://ruby-doc.org/core-2.3.1/Regexp.html#method-c-escape
# http://ruby-doc.org/core-2.3.1/Regexp.html#method-c-new
rx = Regexp.new(Regexp.escape(string), true) # => /whatevs/i
# read entire file to string
# http://ruby-doc.org/core-2.3.1/IO.html#method-i-read
text = IO.read(filename)
# search entire file for string; return first match
found_word = text[rx]
# print word or default string
puts found_word || "word not found"
# —OR—
#STDOUT.write found_word || "word not found"
#STDOUT.write "\n"
end
# 2) search line by line
def line_by_line_rx(filename, string)
# http://ruby-doc.org/core-2.3.1/Regexp.html#method-c-escape
# http://ruby-doc.org/core-2.3.1/Regexp.html#method-c-new
rx = Regexp.new(Regexp.escape(string), true) # => /whatevs/i
# create array to store line numbers of matches
matches_array = []
# search each line for string
# http://ruby-doc.org/core-2.3.1/IO.html#method-c-readlines
#lines = IO.readlines(filename)
#
# http://ruby-doc.org/core-2.3.1/Enumerable.html#method-i-each_with_index
# http://stackoverflow.com/a/5546681/1076207
# "Be wary of "slurping" files. That's when you
# read the entire file into memory at once.
# The problem is that it doesn't scale well.
#lines.each_with_index do |line,i|
#
# —OR—
#
# http://ruby-doc.org/core-2.3.1/IO.html#method-c-foreach
i = 1
IO.foreach(filename) do |line|
# add line number if match found within line
matches_array.push(i) if line[rx]
i += 1
end
# print array or default string
puts matches_array.any? ? matches_array.inspect : "word not found"
# —OR—
#STDOUT.write matches_array.any? ? matches_array.inspect : "word not found"
#STDOUT.write "\n"
end
# RUNNER
#
string = "point"
puts "file_as_string_rx(#{filename1.inspect}, #{string.inspect})"
file_as_string_rx(filename1, string)
puts "\nfile_as_string_rx(#{filename2.inspect}, #{string.inspect})"
file_as_string_rx(filename2, string)
puts "\nline_by_line_rx(#{filename1.inspect}, #{string.inspect})"
line_by_line_rx(filename1, string)
puts "\nline_by_line_rx(#{filename2.inspect}, #{string.inspect})"
line_by_line_rx(filename2, string)
# CLEANUP
#
File.delete(filename1)
File.delete(filename2)
Command line:
$ ruby find_word_in_text_file.rb
file_as_string_rx("file1.txt", "point")
point
file_as_string_rx("file2.txt", "point")
word not found
line_by_line_rx("file1.txt", "point")
[3, 6, 7, 9, 10, 12, 15, 16]
line_by_line_rx("file2.txt", "point")
word not found

How do I read a gzip file line by line?

I have a gzip file and currently I read it like this:
infile = open("file.log.gz")
gz = Zlib::GzipReader.new(infile)
output = gz.read
puts result
I think this converts the file to a string, but I would like to read it line by line.
What I want to accomplish is that the file has some warning messages with some garbage, I want to grep those warning messages and then write them to another file. But, some warning messages are repeated so I have to make sure that i only grep them once. Hence line by line reading would help me.
You should be able to simply loop over the gzip reader like you do with regular streams (according to the docs)
infile = open("file.log.gz")
gz = Zlib::GzipReader.new(infile)
gz.each_line do |line|
puts line
end
Try this:
infile = open("file.log.gz")
gz = Zlib::GzipReader.new(infile)
while output = gz.gets
puts output
end
Other answers show how to read the file line by line, but not how to only capture the errors once. Building on #Tigraine's answer:
require 'set'
infile = open("file.log.gz")
gz = Zlib::GzipReader.new(infile)
errors = Set.new
# or ...
# errors = [].to_set
gz.each_line do |line|
errors << line if (line[/^Error:/])
# or ...
# errors << line if (line['Error:'])
end
puts errors
Set acts like Array, but is built using Hash, so it's like a Hash but we're only concerned with the keys, i.e. only unique values are stored. If you try to add duplicates they will be thrown away, leaving you with only the unique values. You could use an Array, and afterwards use uniq, on it, but a Set will manage it for you up-front.
>> require 'set'
=> true
>> errors = Set.new
=> #<Set: {}>
>> errors << 'a'
=> #<Set: {"a"}>
>> errors << 'b'
=> #<Set: {"a", "b"}>
>> errors << 'a'
=> #<Set: {"a", "b"}>

How do I force one field in Ruby's CSV output to be wrapped with double-quotes?

I'm generating some CSV output using Ruby's built-in CSV. Everything works fine, but the customer wants the name field in the output to have wrapping double-quotes so the output looks like the input file. For instance, the input looks something like this:
1,1.1.1.1,"Firstname Lastname",more,fields
2,2.2.2.2,"Firstname Lastname, Jr.",more,fields
CSV's output, which is correct, looks like:
1,1.1.1.1,Firstname Lastname,more,fields
2,2.2.2.2,"Firstname Lastname, Jr.",more,fields
I know CSV is doing the right thing by not double-quoting the third field just because it has embedded blanks, and wrapping the field with double-quotes when it has the embedded comma. What I'd like to do, to help the customer feel warm and fuzzy, is tell CSV to always double-quote the third field.
I tried wrapping the field in double-quotes in my to_a method, which creates a "Firstname Lastname" field being passed to CSV, but CSV laughed at my puny-human attempt and output """Firstname Lastname""". That is the correct thing to do because it's escaping the double-quotes, so that didn't work.
Then I tried setting CSV's :force_quotes => true in the open method, which output double-quotes wrapping all fields as expected, but the customer didn't like that, which I expected also. So, that didn't work either.
I've looked through the Table and Row docs and nothing appeared to give me access to the "generate a String field" method, or a way to set a "for field n always use quoting" flag.
I'm about to dive into the source to see if there's some super-secret tweaks, or if there's a way to monkey-patch CSV and bend it to do my will, but wondered if anyone had some special knowledge or had run into this before.
And, yes, I know I could roll my own CSV output, but I prefer to not reinvent well-tested wheels. And, I'm also aware of FasterCSV; That's now part of Ruby 1.9.2, which I'm using, so explicitly using FasterCSV buys me nothing special. Also, I'm not using Rails and have no intention of rewriting it in Rails, so unless you have a cute way of implementing it using a small subset of Rails, don't bother. I'll downvote any recommendations to use any of those ways just because you didn't bother to read this far.
Well, there's a way to do it but it wasn't as clean as I'd hoped the CSV code could allow.
I had to subclass CSV, then override the CSV::Row.<<= method and add another method forced_quote_fields= to make it possible to define the fields I want to force-quoting on, plus pull two lambdas from other methods. At least it works for what I want:
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
That's the code. Calling it:
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
results in:
# >> Ruby version: 1.9.2
# >> Quoting fields: 1
# >>
# >> 1,"2",3
# >> 2,"two too",3
# >> 3,"two, too",3
This post is old, but I can't believe no one thought of this.
Why not do:
csv = CSV.generate :quote_char => "\0" do |csv|
where \0 is a null character, then just add quotes to each field where they are needed:
csv << [product.upc, "\"" + product.name + "\"" # ...
Then at the end you can do a
csv.gsub!(/\0/, '')
I doubt if this will help the customer feeling warm and fuzzy after all this time, but this seems to work:
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(",")}}
CSV has a force_quotes option that will force it to quote all fields (it may not have been there when you posted this originally). I realize this isn't exactly what you were proposing, but it's less monkey patching.
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"
The drawback is that the first integer value ends up listed as a string, which changes things when you import into Excel.
It's been a long time, but since the CSV library has been patched, this might help someone if they're now facing this issue:
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
the output would be:
id,ip,name,foo,bar
1,1.1.1.1,"Firstname Lastname",more,fields
2,2.2.2.2,"Firstname Lastname, Jr.",more,fields
It doesn't look like there's any way to do this with the existing CSV implementation short of monkey-patching/rewriting it.
However, assuming you have full control over the source data, you could do this:
Append a custom string including a comma (i.e. one that would never be naturally found in the data) to the end of the field in question for each row; maybe something like "FORCE_COMMAS,".
Generate the CSV output.
Now that you have CSV output with quotes on every row for your field, remove the custom string: csv.gsub!(/FORCE_COMMAS,/, "")
Customer feels warm and fuzzy.
CSV has changed a bit in Ruby 2.1 as mentioned by #jwadsack, however here's an working version of #the-tin-man's MyCSV. Bit modified, you set the forced_quote_fields via options.
MyCSV.generate(forced_quote_fields: [1]) do |_csv|...
The modified code
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

Resources