diff --git a/README.md b/README.md index 6a07834..ea3a744 100644 --- a/README.md +++ b/README.md @@ -143,6 +143,13 @@ file:close() local output = ftcsv.encode(everyUser, ",", {fieldsToKeep={"Name", "Phone", "City"}}) ``` + - `noQuotes` + + if `noQuotes` is set to `true`, the output will not include quotes around fields. + + ```lua + local output = ftcsv.encode(everyUser, ",", {noQuotes=true}) + ``` ## Error Handling diff --git a/ftcsv.lua b/ftcsv.lua index 68b6fe4..16fbcf4 100644 --- a/ftcsv.lua +++ b/ftcsv.lua @@ -1,11 +1,11 @@ local ftcsv = { - _VERSION = 'ftcsv 1.2.0', + _VERSION = 'ftcsv 1.3.0', _DESCRIPTION = 'CSV library for Lua', _URL = 'https://github.com/FourierTransformer/ftcsv', _LICENSE = [[ The MIT License (MIT) - Copyright (c) 2016-2020 Shakil Thakur + Copyright (c) 2016-2023 Shakil Thakur Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -329,18 +329,18 @@ local function parseString(inputString, i, options) end local function handleHeaders(headerField, options) - -- make sure a header isn't empty - for _, headerName in ipairs(headerField) do - if #headerName == 0 then - error('ftcsv: Cannot parse a file which contains empty headers') - end - end - -- for files where there aren't headers! if options.headers == false then for j = 1, #headerField do headerField[j] = j end + else + -- make sure a header isn't empty if there are headers + for _, headerName in ipairs(headerField) do + if #headerName == 0 then + error('ftcsv: Cannot parse a file which contains empty headers') + end + end end -- rename fields as needed! @@ -645,6 +645,17 @@ local function delimitField(field) end end +local function delimitAndQuoteField(field) + field = tostring(field) + if field:find('"') then + return '"' .. field:gsub('"', '""') .. '"' + elseif field:find('[\n,]') then + return '"' .. field .. '"' + else + return field + end +end + local function escapeHeadersForLuaGenerator(headers) local escapedHeaders = {} for i = 1, #headers do @@ -658,7 +669,7 @@ local function escapeHeadersForLuaGenerator(headers) end -- a function that compiles some lua code to quickly print out the csv -local function csvLineGenerator(inputTable, delimiter, headers) +local function csvLineGenerator(inputTable, delimiter, headers, options) local escapedHeaders = escapeHeadersForLuaGenerator(headers) local outputFunc = [[ @@ -670,11 +681,26 @@ local function csvLineGenerator(inputTable, delimiter, headers) delimiter .. [["' .. args.delimitField(args.t[i]["]]) .. [["]) .. '"\r\n']] + if options and options.noQuotes == true then + outputFunc = [[ + local args, i = ... + i = i + 1; + if i > ]] .. #inputTable .. [[ then return nil end; + return i, args.delimitField(args.t[i]["]] .. + table.concat(escapedHeaders, [["]) .. ']] .. + delimiter .. [[' .. args.delimitField(args.t[i]["]]) .. + [["]) .. '\r\n']] + end + local arguments = {} arguments.t = inputTable -- we want to use the same delimitField throughout, -- so we're just going to pass it in - arguments.delimitField = delimitField + if options and options.noQuotes == true then + arguments.delimitField = delimitAndQuoteField + else + arguments.delimitField = delimitField + end return luaCompatibility.load(outputFunc), arguments, 0 @@ -688,17 +714,26 @@ local function validateHeaders(headers, inputTable) end end -local function initializeOutputWithEscapedHeaders(escapedHeaders, delimiter) +local function initializeOutputWithEscapedHeaders(escapedHeaders, delimiter, options) local output = {} - output[1] = '"' .. table.concat(escapedHeaders, '"' .. delimiter .. '"') .. '"\r\n' + if options and options.noQuotes == true then + output[1] = table.concat(escapedHeaders, delimiter) .. '\r\n' + else + output[1] = '"' .. table.concat(escapedHeaders, '"' .. delimiter .. '"') .. '"\r\n' + end return output end -local function escapeHeadersForOutput(headers) +local function escapeHeadersForOutput(headers, options) local escapedHeaders = {} + local delimitField = delimitField + if options and options.noQuotes == true then + delimitField = delimitAndQuoteField + end for i = 1, #headers do escapedHeaders[i] = delimitField(headers[i]) end + return escapedHeaders end @@ -736,8 +771,8 @@ local function initializeGenerator(inputTable, delimiter, options) end validateHeaders(headers, inputTable) - local escapedHeaders = escapeHeadersForOutput(headers) - local output = initializeOutputWithEscapedHeaders(escapedHeaders, delimiter) + local escapedHeaders = escapeHeadersForOutput(headers, options) + local output = initializeOutputWithEscapedHeaders(escapedHeaders, delimiter, options) return output, headers end @@ -745,7 +780,7 @@ end function ftcsv.encode(inputTable, delimiter, options) local output, headers = initializeGenerator(inputTable, delimiter, options) - for i, line in csvLineGenerator(inputTable, delimiter, headers) do + for i, line in csvLineGenerator(inputTable, delimiter, headers, options) do output[i+1] = line end diff --git a/spec/feature_spec.lua b/spec/feature_spec.lua index e2f0ba6..5faca40 100644 --- a/spec/feature_spec.lua +++ b/spec/feature_spec.lua @@ -164,6 +164,21 @@ describe("csv features", function() assert.are.same(expected, actual) end) + it("should handle files without headers with an empty header field", function() + local expected = {} + expected[1] = {} + expected[1][1] = "apple" + expected[1][2] = "banana" + expected[1][3] = "" + expected[2] = {} + expected[2][1] = "diamond" + expected[2][2] = "emerald" + expected[2][3] = "pearl" + local options = {loadFromString=true, headers=false} + local actual = ftcsv.parse("apple>banana>\ndiamond>emerald>pearl", ">", options) + assert.are.same(expected, actual) + end) + it("should handle files without (headers and newlines)", function() local expected = {} expected[1] = {} @@ -175,6 +190,17 @@ describe("csv features", function() assert.are.same(expected, actual) end) + it("should handle files without (headers and newlines) with an empty header field", function() + local expected = {} + expected[1] = {} + expected[1][1] = "apple" + expected[1][2] = "banana" + expected[1][3] = "" + local options = {loadFromString=true, headers=false} + local actual = ftcsv.parse("apple>banana>", ">", options) + assert.are.same(expected, actual) + end) + it("should handle files with quotes and without (headers and newlines)", function() local expected = {} expected[1] = {} @@ -186,6 +212,17 @@ describe("csv features", function() assert.are.same(expected, actual) end) + it("should handle files with quotes and without (headers and newlines) with an empty header field", function() + local expected = {} + expected[1] = {} + expected[1][1] = "apple" + expected[1][2] = "banana" + expected[1][3] = "" + local options = {loadFromString=true, headers=false} + local actual = ftcsv.parse('"apple">"banana">', ">", options) + assert.are.same(expected, actual) + end) + it("should handle files with quotes and without (headers and newlines)", function() local expected = {} expected[1] = {} @@ -201,6 +238,21 @@ describe("csv features", function() assert.are.same(expected, actual) end) + it("should handle files with quotes and without (headers and newlines) with an empty header field", function() + local expected = {} + expected[1] = {} + expected[1][1] = "apple" + expected[1][2] = "banana" + expected[1][3] = "" + expected[2] = {} + expected[2][1] = "diamond" + expected[2][2] = "emerald" + expected[2][3] = "pearl" + local options = {loadFromString=true, headers=false} + local actual = ftcsv.parse('"apple">"banana">\n"diamond">"emerald">"pearl"', ">", options) + assert.are.same(expected, actual) + end) + it("should handle files without (headers and newlines) w/newline at end", function() local expected = {} expected[1] = {} @@ -212,6 +264,17 @@ describe("csv features", function() assert.are.same(expected, actual) end) + it("should handle files without (headers and newlines) w/newline at end with an empty header field", function() + local expected = {} + expected[1] = {} + expected[1][1] = "apple" + expected[1][2] = "banana" + expected[1][3] = "" + local options = {loadFromString=true, headers=false} + local actual = ftcsv.parse("apple>banana>\n", ">", options) + assert.are.same(expected, actual) + end) + it("should handle files without (headers and newlines) w/crlf", function() local expected = {} expected[1] = {} @@ -223,6 +286,17 @@ describe("csv features", function() assert.are.same(expected, actual) end) + it("should handle files without (headers and newlines) w/crlf with an empty header field", function() + local expected = {} + expected[1] = {} + expected[1][1] = "apple" + expected[1][2] = "banana" + expected[1][3] = "" + local options = {loadFromString=true, headers=false} + local actual = ftcsv.parse("apple>banana>\r\n", ">", options) + assert.are.same(expected, actual) + end) + it("should handle files without (headers and newlines) w/cr", function() local expected = {} expected[1] = {} @@ -234,6 +308,18 @@ describe("csv features", function() assert.are.same(expected, actual) end) + it("should handle files without (headers and newlines) w/cr with an empty header field", function() + local expected = {} + expected[1] = {} + expected[1][1] = "apple" + expected[1][2] = "banana" + expected[1][3] = "" + local options = {loadFromString=true, headers=false} + local actual = ftcsv.parse("apple>banana>\r", ">", options) + assert.are.same(expected, actual) + end) + + it("should handle only renaming fields from files without headers", function() local expected = {} expected[1] = {} @@ -249,6 +335,21 @@ describe("csv features", function() assert.are.same(expected, actual) end) + it("should handle only renaming fields from files without headers with an empty header field", function() + local expected = {} + expected[1] = {} + expected[1].a = "apple" + expected[1].b = "banana" + expected[1].c = "" + expected[2] = {} + expected[2].a = "diamond" + expected[2].b = "emerald" + expected[2].c = "pearl" + local options = {loadFromString=true, headers=false, rename={"a","b","c"}} + local actual = ftcsv.parse("apple>banana>\ndiamond>emerald>pearl", ">", options) + assert.are.same(expected, actual) + end) + it("should handle only renaming fields from files without headers and only keeping a few fields", function() local expected = {} expected[1] = {} @@ -262,6 +363,19 @@ describe("csv features", function() assert.are.same(expected, actual) end) + it("should handle only renaming fields from files without headers and only keeping a few fields with an empty header field", function() + local expected = {} + expected[1] = {} + expected[1].a = "apple" + expected[1].b = "" + expected[2] = {} + expected[2].a = "diamond" + expected[2].b = "emerald" + local options = {loadFromString=true, headers=false, rename={"a","b","c"}, fieldsToKeep={"a","b"}} + local actual = ftcsv.parse("apple>>carrot\ndiamond>emerald>pearl", ">", options) + assert.are.same(expected, actual) + end) + it("should handle if the number of renames doesn't equal the number of fields", function() local expected = {} expected[1] = {} @@ -318,6 +432,30 @@ describe("csv features", function() assert.are.same(expected, actual) end) + it("should handle encoding files (str test)", function() + local expected = '"a","b","c","d"\r\n"1","","foo","""quoted"""\r\n' + output = ftcsv.encode({ + { a = 1, b = '', c = 'foo', d = '"quoted"' }; + }, ',') + assert.are.same(expected, output) + end) + + it("should handle encoding files without quotes (str test)", function() + local expected = 'a,b,c,d\r\n1,,foo,"""quoted"""\r\n' + output = ftcsv.encode({ + { a = 1, b = '', c = 'foo', d = '"quoted"' }; + }, ',', {noQuotes=true}) + assert.are.same(expected, output) + end) + + it("should handle encoding files without quotes with certain fields to keep (str test)", function() + local expected = "b,c\r\n,foo\r\n" + output = ftcsv.encode({ + { a = 1, b = '', c = 'foo', d = '"quoted"' }; + }, ',', {noQuotes=true, fieldsToKeep={"b", "c"}}) + assert.are.same(expected, output) + end) + it("should handle headers attempting to escape", function() local expected = {} expected[1] = {} diff --git a/spec/parse_encode_spec.lua b/spec/parse_encode_spec.lua index 0ae250f..8a6b91a 100644 --- a/spec/parse_encode_spec.lua +++ b/spec/parse_encode_spec.lua @@ -87,3 +87,20 @@ describe("csv encode", function() end) end end) + +describe("csv encode without quotes", function() + for _, value in ipairs(files) do + it("should handle " .. value, function() + local jsonFile = loadFile("spec/json/" .. value .. ".json") + local jsonDecode = cjson.decode(jsonFile) + -- local parse = staecsv:ftcsv(contents, ",") + local reEncodedNoQuotes = ftcsv.parse(ftcsv.encode(jsonDecode, ",", {noQuotes=true}), ",", {loadFromString=true}) + -- local f = csv.openstring(contents, {separator=",", header=true}) + -- local parse = {} + -- for fields in f:lines() do + -- parse[#parse+1] = fields + -- end + assert.are.same(jsonDecode, reEncodedNoQuotes) + end) + end +end)