From b0303178089d93ddf06c0da7a7725927bb51214c Mon Sep 17 00:00:00 2001 From: Matvey Gubanikhin Date: Fri, 30 Oct 2026 12:01:01 +0300 Subject: [PATCH] Change mechanism mapping fields in structure and table. RS-345540 Description: Now, the system first checks for an exact match between the names in the structure and the table, and then checks for an inexact match. --- rows.go | 162 +++++++++++++++++++++++++++++++++------------------ rows_test.go | 81 +++++++++++++++++++++++++- 2 files changed, 185 insertions(+), 58 deletions(-) diff --git a/rows.go b/rows.go index 15da262..6eca6f8 100644 --- a/rows.go +++ b/rows.go @@ -880,18 +880,50 @@ func lookupNamedStructFields( return cached.(*namedStructFields), nil } - // We could probably do two-levels of caching, where we compute the key -> fields mapping - // for a type only once, cache it by type, then use that to compute the column -> fields - // mapping for a given set of columns. fieldStack := make([]int, 0, 1) - checkedIndexes := make(map[int]bool) - fields, missingField := computeNamedStructFields( + l := len(fldDescs) + tagToField := mappingTagToField{ + tableFields: make(map[string]*int, l), + tagsList: make([]tag, 0, l), + fieldsList: make([]field, 0, l), + indexNow: l - 1, + notFoundTags: make(map[string]bool, l), + } + fields := iterateNamedStructFields( fldDescs, t, - make([]structRowField, len(fldDescs)), + make([]structRowField, l), &fieldStack, - checkedIndexes, + &tagToField, ) + var missingField string + + for _, tag := range tagToField.tagsList { + var exist bool + for i := len(tagToField.fieldsList) - 1; i >= 0; i-- { + value := tagToField.fieldsList[i] + if *value.index == -1 { + continue + } + key := value.name + if tag.normalize { + key = value.normalizedName + } + if strings.EqualFold(key, tag.name) { + fields[*value.index] = structRowField{ + path: append(make([]int, 0, len(tag.fieldStack)), tag.fieldStack...), + } + *tagToField.fieldsList[i].index = -1 + exist = true + tagToField.notFoundTags[strings.ToLower(tag.name)] = false + break + } + } + if !exist && tagToField.notFoundTags[strings.ToLower(tag.name)] { + missingField = tag.name + } + } + for i, f := range fields { if f.path == nil { return nil, fmt.Errorf( @@ -930,17 +962,36 @@ func joinFieldNames(fldDescs []fbconn.FieldDescription) string { return b.String() } -func computeNamedStructFields( +type tag struct { + normalize bool + name string + fieldStack []int +} + +type field struct { + name string + normalizedName string + index *int +} + +type mappingTagToField struct { + tableFields map[string]*int + notFoundTags map[string]bool + fieldsList []field + tagsList []tag + indexNow int +} + +func iterateNamedStructFields( fldDescs []fbconn.FieldDescription, t reflect.Type, fields []structRowField, fieldStack *[]int, - checkedIndexes map[int]bool, -) ([]structRowField, string) { - var missingField string + tagToField *mappingTagToField, +) []structRowField { tail := len(*fieldStack) *fieldStack = append(*fieldStack, 0) - for i := 0; i < t.NumField(); i++ { + for i := t.NumField() - 1; i >= 0; i-- { sf := t.Field(i) (*fieldStack)[tail] = i if sf.PkgPath != "" && !sf.Anonymous { @@ -949,17 +1000,13 @@ func computeNamedStructFields( } // Handle anonymous struct embedding, but do not try to handle embedded pointers. if sf.Anonymous && sf.Type.Kind() == reflect.Struct { - var missingSubField string - fields, missingSubField = computeNamedStructFields( + fields = iterateNamedStructFields( fldDescs, sf.Type, fields, fieldStack, - checkedIndexes, + tagToField, ) - if missingField == "" { - missingField = missingSubField - } } else { dbTag, dbTagPresent := sf.Tag.Lookup(structTagKey) if dbTagPresent { @@ -971,57 +1018,58 @@ func computeNamedStructFields( } colName := dbTag if !dbTagPresent { - colName = sf.Name + colName = strings.ReplaceAll(sf.Name, "_", "") } - fpos, exactMatch := fieldPosByName(fldDescs, colName, !dbTagPresent) - if fpos == -1 { - if missingField == "" { - missingField = colName + // If the map containing field names already has a value with exactly the same name, + // we compare the field name in the structure with the name in the table and remove it from the map. + if j, ok := tagToField.tableFields[colName]; ok && dbTagPresent { + fields[*j] = structRowField{ + path: append(make([]int, 0, len(*fieldStack)), *fieldStack...), } - continue - } - if wasExact, ok := checkedIndexes[fpos]; !ok || !wasExact && exactMatch || wasExact == exactMatch { - fields[fpos] = structRowField{ - path: append([]int(nil), *fieldStack...), + *tagToField.tableFields[colName] = -1 + delete(tagToField.tableFields, colName) + tagToField.notFoundTags[strings.ToLower(colName)] = false + } else { + // If we find an exact match between the field name in the structure and the column, + // we map them immediately; if not, we set aside the structure field to check for an inexact match. + find := false + for ; tagToField.indexNow >= 0; tagToField.indexNow-- { + fieldName := fldDescs[tagToField.indexNow].Name + if dbTagPresent && colName == fieldName { + fields[tagToField.indexNow] = structRowField{ + path: append(make([]int, 0, len(*fieldStack)), *fieldStack...), + } + find = true + tagToField.indexNow-- + tagToField.notFoundTags[strings.ToLower(colName)] = false + break + } else { + tmp := tagToField.indexNow + tagToField.tableFields[fieldName] = &tmp + tagToField.fieldsList = append(tagToField.fieldsList, field{fieldName, strings.ReplaceAll(fieldName, "_", ""), &tmp}) + } + } + if !find { + if _, ok := tagToField.notFoundTags[strings.ToLower(colName)]; !ok { + tagToField.notFoundTags[strings.ToLower(colName)] = true + } + tagToField.tagsList = append(tagToField.tagsList, tag{ + normalize: !dbTagPresent, + name: colName, + fieldStack: append(make([]int, 0, len(*fieldStack)), *fieldStack...), + }) } - checkedIndexes[fpos] = exactMatch + } } } *fieldStack = (*fieldStack)[:tail] - return fields, missingField + return fields } const structTagKey = "db" -func fieldPosByName(fldDescs []fbconn.FieldDescription, field string, normalize bool) (i int, exactMatch bool) { - i = -1 - - if normalize { - field = strings.ReplaceAll(field, "_", "") - } else { - // Explicit db tags can distinguish quoted identifiers that differ only by case. - for i, desc := range fldDescs { - if desc.Name == field { - return i, true - } - } - } - for i, desc := range fldDescs { - if normalize { - if strings.EqualFold(strings.ReplaceAll(desc.Name, "_", ""), field) { - return i, false - } - } else { - if strings.EqualFold(desc.Name, field) { - return i, false - } - } - } - return -} - // structRowField describes a field of a struct. type structRowField struct { path []int diff --git a/rows_test.go b/rows_test.go index 9a46630..ae603ff 100644 --- a/rows_test.go +++ b/rows_test.go @@ -931,7 +931,26 @@ func TestRowToStructByNameDbTagsPreferExactMatch(t *testing.T) { }) } -func TestRowToStructByNameDbTagsPreferExact(t *testing.T) { +func TestRowToStructByNameDbTagsPreferExactMatchLast(t *testing.T) { + type record struct { + Upper int32 `db:"Foo"` + Lower int32 `db:"foo"` + } + + sql := `select cast(1 as int) as "foo", cast(2 as int) as "FOO" from rdb$database` + + defaultConnTestRunner.RunTest(context.Background(), t, func(ctx context.Context, t testing.TB, conn *fbx.Conn) { + + rows, err := conn.Query(ctx, sql) + require.NoError(t, err) + got, err := fbx.CollectOneRow(rows, fbx.RowToStructByName[record]) + require.NoError(t, err) + require.Equal(t, record{Lower: 1, Upper: 2}, got) + + }) +} + +func TestRowToStructByNameDbTagsPreferLast(t *testing.T) { type Inner struct{ Name string } type record struct { Inner @@ -1064,6 +1083,66 @@ func TestRowToStructByNameEmbeddedDbTagKeepsExactMatch(t *testing.T) { }) } +func TestRowToStructByNameDuplicateColumnNames(t *testing.T) { + type record struct { + First int32 `db:"foo"` + Second int32 `db:"foo"` + Third int32 `db:"foo"` + Fourth int32 `db:"foo"` + } + + for _, tt := range []struct { + name string + query string + }{ + {"exact", `select cast(1 as int) as "foo", cast(2 as int) as "foo", cast(3 as int) as "foo", cast(4 as int) as "foo" from rdb$database`}, + {"case insensitive", `select cast(1 as int) as "FOO", cast(2 as int) as "FOO", cast(3 as int) as "FOO", cast(4 as int) as "FOO" from rdb$database`}, + {"mixed case", `select cast(1 as int) as "foo", cast(2 as int) as "FOO", cast(3 as int) as "Foo", cast(4 as int) as "fOo" from rdb$database`}, + } { + t.Run(tt.name, func(t *testing.T) { + defaultConnTestRunner.RunTest(context.Background(), t, func(ctx context.Context, t testing.TB, conn *fbx.Conn) { + rows, err := conn.Query(ctx, tt.query) + require.NoError(t, err) + got, err := fbx.CollectOneRow(rows, fbx.RowToStructByName[record]) + require.NoError(t, err) + + // Every column must have its own destination; ambiguous aliases do not + // impose an order on the four otherwise equivalent fields. + require.ElementsMatch(t, []int32{1, 2, 3, 4}, []int32{got.First, got.Second, got.Third, got.Fourth}) + }) + }) + } +} + +func TestRowToAddrOfStructByNameExactAndFoldedAliases(t *testing.T) { + type record struct { + Lower int32 `db:"foo"` + Title int32 `db:"Foo"` + Upper int32 `db:"FOO"` + Folded int32 `db:"fOo"` + } + + for _, tt := range []struct { + name string + query string + }{ + {"forward", `select cast(1 as int) as "foo", cast(2 as int) as "Foo", cast(3 as int) as "FOO", cast(4 as int) as "fOO" from rdb$database`}, + {"reverse", `select cast(4 as int) as "fOO", cast(3 as int) as "FOO", cast(2 as int) as "Foo", cast(1 as int) as "foo" from rdb$database`}, + } { + t.Run(tt.name, func(t *testing.T) { + defaultConnTestRunner.RunTest(context.Background(), t, func(ctx context.Context, t testing.TB, conn *fbx.Conn) { + rows, err := conn.Query(ctx, tt.query) + require.NoError(t, err) + got, err := fbx.CollectOneRow(rows, fbx.RowToAddrOfStructByName[record]) + require.NoError(t, err) + + // Exact tags reserve their columns before the remaining folded tag. + require.Equal(t, &record{Lower: 1, Title: 2, Upper: 3, Folded: 4}, got) + }) + }) + } +} + func TestRowToStructByNameEmbeddedStruct(t *testing.T) { type Name struct { Last string `db:"LAST_NAME"`