3
0

Fix deduplication of unique values in module fields

This commit is contained in:
Mumbi Francis
2024-02-19 17:23:48 +01:00
committed by Jože Fortun
parent 08fd291cf6
commit 32d09e5afc
8 changed files with 574 additions and 200 deletions

View File

@@ -52,19 +52,20 @@
</b-input-group>
</multi>
<b-input-group
v-else
:prepend="field.options.prefix"
:append="field.options.suffix"
>
<b-form-input
v-model="value"
autocomplete="off"
type="number"
number
/>
</b-input-group>
<errors :errors="errors" />
<template v-else>
<b-input-group
:prepend="field.options.prefix"
:append="field.options.suffix"
>
<b-form-input
v-model="value"
autocomplete="off"
type="number"
number
/>
</b-input-group>
<errors :errors="errors" />
</template>
</b-form-group>
</template>
<script>

View File

@@ -39,6 +39,8 @@
:options="selectOptions"
stacked
/>
<errors :errors="errors" />
</template>
<multi

View File

@@ -9,7 +9,7 @@
<span
:class="{ 'text-primary': error.kind.includes('warning') }"
>
{{ $t(error.message, { value: error.meta.value }) }}
{{ error.message }}
</span>
</b-form-invalid-feedback>
</div>

View File

@@ -50,19 +50,21 @@ interface Options {
positionField?: string;
refField?: string; // When adding a new record, prefill refField value with parent record ID
editFields?: unknown[];
linkToParent: boolean; // Legacy
// When adding a new record, link it to parent when available
linkToParent: boolean;
// Should records be opened in a new tab
// legacy field that has been removed but we keep it for backwards compatibility
openInNewTab: boolean;
// Are table rows selectable
selectable: boolean;
selectMode: 'multi' | 'single' | 'range';
// Ordered list of buttons to display in the block
selectionButtons: Array<Button>;
bulkRecordEditEnabled: boolean;
inlineRecordEditEnabled: boolean;
filterPresets: FilterPreset[];

View File

@@ -2,5 +2,4 @@ errors:
empty: This field is required
invalidValue: Invalid field value
invalidRef: Invalid field reference
duplicateValueInSet: This value already exists in list
duplicateValue: The value "{{value}}" already exists in another record
duplicateValueInSet: This value already exists in list

View File

@@ -359,7 +359,7 @@ func (set RecordBulkSet) ToBulkOperations(dftModule uint64, dftNamespace uint64)
}
// GetValuesByName filters values for records by names
func (set RecordSet) GetValuesByName(names ...string) (out RecordValueSet) {
func (set RecordSet) GetValuesByName(names ...string) (out map[uint64]RecordValueSet) {
nameMap := make(map[string]bool)
for _, n := range names {
if len(n) > 0 {
@@ -367,11 +367,13 @@ func (set RecordSet) GetValuesByName(names ...string) (out RecordValueSet) {
}
}
out = make(map[uint64]RecordValueSet)
err := set.Walk(func(rec *Record) error {
_ = rec.Values.Walk(func(val *RecordValue) error {
if val != nil && nameMap[val.Name] {
val.RecordID = rec.ID
out = append(out, val)
out[val.RecordID] = append(out[val.RecordID], val)
}
return nil
})

View File

@@ -1,265 +1,337 @@
package types
import (
"context"
"fmt"
"strings"
"context"
"fmt"
"strings"
"github.com/cortezaproject/corteza/server/pkg/locale"
"github.com/cortezaproject/corteza/server/pkg/str"
"github.com/spf13/cast"
"github.com/cortezaproject/corteza/server/pkg/locale"
"github.com/cortezaproject/corteza/server/pkg/str"
"github.com/spf13/cast"
)
type (
deDup struct {
ls localeService
}
deDup struct {
ls localeService
}
localeService interface {
T(ctx context.Context, ns, key string, rr ...string) string
}
localeService interface {
T(ctx context.Context, ns, key string, rr ...string) string
}
DeDupRule struct {
Name DeDupRuleName `json:"name"`
Strict bool `json:"strict"`
ErrorMessage string `json:"errorMessage"`
ConstraintSet DeDupRuleConstraintSet `json:"constraints"`
}
DeDupRule struct {
Name DeDupRuleName `json:"name"`
Strict bool `json:"strict"`
ErrorMessage string `json:"errorMessage"`
ConstraintSet DeDupRuleConstraintSet `json:"constraints"`
}
DeDupRuleConstraint struct {
Attribute string `json:"attribute"`
Modifier DeDupValueModifier `json:"modifier"`
MultiValue DeDupMultiValueConstraint `json:"multiValue"`
}
DeDupRuleConstraint struct {
Attribute string `json:"attribute"`
Modifier DeDupValueModifier `json:"modifier"`
MultiValue DeDupMultiValueConstraint `json:"multiValue"`
}
DeDupRuleConstraintSet []*DeDupRuleConstraint
DeDupRuleConstraintSet []*DeDupRuleConstraint
// DeDupRuleName represent the identifier for duplicate detection rule
DeDupRuleName string
// DeDupRuleName represent the identifier for duplicate detection rule
DeDupRuleName string
// DeDupValueModifier represent the algorithm used to check value string
DeDupValueModifier string
// DeDupValueModifier represent the algorithm used to check value string
DeDupValueModifier string
// DeDupMultiValueConstraint for matching multi values accordingly
DeDupMultiValueConstraint string
// DeDupMultiValueConstraint for matching multi values accordingly
DeDupMultiValueConstraint string
// DeDupIssueKind based on strict mode rule or duplication config
DeDupIssueKind string
// DeDupIssueKind based on strict mode rule or duplication config
DeDupIssueKind string
)
const (
ignoreCase DeDupValueModifier = "ignore-case"
caseSensitive DeDupValueModifier = "case-sensitive"
fuzzyMatch DeDupValueModifier = "fuzzy-match"
soundsLike DeDupValueModifier = "sounds-like"
ignoreCase DeDupValueModifier = "ignore-case"
caseSensitive DeDupValueModifier = "case-sensitive"
fuzzyMatch DeDupValueModifier = "fuzzy-match"
soundsLike DeDupValueModifier = "sounds-like"
oneOf DeDupMultiValueConstraint = "one-of"
equal DeDupMultiValueConstraint = "equal"
oneOf DeDupMultiValueConstraint = "one-of"
equal DeDupMultiValueConstraint = "equal"
deDupWarning DeDupIssueKind = "duplication_warning"
deDupError DeDupIssueKind = "duplication_error"
deDupWarning DeDupIssueKind = "duplication_warning"
deDupError DeDupIssueKind = "duplication_error"
)
func DeDup() *deDup {
return &deDup{
ls: locale.Global(),
}
return &deDup{
ls: locale.Global(),
}
}
func (d deDup) CheckDuplication(ctx context.Context, rules DeDupRuleSet, rec Record, rr RecordSet) (out *RecordValueErrorSet, err error) {
out = &RecordValueErrorSet{}
err = rules.Walk(func(rule *DeDupRule) error {
if rule.HasAttributes() {
values := rr.GetValuesByName(distinct(rule.Attributes())...)
out = &RecordValueErrorSet{}
err = rules.Walk(func(rule *DeDupRule) error {
if rule.HasAttributes() {
values := rr.GetValuesByName(distinct(rule.Attributes())...)
set := rule.validateValue(ctx, d.ls, rec, values)
for valID, value := range values {
if valID == rec.ID {
continue
}
if !set.IsValid() {
out.Push(set.Set...)
}
}
return nil
})
if err != nil {
return
}
set := rule.validateValue(ctx, d.ls, rec, value)
if out.IsValid() {
out = nil
}
return
if !set.IsValid() {
out.Push(set.Set...)
}
}
}
return nil
})
if err != nil {
return
}
if out.IsValid() {
out = nil
}
return
}
func (rule DeDupIssueKind) String() string {
return string(rule)
return string(rule)
}
func (rule DeDupRule) HasAttributes() bool {
return len(rule.ConstraintSet) > 0 && len(rule.Attributes()) > 0
return len(rule.ConstraintSet) > 0 && len(rule.Attributes()) > 0
}
func (rule DeDupRule) Attributes() (out []string) {
for _, c := range rule.ConstraintSet {
out = append(out, c.Attribute)
}
return
for _, c := range rule.ConstraintSet {
out = append(out, c.Attribute)
}
return
}
func (rule DeDupRule) IsStrict() bool {
return rule.Strict
return rule.Strict
}
func (rule DeDupRule) IssueKind() string {
out := deDupWarning
if rule.Strict {
out = deDupError
}
out := deDupWarning
if rule.Strict {
out = deDupError
}
return out.String()
return out.String()
}
func (rule DeDupRule) IssueMessage() (out string) {
return "record-field.errors.duplicateValue"
func (rule DeDupRule) IssueMessage(value string) (out string) {
return fmt.Sprintf("The value %s already exists in another record", value)
}
func (rule DeDupRule) IssueMultivalueMessage(values []string) (out string) {
return fmt.Sprintf("The values [%s] already exist in another record", strings.Join(values, ", "))
}
func (rule DeDupRule) String() string {
return fmt.Sprintf("%s duplicate detection on `%s` field", rule.Name, strings.Join(rule.Attributes(), ", "))
return fmt.Sprintf("%s duplicate detection on `%s` field", rule.Name, strings.Join(rule.Attributes(), ", "))
}
// validateValue will check duplicate detection based on rules name
func (rule DeDupRule) validateValue(ctx context.Context, ls localeService, rec Record, vv RecordValueSet) (out *RecordValueErrorSet) {
return rule.checkCaseSensitiveDuplication(ctx, ls, rec, vv)
return rule.checkDuplication(ctx, ls, rec, vv)
}
func (rule DeDupRule) checkCaseSensitiveDuplication(ctx context.Context, ls localeService, rec Record, vv RecordValueSet) (out *RecordValueErrorSet) {
var (
recVal = rec.Values
)
func (rule DeDupRule) checkDuplication(ctx context.Context, ls localeService, rec Record, vv RecordValueSet) (out *RecordValueErrorSet) {
var (
recVal = rec.Values
)
for _, c := range rule.ConstraintSet {
rvv := recVal.FilterByName(c.Attribute)
if rvv.Len() == 0 {
continue
}
for _, c := range rule.ConstraintSet {
rvv := recVal.FilterByName(c.Attribute)
if rvv.Len() == 0 {
continue
}
var (
valErr = &RecordValueErrorSet{}
)
var (
valErr = &RecordValueErrorSet{}
)
_ = vv.Walk(func(v *RecordValue) error {
if v.RecordID != rec.ID {
_ = rvv.Walk(func(rv *RecordValue) error {
if len(rv.Value) > 0 && matchValue(c.Modifier, rv.Value, v.Value) {
valErr.Push(RecordValueError{
Kind: rule.IssueKind(),
Message: ls.T(ctx, "compose", rule.IssueMessage()),
Meta: map[string]interface{}{
"field": v.Name,
"value": v.Value,
"dupValueField": rv.Name,
"recordID": cast.ToString(v.RecordID),
"rule": rule.String(),
},
})
}
return nil
})
existingVv := vv.FilterByName(c.Attribute)
moduleField := rec.module.Fields.FindByName(c.Attribute)
// 1. multiValue is empty, then all value needs to be a match then return error/warning
// 2. multiValue is oneOf, then one or more value needs to be a match then return error/warning
// 3. multiValue is equal, then all value needs to be a match then return error/warning
if (!valErr.IsValid() && (!c.HasMultiValue() || c.IsAllEqual()) && valErr.Len() == rvv.Len()) || (c.IsOneOf() && valErr.Len() > 0) {
if out == nil {
out = &RecordValueErrorSet{}
}
out.Push(valErr.Set...)
}
}
return nil
})
}
if moduleField.Multi && c.IsAllEqual() {
return rule.multiValueAllEqual(ctx, ls, c, rvv, existingVv)
}
return
_ = vv.Walk(func(v *RecordValue) error {
if v.RecordID != rec.ID {
_ = rvv.Walk(func(rv *RecordValue) error {
if len(rv.Value) > 0 && matchValue(c.Modifier, rv.Value, v.Value) {
valErr.Push(RecordValueError{
Kind: rule.IssueKind(),
Message: ls.T(ctx, "compose", rule.IssueMessage(v.Value)),
Meta: map[string]interface{}{
"field": v.Name,
"value": v.Value,
"dupValueField": rv.Name,
"recordID": cast.ToString(v.RecordID),
"rule": rule.String(),
},
})
}
return nil
})
// 1. multiValue is empty, then all value needs to be a match then return error/warning
// 2. multiValue is oneOf, then one or more value needs to be a match then return error/warning
// 3. multiValue is equal, then all value needs to be a match then return error/warning
if (!valErr.IsValid() && (!c.HasMultiValue() || c.IsAllEqual()) && valErr.Len() == rvv.Len()) || (c.IsOneOf() && valErr.Len() > 0) {
if out == nil {
out = &RecordValueErrorSet{}
}
out.Push(valErr.Set...)
}
}
return nil
})
}
return
}
func (rule DeDupRule) multiValueAllEqual(ctx context.Context, ls localeService, c *DeDupRuleConstraint, rvv RecordValueSet, existingVv RecordValueSet) (out *RecordValueErrorSet) {
var (
valErr = &RecordValueErrorSet{}
)
if rvv.Len() == existingVv.Len() {
rvvmap := make(map[string]int)
existingVvmap := make(map[string]int)
dupValues := recordValueFrequencyMap(rvvmap, c.Modifier, rvv)
_ = recordValueFrequencyMap(existingVvmap, c.Modifier, existingVv)
if matchRecordValueFrequencyMap(rvvmap, existingVvmap) {
valErr.Push(RecordValueError{
Kind: rule.IssueKind(),
Message: ls.T(ctx, "compose", rule.IssueMultivalueMessage(dupValues)),
Meta: map[string]interface{}{
"field": c.Attribute,
"dupValueField": c.Attribute,
"rule": rule.String(),
},
})
return valErr
}
}
return nil
}
func (dr DeDupRuleSet) Validate() (err error) {
return dr.Walk(func(rule *DeDupRule) (err error) {
if !rule.HasAttributes() {
err = fmt.Errorf("deduplication not valid (no constraints)")
return
}
return dr.Walk(func(rule *DeDupRule) (err error) {
if !rule.HasAttributes() {
err = fmt.Errorf("deduplication not valid (no constraints)")
return
}
for _, a := range rule.Attributes() {
if a == "" {
err = fmt.Errorf("deduplication not valid (invalid field)")
return
}
}
for _, a := range rule.Attributes() {
if a == "" {
err = fmt.Errorf("deduplication not valid (invalid field)")
return
}
}
return
})
return
})
}
func (c DeDupRuleConstraint) HasMultiValue() bool {
switch c.MultiValue {
case oneOf, equal:
return true
default:
return false
}
switch c.MultiValue {
case oneOf, equal:
return true
default:
return false
}
}
func (c DeDupRuleConstraint) IsAllEqual() bool {
return c.MultiValue == equal
return c.MultiValue == equal
}
func (c DeDupRuleConstraint) IsOneOf() bool {
return c.MultiValue == oneOf
return c.MultiValue == oneOf
}
func (v *RecordValueErrorSet) SetMetaID(id uint64) {
if v.IsValid() {
return
}
if v.IsValid() {
return
}
for _, val := range v.Set {
if val.Meta != nil {
if _, ok := val.Meta["id"]; !ok {
val.Meta["id"] = cast.ToString(id)
}
}
}
for _, val := range v.Set {
if val.Meta != nil {
if _, ok := val.Meta["id"]; !ok {
val.Meta["id"] = cast.ToString(id)
}
}
}
}
func (v *RecordValueErrorSet) HasStrictErrors() bool {
return v.HasKind(deDupError.String())
return v.HasKind(deDupError.String())
}
// distinct only list the different (distinct) values
func distinct(input []string) (out []string) {
keys := make(map[string]bool)
for _, val := range input {
if _, ok := keys[val]; !ok {
keys[val] = true
out = append(out, val)
}
}
return
keys := make(map[string]bool)
for _, val := range input {
if _, ok := keys[val]; !ok {
keys[val] = true
out = append(out, val)
}
}
return
}
// matchValue will check if the input matches with target string as per the modifier
func matchValue(modifier DeDupValueModifier, input string, target string) bool {
switch modifier {
case ignoreCase:
return str.Match(input, target, str.CaseInSensitiveMatch)
case caseSensitive:
return str.Match(input, target, str.CaseSensitiveMatch)
case fuzzyMatch:
return str.Match(input, target, str.LevenshteinDistance)
case soundsLike:
return str.Match(input, target, str.Soundex)
default:
// ignoreCase as default, if not specified
return str.Match(input, target, str.CaseInSensitiveMatch)
}
switch modifier {
case ignoreCase:
return str.Match(input, target, str.CaseInSensitiveMatch)
case caseSensitive:
return str.Match(input, target, str.CaseSensitiveMatch)
case fuzzyMatch:
return str.Match(input, target, str.LevenshteinDistance)
case soundsLike:
return str.Match(input, target, str.Soundex)
default:
// ignoreCase as default, if not specified
return str.Match(input, target, str.CaseInSensitiveMatch)
}
}
func recordValueFrequencyMap(rvFreqMap map[string]int, c DeDupValueModifier, vv RecordValueSet) (values []string) {
for _, v := range vv {
values = append(values, v.Value)
if c == ignoreCase {
v.Value = strings.ToLower(v.Value)
}
rvFreqMap[v.Value]++
}
return values
}
func matchRecordValueFrequencyMap(a, b map[string]int) (ok bool) {
for k, keyCount := range a {
_, ok = b[k]
if !ok || keyCount != b[k] {
return false
}
}
return true
}

View File

@@ -38,6 +38,16 @@ func TestDeDupRule_checkCaseSensitiveDuplication(t *testing.T) {
rule: rule1,
rec: Record{
ID: 1,
module: &Module{
ID: 1,
Fields: ModuleFieldSet{
&ModuleField{
Name: "name",
Kind: "String",
Multi: false,
},
},
},
Values: RecordValueSet{
&RecordValue{
RecordID: 1,
@@ -57,7 +67,7 @@ func TestDeDupRule_checkCaseSensitiveDuplication(t *testing.T) {
Set: []RecordValueError{
{
Kind: deDupError.String(),
Message: rule1.IssueMessage(),
Message: rule1.IssueMessage("test"),
Meta: map[string]interface{}{
"field": "name",
"value": "test",
@@ -74,8 +84,294 @@ func TestDeDupRule_checkCaseSensitiveDuplication(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotOut := tt.rule.checkCaseSensitiveDuplication(ctx, ls, tt.rec, tt.vv)
req.Equal(tt.wantOut, gotOut, "checkCaseSensitiveDuplication() = %v, want %v", gotOut, tt.wantOut)
gotOut := tt.rule.checkDuplication(ctx, ls, tt.rec, tt.vv)
req.Equal(tt.wantOut, gotOut, "checkDuplication() = %v, want %v", gotOut, tt.wantOut)
})
}
}
func TestDedupRule_checkMultiValueEqualDuplication(t *testing.T) {
var (
req = require.New(t)
ctx = context.Background()
ls = locale.Global()
rule1 = DeDupRule{
Name: "",
Strict: true,
ConstraintSet: []*DeDupRuleConstraint{
{
Attribute: "name",
Modifier: caseSensitive,
MultiValue: equal,
},
},
}
rule2 = DeDupRule{
Name: "ignore case rule",
Strict: true,
ConstraintSet: []*DeDupRuleConstraint{
{
Attribute: "name",
Modifier: ignoreCase,
MultiValue: equal,
},
},
}
numberRule = DeDupRule{
Name: "number rule",
Strict: true,
ConstraintSet: []*DeDupRuleConstraint{
{
Attribute: "count",
Modifier: ignoreCase,
MultiValue: equal,
},
},
}
locationRule = DeDupRule{
Name: "location rule",
Strict: true,
ConstraintSet: []*DeDupRuleConstraint{
{
Attribute: "location",
Modifier: ignoreCase,
MultiValue: equal,
},
},
}
tests = []struct {
name string
rule DeDupRule
rec Record
vv RecordValueSet
wantOut *RecordValueErrorSet
}{
{
name: "no duplication",
rule: rule1,
rec: Record{
ID: 1,
module: &Module{
ID: 1,
Fields: ModuleFieldSet{
&ModuleField{
Name: "name",
Kind: "String",
Multi: true,
},
},
},
Values: RecordValueSet{
&RecordValue{
RecordID: 1,
Name: "name",
Value: "test",
},
&RecordValue{
RecordID: 1,
Name: "name",
Value: "test test",
},
},
},
vv: RecordValueSet{
&RecordValue{
RecordID: 0,
Name: "name",
Value: "test",
},
&RecordValue{
RecordID: 0,
Name: "name",
Value: "test test",
},
},
wantOut: &RecordValueErrorSet{
Set: []RecordValueError{
{
Kind: deDupError.String(),
Message: rule1.IssueMultivalueMessage([]string{"test", "test test"}),
Meta: map[string]interface{}{
"field": "name",
"dupValueField": "name",
"rule": rule1.String(),
},
},
},
},
},
{
name: "no duplication",
rule: rule2,
rec: Record{
ID: 1,
module: &Module{
ID: 1,
Fields: ModuleFieldSet{
&ModuleField{
Name: "name",
Kind: "String",
Multi: true,
},
},
},
Values: RecordValueSet{
&RecordValue{
RecordID: 1,
Name: "name",
Value: "test",
},
&RecordValue{
RecordID: 1,
Name: "name",
Value: "test tEst",
},
},
},
vv: RecordValueSet{
&RecordValue{
RecordID: 0,
Name: "name",
Value: "test",
},
&RecordValue{
RecordID: 0,
Name: "name",
Value: "Test Test",
},
},
wantOut: &RecordValueErrorSet{
Set: []RecordValueError{
{
Kind: deDupError.String(),
Message: rule2.IssueMultivalueMessage([]string{"test", "test tEst"}),
Meta: map[string]interface{}{
"field": "name",
"dupValueField": "name",
"rule": rule2.String(),
},
},
},
},
},
{
name: "no duplication",
rule: numberRule,
rec: Record{
ID: 1,
module: &Module{
ID: 1,
Fields: ModuleFieldSet{
&ModuleField{
Name: "count",
Multi: true,
},
},
},
Values: RecordValueSet{
&RecordValue{
RecordID: 1,
Name: "count",
Value: "234",
},
&RecordValue{
RecordID: 1,
Name: "count",
Value: "897",
},
},
},
vv: RecordValueSet{
&RecordValue{
RecordID: 0,
Name: "count",
Value: "897",
},
&RecordValue{
RecordID: 0,
Name: "count",
Value: "234",
},
},
wantOut: &RecordValueErrorSet{
Set: []RecordValueError{
{
Kind: deDupError.String(),
Message: numberRule.IssueMultivalueMessage([]string{"234", "897"}),
Meta: map[string]interface{}{
"field": "count",
"dupValueField": "count",
"rule": numberRule.String(),
},
},
},
},
},
{
name: "no duplication",
rule: locationRule,
rec: Record{
ID: 1,
module: &Module{
ID: 1,
Fields: ModuleFieldSet{
&ModuleField{
Name: "location",
Multi: true,
},
},
},
Values: RecordValueSet{
&RecordValue{
RecordID: 1,
Name: "location",
Value: "{\"coordinates\":[-6.7833479,20.3768206]}",
},
&RecordValue{
RecordID: 1,
Name: "location",
Value: "{\"coordinates\":[0.7833479,10.3768206]}",
},
},
},
vv: RecordValueSet{
&RecordValue{
RecordID: 0,
Name: "location",
Value: "{\"coordinates\":[0.7833479,10.3768206]}",
},
&RecordValue{
RecordID: 0,
Name: "location",
Value: "{\"coordinates\":[-6.7833479,20.3768206]}",
},
},
wantOut: &RecordValueErrorSet{
Set: []RecordValueError{
{
Kind: deDupError.String(),
Message: locationRule.IssueMultivalueMessage([]string{"{\"coordinates\":[-6.7833479,20.3768206]}", "{\"coordinates\":[0.7833479,10.3768206]}"}),
Meta: map[string]interface{}{
"field": "location",
"dupValueField": "location",
"rule": locationRule.String(),
},
},
},
},
},
}
)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotOut := tt.rule.checkDuplication(ctx, ls, tt.rec, tt.vv)
req.Equal(tt.wantOut, gotOut, "checkDuplication() = %v, want %v", gotOut, tt.wantOut)
})
}
}