Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

85 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Simplecsv

The Go package simplecsv is a simple mini-library to handle csv files. I'm building it to help me writing small command line scripts. Maybe it's useful to someone else as well.

Some notes:

  • all read methods return the value in the csv and a second true/false value that is true if the value exists
  • all write methods that change the csv return the changed csv and a true/false value if the operation was successful
  • all cells are strings
  • methods that return a csv never modify the original csv and share no data with it: the returned csv is an independent copy. Get methods like GetRow and GetHeaders return copies
  • header names (the first row) must be unique, like database columns. Reading a file whose header row has duplicate names, creating a csv with duplicate headers, or renaming a header to a name that already exists is rejected. This guarantees name-based lookups (GetCellByField, FindInField, SortByField, GetRowAsMap, the *FromMap functions, etc.) address exactly one column.
  • all rows have the same number of cells as the header row (uniform row width). Reading a ragged file (rows with more or fewer fields than the header row) is rejected with an error, and AddRow / SetRow reject a row whose length is not the header width. This makes "the number of columns" unambiguous and keeps row operations consistent.
  • find and match (FindInColumn, FindInField, MatchInColumn, MatchInField) are case-insensitive by default: Foo = foo = FOO. Use the *CaseSensitive variants (FindInColumnCaseSensitive, FindInFieldCaseSensitive, MatchInColumnCaseSensitive, MatchInFieldCaseSensitive) when case matters.
  • CSV / formula injection: values that begin with =, +, -, @, a tab or a carriage return are interpreted as formulas by spreadsheet applications (Excel, LibreOffice Calc, Google Sheets). The Write* functions write values verbatim and do not neutralize such values, because escaping changes data. When the csv may contain attacker-controlled data (exported logs, form input, scraped content) and may be opened in a spreadsheet, call SanitizeFormulas first to prefix those cells with a single quote '. Do not open untrusted CSVs in a spreadsheet without sanitization. Callers that do not want their bytes changed should not call it.
  • writes are atomic and not world-readable: WriteCsvFile / WriteCsvFileE / WriteCsvFileComma write the csv to a temporary file in the same directory as the destination and then rename it over the destination, so a failed or interrupted write cannot truncate or corrupt the destination. The file (new or overwritten) ends up with mode 0600 (owner-readable only); callers that need different permissions can os.Chmod the result. Because the destination is replaced with os.Rename rather than opened, a symlink planted at the destination is replaced instead of followed.

Installation

go get github.com/osvik/simplecsv

Then import it in your code:

import "github.com/osvik/simplecsv"

Examples

Csv file

Simplecsv works with comma separated csv files.

Read

Reads file and parses as a SimpleCsv object. fileRead is false if there's an error reading the file or parsing the CSV, if the file's header row contains duplicate names (headers must be unique), or if the file is ragged (rows with a different number of fields than the header row are rejected; uniform row width is an invariant).

var x simplecsv.SimpleCsv
var fileRead bool
x, fileRead = simplecsv.ReadCsvFile("my1file.csv")

Create

Create empty file and define csv headers. Header names must be unique; if they are not, the returned error is non-nil and the returned csv is nil:

var u simplecsv.SimpleCsv
var err error
u, err = simplecsv.CreateEmptyCsv([]string{"Age", "Gender", "ID"})
if err != nil {
	log.Fatal(err)
}

Write

Write the SimpleCsv object to my2file.csv. If there's an error, wasWritten is false.

wasWritten := u.WriteCsvFile("my2file.csv")

The write is atomic: the csv is written to a temp file in the same directory and renamed over the destination, so a failed or interrupted write cannot truncate or corrupt the destination. The file (new or overwritten) ends up with mode 0600 (owner-readable only); callers that need different permissions can os.Chmod the result. A symlink at the destination is replaced rather than followed.

Sanitize (CSV / formula injection)

The Write* functions write values verbatim. A value that begins with =, +, -, @, a tab or a carriage return is interpreted as a formula by spreadsheet applications (Excel, LibreOffice Calc, Google Sheets), and can be used to execute commands or read other cells when the file is opened. This is a real risk when the csv contains data that may be attacker-controlled (exported logs, form input, scraped content) and is later opened in a spreadsheet. Do not open untrusted CSVs in a spreadsheet without sanitization.

SanitizeFormulas returns an independent copy of the csv in which every such cell is prefixed with a single quote ' (the spreadsheet convention), so the value is treated as text. Escaping changes data (for example -5 becomes '-5, though the spreadsheet hides the quote and displays -5 as text), so sanitizing is opt-in. The original csv is not modified.

safe := u.SanitizeFormulas()
wasWritten := safe.WriteCsvFile("export.csv")
err := safe.WriteTo(os.Stdout, ',')

Errors

ReadCsvFileE and WriteCsvFileE work like ReadCsvFile and WriteCsvFile but return an error with the reason of the failure:

