Skip to content

Commit

Permalink
Fix Header-less file bug and add ability to encode without Quotes (#34)
Browse files Browse the repository at this point in the history
  • Loading branch information
FourierTransformer committed Feb 3, 2023
1 parent c858f99 commit 5be2f78
Show file tree
Hide file tree
Showing 4 changed files with 214 additions and 17 deletions.
7 changes: 7 additions & 0 deletions README.md
Expand Up @@ -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
Expand Down
69 changes: 52 additions & 17 deletions 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
Expand Down Expand Up @@ -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!
Expand Down Expand Up @@ -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
Expand All @@ -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 = [[
Expand All @@ -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

Expand All @@ -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

Expand Down Expand Up @@ -736,16 +771,16 @@ 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

-- works really quickly with luajit-2.1, because table.concat life
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

Expand Down
138 changes: 138 additions & 0 deletions spec/feature_spec.lua
Expand Up @@ -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] = {}
Expand All @@ -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] = {}
Expand All @@ -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] = {}
Expand All @@ -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] = {}
Expand All @@ -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] = {}
Expand All @@ -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] = {}
Expand All @@ -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] = {}
Expand All @@ -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] = {}
Expand All @@ -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] = {}
Expand Down Expand Up @@ -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] = {}
Expand Down
17 changes: 17 additions & 0 deletions spec/parse_encode_spec.lua
Expand Up @@ -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)

0 comments on commit 5be2f78

Please sign in to comment.