diff --git a/README.md b/README.md index ef112bc..af43a4e 100644 --- a/README.md +++ b/README.md @@ -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`. diff --git a/cmd/csvq/help.txt b/cmd/csvq/help.txt index 50207e8..841969c 100644 --- a/cmd/csvq/help.txt +++ b/cmd/csvq/help.txt @@ -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) @@ -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 @@ -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 diff --git a/cmd/csvq/main.go b/cmd/csvq/main.go index 45841b6..26b2051 100644 --- a/cmd/csvq/main.go +++ b/cmd/csvq/main.go @@ -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") @@ -72,6 +75,7 @@ func main() { Delimiter: toRune(*flagDelimiter), ShowHeaders: *flagShowHeaders, KeepCols: splitStringList(*flagKeepCols), + SortKeys: parseSortKeys(os.Args[1:]), } for i := range files { @@ -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 +} diff --git a/cmd/csvq/main_test.go b/cmd/csvq/main_test.go index 8c7fda6..36512ef 100644 --- a/cmd/csvq/main_test.go +++ b/cmd/csvq/main_test.go @@ -3,6 +3,8 @@ package main import ( "testing" + "github.com/adamdecaf/csvq/internal/cli" + "github.com/stretchr/testify/require" ) @@ -10,3 +12,51 @@ 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)) + }) + } +} diff --git a/internal/cli/file.go b/internal/cli/file.go index 546de47..e7f54c9 100644 --- a/internal/cli/file.go +++ b/internal/cli/file.go @@ -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 { @@ -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 + } +} diff --git a/internal/cli/file_test.go b/internal/cli/file_test.go new file mode 100644 index 0000000..22ec046 --- /dev/null +++ b/internal/cli/file_test.go @@ -0,0 +1,221 @@ +// Licensed to Adam Shannon under one or more contributor +// license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright +// ownership. The Moov Authors licenses this file to you under +// the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package cli + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +const scoresCSV = `name,score,date +a,12,2025 +b,45,2026 +c,32,2027 +` + +func TestHandleFile_Sort(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + opts FileOpts + want []Line + wantErr string + }{ + { + name: "asc by name without keep", + input: scoresCSV, + opts: FileOpts{ + Delimiter: ',', + SortKeys: []SortKey{{Name: "name"}}, + }, + want: []Line{ + {"a", "12", "2025"}, + {"b", "45", "2026"}, + {"c", "32", "2027"}, + }, + }, + { + name: "desc by score without keep", + input: scoresCSV, + opts: FileOpts{ + Delimiter: ',', + SortKeys: []SortKey{{Name: "score", Desc: true}}, + }, + want: []Line{ + {"b", "45", "2026"}, + {"c", "32", "2027"}, + {"a", "12", "2025"}, + }, + }, + { + name: "keep reorders columns then sort by score desc", + input: scoresCSV, + opts: FileOpts{ + Delimiter: ',', + KeepCols: []string{"score", "name"}, + SortKeys: []SortKey{{Name: "score", Desc: true}}, + }, + want: []Line{ + {"45", "b"}, + {"32", "c"}, + {"12", "a"}, + }, + }, + { + name: "keep in file order then sort by name asc", + input: scoresCSV, + opts: FileOpts{ + Delimiter: ',', + KeepCols: []string{"name", "score"}, + SortKeys: []SortKey{{Name: "name"}}, + }, + want: []Line{ + {"a", "12"}, + {"b", "45"}, + {"c", "32"}, + }, + }, + { + name: "multi-column desc score then asc name", + input: `name,score +c,10 +a,20 +b,10 +`, + opts: FileOpts{ + Delimiter: ',', + SortKeys: []SortKey{ + {Name: "score", Desc: true}, + {Name: "name"}, + }, + }, + want: []Line{ + {"a", "20"}, + {"b", "10"}, + {"c", "10"}, + }, + }, + { + name: "stable on ties", + input: `name,group +first,x +second,x +third,y +`, + opts: FileOpts{ + Delimiter: ',', + SortKeys: []SortKey{{Name: "group"}}, + }, + want: []Line{ + {"first", "x"}, + {"second", "x"}, + {"third", "y"}, + }, + }, + { + name: "unknown column", + input: scoresCSV, + opts: FileOpts{ + Delimiter: ',', + SortKeys: []SortKey{{Name: "missing"}}, + }, + wantErr: `unknown sort column "missing"`, + }, + { + name: "sort by dropped keep column", + input: scoresCSV, + opts: FileOpts{ + Delimiter: ',', + KeepCols: []string{"name"}, + SortKeys: []SortKey{{Name: "score", Desc: true}}, + }, + wantErr: `unknown sort column "score"`, + }, + { + name: "numeric not lexicographic", + input: `name,score +a,9 +b,45 +c,12 +`, + opts: FileOpts{ + Delimiter: ',', + SortKeys: []SortKey{{Name: "score"}}, + }, + want: []Line{ + {"a", "9"}, + {"c", "12"}, + {"b", "45"}, + }, + }, + { + name: "case-insensitive column names", + input: scoresCSV, + opts: FileOpts{ + Delimiter: ',', + KeepCols: []string{"SCORE", "NAME"}, + SortKeys: []SortKey{{Name: "Score", Desc: true}}, + }, + want: []Line{ + {"45", "b"}, + {"32", "c"}, + {"12", "a"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, err := HandleFile(tt.opts, strings.NewReader(tt.input)) + if tt.wantErr != "" { + require.EqualError(t, err, tt.wantErr) + return + } + require.NoError(t, err) + require.Equal(t, tt.want, got.Lines) + }) + } +} + +func TestHandleFile_SortTestdataScores(t *testing.T) { + t.Parallel() + + fd, err := os.Open(filepath.Join("..", "..", "testdata", "scores.csv")) + require.NoError(t, err) + t.Cleanup(func() { fd.Close() }) + + got, err := HandleFile(FileOpts{ + Delimiter: ',', + KeepCols: []string{"score", "name"}, + SortKeys: []SortKey{{Name: "score", Desc: true}}, + }, fd) + require.NoError(t, err) + require.Equal(t, []Line{ + {"45", "b"}, + {"32", "c"}, + {"12", "a"}, + }, got.Lines) +}