Skip to content
Merged
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
139 changes: 137 additions & 2 deletions goquutil/goqutil_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,50 @@ type goquSuite struct {
bs SQLBuilderSettings
}

func TestFormatTable(t *testing.T) {
t.Run("empty input", func(t *testing.T) {
require.Equal(t, "", formatTable(nil))
require.Equal(t, "", formatTable([][]string{}))
})

t.Run("header only", func(t *testing.T) {
got := formatTable([][]string{{"id", "name"}})
require.Equal(t, ""+
"+----+------+\n"+
"| id | name |\n"+
"+----+------+\n"+
"+----+------+\n", got)
})

t.Run("header and rows", func(t *testing.T) {
got := formatTable([][]string{
{"id", "name"},
{"1", "Albert"},
{"42", "Bob"},
})
require.Equal(t, ""+
"+----+--------+\n"+
"| id | name |\n"+
"+----+--------+\n"+
"| 1 | Albert |\n"+
"| 42 | Bob |\n"+
"+----+--------+\n", got)
})

t.Run("NULL values represented as NULL string", func(t *testing.T) {
got := formatTable([][]string{
{"col"},
{"NULL"},
})
require.Equal(t, ""+
"+------+\n"+
"| col |\n"+
"+------+\n"+
"| NULL |\n"+
"+------+\n", got)
})
}

func TestGoqutils(t *testing.T) {
suite.Run(t, &goquSuite{})
}
Expand All @@ -92,6 +136,95 @@ func (s *goquSuite) SetupTest() {
s.bs = SQLBuilderSettings{goqu.Dialect("sqlite3")}
}

func (s *goquSuite) TestBuildLiteralSQL() {
query, err := BuildLiteralSQL(s.bs.Dialect.From("users").Select(goqu.I("name")).
Where(goqu.I("id").Eq(1)))
s.Require().NoError(err)
s.Require().Equal(`SELECT "name" FROM "users" WHERE ("id" = 1)`, query)

query, err = BuildLiteralSQL(s.bs.Dialect.From("users").Select(goqu.I("id"), goqu.I("name")).
Where(goqu.I("name").In("Albert", "Bob")))
s.Require().NoError(err)
s.Require().Equal(`SELECT "id", "name" FROM "users" WHERE ("name" IN ('Albert', 'Bob'))`, query)
}

// TestBuildLiteralSQL_HandlesPreparedDatasets is a regression test for the bug
// where BuildLiteralSQL silently returned a prepared SQL string (with '?'
// placeholders) for goqu's built-in dataset types. Each built-in type's
// Prepared method returns its own concrete type, which does not satisfy the
// preparableExpression interface (Go interfaces are not covariant in return
// types), so the interface-based assertion always failed.
func (s *goquSuite) TestBuildLiteralSQL_HandlesPreparedDatasets() {
d := s.bs.Dialect

insertExpr := d.Insert(goqu.T("users")).Prepared(true).Rows(goqu.Record{"id": 1, "name": "x"})
got, err := BuildLiteralSQL(insertExpr)
s.Require().NoError(err)
s.Require().NotContains(got, "?")
s.Require().Contains(got, "1")
s.Require().Contains(got, "'x'")

updateExpr := d.Update("users").Prepared(true).Set(goqu.Record{"name": "y"}).
Where(goqu.I("id").Eq(7))
got, err = BuildLiteralSQL(updateExpr)
s.Require().NoError(err)
s.Require().NotContains(got, "?")
s.Require().Contains(got, "'y'")
s.Require().Contains(got, "7")

deleteExpr := d.Delete("users").Prepared(true).Where(goqu.I("id").Eq(42))
got, err = BuildLiteralSQL(deleteExpr)
s.Require().NoError(err)
s.Require().NotContains(got, "?")
s.Require().Contains(got, "42")

selectExpr := d.From("users").Prepared(true).Where(goqu.I("name").Eq("z"))
got, err = BuildLiteralSQL(selectExpr)
s.Require().NoError(err)
s.Require().NotContains(got, "?")
s.Require().Contains(got, "'z'")
}

func (s *goquSuite) TestQueryExplain() {
_ = s.db.DoInTx(func(q Querier) error {
rows, err := QueryExplain(q, s.bs.Dialect.From("users").Select(goqu.I("name")).Where(goqu.I("id").Eq(1)))
s.Require().NoError(err)
s.Require().NotEmpty(rows)
return nil
})
}

func (s *goquSuite) TestQueryExplainString() {
_ = s.db.DoInTx(func(q Querier) error {
result, err := QueryExplainString(q, s.bs.Dialect.From("users").Select(goqu.I("name")).Where(goqu.I("id").Eq(1)))
s.Require().NoError(err)
s.Require().NotEmpty(result)
s.Require().Contains(result, "|")
s.Require().Contains(result, "+")
return nil
})
}

