From 3fb69fc1a7ea044638a6cec603c6df3d5335c86f Mon Sep 17 00:00:00 2001 From: CHIKAMATSU Naohiro Date: Sun, 12 May 2024 01:25:03 +0900 Subject: [PATCH] First version --- .all-contributorsrc | 25 ++ .github/FUNDING.yml | 1 + .github/ISSUE_TEMPLATE/bug_report.md | 28 +++ .github/ISSUE_TEMPLATE/issue.md | 17 ++ .github/dependabot.yml | 13 + .github/workflows/coverge.yml | 31 +++ .github/workflows/multi_platform_ut.yml | 36 +++ .github/workflows/reviewdog.yml | 41 ++++ .gitignore | 1 + .golangci.yml | 48 ++++ .octocov.yml | 20 ++ CODE_OF_CONDUCT.md | 1 + Makefile | 32 +++ README.md | 138 ++++++++++- SECURITY.md | 21 ++ csv.go | 145 ++++++++++++ csv_test.go | 197 ++++++++++++++++ errors.go | 34 +++ example_test.go | 42 ++++ go.mod | 8 + go.sum | 4 + option.go | 20 ++ parser.go | 124 ++++++++++ parser_test.go | 87 +++++++ tag.go | 47 ++++ testdata/all_error.csv | 9 + testdata/sample.csv | 4 + testdata/sample.tsv | 4 + testdata/sample_headerless.csv | 3 + validation.go | 302 ++++++++++++++++++++++++ validation_test.go | 166 +++++++++++++ 31 files changed, 1647 insertions(+), 2 deletions(-) create mode 100644 .all-contributorsrc create mode 100644 .github/FUNDING.yml create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/issue.md create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/coverge.yml create mode 100644 .github/workflows/multi_platform_ut.yml create mode 100644 .github/workflows/reviewdog.yml create mode 100644 .golangci.yml create mode 100644 .octocov.yml create mode 100644 CODE_OF_CONDUCT.md create mode 100644 Makefile create mode 100644 SECURITY.md create mode 100644 csv.go create mode 100644 csv_test.go create mode 100644 errors.go create mode 100644 example_test.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 option.go create mode 100644 parser.go create mode 100644 parser_test.go create mode 100644 tag.go create mode 100644 testdata/all_error.csv create mode 100644 testdata/sample.csv create mode 100644 testdata/sample.tsv create mode 100644 testdata/sample_headerless.csv create mode 100644 validation.go create mode 100644 validation_test.go diff --git a/.all-contributorsrc b/.all-contributorsrc new file mode 100644 index 0000000..ac60a45 --- /dev/null +++ b/.all-contributorsrc @@ -0,0 +1,25 @@ +{ + "projectName": "csv", + "projectOwner": "nao1215", + "repoType": "github", + "repoHost": "https://github.com", + "files": [ + "README.md" + ], + "imageSize": 75, + "commit": true, + "commitConvention": "angular", + "contributors": [ + { + "login": "nao1215", + "name": "CHIKAMATSU Naohiro", + "avatar_url": "https://avatars.githubusercontent.com/u/22737008?v=4", + "profile": "https://debimate.jp/", + "contributions": [ + "doc" + ] + } + ], + "contributorsPerLine": 7, + "linkToUsage": true +} diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..e39a5ed --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +github: nao1215 diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..3f70bd3 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,28 @@ +--- +name: Bug report +about: Create a report to help us improve +title: "[BUG] " +labels: bug +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Desktop (please complete the following information):** + - Go Version [e.g. 1.17] + - Library Version [e.g. 1.0.1] + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/issue.md b/.github/ISSUE_TEMPLATE/issue.md new file mode 100644 index 0000000..c33cc79 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/issue.md @@ -0,0 +1,17 @@ +--- +name: Task +about: Describe this issue +title: '' +labels: '' +assignees: '' + +--- + +## What + +Describe what this issue should address. + +## How + +Describe how to address the issue. + diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..5463252 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,13 @@ +version: 2 +updates: + - package-ecosystem: gomod + directory: "/" + schedule: + interval: daily + time: "20:00" + open-pull-requests-limit: 10 + # Maintain dependencies for GitHub Actions + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "daily" diff --git a/.github/workflows/coverge.yml b/.github/workflows/coverge.yml new file mode 100644 index 0000000..34d0371 --- /dev/null +++ b/.github/workflows/coverge.yml @@ -0,0 +1,31 @@ +name: Coverage + +on: + workflow_dispatch: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + unit_test: + name: Unit test (linux) + + strategy: + matrix: + platform: [ubuntu-latest] + + runs-on: ${{ matrix.platform }} + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: "1" + check-latest: true + + - name: Run tests with coverage report output + run: go test -cover -coverpkg=./... -coverprofile=coverage.out ./... + + - uses: k1LoW/octocov-action@v1 diff --git a/.github/workflows/multi_platform_ut.yml b/.github/workflows/multi_platform_ut.yml new file mode 100644 index 0000000..2356a37 --- /dev/null +++ b/.github/workflows/multi_platform_ut.yml @@ -0,0 +1,36 @@ +name: MultiPlatformUnitTest + +on: + workflow_dispatch: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + unit_test: + name: Unit test (linux) + + strategy: + matrix: + os: + - "ubuntu-latest" + - "windows-latest" + - "macos-latest" + go: + - "1" + - "1.22" + - "1.21" + - "1.20" + fail-fast: false + runs-on: ${{ matrix.os }} + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: ${{ matrix.go }} + + - name: Run tests with coverage report output + run: go test -cover -coverpkg=./... -coverprofile=coverage.out ./... diff --git a/.github/workflows/reviewdog.yml b/.github/workflows/reviewdog.yml new file mode 100644 index 0000000..ebaac1e --- /dev/null +++ b/.github/workflows/reviewdog.yml @@ -0,0 +1,41 @@ +name: reviewdog +on: [pull_request] + +jobs: + golangci-lint: + name: golangci-lint + runs-on: ubuntu-latest + steps: + - name: Check out code into the Go module directory + uses: actions/checkout@v4 + with: + persist-credentials: false + - name: golangci-lint + uses: reviewdog/action-golangci-lint@v2 + with: + reporter: github-pr-review + level: warning + + misspell: + name: misspell + runs-on: ubuntu-latest + steps: + - name: Check out code into the Go module directory + uses: actions/checkout@v4 + with: + persist-credentials: false + - name: misspell + uses: reviewdog/action-misspell@v1 + with: + reporter: github-pr-review + level: warning + locale: "US" + + actionlint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: reviewdog/action-actionlint@v1 + with: + reporter: github-pr-review + level: warning diff --git a/.gitignore b/.gitignore index 3b735ec..5ec8727 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,4 @@ # Go workspace file go.work +coverage.* diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..d6e784a --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,48 @@ +run: + go: "1.21" + +issues: + exclude-use-default: false + +linters: + disable-all: true + enable: + - errcheck + - gosimple + - govet + - ineffassign + - staticcheck + - typecheck + - unused + - asciicheck + - bodyclose + - dogsled + - durationcheck + - errorlint + - exhaustive + - exportloopref + - forcetypeassert + - gochecknoinits + - goconst + - gocritic + - goimports + - gosec + - misspell + - nakedret + - noctx + - prealloc + - rowserrcheck + - sqlclosecheck + - stylecheck + - tagliatelle + - thelper + - unconvert + - unparam + - wastedassign + - whitespace +linters-settings: + tagliatelle: + case: + use-field-name: true + rules: + json: snake diff --git a/.octocov.yml b/.octocov.yml new file mode 100644 index 0000000..d37a96f --- /dev/null +++ b/.octocov.yml @@ -0,0 +1,20 @@ +# generated by octocov init +coverage: + if: true + acceptable: 80% + badge: + path: docs/coverage.svg +diff: + datastores: + - artifact://${GITHUB_REPOSITORY} +comment: + if: is_pull_request +summary: + if: true +report: + if: is_default_branch + datastores: + - artifact://${GITHUB_REPOSITORY} +codeToTestRatio: + badge: + path: docs/ratio.svg diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..7fb86f2 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1 @@ +Please approach others with respect. That is everything. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..3fc504f --- /dev/null +++ b/Makefile @@ -0,0 +1,32 @@ +.PHONY: build test clean help tools changelog + +APP = csv +VERSION = $(shell git describe --tags --abbrev=0) +GIT_REVISION := $(shell git rev-parse HEAD) +GO = go +GO_BUILD = $(GO) build +GO_TEST = $(GO) test -v +GO_TOOL = $(GO) tool +GOOS = "" +GOARCH = "" +GO_PKGROOT = ./... +GO_PACKAGES = $(shell $(GO_LIST) $(GO_PKGROOT)) +GO_LDFLAGS = + +clean: ## Clean project + -rm -rf $(APP) coverage.out coverage.html + +test: ## Start unit test for server + env GOOS=$(GOOS) $(GO_TEST) -cover -coverpkg=$(GO_PKGROOT) -coverprofile=coverage.out $(GO_PKGROOT) + $(GO_TOOL) cover -html=coverage.out -o coverage.html + +.DEFAULT_GOAL := help +help: ## Show help message + @grep -E '^[0-9a-zA-Z_-]+[[:blank:]]*:.*?## .*$$' $(MAKEFILE_LIST) | sort \ + | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[1;32m%-15s\033[0m %s\n", $$1, $$2}' + +changelog: ## Generate changelog + ghch --format markdown > CHANGELOG.md + +tools: ## Install dependency tools + $(GO_INSTALL) github.com/Songmu/ghch/cmd/ghch@latest diff --git a/README.md b/README.md index 58d0ac9..21068a3 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,136 @@ -# csv -csv - - decode csv with validation + +[![All Contributors](https://img.shields.io/badge/all_contributors-1-orange.svg?style=flat-square)](#contributors-) + + + +## What is csv package? + +The csv package is a library for performing validation when reading CSV or TSV files. Validation rules are specified using struct tags. The csv package read returns which columns of which rows do not adhere to the specified rules. + +## Why need csv package? + +I was frustrated with error-filled CSV files written by non-engineers. + +I encountered a use case of "importing one CSV file into multiple DB tables". Unfortunately, I couldn't directly import the CSV file into the DB tables. So, I attempted to import the CSV file through a Go-based application. + +What frustrated me was not knowing where the errors in the CSV file were. Existing libraries didn't provide output like "The value in the Mth column of the Nth row is incorrect". I attempted to import multiple times and processed error messages one by one. Eventually, I started writing code to parse each column, which wasted a considerable amount of time. + +Based on the above experience, I decided to create a generic CSV validation tool. + +## How to use + +Please attach the "validate:" tag to your structure and write the validation rules after it. It's crucial that the "order of columns" matches the "order of field definitions" in the structure. The csv package does not automatically adjust the order. + +When using csv.Decode, please pass a pointer to a slice of structures tagged with struct tags. The csv package will perform validation based on the struct tags and save the read results to the slice of structures if there are no errors. If there are errors, it will return them as []error. + +```go +package main + +import ( + "bytes" + "fmt" + + "github.com/nao1215/csv" +) + +func main() { + input := `id,name,age +1,Gina,23 +a,Yulia,25 +3,Den1s,30 +` + buf := bytes.NewBufferString(input) + c, err := csv.NewCSV(buf) + if err != nil { + panic(err) + } + + type person struct { + ID int `validate:"numeric"` + Name string `validate:"alpha"` + Age int `validate:"gt=24"` + } + people := make([]person, 0) + + errs := c.Decode(&people) + if len(errs) != 0 { + for _, err := range errs { + fmt.Println(err.Error()) + } + } + + // Output: + // line:2 column age: target is not greater than the threshold value: threshold=24.000000, value=23.000000 + // line:3 column id: target is not a numeric character: value=a + // line:4 column name: target is not an alphabetic character: value=Den1s +} +``` + +### Struct tags + +You set the validation rules following the "validate:" tag according to the rules in the table below. If you need to set multiple rules, please enumerate them separated by commas. + +#### Validation rule without arguments + +| Tag Name | Description | +|-------------------|---------------------------------------------------| +| boolean | Check whether value is boolean or not. | +| alpha | Check whether value is alphabetic or not | +| numeric | Check whether value is numeric or not | +| alphanumeric | Check whether value is alphanumeric or not | +| required | Check whether value is empty or not | + +#### Validation rule with numeric argument + +| Tag Name | Description | +|-------------------|---------------------------------------------------| +| eq | Check whether value is equal to the specified value.
e.g. `validate:"eq=1"` | +| ne | Check whether value is not equal to the specified value
e.g. `validate:"ne=1"` | +| gt | Check whether value is greater than the specified value
e.g. `validate:"gt=1"` | +| gte | Check whether value is greater than or equal to the specified value
e.g. `validate:"gte=1"` | +| lt | Check whether value is less than the specified value
e.g. `validate:"lt=1"` | +| lte | Check whether value is less than or equal to the specified value
e.g. `validate:"lte=1"` | + +## License +[MIT License](./LICENSE) + +## Contribution + +First off, thanks for taking the time to contribute! Contributions are not only related to development. For example, GitHub Star motivates me to develop! Please feel free to contribute to this project. + +### Special Thanks + +I was inspired by the following OSS. Thank you for your great work! +- [go-playground/validator](https://github.com/go-playground/validator) +- [shogo82148/go-header-csv](https://github.com/shogo82148/go-header-csv) + +### Contributors ✨ + +Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)): + + + + + + + + + + + + + + + +
CHIKAMATSU Naohiro
CHIKAMATSU Naohiro

