Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,12 @@ go install github.com/adamdecaf/csvq/cmd/csvq@latest

Extract the score and name, sort by highest score
```
cat scores.csv | csvq -keep score,name | sort -r
csvq -keep score,name -sort.dsc score scores.csv
```

Extract first_name and last_name columns (in that order). Sort results.
```
csvq -keep first_name,last_name ~/Downloads/report.csv | sort -u
csvq -keep first_name,last_name -sort.asc last_name,first_name ~/Downloads/report.csv
```

Change delimiter used in `report.csv`.
Expand Down
8 changes: 8 additions & 0 deletions cmd/csvq/help.txt
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ Flags:
-d, -delimiter string Field delimiter (default: ",")
-keep string Comma-separated list of column names to keep
(order is preserved in output)
-sort.asc string Comma-separated columns to sort by (ascending)
-sort.dsc string Comma-separated columns to sort by (descending)
-headers Include header row in output (default: false)
-format string Output format
Options: csv, tabs, table, json, jsonl (default: csv)
Expand All @@ -20,6 +22,9 @@ Examples:
# Same, but output as nice table
csvq -keep first_name,last_name -format table users.csv

# Highest score first (numeric), then name
csvq -keep score,name -sort.dsc score testdata/scores.csv

# Change input delimiter (e.g. semicolon)
csvq -d ';' -keep id,name,email accounts.csv

Expand All @@ -35,5 +40,8 @@ Examples:
Notes:
• Column names in -keep are case-sensitive
• Missing columns in -keep are silently ignored
• Sort columns must be present in the output (kept columns, or all columns)
• Mixed -sort.asc / -sort.dsc flags apply in the order given on the command line
• Numeric-looking cells are compared as numbers; everything else is a string
• All input files are expected to have the same headers when using -keep
• Use -format table for human-readable output during exploration
42 changes: 42 additions & 0 deletions cmd/csvq/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ var (

flagKeepCols = flag.String("keep", "", "Column headers to keep in output. Order of kept headers is maintained in output.")

_ = flag.String("sort.asc", "", "Comma-separated column headers to sort output by (ascending)")
_ = flag.String("sort.dsc", "", "Comma-separated column headers to sort output by (descending)")

flagFormat = flag.String("format", "", "Format to output resulting records in")

flagVerbose = flag.Bool("v", false, "Enable verbose logging")
Expand Down Expand Up @@ -72,6 +75,7 @@ func main() {
Delimiter: toRune(*flagDelimiter),
ShowHeaders: *flagShowHeaders,
KeepCols: splitStringList(*flagKeepCols),
SortKeys: parseSortKeys(os.Args[1:]),
}

for i := range files {
Expand Down Expand Up @@ -111,3 +115,41 @@ func splitStringList(input string) []string {
}
return ss
}

// parseSortKeys walks argv so mixed -sort.asc / -sort.dsc flags keep invocation order.
func parseSortKeys(args []string) []cli.SortKey {
var keys []cli.SortKey
for i := 0; i < len(args); i++ {
arg := args[i]
val, desc, ok, skipNext := sortFlagValue(arg, args, i)
if !ok {
continue
}
if skipNext {
i++
}
for _, name := range splitStringList(val) {
keys = append(keys, cli.SortKey{Name: name, Desc: desc})
}
}
return keys
}

func sortFlagValue(arg string, args []string, i int) (val string, desc bool, ok bool, skipNext bool) {
name, inline, hasInline := strings.Cut(arg, "=")
switch name {
case "-sort.asc", "--sort.asc":
desc = false
case "-sort.dsc", "--sort.dsc":
desc = true
default:
return "", false, false, false
}
if hasInline {
return inline, desc, true, false
}
if i+1 < len(args) && !strings.HasPrefix(args[i+1], "-") {
return args[i+1], desc, true, true
}
return "", desc, true, false
}
50 changes: 50 additions & 0 deletions cmd/csvq/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,60 @@ package main
import (
"testing"

"github.com/adamdecaf/csvq/internal/cli"

"github.com/stretchr/testify/require"
)

func TestSplitStringList(t *testing.T) {
got := splitStringList("")
require.Empty(t, got)
}

func TestParseSortKeys(t *testing.T) {
t.Parallel()

tests := []struct {
name string
args []string
want []cli.SortKey
}{
{
name: "empty",
args: []string{"file.csv"},
want: nil,
},
{
name: "desc then asc keeps invocation order",
args: []string{"-sort.dsc", "score", "-sort.asc", "name", "file.csv"},
want: []cli.SortKey{
{Name: "score", Desc: true},
{Name: "name"},
},
},
{
name: "equals form and comma list",
args: []string{"-sort.asc=name,date", "-sort.dsc=score"},
want: []cli.SortKey{
{Name: "name"},
{Name: "date"},
{Name: "score", Desc: true},
},
},
{
name: "double dash",
args: []string{"--sort.dsc", "score", "--sort.asc=name"},
want: []cli.SortKey{
{Name: "score", Desc: true},
{Name: "name"},
},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
require.Equal(t, tt.want, parseSortKeys(tt.args))
})
}
}
83 changes: 83 additions & 0 deletions internal/cli/file.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,23 @@ import (
"errors"
"fmt"
"io"
"sort"
"strconv"
"strings"
)

// SortKey is one output sort column. Desc is true for -sort.dsc.
type SortKey struct {
Name string
Desc bool
}

type FileOpts struct {
Delimiter rune
ShowHeaders bool

KeepCols []string
SortKeys []SortKey
}

type File struct {
Expand Down Expand Up @@ -103,5 +112,79 @@ func HandleFile(opts FileOpts, r io.Reader) (*File, error) {
output.Lines = append(output.Lines, line)
}

if len(opts.SortKeys) > 0 {
// Row values are stored in -keep order, which can differ from output.Headers.
resolved, err := resolveSortKeys(toKeep, opts.SortKeys)
if err != nil {
return nil, err
}
sort.SliceStable(output.Lines, func(i, j int) bool {
for _, key := range resolved {
cmp := compareCell(output.Lines[i][key.idx], output.Lines[j][key.idx])
if cmp == 0 {
continue
}
if key.desc {
return cmp > 0
}
return cmp < 0
}
return false
})
}
return &output, nil
}

type resolvedSortKey struct {
idx int
desc bool
}

func resolveSortKeys(cols []string, keys []SortKey) ([]resolvedSortKey, error) {
var out []resolvedSortKey
for _, key := range keys {
name := strings.TrimSpace(key.Name)
if name == "" {
continue
}
idx := indexCol(cols, name)
if idx < 0 {
return nil, fmt.Errorf("unknown sort column %q", key.Name)
}
out = append(out, resolvedSortKey{idx: idx, desc: key.Desc})
}
return out, nil
}

func indexCol(cols []string, name string) int {
for i, col := range cols {
if strings.EqualFold(strings.TrimSpace(col), name) {
return i
}
}
return -1
}

// compareCell orders numeric-looking cells as numbers, otherwise as strings.
func compareCell(a, b string) int {
an, aErr := strconv.ParseFloat(strings.TrimSpace(a), 64)
bn, bErr := strconv.ParseFloat(strings.TrimSpace(b), 64)
if aErr == nil && bErr == nil {
switch {
case an < bn:
return -1
case an > bn:
return 1
default:
return 0
}
}
switch {
case a < b:
return -1
case a > b:
return 1
default:
return 0
}
}
Loading
Loading