From 1bacbdf6f35ba33095ab2a3e8b337fae16c636ba Mon Sep 17 00:00:00 2001 From: Terry Zhao Date: Mon, 28 Sep 2026 14:30:37 -0700 Subject: [PATCH 1/2] fix --- shared/matcher.go | 12 ++++++++---- shared/matcher_test.go | 25 +++++++++++++++++++++++++ view/columns.go | 9 ++++++++- view/columns_alias_test.go | 21 +++++++++++++++++++++ 4 files changed, 62 insertions(+), 5 deletions(-) create mode 100644 shared/matcher_test.go create mode 100644 view/columns_alias_test.go diff --git a/shared/matcher.go b/shared/matcher.go index ae23bc566..9b27efc0b 100644 --- a/shared/matcher.go +++ b/shared/matcher.go @@ -70,8 +70,12 @@ func doesTagMatch(tag *io.Tag, columnName string) bool { columnName = strings.ToLower(columnName) columnName = strings.ReplaceAll(columnName, "_", "") - tagName := strings.ToLower(tag.Column) - tagName = strings.ReplaceAll(tagName, "_", "") - - return columnName == tagName + for _, candidate := range strings.Split(tag.Column, "|") { + tagName := strings.ToLower(candidate) + tagName = strings.ReplaceAll(tagName, "_", "") + if columnName == tagName { + return true + } + } + return false } diff --git a/shared/matcher_test.go b/shared/matcher_test.go new file mode 100644 index 000000000..a4250d8e9 --- /dev/null +++ b/shared/matcher_test.go @@ -0,0 +1,25 @@ +package shared + +import ( + "reflect" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/viant/tagly/format/text" +) + +func TestMatchField_SQLXAlternativeNames(t *testing.T) { + type publisherView struct { + PublisherID int `sqlx:"ID|PUBLISHER_ID"` + } + + for _, columnName := range []string{"ID", "PUBLISHER_ID"} { + t.Run(columnName, func(t *testing.T) { + field := MatchField(reflect.TypeOf(publisherView{}), columnName, text.CaseFormatUpperUnderscore) + + require.NotNil(t, field) + assert.Equal(t, "PublisherID", field.Name) + }) + } +} diff --git a/view/columns.go b/view/columns.go index b3ddd4de3..7e448cab9 100644 --- a/view/columns.go +++ b/view/columns.go @@ -208,7 +208,14 @@ func NewColumns(columns sqlparser.Columns, config map[string]*ColumnConfig) Colu if ok { if columnConfig.Alias != "" { item.Name = columnConfig.Alias - item.Tag += fmt.Sprintf(` sqlx:"%v"`, columnConfig.Name) + sqlxName := strings.TrimSpace(columnConfig.Name) + if sqlxName == "" { + sqlxName = name + } + if !strings.EqualFold(sqlxName, columnConfig.Alias) { + sqlxName += "|" + columnConfig.Alias + } + item.Tag += fmt.Sprintf(` sqlx:"%v"`, sqlxName) } } name = item.Identity() diff --git a/view/columns_alias_test.go b/view/columns_alias_test.go new file mode 100644 index 000000000..93172f0ca --- /dev/null +++ b/view/columns_alias_test.go @@ -0,0 +1,21 @@ +package view + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/viant/sqlparser" +) + +func TestNewColumns_AliasPreservesSourceAndProjectedNames(t *testing.T) { + columns := NewColumns(sqlparser.Columns{ + &sqlparser.Column{Name: "NAME", Type: "string"}, + }, map[string]*ColumnConfig{ + "NAME": {Name: "NAME", Alias: "SITE_NAME"}, + }) + + require.Len(t, columns, 1) + assert.Equal(t, "SITE_NAME", columns[0].Name) + assert.Contains(t, columns[0].Tag, `sqlx:"NAME|SITE_NAME"`) +} From 94656c0481553fa0319c986847bd9cd404d2bda1 Mon Sep 17 00:00:00 2001 From: terryzhaobj Date: Tue, 29 Sep 2026 15:21:37 -0700 Subject: [PATCH 2/2] Fix SQL alias mapping without altering generated tags --- service/reader/sql.go | 10 ++- service/reader/sql_groupable_test.go | 23 +++++++ shared/matcher.go | 12 ++-- shared/matcher_test.go | 27 +++++--- view/collector.go | 31 ++++++++++ view/collector_alias_test.go | 93 ++++++++++++++++++++++++++++ view/columns.go | 9 +-- view/columns_alias_test.go | 54 +++++++++++++++- 8 files changed, 231 insertions(+), 28 deletions(-) create mode 100644 view/collector_alias_test.go diff --git a/service/reader/sql.go b/service/reader/sql.go index be0d86f65..0bc0f9d5d 100644 --- a/service/reader/sql.go +++ b/service/reader/sql.go @@ -244,13 +244,19 @@ func (b *Builder) appendColumns(sb *strings.Builder, aView *view.View, selector func (b *Builder) appendSelectorColumns(sb *strings.Builder, aView *view.View, selector *view.Statelet) ([]*view.Column, error) { result := make([]*view.Column, 0, len(selector.Columns)) - for i, column := range selector.Columns { + seen := make(map[string]bool, len(selector.Columns)) + for _, column := range selector.Columns { viewColumn, ok := aView.ColumnByName(column) if !ok { return nil, fmt.Errorf("not found column %v at view %v", column, aView.Name) } + columnKey := strings.ToLower(viewColumn.Name) + if seen[columnKey] { + continue + } + seen[columnKey] = true - if i != 0 { + if len(result) != 0 { sb.WriteString(separatorFragment) } diff --git a/service/reader/sql_groupable_test.go b/service/reader/sql_groupable_test.go index 1dcb85076..163f408c7 100644 --- a/service/reader/sql_groupable_test.go +++ b/service/reader/sql_groupable_test.go @@ -447,6 +447,29 @@ func TestBuilder_appendRelationColumn_UsesProjectedAliasForQualifiedSourceRelati }) } +func TestBuilder_appendSelectorColumns_DeduplicatesHolderAlias(t *testing.T) { + builder := NewBuilder() + aView := view.NewView("campaign", "campaign", + view.WithConnector(view.NewConnector("test", "sqlite3", ":memory:")), + view.WithColumns(view.Columns{ + &view.Column{Name: "ID", DataType: "int"}, + &view.Column{Name: "NAME", DataType: "string"}, + }), + ) + require.NoError(t, aView.Init(context.Background(), view.EmptyResource())) + require.NoError(t, aView.IndexedColumns().RegisterHolder("ID", "Adorder")) + + selector := view.NewStatelet() + selector.Columns = []string{"ID", "NAME", "Adorder"} + selector.Init(aView) + + sb := &strings.Builder{} + columns, err := builder.appendSelectorColumns(sb, aView, selector) + require.NoError(t, err) + require.Equal(t, " ID, NAME", sb.String()) + require.Equal(t, view.Columns{aView.Columns[0], aView.Columns[1]}, view.Columns(columns)) +} + func newGroupableTestView(t *testing.T) *view.View { t.Helper() trueValue := true diff --git a/shared/matcher.go b/shared/matcher.go index 9b27efc0b..ae23bc566 100644 --- a/shared/matcher.go +++ b/shared/matcher.go @@ -70,12 +70,8 @@ func doesTagMatch(tag *io.Tag, columnName string) bool { columnName = strings.ToLower(columnName) columnName = strings.ReplaceAll(columnName, "_", "") - for _, candidate := range strings.Split(tag.Column, "|") { - tagName := strings.ToLower(candidate) - tagName = strings.ReplaceAll(tagName, "_", "") - if columnName == tagName { - return true - } - } - return false + tagName := strings.ToLower(tag.Column) + tagName = strings.ReplaceAll(tagName, "_", "") + + return columnName == tagName } diff --git a/shared/matcher_test.go b/shared/matcher_test.go index a4250d8e9..3115d8a29 100644 --- a/shared/matcher_test.go +++ b/shared/matcher_test.go @@ -9,17 +9,28 @@ import ( "github.com/viant/tagly/format/text" ) -func TestMatchField_SQLXAlternativeNames(t *testing.T) { +func TestMatchField_NestedRelationNativeSQLXNames(t *testing.T) { + type siteView struct { + SiteName *string `sqlx:"NAME"` + } type publisherView struct { - PublisherID int `sqlx:"ID|PUBLISHER_ID"` + PublisherID int `sqlx:"ID"` + PublisherName string `sqlx:"NAME"` } - for _, columnName := range []string{"ID", "PUBLISHER_ID"} { - t.Run(columnName, func(t *testing.T) { - field := MatchField(reflect.TypeOf(publisherView{}), columnName, text.CaseFormatUpperUnderscore) + testCases := []struct { + rType reflect.Type + columnName string + fieldName string + }{ + {reflect.TypeOf(siteView{}), "NAME", "SiteName"}, + {reflect.TypeOf(publisherView{}), "ID", "PublisherID"}, + {reflect.TypeOf(publisherView{}), "NAME", "PublisherName"}, + } + for _, testCase := range testCases { + field := MatchField(testCase.rType, testCase.columnName, text.CaseFormatUpperUnderscore) - require.NotNil(t, field) - assert.Equal(t, "PublisherID", field.Name) - }) + require.NotNil(t, field) + assert.Equal(t, testCase.fieldName, field.Name) } } diff --git a/view/collector.go b/view/collector.go index 8012f5860..17da3b9c1 100644 --- a/view/collector.go +++ b/view/collector.go @@ -190,6 +190,10 @@ func (r *Collector) Lock() *sync.Mutex { // Resolve resolved unmapped column func (r *Collector) Resolve(column io.Column) func(ptr unsafe.Pointer) interface{} { + if resolver := r.resolveViewColumn(column); resolver != nil { + return resolver + } + r.lockIndex() buffer, ok := r.values[column.Name()] if !ok { @@ -230,6 +234,33 @@ func (r *Collector) Resolve(column io.Column) func(ptr unsafe.Pointer) interface } } +// resolveViewColumn maps a projected SQL alias back to the field registered for +// that logical view column. This keeps generated structs bound to native column +// names (for example sqlx:"NAME") while allowing the query to return a flattened +// alias such as SITE_NAME. +func (r *Collector) resolveViewColumn(column io.Column) func(ptr unsafe.Pointer) interface{} { + if r == nil || r.view == nil || column == nil { + return nil + } + viewColumn, ok := r.view.ColumnByName(column.Name()) + if !ok || !strings.EqualFold(viewColumn.Name, column.Name()) { + return nil + } + if viewColumn.DatabaseColumn == "" || strings.EqualFold(viewColumn.Name, viewColumn.DatabaseColumn) { + return nil + } + field := viewColumn.Field() + if field == nil { + return nil + } + tag := io.ParseTag(field.Tag) + if tag == nil || tag.Transient || tag.Column == "" || !strings.EqualFold(tag.Column, viewColumn.DatabaseColumn) { + return nil + } + xField := xunsafe.NewField(*field) + return xField.Addr +} + // parentColumnIndex returns the live parent column index. Caller must hold the parent index lock. func (r *Collector) parentColumnIndex(ns string, columnName string) map[interface{}][]int { columnValues, ok := r.parent.valuePosition[ns] diff --git a/view/collector_alias_test.go b/view/collector_alias_test.go new file mode 100644 index 000000000..54acc508d --- /dev/null +++ b/view/collector_alias_test.go @@ -0,0 +1,93 @@ +package view + +import ( + "reflect" + "testing" + "unsafe" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/viant/sqlx/io" + "github.com/viant/tagly/format/text" +) + +func TestCollectorResolve_MapsProjectedAliasesToNativeTaggedFields(t *testing.T) { + type siteView struct { + SiteName *string `sqlx:"NAME"` + } + type publisherView struct { + PublisherId int `sqlx:"ID"` + PublisherName string `sqlx:"NAME"` + } + + t.Run("SiteView", func(t *testing.T) { + column := aliasTestColumn(t, reflect.TypeOf(siteView{}), "SiteName", "SITE_NAME", "NAME") + aView := aliasTestView(column) + collector := &Collector{view: aView} + row := siteView{} + + target := collector.Resolve(io.NewColumn("SITE_NAME", "VARCHAR", reflect.TypeOf("")))(unsafe.Pointer(&row)) + value := "example.com" + *target.(**string) = &value + + require.NotNil(t, row.SiteName) + assert.Equal(t, value, *row.SiteName) + }) + + t.Run("PublisherView", func(t *testing.T) { + idColumn := aliasTestColumn(t, reflect.TypeOf(publisherView{}), "PublisherId", "PUBLISHER_ID", "ID") + nameColumn := aliasTestColumn(t, reflect.TypeOf(publisherView{}), "PublisherName", "PUBLISHER_NAME", "NAME") + aView := aliasTestView(idColumn, nameColumn) + collector := &Collector{view: aView} + row := publisherView{} + + idTarget := collector.Resolve(io.NewColumn("PUBLISHER_ID", "INT", reflect.TypeOf(0)))(unsafe.Pointer(&row)) + nameTarget := collector.Resolve(io.NewColumn("PUBLISHER_NAME", "VARCHAR", reflect.TypeOf("")))(unsafe.Pointer(&row)) + *idTarget.(*int) = 17 + *nameTarget.(*string) = "publisher" + + assert.Equal(t, 17, row.PublisherId) + assert.Equal(t, "publisher", row.PublisherName) + }) +} + +func TestCollectorResolveViewColumn_IgnoresTransientAndNonAliasFields(t *testing.T) { + type derivedView struct { + Writable bool `sqlx:"-"` + RunningBudget float64 `sqlx:"-"` + Name string `sqlx:"NAME"` + } + rType := reflect.TypeOf(derivedView{}) + testCases := []struct { + fieldName string + columnName string + databaseName string + }{ + {fieldName: "Writable", columnName: "writable", databaseName: "writable"}, + {fieldName: "RunningBudget", columnName: "RUNNING_BUDGET", databaseName: "RUNNING_BUDGET"}, + {fieldName: "Name", columnName: "NAME", databaseName: "NAME"}, + } + + for _, testCase := range testCases { + column := aliasTestColumn(t, rType, testCase.fieldName, testCase.columnName, testCase.databaseName) + collector := &Collector{view: aliasTestView(column)} + sourceColumn := io.NewColumn(testCase.columnName, "VARCHAR", reflect.TypeOf("")) + + assert.Nil(t, collector.resolveViewColumn(sourceColumn)) + } +} + +func aliasTestColumn(t *testing.T, rType reflect.Type, fieldName, alias, nativeName string) *Column { + t.Helper() + field, ok := rType.FieldByName(fieldName) + require.True(t, ok) + column := &Column{Name: alias, DatabaseColumn: nativeName} + column.SetField(field) + return column +} + +func aliasTestView(columns ...*Column) *View { + aView := &View{Name: "alias_test", Columns: columns, CaseFormat: text.CaseFormatUpperUnderscore} + aView._columns = Columns(columns).Index(aView.CaseFormat) + return aView +} diff --git a/view/columns.go b/view/columns.go index 7e448cab9..b3ddd4de3 100644 --- a/view/columns.go +++ b/view/columns.go @@ -208,14 +208,7 @@ func NewColumns(columns sqlparser.Columns, config map[string]*ColumnConfig) Colu if ok { if columnConfig.Alias != "" { item.Name = columnConfig.Alias - sqlxName := strings.TrimSpace(columnConfig.Name) - if sqlxName == "" { - sqlxName = name - } - if !strings.EqualFold(sqlxName, columnConfig.Alias) { - sqlxName += "|" + columnConfig.Alias - } - item.Tag += fmt.Sprintf(` sqlx:"%v"`, sqlxName) + item.Tag += fmt.Sprintf(` sqlx:"%v"`, columnConfig.Name) } } name = item.Identity() diff --git a/view/columns_alias_test.go b/view/columns_alias_test.go index 93172f0ca..7bbe60a4f 100644 --- a/view/columns_alias_test.go +++ b/view/columns_alias_test.go @@ -1,14 +1,16 @@ package view import ( + "reflect" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/viant/sqlparser" + "github.com/viant/tagly/format/text" ) -func TestNewColumns_AliasPreservesSourceAndProjectedNames(t *testing.T) { +func TestNewColumns_AliasKeepsNativeSQLXColumn(t *testing.T) { columns := NewColumns(sqlparser.Columns{ &sqlparser.Column{Name: "NAME", Type: "string"}, }, map[string]*ColumnConfig{ @@ -17,5 +19,53 @@ func TestNewColumns_AliasPreservesSourceAndProjectedNames(t *testing.T) { require.Len(t, columns, 1) assert.Equal(t, "SITE_NAME", columns[0].Name) - assert.Contains(t, columns[0].Tag, `sqlx:"NAME|SITE_NAME"`) + assert.Contains(t, columns[0].Tag, `sqlx:"NAME"`) + assert.Equal(t, "NAME", columns[0].DatabaseColumn) +} + +func TestColumnsSchema_AliasedRelationsKeepNativeSQLXNames(t *testing.T) { + testCases := []struct { + name string + columns sqlparser.Columns + config map[string]*ColumnConfig + expected map[string]string + }{ + { + name: "SiteView", + columns: sqlparser.Columns{ + &sqlparser.Column{Name: "NAME", Type: "string", RawType: reflect.TypeOf(""), IsNullable: true}, + }, + config: map[string]*ColumnConfig{ + "NAME": {Name: "NAME", Alias: "SITE_NAME"}, + }, + expected: map[string]string{"SiteName": "NAME"}, + }, + { + name: "PublisherView", + columns: sqlparser.Columns{ + &sqlparser.Column{Name: "ID", Type: "int", RawType: reflect.TypeOf(0)}, + &sqlparser.Column{Name: "NAME", Type: "string", RawType: reflect.TypeOf("")}, + }, + config: map[string]*ColumnConfig{ + "ID": {Name: "ID", Alias: "PUBLISHER_ID"}, + "NAME": {Name: "NAME", Alias: "PUBLISHER_NAME"}, + }, + expected: map[string]string{"PublisherId": "ID", "PublisherName": "NAME"}, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + columns := NewColumns(testCase.columns, testCase.config) + rType, err := ColumnsSchema(text.CaseFormatUpperUnderscore, columns, nil, &View{}, nil)() + require.NoError(t, err) + + for fieldName, sqlxName := range testCase.expected { + field, ok := rType.Elem().FieldByName(fieldName) + require.True(t, ok, "missing generated field %s", fieldName) + assert.Equal(t, sqlxName, field.Tag.Get("sqlx")) + assert.NotContains(t, field.Tag.Get("sqlx"), "|") + } + }) + } }