📖
+ + Add your contributions + +
+ + + + + + +This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome! diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..2b75a05 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,21 @@ +# Security Policy + +## Reporting a Vulnerability + +If you discover any security-related issues or vulnerabilities, please contact us at [n.chika156@gmail.com](mailto:n.chika156@gmail.com). We appreciate your responsible disclosure and will work with you to address the issue promptly. + +## Supported Versions + +We recommend using the latest release for the most up-to-date and secure experience. Security updates are provided for the latest stable version. + +## Security Policy + +- Security issues are treated with the highest priority. +- We follow responsible disclosure practices. +- Fixes for security vulnerabilities will be provided in a timely manner. + +## Acknowledgments + +We would like to thank the security researchers and contributors who responsibly report security issues and work with us to make our project more secure. + +Thank you for your help in making our project safe and secure for everyone. diff --git a/csv.go b/csv.go new file mode 100644 index 0000000..b446fb4 --- /dev/null +++ b/csv.go @@ -0,0 +1,145 @@ +// Package csv returns which columns have syntax errors on a per-line basis when reading CSV. +// It also has the capability to convert the character encoding to UTF-8 if the CSV character +// encoding is not UTF-8. +package csv + +import ( + "encoding/csv" + "fmt" + "io" + "reflect" + "strconv" +) + +// CSV is a struct that implements CSV Reader and Writer. +type CSV struct { + // headerless is a flag that indicates the csv file has no header. + headerless bool + // reader is the csv reader. + reader *csv.Reader + // header is a type that represents the header of a csv. + header header + // ruleSets is slice of ruleSet. + // The order of the ruleSet is the same as the order of the columns in the csv. + ruleSet ruleSet +} + +type ( + // header is a type that represents the header of a CSV file. + header []column + // column is a type that represents a column in a CSV file. + column string + // ruleSet is a map that contains the validation rules for each column. + ruleSet []validators +) + +// NewCSV returns a new CSV struct. +func NewCSV(r io.Reader, opts ...Option) (*CSV, error) { + csv := &CSV{ + reader: csv.NewReader(r), + } + + for _, opt := range opts { + if err := opt(csv); err != nil { + return nil, err + } + } + return csv, nil +} + +// Decode reads the CSV and returns the columns that have syntax errors on a per-line basis. +// The strutSlicePointer is a pointer to structure slice where validation rules are set in struct tags. +// +// Example: +func (c *CSV) Decode(structSlicePointer any) []error { + errors := make([]error, 0) + if err := c.parseStructTag(structSlicePointer); err != nil { + errors = append(errors, err) + return errors + } + + firstLine := 1 + if !c.headerless { + firstLine = 2 // first line is 2 because the header is on line 1. + if err := c.readHeader(); err != nil { + errors = append(errors, err) + return errors + } + } + + structSlicePtrValue := reflect.ValueOf(structSlicePointer) + structSliceValue := structSlicePtrValue.Elem() + + for line := firstLine; ; line++ { + record, err := c.reader.Read() + if err == io.EOF { + break + } + if err != nil { + errors = append(errors, err) + break + } + + structValue := reflect.New(structSliceValue.Type().Elem()).Elem() + for i, v := range record { + validators := c.ruleSet[i] + for _, validator := range validators { + if err := validator.Do(v); err != nil { + errors = append(errors, fmt.Errorf("line:%d column %s: %w", line, c.header[i], err)) + } + } + _ = setStructFieldValue(structValue, i, v) //nolint:errcheck // user will not see this error. + } + structSliceValue.Set(reflect.Append(structSliceValue, structValue)) + } + return errors +} + +// readHeader reads the header of the CSV file. +func (c *CSV) readHeader() error { + record, err := c.reader.Read() + if err != nil { + return err + } + + columns := make([]column, 0, len(record)) + for _, v := range record { + columns = append(columns, column(v)) + } + c.header = columns + return nil +} + +// setStructFieldValue sets the value of a field in a struct. +func setStructFieldValue(structValue reflect.Value, index int, value string) error { + if index >= structValue.NumField() { + return fmt.Errorf("index out of range for struct") + } + + fieldValue := structValue.Field(index) + switch fieldValue.Kind() { + case reflect.String: + fieldValue.SetString(value) + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + intValue, err := strconv.ParseInt(value, 10, fieldValue.Type().Bits()) + if err != nil { + return err + } + fieldValue.SetInt(intValue) + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + uintValue, err := strconv.ParseUint(value, 10, fieldValue.Type().Bits()) + if err != nil { + return err + } + fieldValue.SetUint(uintValue) + case reflect.Float32, reflect.Float64: + floatValue, err := strconv.ParseFloat(value, fieldValue.Type().Bits()) + if err != nil { + return err + } + fieldValue.SetFloat(floatValue) + default: + return fmt.Errorf("unsupported field type: %s", fieldValue.Kind().String()) + } + return nil +} diff --git a/csv_test.go b/csv_test.go new file mode 100644 index 0000000..f920c4e --- /dev/null +++ b/csv_test.go @@ -0,0 +1,197 @@ +package csv + +import ( + "os" + "path/filepath" + "testing" + + "github.com/google/go-cmp/cmp" +) + +func TestCSV_Decode(t *testing.T) { + t.Parallel() + + t.Run("all error: `id,name,age,password` header", func(t *testing.T) { + t.Parallel() + + f, err := os.Open(filepath.Join("testdata", "all_error.csv")) + if err != nil { + t.Fatal(err) + } + + c, err := NewCSV(f) + if err != nil { + t.Fatal(err) + } + + type person struct { + ID int `validate:"numeric,gte=1"` + Name string `validate:"alpha"` + Age int `validate:"numeric,gt=-1,lt=120,gte=0"` + Password string `validate:"required,alphanumeric"` + IsAdmin bool `validate:"boolean"` + Zero int `validate:"numeric,eq=0,lte=1,ne=1"` + } + people := make([]person, 0) + + got := c.Decode(&people) + for i, err := range got { + switch i { + case 0: + if err.Error() != "line:2 column id: target is not greater than or equal to the threshold value: threshold=1.000000, value=0.000000" { + t.Errorf("CSV.Decode() got errors: %v", err) + } + case 1: + if err.Error() != "line:3 column password: target is not an alphanumeric character: value=password-bad" { + t.Errorf("CSV.Decode() got errors: %v", err) + } + case 2: + if err.Error() != "line:4 column password: target is required but is empty: value=" { + t.Errorf("CSV.Decode() got errors: %v", err) + } + case 3: + if err.Error() != "line:5 column name: target is not an alphabetic character: value=1Joyless" { + t.Errorf("CSV.Decode() got errors: %v", err) + } + case 4: + if err.Error() != "line:5 column zero: target is not equal to the threshold value: threshold=0.000000, value=1.000000" { + t.Errorf("CSV.Decode() got errors: %v", err) + } + case 5: + if err.Error() != "line:5 column zero: target is equal to threshold the value: threshold=1.000000, value=1.000000" { + t.Errorf("CSV.Decode() got errors: %v", err) + } + case 6: + if err.Error() != "line:6 column age: target is not less than the threshold value: threshold=120.000000, value=120.000000" { + t.Errorf("CSV.Decode() got errors: %v", err) + } + case 7: + if err.Error() != "line:7 column is_admin: target is not a boolean: value=2" { + t.Errorf("CSV.Decode() got errors: %v", err) + } + case 8: + if err.Error() != "line:8 column age: target is not greater than the threshold value: threshold=-1.000000, value=-1.000000" { + t.Errorf("CSV.Decode() got errors: %v", err) + } + case 9: + if err.Error() != "line:8 column age: target is not greater than or equal to the threshold value: threshold=0.000000, value=-1.000000" { + t.Errorf("CSV.Decode() got errors: %v", err) + } + case 10: + if err.Error() != "line:9 column id: target is not a numeric character: value=a" { + t.Errorf("CSV.Decode() got errors: %v", err) + } + } + } + }) + + t.Run("read `id,name,age` header with value", func(t *testing.T) { + t.Parallel() + + f, err := os.Open(filepath.Join("testdata", "sample.csv")) + if err != nil { + t.Fatal(err) + } + defer f.Close() //nolint: errcheck + + c, err := NewCSV(f) + if err != nil { + t.Fatal(err) + } + + type person struct { + ID int `validate:"numeric"` + Name string `validate:"alpha"` + Age int `validate:"numeric"` + } + people := make([]person, 0) + + errs := c.Decode(&people) + if len(errs) != 0 { + for _, err := range errs { + t.Errorf("CSV.Decode() got errors: %v", err) + } + } + + want := []person{ + {ID: 1, Name: "Gina", Age: 23}, + {ID: 2, Name: "Yulia", Age: 25}, + {ID: 3, Name: "Denis", Age: 30}, + } + if diff := cmp.Diff(people, want); diff != "" { + t.Errorf("CSV.Decode() mismatch (-got +want):\n%s", diff) + } + }) + + t.Run("read `id,name,age` header with value and headerless", func(t *testing.T) { + t.Parallel() + + f, err := os.Open(filepath.Join("testdata", "sample_headerless.csv")) + if err != nil { + t.Fatal(err) + } + defer f.Close() //nolint: errcheck + + c, err := NewCSV(f, WithHeaderless()) + if err != nil { + t.Fatal(err) + } + + type person struct { + ID int `validate:"numeric"` + Name string `validate:"alpha"` + Age int `validate:"numeric"` + } + people := make([]person, 0) + + errs := c.Decode(&people) + if len(errs) != 0 { + t.Errorf("CSV.Decode() got errors: %v", errs) + } + + want := []person{ + {ID: 1, Name: "Gina", Age: 23}, + {ID: 2, Name: "Yulia", Age: 25}, + {ID: 3, Name: "Denis", Age: 30}, + } + if diff := cmp.Diff(people, want); diff != "" { + t.Errorf("CSV.Decode() mismatch (-got +want):\n%s", diff) + } + }) + + t.Run("read `id,name,age` header with tab separator", func(t *testing.T) { + t.Parallel() + + f, err := os.Open(filepath.Join("testdata", "sample.tsv")) + if err != nil { + t.Fatal(err) + } + defer f.Close() //nolint: errcheck + + c, err := NewCSV(f, WithTabDelimiter()) + if err != nil { + t.Fatal(err) + } + + type person struct { + ID int `validate:"numeric"` + Name string `validate:"alpha"` + Age int `validate:"numeric"` + } + people := make([]person, 0) + + errs := c.Decode(&people) + if len(errs) != 0 { + t.Errorf("CSV.Decode() got errors: %v", errs) + } + + want := []person{ + {ID: 1, Name: "Gina", Age: 23}, + {ID: 2, Name: "Yulia", Age: 25}, + {ID: 3, Name: "Denis", Age: 30}, + } + if diff := cmp.Diff(people, want); diff != "" { + t.Errorf("CSV.Decode() mismatch (-got +want):\n%s", diff) + } + }) +} diff --git a/errors.go b/errors.go new file mode 100644 index 0000000..4ae0051 --- /dev/null +++ b/errors.go @@ -0,0 +1,34 @@ +package csv + +import "errors" + +var ( + // ErrStructSlicePointer is returned when the value is not a pointer to a struct slice. + ErrStructSlicePointer = errors.New("value is not a pointer to a struct slice") + // ErrInvalidBoolean is returned when the target is not a boolean. + ErrInvalidBoolean = errors.New("target is not a boolean") + // ErrInvalidAlphabet is returned when the target is not an alphabetic character. + ErrInvalidAlphabet = errors.New("target is not an alphabetic character") + // ErrInvalidNumeric is returned when the target is not a numeric character. + ErrInvalidNumeric = errors.New("target is not a numeric character") + // ErrInvalidAlphanumeric is returned when the target is not an alphanumeric character. + ErrInvalidAlphanumeric = errors.New("target is not an alphanumeric character") + // ErrRequired is returned when the target is required but is empty. + ErrRequired = errors.New("target is required but is empty") + // ErrEqual is returned when the target is not equal to the value. + ErrEqual = errors.New("target is not equal to the threshold value") + // ErrInvalidThreshold is returned when the target is not greater than the value. + ErrInvalidThreshold = errors.New("threshold value is invalid") + // ErrInvalidThresholdFormat is returned when the threshold value is not an integer. + ErrInvalidThresholdFormat = errors.New("threshold format is invalid") + // ErrNotEqual is returned when the target is equal to the value. + ErrNotEqual = errors.New("target is equal to threshold the value") + // ErrGreaterThan is returned when the target is not greater than the value. + ErrGreaterThan = errors.New("target is not greater than the threshold value") + // ErrGreaterThanEqual is returned when the target is not greater than or equal to the value. + ErrGreaterThanEqual = errors.New("target is not greater than or equal to the threshold value") + // ErrLessThan is returned when the target is not less than the value. + ErrLessThan = errors.New("target is not less than the threshold value") + // ErrLessThanEqual is returned when the target is not less than or equal to the value. + ErrLessThanEqual = errors.New("target is not less than or equal to the threshold value") +) diff --git a/example_test.go b/example_test.go new file mode 100644 index 0000000..e98d62d --- /dev/null +++ b/example_test.go @@ -0,0 +1,42 @@ +//go:build linux || darwin + +package csv_test + +import ( + "bytes" + "fmt" + + "github.com/nao1215/csv" +) + +func ExampleCSV() { + input := `id,name,age +1,Gina,23 +a,Yulia,25 +3,Den1s,30 +` + buf := bytes.NewBufferString(input) + c, err := csv.NewCSV(buf) + if err != nil { + panic(err) + } + + type person struct { + ID int `validate:"numeric"` + Name string `validate:"alpha"` + Age int `validate:"gt=24"` + } + people := make([]person, 0) + + errs := c.Decode(&people) + if len(errs) != 0 { + for _, err := range errs { + fmt.Println(err.Error()) + } + } + + // Output: + // line:2 column age: target is not greater than the threshold value: threshold=24.000000, value=23.000000 + // line:3 column id: target is not a numeric character: value=a + // line:4 column name: target is not an alphabetic character: value=Den1s +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..ee2a710 --- /dev/null +++ b/go.mod @@ -0,0 +1,8 @@ +module github.com/nao1215/csv + +go 1.20 + +require ( + github.com/google/go-cmp v0.6.0 + github.com/motemen/go-testutil v0.0.0-20231019055648-af6add1c10c8 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..c8b26fe --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/motemen/go-testutil v0.0.0-20231019055648-af6add1c10c8 h1:bSyU9M98Y4Rrs8X1tYkO8hyHsGw1kWCWyG6FnCQ0l/E= +github.com/motemen/go-testutil v0.0.0-20231019055648-af6add1c10c8/go.mod h1:fz3ptMGvFb+/JIPQadvSpFND5BuGi7cJka/JgG7njN8= diff --git a/option.go b/option.go new file mode 100644 index 0000000..5ad00e3 --- /dev/null +++ b/option.go @@ -0,0 +1,20 @@ +package csv + +// Option is a function that sets a configuration option for CSV struct. +type Option func(c *CSV) error + +// WithTabDelimiter is an Option that sets the delimiter to a tab character. +func WithTabDelimiter() Option { + return func(c *CSV) error { + c.reader.Comma = '\t' + return nil + } +} + +// WithHeaderless is an Option that sets the headerless flag to true. +func WithHeaderless() Option { + return func(c *CSV) error { + c.headerless = true + return nil + } +} diff --git a/parser.go b/parser.go new file mode 100644 index 0000000..8b9a634 --- /dev/null +++ b/parser.go @@ -0,0 +1,124 @@ +package csv + +import ( + "fmt" + "reflect" + "strconv" + "strings" +) + +// parseStructTag parses the struct tag and extracts the header and ruleSet. +// structSlicePointer is a pointer to a slice of structs. +func (c *CSV) parseStructTag(structSlicePointer any) error { + rv := reflect.ValueOf(structSlicePointer) + if rv.Kind() != reflect.Ptr { + return ErrStructSlicePointer + } + + elem := rv.Elem() + switch elem.Kind() { + case reflect.Slice, reflect.Array: + elemType := elem.Type().Elem() + if elemType.Kind() != reflect.Struct { + return ErrStructSlicePointer + } + ruleSet, err := extractRuleSet(elemType) + if err != nil { + return err + } + c.ruleSet = ruleSet + default: + return fmt.Errorf("csv: v is not a slice or array, got %v", elem.Kind()) + } + return nil +} + +// / extractRuleSet extracts the ruleSet from the struct. +func extractRuleSet(structType reflect.Type) (ruleSet, error) { + ruleSet := make(ruleSet, 0, structType.NumField()) + + for i := 0; i < structType.NumField(); i++ { + tag := structType.Field(i).Tag + validators, err := parseValidateTag(tag.Get(validateTag.String())) + if err != nil { + return nil, err + } + ruleSet = append(ruleSet, validators) + } + return ruleSet, nil +} + +// parseValidateTag parses the validate tag. +// This function return a set of Validate functions based on +// the rules specified in the validation tag. +func parseValidateTag(tags string) (validators, error) { + tagList := strings.Split(tags, ",") + validatorList := make(validators, 0, len(tagList)) + + for _, t := range tagList { + switch { + case strings.HasPrefix(t, booleanTagValue.String()): + validatorList = append(validatorList, newBooleanValidator()) + case strings.HasPrefix(t, alphaTagValue.String()) && !strings.HasPrefix(t, alphanumericTagValue.String()): + validatorList = append(validatorList, newAlphaValidator()) + case strings.HasPrefix(t, numericTagValue.String()): + validatorList = append(validatorList, newNumericValidator()) + case strings.HasPrefix(t, alphanumericTagValue.String()): + validatorList = append(validatorList, newAlphanumericValidator()) + case strings.HasPrefix(t, requiredTagValue.String()): + validatorList = append(validatorList, newRequiredValidator()) + case strings.HasPrefix(t, equalTagValue.String()): + threshold, err := parseThreshold(t) + if err != nil { + return nil, err + } + validatorList = append(validatorList, newEqualValidator(threshold)) + case strings.HasPrefix(t, notEqualTagValue.String()): + threshold, err := parseThreshold(t) + if err != nil { + return nil, err + } + validatorList = append(validatorList, newNotEqualValidator(threshold)) + case strings.HasPrefix(t, greaterThanTagValue.String()) && !strings.HasPrefix(t, greaterThanEqualTagValue.String()): + threshold, err := parseThreshold(t) + if err != nil { + return nil, err + } + validatorList = append(validatorList, newGreaterThanValidator(threshold)) + case strings.HasPrefix(t, greaterThanEqualTagValue.String()): + threshold, err := parseThreshold(t) + if err != nil { + return nil, err + } + validatorList = append(validatorList, newGreaterThanEqualValidator(threshold)) + case strings.HasPrefix(t, lessThanTagValue.String()) && !strings.HasPrefix(t, lessThanEqualTagValue.String()): + threshold, err := parseThreshold(t) + if err != nil { + return nil, err + } + validatorList = append(validatorList, newLessThanValidator(threshold)) + case strings.HasPrefix(t, lessThanEqualTagValue.String()): + threshold, err := parseThreshold(t) + if err != nil { + return nil, err + } + validatorList = append(validatorList, newLessThanEqualValidator(threshold)) + } + } + return validatorList, nil +} + +// parseThreshold parses the threshold value. +// tagValue is the value of the struct tag. e.g. eq=10, gt=5.2 +func parseThreshold(tagValue string) (float64, error) { + parts := strings.Split(tagValue, "=") + + if len(parts) == 2 { + num, err := strconv.ParseFloat(parts[1], 64) + if err != nil { + return 0, fmt.Errorf("%w: %s", ErrInvalidThreshold, tagValue) + } + return num, nil + } + return 0, fmt.Errorf("%w: %s", ErrInvalidThresholdFormat, tagValue) +} diff --git a/parser_test.go b/parser_test.go new file mode 100644 index 0000000..a57099f --- /dev/null +++ b/parser_test.go @@ -0,0 +1,87 @@ +package csv + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/motemen/go-testutil/dataloc" +) + +func Test_parseValidateTag(t *testing.T) { + t.Parallel() + type args struct { + tags string + } + tests := []struct { + name string + args args + want validators + }{ + { + name: "should return a validationRule with all fields set to false", + args: args{tags: ""}, + want: validators{}, + }, + { + name: "should return a validationRule with shouldBool set to true", + args: args{tags: "boolean,alpha"}, + want: validators{ + newBooleanValidator(), + newAlphaValidator(), + }, + }, + } + for _, tt := range tests { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got, err := parseValidateTag(tt.args.tags) + if err != nil { + t.Errorf("parseValidateTag() error = %v, test case at %s", err, dataloc.L(tt.name)) + } + + if diff := cmp.Diff(got, tt.want); diff != "" { + t.Errorf("parseValidateTage() mismatch (-got +want):\n%s", diff) + } + }) + } +} + +func TestCSV_parseStructTag(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + arg any + want ruleSet + wantErr bool + }{ + { + name: "should return an error if the struct is not a pointer", + arg: &[]struct { + Name string `validate:"boolean"` + }{}, + want: ruleSet{ + validators{newBooleanValidator()}, + }, + wantErr: false, + }, + } + for _, tt := range tests { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + csv := &CSV{} + if err := csv.parseStructTag(tt.arg); (err != nil) != tt.wantErr { + t.Errorf("CSV.parseStructTag() error = %v, wantErr %v, test case at %s", err, tt.wantErr, dataloc.L(tt.name)) + } + + if diff := cmp.Diff(csv.ruleSet, tt.want); diff != "" { + t.Errorf("CSV.parseStructTag() mismatch (-got +want):\n%s", diff) + } + }) + } +} diff --git a/tag.go b/tag.go new file mode 100644 index 0000000..3ea0b25 --- /dev/null +++ b/tag.go @@ -0,0 +1,47 @@ +package csv + +// tag is struct tag name. +type tag string + +const ( + // validateTag is the struct tag name for validation rules. + validateTag tag = "validate" +) + +// tagValue is the struct tag value. +type tagValue string + +const ( + // booleanTagValue is the struct tag value for boolean rule. + booleanTagValue tagValue = "boolean" + // alphaTagValue is the struct tag name for alpha only fields. + alphaTagValue tagValue = "alpha" + // numericTagValue is the struct tag name for numeric fields. + numericTagValue tagValue = "numeric" + // alphanumericTagValue is the struct tag name for alphanumeric fields. + alphanumericTagValue tagValue = "alphanumeric" + // requiredTagValue is the struct tag name for required fields. + requiredTagValue tagValue = "required" + // equalTagValue is the struct tag name for equal fields. + equalTagValue tagValue = "eq" + // notEqualTagValue is the struct tag name for not equal fields. + notEqualTagValue tagValue = "ne" + // greaterThanTagValue is the struct tag name for greater than fields. + greaterThanTagValue tagValue = "gt" + // greaterThanEqualTagValue is the struct tag name for greater than or equal fields. + greaterThanEqualTagValue tagValue = "gte" + // lessThanTagValue is the struct tag name for less than fields. + lessThanTagValue tagValue = "lt" + // lessThanEqualTagValue is the struct tag name for less than or equal fields. + lessThanEqualTagValue tagValue = "lte" +) + +// String returns the string representation of the tag. +func (t tag) String() string { + return string(t) +} + +// String returns the string representation of the tag value. +func (t tagValue) String() string { + return string(t) +} diff --git a/testdata/all_error.csv b/testdata/all_error.csv new file mode 100644 index 0000000..e5ce43b --- /dev/null +++ b/testdata/all_error.csv @@ -0,0 +1,9 @@ +id,name,age,password,is_admin,zero +0,George,10,password,true,0 +4,Jill,50,password-bad,false,0 +7,Joy,80,,true,0 +10,1Joyless,110,password,1,1 +11,Joyous,120,password,1,0 +12,Joyful,100,password,2,0 +13,Joyfullest,-1,password,1,0 +a,George,10,password,true,0 diff --git a/testdata/sample.csv b/testdata/sample.csv new file mode 100644 index 0000000..de88cfc --- /dev/null +++ b/testdata/sample.csv @@ -0,0 +1,4 @@ +id,name,age +1,Gina,23 +2,Yulia,25 +3,Denis,30 diff --git a/testdata/sample.tsv b/testdata/sample.tsv new file mode 100644 index 0000000..d42689f --- /dev/null +++ b/testdata/sample.tsv @@ -0,0 +1,4 @@ +id name age +1 Gina 23 +2 Yulia 25 +3 Denis 30 diff --git a/testdata/sample_headerless.csv b/testdata/sample_headerless.csv new file mode 100644 index 0000000..d21dca5 --- /dev/null +++ b/testdata/sample_headerless.csv @@ -0,0 +1,3 @@ +1,Gina,23 +2,Yulia,25 +3,Denis,30 diff --git a/validation.go b/validation.go new file mode 100644 index 0000000..bbfb9ef --- /dev/null +++ b/validation.go @@ -0,0 +1,302 @@ +package csv + +import ( + "fmt" + "strconv" +) + +// validator is a struct that contains the validation rules for a column. +type validators []validator + +// validator is the interface that wraps the Do method. +type validator interface { + Do(target any) error +} + +// booleanValidator is a struct that contains the validation rules for a boolean column. +type booleanValidator struct{} + +// newBooleanValidator returns a new booleanValidator. +func newBooleanValidator() *booleanValidator { + return &booleanValidator{} +} + +// Do validates the target as a boolean. +// If the target is an int, it will be validated as a boolean if it's 0 or 1. +func (b *booleanValidator) Do(target any) error { + if v, ok := target.(string); ok { + if v == "true" || v == "false" || v == "0" || v == "1" { + return nil + } + } + return fmt.Errorf("%w: value=%v", ErrInvalidBoolean, target) //nolint +} + +// alphabetValidator is a struct that contains the validation rules for an alpha column. +type alphabetValidator struct{} + +// newAlphaValidator returns a new alphaValidator. +func newAlphaValidator() *alphabetValidator { + return &alphabetValidator{} +} + +// Do validates the target string only contains alphabetic character. +func (a *alphabetValidator) Do(target any) error { + v, ok := target.(string) + if !ok { + return fmt.Errorf("%w: value=%v", ErrInvalidAlphabet, target) //nolint + } + + for _, r := range v { + if !isAlpha(r) { + return fmt.Errorf("%w: value=%v", ErrInvalidAlphabet, target) //nolint + } + } + return nil +} + +// isAlpha returns true if the rune is an alphabetic character. +func isAlpha(r rune) bool { + return r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' +} + +// numericValidator is a struct that contains the validation rules for a numeric column. +type numericValidator struct{} + +// newNumericValidator returns a new numericValidator. +func newNumericValidator() *numericValidator { + return &numericValidator{} +} + +// Do validates the target as a numeric. +func (n *numericValidator) Do(target any) error { + v, ok := target.(string) + if !ok { + return fmt.Errorf("%w: value=%v", ErrInvalidNumeric, target) //nolint + } + + if v == "" { + return nil + } + + if _, err := strconv.Atoi(v); err != nil { + return fmt.Errorf("%w: value=%s", ErrInvalidNumeric, v) //nolint + } + return nil +} + +// isNumeric returns true if the rune is a numeric character. +func isNumeric(r rune) bool { + return r >= '0' && r <= '9' +} + +// alphanumericValidator is a struct that contains the validation rules for an alphanumeric column. +type alphanumericValidator struct{} + +// newAlphanumericValidator returns a new alphanumericValidator. +func newAlphanumericValidator() *alphanumericValidator { + return &alphanumericValidator{} +} + +// Do validates the target string only contains alphanumeric character. +func (a *alphanumericValidator) Do(target any) error { + v, ok := target.(string) + if !ok { + return fmt.Errorf("%w: value=%v", ErrInvalidAlphanumeric, target) //nolint + } + + for _, r := range v { + if !isAlpha(r) && !isNumeric(r) { + return fmt.Errorf("%w: value=%v", ErrInvalidAlphanumeric, target) //nolint + } + } + return nil +} + +// requiredValidator is a struct that contains the validation rules for a required column. +type requiredValidator struct{} + +// newRequiredValidator returns a new requiredValidator. +func newRequiredValidator() *requiredValidator { + return &requiredValidator{} +} + +// Do validates the target is not empty. +func (r *requiredValidator) Do(target any) error { + v, ok := target.(string) + if !ok { + return fmt.Errorf("%w: value=%v", ErrRequired, target) //nolint + } + + if v == "" { + return fmt.Errorf("%w: value=%v", ErrRequired, target) //nolint + } + return nil +} + +// equalValidator is a struct that contains the validation rules for an equal column. +type equalValidator struct { + threshold float64 +} + +// newEqualValidator returns a new equalValidator. +func newEqualValidator(threshold float64) *equalValidator { + return &equalValidator{threshold: threshold} +} + +// Do validates the target is equal to the threshold. +func (e *equalValidator) Do(target any) error { + v, ok := target.(string) + if !ok { + return fmt.Errorf("%w: value=%v", ErrEqual, target) //nolint + } + + value, err := strconv.ParseFloat(v, 64) + if err != nil { + return fmt.Errorf("%w: value=%v", ErrEqual, target) //nolint + } + + if value != e.threshold { + return fmt.Errorf("%w: threshold=%f, value=%f", ErrEqual, e.threshold, value) //nolint + } + return nil +} + +// notEqualValidator is a struct that contains the validation rules for a not equal column. +type notEqualValidator struct { + threshold float64 +} + +// newNotEqualValidator returns a new notEqualValidator. +func newNotEqualValidator(threshold float64) *notEqualValidator { + return ¬EqualValidator{threshold: threshold} +} + +// Do validates the target is not equal to the threshold. +func (n *notEqualValidator) Do(target any) error { + v, ok := target.(string) + if !ok { + return fmt.Errorf("%w: value=%v", ErrNotEqual, target) //nolint + } + + value, err := strconv.ParseFloat(v, 64) + if err != nil { + return fmt.Errorf("%w: value=%v", ErrNotEqual, target) //nolint + } + + if value == n.threshold { + return fmt.Errorf("%w: threshold=%f, value=%f", ErrNotEqual, n.threshold, value) //nolint + } + return nil +} + +// greaterThanValidator is a struct that contains the validation rules for a greater than column. +type greaterThanValidator struct { + threshold float64 +} + +// newGreaterThanValidator returns a new greaterThanValidator. +func newGreaterThanValidator(threshold float64) *greaterThanValidator { + return &greaterThanValidator{threshold: threshold} +} + +// Do validates the target is greater than the threshold. +func (g *greaterThanValidator) Do(target any) error { + v, ok := target.(string) + if !ok { + return fmt.Errorf("%w: value=%v", ErrGreaterThan, target) //nolint + } + + value, err := strconv.ParseFloat(v, 64) + if err != nil { + return fmt.Errorf("%w: value=%v", ErrGreaterThan, target) //nolint + } + + if value <= g.threshold { + return fmt.Errorf("%w: threshold=%f, value=%f", ErrGreaterThan, g.threshold, value) //nolint + } + return nil +} + +// greaterThanEqualValidator is a struct that contains the validation rules for a greater than or equal column. +type greaterThanEqualValidator struct { + threshold float64 +} + +// newGreaterThanEqualValidator returns a new greaterThanEqualValidator. +func newGreaterThanEqualValidator(threshold float64) *greaterThanEqualValidator { + return &greaterThanEqualValidator{threshold: threshold} +} + +// Do validates the target is greater than or equal to the threshold. +func (g *greaterThanEqualValidator) Do(target any) error { + v, ok := target.(string) + if !ok { + return fmt.Errorf("%w: value=%v", ErrGreaterThanEqual, target) //nolint + } + + value, err := strconv.ParseFloat(v, 64) + if err != nil { + return fmt.Errorf("%w: value=%v", ErrGreaterThanEqual, target) //nolint + } + + if value < g.threshold { + return fmt.Errorf("%w: threshold=%f, value=%f", ErrGreaterThanEqual, g.threshold, value) //nolint + } + return nil +} + +// lessThanValidator is a struct that contains the validation rules for a less than column. +type lessThanValidator struct { + threshold float64 +} + +// newLessThanValidator returns a new lessThanValidator. +func newLessThanValidator(threshold float64) *lessThanValidator { + return &lessThanValidator{threshold: threshold} +} + +// Do validates the target is less than the threshold. +func (l *lessThanValidator) Do(target any) error { + v, ok := target.(string) + if !ok { + return fmt.Errorf("%w: value=%v", ErrLessThan, target) //nolint + } + + value, err := strconv.ParseFloat(v, 64) + if err != nil { + return fmt.Errorf("%w: value=%v", ErrLessThan, target) //nolint + } + if value >= l.threshold { + return fmt.Errorf("%w: threshold=%f, value=%f", ErrLessThan, l.threshold, value) //nolint + } + return nil +} + +// lessThanEqualValidator is a struct that contains the validation rules for a less than or equal column. +type lessThanEqualValidator struct { + threshold float64 +} + +// newLessThanEqualValidator returns a new lessThanEqualValidator. +func newLessThanEqualValidator(threshold float64) *lessThanEqualValidator { + return &lessThanEqualValidator{threshold: threshold} +} + +// Do validates the target is less than or equal to the threshold. +func (l *lessThanEqualValidator) Do(target any) error { + v, ok := target.(string) + if !ok { + return fmt.Errorf("%w: value=%v", ErrLessThanEqual, target) //nolint + } + + value, err := strconv.ParseFloat(v, 64) + if err != nil { + return fmt.Errorf("%w: value=%v", ErrLessThanEqual, target) //nolint + } + + if value > l.threshold { + return fmt.Errorf("%w: threshold=%f, value=%f", ErrLessThanEqual, l.threshold, value) //nolint + } + return nil +} diff --git a/validation_test.go b/validation_test.go new file mode 100644 index 0000000..2fc7dec --- /dev/null +++ b/validation_test.go @@ -0,0 +1,166 @@ +package csv + +import ( + "testing" + + "github.com/motemen/go-testutil/dataloc" +) + +func Test_booleanValidator_Do(t *testing.T) { + t.Parallel() + + type args struct { + target any + } + tests := []struct { + name string + b *booleanValidator + args args + wantErr bool + }{ + { + name: "should return nil if target is a boolean: true", + b: newBooleanValidator(), + args: args{target: "true"}, + wantErr: false, + }, + { + name: "should return nil if target is a boolean: false", + b: newBooleanValidator(), + args: args{target: "false"}, + wantErr: false, + }, + { + name: "should return nil if target is an int and is 0", + b: newBooleanValidator(), + args: args{target: "0"}, + wantErr: false, + }, + { + name: "should return nil if target is an int and is 1", + b: newBooleanValidator(), + args: args{target: "1"}, + wantErr: false, + }, + { + name: "should return an error if target is an int and is not 0 or 1", + b: newBooleanValidator(), + args: args{target: "2"}, + wantErr: true, + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + b := &booleanValidator{} + if err := b.Do(tt.args.target); (err != nil) != tt.wantErr { + t.Errorf("booleanValidator.Do() error = %v, wantErr %v, test case at %s", err, tt.wantErr, dataloc.L(tt.name)) + } + }) + } +} + +func Test_alphaValidator_Do(t *testing.T) { + t.Parallel() + + type args struct { + target any + } + tests := []struct { + name string + a *alphabetValidator + args args + wantErr bool + }{ + { + name: "should return nil if target is a string and is a multiple alphabetic characters", + a: newAlphaValidator(), + args: args{target: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"}, + wantErr: false, + }, + { + name: "should return nil if target is empty string", + a: newAlphaValidator(), + args: args{target: ""}, + }, + { + name: "should return an error if target is not a string", + a: newAlphaValidator(), + args: args{target: 1}, + wantErr: true, + }, + { + name: "should return an error if target contains number", + a: newAlphaValidator(), + args: args{target: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1"}, + wantErr: true, + }, + { + name: "should return an error if target contains special character", + a: newAlphaValidator(), + args: args{target: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!"}, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + a := &alphabetValidator{} + if err := a.Do(tt.args.target); (err != nil) != tt.wantErr { + t.Errorf("alphaValidator.Do() error = %v, wantErr %v, test case at %s", err, tt.wantErr, dataloc.L(tt.name)) + } + }) + } +} + +func Test_numericValidator_Do(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + n *numericValidator + arg any + wantErr bool + }{ + { + name: "should return nil if target is a string and is a numeric character", + n: newNumericValidator(), + arg: "1234567890", + wantErr: false, + }, + { + name: "should return an error if target is not a string", + n: newNumericValidator(), + arg: 1, + wantErr: true, + }, + { + name: "should return an error if target is not a numeric character", + n: newNumericValidator(), + arg: "1234567890a", + wantErr: true, + }, + { + name: "should return an error if target is an empty string", + n: newNumericValidator(), + arg: "", + wantErr: false, + }, + { + name: "should return error if target is a string and is a float", + n: newNumericValidator(), + arg: "0.0", + wantErr: true, + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + n := &numericValidator{} + if err := n.Do(tt.arg); (err != nil) != tt.wantErr { + t.Errorf("numericValidator.Do() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +}