Skip to content
Merged

fix #114

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions service/reader/sql.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
23 changes: 23 additions & 0 deletions service/reader/sql_groupable_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
36 changes: 36 additions & 0 deletions shared/matcher_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package shared

import (
"reflect"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/viant/tagly/format/text"
)

func TestMatchField_NestedRelationNativeSQLXNames(t *testing.T) {
type siteView struct {
SiteName *string `sqlx:"NAME"`
}
type publisherView struct {
PublisherID int `sqlx:"ID"`
PublisherName string `sqlx:"NAME"`
}

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, testCase.fieldName, field.Name)
}
}
31 changes: 31 additions & 0 deletions view/collector.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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]
Expand Down
93 changes: 93 additions & 0 deletions view/collector_alias_test.go
Original file line number Diff line number Diff line change
@@ -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
}
71 changes: 71 additions & 0 deletions view/columns_alias_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
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_AliasKeepsNativeSQLXColumn(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"`)
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"), "|")
}
})
}
}
Loading