Skip to content

Commit

Permalink
feat: unique table column validator (#99)
Browse files Browse the repository at this point in the history
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 <[email protected]>
  • Loading branch information
vesse and majori authored May 14, 2024
1 parent 4198020 commit d187ae9
Show file tree
Hide file tree
Showing 9 changed files with 182 additions and 32 deletions.
1 change: 1 addition & 0 deletions cmd/docs/templates/_schema.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -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`)

Expand Down
2 changes: 1 addition & 1 deletion docs/site/docs/usage.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
13 changes: 10 additions & 3 deletions examples/variable-types/recipe.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
74 changes: 58 additions & 16 deletions pkg/recipe/variable.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"regexp"
"slices"
"strings"

"github.com/expr-lang/expr"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 != "" {
Expand All @@ -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 != "" {
Expand Down Expand Up @@ -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 {
Expand Down
63 changes: 61 additions & 2 deletions pkg/recipe/variable_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down Expand Up @@ -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")
}
}
23 changes: 20 additions & 3 deletions pkg/recipeutil/values.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand All @@ -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)
}
Expand Down
13 changes: 11 additions & 2 deletions pkg/ui/editable/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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)
}
Expand Down
5 changes: 4 additions & 1 deletion pkg/ui/survey/prompt/string.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
20 changes: 16 additions & 4 deletions pkg/ui/survey/prompt/table.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down

0 comments on commit d187ae9

Please sign in to comment.