diff --git a/csvtk/cmd/mutate3.go b/csvtk/cmd/mutate3.go new file mode 100644 index 0000000..fb028cd --- /dev/null +++ b/csvtk/cmd/mutate3.go @@ -0,0 +1,470 @@ +// Copyright © 2016-2023 Wei Shen +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +package cmd + +import ( + "encoding/csv" + "fmt" + "regexp" + "runtime" + "sort" + "strconv" + "strings" + + "github.com/expr-lang/expr" + "github.com/expr-lang/expr/vm" + "github.com/mattn/go-runewidth" + "github.com/shenwei356/xopen" + "github.com/spf13/cobra" +) + +// mutate3Cmd represents the mutate command +var mutate3Cmd = &cobra.Command{ + GroupID: "edit", + + Use: "mutate3", + Short: "create a new column from selected fields with Go-like expressions", + Long: `create a new column from selected fields with Go-like expressions + +The expression language is supported by Expr: + + https://expr-lang.org/docs/language-definition + +Variables formats: + $1 or ${1} The first field/column + $a or ${a} Column "a" + ${a,b} or ${a b} or ${a (b)} Column name with special charactors, + e.g., commas, spaces, and parentheses + +Supported Operators: + + Arithmetic: + - / * ^ ** % + Comparison: > >= < <= == != + Logical: not ! and && or || + String: + contains startsWith endsWith + Regex: matches + Range: .. + Slice: [:] + Pipe: | + Ternary conditional: ? : + Null coalescence: ?? + +Supported Literals: + + Arrays: [1, 2, 3] + Boolean: true false + Float: 0.5 .5 + Integer: 42 0x2A 0o52 0b101010 + Map: {a: 1, b: 2} + Null: nil + String: "foo" 'bar' + +See Expr language definition link for documentation on built-in functions. + +Custom functions: + - ulen(), length of unicode strings/width of unicode strings rendered + to a terminal, e.g., len("沈伟")==6, ulen("沈伟")==4 +`, + Run: func(cmd *cobra.Command, args []string) { + config := getConfigs(cmd) + opts := mutate3Opts{} + opts.At = getFlagNonNegativeInt(cmd, "at") + opts.After = getFlagString(cmd, "after") + opts.Before = getFlagString(cmd, "before") + if config.NoHeaderRow { + if opts.After != "" { + checkError(fmt.Errorf("the flag --after is not allowed with -H/--no-header-row")) + } + if opts.Before != "" { + checkError(fmt.Errorf("the flag --before is not allowed with -H/--no-header-row")) + } + } + if opts.After != "" && opts.Before != "" { + checkError(fmt.Errorf("the flag --after and --before are incompatible")) + } + if opts.At > 0 && !(opts.After == "" && opts.Before == "") { + checkError(fmt.Errorf("the flag --at is incompatible with --after and --before")) + } + + opts.Files = getFileListFromArgsAndFile(cmd, args, true, "infile-list", true) + if len(opts.Files) > 1 { + checkError(fmt.Errorf("no more than one file should be given")) + } + runtime.GOMAXPROCS(config.NumCPUs) + + opts.Name = getFlagString(cmd, "name") + if !config.NoHeaderRow && opts.Name == "" && !config.NoOutHeader { + checkError(fmt.Errorf("flag -n (--name) needed")) + } + + opts.ExprStr = getFlagString(cmd, "expression") + if opts.ExprStr == "" { + checkError(fmt.Errorf("flag -e (--expression) needed")) + } + + opts.DecimalWidth = getFlagNonNegativeInt(cmd, "decimal-width") + opts.DigitsAsString = getFlagBool(cmd, "numeric-as-string") + + doMutate3(config, opts) + }, +} + +type mutate3Opts struct { + After string + At int + Before string + DecimalWidth int + DigitsAsString bool + ExprStr string + Files []string + Name string +} + +func doMutate3(config Config, opts mutate3Opts) { + outfh, err := xopen.Wopen(config.OutFile) + checkError(err) + defer outfh.Close() + + writer := csv.NewWriter(outfh) + if config.OutTabs || config.Tabs { + if config.OutDelimiter == ',' { + writer.Comma = '\t' + } else { + writer.Comma = config.OutDelimiter + } + } else { + writer.Comma = config.OutDelimiter + } + defer func() { + writer.Flush() + checkError(writer.Error()) + }() + + emptyParams := make(map[string]interface{}) + + fs := make([]string, 0) + varType := make(map[string]int) + for _, f := range reFilter2.FindAllStringSubmatch(opts.ExprStr, -1) { + if reFilter2b.MatchString(f[0]) { + varType[f[1]] = 1 + fs = append(fs, f[1]) + } else { + varType[f[2]] = 0 + fs = append(fs, f[2]) + } + } + + varSep := "__sep__" + fieldStr := strings.Join(fs, varSep) + + hasNullCoalescence := reNullCoalescence.MatchString(opts.ExprStr) + + var quote string + opts.ExprStr = reFiler2VarSymbolStartsWithDigits.ReplaceAllString(opts.ExprStr, "shenwei_$1$2") + opts.ExprStr = reFilter2VarField.ReplaceAllString(opts.ExprStr, "shenwei$1") + + var exprStr1 string + var program *vm.Program + + customFuncs := []expr.Option{ + expr.Function( + "ulen", + func(args ...interface{}) (interface{}, error) { + n := 0 + for _, s := range args { + switch s.(type) { + case int: + n += runewidth.StringWidth(fmt.Sprintf("%d", s.(int))) + case float64: + n += runewidth.StringWidth(fmt.Sprintf("%f", s.(float64))) + case string: + n += runewidth.StringWidth(s.(string)) + } + } + return float64(n), nil + }, + new(func(int) float64), + new(func(float64) float64), + new(func(string) float64), + ), + } + + fuzzyFields := false + + for _, file := range opts.Files { + csvReader, err := newCSVReaderByConfig(config, file) + + if err != nil { + if err == xopen.ErrNoContent { + if config.Verbose { + log.Warningf("csvtk mutate3: skipping empty input file: %s", file) + } + continue + } + checkError(err) + } + + csvReader.Read(ReadOption{ + FieldStr: fieldStr, + FieldStrSep: varSep, + FuzzyFields: fuzzyFields, + + DoNotAllowDuplicatedColumnName: true, + }) + + var parameters map[string]string + var parameters2 map[string]interface{} + + var col string + var fieldTmp int + var _fields []int + var i int + var ok bool + var value string + var valueFloat float64 + var result interface{} + var colnames2fileds map[string][]int // column name -> []field + var colnamesMap map[string]*regexp.Regexp + var fieldsUniq []int + var selectWithColnames bool + var record2 []string // for output + keys := make([]string, 0, 8) + + checkFirstLine := true + for record := range csvReader.Ch { + if record.Err != nil { + checkError(record.Err) + } + + if checkFirstLine { + checkFirstLine = false + + selectWithColnames = record.SelectWithColnames + + parameters = make(map[string]string, len(record.All)) + parameters2 = make(map[string]interface{}, len(record.All)) + parameters2["shenweiNULL"] = nil + + fieldsUniq = UniqInts(record.Fields) + + if !config.NoHeaderRow || record.IsHeaderRow { // do not replace head line + colnames2fileds = make(map[string][]int, len(record.All)) + + colnamesMap = make(map[string]*regexp.Regexp, len(record.All)) + for i, col = range record.All { + if _, ok = colnames2fileds[col]; !ok { + colnames2fileds[col] = []int{i + 1} + } else { + colnames2fileds[col] = append(colnames2fileds[col], i+1) + } + + colnamesMap[col] = fuzzyField2Regexp(col) + } + + value = opts.Name + record2 = record.All + record2 = append(record2, value) + + if opts.After != "" { + if _fields, ok = colnames2fileds[opts.After]; ok { + opts.At = _fields[len(_fields)-1] + 1 + } else { + checkError(fmt.Errorf(`column "%s" not existed in file: %s`, opts.After, file)) + } + copy(record2[opts.At:], record2[opts.At-1:len(record2)-1]) + record2[opts.At-1] = value + } else if opts.Before != "" { + if _fields, ok = colnames2fileds[opts.Before]; ok { + opts.At = _fields[0] + } else { + checkError(fmt.Errorf(`column "%s" not existed in file: %s`, opts.Before, file)) + } + copy(record2[opts.At:], record2[opts.At-1:len(record2)-1]) + record2[opts.At-1] = value + } else if opts.At > 0 && opts.At <= len(record2) { + copy(record2[opts.At:], record2[opts.At-1:len(record2)-1]) + record2[opts.At-1] = value + } + + if !config.NoOutHeader { + checkError(writer.Write(record2)) + } + + continue + } + } + + // prepare parameters + if !selectWithColnames { + for _, fieldTmp = range fieldsUniq { + value = record.All[fieldTmp-1] + col = strconv.Itoa(fieldTmp) + if varType[col] == 1 { + col = "${" + col + "}" + } else { + col = fmt.Sprintf("shenwei%d", fieldTmp) + } + + quote = `'` + + if reDigitals.MatchString(value) { + if opts.DigitsAsString { + parameters[col] = quote + value + quote + } else { + valueFloat, _ = strconv.ParseFloat(removeComma(value), 64) + parameters[col] = fmt.Sprintf("%.16f", valueFloat) + } + } else { + if value == "" && hasNullCoalescence { + parameters[col] = "shenweiNULL" + } else { + if strings.Contains(value, `'`) { + value = strings.ReplaceAll(value, `'`, `\'`) + } + if strings.Contains(value, `"`) { + value = strings.ReplaceAll(value, `"`, `\"`) + } + + parameters[col] = quote + value + quote + } + } + } + } else { + for col = range colnamesMap { + value = record.All[colnames2fileds[col][0]-1] + + if reFiler2ColSymbolStartsWithDigits.MatchString(col) { + col = fmt.Sprintf("shenwei_%s", col) + } else if varType[col] == 1 { + col = "${" + col + "}" + } else { + col = "$" + col + } + + quote = `'` + + if reDigitals.MatchString(value) { + if opts.DigitsAsString { + parameters[col] = quote + value + quote + } else { + valueFloat, _ = strconv.ParseFloat(removeComma(value), 64) + parameters[col] = fmt.Sprintf("%.16f", valueFloat) + } + } else { + if value == "" && hasNullCoalescence { + parameters[col] = "shenweiNULL" + } else { + if strings.Contains(value, `'`) { + value = strings.ReplaceAll(value, `'`, `\'`) + } + if strings.Contains(value, `"`) { + value = strings.ReplaceAll(value, `"`, `\"`) + } + + parameters[col] = quote + value + quote + } + } + } + } + + // sort variable names by length, so we can replace variables in the right order. + // e.g., for -e '$reads_mapped/$reads', we should firstly replace $reads_mapped then $reads. + keys = keys[:0] + for col = range parameters { + keys = append(keys, col) + } + sort.Slice(keys, func(i, j int) bool { + return len(keys[i]) > len(keys[j]) + }) + + // replace variable with column data + exprStr1 = opts.ExprStr + for _, col = range keys { + exprStr1 = strings.ReplaceAll(exprStr1, col, parameters[col]) + } + + // evaluate + program, err = expr.Compile(exprStr1, customFuncs...) + checkError(err) + + decimalFormat := fmt.Sprintf("%%.%df", opts.DecimalWidth) + + // check result + if hasNullCoalescence { + result, err = expr.Run(program, parameters2) + } else { + result, err = expr.Run(program, emptyParams) + } + if err != nil { + checkError(fmt.Errorf("data: %s, err: %s", record.All, err)) + } + switch result.(type) { + case bool: + value = fmt.Sprintf("%v", result) + case float32, float64: + value = fmt.Sprintf(decimalFormat, result) + case int, int32, int64: + value = fmt.Sprintf("%d", result) + default: + value = fmt.Sprintf("%s", result) + } + + record2 = record.All + record2 = append(record2, value) + + if opts.After != "" { + if _fields, ok = colnames2fileds[opts.After]; ok { + opts.At = _fields[len(_fields)-1] + 1 + } else { + checkError(fmt.Errorf(`column "%s" not existed in file: %s`, opts.After, file)) + } + copy(record2[opts.At:], record2[opts.At-1:len(record2)-1]) + record2[opts.At-1] = value + } else if opts.Before != "" { + if _fields, ok = colnames2fileds[opts.Before]; ok { + opts.At = _fields[0] + } else { + checkError(fmt.Errorf(`column "%s" not existed in file: %s`, opts.Before, file)) + } + copy(record2[opts.At:], record2[opts.At-1:len(record2)-1]) + record2[opts.At-1] = value + } else if opts.At > 0 && opts.At <= len(record2) { + copy(record2[opts.At:], record2[opts.At-1:len(record2)-1]) + record2[opts.At-1] = value + } + + checkError(writer.Write(record2)) + } + + readerReport(&config, csvReader, file) + } +} + +func init() { + RootCmd.AddCommand(mutate3Cmd) + mutate3Cmd.Flags().StringP("expression", "e", "", `arithmetic/string expressions. e.g. "'string'", '"abc"', ' $a + "-" + $b ', '$1 + $2', '$a / $b', ' $1 > 100 ? "big" : "small" '`) + mutate3Cmd.Flags().StringP("name", "n", "", `new column name`) + mutate3Cmd.Flags().BoolP("numeric-as-string", "s", false, `treat even numeric fields as strings to avoid converting big numbers into scientific notation`) + mutate3Cmd.Flags().IntP("decimal-width", "w", 2, "limit floats to N decimal points") + mutate3Cmd.Flags().IntP("at", "", 0, "where the new column should appear, 1 for the 1st column, 0 for the last column") + mutate3Cmd.Flags().StringP("after", "", "", "insert the new column right after the given column name") + mutate3Cmd.Flags().StringP("before", "", "", "insert the new column right before the given column name") +} diff --git a/csvtk/cmd/mutate3_test.go b/csvtk/cmd/mutate3_test.go new file mode 100644 index 0000000..91fafda --- /dev/null +++ b/csvtk/cmd/mutate3_test.go @@ -0,0 +1,230 @@ +package cmd + +import ( + "os" + "runtime" + "testing" +) + +func TestMutate3(t *testing.T) { + cases := []struct { + expect string + noHeader bool + opts mutate3Opts + tabs bool + }{ + // Strings + { + opts: mutate3Opts{ + ExprStr: ` $first_name + " " + $last_name `, + Files: []string{"../../testdata/names.csv"}, + Name: "full_name", + }, + expect: `id,first_name,last_name,username,full_name +11,Rob,Pike,rob,Rob Pike +2,Ken,Thompson,ken,Ken Thompson +4,Robert,Griesemer,gri,Robert Griesemer +1,Robert,Thompson,abc,Robert Thompson +NA,Robert,Abel,123,Robert Abel +`, + }, + + // Constants + { + tabs: true, + noHeader: true, + opts: mutate3Opts{ + ExprStr: ` "abc" `, + Files: []string{"../../testdata/digitals.tsv"}, + }, + expect: `4 5 6 abc +1 2 3 abc +7 8 0 abc +8 1,000 4 abc +`, + }, + + // Math + { + tabs: true, + noHeader: true, + opts: mutate3Opts{ + ExprStr: ` $1 + $3 `, + Files: []string{"../../testdata/digitals.tsv"}, + DecimalWidth: 0, + }, + expect: `4 5 6 10 +1 2 3 4 +7 8 0 7 +8 1,000 4 12 +`, + }, + + // Bool + { + tabs: true, + noHeader: true, + opts: mutate3Opts{ + ExprStr: ` $1 > 5 `, + Files: []string{"../../testdata/digitals.tsv"}, + }, + expect: `4 5 6 false +1 2 3 false +7 8 0 true +8 1,000 4 true +`, + }, + + // Ternary + { + tabs: true, + noHeader: true, + opts: mutate3Opts{ + ExprStr: `$1 > 5 ? "big" : "small"`, + Files: []string{"../../testdata/digitals.tsv"}, + }, + expect: `4 5 6 small +1 2 3 small +7 8 0 big +8 1,000 4 big +`, + }, + + // Null coalescence + { + opts: mutate3Opts{ + ExprStr: `$one ?? $two`, + Files: []string{"../../testdata/null_coalescence.csv"}, + Name: "three", + }, + expect: `one,two,three +a1,a2,a1 +,b2,b2 +a2,,a2 +`, + }, + + // Position: --at 1 + { + opts: mutate3Opts{ + ExprStr: `$a+$c`, + Files: []string{"../../testdata/positions.csv"}, + Name: "x", + DecimalWidth: 0, + At: 1, + }, + expect: `x,a,b,c +4,1,2,3 +`, + }, + + // Position: --at 3 + { + opts: mutate3Opts{ + ExprStr: `$a+$c`, + Files: []string{"../../testdata/positions.csv"}, + Name: "x", + DecimalWidth: 0, + At: 3, + }, + expect: `a,b,x,c +1,2,4,3 +`, + }, + + // Position: --after a + { + opts: mutate3Opts{ + ExprStr: `$a+$c`, + Files: []string{"../../testdata/positions.csv"}, + Name: "x", + DecimalWidth: 0, + After: "a", + }, + expect: `a,x,b,c +1,4,2,3 +`, + }, + + // Position: --before c + { + opts: mutate3Opts{ + ExprStr: `$a+$c`, + Files: []string{"../../testdata/positions.csv"}, + Name: "x", + DecimalWidth: 0, + Before: "c", + }, + expect: `a,b,x,c +1,2,4,3 +`, + }, + + // Date math + { + opts: mutate3Opts{ + ExprStr: `(date(${Out}) - date($In)).Hours() | int()`, + Files: []string{"../../testdata/datesub.csv"}, + Name: "Hours", + }, + expect: `ID,Name,In,Out,Hours +1,Tom,2023-08-25 11:24:00,2023-08-27 08:33:02,45 +2,Sally,2023-08-25 11:28:00,2023-08-26 14:17:35,26 +3,Alf,2023-08-26 11:29:00,2023-08-29 20:43:00,81 +`, + }, + + // len + { + opts: mutate3Opts{ + ExprStr: `len($SD)`, + Files: []string{"../../testdata/mutate3len.csv"}, + Name: "Len", + }, + expect: `SD,Len +沈伟,6 +`, + }, + + // ulen + { + opts: mutate3Opts{ + ExprStr: `ulen($SD)`, + Files: []string{"../../testdata/mutate3len.csv"}, + Name: "Len", + }, + expect: `SD,Len +沈伟,4 +`, + }, + } + + for _, c := range cases { + f, err := os.CreateTemp("", "outfile") + if err != nil { + t.Fatalf("failed to open temp file: %s\n", err) + } + defer os.Remove(f.Name()) + + config := Config{ + CommentChar: '#', + Delimiter: ',', + NoHeaderRow: c.noHeader, + NumCPUs: runtime.NumCPU(), + OutDelimiter: ',', + OutFile: f.Name(), + Tabs: c.tabs, + } + + doMutate3(config, c.opts) + + output, err := os.ReadFile(f.Name()) + if err != nil { + t.Fatalf("failed to read temp file %q: %s\n", f.Name(), err) + } + + if string(output) != c.expect { + t.Errorf("test failed:\noptions:\n\t%#v\nwant:\n\t%q\ngot:\n\t%q\n", c.opts, c.expect, output) + } + } +} diff --git a/go.mod b/go.mod index 1ae14ff..578f6d2 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de github.com/botond-sipos/thist v1.1.0 github.com/cheggaaa/pb/v3 v3.1.0 + github.com/expr-lang/expr v1.16.3 github.com/fatih/color v1.13.0 github.com/mattn/go-colorable v0.1.13 github.com/mattn/go-runewidth v0.0.14 diff --git a/go.sum b/go.sum index f420019..922a931 100644 --- a/go.sum +++ b/go.sum @@ -51,6 +51,8 @@ github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdf github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/esiqveland/notify v0.11.0/go.mod h1:63UbVSaeJwF0LVJARHFuPgUAoM7o1BEvCZyknsuonBc= +github.com/expr-lang/expr v1.16.3 h1:NLldf786GffptcXNxxJx5dQ+FzeWDKChBDqOOwyK8to= +github.com/expr-lang/expr v1.16.3/go.mod h1:uCkhfG+x7fcZ5A5sXHKuQ07jGZRl6J0FCAaf2k4PtVQ= github.com/fatih/color v1.10.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM= github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w= github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= @@ -175,11 +177,13 @@ github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/tatsushid/go-prettytable v0.0.0-20141013043238-ed2d14c29939 h1:BhIUXV2ySTLrKgh/Hnts+QTQlIbWtomXt3LMdzME0A0= github.com/tatsushid/go-prettytable v0.0.0-20141013043238-ed2d14c29939/go.mod h1:omGxs4/6hNjxPKUTjmaNkPzehSnNJOJN6pMEbrlYIT4= github.com/twotwotwo/sorts v0.0.0-20160814051341-bf5c1f2b8553 h1:DRC1ubdb3ZmyyIeCSTxjZIQAnpLPfKVgYrLETQuOPjo= diff --git a/testdata/datesub.csv b/testdata/datesub.csv new file mode 100644 index 0000000..044c043 --- /dev/null +++ b/testdata/datesub.csv @@ -0,0 +1,4 @@ +ID,Name,In,Out +1,Tom,2023-08-25 11:24:00,2023-08-27 08:33:02 +2,Sally,2023-08-25 11:28:00,2023-08-26 14:17:35 +3,Alf,2023-08-26 11:29:00,2023-08-29 20:43:00 diff --git a/testdata/mutate3len.csv b/testdata/mutate3len.csv new file mode 100644 index 0000000..469741d --- /dev/null +++ b/testdata/mutate3len.csv @@ -0,0 +1,2 @@ +SD +沈伟 diff --git a/testdata/null_coalescence.csv b/testdata/null_coalescence.csv new file mode 100644 index 0000000..1742983 --- /dev/null +++ b/testdata/null_coalescence.csv @@ -0,0 +1,4 @@ +one,two +a1,a2 +,b2 +a2, diff --git a/testdata/positions.csv b/testdata/positions.csv new file mode 100644 index 0000000..bfde6bf --- /dev/null +++ b/testdata/positions.csv @@ -0,0 +1,2 @@ +a,b,c +1,2,3