Skip to content

Commit d187ae9

Browse files
vessemajori
andauthored
feat: unique table column validator (#99)
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]>
1 parent 4198020 commit d187ae9

File tree

9 files changed

+182
-32
lines changed

9 files changed

+182
-32
lines changed

cmd/docs/templates/_schema.tmpl

+1
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
| `pattern` | `string` | | Regular expression pattern to match the input against. |
4141
| `help` | `string` | | If the regular expression validation fails, this help message will be shown to the user. |
4242
| `column` | `string` | | Apply the validator to a column if the variable type is table. |
43+
| `unique` | `bool` | | When targeting table columns, set this to true to make sure that the values in the column are unique. |
4344

4445
## Test schema (`test.yml`)
4546

docs/site/docs/usage.mdx

+1-1
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ If you need to use numbers in the templates, you can use the `atoi` function to
134134

135135
### Validation
136136

137-
Variables can be validated by defining [`validators`](/api#variable) property for the variable. Validators support regular expression pattern matching.
137+
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.
138138

139139
## Publishing recipes
140140

examples/variable-types/recipe.yml

+10-3
Original file line numberDiff line numberDiff line change
@@ -75,9 +75,16 @@ vars:
7575

7676
- name: TABLE_VAR_WITH_VALIDATOR
7777
description: |
78-
Regular expression validators can be set for a table variable by defining `validators` and `column` property
79-
columns: [NOT_EMPTY_COL, CAN_BE_EMPTY_COL]
78+
Validators can be set for a table variable by defining `validators` and `column` property.
79+
80+
Regular expression validator checks that the value entered in a cell matches the defined expression.
81+
82+
Unique validator ensures all values within a column are unique.
83+
columns: [NOT_EMPTY_UNIQUE_COL, CAN_BE_EMPTY_COL]
8084
validators:
8185
- pattern: ".+"
82-
column: NOT_EMPTY_COL
86+
column: NOT_EMPTY_UNIQUE_COL
8387
help: "If the cell is empty, this help message will be shown"
88+
- unique: true
89+
column: NOT_EMPTY_UNIQUE_COL
90+
help: "If the values in the defined column are not unique this help message will be shown"

pkg/recipe/variable.go

+58-16
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"errors"
66
"fmt"
77
"regexp"
8+
"slices"
89
"strings"
910

1011
"github.com/expr-lang/expr"
@@ -56,6 +57,9 @@ type VariableValidator struct {
5657

5758
// Apply the validator to a column if the variable type is table
5859
Column string `yaml:"column,omitempty"`
60+
61+
// When targeting table columns, set this to true to make sure that the values in the column are unique
62+
Unique bool `yaml:"unique,omitempty"`
5963
}
6064

6165
// VariableValues stores values for each variable
@@ -113,8 +117,21 @@ func (v *Variable) Validate() error {
113117
return fmt.Errorf("%s: validator need to have `column` property defined since the variable is table type", validatorIndex)
114118
}
115119

116-
if validator.Pattern == "" {
117-
return fmt.Errorf("%s: regexp pattern is empty", validatorIndex)
120+
if validator.Unique {
121+
if validator.Column == "" {
122+
return fmt.Errorf("%s: validator need to have `column` property defined since unique validation works only on table variables", validatorIndex)
123+
}
124+
if validator.Pattern != "" {
125+
return fmt.Errorf("%s: validator can not have `pattern` property defined when `unique` is set to true", validatorIndex)
126+
}
127+
return nil
128+
} else {
129+
if validator.Pattern == "" {
130+
return fmt.Errorf("%s: regexp pattern is empty", validatorIndex)
131+
}
132+
if _, err := regexp.Compile(validator.Pattern); err != nil {
133+
return fmt.Errorf("%s: invalid validator regexp pattern: %w", validatorIndex, err)
134+
}
118135
}
119136

120137
if validator.Column != "" {
@@ -134,10 +151,6 @@ func (v *Variable) Validate() error {
134151
return fmt.Errorf("%s: column %s does not exist in the variable", validatorIndex, validator.Column)
135152
}
136153
}
137-
138-
if _, err := regexp.Compile(validator.Pattern); err != nil {
139-
return fmt.Errorf("%s: invalid variable regexp pattern: %w", validatorIndex, err)
140-
}
141154
}
142155

143156
if v.If != "" {
@@ -167,19 +180,48 @@ func (val VariableValues) Validate() error {
167180
return nil
168181
}
169182

170-
func (r *VariableValidator) CreateValidatorFunc() func(input string) error {
171-
reg := regexp.MustCompile(r.Pattern)
183+
func (r *VariableValidator) CreateTableValidatorFunc() (func(cols []string, rows [][]string, input string) error, error) {
184+
if r.Unique {
185+
return func(cols []string, rows [][]string, input string) error {
186+
colIndex := slices.Index(cols, r.Column)
187+
colValues := make([]string, len(rows))
188+
for i, row := range rows {
189+
colValues[i] = row[colIndex]
190+
}
191+
slices.Sort(colValues)
172192

173-
return func(input string) error {
174-
if match := reg.MatchString(input); !match {
175-
if r.Help != "" {
176-
return errors.New(r.Help)
177-
} else {
178-
return errors.New("the input did not match the regexp pattern")
193+
if uniqValues := len(slices.Compact(colValues)); uniqValues != len(colValues) {
194+
if r.Help != "" {
195+
return errors.New(r.Help)
196+
} else {
197+
return errors.New("value not unique within column")
198+
}
179199
}
180-
}
181-
return nil
200+
201+
return nil
202+
}, nil
182203
}
204+
205+
return nil, fmt.Errorf("unsupported table validator on column %q", r.Column)
206+
}
207+
208+
func (r *VariableValidator) CreateValidatorFunc() (func(input string) error, error) {
209+
if r.Pattern != "" {
210+
reg := regexp.MustCompile(r.Pattern)
211+
212+
return func(input string) error {
213+
if match := reg.MatchString(input); !match {
214+
if r.Help != "" {
215+
return errors.New(r.Help)
216+
} else {
217+
return errors.New("the input did not match the regexp pattern")
218+
}
219+
}
220+
return nil
221+
}, nil
222+
}
223+
224+
return nil, fmt.Errorf("unsupported validator on column %q", r.Column)
183225
}
184226

185227
func (t *TableValue) FromCSV(columns []string, input string, delimiter rune) error {

pkg/recipe/variable_test.go

+61-2
Original file line numberDiff line numberDiff line change
@@ -79,9 +79,12 @@ func TestVariableRegExpValidation(t *testing.T) {
7979
},
8080
}
8181

82-
validatorFunc := variable.Validators[0].CreateValidatorFunc()
82+
validatorFunc, err := variable.Validators[0].CreateValidatorFunc()
83+
if err != nil {
84+
t.Error("Validator function creation failed")
85+
}
8386

84-
err := validatorFunc("")
87+
err = validatorFunc("")
8588
if err == nil {
8689
t.Error("Incorrectly validated empty string")
8790
}
@@ -111,3 +114,59 @@ func TestVariableRegExpValidation(t *testing.T) {
111114
t.Error("Incorrectly invalidated valid string")
112115
}
113116
}
117+
118+
func TestUniqueColumnValidation(t *testing.T) {
119+
variable := &Variable{
120+
Name: "foo",
121+
Description: "foo description",
122+
Validators: []VariableValidator{
123+
{
124+
Unique: true,
125+
Column: "COL_1",
126+
},
127+
},
128+
}
129+
130+
validatorFunc, err := variable.Validators[0].CreateTableValidatorFunc()
131+
if err != nil {
132+
t.Error("Validator function creation failed")
133+
}
134+
135+
cols := []string{"COL_1", "COL_2"}
136+
137+
err = validatorFunc(
138+
cols,
139+
[][]string{
140+
{"0_0", "0_1"},
141+
{"1_0", "1_1"},
142+
{"2_0", "2_1"},
143+
},
144+
"")
145+
if err != nil {
146+
t.Error("Incorrectly invalidated valid data")
147+
}
148+
149+
err = validatorFunc(
150+
cols,
151+
[][]string{
152+
{"0_0", "0_1"},
153+
{"0_0", "1_1"},
154+
{"2_0", "2_1"},
155+
},
156+
"")
157+
if err == nil {
158+
t.Error("Incorrectly validated invalid data")
159+
}
160+
161+
err = validatorFunc(
162+
cols,
163+
[][]string{
164+
{"0_0", "0_1"},
165+
{"1_0", "0_1"},
166+
{"2_0", "0_1"},
167+
},
168+
"")
169+
if err != nil {
170+
t.Error("Incorrectly invalidated valid data")
171+
}
172+
}

pkg/recipeutil/values.go

+20-3
Original file line numberDiff line numberDiff line change
@@ -71,10 +71,24 @@ func ParseProvidedValues(variables []recipe.Variable, flags []string, delimiter
7171

7272
for i := range targetedVariable.Validators {
7373
validator := targetedVariable.Validators[i]
74-
validatorFunc := validator.CreateValidatorFunc()
74+
75+
var validatorFunc func([]string, [][]string, string) error
76+
77+
if validator.Pattern != "" {
78+
regexValidator, _ := validator.CreateValidatorFunc()
79+
validatorFunc = func(cols []string, rows [][]string, input string) error {
80+
return regexValidator(input)
81+
}
82+
} else {
83+
validatorFunc, err = validator.CreateTableValidatorFunc()
84+
if err != nil {
85+
return nil, fmt.Errorf("validator create failed for variable %s in column %s, row %d: %w", varName, validator.Column, i, err)
86+
}
87+
}
88+
7589
for _, row := range table.Rows {
7690
columnIndex := slices.Index(table.Columns, validator.Column)
77-
if err := validatorFunc(row[columnIndex]); err != nil {
91+
if err := validatorFunc(table.Columns, table.Rows, row[columnIndex]); err != nil {
7892
return nil, fmt.Errorf("validator failed for variable %s in column %s, row %d: %w", varName, validator.Column, i, err)
7993
}
8094

@@ -84,7 +98,10 @@ func ParseProvidedValues(variables []recipe.Variable, flags []string, delimiter
8498

8599
default:
86100
for i := range targetedVariable.Validators {
87-
validatorFunc := targetedVariable.Validators[i].CreateValidatorFunc()
101+
validatorFunc, err := targetedVariable.Validators[i].CreateValidatorFunc()
102+
if err != nil {
103+
return nil, fmt.Errorf("validator create failed for value '%s=%s': %w", varName, varValue, err)
104+
}
88105
if err := validatorFunc(varValue); err != nil {
89106
return nil, fmt.Errorf("validator failed for value '%s=%s': %w", varName, varValue, err)
90107
}

pkg/ui/editable/model.go

+11-2
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ type Cell struct {
4141
type Column struct {
4242
Title string
4343
Width int
44-
Validators []func(string) error
44+
Validators []func([]string, [][]string, string) error
4545
}
4646

4747
type KeyMap struct {
@@ -375,6 +375,15 @@ func (m *Model) Move(y, x int) tea.Cmd {
375375
return m.rows[m.cursorY][m.cursorX].input.Focus()
376376
}
377377

378+
func (m Model) Titles() []string {
379+
titles := make([]string, len(m.cols))
380+
for i, col := range m.cols {
381+
titles[i] = col.Title
382+
}
383+
384+
return titles
385+
}
386+
378387
func (m Model) Values() [][]string {
379388
// If the table has only empty cells, return an empty slice
380389
if m.isEmpty() {
@@ -428,7 +437,7 @@ func (m *Model) validateCell(y, x int) {
428437

429438
errs := make([]error, 0, len(m.cols[x].Validators))
430439
for i := range m.cols[x].Validators {
431-
err := m.cols[x].Validators[i](cell.input.Value())
440+
err := m.cols[x].Validators[i](m.Titles(), m.Values(), cell.input.Value())
432441
if err != nil {
433442
errs = append(errs, err)
434443
}

pkg/ui/survey/prompt/string.go

+4-1
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,10 @@ func (m StringModel) Validate() error {
137137

138138
for _, v := range m.variable.Validators {
139139
if v.Pattern != "" {
140-
validatorFunc := v.CreateValidatorFunc()
140+
validatorFunc, err := v.CreateValidatorFunc()
141+
if err != nil {
142+
return fmt.Errorf("validator function create failed: %s", err)
143+
}
141144
if err := validatorFunc(m.textInput.Value()); err != nil {
142145
return fmt.Errorf("%w: %s", util.ErrRegExFailed, err)
143146
}

pkg/ui/survey/prompt/table.go

+16-4
Original file line numberDiff line numberDiff line change
@@ -31,17 +31,29 @@ var _ Model = TableModel{}
3131
func NewTableModel(v recipe.Variable, styles style.Styles) TableModel {
3232
cols := make([]editable.Column, len(v.Columns))
3333

34-
validators := make(map[string][]func(string) error)
34+
validators := make(map[string][]func([]string, [][]string, string) error)
3535
for i, validator := range v.Validators {
3636
if validator.Column != "" {
3737
if validators[validator.Column] == nil {
38-
validators[validator.Column] = make([]func(string) error, 0)
38+
validators[validator.Column] = make([]func([]string, [][]string, string) error, 0)
3939
}
4040

41-
validators[validator.Column] = append(validators[validator.Column], v.Validators[i].CreateValidatorFunc())
41+
if validator.Pattern != "" {
42+
regexValidator, err := v.Validators[i].CreateValidatorFunc()
43+
if err == nil {
44+
validators[validator.Column] = append(validators[validator.Column],
45+
func(cols []string, rows [][]string, input string) error {
46+
return regexValidator(input)
47+
})
48+
}
49+
} else {
50+
validatorFn, err := validator.CreateTableValidatorFunc()
51+
if err == nil {
52+
validators[validator.Column] = append(validators[validator.Column], validatorFn)
53+
}
54+
}
4255
}
4356
}
44-
4557
for i, c := range v.Columns {
4658
cols[i] = editable.Column{
4759
Title: c,

0 commit comments

Comments
 (0)