x, err := simplecsv.ReadCsvFileE("my1file.csv")
if err != nil {
	log.Fatal(err)
}
err := u.WriteCsvFileE("my2file.csv")

Delimiters, readers and writers

Simplecsv uses , as the default field separator, but it can read and write files with other separators, like ; or tabs, and it can read from any io.Reader (for example os.Stdin) and write to any io.Writer (for example os.Stdout):

x, err := simplecsv.ReadCsvFileComma("semicolonfile.csv", ';')
x, err := simplecsv.ReadCsv(os.Stdin, '\t')
err := x.WriteCsvFileComma("semicolonfile.csv", ';')
err := x.WriteTo(os.Stdout, ',')

Headers

The cells of the first row are considered headers.

Get

Get all headers:

headers := x.GetHeaders()

Get header at position one (second position as it starts from 0):

headerName, headerExists := x.GetHeader(1)

Get header position: (it returns -1 if the header does not exist)

position := x.GetHeaderPosition("Gender")

Rename

Rename header: (old header, new header)

x, headerExists := x.RenameHeader("ID", "IDnumber")

headerExists is false if the old header does not exist, or if IDnumber already exists as another column: header names must stay unique, so a rename that would produce a duplicate is rejected. Renaming a header to its own name is a no-op and is allowed.

Rows

Get

Get number of rows:

numberOfRows := x.GetNumberRows()

Get second row:

row, rowExists := x.GetRow(1)

Get second row as a map:

row, rowExists := x.GetRowAsMap(1)

Add

Add a slice to a row. The slice must have the same size as the CSV number of columns. If not wasSuccessful is false.

var wasSuccessful bool
x, wasSuccessful = x.AddRow([]string{"24", "M", "2986732"})

