Table alias implemented (#278)

This commit is contained in:
Neil O'Toole 2023-06-25 10:29:24 -06:00 committed by GitHub
parent 072cb4f515
commit 1edc02c378
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 127 additions and 28 deletions

View File

@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
Breaking changes are annotated with ☢️. Breaking changes are annotated with ☢️.
## Upcoming
### Added
- [#277]: A table selector can now have an alias. This in and of itself is not
particularly useful, but it's a building block for [multiple joins](https://github.com/neilotoole/sq/issues/12).
```shell
$ sq `@sakila | .actor:a | .a.first_name`
```
## [v0.39.1] - 2023-06-22 ## [v0.39.1] - 2023-06-22
### Fixed ### Fixed
@ -641,6 +652,7 @@ make working with lots of sources much easier.
[#258]: https://github.com/neilotoole/sq/issues/258 [#258]: https://github.com/neilotoole/sq/issues/258
[#261]: https://github.com/neilotoole/sq/issues/261 [#261]: https://github.com/neilotoole/sq/issues/261
[#263]: https://github.com/neilotoole/sq/issues/263 [#263]: https://github.com/neilotoole/sq/issues/263
[#277]: https://github.com/neilotoole/sq/issues/277
[v0.15.2]: https://github.com/neilotoole/sq/releases/tag/v0.15.2 [v0.15.2]: https://github.com/neilotoole/sq/releases/tag/v0.15.2
[v0.15.3]: https://github.com/neilotoole/sq/compare/v0.15.2...v0.15.3 [v0.15.3]: https://github.com/neilotoole/sq/compare/v0.15.2...v0.15.3

View File

@ -27,6 +27,10 @@ type ExprElementNode struct {
exprNode *ExprNode exprNode *ExprNode
} }
// resultColumn implements ast.ResultColumn.
func (ex *ExprElementNode) resultColumn() {
}
// String returns a log/debug-friendly representation. // String returns a log/debug-friendly representation.
func (ex *ExprElementNode) String() string { func (ex *ExprElementNode) String() string {
str := nodeString(ex) str := nodeString(ex)

View File

@ -19,6 +19,12 @@ type FuncNode struct {
proprietary bool proprietary bool
} }
// resultColumn implements ast.ResultColumn.
//
// REVISIT: should ast.FuncNode implement ast.ResultColumn?
func (fn *FuncNode) resultColumn() {
}
// FuncName returns the function name. // FuncName returns the function name.
func (fn *FuncNode) FuncName() string { func (fn *FuncNode) FuncName() string {
return fn.fnName return fn.fnName

View File

@ -70,6 +70,8 @@ type ResultColumn interface {
// Text returns the raw text of the node, e.g. ".actor" or "1*2". // Text returns the raw text of the node, e.g. ".actor" or "1*2".
Text() string Text() string
resultColumn()
} }
// baseNode is a base implementation of Node. // baseNode is a base implementation of Node.

View File

@ -5,12 +5,21 @@ import (
"github.com/neilotoole/sq/libsq/core/errz" "github.com/neilotoole/sq/libsq/core/errz"
) )
// doFromTable renders a table selector to SQL.
//
// .actor --> FROM "actor"
// .actor:a --> FROM "actor" "a"
func doFromTable(rc *Context, tblSel *ast.TblSelectorNode) (string, error) { func doFromTable(rc *Context, tblSel *ast.TblSelectorNode) (string, error) {
tblName, _ := tblSel.SelValue() tblName := tblSel.TblName()
if tblName == "" { if tblName == "" {
return "", errz.Errorf("selector has empty table name: {%s}", tblSel.Text()) return "", errz.Errorf("selector has empty table name: {%s}", tblSel.Text())
} }
clause := "FROM " + rc.Dialect.Enquote(tblSel.TblName()) clause := "FROM " + rc.Dialect.Enquote(tblName)
alias := tblSel.Alias()
if alias != "" {
clause += " " + rc.Dialect.Enquote(alias)
}
return clause, nil return clause, nil
} }

View File

@ -146,34 +146,39 @@ func newTblSelector(selNode *SelectorNode) (*TblSelectorNode, error) { //nolint:
} }
// TblName returns the table name. This is the raw value without punctuation. // TblName returns the table name. This is the raw value without punctuation.
func (s *TblSelectorNode) TblName() string { func (n *TblSelectorNode) TblName() string {
return s.tblName return n.tblName
}
// Alias returns the node's alias, or empty string.
func (n *TblSelectorNode) Alias() string {
return n.alias
} }
// Handle returns the handle, which may be empty. // Handle returns the handle, which may be empty.
func (s *TblSelectorNode) Handle() string { func (n *TblSelectorNode) Handle() string {
return s.handle return n.handle
} }
// Tabler implements the Tabler marker interface. // Tabler implements the Tabler marker interface.
func (s *TblSelectorNode) tabler() { func (n *TblSelectorNode) tabler() {
// no-op // no-op
} }
// SelValue returns the table name. // SelValue returns the table name.
// TODO: Can we get rid of this method SelValue? // TODO: Can we get rid of this method SelValue?
func (s *TblSelectorNode) SelValue() (string, error) { func (n *TblSelectorNode) SelValue() (string, error) {
return s.TblName(), nil return n.TblName(), nil
} }
// String returns a log/debug-friendly representation. // String returns a log/debug-friendly representation.
func (s *TblSelectorNode) String() string { func (n *TblSelectorNode) String() string {
text := nodeString(s) text := nodeString(n)
selVal, err := s.SelValue() selVal, err := n.SelValue()
if err != nil { if err != nil {
selVal = "error: " + err.Error() selVal = "error: " + err.Error()
} }
text += fmt.Sprintf(" | table: {%s} | datasource: {%s}", selVal, s.Handle()) text += fmt.Sprintf(" | table: {%s} | datasource: {%s}", selVal, n.Handle())
return text return text
} }
@ -190,6 +195,10 @@ type TblColSelectorNode struct {
colName string colName string
} }
// resultColumn implements ast.ResultColumn.
func (n *TblColSelectorNode) resultColumn() {
}
// IsColumn implements ResultColumn. // IsColumn implements ResultColumn.
func (n *TblColSelectorNode) IsColumn() bool { func (n *TblColSelectorNode) IsColumn() bool {
return true return true
@ -253,6 +262,10 @@ type ColSelectorNode struct {
colName string colName string
} }
// resultColumn implements ast.ResultColumn.
func (n *ColSelectorNode) resultColumn() {
}
// newColSelectorNode returns a ColSelectorNode constructed from ctx. // newColSelectorNode returns a ColSelectorNode constructed from ctx.
func newColSelectorNode(selNode *SelectorNode) (*ColSelectorNode, error) { //nolint:unparam func newColSelectorNode(selNode *SelectorNode) (*ColSelectorNode, error) { //nolint:unparam
n := &ColSelectorNode{SelectorNode: selNode} n := &ColSelectorNode{SelectorNode: selNode}

View File

@ -176,10 +176,10 @@ func (ng *engine) prepareNoTabler(ctx context.Context, qm *queryModel) error {
return nil return nil
} }
// buildTableFromClause builds the "FROM table" fragment. // prepareFromTable builds the "FROM table" fragment.
// //
// When this function returns, ng.rc will be set. // When this function returns, ng.rc will be set.
func (ng *engine) buildTableFromClause(ctx context.Context, tblSel *ast.TblSelectorNode) (fromClause string, func (ng *engine) prepareFromTable(ctx context.Context, tblSel *ast.TblSelectorNode) (fromClause string,
fromConn driver.Database, err error, fromConn driver.Database, err error,
) { ) {
handle := tblSel.Handle() handle := tblSel.Handle()
@ -215,10 +215,10 @@ func (ng *engine) buildTableFromClause(ctx context.Context, tblSel *ast.TblSelec
return fromClause, fromConn, nil return fromClause, fromConn, nil
} }
// buildJoinFromClause builds the "JOIN" clause. // prepareFromJoin builds the "JOIN" clause.
// //
// When this function returns, ng.rc will be set. // When this function returns, ng.rc will be set.
func (ng *engine) buildJoinFromClause(ctx context.Context, fnJoin *ast.JoinNode) (fromClause string, func (ng *engine) prepareFromJoin(ctx context.Context, fnJoin *ast.JoinNode) (fromClause string,
fromConn driver.Database, err error, fromConn driver.Database, err error,
) { ) {
if fnJoin.LeftTbl() == nil || fnJoin.LeftTbl().TblName() == "" { if fnJoin.LeftTbl() == nil || fnJoin.LeftTbl().TblName() == "" {
@ -230,16 +230,16 @@ func (ng *engine) buildJoinFromClause(ctx context.Context, fnJoin *ast.JoinNode)
} }
if fnJoin.LeftTbl().Handle() != fnJoin.RightTbl().Handle() { if fnJoin.LeftTbl().Handle() != fnJoin.RightTbl().Handle() {
return ng.crossSourceJoin(ctx, fnJoin) return ng.joinCrossSource(ctx, fnJoin)
} }
return ng.singleSourceJoin(ctx, fnJoin) return ng.joinSingleSource(ctx, fnJoin)
} }
// singleSourceJoin sets up a join against a single source. // joinSingleSource sets up a join against a single source.
// //
// On return, ng.rc will be set. // On return, ng.rc will be set.
func (ng *engine) singleSourceJoin(ctx context.Context, fnJoin *ast.JoinNode) (fromClause string, func (ng *engine) joinSingleSource(ctx context.Context, fnJoin *ast.JoinNode) (fromClause string,
fromDB driver.Database, err error, fromDB driver.Database, err error,
) { ) {
src, err := ng.qc.Collection.Get(fnJoin.LeftTbl().Handle()) src, err := ng.qc.Collection.Get(fnJoin.LeftTbl().Handle())
@ -267,11 +267,11 @@ func (ng *engine) singleSourceJoin(ctx context.Context, fnJoin *ast.JoinNode) (f
return fromClause, fromDB, nil return fromClause, fromDB, nil
} }
// crossSourceJoin returns a FROM clause that forms part of // joinCrossSource returns a FROM clause that forms part of
// the SQL SELECT statement against fromDB. // the SQL SELECT statement against fromDB.
// //
// On return, ng.rc will be set. // On return, ng.rc will be set.
func (ng *engine) crossSourceJoin(ctx context.Context, fnJoin *ast.JoinNode) (fromClause string, fromDB driver.Database, func (ng *engine) joinCrossSource(ctx context.Context, fnJoin *ast.JoinNode) (fromClause string, fromDB driver.Database,
err error, err error,
) { ) {
leftTblName, rightTblName := fnJoin.LeftTbl().TblName(), fnJoin.RightTbl().TblName() leftTblName, rightTblName := fnJoin.LeftTbl().TblName(), fnJoin.RightTbl().TblName()

View File

@ -26,13 +26,12 @@ func (ng *engine) prepare(ctx context.Context, qm *queryModel) error {
if err = ng.prepareNoTabler(ctx, qm); err != nil { if err = ng.prepareNoTabler(ctx, qm); err != nil {
return err return err
} }
case *ast.TblSelectorNode: case *ast.TblSelectorNode:
if frags.From, ng.targetDB, err = ng.buildTableFromClause(ctx, node); err != nil { if frags.From, ng.targetDB, err = ng.prepareFromTable(ctx, node); err != nil {
return err return err
} }
case *ast.JoinNode: case *ast.JoinNode:
if frags.From, ng.targetDB, err = ng.buildJoinFromClause(ctx, node); err != nil { if frags.From, ng.targetDB, err = ng.prepareFromJoin(ctx, node); err != nil {
return err return err
} }
default: default:

View File

@ -3,6 +3,8 @@ package libsq_test
import ( import (
"testing" "testing"
"github.com/neilotoole/sq/testh/tutil"
"github.com/neilotoole/sq/testh/sakila" "github.com/neilotoole/sq/testh/sakila"
"github.com/neilotoole/sq/drivers/mysql" "github.com/neilotoole/sq/drivers/mysql"
@ -10,6 +12,59 @@ import (
_ "github.com/mattn/go-sqlite3" _ "github.com/mattn/go-sqlite3"
) )
//nolint:exhaustive
func TestQuery_table_alias(t *testing.T) {
testCases := []queryTestCase{
{
name: "table-alias",
in: `@sakila | .actor:a | .a.first_name`,
wantSQL: `SELECT "a"."first_name" FROM "actor" "a"`,
override: driverMap{mysql.Type: "SELECT `a`.`first_name` FROM `actor` `a`"},
wantRecCount: sakila.TblActorCount,
sinkFns: []SinkTestFunc{
assertSinkColName(0, "first_name"),
},
},
{
name: "table-whitespace-alias",
in: `@sakila | .actor:"oy vey" | ."oy vey".first_name`,
wantSQL: `SELECT "oy vey"."first_name" FROM "actor" "oy vey"`,
override: driverMap{mysql.Type: "SELECT `oy vey`.`first_name` FROM `actor` `oy vey`"},
wantRecCount: sakila.TblActorCount,
sinkFns: []SinkTestFunc{
assertSinkColName(0, "first_name"),
},
},
{
name: "table-whitespace-alias-with-col-alias",
in: `@sakila | .actor:"oy vey" | ."oy vey".first_name:given_name`,
wantSQL: `SELECT "oy vey"."first_name" AS "given_name" FROM "actor" "oy vey"`,
override: driverMap{mysql.Type: "SELECT `oy vey`.`first_name` AS `given_name` FROM `actor` `oy vey`"},
wantRecCount: sakila.TblActorCount,
sinkFns: []SinkTestFunc{
assertSinkColName(0, "given_name"),
},
},
{
name: "table-whitespace-alias-with-col-whitespace-alias",
in: `@sakila | .actor:"oy vey" | ."oy vey".first_name:"oy vey"`,
wantSQL: `SELECT "oy vey"."first_name" AS "oy vey" FROM "actor" "oy vey"`,
override: driverMap{mysql.Type: "SELECT `oy vey`.`first_name` AS `oy vey` FROM `actor` `oy vey`"},
wantRecCount: sakila.TblActorCount,
sinkFns: []SinkTestFunc{
assertSinkColName(0, "oy vey"),
},
},
}
for i, tc := range testCases {
tc := tc
t.Run(tutil.Name(i, tc.name), func(t *testing.T) {
execQueryTestCase(t, tc)
})
}
}
//nolint:exhaustive,lll //nolint:exhaustive,lll
func TestQuery_join(t *testing.T) { func TestQuery_join(t *testing.T) {
testCases := []queryTestCase{ testCases := []queryTestCase{

View File

@ -81,7 +81,6 @@ func execQueryTestCase(t *testing.T, tc queryTestCase) {
t.Helper() t.Helper()
coll := testh.New(t).NewCollection(sakila.SQLLatest()...) coll := testh.New(t).NewCollection(sakila.SQLLatest()...)
// coll := testh.New(t).NewCollection(sakila.Pg)
for _, src := range coll.Sources() { for _, src := range coll.Sources() {
src := src src := src
@ -153,7 +152,7 @@ func assertSinkColValue(colIndex int, val any) SinkTestFunc {
// assertSinkColValue returns a SinkTestFunc that asserts that // assertSinkColValue returns a SinkTestFunc that asserts that
// the name of column colIndex matches name. // the name of column colIndex matches name.
func assertSinkColName(colIndex int, name string) SinkTestFunc { func assertSinkColName(colIndex int, name string) SinkTestFunc { //nolint:unparam
return func(t testing.TB, sink *testh.RecordSink) { return func(t testing.TB, sink *testh.RecordSink) {
assert.Equal(t, name, sink.RecMeta[colIndex].Name(), "column %d", colIndex) assert.Equal(t, name, sink.RecMeta[colIndex].Name(), "column %d", colIndex)
} }