func (s *goquSuite) TestQueryExplainQueryPlan() {
_ = s.db.DoInTx(func(q Querier) error {
rows, err := QueryExplainQueryPlan(q, s.bs.Dialect.From("users").Select(goqu.I("name")).Where(goqu.I("id").Eq(1)))
s.Require().NoError(err)
s.Require().NotEmpty(rows)
return nil
})
}

func (s *goquSuite) TestQueryExplainQueryPlanString() {
_ = s.db.DoInTx(func(q Querier) error {
result, err := QueryExplainQueryPlanString(q, s.bs.Dialect.From("users").Select(goqu.I("name")).Where(goqu.I("id").Eq(1)))
s.Require().NoError(err)
s.Require().NotEmpty(result)
s.Require().Contains(result, "|")
s.Require().Contains(result, "+")
return nil
})
}

func (s *goquSuite) TestBuildSQLAndExec() {
_ = s.db.DoInTx(func(q Querier) error {
var rowCount int
Expand All @@ -117,14 +250,16 @@ func (s *goquSuite) TestBuildSQLAndQueryScalar() {
var name string
s.Require().NoError(
BuildSQLAndQueryScalar(
q, s.bs.Dialect.From("users").Select(goqu.I("name")).Where(goqu.I("id").Eq(1)), &name,
q, s.bs.Dialect.From("users").Select(goqu.I("name")).Where(goqu.I("id").Eq(1)),
&name,
),
)
s.Require().Equal("Albert", name)
s.Require().Equal(
ErrNotFound,
BuildSQLAndQueryScalar(
q, s.bs.Dialect.From("users").Select(goqu.I("name")).Where(goqu.I("id").Eq(123)), &name,
q, s.bs.Dialect.From("users").Select(goqu.I("name")).Where(goqu.I("id").Eq(123)),
&name,
),
)
return nil
Expand Down
186 changes: 186 additions & 0 deletions goquutil/query.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"fmt"
"reflect"
"sort"
"strings"
"time"

"github.com/doug-martin/goqu/v9"
Expand Down Expand Up @@ -96,6 +97,191 @@ func queryDatabase(
return sqlResult, sqlRows, sqlRow, queryErr
}

// BuildLiteralSQL builds a SQL string with all arguments interpolated inline (non-prepared mode).
// Use with caution: since all argument values are embedded directly into the query string,
// the result may expose sensitive user data. Avoid using it for logging or any output
// that could be stored or transmitted insecurely.
func BuildLiteralSQL(sqlExpression exp.SQLExpression) (string, error) {
toSQL := toNonPreparedExpression(sqlExpression)
query, _, err := toSQL.ToSQL()
if err != nil {
return "", fmt.Errorf("query building: %w", err)
}
return query, nil
}

// toNonPreparedExpression switches a known goqu dataset to non-prepared mode so that
// ToSQL() inlines argument values instead of emitting '?' placeholders. Falls back to
// the preparableExpression interface for custom types, and finally returns the input
// as-is when neither applies.
func toNonPreparedExpression(sqlExpression exp.SQLExpression) exp.SQLExpression {
switch e := sqlExpression.(type) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we really need this type-assertion switch? why sqlExpression.(preparableExpression) is not enough?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unfortunately, Datasets have different return types

func (d *SelectDataset) Prepared(prepared bool) *SelectDataset
func (d *InsertDataset) Prepared(prepared bool) *InsertDataset
...

so the sqlExpression.(preparableExpression) will not work for them.

case *goqu.SelectDataset:
return e.Prepared(false)
case *goqu.InsertDataset:
return e.Prepared(false)
case *goqu.UpdateDataset:
return e.Prepared(false)
case *goqu.DeleteDataset:
return e.Prepared(false)
case *goqu.TruncateDataset:
return e.Prepared(false)
}
type preparableExpression interface {
Prepared(prepared bool) exp.SQLExpression
}
if p, ok := sqlExpression.(preparableExpression); ok {
return p.Prepared(false)
}
return sqlExpression
}

// QueryExplain executes EXPLAIN for the given expression and returns the result rows.
// Each inner slice contains the column values of a single output row as strings.
func QueryExplain(q Querier, sqlExpression exp.SQLExpression) ([][]string, error) {
return runExplain(q, sqlExpression, "EXPLAIN")
}

// QueryExplainString executes EXPLAIN for the given expression and returns the result as a formatted text table.
func QueryExplainString(q Querier, sqlExpression exp.SQLExpression) (string, error) {
return runExplainString(q, sqlExpression, "EXPLAIN")
}

// QueryExplainQueryPlan executes EXPLAIN QUERY PLAN for the given expression and returns the result rows.
// Each inner slice contains the column values of a single output row as strings.
// Note: EXPLAIN QUERY PLAN is supported by SQLite. For MySQL/PostgreSQL use QueryExplain.
func QueryExplainQueryPlan(q Querier, sqlExpression exp.SQLExpression) ([][]string, error) {
return runExplain(q, sqlExpression, "EXPLAIN QUERY PLAN")
}

// QueryExplainQueryPlanString executes EXPLAIN QUERY PLAN for the given expression and returns the result as a
// formatted text table.
// Note: EXPLAIN QUERY PLAN is supported by SQLite. For MySQL/PostgreSQL use QueryExplainString.
func QueryExplainQueryPlanString(q Querier, sqlExpression exp.SQLExpression) (string, error) {
return runExplainString(q, sqlExpression, "EXPLAIN QUERY PLAN")
}

func runExplain(q Querier, sqlExpression exp.SQLExpression, prefix string) ([][]string, error) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

runExplain and runExplainString are almost the same. Did you consider an option to return calls from the runExplain and call it within runExplainString?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed

data, err := queryExplainRows(q, sqlExpression, prefix)
if err != nil {
return nil, err
}
if len(data) == 0 {
return nil, nil
}
return data[1:], nil
}

func runExplainString(q Querier, sqlExpression exp.SQLExpression, prefix string) (string, error) {
data, err := queryExplainRows(q, sqlExpression, prefix)
if err != nil {
return "", err
}
return formatTable(data), nil
}

func queryExplainRows(q Querier, sqlExpression exp.SQLExpression, prefix string) ([][]string, error) {
literalSQL, err := BuildLiteralSQL(sqlExpression)
if err != nil {
return nil, err
}
rows, err := q.Query(prefix + " " + literalSQL)
if err != nil {
return nil, fmt.Errorf("explain query: %w", err)
}
defer func() { _ = rows.Close() }()

cols, err := rows.Columns()
if err != nil {
return nil, fmt.Errorf("explain columns: %w", err)
}

data := [][]string{cols}
for rows.Next() {
row, scanErr := scanRowAsStrings(rows, len(cols))
if scanErr != nil {
return nil, fmt.Errorf("explain scan: %w", scanErr)
}
data = append(data, row)
}
if err = rows.Err(); err != nil {
return nil, fmt.Errorf("explain rows: %w", err)
}
return data, nil
}

func scanRowAsStrings(rows *sql.Rows, colCount int) ([]string, error) {
vals := make([]sql.NullString, colCount)
ptrs := make([]interface{}, colCount)
for i := range vals {
ptrs[i] = &vals[i]
}
if err := rows.Scan(ptrs...); err != nil {
return nil, err
}
row := make([]string, colCount)
for i, v := range vals {
if v.Valid {
row[i] = v.String
} else {
row[i] = "NULL"
}
}
return row, nil
}

func formatTable(data [][]string) string {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there are no unit tests for checking the table presentation, right? suggest adding them

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed

if len(data) == 0 {
return ""
}
widths := make([]int, len(data[0]))
for _, row := range data {
for i, cell := range row {
if len(cell) > widths[i] {
widths[i] = len(cell)
}
}
}

// Pre-calculate separator capacity: "+" + (" " + w + " " + "+") per column.
sepCap := 1
for _, w := range widths {
sepCap += w + 3
}
var sepB strings.Builder
sepB.Grow(sepCap)
sepB.WriteByte('+')
for _, w := range widths {
sepB.WriteString(strings.Repeat("-", w+2))
sepB.WriteByte('+')
}
sep := sepB.String()

var sb strings.Builder
writeRow := func(row []string) {
sb.WriteByte('|')
for i, cell := range row {
sb.WriteByte(' ')
sb.WriteString(cell)
sb.WriteString(strings.Repeat(" ", widths[i]-len(cell)))
sb.WriteString(" |")
}
sb.WriteByte('\n')
}

for i, row := range data {
if i == 0 {
sb.WriteString(sep + "\n")
}
writeRow(row)
if i == 0 {
sb.WriteString(sep + "\n")
}
}
sb.WriteString(sep + "\n")
return sb.String()
}

// BuildSQLAndExec is a function for running DML not returning any data like UPDATE, DELETE, INSERT
func BuildSQLAndExec(q Querier, sqlExpression exp.SQLExpression) (sql.Result, error) {
result, _, _, err := queryDatabase(q, sqlExpression, Querier.Exec, nil, nil)
Expand Down
Loading