From d187ae90a8a66cfcd9c7043c485695808cc7f077 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vesa=20Poikaj=C3=A4rvi?= Date: Tue, 14 May 2024 14:44:57 +0300 Subject: [PATCH] feat: unique table column validator (#99) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added table column validator `unique: true` which ensures all values in defined column of the table variable are unique. Co-authored-by: Antti Kivimäki --- cmd/docs/templates/_schema.tmpl | 1 + docs/site/docs/usage.mdx | 2 +- examples/variable-types/recipe.yml | 13 ++++-- pkg/recipe/variable.go | 74 +++++++++++++++++++++++------- pkg/recipe/variable_test.go | 63 ++++++++++++++++++++++++- pkg/recipeutil/values.go | 23 ++++++++-- pkg/ui/editable/model.go | 13 +++++- pkg/ui/survey/prompt/string.go | 5 +- pkg/ui/survey/prompt/table.go | 20 ++++++-- 9 files changed, 182 insertions(+), 32 deletions(-) diff --git a/cmd/docs/templates/_schema.tmpl b/cmd/docs/templates/_schema.tmpl index 77fad02e..4d2babe2 100644 --- a/cmd/docs/templates/_schema.tmpl +++ b/cmd/docs/templates/_schema.tmpl @@ -40,6 +40,7 @@ | `pattern` | `string` | | Regular expression pattern to match the input against. | | `help` | `string` | | If the regular expression validation fails, this help message will be shown to the user. | | `column` | `string` | | Apply the validator to a column if the variable type is table. | +| `unique` | `bool` | | When targeting table columns, set this to true to make sure that the values in the column are unique. | ## Test schema (`test.yml`) diff --git a/docs/site/docs/usage.mdx b/docs/site/docs/usage.mdx index 4a2d126c..b69cb212 100644 --- a/docs/site/docs/usage.mdx +++ b/docs/site/docs/usage.mdx @@ -134,7 +134,7 @@ If you need to use numbers in the templates, you can use the `atoi` function to ### Validation -Variables can be validated by defining [`validators`](/api#variable) property for the variable. Validators support regular expression pattern matching. +Variables can be validated by defining [`validators`](/api#variable) property for the variable. Validators support regular expression pattern matching, and table validators also have column value uniqueness validator. ## Publishing recipes diff --git a/examples/variable-types/recipe.yml b/examples/variable-types/recipe.yml index da3582af..66ad2fab 100644 --- a/examples/variable-types/recipe.yml +++ b/examples/variable-types/recipe.yml @@ -75,9 +75,16 @@ vars: - name: TABLE_VAR_WITH_VALIDATOR description: | - Regular expression validators can be set for a table variable by defining `validators` and `column` property - columns: [NOT_EMPTY_COL, CAN_BE_EMPTY_COL] + Validators can be set for a table variable by defining `validators` and `column` property. + + Regular expression validator checks that the value entered in a cell matches the defined expression. + + Unique validator ensures all values within a column are unique. + columns: [NOT_EMPTY_UNIQUE_COL, CAN_BE_EMPTY_COL] validators: - pattern: ".+" - column: NOT_EMPTY_COL + column: NOT_EMPTY_UNIQUE_COL help: "If the cell is empty, this help message will be shown" + - unique: true + column: NOT_EMPTY_UNIQUE_COL + help: "If the values in the defined column are not unique this help message will be shown" diff --git a/pkg/recipe/variable.go b/pkg/recipe/variable.go index 23e1eaf5..6da485d5 100644 --- a/pkg/recipe/variable.go +++ b/pkg/recipe/variable.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "regexp" + "slices" "strings" "github.com/expr-lang/expr" @@ -56,6 +57,9 @@ type VariableValidator struct { // Apply the validator to a column if the variable type is table Column string `yaml:"column,omitempty"` + + // When targeting table columns, set this to true to make sure that the values in the column are unique + Unique bool `yaml:"unique,omitempty"` } // VariableValues stores values for each variable @@ -113,8 +117,21 @@ func (v *Variable) Validate() error { return fmt.Errorf("%s: validator need to have `column` property defined since the variable is table type", validatorIndex) } - if validator.Pattern == "" { - return fmt.Errorf("%s: regexp pattern is empty", validatorIndex) + if validator.Unique { + if validator.Column == "" { + return fmt.Errorf("%s: validator need to have `column` property defined since unique validation works only on table variables", validatorIndex) + } + if validator.Pattern != "" { + return fmt.Errorf("%s: validator can not have `pattern` property defined when `unique` is set to true", validatorIndex) + } + return nil + } else { + if validator.Pattern == "" { + return fmt.Errorf("%s: regexp pattern is empty", validatorIndex) + } + if _, err := regexp.Compile(validator.Pattern); err != nil { + return fmt.Errorf("%s: invalid validator regexp pattern: %w", validatorIndex, err) + } } if validator.Column != "" { @@ -134,10 +151,6 @@ func (v *Variable) Validate() error { return fmt.Errorf("%s: column %s does not exist in the variable", validatorIndex, validator.Column) } } - - if _, err := regexp.Compile(validator.Pattern); err != nil { - return fmt.Errorf("%s: invalid variable regexp pattern: %w", validatorIndex, err) - } } if v.If != "" { @@ -167,19 +180,48 @@ func (val VariableValues) Validate() error { return nil } -func (r *VariableValidator) CreateValidatorFunc() func(input string) error { - reg := regexp.MustCompile(r.Pattern) +func (r *VariableValidator) CreateTableValidatorFunc() (func(cols []string, rows [][]string, input string) error, error) { + if r.Unique { + return func(cols []string, rows [][]string, input string) error { + colIndex := slices.Index(cols, r.Column) + colValues := make([]string, len(rows)) + for i, row := range rows { + colValues[i] = row[colIndex] + } + slices.Sort(colValues) - return func(input string) error { - if match := reg.MatchString(input); !match { - if r.Help != "" { - return errors.New(r.Help) - } else { - return errors.New("the input did not match the regexp pattern") + if uniqValues := len(slices.Compact(colValues)); uniqValues != len(colValues) { + if r.Help != "" { + return errors.New(r.Help) + } else { + return errors.New("value not unique within column") + } } - } - return nil + + return nil + }, nil } + + return nil, fmt.Errorf("unsupported table validator on column %q", r.Column) +} + +func (r *VariableValidator) CreateValidatorFunc() (func(input string) error, error) { + if r.Pattern != "" { + reg := regexp.MustCompile(r.Pattern) + + return func(input string) error { + if match := reg.MatchString(input); !match { + if r.Help != "" { + return errors.New(r.Help) + } else { + return errors.New("the input did not match the regexp pattern") + } + } + return nil + }, nil + } + + return nil, fmt.Errorf("unsupported validator on column %q", r.Column) } func (t *TableValue) FromCSV(columns []string, input string, delimiter rune) error { diff --git a/pkg/recipe/variable_test.go b/pkg/recipe/variable_test.go index 7be5cfaf..c53860df 100644 --- a/pkg/recipe/variable_test.go +++ b/pkg/recipe/variable_test.go @@ -79,9 +79,12 @@ func TestVariableRegExpValidation(t *testing.T) { }, } - validatorFunc := variable.Validators[0].CreateValidatorFunc() + validatorFunc, err := variable.Validators[0].CreateValidatorFunc() + if err != nil { + t.Error("Validator function creation failed") + } - err := validatorFunc("") + err = validatorFunc("") if err == nil { t.Error("Incorrectly validated empty string") } @@ -111,3 +114,59 @@ func TestVariableRegExpValidation(t *testing.T) { t.Error("Incorrectly invalidated valid string") } } + +func TestUniqueColumnValidation(t *testing.T) { + variable := &Variable{ + Name: "foo", + Description: "foo description", + Validators: []VariableValidator{ + { + Unique: true, + Column: "COL_1", + }, + }, + } + + validatorFunc, err := variable.Validators[0].CreateTableValidatorFunc() + if err != nil { + t.Error("Validator function creation failed") + } + + cols := []string{"COL_1", "COL_2"} + + err = validatorFunc( + cols, + [][]string{ + {"0_0", "0_1"}, + {"1_0", "1_1"}, + {"2_0", "2_1"}, + }, + "") + if err != nil { + t.Error("Incorrectly invalidated valid data") + } + + err = validatorFunc( + cols, + [][]string{ + {"0_0", "0_1"}, + {"0_0", "1_1"}, + {"2_0", "2_1"}, + }, + "") + if err == nil { + t.Error("Incorrectly validated invalid data") + } + + err = validatorFunc( + cols, + [][]string{ + {"0_0", "0_1"}, + {"1_0", "0_1"}, + {"2_0", "0_1"}, + }, + "") + if err != nil { + t.Error("Incorrectly invalidated valid data") + } +} diff --git a/pkg/recipeutil/values.go b/pkg/recipeutil/values.go index 730d6e6a..24cbd367 100644 --- a/pkg/recipeutil/values.go +++ b/pkg/recipeutil/values.go @@ -71,10 +71,24 @@ func ParseProvidedValues(variables []recipe.Variable, flags []string, delimiter for i := range targetedVariable.Validators { validator := targetedVariable.Validators[i] - validatorFunc := validator.CreateValidatorFunc() + + var validatorFunc func([]string, [][]string, string) error + + if validator.Pattern != "" { + regexValidator, _ := validator.CreateValidatorFunc() + validatorFunc = func(cols []string, rows [][]string, input string) error { + return regexValidator(input) + } + } else { + validatorFunc, err = validator.CreateTableValidatorFunc() + if err != nil { + return nil, fmt.Errorf("validator create failed for variable %s in column %s, row %d: %w", varName, validator.Column, i, err) + } + } + for _, row := range table.Rows { columnIndex := slices.Index(table.Columns, validator.Column) - if err := validatorFunc(row[columnIndex]); err != nil { + if err := validatorFunc(table.Columns, table.Rows, row[columnIndex]); err != nil { return nil, fmt.Errorf("validator failed for variable %s in column %s, row %d: %w", varName, validator.Column, i, err) } @@ -84,7 +98,10 @@ func ParseProvidedValues(variables []recipe.Variable, flags []string, delimiter default: for i := range targetedVariable.Validators { - validatorFunc := targetedVariable.Validators[i].CreateValidatorFunc() + validatorFunc, err := targetedVariable.Validators[i].CreateValidatorFunc() + if err != nil { + return nil, fmt.Errorf("validator create failed for value '%s=%s': %w", varName, varValue, err) + } if err := validatorFunc(varValue); err != nil { return nil, fmt.Errorf("validator failed for value '%s=%s': %w", varName, varValue, err) } diff --git a/pkg/ui/editable/model.go b/pkg/ui/editable/model.go index f2fba7d3..7fd6217d 100644 --- a/pkg/ui/editable/model.go +++ b/pkg/ui/editable/model.go @@ -41,7 +41,7 @@ type Cell struct { type Column struct { Title string Width int - Validators []func(string) error + Validators []func([]string, [][]string, string) error } type KeyMap struct { @@ -375,6 +375,15 @@ func (m *Model) Move(y, x int) tea.Cmd { return m.rows[m.cursorY][m.cursorX].input.Focus() } +func (m Model) Titles() []string { + titles := make([]string, len(m.cols)) + for i, col := range m.cols { + titles[i] = col.Title + } + + return titles +} + func (m Model) Values() [][]string { // If the table has only empty cells, return an empty slice if m.isEmpty() { @@ -428,7 +437,7 @@ func (m *Model) validateCell(y, x int) { errs := make([]error, 0, len(m.cols[x].Validators)) for i := range m.cols[x].Validators { - err := m.cols[x].Validators[i](cell.input.Value()) + err := m.cols[x].Validators[i](m.Titles(), m.Values(), cell.input.Value()) if err != nil { errs = append(errs, err) } diff --git a/pkg/ui/survey/prompt/string.go b/pkg/ui/survey/prompt/string.go index 40db4c07..3321449d 100644 --- a/pkg/ui/survey/prompt/string.go +++ b/pkg/ui/survey/prompt/string.go @@ -137,7 +137,10 @@ func (m StringModel) Validate() error { for _, v := range m.variable.Validators { if v.Pattern != "" { - validatorFunc := v.CreateValidatorFunc() + validatorFunc, err := v.CreateValidatorFunc() + if err != nil { + return fmt.Errorf("validator function create failed: %s", err) + } if err := validatorFunc(m.textInput.Value()); err != nil { return fmt.Errorf("%w: %s", util.ErrRegExFailed, err) } diff --git a/pkg/ui/survey/prompt/table.go b/pkg/ui/survey/prompt/table.go index e42e9d51..c7faaf22 100644 --- a/pkg/ui/survey/prompt/table.go +++ b/pkg/ui/survey/prompt/table.go @@ -31,17 +31,29 @@ var _ Model = TableModel{} func NewTableModel(v recipe.Variable, styles style.Styles) TableModel { cols := make([]editable.Column, len(v.Columns)) - validators := make(map[string][]func(string) error) + validators := make(map[string][]func([]string, [][]string, string) error) for i, validator := range v.Validators { if validator.Column != "" { if validators[validator.Column] == nil { - validators[validator.Column] = make([]func(string) error, 0) + validators[validator.Column] = make([]func([]string, [][]string, string) error, 0) } - validators[validator.Column] = append(validators[validator.Column], v.Validators[i].CreateValidatorFunc()) + if validator.Pattern != "" { + regexValidator, err := v.Validators[i].CreateValidatorFunc() + if err == nil { + validators[validator.Column] = append(validators[validator.Column], + func(cols []string, rows [][]string, input string) error { + return regexValidator(input) + }) + } + } else { + validatorFn, err := validator.CreateTableValidatorFunc() + if err == nil { + validators[validator.Column] = append(validators[validator.Column], validatorFn) + } + } } } - for i, c := range v.Columns { cols[i] = editable.Column{ Title: c,