Add row from map: (If the map keys don't exist as columns, the value will be discarded. If a key does not exist, it will create empty cells.)

mymap := make(map[string]string)
mymap["Age"] = "62"
mymap["Gender"] = "F"
mymap["ID"] = "6463246"
var wasAdded bool 
x, wasAdded = x.AddRowFromMap(mymap)

Set

Set second row (1) from a slice. The length of the slice must be the same as the number of columns and the row must already exist. If there’s an error wasSet is false.

var wasSet bool
x, wasSet = x.SetRow(1, []string{"45", "F", "8356138"})

Set second row from map: If the map keys don't exist as columns, the value will be discarded. If a key does not exist, it will create empty cells.)

mymap2 := make(map[string]string)
mymap2["Age"] = "62"
mymap2["Gender"] = "F"
mymap2["ID"] = "6463246"
var wasSet bool
x, wasSet = x.SetRowFromMap(1, mymap2)

Update

Unlike SetRowFromMap, UpdateRowCellsFromMap does not erase the cells value just because the column names are not keys in the map. It updates the cells that have the column name in the map and maintains the value of all the others.

To update the age in row 1:

mymap3 := make(map[string]string)
mymap3["Age"] = "63"
var wasUpdated bool
x, wasUpdated = x.UpdateRowCellsFromMap(1, mymap3)

Delete

Delete second row: (If the row number is invalid, wasDeleted is false)

var wasDeleted bool
x, wasDeleted = x.DeleteRow(1)

Column

Add

To add a column at the end of the CSV:

var wasSuccessful bool
x, wasSuccessful = x.AddEmptyColumn("NewColumn")

Remove

To remove a column at position 1 (second column, because it's zero based):

var wasRemoved bool
x, wasRemoved = x.RemoveColumn(1)

To remove a column by name:

var wasRemoved bool
x, wasRemoved = x.RemoveColumnByName("Gender")

Cells

Get

Get value of the cell in the second column, second row:

cellValue, cellExists := x.GetCell(1, 1)

Get the value of the cell in the column Age, second row:

cellValue, cellExists := x.GetCellByField("Age", 1)

Set

Changes the value of the cell in the first column (0) and the second row (1) to "27":

var wasChanged bool 
x, wasChanged = x.SetCell(0, 1, "27")

The same, using the column name instead of the column position:

var wasChanged bool 
x, wasChanged = x.SetCellByField("Age", 1, "27")

Find

Find in column

Find the word "27" in the first column (column 0):

rowsWithWord, validColumn := x.FindInColumn(0, "27")

It returns a slice of rownumbers (int) where you can find the word in the column position. Please note that in simplecsv all cells are strings.

If it doesn't find the value, it returns an empty slice.

In FindInColumn and in FindInField case does not matter. Foo = foo = FOO.

Find in field

The same as FindInColumn but using a column/field name instead of position. Please note that FindInField, unlike FindInColumn never includes the header in the search result.

rowsWithWord, validFieldName := x.FindInField("Age", "27")

If the field name does not exist, the second value returned (validFieldName) is false.

Match in column

Find where results match a regular expression in the third column (column 2):

rowsWithWord, areParamsOk := x.MatchInColumn(2, "p([a-z]+)ch$")

Use ^ and $ in the regular expression to match exact results.

Matching is case-insensitive by default: Foo = foo = FOO. Use MatchInColumnCaseSensitive when case matters.

Match in field

Same as with MatchInColumn, but with a field (column name). Find where results match a regular expression in the third column (column "ID"):

rowsWithWord, areParamsOk := x.MatchInField("ID", "p([a-z]+)ch$")

Use ^ and $ in the regular expression to match exact results.

Please note that MatchInField, unlike MatchInColumn never includes the header in the search result.

Matching is case-insensitive by default: Foo = foo = FOO. Use MatchInFieldCaseSensitive when case matters.

Case sensitive find

FindInColumn, FindInField, MatchInColumn and MatchInField ignore case by default. Use the *CaseSensitive variants when case matters:

rowsWithWord, validFieldName := x.FindInFieldCaseSensitive("Name", "Ana")
rowsWithWord, areParamsOk := x.MatchInFieldCaseSensitive("ID", "p([a-z]+)ch$")

Sort

Sort by field

Sorts the csv by a column name and returns a new sorted csv. The header row stays at the top and the original csv is not modified. Numbers are sorted numerically ("7" comes before "30"), other values as strings. The sort is stable.

sortedCsv, fieldExists := x.SortByField("Age", true) // true: ascending, false: descending

Sort by column

The same as SortByField but using a column position. It sorts all the rows, including the first one:

sortedCsv, validColumn := x.SortByColumn(0, true)

Sort an index

SortIndex returns a sorted copy of an index:

sortedIndex := simplecsv.SortIndex([]int{5, 1, 3})

Filter rows

FilterRows returns a new csv with the rows where the predicate function returns true. If header is true, the first row is kept as the header:

adults := x.FilterRows(func(row []string) bool {
	age, _ := strconv.Atoi(row[1])
	return age >= 18
}, true)

Join

Inner join

JoinByField joins two csvs by a common field and returns a new csv with the rows where the field has the same value in both csvs. The result has all the columns of the first csv, followed by the columns of the second csv except the join column. If a join value shows up more than once, all combinations of rows are in the result. Values are compared exactly: case matters. The source csvs are not modified.

people, _ := simplecsv.ReadCsvFile("people.csv") // columns: ID, Name
ages, _ := simplecsv.ReadCsvFile("ages.csv")     // columns: ID, Age
joined, fieldExistsInBoth := people.JoinByField(ages, "ID")

fieldExistsInBoth is false if the field does not exist in one of the csvs.

Left join

LeftJoinByField is like JoinByField, but the rows of the first csv without a match in the second one are also in the result, with empty cells in the columns of the second csv:

joined, fieldExistsInBoth := people.LeftJoinByField(ages, "ID")

Boolean functions

Use boolean functions AND, OR and NOT to combine indexes and produce other complex indexes that point to rows in the csv. Very useful to produce search results.

Indexes are slices of row numbers (integers between 0 or 1 and the length of the csv -1). The indexes returned by OrIndex, AndIndex and NotIndex are sorted in ascending order.

OR 2 indexes

var w []int
w = simplecsv.OrIndex(a,b)
w = simplecsv.OrIndex(a,b,c,d,e)

Note: The OrIndex function can be used in 2 or more operands.

AND 2 indexes

var p []int
p = simplecsv.AndIndex(a,b)
p = simplecsv.AndIndex(a,b,c,d,e)

Note: The AndIndex function can be used in 2 or more operands.

NOT an index

The code below returns the negative of the index g, between row 1 and row 4. If g is an index with the values {1, 2} the negative of g is {3, 4}. Because 3 and 4 are the integers between 1 and 4 that are not in g.

var g []int
min := 1
max := 4
p = simplecsv.NotIndex(g, min, max)

Note: For csvs with headers the min value is usually 1 and for csvs without headers the min value is usually 0.

Only

Only are 2 functions to simplify and sort a csv.

Only this rows

It removes rows that are not in the index and reorders a CSV by the index order. If header is true, it starts by the csv header. Note: if header is true and the index contains 0, the header row appears twice in the result (once as the header, once as row 0).

newIndex := []int{1,3}
header := true
x, _ = x.OnlyThisRows(newIndex, header)

Only this fields

Removes fields that are not in the list of fields, reorders the CSV by the list of fields and adds fields that do not exist as blank fields.

fieldsList := []string{"Age","ID"}
x, _ = x.OnlyThisFields(fieldsList)

About

The Go package simplecsv is a simple mini-library to handle csv files.

Topics

Resources

Stars

Watchers

Forks

Releases

Packages

Used by

Contributors

Languages