Files
2026-07-13 12:32:25 +08:00

1066 lines
34 KiB
Go

// Copyright 2020 Dolthub, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package enginetest
import (
"context"
gosql "database/sql"
"fmt"
"io"
"regexp"
"runtime"
"strings"
"testing"
"time"
"github.com/cockroachdb/errors"
"github.com/dolthub/dolt/go/libraries/doltcore/sqle/dsess"
denginetest "github.com/dolthub/dolt/go/libraries/doltcore/sqle/enginetest"
"github.com/dolthub/dolt/go/libraries/utils/svcs"
gms "github.com/dolthub/go-mysql-server"
"github.com/dolthub/go-mysql-server/enginetest"
"github.com/dolthub/go-mysql-server/enginetest/queries"
"github.com/dolthub/go-mysql-server/enginetest/scriptgen/setup"
"github.com/dolthub/go-mysql-server/sql"
"github.com/dolthub/go-mysql-server/sql/analyzer"
gmstypes "github.com/dolthub/go-mysql-server/sql/types"
vitess "github.com/dolthub/vitess/go/vt/sqlparser"
_ "github.com/jackc/pgx/v4/stdlib"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/lib/pq/oid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
gmserrors "gopkg.in/src-d/go-errors.v1"
"github.com/dolthub/doltgresql/server"
"github.com/dolthub/doltgresql/servercfg"
"github.com/dolthub/doltgresql/servercfg/cfgdetails"
)
type DoltgresHarness struct {
t *testing.T
setupData []setup.SetupScript
skippedQueries []string
queryEngine *DoltgresQueryEngine
parallelism int
skipSetupCommit bool
configureStats bool
useLocalFilesystem bool
}
var _ denginetest.DoltEnginetestHarness = &DoltgresHarness{}
var _ enginetest.SkippingHarness = &DoltgresHarness{}
var _ enginetest.ResultEvaluationHarness = &DoltgresHarness{}
var _ enginetest.DialectHarness = &DoltgresHarness{}
func (d *DoltgresHarness) ValidateEngine(ctx *sql.Context, e *gms.Engine) error {
// TODO
return nil
}
func (d *DoltgresHarness) UseLocalFileSystem() {
d.useLocalFilesystem = true
}
func (d *DoltgresHarness) Dialect() string {
return "postgres"
}
func (d *DoltgresHarness) Session() *dsess.DoltSession {
panic("implement me")
}
func (d *DoltgresHarness) WithConfigureStats(configureStats bool) denginetest.DoltEnginetestHarness {
nd := *d
nd.configureStats = configureStats
return &nd
}
func (d *DoltgresHarness) NewHarness(t *testing.T) denginetest.DoltEnginetestHarness {
// Each harness owns a server on a single fixed port, so we must shut down the
// previous one before standing up its replacement to avoid a port conflict.
d.Close()
h := newDoltgresServerHarness(t).(*DoltgresHarness)
h.skippedQueries = d.skippedQueries
h.setupData = d.setupData
return h
}
// newDoltgresServerHarness creates a new harness for testing Dolt, using an in-memory filesystem and an in-memory blob store.
func newDoltgresServerHarness(t *testing.T) denginetest.DoltEnginetestHarness {
server.ForceTextWireFormat = true // All of our hacks fail when using the binary wire format
dh := &DoltgresHarness{
t: t,
skippedQueries: defaultSkippedQueries,
}
return dh
}
var defaultSkippedQueries = []string{
"show variables", // we set extra variables
"show create table fk_tbl", // we create an extra key for the FK that vanilla gms does not
"show indexes from", // we create / expose extra indexes (for foreign keys)
"show global variables like", // we set extra variables
"show columns from", // MySQL SHOW variant with no PostgreSQL equivalent
"show extended columns from", // MySQL SHOW variant with no PostgreSQL equivalent
// unsupported doltgres syntax
// " WITH ",
// " OVER ",
// string functions are broken due to incompatible types
"HEX(",
"TO_BASE64(",
// MySQL-specific functions not supported in doltgresql
"json_unquote", // MySQL JSON function
"year(", // YEAR() functional index hits a type-incompatibility in doltgresql
// MySQL-specific operators and syntax not supported in PostgreSQL
"<=>", // null-safe equality (PostgreSQL uses IS NOT DISTINCT FROM)
"modify column", // MySQL ALTER TABLE MODIFY COLUMN syntax
"column first", // MySQL ADD COLUMN ... FIRST positioning
}
// Setup sets the setup scripts for this DoltHarness's engine
func (d *DoltgresHarness) Setup(setupData ...[]setup.SetupScript) {
d.setupData = nil
for i := range setupData {
d.setupData = append(d.setupData, setupData[i]...)
}
}
func (d *DoltgresHarness) SkipSetupCommit() {
d.skipSetupCommit = true
}
// NewEngine creates a new *gms.Engine or calls reset and clear scripts on the existing
// engine for reuse.
func (d *DoltgresHarness) NewEngine(t *testing.T) (enginetest.QueryEngine, error) {
if d.queryEngine != nil {
err := d.queryEngine.Close()
if err != nil {
return nil, err
}
}
queryEngine := NewDoltgresQueryEngine(t, d)
d.queryEngine = queryEngine
ctx := d.NewContext()
for _, setupScript := range d.getSetupData() {
for _, s := range setupScript {
runQuery, sanitized := sanitizeQuery(s)
if !runQuery {
t.Log("Skipping setup query: ", s)
continue
}
// Honor per-test skip patterns during setup too. This lets a
// test that needs most of a setup script but not one particular
// query (e.g. a CREATE VIEW that uses MySQL-only syntax) skip
// just that query via WithSkippedQueries rather than rebuilding
// the entire setup.
if d.SkipQueryTest(sanitized) {
t.Log("Skipping setup query (per-test skip): ", s)
continue
}
t.Log("Running setup query: ", s)
_, rowIter, _, err := queryEngine.Query(ctx, sanitized)
if err != nil {
return nil, err
}
err = drainIter(ctx, rowIter)
if err != nil {
return nil, err
}
}
}
dbs := d.allDatabaseNames(ctx, queryEngine)
for _, setupScript := range commitScripts(dbs) {
for _, s := range setupScript {
runQuery, sanitized := sanitizeQuery(s)
if !runQuery {
t.Log("Skipping setup query: ", s)
continue
} else {
t.Log("Running setup query: ", s)
}
_, rowIter, _, err := queryEngine.Query(ctx, sanitized)
if err != nil {
return nil, err
}
err = drainIter(ctx, rowIter)
if err != nil {
return nil, err
}
}
}
return queryEngine, nil
}
func (d *DoltgresHarness) getSetupData() []setup.SetupScript {
// The way we construct and initialize the database and engine is convoluted. In dolt, this happens in the
// enginetest package in GMS, but we take a slightly different codepath, so we need to do this here.
if len(d.setupData) == 0 {
return setup.MydbData
}
return d.setupData
}
// commitScripts returns a set of queries that will commit the working sets of the given database names
func commitScripts(dbs []string) []setup.SetupScript {
var commitCmds setup.SetupScript
for i := range dbs {
db := dbs[i]
commitCmds = append(commitCmds, fmt.Sprintf("use %s", db))
commitCmds = append(commitCmds, "call dolt_add('.')")
commitCmds = append(commitCmds, fmt.Sprintf("call dolt_commit('--allow-empty', '-am', 'checkpoint enginetest database %s', '--date', '1970-01-01T12:00:00')", db))
}
commitCmds = append(commitCmds, "use mydb")
return []setup.SetupScript{commitCmds}
}
var skippedSetupWords = []string{
"typestable", // lots of work to do
"datetime_table", // invalid timestamp format
"analyze table", // unsupported syntax
}
var commentClause = regexp.MustCompile(`(?i)comment '.*?'`)
// sanitizeQuery strips the query string given of any unsupported constructs without attempting to actually convert
// to Postgres syntax.
func sanitizeQuery(s string) (bool, string) {
for _, word := range skippedSetupWords {
if strings.Contains(s, word) {
return false, ""
}
}
s = commentClause.ReplaceAllString(s, "")
return true, s
}
func drainIter(ctx *sql.Context, rowIter sql.RowIter) error {
for {
_, err := rowIter.Next(ctx)
if err == io.EOF {
break
} else if err != nil {
return err
}
}
return rowIter.Close(ctx)
}
// WithParallelism returns a copy of the harness with parallelism set to the given number of threads. A value of 0 or
// less means to use the system parallelism settings.
func (d *DoltgresHarness) WithParallelism(parallelism int) denginetest.DoltEnginetestHarness {
nd := *d
nd.parallelism = parallelism
return &nd
}
// WithSkippedQueries returns a copy of the harness with the given queries skipped
func (d *DoltgresHarness) WithSkippedQueries(queries []string) denginetest.DoltEnginetestHarness {
nd := *d
nd.skippedQueries = append(d.skippedQueries, queries...)
return &nd
}
func (d *DoltgresHarness) Engine() *gms.Engine {
panic("implement me")
}
// SkipQueryTest returns whether to skip a query
func (d *DoltgresHarness) SkipQueryTest(query string) bool {
lowerQuery := strings.ToLower(query)
for _, skipped := range append(d.skippedQueries, skippedSetupWords...) {
if strings.Contains(lowerQuery, strings.ToLower(skipped)) {
return true
}
}
return false
}
func (d *DoltgresHarness) Parallelism() int {
if d.parallelism <= 0 {
// always test with some parallelism
parallelism := runtime.NumCPU()
if parallelism <= 1 {
parallelism = 2
}
return parallelism
}
return d.parallelism
}
func (d *DoltgresHarness) NewContext() *sql.Context {
return sql.NewEmptyContext()
}
func (d *DoltgresHarness) NewContextWithClient(client sql.Client) *sql.Context {
// unused for now, linter is complaining
// return sql.NewContext(context.Background(), sql.WithSession(d.newSessionWithClient(client)))
panic("implement me")
}
func (d *DoltgresHarness) NewSession() *sql.Context {
panic("implement me")
}
func (d *DoltgresHarness) SupportsNativeIndexCreation() bool {
return true
}
func (d *DoltgresHarness) SupportsForeignKeys() bool {
return true
}
func (d *DoltgresHarness) SupportsKeylessTables() bool {
return true
}
func (d *DoltgresHarness) NewDatabases(names ...string) []sql.Database {
panic("implement me")
}
func (d *DoltgresHarness) NewReadOnlyEngine(provider sql.DatabaseProvider) (enginetest.QueryEngine, error) {
// TODO: toggle the server to be read-only
d.Close()
return d.NewEngine(d.t)
}
func (d *DoltgresHarness) NewDatabaseProvider() sql.MutableDatabaseProvider {
panic("implement me")
}
func (d *DoltgresHarness) Close() {
if d.queryEngine != nil {
err := d.queryEngine.Close()
if err != nil {
d.t.Fatal(err)
}
}
}
// NewTableAsOf implements enginetest.VersionedHarness
// Dolt doesn't version tables per se, just the entire database. So ignore the name and schema and just create a new
// branch with the given name.
func (d *DoltgresHarness) NewTableAsOf(db sql.VersionedDatabase, name string, schema sql.PrimaryKeySchema, asOf interface{}) sql.Table {
panic("implement me")
}
// SnapshotTable implements enginetest.VersionedHarness
// Dolt doesn't version tables per se, just the entire database. So ignore the name and schema and just create a new
// branch with the given name.
func (d *DoltgresHarness) SnapshotTable(db sql.VersionedDatabase, tableName string, asOf interface{}) error {
panic("implement me")
}
func (d *DoltgresHarness) EvaluateQueryResults(t *testing.T, expected []sql.Row, expectedCols []*sql.Column, sch sql.Schema, rows []sql.Row, q string, wrapBehavior queries.WrapBehavior) {
widenedRows := enginetest.WidenRows(t, sch, rows)
widenedExpected := enginetest.WidenRows(t, sch, expected)
upperQuery := strings.ToUpper(q)
orderBy := strings.Contains(upperQuery, "ORDER BY ")
isNilOrEmptySchema := len(sch) == 0
// We replace all times for SHOW statements with the Unix epoch except for SHOW EVENTS
setZeroTime := strings.HasPrefix(upperQuery, "SHOW ") && !strings.Contains(upperQuery, "EVENTS")
for _, widenedRow := range widenedRows {
for i, val := range widenedRow {
switch v := val.(type) {
case time.Time:
if setZeroTime {
widenedRow[i] = time.Unix(0, 0).UTC()
}
case sql.AnyWrapper:
switch wrapBehavior {
case queries.WrapBehavior_Unwrap:
var err error
widenedRow[i], err = sql.UnwrapAny(context.Background(), v)
require.NoError(t, err)
case queries.WrapBehavior_Hash:
widenedRow[i] = v.Hash()
}
}
}
}
// if the sch is nil or empty, over the wire result is no row whereas single empty row is expected.
// This happens for SET and SELECT INTO statements.
if isNilOrEmptySchema && len(widenedRows) == 0 && len(widenedExpected) == 1 && len(widenedExpected[0]) == 0 {
widenedExpected = widenedRows
}
switch true {
case convertExpectedResultsForDoltProcedures(t, q, widenedExpected, widenedRows):
case convertCountStarDoltLog(t, q, widenedExpected, widenedRows):
case convertShowCreateTableExpected(t, q, widenedExpected):
// widenedExpected modified in place
default:
// The expected results that need widening before checking against actual results.
widenExpectedRows(t, q, widenedExpected, sch, widenedRows, isNilOrEmptySchema)
}
// .Equal gives better error messages than .ElementsMatch, so use it when possible
if orderBy || len(expected) <= 1 {
assert.Equal(t, widenedExpected, widenedRows, "Unexpected result for query %s", q)
} else {
assert.ElementsMatch(t, widenedExpected, widenedRows, "Unexpected result for query %s", q)
}
// If the expected schema was given, test it as well
// TODO: handle expected schema
// if expectedCols != nil && !isServerEngine {
// assert.Equal(t, simplifyResultSchema(expectedCols), simplifyResultSchema(sch))
// }
}
func convertCountStarDoltLog(t *testing.T, q string, expected []sql.Row, rows []sql.Row) bool {
// doltgres setup involves one additional commit in the commit history
if strings.ToLower(q) == "select count(*) from dolt_log" {
switch count := expected[0][0].(type) {
case int64:
expected[0][0] = count + 1
case int32:
expected[0][0] = count + 1
case int:
expected[0][0] = count + 1
}
return true
}
return false
}
// showCreateTableQueryRegex matches `SHOW CREATE TABLE`, optionally prefixed
// with whitespace and possibly continuation tokens. The match is anchored at
// the start of the query and is case-insensitive.
var showCreateTableQueryRegex = regexp.MustCompile(`(?i)^\s*show\s+create\s+table\b`)
// convertShowCreateTableExpected rewrites the MySQL-style CREATE TABLE
// text in the expected rows of a SHOW CREATE TABLE assertion into the
// doltgres/postgres dialect, so the test framework can compare it directly
// against what the server emits. The rewrite is intentionally textual: the
// expected values in the test queries are hand-authored MySQL strings, and
// re-parsing/re-emitting them through a tree formatter would lose the
// formatting (newlines, indentation) the tests check.
//
// Translations performed:
// - Backtick-quoted identifiers become double-quoted ones.
// - MySQL type names become their postgres equivalents (int → integer,
// tinyint/smallint → smallint, mediumint → integer, blob → bytea,
// datetime → timestamp, char(N) → bpchar(N), double → double precision,
// decimal(N,M) → numeric, float → real).
// - The MySQL `KEY name (cols)` (non-unique secondary index) line is
// dropped — postgres represents these as separate CREATE INDEX
// statements outside the table body.
// - `UNIQUE KEY name (cols)` becomes `CONSTRAINT "name" UNIQUE ("cols")`.
// - The trailing `) ENGINE=InnoDB DEFAULT CHARSET=… COLLATE=…` clause
// becomes a bare `)`.
// - The MySQL `DEFAULT CURRENT_TIMESTAMP` form becomes `DEFAULT (now())`.
// - Doubled parentheses around a DEFAULT expression `((expr))` become
// `(expr)` (MySQL's `((7 + 11))` vs postgres' `(7 + 11)`).
// - A trailing comma left dangling after we drop a KEY clause is removed.
func convertShowCreateTableExpected(t *testing.T, q string, expected []sql.Row) bool {
if !showCreateTableQueryRegex.MatchString(q) {
return false
}
for i := range expected {
// We expect rows of shape (table_name, create_statement).
if len(expected[i]) < 2 {
continue
}
s, ok := expected[i][1].(string)
if !ok {
continue
}
expected[i][1] = translateMysqlShowCreateTable(s)
}
return true
}
// reEngineSuffix matches the `) ENGINE=… COLLATE=…` tail.
var reEngineSuffix = regexp.MustCompile(`\)\s+ENGINE=[^\n]*$`)
// reUniqueKey matches `UNIQUE KEY \`name\` (cols)` lines emitted by MySQL.
var reUniqueKey = regexp.MustCompile("(?m)^(\\s*)UNIQUE KEY `([^`]+)` \\(([^)]+)\\)")
// reKeyLine matches a non-unique `KEY \`name\` (cols)` line.
var reKeyLine = regexp.MustCompile("(?m)^\\s*KEY `[^`]+` \\([^)]+\\),?\n")
// reDoubleParenDefault matches a DEFAULT clause wrapped in two layers of
// parentheses, like `DEFAULT ((7 + 11))`.
var reDoubleParenDefault = regexp.MustCompile(`DEFAULT \(\(([^()]+)\)\)`)
// reBacktickIdent matches a backtick-quoted MySQL identifier — we replace
// these with double-quoted postgres identifiers.
var reBacktickIdent = regexp.MustCompile("`([^`]*)`")
// mysqlToPostgresTypes lists the MySQL → postgres type-name substitutions
// applied to the SHOW CREATE TABLE body. Order matters: we want the longer
// names matched first so e.g. `mediumint` doesn't get mangled by the rule
// for `int`.
var mysqlToPostgresTypes = []struct {
re *regexp.Regexp
repl string
}{
// Composite/multi-word types first.
{regexp.MustCompile(`(?i)\bdouble\b`), "double precision"},
{regexp.MustCompile(`(?i)\bdecimal\(\d+,\d+\)`), "numeric"},
{regexp.MustCompile(`(?i)\bdecimal\(\d+\)`), "numeric"},
{regexp.MustCompile(`(?i)\bdecimal\b`), "numeric"},
// Integer family.
{regexp.MustCompile(`(?i)\btinyint\b`), "smallint"},
{regexp.MustCompile(`(?i)\bmediumint\b`), "integer"},
{regexp.MustCompile(`(?i)\bbigint\b`), "bigint"},
{regexp.MustCompile(`(?i)\bsmallint\b`), "smallint"},
{regexp.MustCompile(`(?i)\bint\b`), "integer"},
// Floating point.
{regexp.MustCompile(`(?i)\bfloat\b`), "real"},
// Strings / bytes.
{regexp.MustCompile(`(?i)\btinyblob\b`), "bytea"},
{regexp.MustCompile(`(?i)\bmediumblob\b`), "bytea"},
{regexp.MustCompile(`(?i)\blongblob\b`), "bytea"},
{regexp.MustCompile(`(?i)\bblob\b`), "bytea"},
{regexp.MustCompile(`(?i)\btinytext\b`), "text"},
{regexp.MustCompile(`(?i)\bmediumtext\b`), "text"},
{regexp.MustCompile(`(?i)\blongtext\b`), "text"},
{regexp.MustCompile(`(?i)\bchar\((\d+)\)`), "bpchar($1)"},
// Date/time.
{regexp.MustCompile(`(?i)\bdatetime\b`), "timestamp"},
}
func translateMysqlShowCreateTable(s string) string {
// 1. Drop the MySQL ENGINE / CHARSET / COLLATE suffix.
s = reEngineSuffix.ReplaceAllString(s, ")")
// 2. Strip non-unique KEY lines entirely; postgres represents these
// as separate CREATE INDEX statements.
s = reKeyLine.ReplaceAllString(s, "")
// 3. Convert UNIQUE KEY clauses to CONSTRAINT ... UNIQUE form.
s = reUniqueKey.ReplaceAllString(s, "${1}CONSTRAINT `${2}` UNIQUE (${3})")
// 4. Apply type-name substitutions before we lose backticks (the regexes
// use word boundaries that don't care about backticks).
for _, sub := range mysqlToPostgresTypes {
s = sub.re.ReplaceAllString(s, sub.repl)
}
// 5. DEFAULT CURRENT_TIMESTAMP → DEFAULT (now()) and unwrap doubled parens.
s = strings.ReplaceAll(s, "DEFAULT CURRENT_TIMESTAMP", "DEFAULT (now())")
s = reDoubleParenDefault.ReplaceAllString(s, "DEFAULT ($1)")
// 6. Backticks → double quotes.
s = reBacktickIdent.ReplaceAllString(s, `"$1"`)
// 7. If we dropped a KEY line in the middle of the table body, we may
// have left a stranded trailing comma on the line above the closing
// parenthesis. Trim it.
s = regexp.MustCompile(`,(\s*\n\s*\))`).ReplaceAllString(s, "$1")
return s
}
func widenExpectedRows(t *testing.T, q string, expected []sql.Row, sch sql.Schema, actual []sql.Row, isNilOrEmptySchema bool) {
ctx := context.Background()
for i, row := range expected {
for j := range sch {
field := row[j]
// Special case for custom values
if cvv, isCustom := field.(enginetest.CustomValueValidator); isCustom {
if i >= len(actual) {
continue
}
actual := actual[i][j] // shouldn't panic, but fine if it does
ok, err := cvv.Validate(actual)
if err != nil {
t.Error(err.Error())
}
if !ok {
t.Errorf("Custom value validation, got %v", actual)
}
expected[i][j] = actual // ensure it passes equality check later
}
if isNilOrEmptySchema {
continue
}
convertedExpected, _, err := sch[j].Type.Convert(ctx, expected[i][j])
require.NoError(t, err)
expected[i][j] = convertedExpected
}
expected[i] = enginetest.WidenRow(t, sch, expected[i])
// OK results from GMS manifest as a nil schema in postgres, only accessible via command tags
if isNilOrEmptySchema && len(expected[i]) == 1 {
if okResult, isOkResult := expected[i][0].(gmstypes.OkResult); isOkResult {
// we can't verify the custom text fields of things like update results, so we strip out that info
expected[i][0] = gmstypes.NewOkResult(int(okResult.RowsAffected))
// there are other Postgres queries that lack a row count
if strings.HasPrefix(strings.ToLower(q), "truncate") {
expected[i][0] = gmstypes.NewOkResult(0)
}
}
}
}
}
func convertExpectedResultsForDoltProcedures(t *testing.T, q string, widenedExpected []sql.Row, widenedActual []sql.Row) bool {
if doltProcedureCall.MatchString(q) {
// if this was a dolt procedure call, we need to convert the expected values to what doltgres currently outputs
// TODO: this can be removed when we support `select * from dolt_procedure_call(...)`
for i := range widenedExpected {
r := widenedExpected[i]
sb := strings.Builder{}
sb.WriteRune('{')
for j, val := range r {
if j > 0 {
sb.WriteRune(',')
}
switch v := val.(type) {
case string:
// Quoting here is wrong in several ways, but we need to match the current output
sb.WriteString("\"")
sb.WriteString(v)
sb.WriteString("\"")
case int64, uint64:
sb.WriteString(fmt.Sprintf("%d", v))
case float64:
sb.WriteString(fmt.Sprintf("%f", v))
case bool:
if v {
sb.WriteString("t")
} else {
sb.WriteString("f")
}
case time.Time:
sb.WriteString(v.Format("2006-01-02 15:04:05.999999999"))
case enginetest.CustomValueValidator:
// This is a hack, but in practice there's only a single implementation of this interface, used by dolt
v = &doltCommitValidator{}
actual := widenedActual[i][j]
ok, err := v.Validate(actual)
if err != nil {
t.Error(err.Error())
}
if !ok {
t.Errorf("Custom value validation, got %v", actual)
}
if dcv, ok := v.(*doltCommitValidator); ok {
ok, hash := dcv.CommitHash(actual)
if !ok {
t.Errorf("Custom value validation, got %v", actual)
}
sb.WriteString(hash)
} else {
sb.WriteString(fmt.Sprintf("%v", strings.Trim(actual.(string), "{}")))
}
default:
t.Fatalf("unexpected type %T", val)
}
}
sb.WriteRune('}')
widenedExpected[i] = []interface{}{sb.String()}
}
return true
}
return false
}
// EvaluateExpectedError is a harness extension that gives us more control over matching expected errors. Our error
// strings after being transmitted through the server are slightly different than the vanilla gms ones.
func (d *DoltgresHarness) EvaluateExpectedError(t *testing.T, expected string, err error) {
assert.Contains(t, err.Error(), expected)
}
// EvaluateExpectedErrorKind is a harness extension that gives us more control over matching expected errors. We don't
// have access to the error kind object eny longer, so we have to see if the error we get matches its pattern
func (d *DoltgresHarness) EvaluateExpectedErrorKind(t *testing.T, expected *gmserrors.Kind, actualErr error) {
pattern := strings.ReplaceAll(expected.Message, "*", "\\*")
pattern = strings.ReplaceAll(pattern, "(", "\\(")
pattern = strings.ReplaceAll(pattern, ")", "\\)")
pattern = strings.ReplaceAll(pattern, "%d", "\\d+")
pattern = strings.ReplaceAll(pattern, "%s", ".+")
pattern = strings.ReplaceAll(pattern, "%q", "\".+\"")
pattern = strings.ReplaceAll(pattern, "%v", ".+?")
regex, regexErr := regexp.Compile(pattern)
require.NoError(t, regexErr)
assert.Regexp(t, regex, actualErr.Error())
}
func (d *DoltgresHarness) allDatabaseNames(ctx *sql.Context, queryEngine *DoltgresQueryEngine) []string {
_, rowIter, _, err := queryEngine.Query(ctx, "SELECT datname FROM pg_database")
if err != nil {
d.t.Fatalf("error getting database names: %v", err)
}
var dbs []string
for {
r, err := rowIter.Next(ctx)
if err == io.EOF {
break
} else if err != nil {
d.t.Fatalf("error getting database names: %v", err)
}
dbName := r[0].(string)
dbs = append(dbs, dbName)
}
_ = rowIter.Close(ctx)
return dbs
}
type DoltgresQueryEngine struct {
harness *DoltgresHarness
controller *svcs.Controller
conn *pgx.Conn
}
var _ enginetest.QueryEngine = &DoltgresQueryEngine{}
// Ptr is a helper function that returns a pointer to the value passed in. This is necessary to e.g. get a pointer to
// a const value without assigning to an intermediate variable.
func Ptr[T any](v T) *T {
return &v
}
const port = 5433
func NewDoltgresQueryEngine(t *testing.T, harness *DoltgresHarness) *DoltgresQueryEngine {
ctrl, err := server.RunInMemory(&servercfg.DoltgresConfig{
DoltgresConfig: cfgdetails.DoltgresConfig{
LogLevelStr: Ptr("debug"),
ListenerConfig: &cfgdetails.DoltgresListenerConfig{
PortNumber: Ptr(port),
},
},
}, server.NewListener)
require.NoError(t, err)
return &DoltgresQueryEngine{
harness: harness,
controller: ctrl,
}
}
func (d *DoltgresQueryEngine) PrepareQuery(s *sql.Context, s2 string) (sql.Node, error) {
panic("implement me")
}
func (d *DoltgresQueryEngine) AnalyzeQuery(s *sql.Context, s2 string) (sql.Node, error) {
panic("implement me")
}
// TODO: random port
var doltgresNoDbDsn = fmt.Sprintf("postgresql://postgres:password@127.0.0.1:%d/?sslmode=disable", port)
func (d *DoltgresQueryEngine) Query(ctx *sql.Context, query string) (sql.Schema, sql.RowIter, *sql.QueryFlags, error) {
db, err := d.getConnection()
if err != nil {
return nil, nil, nil, err
}
queries := convertQuery(query)
// convertQuery may return more than one query in the case of some DDL operations that can be represented as a single
// statement in MySQL but not in Postgres. We always return the result from only the first one, but execute all of
// them.
var (
resultSchema sql.Schema
resultRows []sql.Row
)
for _, query := range queries {
rows, err := db.Query(context.Background(), query)
if err != nil {
return nil, nil, nil, err
}
if rows == nil {
return nil, nil, nil, errors.Errorf("rows is nil")
}
if rows.Err() != nil {
return nil, nil, nil, rows.Err()
}
defer rows.Close()
schema, columns, err := columns(rows)
if err != nil {
return nil, nil, nil, err
}
results := make([]sql.Row, 0)
for rows.Next() {
rows.Scan(columns...)
row, err := toRow(ctx, schema, columns)
if err != nil {
return nil, nil, nil, err
}
results = append(results, row)
}
if resultRows == nil {
resultSchema = schema
resultRows = results
}
rows.Close()
if rows.Err() != nil {
return nil, nil, nil, rows.Err()
}
if dmlResult, ok := getDmlResult(rows, query); ok {
// we can only capture the last command tag in the case there were multiple queries
resultRows = []sql.Row{dmlResult}
}
}
return resultSchema, sql.RowsToRowIter(resultRows...), nil, nil
}
var emptyCommandTag = pgconn.NewCommandTag("")
// getDmlResult returns a Row representing the result of a DML operation, or nil if the operation was not a DML operation.
func getDmlResult(rows pgx.Rows, query string) (sql.Row, bool) {
tag := rows.CommandTag()
if tag == emptyCommandTag {
return nil, false
}
switch true {
case tag.Insert():
// The engine tests are currently all MySQL based, which doesn't support the RETURNING clause. If we decide
// to support this in the future, we will have to do so here.
return sql.NewRow(gmstypes.NewOkResult(int(tag.RowsAffected()))), true
case tag.Update():
return sql.NewRow(gmstypes.NewOkResult(int(tag.RowsAffected()))), true
case tag.Delete():
return sql.NewRow(gmstypes.NewOkResult(int(tag.RowsAffected()))), true
case strings.HasPrefix(tag.String(), "RENAME TABLE"):
return sql.NewRow(gmstypes.NewOkResult(0)), true
case strings.HasPrefix(tag.String(), "DROP TABLE"):
return sql.NewRow(gmstypes.NewOkResult(0)), true
case strings.HasPrefix(tag.String(), "CREATE TABLE"):
return sql.NewRow(gmstypes.NewOkResult(0)), true
case strings.HasPrefix(tag.String(), "ALTER TABLE"):
return sql.NewRow(gmstypes.NewOkResult(0)), true
case strings.HasPrefix(tag.String(), "CREATE INDEX"):
return sql.NewRow(gmstypes.NewOkResult(0)), true
case strings.HasPrefix(tag.String(), "DROP INDEX"):
return sql.NewRow(gmstypes.NewOkResult(0)), true
case strings.HasPrefix(tag.String(), "TRUNCATE"):
return sql.NewRow(gmstypes.NewOkResult(0)), true
case strings.HasPrefix(tag.String(), "SET"):
// for some reason, `USE <db>` queries have a SET command tag?
if strings.HasPrefix(strings.ToLower(query), "use") {
return nil, false
}
return sql.NewRow(gmstypes.NewOkResult(0)), true
default:
return nil, false
}
}
func (d *DoltgresQueryEngine) getConnection() (*pgx.Conn, error) {
if d.conn != nil {
return d.conn, nil
}
config, err := pgx.ParseConfig(doltgresNoDbDsn)
if err != nil {
return nil, err
}
// pgx v5.9.1+ builds scan plans from the client's requested result format codes rather
// than the server's RowDescription. Our server uses ForceTextWireFormat which sends text
// regardless of what the client requests, so we must match that here by telling pgx not
// to request binary format. Simple protocol never sends binary result format codes.
config.DefaultQueryExecMode = pgx.QueryExecModeSimpleProtocol
db, err := pgx.ConnectConfig(context.Background(), config)
if err != nil {
return nil, err
}
d.conn = db
return d.conn, nil
}
func toRow(ctx *sql.Context, schema sql.Schema, r []interface{}) (sql.Row, error) {
row := make(sql.Row, len(schema))
for i, col := range schema {
val, err := unwrapResultColumn(r[i])
if err != nil {
return nil, err
}
row[i], _, err = col.Type.Convert(ctx, val)
if err != nil {
return nil, err
}
}
return row, nil
}
func unwrapResultColumn(v any) (any, error) {
switch v := v.(type) {
case *gosql.NullBool:
if v.Valid {
return v.Bool, nil
}
return nil, nil
case *gosql.NullString:
if v.Valid {
return v.String, nil
}
return nil, nil
case *gosql.NullFloat64:
if v.Valid {
return v.Float64, nil
}
return nil, nil
case *gosql.NullInt64:
if v.Valid {
return v.Int64, nil
}
return nil, nil
case *gosql.NullTime:
if v.Valid {
return v.Time, nil
}
return nil, nil
case *gosql.NullInt32:
if v.Valid {
return v.Int32, nil
}
return nil, nil
case *gosql.NullInt16:
if v.Valid {
return v.Int16, nil
}
return nil, nil
default:
return nil, errors.Errorf("unsupported type %T", v)
}
}
// IsServerBacked implements enginetest.ServerBackedEngine, marking DoltgresQueryEngine
// as a server-backed engine so that plan-inspection checks (evalIndexTest, etc.) are
// skipped, matching the behavior of GMS's own ServerQueryEngine.
func (d *DoltgresQueryEngine) IsServerBacked() bool { return true }
func (d *DoltgresQueryEngine) EngineAnalyzer() *analyzer.Analyzer {
// TODO: this is a shim to get simple tests to work, we need to restructure the tests to not require access to
// an analyzer
catalog := analyzer.NewCatalog(nil, sql.EngineOverrides{})
return &analyzer.Analyzer{
Catalog: catalog,
}
}
func (d *DoltgresQueryEngine) EngineEventScheduler() sql.EventScheduler {
return nil
}
func (d *DoltgresQueryEngine) QueryWithBindings(ctx *sql.Context, query string, parsed vitess.Statement, bindings map[string]vitess.Expr, qFlags *sql.QueryFlags) (sql.Schema, sql.RowIter, *sql.QueryFlags, error) {
if len(bindings) > 0 {
return nil, nil, nil, errors.Errorf("bindings not supported")
}
return d.Query(ctx, query)
}
func (d *DoltgresQueryEngine) CloseSession(connID uint32) {
// TODO: track connection ids
d.conn = nil
}
func (d *DoltgresQueryEngine) Close() error {
d.conn = nil
d.controller.Stop()
return d.controller.WaitForStop()
}
func columns(rows pgx.Rows) (sql.Schema, []interface{}, error) {
fields := rows.FieldDescriptions()
schema := make(sql.Schema, 0, len(fields))
columnVals := make([]interface{}, 0, len(fields))
for _, field := range fields {
switch field.DataTypeOID {
case uint32(oid.T_bool):
colVal := gosql.NullBool{}
columnVals = append(columnVals, &colVal)
schema = append(schema, &sql.Column{Name: field.Name, Type: gmstypes.Int8, Nullable: true})
case uint32(oid.T_text), uint32(oid.T_varchar), uint32(oid.T_name), uint32(oid.T__text), uint32(oid.T_bpchar):
colVal := gosql.NullString{}
columnVals = append(columnVals, &colVal)
schema = append(schema, &sql.Column{Name: field.Name, Type: gmstypes.LongText, Nullable: true})
case uint32(oid.T_numeric), uint32(oid.T_float8), uint32(oid.T_float4):
colVal := gosql.NullFloat64{}
columnVals = append(columnVals, &colVal)
schema = append(schema, &sql.Column{Name: field.Name, Type: gmstypes.Float64, Nullable: true})
case uint32(oid.T_int2), uint32(oid.T_int4), uint32(oid.T_int8):
colVal := gosql.NullInt64{}
columnVals = append(columnVals, &colVal)
schema = append(schema, &sql.Column{Name: field.Name, Type: gmstypes.Int64, Nullable: true})
case uint32(oid.T_timestamp), uint32(oid.T_time), uint32(oid.T_date):
colVal := gosql.NullTime{}
columnVals = append(columnVals, &colVal)
schema = append(schema, &sql.Column{Name: field.Name, Type: gmstypes.Timestamp, Nullable: true})
case uint32(oid.T_bytea):
colVal := gosql.NullString{}
columnVals = append(columnVals, &colVal)
schema = append(schema, &sql.Column{Name: field.Name, Type: gmstypes.LongBlob, Nullable: true})
case uint32(oid.T_json):
colVal := gosql.NullString{}
columnVals = append(columnVals, &colVal)
schema = append(schema, &sql.Column{Name: field.Name, Type: gmstypes.JSON, Nullable: true})
case uint32(oid.T_unknown): // TODO: this should not be returned
colVal := gosql.NullString{}
columnVals = append(columnVals, &colVal)
schema = append(schema, &sql.Column{Name: field.Name, Type: gmstypes.LongBlob, Nullable: true})
default:
return nil, nil, errors.Errorf("Unhandled OID %d", field.DataTypeOID)
}
}
return schema, columnVals, nil
}