snapshot of https://github.com/sqlc-dev/sqlc.git (main, 2026-09-16; blobs > 131072B omitted)
Some checks failed
kotlin / test (push) Has been skipped
python / test (push) Has been skipped
typescript / test (push) Has been skipped
go / build darwin/amd64 (push) Has been cancelled
go / build linux/amd64 (push) Has been cancelled
go / build windows/amd64 (push) Has been cancelled
go / build darwin/arm64 (push) Has been cancelled
go / build linux/arm64 (push) Has been cancelled
go / build windows/arm64 (push) Has been cancelled
go / test (push) Has been cancelled
go / vuln_check (push) Has been cancelled
Some checks failed
kotlin / test (push) Has been skipped
python / test (push) Has been skipped
typescript / test (push) Has been skipped
go / build darwin/amd64 (push) Has been cancelled
go / build linux/amd64 (push) Has been cancelled
go / build windows/amd64 (push) Has been cancelled
go / build darwin/arm64 (push) Has been cancelled
go / build linux/arm64 (push) Has been cancelled
go / build windows/arm64 (push) Has been cancelled
go / test (push) Has been cancelled
go / vuln_check (push) Has been cancelled
This commit is contained in:
787
internal/goldeneye/mysql/analyze.go
Normal file
787
internal/goldeneye/mysql/analyze.go
Normal file
@@ -0,0 +1,787 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/sqlc-dev/sqlc/internal/goldeneye/dialect"
|
||||
"os"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/sqlc-dev/sqlc/internal/goldeneye/analysis"
|
||||
"github.com/sqlc-dev/sqlc/internal/goldeneye/endtoend"
|
||||
)
|
||||
|
||||
// The analyze cases are checked against a live server, which is asked
|
||||
// three things about each query. What a driver sees: the query is run,
|
||||
// with every parameter a user variable set to NULL, and each result
|
||||
// column's name, type and nullability are read from the result set's
|
||||
// metadata, the way go-sql-driver reports them. What the resolver made of
|
||||
// it: the optimizer trace prints each query block back after name
|
||||
// resolution, with every column qualified, every alias kept and every
|
||||
// SELECT * expanded, which says which table each result column is read
|
||||
// from and what each parameter is compared with or assigned to. And for a
|
||||
// statement the trace does not expand — an INSERT ... VALUES, a
|
||||
// single-table UPDATE or DELETE — the note EXPLAIN leaves, which prints
|
||||
// the statement the same way. Views and derived tables are kept as the
|
||||
// query wrote them rather than merged, so that a column read through one
|
||||
// is reported as its column. MySQL itself reports nothing about a
|
||||
// parameter but its position, so a parameter is described by its partner:
|
||||
// a column's type and nullability come from information_schema, a column
|
||||
// of a derived table from what its block projects, and an expression's
|
||||
// from running it, over the tables it reads, as a query of its own.
|
||||
|
||||
// placeholder is one parameter of a query as sqlc numbers them: each ? in
|
||||
// turn, and each sqlc.arg name once, at its first appearance. Each becomes
|
||||
// a user variable, which MySQL prints back by name, except in a LIMIT or
|
||||
// OFFSET, where MySQL allows nothing but a number.
|
||||
type placeholder struct {
|
||||
Number int
|
||||
Name string
|
||||
Limit bool
|
||||
Sentinel string // the variable's name, or the number
|
||||
}
|
||||
|
||||
// limitBase is added to a LIMIT parameter's number, so that the count is
|
||||
// unlike any a query would write.
|
||||
const limitBase = 4000000000
|
||||
|
||||
func bind(sql string) (string, []placeholder) {
|
||||
var phs []placeholder
|
||||
numbers := map[string]int{}
|
||||
out := endtoend.Rewrite(sql, func(name, lastWord string) string {
|
||||
n, ok := numbers[name]
|
||||
if name == "" || !ok {
|
||||
n = len(phs) + 1
|
||||
ph := placeholder{Number: n, Name: name}
|
||||
switch strings.ToLower(lastWord) {
|
||||
case "limit", "offset":
|
||||
ph.Limit = true
|
||||
ph.Sentinel = strconv.Itoa(limitBase + n)
|
||||
default:
|
||||
ph.Sentinel = "goldeneye_" + strconv.Itoa(n)
|
||||
}
|
||||
phs = append(phs, ph)
|
||||
if name != "" {
|
||||
numbers[name] = n
|
||||
}
|
||||
}
|
||||
ph := phs[n-1]
|
||||
if ph.Limit {
|
||||
return ph.Sentinel
|
||||
}
|
||||
return "@" + ph.Sentinel
|
||||
})
|
||||
return out, phs
|
||||
}
|
||||
|
||||
// column is what information_schema says about a column: its type as
|
||||
// COLUMN_TYPE spells it, read into an expression.
|
||||
type column struct {
|
||||
name string
|
||||
typ *analysis.TypeExpr
|
||||
nullable bool
|
||||
}
|
||||
|
||||
// typeOfColumn is a column's type as it was declared, from COLUMN_TYPE, which
|
||||
// carries the arguments and the unsigned that DATA_TYPE does not: "decimal(10,2)
|
||||
// unsigned" is the family "decimal unsigned" applied to 10 and 2, and
|
||||
// "enum('a','b')" is enum applied to its members. A trailing word such as
|
||||
// zerofill is part of the family too.
|
||||
func typeOfColumn(columnType string) *analysis.TypeExpr {
|
||||
s := strings.TrimSpace(columnType)
|
||||
open := strings.IndexByte(s, '(')
|
||||
if open < 0 {
|
||||
return &analysis.TypeExpr{Name: strings.ToLower(s)}
|
||||
}
|
||||
close := strings.LastIndexByte(s, ')')
|
||||
if close < open {
|
||||
return &analysis.TypeExpr{Name: strings.ToLower(s)}
|
||||
}
|
||||
// The family is spelled in lower case; an enum's members keep theirs,
|
||||
// since they are values.
|
||||
name := strings.ToLower(strings.TrimSpace(s[:open]))
|
||||
if rest := strings.ToLower(strings.TrimSpace(s[close+1:])); rest != "" {
|
||||
name += " " + rest
|
||||
}
|
||||
t := &analysis.TypeExpr{Name: name}
|
||||
for _, a := range splitArgs(s[open+1 : close]) {
|
||||
a = strings.TrimSpace(a)
|
||||
if strings.HasPrefix(a, "'") && strings.HasSuffix(a, "'") && len(a) >= 2 {
|
||||
v := unquoteMember(a[1 : len(a)-1])
|
||||
t.Args = append(t.Args, analysis.TypeArg{String: &v})
|
||||
continue
|
||||
}
|
||||
if n, err := strconv.ParseInt(a, 10, 64); err == nil {
|
||||
t.Args = append(t.Args, analysis.TypeArg{Int: &n})
|
||||
}
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// splitArgs splits a type's argument list on the commas outside quotes. A
|
||||
// backslash inside a quoted member escapes the character after it.
|
||||
func splitArgs(s string) []string {
|
||||
var out []string
|
||||
start, quoted := 0, false
|
||||
for i := 0; i < len(s); i++ {
|
||||
switch {
|
||||
case quoted && s[i] == '\\':
|
||||
i++
|
||||
case s[i] == '\'':
|
||||
quoted = !quoted
|
||||
case s[i] == ',' && !quoted:
|
||||
out = append(out, s[start:i])
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
return append(out, s[start:])
|
||||
}
|
||||
|
||||
// unquoteMember reads the body of a quoted enum or set member as
|
||||
// information_schema spells it: a quote is doubled and a backslash escapes
|
||||
// the character after it.
|
||||
func unquoteMember(s string) string {
|
||||
var out strings.Builder
|
||||
for i := 0; i < len(s); i++ {
|
||||
switch {
|
||||
case s[i] == '\\' && i+1 < len(s):
|
||||
i++
|
||||
out.WriteByte(s[i])
|
||||
case s[i] == '\'' && i+1 < len(s) && s[i+1] == '\'':
|
||||
i++
|
||||
out.WriteByte('\'')
|
||||
default:
|
||||
out.WriteByte(s[i])
|
||||
}
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
// withNullable copies a type with its nullability set.
|
||||
func withNullable(t *analysis.TypeExpr, nullable bool) *analysis.TypeExpr {
|
||||
out := *t
|
||||
out.Nullable = nullable
|
||||
return &out
|
||||
}
|
||||
|
||||
// relation is a table the catalog knows, by schema and name.
|
||||
type relation struct {
|
||||
schema, name string
|
||||
}
|
||||
|
||||
// analyzer holds the session a case runs in and the catalog: the case's
|
||||
// own schema, and information_schema, whose names are matched and
|
||||
// reported in lower case, since MySQL matches them in any case and the
|
||||
// dialect spells them that way.
|
||||
type analyzer struct {
|
||||
conn *sql.Conn
|
||||
db string // the case's schema
|
||||
catalog map[relation][]column // columns in order
|
||||
}
|
||||
|
||||
// table returns the catalog's name for a table of a schema, or "" when
|
||||
// the catalog has no such table.
|
||||
func (a *analyzer) table(schema, name string) (relation, bool) {
|
||||
if schema == "" {
|
||||
schema = a.db
|
||||
}
|
||||
if strings.EqualFold(schema, "information_schema") {
|
||||
schema, name = "information_schema", strings.ToLower(name)
|
||||
}
|
||||
rel := relation{schema, name}
|
||||
_, ok := a.catalog[rel]
|
||||
return rel, ok
|
||||
}
|
||||
|
||||
var dbNameRe = regexp.MustCompile(`[^A-Za-z0-9_]+`)
|
||||
|
||||
// Analyze loads a case's schema and fixture into a database of their own
|
||||
// on the server, runs its queries there and returns what MySQL reports in
|
||||
// the JSON shape sqlc analyze prints.
|
||||
func Analyze(ctx context.Context, dsn string, c endtoend.Case) ([]byte, error) {
|
||||
conn, err := open(ctx, dsn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
schema, err := os.ReadFile(c.Schema)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var fixture []byte
|
||||
if c.Fixture != "" {
|
||||
if fixture, err = os.ReadFile(c.Fixture); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
queries, err := c.Queries()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
name := "goldeneye_" + dbNameRe.ReplaceAllString(c.Name, "_")
|
||||
db := quote(name)
|
||||
for _, stmt := range []string{
|
||||
"DROP DATABASE IF EXISTS " + db,
|
||||
"CREATE DATABASE " + db,
|
||||
"USE " + db,
|
||||
"SET SESSION optimizer_trace = 'enabled=on'",
|
||||
"SET SESSION optimizer_trace_max_mem_size = 67108864",
|
||||
// A view or derived table is kept as the query wrote it rather
|
||||
// than merged into the block that reads it, so that a column read
|
||||
// from one is reported as its column, and an information_schema
|
||||
// view is not resolved away into the data dictionary tables
|
||||
// behind it.
|
||||
"SET SESSION optimizer_switch = 'derived_merge=off,derived_condition_pushdown=off'",
|
||||
} {
|
||||
if _, err := conn.ExecContext(ctx, stmt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
defer conn.ExecContext(context.WithoutCancel(ctx), "DROP DATABASE IF EXISTS "+db)
|
||||
if _, err := conn.ExecContext(ctx, string(schema)); err != nil {
|
||||
return nil, fmt.Errorf("loading %s: %w", c.Schema, err)
|
||||
}
|
||||
if strings.TrimSpace(string(fixture)) != "" {
|
||||
if _, err := conn.ExecContext(ctx, string(fixture)); err != nil {
|
||||
return nil, fmt.Errorf("loading %s: %w", c.Fixture, err)
|
||||
}
|
||||
}
|
||||
|
||||
a := &analyzer{conn: conn, db: name}
|
||||
if a.catalog, err = readCatalog(ctx, conn, name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]analysis.Query, 0, len(queries))
|
||||
for _, q := range queries {
|
||||
aq, err := a.analyzeQuery(ctx, q)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", q.Name, err)
|
||||
}
|
||||
out = append(out, aq)
|
||||
}
|
||||
return analysis.Encode(out)
|
||||
}
|
||||
|
||||
// Check compares what MySQL reports for a case with the output the case
|
||||
// committed, returning a diff when they differ.
|
||||
// Check compares a case's committed output with what MySQL reports. A
|
||||
// column read from a table is compared in full, since information_schema
|
||||
// spells its whole type; an expression's type comes from the wire, which
|
||||
// carries the family and nothing of its arguments, so an expression, and
|
||||
// a parameter typed by one, is compared by family alone.
|
||||
func Check(ctx context.Context, dsn string, c endtoend.Case) (string, error) {
|
||||
got, err := Analyze(ctx, dsn, c)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
want, err := os.ReadFile(c.Output)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var queries []analysis.Query
|
||||
if err := json.Unmarshal(want, &queries); err != nil {
|
||||
return "", fmt.Errorf("%s: %w", c.Output, err)
|
||||
}
|
||||
for i := range queries {
|
||||
for j := range queries[i].Columns {
|
||||
familyOnly(&queries[i].Columns[j])
|
||||
}
|
||||
for j := range queries[i].Params {
|
||||
familyOnly(&queries[i].Params[j].Column)
|
||||
}
|
||||
}
|
||||
want, err = analysis.Encode(queries)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if bytes.Equal(want, got) {
|
||||
return "", nil
|
||||
}
|
||||
return dialect.Diff(string(want), string(got)), nil
|
||||
}
|
||||
|
||||
// familyOnly drops the arguments of a column's type when the column is not
|
||||
// read from a table, which is as much as the wire says about it.
|
||||
func familyOnly(col *analysis.Column) {
|
||||
if col.Table == "" && col.Type != nil {
|
||||
col.Type.Args = nil
|
||||
}
|
||||
}
|
||||
|
||||
const catalogQuery = `
|
||||
SELECT TABLE_SCHEMA, TABLE_NAME, COLUMN_NAME, DATA_TYPE, COLUMN_TYPE, IS_NULLABLE
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA IN (?, 'information_schema')
|
||||
ORDER BY TABLE_SCHEMA, TABLE_NAME, ORDINAL_POSITION`
|
||||
|
||||
func readCatalog(ctx context.Context, conn *sql.Conn, db string) (map[relation][]column, error) {
|
||||
rows, err := conn.QueryContext(ctx, catalogQuery, db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
catalog := map[relation][]column{}
|
||||
for rows.Next() {
|
||||
var schema, table, name, dataType, columnType, nullable string
|
||||
if err := rows.Scan(&schema, &table, &name, &dataType, &columnType, &nullable); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if schema == "information_schema" {
|
||||
table, name = strings.ToLower(table), strings.ToLower(name)
|
||||
}
|
||||
rel := relation{schema, table}
|
||||
catalog[rel] = append(catalog[rel], column{
|
||||
name: name,
|
||||
typ: typeOfColumn(columnType),
|
||||
nullable: nullable == "YES",
|
||||
})
|
||||
}
|
||||
return catalog, rows.Err()
|
||||
}
|
||||
|
||||
// statement is everything the server said about one query.
|
||||
type statement struct {
|
||||
a *analyzer
|
||||
trace trace
|
||||
note *text // the statement as EXPLAIN printed it, nil without one
|
||||
aliases map[string]tableRef
|
||||
results []*sql.ColumnType // of the executed query, nil for one returning no rows
|
||||
target *relation // the table an INSERT ... VALUES writes
|
||||
targets []string // its columns, in row order
|
||||
}
|
||||
|
||||
func (a *analyzer) analyzeQuery(ctx context.Context, q endtoend.Query) (analysis.Query, error) {
|
||||
sql, phs := bind(q.SQL)
|
||||
|
||||
var sets []string
|
||||
for _, ph := range phs {
|
||||
if !ph.Limit {
|
||||
sets = append(sets, "@"+ph.Sentinel+" = NULL")
|
||||
}
|
||||
}
|
||||
if len(sets) > 0 {
|
||||
if _, err := a.conn.ExecContext(ctx, "SET "+strings.Join(sets, ", ")); err != nil {
|
||||
return analysis.Query{}, err
|
||||
}
|
||||
}
|
||||
|
||||
// EXPLAIN resolves and optimises the statement without running it,
|
||||
// leaving its note and its trace behind. Only the traditional format
|
||||
// leaves the note, and since MySQL 26 the default format is the tree.
|
||||
if err := drain(a.conn.QueryContext(ctx, "EXPLAIN FORMAT=TRADITIONAL "+sql)); err != nil {
|
||||
return analysis.Query{}, err
|
||||
}
|
||||
note, err := readNote(ctx, a.conn)
|
||||
if err != nil {
|
||||
return analysis.Query{}, err
|
||||
}
|
||||
tr, err := readTrace(ctx, a.conn)
|
||||
if err != nil {
|
||||
return analysis.Query{}, err
|
||||
}
|
||||
|
||||
s := &statement{a: a, trace: tr, aliases: map[string]tableRef{}}
|
||||
if note != "" {
|
||||
t := tokenize(note)
|
||||
s.note = &t
|
||||
t.aliases(s.aliases)
|
||||
s.target, s.targets = s.insertTargets(t)
|
||||
}
|
||||
for _, t := range tr.blocks {
|
||||
t.aliases(s.aliases)
|
||||
}
|
||||
|
||||
aq := analysis.Query{
|
||||
Name: q.Name,
|
||||
Cmd: q.Cmd,
|
||||
Columns: []analysis.Column{},
|
||||
Params: []analysis.Param{},
|
||||
}
|
||||
if returnsRows(sql) {
|
||||
rows, err := a.conn.QueryContext(ctx, sql)
|
||||
if err != nil {
|
||||
return analysis.Query{}, err
|
||||
}
|
||||
s.results, err = rows.ColumnTypes()
|
||||
rows.Close()
|
||||
if err != nil {
|
||||
return analysis.Query{}, err
|
||||
}
|
||||
// Names, types and nullability are the result set's. The top
|
||||
// block's SELECT list adds which table each column is read from,
|
||||
// when the two line up. MySQL sends every size of TEXT and BLOB
|
||||
// as the one wire type and tells them apart by the length beside
|
||||
// it, which the driver keeps to itself, so a column read from a
|
||||
// table, directly or through a derived table, is spelled the way
|
||||
// the table declares it.
|
||||
var items []item
|
||||
if top, ok := tr.blocks[1]; ok {
|
||||
items = top.selectItems()
|
||||
}
|
||||
for i, ct := range s.results {
|
||||
ac := analysis.Column{Name: ct.Name(), Type: typeOf(ct)}
|
||||
if len(items) == len(s.results) {
|
||||
top := tr.blocks[1]
|
||||
if it := items[i]; it.end-it.start == 1 && top.at(it.start).kind == tkIdent {
|
||||
tok := top.at(it.start)
|
||||
if rel, ok := s.resolve(tok.parts); ok {
|
||||
ac.Table = rel.name
|
||||
}
|
||||
if col, ok := s.origin(top, tok, 0); ok {
|
||||
ac.Type = withNullable(col.typ, ac.Type.Nullable)
|
||||
}
|
||||
}
|
||||
}
|
||||
aq.Columns = append(aq.Columns, ac)
|
||||
}
|
||||
}
|
||||
|
||||
for _, ph := range phs {
|
||||
ac := analysis.Column{}
|
||||
if t, i, ok := s.locate(ph); ok {
|
||||
ac = s.describe(ctx, t, t.partner(i), 0)
|
||||
}
|
||||
if ph.Name != "" {
|
||||
ac.Name = ph.Name
|
||||
}
|
||||
aq.Params = append(aq.Params, analysis.Param{Number: ph.Number, Column: ac})
|
||||
}
|
||||
return aq, nil
|
||||
}
|
||||
|
||||
// drain reads a result set to the end and closes it.
|
||||
func drain(rows *sql.Rows, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
}
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
// readNote returns the statement EXPLAIN printed back, from the note it
|
||||
// leaves among the warnings, or "" when it left none.
|
||||
func readNote(ctx context.Context, conn *sql.Conn) (string, error) {
|
||||
rows, err := conn.QueryContext(ctx, "SHOW WARNINGS")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer rows.Close()
|
||||
note := ""
|
||||
for rows.Next() {
|
||||
var level, message string
|
||||
var code int
|
||||
if err := rows.Scan(&level, &code, &message); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if code == 1003 {
|
||||
note = message
|
||||
}
|
||||
}
|
||||
return note, rows.Err()
|
||||
}
|
||||
|
||||
// readTrace returns the optimizer trace of the last statement.
|
||||
func readTrace(ctx context.Context, conn *sql.Conn) (trace, error) {
|
||||
rows, err := conn.QueryContext(ctx, "SELECT TRACE, MISSING_BYTES_BEYOND_MAX_MEM_SIZE FROM information_schema.OPTIMIZER_TRACE")
|
||||
if err != nil {
|
||||
return trace{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
blob := ""
|
||||
for rows.Next() {
|
||||
var missing int64
|
||||
if err := rows.Scan(&blob, &missing); err != nil {
|
||||
return trace{}, err
|
||||
}
|
||||
if missing > 0 {
|
||||
return trace{}, fmt.Errorf("the optimizer trace was truncated by %d bytes", missing)
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return trace{}, err
|
||||
}
|
||||
return parseTrace(blob)
|
||||
}
|
||||
|
||||
// returnsRows reports whether a statement produces a result set.
|
||||
func returnsRows(sql string) bool {
|
||||
head := strings.ToLower(strings.TrimSpace(sql))
|
||||
if strings.HasPrefix(head, "(") {
|
||||
return true
|
||||
}
|
||||
for _, kw := range []string{"select", "with", "show", "describe", "desc", "explain", "table", "values"} {
|
||||
if strings.HasPrefix(head, kw) && (len(head) == len(kw) || !isWordByte(head[len(kw)])) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// typeOf is a result column's type as the driver reports it, spelled the
|
||||
// way a column declaration does: "bigint unsigned" rather than the
|
||||
// driver's "UNSIGNED BIGINT".
|
||||
func typeOf(ct *sql.ColumnType) *analysis.TypeExpr {
|
||||
name := strings.ToLower(ct.DatabaseTypeName())
|
||||
if rest, ok := strings.CutPrefix(name, "unsigned "); ok {
|
||||
name = rest + " unsigned"
|
||||
}
|
||||
nullable, _ := ct.Nullable()
|
||||
return &analysis.TypeExpr{Name: name, Nullable: nullable}
|
||||
}
|
||||
|
||||
// resolve returns the catalog table a qualified column name is read from:
|
||||
// the qualifier is an alias, or a table of the case's schema, or a table
|
||||
// of the schema the name spells out.
|
||||
func (s *statement) resolve(parts []string) (relation, bool) {
|
||||
if len(parts) < 2 {
|
||||
return relation{}, false
|
||||
}
|
||||
qualifier := parts[len(parts)-2]
|
||||
if ref, ok := s.aliases[qualifier]; ok {
|
||||
return s.a.table(ref.schema, ref.name)
|
||||
}
|
||||
schema := ""
|
||||
if len(parts) >= 3 {
|
||||
schema = parts[len(parts)-3]
|
||||
}
|
||||
return s.a.table(schema, qualifier)
|
||||
}
|
||||
|
||||
// locate finds the text a placeholder was printed in and where: the
|
||||
// innermost query block whose expanded_query has it, or else the note.
|
||||
func (s *statement) locate(ph placeholder) (text, int, bool) {
|
||||
find := func(t text) int {
|
||||
if ph.Limit {
|
||||
return t.findNumber(ph.Sentinel)
|
||||
}
|
||||
return t.findVar(ph.Sentinel)
|
||||
}
|
||||
for _, sel := range s.trace.order() {
|
||||
t := s.trace.blocks[sel]
|
||||
if i := find(t); i >= 0 {
|
||||
return t, i, true
|
||||
}
|
||||
}
|
||||
if s.note != nil {
|
||||
if i := find(*s.note); i >= 0 {
|
||||
return *s.note, i, true
|
||||
}
|
||||
}
|
||||
return text{}, -1, false
|
||||
}
|
||||
|
||||
// describe turns a parameter's partner into the column description sqlc
|
||||
// prints for the parameter.
|
||||
func (s *statement) describe(ctx context.Context, t text, p partner, depth int) analysis.Column {
|
||||
switch p.kind {
|
||||
case partnerOperand:
|
||||
return s.operand(ctx, t, p.start, p.end, depth)
|
||||
case partnerProjection:
|
||||
if s.results != nil {
|
||||
if top, ok := s.trace.blocks[1]; ok && top.src == t.src && p.index < len(s.results) {
|
||||
ct := s.results[p.index]
|
||||
return analysis.Column{Name: ct.Name(), Type: typeOf(ct)}
|
||||
}
|
||||
}
|
||||
if items := t.selectItems(); p.index < len(items) {
|
||||
return analysis.Column{Name: items[p.index].alias}
|
||||
}
|
||||
case partnerInsert:
|
||||
if s.target != nil && len(s.targets) > 0 {
|
||||
return s.column(*s.target, s.targets[p.index%len(s.targets)])
|
||||
}
|
||||
case partnerLimit:
|
||||
// A LIMIT or OFFSET count is an unsigned 64-bit integer to MySQL.
|
||||
return analysis.Column{Type: &analysis.TypeExpr{Name: "bigint unsigned"}}
|
||||
}
|
||||
return analysis.Column{}
|
||||
}
|
||||
|
||||
// operand describes an operand: a column by what the schema says of it, a
|
||||
// call by its name and what it returns, anything else by its type.
|
||||
func (s *statement) operand(ctx context.Context, t text, start, end, depth int) analysis.Column {
|
||||
tok := t.at(start)
|
||||
if end-start == 1 && tok.kind == tkIdent {
|
||||
return s.columnRef(ctx, t, tok, depth)
|
||||
}
|
||||
if tok.kind == tkVar {
|
||||
return analysis.Column{}
|
||||
}
|
||||
ac := analysis.Column{}
|
||||
if tok.kind == tkWord && t.at(start+1).kind == tkLParen && t.isCall(start) && !strings.HasPrefix(tok.text, "<") {
|
||||
ac.Name = tok.text
|
||||
}
|
||||
ac.Type = s.eval(ctx, t, start, end)
|
||||
return ac
|
||||
}
|
||||
|
||||
// columnRef describes a column reference: a column of the schema, a column
|
||||
// of a derived table by the expression its block projects, or a SELECT list
|
||||
// alias by the expression it names.
|
||||
func (s *statement) columnRef(ctx context.Context, t text, tok token, depth int) analysis.Column {
|
||||
parts := tok.parts
|
||||
if depth > 8 {
|
||||
return analysis.Column{Name: parts[len(parts)-1]}
|
||||
}
|
||||
if len(parts) == 1 {
|
||||
for _, it := range t.selectItems() {
|
||||
if it.alias == parts[0] {
|
||||
ac := s.operand(ctx, t, it.start, it.end, depth+1)
|
||||
ac.Name = parts[0]
|
||||
return ac
|
||||
}
|
||||
}
|
||||
return analysis.Column{Name: parts[0]}
|
||||
}
|
||||
qualifier, name := parts[len(parts)-2], parts[len(parts)-1]
|
||||
if rel, ok := s.resolve(parts); ok {
|
||||
return s.column(rel, name)
|
||||
}
|
||||
if sel, ok := s.trace.derived[qualifier]; ok {
|
||||
if block, ok := s.trace.blocks[sel]; ok {
|
||||
for _, it := range block.selectItems() {
|
||||
if it.alias == name {
|
||||
ac := s.operand(ctx, block, it.start, it.end, depth+1)
|
||||
ac.Name = name
|
||||
return ac
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return analysis.Column{Name: name}
|
||||
}
|
||||
|
||||
// origin follows a column reference to the catalog column it reads, through
|
||||
// any derived table or CTE whose block projects that column as it is.
|
||||
func (s *statement) origin(t text, tok token, depth int) (column, bool) {
|
||||
parts := tok.parts
|
||||
if depth > 8 || len(parts) < 2 {
|
||||
return column{}, false
|
||||
}
|
||||
qualifier, name := parts[len(parts)-2], parts[len(parts)-1]
|
||||
if rel, ok := s.resolve(parts); ok {
|
||||
if rel.schema == "information_schema" {
|
||||
name = strings.ToLower(name)
|
||||
}
|
||||
for _, col := range s.a.catalog[rel] {
|
||||
if col.name == name {
|
||||
return col, true
|
||||
}
|
||||
}
|
||||
return column{}, false
|
||||
}
|
||||
sel, ok := s.trace.derived[qualifier]
|
||||
if !ok {
|
||||
return column{}, false
|
||||
}
|
||||
block, ok := s.trace.blocks[sel]
|
||||
if !ok {
|
||||
return column{}, false
|
||||
}
|
||||
for _, it := range block.selectItems() {
|
||||
if it.alias == name && it.end-it.start == 1 && block.at(it.start).kind == tkIdent {
|
||||
return s.origin(block, block.at(it.start), depth+1)
|
||||
}
|
||||
}
|
||||
return column{}, false
|
||||
}
|
||||
|
||||
// column describes a column of a catalog table.
|
||||
func (s *statement) column(rel relation, name string) analysis.Column {
|
||||
if rel.schema == "information_schema" {
|
||||
name = strings.ToLower(name)
|
||||
}
|
||||
for _, col := range s.a.catalog[rel] {
|
||||
if col.name == name {
|
||||
return analysis.Column{
|
||||
Name: name,
|
||||
Type: withNullable(col.typ, col.nullable),
|
||||
Table: rel.name,
|
||||
}
|
||||
}
|
||||
}
|
||||
return analysis.Column{Name: name, Table: rel.name}
|
||||
}
|
||||
|
||||
// eval finds the type of an expression by running it as a query of its
|
||||
// own, over the tables its columns are read from under the aliases the
|
||||
// statement gave them. An expression that reads a derived table cannot be
|
||||
// run that way and gets no type.
|
||||
func (s *statement) eval(ctx context.Context, t text, start, end int) *analysis.TypeExpr {
|
||||
var from []string
|
||||
seen := map[string]bool{}
|
||||
for _, id := range t.idents(start, end) {
|
||||
qualifier := id.parts[len(id.parts)-2]
|
||||
if seen[qualifier] {
|
||||
continue
|
||||
}
|
||||
seen[qualifier] = true
|
||||
rel, ok := s.resolve(id.parts)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
item := quote(rel.schema) + "." + quote(rel.name)
|
||||
if qualifier != rel.name {
|
||||
item += " " + quote(qualifier)
|
||||
}
|
||||
from = append(from, item)
|
||||
}
|
||||
query := "SELECT " + t.slice(start, end)
|
||||
if len(from) > 0 {
|
||||
query += " FROM " + strings.Join(from, ", ")
|
||||
}
|
||||
rows, err := s.a.conn.QueryContext(ctx, query+" LIMIT 0")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
cts, err := rows.ColumnTypes()
|
||||
if err != nil || len(cts) != 1 {
|
||||
return nil
|
||||
}
|
||||
return typeOf(cts[0])
|
||||
}
|
||||
|
||||
// insertTargets reads the table and columns an INSERT ... VALUES writes,
|
||||
// as the note prints them: the columns listed, or every column of the
|
||||
// table in order when none are.
|
||||
func (s *statement) insertTargets(t text) (*relation, []string) {
|
||||
for i, tok := range t.toks {
|
||||
if tok.kind != tkWord || tok.text != "into" || t.at(i+1).kind != tkIdent {
|
||||
continue
|
||||
}
|
||||
parts := t.at(i + 1).parts
|
||||
schema := ""
|
||||
if len(parts) >= 2 {
|
||||
schema = parts[len(parts)-2]
|
||||
}
|
||||
rel, ok := s.a.table(schema, parts[len(parts)-1])
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
var targets []string
|
||||
if t.at(i+2).kind == tkLParen {
|
||||
for _, m := range t.list(i + 2) {
|
||||
if m[1]-m[0] == 1 && t.at(m[0]).kind == tkIdent {
|
||||
parts := t.at(m[0]).parts
|
||||
targets = append(targets, parts[len(parts)-1])
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for _, col := range s.a.catalog[rel] {
|
||||
targets = append(targets, col.name)
|
||||
}
|
||||
}
|
||||
return &rel, targets
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
150
internal/goldeneye/mysql/mysql.go
Normal file
150
internal/goldeneye/mysql/mysql.go
Normal file
@@ -0,0 +1,150 @@
|
||||
// Package mysql generates the MySQL dialect seed under
|
||||
// internal/engine/dolphin/dialect from a live server, and verifies the
|
||||
// MySQL analyze cases under internal/endtoend/testdata against the same
|
||||
// server.
|
||||
//
|
||||
// MySQL keeps no catalog of its types, functions or operators — the server
|
||||
// describes a built-in function no further than its name, in the help
|
||||
// tables — so types.jsonl, functions.jsonl and operators.jsonl are written
|
||||
// by hand and are not this package's business. What it does describe is
|
||||
// its data dictionary: relations.jsonl is every view of information_schema,
|
||||
// read from information_schema itself.
|
||||
//
|
||||
// The package also verifies the MySQL analyze cases under
|
||||
// internal/endtoend/testdata against the same server: each case's schema
|
||||
// and fixture are loaded into a database of their own and its queries run
|
||||
// there. Result columns come from the result set's metadata as the driver
|
||||
// reports it, provenance and parameters from the optimizer trace's
|
||||
// expanded_query and the note EXPLAIN leaves, since MySQL reports nothing
|
||||
// about a parameter but its position. The answer is printed in the JSON
|
||||
// shape sqlc analyze prints and compared with the case's committed
|
||||
// stdout.json byte for byte.
|
||||
//
|
||||
// The server is named by MYSQL_SERVER_URI, in the form the go-sql-driver
|
||||
// DSN takes, and has to be the major release in Major, since each release
|
||||
// adds to information_schema.
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
driver "github.com/go-sql-driver/mysql"
|
||||
|
||||
"github.com/sqlc-dev/sqlc/internal/goldeneye/dialect"
|
||||
)
|
||||
|
||||
// Engine is the name sqlc knows the dialect by, and the name of the analyze
|
||||
// case directories under internal/endtoend/testdata.
|
||||
const Engine = "mysql"
|
||||
|
||||
// Dir is the name of the engine directory the dialect lives under, which
|
||||
// is named after the parser sqlc reads MySQL with rather than the engine.
|
||||
const Dir = "dolphin"
|
||||
|
||||
// Major is the MySQL major release the dialect is generated from. Bumping
|
||||
// it is a deliberate change: every release adds tables and columns to the
|
||||
// system schemas, so regenerate and review the dialect after changing it.
|
||||
const Major = 26
|
||||
|
||||
// Locate returns the server to generate from, named by MYSQL_SERVER_URI.
|
||||
func Locate() (string, error) {
|
||||
dsn := os.Getenv("MYSQL_SERVER_URI")
|
||||
if dsn == "" {
|
||||
return "", errors.New("MYSQL_SERVER_URI is not set")
|
||||
}
|
||||
if _, err := driver.ParseDSN(dsn); err != nil {
|
||||
return "", fmt.Errorf("MYSQL_SERVER_URI: %w", err)
|
||||
}
|
||||
return dsn, nil
|
||||
}
|
||||
|
||||
// open connects to the server. The connection runs several statements at
|
||||
// once, so that a case's schema loads as one script, and holds one
|
||||
// session, since the optimizer trace and the warnings a statement leaves
|
||||
// are both per session.
|
||||
func open(ctx context.Context, dsn string) (*sql.Conn, error) {
|
||||
cfg, err := driver.ParseDSN(dsn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.MultiStatements = true
|
||||
db, err := sql.Open("mysql", cfg.FormatDSN())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
conn, err := db.Conn(ctx)
|
||||
if err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
// Version reports the release a server is.
|
||||
func Version(ctx context.Context, dsn string) (string, error) {
|
||||
conn, err := open(ctx, dsn)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer conn.Close()
|
||||
return version(ctx, conn)
|
||||
}
|
||||
|
||||
func version(ctx context.Context, conn *sql.Conn) (string, error) {
|
||||
var v string
|
||||
if err := conn.QueryRowContext(ctx, "SELECT version()").Scan(&v); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "MySQL " + v, nil
|
||||
}
|
||||
|
||||
// checkVersion refuses a server of another major release than the dialect
|
||||
// is generated from, whose system schemas would differ from the committed
|
||||
// ones without anything being wrong.
|
||||
func checkVersion(ctx context.Context, conn *sql.Conn) error {
|
||||
var v string
|
||||
if err := conn.QueryRowContext(ctx, "SELECT version()").Scan(&v); err != nil {
|
||||
return err
|
||||
}
|
||||
head, _, _ := strings.Cut(v, ".")
|
||||
major, err := strconv.Atoi(head)
|
||||
if err != nil {
|
||||
return fmt.Errorf("version %q: %w", v, err)
|
||||
}
|
||||
if major != Major {
|
||||
return fmt.Errorf("the dialect is generated from MySQL %d, but the server is MySQL %d", Major, major)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Generate reads the dialect from the server: relations.jsonl, the views of
|
||||
// information_schema.
|
||||
func Generate(ctx context.Context, dsn string) (dialect.Files, error) {
|
||||
conn, err := open(ctx, dsn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer conn.Close()
|
||||
if err := checkVersion(ctx, conn); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var relations []dialect.Relation
|
||||
for _, schema := range systemSchemas {
|
||||
rels, err := readRelations(ctx, conn, schema)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
relations = append(relations, rels...)
|
||||
}
|
||||
blob, err := dialect.JSONL(relations)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return dialect.Files{dialect.RelationsFile: blob}, nil
|
||||
}
|
||||
66
internal/goldeneye/mysql/mysql_test.go
Normal file
66
internal/goldeneye/mysql/mysql_test.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/sqlc-dev/sqlc/internal/goldeneye/dialect"
|
||||
"github.com/sqlc-dev/sqlc/internal/goldeneye/endtoend"
|
||||
)
|
||||
|
||||
// TestDialect verifies the committed MySQL dialect against what the server
|
||||
// reports. It skips unless MYSQL_SERVER_URI names a server.
|
||||
func TestDialect(t *testing.T) {
|
||||
dsn, err := Locate()
|
||||
if err != nil {
|
||||
t.Skip(err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
version, err := Version(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
files, err := Generate(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dir, err := dialect.Dir(Dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
report, err := dialect.Check(dir, files)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report != "" {
|
||||
t.Errorf("%s does not match what %s reports:\n%s", dir, version, report)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAnalyzeCases verifies every MySQL analyze case under
|
||||
// internal/endtoend/testdata against what the server reports. It skips
|
||||
// unless MYSQL_SERVER_URI names a server.
|
||||
func TestAnalyzeCases(t *testing.T) {
|
||||
dsn, err := Locate()
|
||||
if err != nil {
|
||||
t.Skip(err)
|
||||
}
|
||||
cases, err := endtoend.Cases(Engine)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(cases) == 0 {
|
||||
t.Fatal("no mysql analyze cases found")
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.Name, func(t *testing.T) {
|
||||
diff, err := Check(context.Background(), dsn, c)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if diff != "" {
|
||||
t.Errorf("%s does not match what MySQL reports (-committed +mysql):\n%s", c.Output, diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
99
internal/goldeneye/mysql/relations.go
Normal file
99
internal/goldeneye/mysql/relations.go
Normal file
@@ -0,0 +1,99 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/sqlc-dev/sqlc/internal/goldeneye/dialect"
|
||||
)
|
||||
|
||||
// systemSchemas are the schemas whose relations are generated: the data
|
||||
// dictionary's views, which are what a query of the server's catalog
|
||||
// reads. The other schemas mysqld --initialize creates — mysql,
|
||||
// performance_schema and sys — are tables rather than views, and every
|
||||
// table a dialect seeds is one the analysis core hands codegen as a model,
|
||||
// so they wait until codegen knows a system schema when it sees one.
|
||||
var systemSchemas = []string{
|
||||
"information_schema",
|
||||
}
|
||||
|
||||
// relationQuery lists a schema's relations and their columns, in the order
|
||||
// the columns were declared, so that the output is stable.
|
||||
const relationQuery = `
|
||||
SELECT t.TABLE_NAME, t.TABLE_TYPE,
|
||||
c.COLUMN_NAME, c.DATA_TYPE, c.COLUMN_TYPE, c.IS_NULLABLE
|
||||
FROM information_schema.TABLES t
|
||||
JOIN information_schema.COLUMNS c
|
||||
ON c.TABLE_SCHEMA = t.TABLE_SCHEMA AND c.TABLE_NAME = t.TABLE_NAME
|
||||
WHERE t.TABLE_SCHEMA = ?
|
||||
ORDER BY t.TABLE_NAME, c.ORDINAL_POSITION`
|
||||
|
||||
// readRelations reads a schema's tables and views. Their names are written
|
||||
// in lower case: information_schema names its views in upper case and
|
||||
// matches them in any case, MySQL matches every column name in any case,
|
||||
// and sqlc's MySQL parser lowercases every identifier, so lower case is
|
||||
// how a query reaches them. A column's type is spelled the way a
|
||||
// declaration does, as COLUMN_TYPE reports it, arguments and UNSIGNED
|
||||
// included: varchar(64), bigint unsigned.
|
||||
func readRelations(ctx context.Context, conn *sql.Conn, schema string) ([]dialect.Relation, error) {
|
||||
rows, err := conn.QueryContext(ctx, relationQuery, schema)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", schema, err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var relations []dialect.Relation
|
||||
var cur *dialect.Relation
|
||||
for rows.Next() {
|
||||
var name, kind, colName, dataType, columnType, nullable string
|
||||
if err := rows.Scan(&name, &kind, &colName, &dataType, &columnType, &nullable); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
name = strings.ToLower(name)
|
||||
if cur == nil || cur.Name != name {
|
||||
rel := dialect.Relation{Catalog: "def", Schema: schema, Name: name}
|
||||
if kind != "BASE TABLE" {
|
||||
rel.Kind = "v"
|
||||
}
|
||||
relations = append(relations, rel)
|
||||
cur = &relations[len(relations)-1]
|
||||
}
|
||||
cur.Columns = append(cur.Columns, dialect.Column{
|
||||
Name: strings.ToLower(colName),
|
||||
Type: typeName(dataType, columnType),
|
||||
NotNull: nullable == "NO",
|
||||
})
|
||||
}
|
||||
return relations, rows.Err()
|
||||
}
|
||||
|
||||
// typeName spells a column's type the way a declaration does, which is
|
||||
// COLUMN_TYPE as information_schema reports it: "varchar(64)", "bigint
|
||||
// unsigned", "enum('a','b')". The family is spelled in lower case; the
|
||||
// members of an enum or set are values and keep theirs.
|
||||
func typeName(dataType, columnType string) string {
|
||||
if columnType == "" {
|
||||
return strings.ToLower(dataType)
|
||||
}
|
||||
var out strings.Builder
|
||||
quoted := false
|
||||
for i := 0; i < len(columnType); i++ {
|
||||
c := columnType[i]
|
||||
switch {
|
||||
case quoted && c == '\\' && i+1 < len(columnType):
|
||||
out.WriteByte(c)
|
||||
i++
|
||||
out.WriteByte(columnType[i])
|
||||
continue
|
||||
case c == '\'':
|
||||
quoted = !quoted
|
||||
}
|
||||
if quoted {
|
||||
out.WriteByte(c)
|
||||
} else {
|
||||
out.WriteString(strings.ToLower(string(c)))
|
||||
}
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
678
internal/goldeneye/mysql/text.go
Normal file
678
internal/goldeneye/mysql/text.go
Normal file
@@ -0,0 +1,678 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// MySQL prints a statement back with every name resolved, every table
|
||||
// alias kept, every SELECT * expanded and every binary operation
|
||||
// parenthesised — in the optimizer trace's expanded_query, once per query
|
||||
// block, and in the note EXPLAIN leaves. Such a text is tokenised here and
|
||||
// read for three things: the tables it names and their aliases, the
|
||||
// columns each query block projects, and the operand a parameter is
|
||||
// compared with or assigned to.
|
||||
|
||||
type tokKind int
|
||||
|
||||
const (
|
||||
tkIdent tokKind = iota // `a`.`b`.`c`
|
||||
tkVar // @`name`
|
||||
tkNumber // 42, 1.5
|
||||
tkString // 'text'
|
||||
tkWord // a keyword, a function name or a <marker>
|
||||
tkOp // =, <=>, +, like is a word
|
||||
tkLParen
|
||||
tkRParen
|
||||
tkComma
|
||||
)
|
||||
|
||||
type token struct {
|
||||
kind tokKind
|
||||
text string // the token as printed; a word is lower-cased
|
||||
parts []string // an identifier's parts, unquoted; a variable's name
|
||||
start int // offsets into the source
|
||||
end int
|
||||
}
|
||||
|
||||
// text is a tokenised statement.
|
||||
type text struct {
|
||||
src string
|
||||
toks []token
|
||||
match []int // the index of each parenthesis's partner, or -1
|
||||
}
|
||||
|
||||
func tokenize(src string) text {
|
||||
var toks []token
|
||||
i := 0
|
||||
emit := func(kind tokKind, start, end int, parts []string) {
|
||||
toks = append(toks, token{kind: kind, text: src[start:end], parts: parts, start: start, end: end})
|
||||
}
|
||||
for i < len(src) {
|
||||
c := src[i]
|
||||
switch {
|
||||
case c == ' ' || c == '\t' || c == '\n' || c == '\r':
|
||||
i++
|
||||
case strings.HasPrefix(src[i:], "/*"):
|
||||
end := strings.Index(src[i:], "*/")
|
||||
if end < 0 {
|
||||
i = len(src)
|
||||
} else {
|
||||
i += end + 2
|
||||
}
|
||||
case c == '`':
|
||||
start := i
|
||||
var parts []string
|
||||
for {
|
||||
part, end := readQuoted(src, i)
|
||||
parts = append(parts, part)
|
||||
i = end
|
||||
if i+1 < len(src) && src[i] == '.' && src[i+1] == '`' {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
emit(tkIdent, start, i, parts)
|
||||
case c == '@':
|
||||
start := i
|
||||
i++
|
||||
for i < len(src) && src[i] == '@' {
|
||||
i++
|
||||
}
|
||||
if i < len(src) && src[i] == '`' {
|
||||
name, end := readQuoted(src, i)
|
||||
i = end
|
||||
emit(tkVar, start, i, []string{name})
|
||||
} else {
|
||||
for i < len(src) && isWordByte(src[i]) {
|
||||
i++
|
||||
}
|
||||
emit(tkVar, start, i, []string{src[start+1 : i]})
|
||||
}
|
||||
case c == '\'' || c == '"':
|
||||
start := i
|
||||
i = skipString(src, i)
|
||||
emit(tkString, start, i, nil)
|
||||
case c >= '0' && c <= '9' || c == '.' && i+1 < len(src) && src[i+1] >= '0' && src[i+1] <= '9':
|
||||
start := i
|
||||
for i < len(src) && (src[i] >= '0' && src[i] <= '9' || src[i] == '.') {
|
||||
i++
|
||||
}
|
||||
if i < len(src) && (src[i] == 'e' || src[i] == 'E') {
|
||||
i++
|
||||
if i < len(src) && (src[i] == '+' || src[i] == '-') {
|
||||
i++
|
||||
}
|
||||
for i < len(src) && src[i] >= '0' && src[i] <= '9' {
|
||||
i++
|
||||
}
|
||||
}
|
||||
emit(tkNumber, start, i, nil)
|
||||
case c == '<' && i+1 < len(src) && isWordByte(src[i+1]) && strings.IndexByte(src[i:], '>') > 0 && isMarker(src[i:i+strings.IndexByte(src[i:], '>')+1]):
|
||||
// <cache>, <in_optimizer>, <exists>: what the optimizer wrapped
|
||||
// an expression in. <cache> says nothing about the expression
|
||||
// and is dropped; the others are read as calls.
|
||||
end := i + strings.IndexByte(src[i:], '>') + 1
|
||||
if src[i:end] != "<cache>" {
|
||||
emit(tkWord, i, end, nil)
|
||||
toks[len(toks)-1].text = src[i+1 : end-1]
|
||||
}
|
||||
i = end
|
||||
case c == '(':
|
||||
emit(tkLParen, i, i+1, nil)
|
||||
i++
|
||||
case c == ')':
|
||||
emit(tkRParen, i, i+1, nil)
|
||||
i++
|
||||
case c == ',':
|
||||
emit(tkComma, i, i+1, nil)
|
||||
i++
|
||||
case isWordByte(c):
|
||||
start := i
|
||||
for i < len(src) && isWordByte(src[i]) {
|
||||
i++
|
||||
}
|
||||
emit(tkWord, start, i, nil)
|
||||
toks[len(toks)-1].text = strings.ToLower(src[start:i])
|
||||
default:
|
||||
n := 1
|
||||
for _, op := range operators {
|
||||
if strings.HasPrefix(src[i:], op) {
|
||||
n = len(op)
|
||||
break
|
||||
}
|
||||
}
|
||||
emit(tkOp, i, i+n, nil)
|
||||
i += n
|
||||
}
|
||||
}
|
||||
t := text{src: src, toks: toks, match: make([]int, len(toks))}
|
||||
var open []int
|
||||
for i, tok := range toks {
|
||||
t.match[i] = -1
|
||||
switch tok.kind {
|
||||
case tkLParen:
|
||||
open = append(open, i)
|
||||
case tkRParen:
|
||||
if len(open) > 0 {
|
||||
j := open[len(open)-1]
|
||||
open = open[:len(open)-1]
|
||||
t.match[i] = j
|
||||
t.match[j] = i
|
||||
}
|
||||
}
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// operators is every symbolic operator MySQL prints, longest first.
|
||||
var operators = []string{
|
||||
"<=>", "->>", "<>", "!=", ">=", "<=", "->", "<<", ">>", "||", "&&", ":=",
|
||||
"=", "<", ">", "+", "-", "*", "/", "%", "!", "^", "&", "|", "~", ".",
|
||||
}
|
||||
|
||||
func isMarker(s string) bool {
|
||||
for _, c := range []byte(s[1 : len(s)-1]) {
|
||||
if !isWordByte(c) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func isWordByte(c byte) bool {
|
||||
return c == '_' || c == '$' || c >= '0' && c <= '9' || c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z'
|
||||
}
|
||||
|
||||
// readQuoted reads the backticked identifier at i, unescaping a doubled
|
||||
// backtick, and returns it with the index just past its closing quote.
|
||||
func readQuoted(s string, i int) (string, int) {
|
||||
var b strings.Builder
|
||||
j := i + 1
|
||||
for j < len(s) {
|
||||
if s[j] == '`' {
|
||||
if j+1 < len(s) && s[j+1] == '`' {
|
||||
b.WriteByte('`')
|
||||
j += 2
|
||||
continue
|
||||
}
|
||||
return b.String(), j + 1
|
||||
}
|
||||
b.WriteByte(s[j])
|
||||
j++
|
||||
}
|
||||
return b.String(), j
|
||||
}
|
||||
|
||||
// skipString returns the index just past the string literal starting at i,
|
||||
// honouring backslash escapes and doubled quotes.
|
||||
func skipString(s string, i int) int {
|
||||
q := s[i]
|
||||
j := i + 1
|
||||
for j < len(s) {
|
||||
switch {
|
||||
case s[j] == '\\' && j+1 < len(s):
|
||||
j += 2
|
||||
case s[j] == q && j+1 < len(s) && s[j+1] == q:
|
||||
j += 2
|
||||
case s[j] == q:
|
||||
return j + 1
|
||||
default:
|
||||
j++
|
||||
}
|
||||
}
|
||||
return len(s)
|
||||
}
|
||||
|
||||
// wordOperators are the operators MySQL spells as words. and/or/not/is
|
||||
// are left out: MySQL parenthesises each predicate, so a parameter that
|
||||
// is a whole predicate has nothing to be compared with.
|
||||
var wordOperators = map[string]bool{
|
||||
"like": true, "in": true, "regexp": true, "rlike": true, "div": true, "mod": true,
|
||||
"xor": true, "sounds": true, "escape": true, "between": true, "collate": true,
|
||||
"member": true,
|
||||
}
|
||||
|
||||
// keywords are the words that can precede a parenthesis without naming a
|
||||
// function, so that "(a, b)" after one is a list rather than a call.
|
||||
var keywords = map[string]bool{
|
||||
"in": true, "values": true, "value": true, "and": true, "or": true, "not": true,
|
||||
"where": true, "on": true, "from": true, "select": true, "having": true,
|
||||
"set": true, "when": true, "then": true, "else": true, "case": true, "end": true,
|
||||
"like": true, "between": true, "by": true, "group": true, "order": true,
|
||||
"limit": true, "offset": true, "using": true, "join": true, "as": true, "is": true,
|
||||
"xor": true, "div": true, "mod": true, "interval": true, "distinct": true,
|
||||
"all": true, "any": true, "some": true, "escape": true, "regexp": true,
|
||||
"rlike": true, "sounds": true, "collate": true, "union": true, "except": true,
|
||||
"intersect": true, "with": true, "insert": true, "into": true, "update": true,
|
||||
"delete": true, "replace": true, "key": true, "duplicate": true, "do": true,
|
||||
"partition": true, "over": true, "window": true, "rows": true, "range": true,
|
||||
"straight_join": true, "cross": true, "inner": true, "left": true, "right": true,
|
||||
"natural": true, "lateral": true, "of": true, "for": true, "lock": true,
|
||||
"share": true, "null": true, "true": true, "false": true, "unknown": true,
|
||||
"binary": true, "row": true, "asc": true, "desc": true, "sql_calc_found_rows": true,
|
||||
"high_priority": true, "low_priority": true, "ignore": true, "table": true,
|
||||
"member": true, "exists": true,
|
||||
}
|
||||
|
||||
// literalWords are the words that are operands on their own.
|
||||
var literalWords = map[string]bool{
|
||||
"null": true, "true": true, "false": true, "unknown": true,
|
||||
"current_timestamp": true, "current_date": true, "current_time": true,
|
||||
"current_user": true, "localtime": true, "localtimestamp": true, "utc_timestamp": true,
|
||||
}
|
||||
|
||||
func (t text) at(i int) token {
|
||||
if i < 0 || i >= len(t.toks) {
|
||||
return token{kind: -1}
|
||||
}
|
||||
return t.toks[i]
|
||||
}
|
||||
|
||||
func (t text) isWord(i int, word string) bool {
|
||||
tok := t.at(i)
|
||||
return tok.kind == tkWord && tok.text == word
|
||||
}
|
||||
|
||||
// isCall reports whether the word at i names a function, given that a
|
||||
// parenthesis follows it.
|
||||
func (t text) isCall(i int) bool {
|
||||
tok := t.at(i)
|
||||
return tok.kind == tkWord && !keywords[tok.text] || tok.kind == tkWord && tok.text == "exists"
|
||||
}
|
||||
|
||||
// isOperator reports whether the token at i joins two operands.
|
||||
func (t text) isOperator(i int) bool {
|
||||
tok := t.at(i)
|
||||
switch tok.kind {
|
||||
case tkOp:
|
||||
return tok.text != "." && tok.text != "~" && tok.text != "!"
|
||||
case tkWord:
|
||||
return wordOperators[tok.text]
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// operandEnd returns the index just past the operand starting at i, or -1
|
||||
// when no operand starts there: a name, a literal, a call with its
|
||||
// arguments, or a parenthesised group.
|
||||
func (t text) operandEnd(i int) int {
|
||||
tok := t.at(i)
|
||||
switch tok.kind {
|
||||
case tkIdent, tkVar, tkNumber, tkString:
|
||||
return i + 1
|
||||
case tkLParen:
|
||||
if t.match[i] < 0 {
|
||||
return -1
|
||||
}
|
||||
return t.match[i] + 1
|
||||
case tkWord:
|
||||
if literalWords[tok.text] {
|
||||
return i + 1
|
||||
}
|
||||
if t.at(i+1).kind == tkLParen && t.isCall(i) {
|
||||
return t.operandEnd(i + 1)
|
||||
}
|
||||
if tok.text == "not" || tok.text == "binary" || tok.text == "interval" {
|
||||
return t.operandEnd(i + 1)
|
||||
}
|
||||
case tkOp:
|
||||
if tok.text == "-" || tok.text == "+" || tok.text == "~" || tok.text == "!" {
|
||||
return t.operandEnd(i + 1)
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// operandStart returns the index of the operand ending just before j, or
|
||||
// -1 when none ends there.
|
||||
func (t text) operandStart(j int) int {
|
||||
k := j - 1
|
||||
tok := t.at(k)
|
||||
switch tok.kind {
|
||||
case tkIdent, tkVar, tkNumber, tkString:
|
||||
return k
|
||||
case tkRParen:
|
||||
open := t.match[k]
|
||||
if open < 0 {
|
||||
return -1
|
||||
}
|
||||
if t.at(open-1).kind == tkWord && t.isCall(open-1) {
|
||||
return open - 1
|
||||
}
|
||||
return open
|
||||
case tkWord:
|
||||
if literalWords[tok.text] {
|
||||
return k
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// group returns the index of the parenthesis enclosing token i, or -1.
|
||||
func (t text) group(i int) int {
|
||||
depth := 0
|
||||
for k := i - 1; k >= 0; k-- {
|
||||
switch t.toks[k].kind {
|
||||
case tkRParen:
|
||||
depth++
|
||||
case tkLParen:
|
||||
if depth == 0 {
|
||||
return k
|
||||
}
|
||||
depth--
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// list splits the tokens strictly inside the group opened at open into its
|
||||
// comma-separated members, each a token range.
|
||||
func (t text) list(open int) [][2]int {
|
||||
close := t.match[open]
|
||||
if close < 0 {
|
||||
return nil
|
||||
}
|
||||
var members [][2]int
|
||||
start := open + 1
|
||||
for i := open + 1; i < close; i++ {
|
||||
switch t.toks[i].kind {
|
||||
case tkLParen:
|
||||
i = t.match[i]
|
||||
case tkComma:
|
||||
members = append(members, [2]int{start, i})
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
if start < close {
|
||||
members = append(members, [2]int{start, close})
|
||||
}
|
||||
return members
|
||||
}
|
||||
|
||||
// item is one entry of a SELECT list: the expression and the name it is
|
||||
// projected as.
|
||||
type item struct {
|
||||
start, end int
|
||||
alias string
|
||||
}
|
||||
|
||||
// selectItems reads the list of the first SELECT at the top level of the
|
||||
// text, which is the statement's own for a query block's expanded_query
|
||||
// and for a statement whose CTEs come first.
|
||||
func (t text) selectItems() []item {
|
||||
depth := 0
|
||||
start := -1
|
||||
for i, tok := range t.toks {
|
||||
switch tok.kind {
|
||||
case tkLParen:
|
||||
depth++
|
||||
case tkRParen:
|
||||
depth--
|
||||
case tkWord:
|
||||
if depth == 0 && tok.text == "select" {
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
if start >= 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
if start < 0 {
|
||||
return nil
|
||||
}
|
||||
for t.at(start).kind == tkWord && (t.at(start).text == "distinct" || t.at(start).text == "all" || t.at(start).text == "sql_calc_found_rows" || t.at(start).text == "straight_join" || t.at(start).text == "high_priority") {
|
||||
start++
|
||||
}
|
||||
end := len(t.toks)
|
||||
for i := start; i < len(t.toks); i++ {
|
||||
switch tok := t.toks[i]; tok.kind {
|
||||
case tkLParen:
|
||||
i = t.match[i]
|
||||
if i < 0 {
|
||||
return nil
|
||||
}
|
||||
case tkWord:
|
||||
switch tok.text {
|
||||
case "from", "where", "group", "having", "order", "limit", "union", "into", "window", "for", "lock", "except", "intersect":
|
||||
end = i
|
||||
}
|
||||
}
|
||||
if end != len(t.toks) {
|
||||
break
|
||||
}
|
||||
}
|
||||
var items []item
|
||||
itemStart := start
|
||||
flush := func(itemEnd int) {
|
||||
it := item{start: itemStart, end: itemEnd}
|
||||
if itemEnd-itemStart >= 3 && t.isWord(itemEnd-2, "as") && t.at(itemEnd-1).kind == tkIdent {
|
||||
it.alias = t.toks[itemEnd-1].parts[0]
|
||||
it.end = itemEnd - 2
|
||||
}
|
||||
items = append(items, it)
|
||||
}
|
||||
for i := start; i < end; i++ {
|
||||
switch t.toks[i].kind {
|
||||
case tkLParen:
|
||||
i = t.match[i]
|
||||
case tkComma:
|
||||
flush(i)
|
||||
itemStart = i + 1
|
||||
}
|
||||
}
|
||||
if itemStart < end {
|
||||
flush(end)
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
// tableRef is a table as a FROM clause names it, with the schema when it
|
||||
// spells one out.
|
||||
type tableRef struct {
|
||||
schema, name string
|
||||
}
|
||||
|
||||
// aliases reads the table aliases the text declares: a table name followed
|
||||
// by a bare identifier, as `users` `u` in a FROM clause. A SELECT list
|
||||
// alias is always introduced by AS, so the shape means nothing else.
|
||||
func (t text) aliases(into map[string]tableRef) {
|
||||
for i := 0; i+1 < len(t.toks); i++ {
|
||||
tok, next := t.toks[i], t.toks[i+1]
|
||||
if tok.kind != tkIdent || next.kind != tkIdent || len(next.parts) != 1 || len(tok.parts) > 2 {
|
||||
continue
|
||||
}
|
||||
if t.at(i-1).kind == tkOp && t.at(i-1).text == "." {
|
||||
continue
|
||||
}
|
||||
ref := tableRef{name: tok.parts[len(tok.parts)-1]}
|
||||
if len(tok.parts) == 2 {
|
||||
ref.schema = tok.parts[0]
|
||||
}
|
||||
into[next.parts[0]] = ref
|
||||
}
|
||||
}
|
||||
|
||||
// find returns the index of the variable named name, or -1.
|
||||
func (t text) findVar(name string) int {
|
||||
for i, tok := range t.toks {
|
||||
if tok.kind == tkVar && len(tok.parts) == 1 && tok.parts[0] == name {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// findNumber returns the index of the number printed as lit, or -1.
|
||||
func (t text) findNumber(lit string) int {
|
||||
for i, tok := range t.toks {
|
||||
if tok.kind == tkNumber && tok.text == lit {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// A partner is what a parameter stands against in a statement.
|
||||
type partnerKind int
|
||||
|
||||
const (
|
||||
partnerNone partnerKind = iota
|
||||
partnerOperand // the operand it is compared with, or the argument beside it
|
||||
partnerProjection // the SELECT list item it is projected as
|
||||
partnerInsert // the column of an INSERT ... VALUES row it is written to
|
||||
partnerLimit // the count of a LIMIT or OFFSET
|
||||
)
|
||||
|
||||
type partner struct {
|
||||
kind partnerKind
|
||||
start, end int // partnerOperand: the operand's tokens
|
||||
index int // partnerProjection: the item; partnerInsert: the position in the row
|
||||
}
|
||||
|
||||
// partner finds what the parameter at token index s stands against.
|
||||
func (t text) partner(s int) partner {
|
||||
lo, hi := s, s+1
|
||||
if t.at(lo-1).kind == tkLParen && t.at(hi).kind == tkRParen {
|
||||
lo, hi = lo-1, hi+1
|
||||
}
|
||||
if t.at(hi).kind == tkWord && t.at(hi).text == "as" {
|
||||
for i, it := range t.selectItems() {
|
||||
if it.start <= lo && hi <= it.end {
|
||||
return partner{kind: partnerProjection, index: i}
|
||||
}
|
||||
}
|
||||
}
|
||||
// The count of a LIMIT or OFFSET, which is a number rather than a
|
||||
// variable since MySQL allows nothing else there.
|
||||
if t.at(lo-1).kind == tkWord && (t.at(lo-1).text == "limit" || t.at(lo-1).text == "offset") ||
|
||||
t.at(lo-1).kind == tkComma && t.at(lo-2).kind == tkNumber && t.isWord(lo-3, "limit") {
|
||||
return partner{kind: partnerLimit}
|
||||
}
|
||||
// Compared: the operand on the other side of the operator.
|
||||
if t.isOperator(lo - 1) {
|
||||
q := lo - 2
|
||||
if t.isWord(q, "not") {
|
||||
q--
|
||||
}
|
||||
if st := t.operandStart(q + 1); st >= 0 {
|
||||
// The second bound of BETWEEN ... AND: the operand before BETWEEN.
|
||||
if t.isWord(lo-1, "and") {
|
||||
if t.isWord(st-1, "between") || t.isWord(st-1, "not") && t.isWord(st-2, "between") {
|
||||
q = st - 2
|
||||
if t.isWord(q, "not") {
|
||||
q--
|
||||
}
|
||||
if st2 := t.operandStart(q + 1); st2 >= 0 {
|
||||
return partner{kind: partnerOperand, start: st2, end: q + 1}
|
||||
}
|
||||
}
|
||||
}
|
||||
return partner{kind: partnerOperand, start: st, end: q + 1}
|
||||
}
|
||||
}
|
||||
if t.isWord(lo-1, "and") {
|
||||
if st := t.operandStart(lo - 1); st >= 0 && (t.isWord(st-1, "between") || t.isWord(st-1, "not") && t.isWord(st-2, "between")) {
|
||||
q := st - 2
|
||||
if t.isWord(q, "not") {
|
||||
q--
|
||||
}
|
||||
if st2 := t.operandStart(q + 1); st2 >= 0 {
|
||||
return partner{kind: partnerOperand, start: st2, end: q + 1}
|
||||
}
|
||||
}
|
||||
}
|
||||
if t.isOperator(hi) {
|
||||
n := hi + 1
|
||||
if t.isWord(n, "not") {
|
||||
n++
|
||||
}
|
||||
if end := t.operandEnd(n); end >= 0 {
|
||||
return partner{kind: partnerOperand, start: n, end: end}
|
||||
}
|
||||
}
|
||||
// A member of a list: the row of an INSERT ... VALUES, the values of an
|
||||
// IN, or the arguments of a call, where the argument beside it says
|
||||
// what it is, a column above an expression above a constant.
|
||||
if t.at(lo-1).kind == tkComma || t.at(lo-1).kind == tkLParen {
|
||||
open := t.group(lo)
|
||||
if open < 0 {
|
||||
return partner{}
|
||||
}
|
||||
members := t.list(open)
|
||||
index := -1
|
||||
for i, m := range members {
|
||||
if m[0] <= lo && hi <= m[1] {
|
||||
index = i
|
||||
}
|
||||
}
|
||||
head := open - 1
|
||||
for t.at(head).kind == tkComma && t.at(head-1).kind == tkRParen {
|
||||
head = t.match[head-1] - 1
|
||||
}
|
||||
switch {
|
||||
case t.isWord(head, "values") || t.isWord(head, "value"):
|
||||
return partner{kind: partnerInsert, index: index}
|
||||
case t.isWord(head, "in"):
|
||||
q := head - 1
|
||||
if t.isWord(q, "not") {
|
||||
q--
|
||||
}
|
||||
if st := t.operandStart(q + 1); st >= 0 {
|
||||
return partner{kind: partnerOperand, start: st, end: q + 1}
|
||||
}
|
||||
case t.at(head).kind == tkWord && t.isCall(head):
|
||||
best, bestRank := -1, 3
|
||||
for i, m := range members {
|
||||
if i == index {
|
||||
continue
|
||||
}
|
||||
if r := t.rank(m[0], m[1]); r < bestRank {
|
||||
best, bestRank = i, r
|
||||
}
|
||||
}
|
||||
if best >= 0 {
|
||||
return partner{kind: partnerOperand, start: members[best][0], end: members[best][1]}
|
||||
}
|
||||
}
|
||||
}
|
||||
return partner{}
|
||||
}
|
||||
|
||||
// rank orders operands by how much they say about a parameter beside them.
|
||||
func (t text) rank(start, end int) int {
|
||||
switch tok := t.at(start); {
|
||||
case end-start == 1 && tok.kind == tkIdent:
|
||||
return 0
|
||||
case tok.kind == tkVar:
|
||||
return 3
|
||||
case end-start == 1:
|
||||
return 2
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
// idents lists the qualified identifiers in a token range: the columns an
|
||||
// expression reads.
|
||||
func (t text) idents(start, end int) []token {
|
||||
var out []token
|
||||
for i := start; i < end; i++ {
|
||||
if tok := t.toks[i]; tok.kind == tkIdent && len(tok.parts) >= 2 {
|
||||
out = append(out, tok)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// slice is the source text of a token range.
|
||||
func (t text) slice(start, end int) string {
|
||||
if start >= end {
|
||||
return ""
|
||||
}
|
||||
return t.src[t.toks[start].start:t.toks[end-1].end]
|
||||
}
|
||||
|
||||
// quote backticks an identifier.
|
||||
func quote(name string) string {
|
||||
return "`" + strings.ReplaceAll(name, "`", "``") + "`"
|
||||
}
|
||||
87
internal/goldeneye/mysql/trace.go
Normal file
87
internal/goldeneye/mysql/trace.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// trace is what the optimizer trace says about a statement: the text of
|
||||
// each query block after name resolution and before optimisation, by the
|
||||
// number MySQL gives the block, and the block each derived table or CTE is
|
||||
// materialised from.
|
||||
//
|
||||
// The trace is the one place MySQL prints a query block as it was resolved
|
||||
// rather than as it was optimised. The note EXPLAIN leaves is printed
|
||||
// after optimisation, and a lookup on a unique key against an empty table
|
||||
// folds to `NULL = (@x)` there, losing the column the parameter was
|
||||
// compared with; the expanded_query of the block still has it.
|
||||
type trace struct {
|
||||
blocks map[int]text
|
||||
derived map[string]int
|
||||
}
|
||||
|
||||
// parseTrace reads the JSON of the OPTIMIZER_TRACE table's TRACE column.
|
||||
func parseTrace(blob string) (trace, error) {
|
||||
tr := trace{blocks: map[int]text{}, derived: map[string]int{}}
|
||||
if strings.TrimSpace(blob) == "" {
|
||||
return tr, nil
|
||||
}
|
||||
var v any
|
||||
if err := json.Unmarshal([]byte(blob), &v); err != nil {
|
||||
return tr, fmt.Errorf("reading the optimizer trace: %w", err)
|
||||
}
|
||||
tr.walk(v, 0)
|
||||
return tr, nil
|
||||
}
|
||||
|
||||
// walk collects the first expanded_query printed for each block — the one
|
||||
// join_preparation prints, before join_optimization prints its rewrite —
|
||||
// and the derived tables. Lists are walked in order and objects in key
|
||||
// order, so the result does not depend on map iteration.
|
||||
func (tr *trace) walk(v any, sel int) {
|
||||
switch v := v.(type) {
|
||||
case map[string]any:
|
||||
if n, ok := v["select#"].(float64); ok {
|
||||
sel = int(n)
|
||||
}
|
||||
if d, ok := v["derived"].(map[string]any); ok {
|
||||
if table, ok := d["table"].(string); ok {
|
||||
if n, ok := d["select#"].(float64); ok {
|
||||
tr.derived[strings.Trim(table, "` ")] = int(n)
|
||||
}
|
||||
}
|
||||
}
|
||||
if q, ok := v["expanded_query"].(string); ok {
|
||||
if _, seen := tr.blocks[sel]; !seen {
|
||||
tr.blocks[sel] = tokenize(q)
|
||||
}
|
||||
}
|
||||
keys := make([]string, 0, len(v))
|
||||
for k := range v {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, k := range keys {
|
||||
tr.walk(v[k], sel)
|
||||
}
|
||||
case []any:
|
||||
for _, c := range v {
|
||||
tr.walk(c, sel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// order lists the block numbers innermost first: a block's text includes
|
||||
// its subqueries' text, and a subquery is numbered after the block it is
|
||||
// in, so the block a parameter belongs to is the highest-numbered block
|
||||
// whose text has it.
|
||||
func (tr *trace) order() []int {
|
||||
sels := make([]int, 0, len(tr.blocks))
|
||||
for sel := range tr.blocks {
|
||||
sels = append(sels, sel)
|
||||
}
|
||||
sort.Sort(sort.Reverse(sort.IntSlice(sels)))
|
||||
return sels
|
||||
}
|
||||
Reference in New Issue
Block a user