diff --git a/README.md b/README.md index 0385c740..df77766e 100644 --- a/README.md +++ b/README.md @@ -244,6 +244,37 @@ type Output struct { } ``` +### Case-insensitive field matching + +With `goverter:matchIgnoreCase` tag you can instruct goverter to perform case-insensitive mapping +between source and target fields. If this tag is present on a conversion method, goverter matches +the fields using strings.EqualFold method. + +Use this tag only when it is extremely unlikely for the source or the target to have two fields +that differ only in casing. E.g.: converting go-jet generated model to protoc generated struct. +If `matchIgnoreCase` is present and goverter detects an ambiquous match, it either prefers an exact +match (if found) or reports an error. Use goverter:map to fix an ambiquous match error. + +`goverter:matchIgnoreCase` takes no parameters. + +```go +// goverter:converter +type Converter interface { + // goverter:matchIgnoreCase + // goverter:map FullId FullID + Convert(source Input) Output +} + +type Input struct { + Uuid string + FullId int + fullId int +} +type Output struct { + UUID string // auto-matched with Uuid due to goverter:matchIgnoreCase + FullID string // mapped to FullId, to resolve ambiguity +} +``` #### Struct identity mapping diff --git a/builder/builder.go b/builder/builder.go index 6532ad57..5e0cf82a 100644 --- a/builder/builder.go +++ b/builder/builder.go @@ -28,4 +28,5 @@ type MethodContext struct { IdentityMapping map[string]struct{} Signature xtype.Signature PointerChange bool + MatchIgnoreCase bool } diff --git a/builder/struct.go b/builder/struct.go index 92e8ddf4..1dd27282 100644 --- a/builder/struct.go +++ b/builder/struct.go @@ -78,18 +78,20 @@ func mapField(gen Generator, ctx *MethodContext, targetField *types.Var, sourceI mappedName, hasOverride := ctx.Mapping[targetField.Name()] if ctx.Signature.Target != target.T.String() || !hasOverride { - if fieldSource, ok := source.StructField(targetField.Name()); ok { - nextID := sourceID.Code.Clone().Dot(targetField.Name()) + sourceMatch, err := source.StructField(targetField.Name(), ctx.MatchIgnoreCase, ctx.IgnoredFields) + if err == nil { + nextID := sourceID.Code.Clone().Dot(sourceMatch.Name) lift = append(lift, &Path{ Prefix: ".", - SourceID: targetField.Name(), - SourceType: fieldSource.T.String(), + SourceID: sourceMatch.Name, + SourceType: sourceMatch.Type.T.String(), TargetID: targetField.Name(), TargetType: targetField.Type().String(), }) - return nextID, fieldSource, []jen.Code{}, lift, nil + return nextID, sourceMatch.Type, []jen.Code{}, lift, nil } - cause := fmt.Sprintf("Cannot set value for field %s because it does not exist on the source entry.", targetField.Name()) + // field lookup either did not find anything or failed due to ambiquous match with case ignored + cause := fmt.Sprintf("Cannot match the target field with the source entry: %s.", err.Error()) return nil, nil, nil, nil, NewError(cause).Lift(&Path{ Prefix: ".", SourceID: "???", @@ -122,12 +124,14 @@ func mapField(gen Generator, ctx *MethodContext, targetField *types.Var, sourceI SourceType: "???", }).Lift(lift...) } - var ok bool - if nextSource, ok = nextSource.StructField(path[i]); ok { - nextID = nextID.Clone().Dot(path[i]) + // since we are searching for a mapped name, search for exact match, explicit field map does not ignore case + sourceMatch, err := nextSource.StructField(path[i], false, ctx.IgnoredFields) + if err == nil { + nextSource = sourceMatch.Type + nextID = nextID.Clone().Dot(sourceMatch.Name) liftPath := &Path{ Prefix: ".", - SourceID: path[i], + SourceID: sourceMatch.Name, SourceType: nextSource.T.String(), } @@ -142,7 +146,7 @@ func mapField(gen Generator, ctx *MethodContext, targetField *types.Var, sourceI continue } - cause := fmt.Sprintf("Mapped source field '%s' doesn't exist.", path[i]) + cause := fmt.Sprintf("Cannot find the mapped field on the source entry: %s.", err.Error()) return nil, nil, []jen.Code{}, nil, NewError(cause).Lift(&Path{ Prefix: ".", SourceID: path[i], diff --git a/comments/parse_docs.go b/comments/parse_docs.go index 38d4ed30..388d6aa0 100644 --- a/comments/parse_docs.go +++ b/comments/parse_docs.go @@ -44,8 +44,9 @@ type ConverterConfig struct { // Method contains settings that can be set via comments. type Method struct { - IgnoredFields map[string]struct{} - NameMapping map[string]string + IgnoredFields map[string]struct{} + NameMapping map[string]string + MatchIgnoreCase bool // target to source IdentityMapping map[string]struct{} } @@ -226,6 +227,12 @@ func parseMethodComment(comment string) (Method, error) { m.IgnoredFields[f] = struct{}{} } continue + case "matchIgnoreCase": + if len(fields) != 1 { + return m, fmt.Errorf("invalid %s:matchIgnoreCase, parameters not supported", prefix) + } + m.MatchIgnoreCase = true + continue } return m, fmt.Errorf("unknown %s comment: %s", prefix, line) } diff --git a/generator/generator.go b/generator/generator.go index 549e0ee8..cbaf70d1 100644 --- a/generator/generator.go +++ b/generator/generator.go @@ -24,6 +24,7 @@ type methodDefinition struct { Mapping map[string]string IgnoredFields map[string]struct{} IdentityMapping map[string]struct{} + MatchIgnoreCase bool Jen jen.Code @@ -77,6 +78,7 @@ func (g *generator) registerMethod(methodType *types.Func, methodComments commen Source: xtype.TypeOf(source), Target: xtype.TypeOf(target), Mapping: methodComments.NameMapping, + MatchIgnoreCase: methodComments.MatchIgnoreCase, IgnoredFields: methodComments.IgnoredFields, IdentityMapping: methodComments.IdentityMapping, ReturnError: returnError, @@ -152,6 +154,7 @@ func (g *generator) buildMethod(method *methodDefinition) *builder.Error { Mapping: method.Mapping, IgnoredFields: method.IgnoredFields, IdentityMapping: method.IdentityMapping, + MatchIgnoreCase: method.MatchIgnoreCase, Signature: xtype.Signature{Source: method.Source.T.String(), Target: method.Target.T.String()}, } stmt, newID, err := g.buildNoLookup(ctx, xtype.VariableID(sourceID.Clone()), source, target) @@ -232,6 +235,7 @@ func (g *generator) Build(ctx *builder.MethodContext, sourceID *xtype.JenID, sou if ctx.PointerChange { ctx.PointerChange = false method.Mapping = ctx.Mapping + method.MatchIgnoreCase = ctx.MatchIgnoreCase method.IgnoredFields = ctx.IgnoredFields } diff --git a/runner_test.go b/runner_test.go index b2459ea2..abad78ae 100644 --- a/runner_test.go +++ b/runner_test.go @@ -75,7 +75,7 @@ func TestScenario(t *testing.T) { if scenario.Error != "" { require.Error(t, err) - require.Equal(t, replaceAbsolutePath(fmt.Sprint(err)), scenario.Error) + require.Equal(t, scenario.Error, replaceAbsolutePath(fmt.Sprint(err))) return } diff --git a/scenario/3_struct_match_ignore_case.yml b/scenario/3_struct_match_ignore_case.yml new file mode 100644 index 00000000..81d51fed --- /dev/null +++ b/scenario/3_struct_match_ignore_case.yml @@ -0,0 +1,36 @@ +input: + input.go: | + package structs + + // goverter:converter + type Converter interface { + // goverter:matchIgnoreCase + Convert(source Input) Output + } + + type Input struct { + Name string + UUID string + OtherID int + } + type Output struct { + Name string + Uuid string + OtherId int + } +success: | + // Code generated by github.com/jmattheis/goverter, DO NOT EDIT. + + package generated + + import execution "github.com/jmattheis/goverter/execution" + + type ConverterImpl struct{} + + func (c *ConverterImpl) Convert(source execution.Input) execution.Output { + var structsOutput execution.Output + structsOutput.Name = source.Name + structsOutput.Uuid = source.UUID + structsOutput.OtherId = source.OtherID + return structsOutput + } diff --git a/scenario/3_struct_match_ignore_case_ambiguous_ok.yml b/scenario/3_struct_match_ignore_case_ambiguous_ok.yml new file mode 100644 index 00000000..1316144e --- /dev/null +++ b/scenario/3_struct_match_ignore_case_ambiguous_ok.yml @@ -0,0 +1,46 @@ +input: + input.go: | + package structs + + // goverter:converter + type Converter interface { + // goverter:matchIgnoreCase + // goverter:map AmbID AmbId + // goverter:ignore Ambid2 + Convert(source Input) Output + } + + type Input struct { + OtherID string + OtherId int // should prefer exact match + Uuid string + AmbID float64 // explicit choice + Ambid float64 + AmbID2 string + Ambid2 string + } + type Output struct { + OtherId int + Uuid string // should prefer exact match + UUID string + AmbId float64 + AmbId2 string + } +success: | + // Code generated by github.com/jmattheis/goverter, DO NOT EDIT. + + package generated + + import execution "github.com/jmattheis/goverter/execution" + + type ConverterImpl struct{} + + func (c *ConverterImpl) Convert(source execution.Input) execution.Output { + var structsOutput execution.Output + structsOutput.OtherId = source.OtherId + structsOutput.Uuid = source.Uuid + structsOutput.UUID = source.Uuid + structsOutput.AmbId = source.AmbID + structsOutput.AmbId2 = source.AmbID2 + return structsOutput + } diff --git a/scenario/7_error_invalid_match_ignore_case_with_param.yml b/scenario/7_error_invalid_match_ignore_case_with_param.yml new file mode 100644 index 00000000..0b4000bc --- /dev/null +++ b/scenario/7_error_invalid_match_ignore_case_with_param.yml @@ -0,0 +1,10 @@ +input: + input.go: | + package structs + + // goverter:converter + type Converter interface { + // goverter:matchIgnoreCase a + ConvertString(source string) string + } +error: '/ABSOLUTE/execution/input.go:4:1: type Converter: parsing method ConvertString: invalid goverter:matchIgnoreCase, parameters not supported' diff --git a/scenario/7_error_match_ignore_case_err_path copy.yml b/scenario/7_error_match_ignore_case_err_path copy.yml new file mode 100644 index 00000000..45be0b3b --- /dev/null +++ b/scenario/7_error_match_ignore_case_err_path copy.yml @@ -0,0 +1,42 @@ +input: + input.go: | + package structs + + // goverter:converter + type Converter interface { + // goverter:matchIgnoreCase + Convert(source Input) Output + } + + type Input struct { + NESTED InputNested + } + type InputNested struct { + Name string + } + type Output struct { + Nested OutputNested + } + type OutputNested struct { + Name int + } +error: |- + Error while creating converter method: + func (github.com/jmattheis/goverter/execution.Converter).Convert(source github.com/jmattheis/goverter/execution.Input) github.com/jmattheis/goverter/execution.Output + + | github.com/jmattheis/goverter/execution.Input + | + | | github.com/jmattheis/goverter/execution.InputNested + | | + | | | string + | | | + source.NESTED.Name + target.Nested.Name + | | | + | | | int + | | + | | github.com/jmattheis/goverter/execution.OutputNested + | + | github.com/jmattheis/goverter/execution.Output + + TypeMismatch: Cannot convert string to int diff --git a/scenario/7_error_match_ignore_case_unresolved_ambiguity.yml b/scenario/7_error_match_ignore_case_unresolved_ambiguity.yml new file mode 100644 index 00000000..cce022df --- /dev/null +++ b/scenario/7_error_match_ignore_case_unresolved_ambiguity.yml @@ -0,0 +1,39 @@ +input: + input.go: | + package structs + + // goverter:converter + type Converter interface { + // goverter:matchIgnoreCase + Convert(source Input) Output + } + + type Input struct { + MyID string + MyId string + } + type Output struct { + Myid string + } +error: |- + Error while creating converter method: + func (github.com/jmattheis/goverter/execution.Converter).Convert(source github.com/jmattheis/goverter/execution.Input) github.com/jmattheis/goverter/execution.Output + + | github.com/jmattheis/goverter/execution.Input + | + | + | + source.??? + target.Myid + | | + | | string + | + | github.com/jmattheis/goverter/execution.Output + + Cannot match the target field with the source entry: multiple matches found for "Myid". Possible matches: MyID, MyId. + + Explicitly define the mapping via goverter:map. Example: + + goverter:map MyID Myid + + See https://github.com/jmattheis/goverter#struct-field-mapping. diff --git a/scenario/7_error_struct_missing_field.yml b/scenario/7_error_struct_missing_field.yml index 0d0a869a..890e399b 100644 --- a/scenario/7_error_struct_missing_field.yml +++ b/scenario/7_error_struct_missing_field.yml @@ -29,4 +29,4 @@ error: |- | | github.com/jmattheis/goverter/execution.Output - Cannot set value for field Age because it does not exist on the source entry. + Cannot match the target field with the source entry: "Age" does not exist. diff --git a/scenario/7_error_struct_missing_mapped_field.yml b/scenario/7_error_struct_missing_mapped_field.yml index df4e72d1..feb1c62d 100644 --- a/scenario/7_error_struct_missing_mapped_field.yml +++ b/scenario/7_error_struct_missing_mapped_field.yml @@ -30,4 +30,4 @@ error: |- | | github.com/jmattheis/goverter/execution.Output - Mapped source field 'Name3' doesn't exist. + Cannot find the mapped field on the source entry: "Name3" does not exist. diff --git a/scenario/7_error_struct_missing_mapped_nested_field.yml b/scenario/7_error_struct_missing_mapped_nested_field.yml index 27133615..3cecf096 100644 --- a/scenario/7_error_struct_missing_mapped_nested_field.yml +++ b/scenario/7_error_struct_missing_mapped_nested_field.yml @@ -38,4 +38,4 @@ error: |- | | github.com/jmattheis/goverter/execution.Output - Mapped source field 'Name3' doesn't exist. + Cannot find the mapped field on the source entry: "Name3" does not exist. diff --git a/xtype/type.go b/xtype/type.go index aa0e4c27..7ac73e22 100644 --- a/xtype/type.go +++ b/xtype/type.go @@ -40,19 +40,48 @@ type Type struct { BasicType *types.Basic } -// StructField returns the type of a struct field. -func (t Type) StructField(name string) (*Type, bool) { +// StructField holds the type of a struct field and its name. +type StructField struct { + Name string + Type *Type +} + +// StructField returns the type of a struct field and its name upon successful match or +// an error if it is not found. This method will also return a detailed error if matchIgnoreCase +// is enabled and there are multiple non-exact matches. +func (t Type) StructField(name string, ignoreCase bool, ignore map[string]struct{}) (*StructField, error) { if !t.Struct { panic("trying to get field of non struct") } + var ambMatches []*StructField for y := 0; y < t.StructType.NumFields(); y++ { m := t.StructType.Field(y) + if _, ignored := ignore[m.Name()]; ignored { + continue + } if m.Name() == name { - return TypeOf(m.Type()), true + // exact match takes precedence over case-insensitive match + return &StructField{Name: m.Name(), Type: TypeOf(m.Type())}, nil + } + if ignoreCase && strings.EqualFold(m.Name(), name) { + ambMatches = append(ambMatches, &StructField{Name: m.Name(), Type: TypeOf(m.Type())}) + // keep going to ensure struct does not have another case-insensitive match } } - return nil, false + + switch len(ambMatches) { + case 0: + return nil, fmt.Errorf("%q does not exist", name) + case 1: + return ambMatches[0], nil + default: + ambNames := make([]string, 0, len(ambMatches)) + for _, m := range ambMatches { + ambNames = append(ambNames, m.Name) + } + return nil, ambiguousMatchError(name, ambNames) + } } // JenID a jennifer code wrapper with extra infos. @@ -222,3 +251,13 @@ func toCodeBasic(t types.BasicKind, st *jen.Statement) *jen.Statement { panic(fmt.Sprintf("unsupported type %d", t)) } } + +func ambiguousMatchError(name string, ambNames []string) error { + return fmt.Errorf(`multiple matches found for %q. Possible matches: %s. + +Explicitly define the mapping via goverter:map. Example: + + goverter:map %s %s + +See https://github.com/jmattheis/goverter#struct-field-mapping`, name, strings.Join(ambNames, ", "), ambNames[0], name) +}