mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-31 07:44:11 +08:00
4654 lines
177 KiB
Go
4654 lines
177 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"database/sql/driver"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"gitea.com/kingbase/gokb"
|
|
)
|
|
|
|
var registerTestDriver sync.Once
|
|
var testDriverState atomic.Pointer[fakeDriverState]
|
|
var registerExpressionFallbackDriver sync.Once
|
|
var expressionFallbackState atomic.Pointer[fallbackDriverState]
|
|
var registerModeDetectionDriver sync.Once
|
|
var modeDetectionState atomic.Pointer[modeDetectionDriverState]
|
|
var registerMetadataDriver sync.Once
|
|
var metadataState atomic.Pointer[metadataDriverState]
|
|
|
|
type fakeDriverState struct {
|
|
mu sync.Mutex
|
|
nextConnID int
|
|
queryArgs int
|
|
queryCtx context.Context
|
|
queryConnID int
|
|
rowCount int
|
|
execStatements []string
|
|
execConnIDs []int
|
|
execErrors map[string]error
|
|
}
|
|
|
|
type fakeDriver struct{}
|
|
|
|
type fakeConn struct {
|
|
id int
|
|
}
|
|
|
|
type fakeRows struct {
|
|
current int
|
|
count int
|
|
}
|
|
|
|
type fallbackDriverState struct {
|
|
mu sync.Mutex
|
|
queries []string
|
|
rejectAttidentity bool
|
|
}
|
|
|
|
type fallbackDriver struct{}
|
|
|
|
type fallbackConn struct {
|
|
state *fallbackDriverState
|
|
}
|
|
|
|
type modeDetectionDriverState struct {
|
|
mu sync.Mutex
|
|
queries []string
|
|
databaseMode *string
|
|
backtickIdentifiers bool
|
|
databaseErr error
|
|
}
|
|
|
|
type modeDetectionDriver struct{}
|
|
|
|
type modeDetectionConn struct {
|
|
state *modeDetectionDriverState
|
|
}
|
|
|
|
type metadataDriverState struct {
|
|
mu sync.Mutex
|
|
queries []string
|
|
query func(string) (driver.Rows, error)
|
|
}
|
|
|
|
type metadataDriver struct{}
|
|
|
|
type metadataConn struct {
|
|
state *metadataDriverState
|
|
}
|
|
|
|
type connectionAttemptState struct {
|
|
mu sync.Mutex
|
|
attempts []string
|
|
dsns []string
|
|
deadlines []time.Time
|
|
pingErrors map[string]error
|
|
}
|
|
|
|
type connectionAttemptConnector struct {
|
|
state *connectionAttemptState
|
|
sslMode string
|
|
}
|
|
|
|
type connectionAttemptDriver struct{}
|
|
|
|
type connectionAttemptConn struct {
|
|
state *connectionAttemptState
|
|
sslMode string
|
|
}
|
|
|
|
type valueRows struct {
|
|
columns []string
|
|
rows [][]driver.Value
|
|
index int
|
|
nextErr error
|
|
closeErr error
|
|
}
|
|
|
|
func (fakeDriver) Open(string) (driver.Conn, error) {
|
|
state := testDriverState.Load()
|
|
state.mu.Lock()
|
|
defer state.mu.Unlock()
|
|
state.nextConnID++
|
|
return fakeConn{id: state.nextConnID}, nil
|
|
}
|
|
|
|
func (fakeConn) Prepare(string) (driver.Stmt, error) { return nil, driver.ErrSkip }
|
|
|
|
func (fakeConn) Close() error { return nil }
|
|
|
|
func (fakeConn) Begin() (driver.Tx, error) { return nil, driver.ErrSkip }
|
|
|
|
func (connection fakeConn) QueryContext(ctx context.Context, _ string, args []driver.NamedValue) (driver.Rows, error) {
|
|
state := testDriverState.Load()
|
|
state.mu.Lock()
|
|
defer state.mu.Unlock()
|
|
state.queryArgs = len(args)
|
|
state.queryCtx = ctx
|
|
state.queryConnID = connection.id
|
|
return &fakeRows{count: state.rowCount}, nil
|
|
}
|
|
|
|
func (connection fakeConn) ExecContext(_ context.Context, query string, _ []driver.NamedValue) (driver.Result, error) {
|
|
state := testDriverState.Load()
|
|
state.mu.Lock()
|
|
defer state.mu.Unlock()
|
|
state.execStatements = append(state.execStatements, query)
|
|
state.execConnIDs = append(state.execConnIDs, connection.id)
|
|
if err := state.execErrors[query]; err != nil {
|
|
return nil, err
|
|
}
|
|
return driver.RowsAffected(1), nil
|
|
}
|
|
|
|
func (fakeRows) Columns() []string { return []string{"value"} }
|
|
|
|
func (fakeRows) Close() error { return nil }
|
|
|
|
func (rows *fakeRows) Next(values []driver.Value) error {
|
|
if rows.current >= rows.count {
|
|
return io.EOF
|
|
}
|
|
rows.current++
|
|
values[0] = int64(rows.current)
|
|
return nil
|
|
}
|
|
|
|
func (fallbackDriver) Open(string) (driver.Conn, error) {
|
|
return &fallbackConn{state: expressionFallbackState.Load()}, nil
|
|
}
|
|
|
|
func (*fallbackConn) Prepare(string) (driver.Stmt, error) { return nil, driver.ErrSkip }
|
|
|
|
func (*fallbackConn) Close() error { return nil }
|
|
|
|
func (*fallbackConn) Begin() (driver.Tx, error) { return nil, driver.ErrSkip }
|
|
|
|
func (connection *fallbackConn) QueryContext(_ context.Context, query string, _ []driver.NamedValue) (driver.Rows, error) {
|
|
connection.state.mu.Lock()
|
|
connection.state.queries = append(connection.state.queries, query)
|
|
connection.state.mu.Unlock()
|
|
if strings.Contains(query, "information_schema.table_constraints") {
|
|
return &valueRows{columns: []string{"column_name"}}, nil
|
|
}
|
|
if strings.Contains(query, "CASE c.relkind") && strings.Contains(query, "obj_description(c.oid)") {
|
|
return &valueRows{
|
|
columns: []string{"table_name", "table_type", "table_comment"},
|
|
rows: [][]driver.Value{{"orders", "BASE TABLE", "orders table"}},
|
|
}, nil
|
|
}
|
|
if strings.Contains(query, "SELECT obj_description(c.oid)") {
|
|
return &valueRows{
|
|
columns: []string{"table_comment"},
|
|
rows: [][]driver.Value{{"orders table"}},
|
|
}, nil
|
|
}
|
|
if strings.Contains(query, "SELECT i.relname, sys_catalog.sys_get_indexdef(") || strings.Contains(query, "SELECT i.relname, pg_catalog.pg_get_indexdef(") {
|
|
return &valueRows{
|
|
columns: []string{"index_name", "index_definition", "index_comment"},
|
|
rows: [][]driver.Value{
|
|
{"orders_id_idx", `CREATE INDEX orders_id_idx ON public.orders USING btree (id)`, "lookup index"},
|
|
},
|
|
}, nil
|
|
}
|
|
if strings.Contains(query, "SELECT sys_catalog.sys_get_triggerdef(tg.oid, true)") || strings.Contains(query, "SELECT pg_catalog.pg_get_triggerdef(tg.oid, true)") {
|
|
return &valueRows{
|
|
columns: []string{"trigger_definition"},
|
|
rows: [][]driver.Value{
|
|
{`CREATE TRIGGER orders_audit BEFORE INSERT ON public.orders FOR EACH ROW EXECUTE FUNCTION audit_orders()`},
|
|
},
|
|
}, nil
|
|
}
|
|
if strings.Contains(query, "FROM information_schema.columns c") {
|
|
return &valueRows{
|
|
columns: []string{"column_name", "data_type", "column_type", "is_nullable", "column_default", "column_comment", "numeric_precision", "numeric_scale", "character_maximum_length"},
|
|
rows: [][]driver.Value{{"id", "integer", "integer", "NO", nil, "primary key", int64(32), int64(0), nil}},
|
|
}, nil
|
|
}
|
|
if connection.state.rejectAttidentity && strings.Contains(query, "a.attidentity") {
|
|
return nil, &gokb.Error{Code: gokb.ErrorCode("42703"), Message: "column a.attidentity does not exist"}
|
|
}
|
|
if strings.Contains(query, "sys_get_expr(") {
|
|
return nil, &gokb.Error{Code: gokb.ErrorCode("42883"), Message: "function sys_get_expr(pg_node_tree, oid) does not exist"}
|
|
}
|
|
if strings.Contains(query, "pg_get_expr(") {
|
|
var identity driver.Value = "d"
|
|
if strings.Contains(query, "CAST(NULL AS varchar(1)) AS attidentity") {
|
|
identity = nil
|
|
}
|
|
return &valueRows{
|
|
columns: []string{"resolved_schema", "column_name", "data_type", "is_nullable", "column_default", "column_comment", "numeric_precision", "numeric_scale", "character_maximum_length", "attidentity"},
|
|
rows: [][]driver.Value{{"public", "id", "integer", false, nil, nil, int64(32), int64(0), nil, identity}},
|
|
}, nil
|
|
}
|
|
return nil, errors.New("unexpected query: " + query)
|
|
}
|
|
|
|
func (modeDetectionDriver) Open(string) (driver.Conn, error) {
|
|
return &modeDetectionConn{state: modeDetectionState.Load()}, nil
|
|
}
|
|
|
|
func (*modeDetectionConn) Prepare(string) (driver.Stmt, error) { return nil, driver.ErrSkip }
|
|
|
|
func (*modeDetectionConn) Close() error { return nil }
|
|
|
|
func (*modeDetectionConn) Begin() (driver.Tx, error) { return nil, driver.ErrSkip }
|
|
|
|
func (connection *modeDetectionConn) QueryContext(_ context.Context, query string, _ []driver.NamedValue) (driver.Rows, error) {
|
|
connection.state.mu.Lock()
|
|
connection.state.queries = append(connection.state.queries, query)
|
|
connection.state.mu.Unlock()
|
|
|
|
switch {
|
|
case strings.Contains(query, "SELECT current_database()"):
|
|
return &valueRows{
|
|
columns: []string{"current_database", "current_user", "version", "current_schema"},
|
|
rows: [][]driver.Value{{"test", "system", "KingbaseES", "public"}},
|
|
}, nil
|
|
case strings.Contains(query, "LOWER(name) = 'database_mode'"):
|
|
if connection.state.databaseErr != nil {
|
|
return nil, connection.state.databaseErr
|
|
}
|
|
rows := [][]driver.Value{}
|
|
if connection.state.databaseMode != nil {
|
|
rows = append(rows, []driver.Value{*connection.state.databaseMode})
|
|
}
|
|
return &valueRows{columns: []string{"setting"}, rows: rows}, nil
|
|
case strings.Contains(query, "AS `dbx_identifier_probe`"):
|
|
if !connection.state.backtickIdentifiers {
|
|
return nil, &gokb.Error{Code: gokb.ErrorCode("42601"), Message: "syntax error at or near `"}
|
|
}
|
|
return &valueRows{columns: []string{"dbx_identifier_probe"}, rows: [][]driver.Value{{int64(1)}}}, nil
|
|
default:
|
|
return nil, errors.New("unexpected query: " + query)
|
|
}
|
|
}
|
|
|
|
func (metadataDriver) Open(string) (driver.Conn, error) {
|
|
return &metadataConn{state: metadataState.Load()}, nil
|
|
}
|
|
|
|
func (*metadataConn) Prepare(string) (driver.Stmt, error) { return nil, driver.ErrSkip }
|
|
|
|
func (*metadataConn) Close() error { return nil }
|
|
|
|
func (*metadataConn) Begin() (driver.Tx, error) { return nil, driver.ErrSkip }
|
|
|
|
func (connection *metadataConn) QueryContext(_ context.Context, query string, _ []driver.NamedValue) (driver.Rows, error) {
|
|
connection.state.mu.Lock()
|
|
connection.state.queries = append(connection.state.queries, query)
|
|
connection.state.mu.Unlock()
|
|
return connection.state.query(query)
|
|
}
|
|
|
|
func (rows *valueRows) Columns() []string { return rows.columns }
|
|
|
|
func (rows *valueRows) Close() error { return rows.closeErr }
|
|
|
|
func (rows *valueRows) Next(values []driver.Value) error {
|
|
if rows.index >= len(rows.rows) {
|
|
if rows.nextErr != nil {
|
|
return rows.nextErr
|
|
}
|
|
return io.EOF
|
|
}
|
|
copy(values, rows.rows[rows.index])
|
|
rows.index++
|
|
return nil
|
|
}
|
|
|
|
func (state *connectionAttemptState) open(cp connectParams, sslMode string) (*sql.DB, error) {
|
|
state.mu.Lock()
|
|
state.attempts = append(state.attempts, sslMode)
|
|
state.dsns = append(state.dsns, buildDSNWithSSLMode(cp, sslMode))
|
|
state.mu.Unlock()
|
|
return sql.OpenDB(connectionAttemptConnector{state: state, sslMode: sslMode}), nil
|
|
}
|
|
|
|
func (connector connectionAttemptConnector) Connect(context.Context) (driver.Conn, error) {
|
|
return &connectionAttemptConn{state: connector.state, sslMode: connector.sslMode}, nil
|
|
}
|
|
|
|
func (connectionAttemptConnector) Driver() driver.Driver { return connectionAttemptDriver{} }
|
|
|
|
func (connectionAttemptDriver) Open(string) (driver.Conn, error) { return nil, driver.ErrSkip }
|
|
|
|
func (*connectionAttemptConn) Prepare(string) (driver.Stmt, error) { return nil, driver.ErrSkip }
|
|
|
|
func (*connectionAttemptConn) Close() error { return nil }
|
|
|
|
func (*connectionAttemptConn) Begin() (driver.Tx, error) { return nil, driver.ErrSkip }
|
|
|
|
func (connection *connectionAttemptConn) Ping(ctx context.Context) error {
|
|
connection.state.mu.Lock()
|
|
if deadline, ok := ctx.Deadline(); ok {
|
|
connection.state.deadlines = append(connection.state.deadlines, deadline)
|
|
}
|
|
err := connection.state.pingErrors[connection.sslMode]
|
|
connection.state.mu.Unlock()
|
|
return err
|
|
}
|
|
|
|
func (state *connectionAttemptState) snapshot() ([]string, []time.Time) {
|
|
state.mu.Lock()
|
|
defer state.mu.Unlock()
|
|
return append([]string(nil), state.attempts...), append([]time.Time(nil), state.deadlines...)
|
|
}
|
|
|
|
func (state *connectionAttemptState) connectionStrings() []string {
|
|
state.mu.Lock()
|
|
defer state.mu.Unlock()
|
|
return append([]string(nil), state.dsns...)
|
|
}
|
|
|
|
// failoverState records every opener attempt (per endpoint host + sslmode)
|
|
// and simulates ping failures keyed as "<sslMode>@<host>".
|
|
type failoverState struct {
|
|
mu sync.Mutex
|
|
opened []string
|
|
dsns []string
|
|
pingErrors map[string]error
|
|
}
|
|
|
|
type failoverOpener struct {
|
|
state *failoverState
|
|
}
|
|
|
|
type failoverConnector struct {
|
|
state *failoverState
|
|
host string
|
|
sslMode string
|
|
}
|
|
|
|
type failoverDriver struct{}
|
|
|
|
type failoverConn struct {
|
|
state *failoverState
|
|
host string
|
|
sslMode string
|
|
}
|
|
|
|
func (o failoverOpener) open(cp connectParams, sslMode string) (*sql.DB, error) {
|
|
dsn := buildDSNWithSSLMode(cp, sslMode)
|
|
o.state.mu.Lock()
|
|
o.state.opened = append(o.state.opened, sslMode+"@"+cp.Host)
|
|
o.state.dsns = append(o.state.dsns, dsn)
|
|
o.state.mu.Unlock()
|
|
return sql.OpenDB(failoverConnector{state: o.state, host: cp.Host, sslMode: sslMode}), nil
|
|
}
|
|
|
|
func (connector failoverConnector) Connect(context.Context) (driver.Conn, error) {
|
|
conn := failoverConn{state: connector.state, host: connector.host, sslMode: connector.sslMode}
|
|
return &conn, nil
|
|
}
|
|
|
|
func (failoverConnector) Driver() driver.Driver { return failoverDriver{} }
|
|
|
|
func (failoverDriver) Open(string) (driver.Conn, error) { return nil, driver.ErrSkip }
|
|
|
|
func (*failoverConn) Prepare(string) (driver.Stmt, error) { return nil, driver.ErrSkip }
|
|
|
|
func (*failoverConn) Close() error { return nil }
|
|
|
|
func (*failoverConn) Begin() (driver.Tx, error) { return nil, driver.ErrSkip }
|
|
|
|
func (connection failoverConn) Ping(context.Context) error {
|
|
connection.state.mu.Lock()
|
|
defer connection.state.mu.Unlock()
|
|
return connection.state.pingErrors[connection.sslMode+"@"+connection.host]
|
|
}
|
|
|
|
func (state *failoverState) attempts() []string {
|
|
state.mu.Lock()
|
|
defer state.mu.Unlock()
|
|
return append([]string(nil), state.opened...)
|
|
}
|
|
|
|
func (state *failoverState) connectionStrings() []string {
|
|
state.mu.Lock()
|
|
defer state.mu.Unlock()
|
|
return append([]string(nil), state.dsns...)
|
|
}
|
|
|
|
func openFakeDB(t *testing.T, rowCount int) (*sql.DB, *fakeDriverState) {
|
|
t.Helper()
|
|
registerTestDriver.Do(func() { sql.Register("kingbase-agent-test", fakeDriver{}) })
|
|
state := &fakeDriverState{rowCount: rowCount}
|
|
testDriverState.Store(state)
|
|
db, err := sql.Open("kingbase-agent-test", "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
db.SetMaxOpenConns(1)
|
|
t.Cleanup(func() { _ = db.Close() })
|
|
return db, state
|
|
}
|
|
|
|
func openModeDetectionDB(t *testing.T, state *modeDetectionDriverState) *sql.DB {
|
|
t.Helper()
|
|
registerModeDetectionDriver.Do(func() { sql.Register("kingbase-mode-detection-test", modeDetectionDriver{}) })
|
|
modeDetectionState.Store(state)
|
|
db, err := sql.Open("kingbase-mode-detection-test", "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
db.SetMaxOpenConns(1)
|
|
t.Cleanup(func() { _ = db.Close() })
|
|
return db
|
|
}
|
|
|
|
func openMetadataDB(t *testing.T, state *metadataDriverState) *sql.DB {
|
|
t.Helper()
|
|
registerMetadataDriver.Do(func() { sql.Register("kingbase-metadata-test", metadataDriver{}) })
|
|
metadataState.Store(state)
|
|
db, err := sql.Open("kingbase-metadata-test", "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
db.SetMaxOpenConns(1)
|
|
t.Cleanup(func() { _ = db.Close() })
|
|
return db
|
|
}
|
|
|
|
func (state *metadataDriverState) snapshotQueries() []string {
|
|
state.mu.Lock()
|
|
defer state.mu.Unlock()
|
|
return append([]string(nil), state.queries...)
|
|
}
|
|
|
|
func TestHandshakeAdvertisesMultiSession(t *testing.T) {
|
|
runtime := &runtimeServer{sessions: map[string]*agentSession{}}
|
|
result, shutdown, err := runtime.dispatch("handshake", nil)
|
|
if err != nil || shutdown {
|
|
t.Fatalf("handshake failed: shutdown=%v err=%v", shutdown, err)
|
|
}
|
|
values := result.(map[string]any)
|
|
if values["protocolVersion"] != protocolVersion {
|
|
t.Fatalf("unexpected protocol version: %#v", values["protocolVersion"])
|
|
}
|
|
capabilities := values["capabilities"].([]string)
|
|
if !containsString(capabilities, "multi_session") || !containsString(capabilities, "paged_query") {
|
|
t.Fatalf("missing capabilities: %v", capabilities)
|
|
}
|
|
}
|
|
|
|
// dsnContainsParam reports whether dsn contains key= as a real parameter pair
|
|
// (not as a substring of another parameter name such as fallback_application_name).
|
|
func dsnContainsParam(dsn, key string) bool {
|
|
lowerDSN := strings.ToLower(dsn)
|
|
needle := strings.ToLower(strings.TrimSpace(key)) + "="
|
|
if strings.HasPrefix(lowerDSN, needle) {
|
|
return true
|
|
}
|
|
for _, boundary := range []string{" ", "?", "&"} {
|
|
if strings.Contains(lowerDSN, boundary+needle) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func TestBuildDSNQuotesCredentialsAndFiltersUnsafeKeys(t *testing.T) {
|
|
dsn := buildDSN(connectParams{
|
|
Host: "db host",
|
|
Port: 54321,
|
|
Database: "test'db",
|
|
Username: "system",
|
|
Password: `p'ass\\word`,
|
|
URLParams: "application_name=dbx&fallback_application_name=dbx&useSSL=false&bad-key=ignored",
|
|
})
|
|
for _, expected := range []string{
|
|
`host='db host'`, `dbname='test\'db'`, `password='p\'ass\\\\word'`, `application_name='dbx'`, `fallback_application_name='dbx'`,
|
|
} {
|
|
if !strings.Contains(dsn, expected) {
|
|
t.Fatalf("DSN missing %q: %s", expected, dsn)
|
|
}
|
|
}
|
|
for _, skipped := range []string{"useSSL", "bad-key"} {
|
|
if dsnContainsParam(dsn, skipped) {
|
|
t.Fatalf("unsupported parameter %q was not skipped: %s", skipped, dsn)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBuildDSNKeepsOnlySupportedURLParams(t *testing.T) {
|
|
cp := connectParams{
|
|
Host: "127.0.0.1",
|
|
Port: 54321,
|
|
Database: "test",
|
|
Username: "system",
|
|
Password: "secret",
|
|
URLParams: "fallback_application_name=dbx&connect_timeout=30&sslcert=cert.pem&sslkey=key.pem&sslrootcert=root.pem" +
|
|
"&disable_prepared_binary_result=yes&binary_parameters=yes&krbsrvname=kingbase&krbspn=kingbase/db.example.com" +
|
|
"&application_name=dbx&options=-csearch_path=public&client_encoding=UTF8&search_path=public&statement_timeout=1000&work_mem=64MB" +
|
|
"&timezone=Asia/Shanghai&default_transaction_read_only=off&synchronous_commit=on" +
|
|
"&useSSL=false&autoReconnect=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true" +
|
|
"&useServerPrepStmts=true&connectTimeout=10&socketTimeout=30&useCompression=true&zeroDateTimeBehavior=convertToNull" +
|
|
"&useAffectedRows=true&useCursorFetch=true&defaultFetchSize=100&allowMultiQueries=true&useUnicode=true¤tSchema=public",
|
|
}
|
|
dsn := buildDSN(cp)
|
|
for _, expected := range []string{
|
|
`fallback_application_name='dbx'`, `connect_timeout='30'`, `sslcert='cert.pem'`, `sslkey='key.pem'`, `sslrootcert='root.pem'`,
|
|
`disable_prepared_binary_result='yes'`, `binary_parameters='yes'`, `krbsrvname='kingbase'`, `krbspn='kingbase/db.example.com'`,
|
|
`application_name='dbx'`, `options='-csearch_path=public'`, `client_encoding='UTF8'`, `search_path='public'`, `statement_timeout='1000'`, `work_mem='64MB'`,
|
|
`timezone='Asia/Shanghai'`, `default_transaction_read_only='off'`, `synchronous_commit='on'`,
|
|
} {
|
|
if !strings.Contains(dsn, expected) {
|
|
t.Fatalf("DSN missing supported parameter %q: %s", expected, dsn)
|
|
}
|
|
}
|
|
for _, skipped := range []string{
|
|
"useSSL", "autoReconnect", "characterEncoding", "serverTimezone", "rewriteBatchedStatements", "useServerPrepStmts",
|
|
"connectTimeout", "socketTimeout", "useCompression", "zeroDateTimeBehavior", "useAffectedRows", "useCursorFetch",
|
|
"defaultFetchSize", "allowMultiQueries", "useUnicode", "currentSchema",
|
|
} {
|
|
if dsnContainsParam(dsn, skipped) {
|
|
t.Fatalf("unsupported parameter %q was not skipped: %s", skipped, dsn)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBuildDSNKeepsOnlySupportedNativeConnectionStringParameters(t *testing.T) {
|
|
for _, test := range []struct {
|
|
name string
|
|
connectionString string
|
|
preservedFragments []string
|
|
}{
|
|
{
|
|
name: "keyword DSN",
|
|
connectionString: "host=db.example.com port=54321 user=system password=secret dbname=test connect_timeout=30 fallback_application_name='dbx' application_name='dbx app' options='-c search_path=public' client_encoding=UTF8 disable_prepared_binary_result=yes binary_parameters=yes krbsrvname=kingbase krbspn='kingbase/db.example.com' statement_timeout=1000 useSSL=false serverTimezone=Asia/Shanghai currentSchema=public",
|
|
preservedFragments: []string{
|
|
"host=db.example.com", "port=54321", "user=system", "password=secret", "dbname=test", "connect_timeout=30",
|
|
"fallback_application_name='dbx'", "application_name='dbx app'", "options='-c search_path=public'", "client_encoding=UTF8",
|
|
"disable_prepared_binary_result=yes", "binary_parameters=yes", "krbsrvname=kingbase", "krbspn='kingbase/db.example.com'",
|
|
"statement_timeout=1000",
|
|
},
|
|
},
|
|
{
|
|
name: "Kingbase URL",
|
|
connectionString: "kingbase://system:secret@db.example.com:54321/test?connect_timeout=30&fallback_application_name=dbx&application_name=dbx&options=-c%20search_path%3Dpublic&disable_prepared_binary_result=yes&binary_parameters=yes&krbsrvname=kingbase&statement_timeout=1000&useSSL=false&serverTimezone=Asia%2FShanghai¤tSchema=public",
|
|
preservedFragments: []string{
|
|
"connect_timeout=30", "fallback_application_name=dbx", "application_name=dbx", "options=-c%20search_path%3Dpublic",
|
|
"disable_prepared_binary_result=yes", "binary_parameters=yes", "krbsrvname=kingbase", "statement_timeout=1000",
|
|
},
|
|
},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
dsn := buildDSN(connectParams{ConnectionString: test.connectionString})
|
|
for _, expected := range test.preservedFragments {
|
|
if !strings.Contains(dsn, expected) {
|
|
t.Fatalf("native DSN missing supported parameter %q: %s", expected, dsn)
|
|
}
|
|
}
|
|
for _, skipped := range []string{"useSSL", "serverTimezone", "currentSchema"} {
|
|
if dsnContainsParam(dsn, skipped) {
|
|
t.Fatalf("native DSN kept unsupported parameter %q: %s", skipped, dsn)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// kingbaseParamSurfaces expands a &-separated parameter list into the three
|
|
// connection-input surfaces the driver must treat consistently: app-supplied
|
|
// url_params, a native keyword DSN, and a kingbase:// URL. Values must not
|
|
// contain spaces so they survive the keyword-DSN join.
|
|
func kingbaseParamSurfaces(params string) map[string]connectParams {
|
|
pairs := strings.Split(params, "&")
|
|
keyword := "host=db.example.com user=system password=secret dbname=test " + strings.Join(pairs, " ")
|
|
kurl := "kingbase://system:secret@db.example.com:54321/test?" + params
|
|
return map[string]connectParams{
|
|
"url_params": {Host: "db.example.com", Port: 54321, Database: "test", Username: "system", Password: "secret", URLParams: params},
|
|
"keyword_dsn": {ConnectionString: keyword},
|
|
"kingbase_url": {ConnectionString: kurl},
|
|
}
|
|
}
|
|
|
|
// TestBuildDSNForwardsUnknownServerParameters locks in the review's central
|
|
// requirement: gokb forwards every non-driver-setting to the server startup
|
|
// packet (conn.go startup()), so user/session GUCs that are not in the curated
|
|
// native list must still be passed through rather than silently dropped.
|
|
func TestBuildDSNForwardsUnknownServerParameters(t *testing.T) {
|
|
for surface, cp := range kingbaseParamSurfaces("plan_cache_mode=force_generic_plan&row_security=off&bytea_output=hex") {
|
|
t.Run(surface, func(t *testing.T) {
|
|
dsn := buildDSN(cp)
|
|
for _, key := range []string{"plan_cache_mode", "row_security", "bytea_output"} {
|
|
if !dsnContainsParam(dsn, key) {
|
|
t.Fatalf("expected server GUC %q to be forwarded: %s", key, dsn)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestBuildDSNNormalizesJDBCAliases verifies JDBC properties with a direct native
|
|
// equivalent are rewritten to the gokb/server name instead of being discarded.
|
|
func TestBuildDSNNormalizesJDBCAliases(t *testing.T) {
|
|
for surface, cp := range kingbaseParamSurfaces("connectTimeout=20¤tSchema=public&ApplicationName=dbx&clientEncoding=UTF-8") {
|
|
t.Run(surface, func(t *testing.T) {
|
|
dsn := buildDSN(cp)
|
|
for _, native := range []string{"connect_timeout", "search_path", "application_name", "client_encoding"} {
|
|
if !dsnContainsParam(dsn, native) {
|
|
t.Fatalf("expected JDBC alias to normalize to %q: %s", native, dsn)
|
|
}
|
|
}
|
|
for _, jdbc := range []string{"connectTimeout", "currentSchema", "ApplicationName", "clientEncoding"} {
|
|
if dsnContainsParam(dsn, jdbc) {
|
|
t.Fatalf("JDBC alias %q must not be forwarded verbatim: %s", jdbc, dsn)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestBuildDSNDropsNonUTF8ClientEncoding checks that a non-UTF-8 clientEncoding
|
|
// is dropped: gokb rejects any client_encoding other than UTF-8, so forwarding
|
|
// or renaming a GBK value would fail the whole connection.
|
|
func TestBuildDSNDropsNonUTF8ClientEncoding(t *testing.T) {
|
|
for surface, cp := range kingbaseParamSurfaces("clientEncoding=GBK") {
|
|
t.Run(surface, func(t *testing.T) {
|
|
dsn := buildDSN(cp)
|
|
if dsnContainsParam(dsn, "client_encoding") || strings.Contains(strings.ToLower(dsn), "gbk") {
|
|
t.Fatalf("non-UTF8 clientEncoding must be dropped: %s", dsn)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestBuildDSNDropsUnknownCamelCaseJDBCProperties guards the heuristic: unknown
|
|
// camelCase names are treated as client-side JDBC properties and dropped, since
|
|
// forwarding them would make the server reject the startup packet.
|
|
func TestBuildDSNDropsUnknownCamelCaseJDBCProperties(t *testing.T) {
|
|
for surface, cp := range kingbaseParamSurfaces("tinyInt1isBit=true&someFutureJdbcFlag=1") {
|
|
t.Run(surface, func(t *testing.T) {
|
|
dsn := buildDSN(cp)
|
|
for _, jdbc := range []string{"tinyInt1isBit", "someFutureJdbcFlag"} {
|
|
if dsnContainsParam(dsn, jdbc) {
|
|
t.Fatalf("unknown camelCase JDBC property %q must be dropped: %s", jdbc, dsn)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestBuildDSNParameterPrecedenceNativeBeatsAlias verifies duplicate-parameter
|
|
// precedence: an explicit native parameter wins over its JDBC alias and the
|
|
// parameter is emitted exactly once (no duplicate for gokb to resolve).
|
|
func TestBuildDSNParameterPrecedenceNativeBeatsAlias(t *testing.T) {
|
|
for _, params := range []string{"connect_timeout=30&connectTimeout=10", "connectTimeout=10&connect_timeout=30"} {
|
|
for surface, cp := range kingbaseParamSurfaces(params) {
|
|
t.Run(surface+"/"+params, func(t *testing.T) {
|
|
dsn := buildDSN(cp)
|
|
if got := strings.Count(strings.ToLower(dsn), "connect_timeout="); got != 1 {
|
|
t.Fatalf("connect_timeout must appear exactly once, saw %d: %s", got, dsn)
|
|
}
|
|
unquoted := strings.ReplaceAll(dsn, "'", "")
|
|
if !strings.Contains(unquoted, "connect_timeout=30") {
|
|
t.Fatalf("native connect_timeout=30 must win over alias: %s", dsn)
|
|
}
|
|
if strings.Contains(unquoted, "connect_timeout=10") {
|
|
t.Fatalf("alias connectTimeout=10 must not win: %s", dsn)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBuildDSNPreservesFirstDuplicateWithinSameParameterClass(t *testing.T) {
|
|
for _, params := range []string{"application_name=first&application_name=second", "ApplicationName=first&applicationName=second"} {
|
|
for surface, cp := range kingbaseParamSurfaces(params) {
|
|
t.Run(surface+"/"+params, func(t *testing.T) {
|
|
dsn := strings.ReplaceAll(buildDSN(cp), "'", "")
|
|
if !strings.Contains(dsn, "application_name=first") {
|
|
t.Fatalf("first duplicate value must be preserved: %s", dsn)
|
|
}
|
|
if strings.Contains(dsn, "application_name=second") {
|
|
t.Fatalf("later duplicate value must not replace the first: %s", dsn)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBuildDSNConvertsDBXJDBCURL(t *testing.T) {
|
|
dsn := buildDSN(connectParams{
|
|
Host: "127.0.0.1",
|
|
Port: 54321,
|
|
Database: "test",
|
|
Username: "system",
|
|
Password: "secret",
|
|
URLParams: "application_name=dbx",
|
|
ConnectionString: "jdbc:kingbase8://127.0.0.1:54321/test?application_name=dbx",
|
|
})
|
|
if strings.HasPrefix(dsn, "jdbc:") || !strings.Contains(dsn, "host='127.0.0.1'") || !strings.Contains(dsn, "dbname='test'") {
|
|
t.Fatalf("JDBC URL was not converted to a gokb DSN: %s", dsn)
|
|
}
|
|
}
|
|
|
|
func TestBuildDSNNormalizesPreferWithoutPassingLiteralMode(t *testing.T) {
|
|
cp := connectParams{
|
|
Host: "127.0.0.1",
|
|
Port: 54321,
|
|
Database: "test",
|
|
Username: "system",
|
|
Password: "secret",
|
|
URLParams: "SSLMODE=disable&sslmode=prefer&application_name=dbx&fallback_application_name=dbx&useSSL=false",
|
|
}
|
|
if mode := effectiveSSLMode(cp); mode != "prefer" {
|
|
t.Fatalf("unexpected effective SSL mode: %q", mode)
|
|
}
|
|
dsn := buildDSN(cp)
|
|
if strings.Count(strings.ToLower(dsn), "sslmode=") != 1 {
|
|
t.Fatalf("DSN must contain exactly one sslmode: %s", dsn)
|
|
}
|
|
if !strings.Contains(dsn, "sslmode=require") || strings.Contains(strings.ToLower(dsn), "sslmode=prefer") {
|
|
t.Fatalf("prefer must be converted to the first require attempt: %s", dsn)
|
|
}
|
|
for _, expected := range []string{`application_name='dbx'`, `fallback_application_name='dbx'`} {
|
|
if !strings.Contains(dsn, expected) {
|
|
t.Fatalf("unrelated URL parameters must be preserved, missing %q: %s", expected, dsn)
|
|
}
|
|
}
|
|
if dsnContainsParam(dsn, "useSSL") {
|
|
t.Fatalf("unsupported parameter was not skipped: %s", dsn)
|
|
}
|
|
}
|
|
|
|
func TestBuildDSNOverridesPreferInNativeConnectionStrings(t *testing.T) {
|
|
for _, test := range []struct {
|
|
name string
|
|
connectionString string
|
|
preservedFragments []string
|
|
droppedFragments []string
|
|
}{
|
|
{
|
|
name: "keyword DSN",
|
|
connectionString: "host=db.example.com application_name='dbx app' sslmode = 'prefer' options='-c search_path=public tenant' useSSL=false",
|
|
preservedFragments: []string{
|
|
"host=db.example.com",
|
|
"application_name='dbx app'",
|
|
"options='-c search_path=public tenant'",
|
|
},
|
|
droppedFragments: []string{
|
|
"useSSL",
|
|
},
|
|
},
|
|
{
|
|
name: "Kingbase URL",
|
|
connectionString: "kingbase://system:secret@db.example.com/test?application_name=dbx&options=-c%20search_path%3Dpublic&useSSL=false&SSLMODE=prefer#section",
|
|
preservedFragments: []string{
|
|
"kingbase://system:secret@db.example.com/test?",
|
|
"application_name=dbx",
|
|
"options=-c%20search_path%3Dpublic",
|
|
"#section",
|
|
},
|
|
droppedFragments: []string{
|
|
"useSSL",
|
|
},
|
|
},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
cp := connectParams{ConnectionString: test.connectionString}
|
|
if mode := effectiveSSLMode(cp); mode != "prefer" {
|
|
t.Fatalf("unexpected effective SSL mode: %q", mode)
|
|
}
|
|
dsn := buildDSN(cp)
|
|
if strings.Count(strings.ToLower(dsn), "sslmode=") != 1 {
|
|
t.Fatalf("native DSN must contain exactly one sslmode: %s", dsn)
|
|
}
|
|
if strings.Contains(strings.ToLower(dsn), "prefer") || !strings.Contains(strings.ToLower(dsn), "sslmode=require") {
|
|
t.Fatalf("native prefer must be replaced by require: %s", dsn)
|
|
}
|
|
for _, fragment := range test.preservedFragments {
|
|
if !strings.Contains(dsn, fragment) {
|
|
t.Fatalf("native DSN lost %q: %s", fragment, dsn)
|
|
}
|
|
}
|
|
for _, fragment := range test.droppedFragments {
|
|
if dsnContainsParam(dsn, fragment) {
|
|
t.Fatalf("native DSN kept unsupported %q: %s", fragment, dsn)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestOpenAndPingDBNativeConnectionStringsWithoutSSLModeUsePreferFallback(t *testing.T) {
|
|
for _, test := range []struct {
|
|
name string
|
|
connectionString string
|
|
preservedFragments []string
|
|
}{
|
|
{
|
|
name: "keyword DSN",
|
|
connectionString: "host=db.example.com application_name=dbx fallback_application_name=dbx useSSL=false",
|
|
preservedFragments: []string{
|
|
"host=db.example.com",
|
|
"application_name=dbx",
|
|
"fallback_application_name=dbx",
|
|
},
|
|
},
|
|
{
|
|
name: "Kingbase URL",
|
|
connectionString: "kingbase://system:secret@db.example.com/test?application_name=dbx&fallback_application_name=dbx&useSSL=false",
|
|
preservedFragments: []string{
|
|
"kingbase://system:secret@db.example.com/test?",
|
|
"application_name=dbx",
|
|
"fallback_application_name=dbx",
|
|
},
|
|
},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
cp := connectParams{ConnectionString: test.connectionString}
|
|
if mode := effectiveSSLMode(cp); mode != "prefer" {
|
|
t.Fatalf("native connection string without sslmode must use prefer semantics: %q", mode)
|
|
}
|
|
|
|
state := &connectionAttemptState{pingErrors: map[string]error{"require": gokb.ErrSSLNotSupported}}
|
|
db, err := openAndPingDB(cp, time.Second, state.open)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer db.Close()
|
|
|
|
attempts, _ := state.snapshot()
|
|
if strings.Join(attempts, ",") != "require,disable" {
|
|
t.Fatalf("unexpected implicit prefer attempts: %v", attempts)
|
|
}
|
|
dsns := state.connectionStrings()
|
|
if len(dsns) != 2 {
|
|
t.Fatalf("unexpected generated DSNs: %v", dsns)
|
|
}
|
|
for index, sslMode := range []string{"require", "disable"} {
|
|
dsn := dsns[index]
|
|
lowerDSN := strings.ToLower(dsn)
|
|
if strings.Count(lowerDSN, "sslmode=") != 1 || !strings.Contains(lowerDSN, "sslmode="+sslMode) {
|
|
t.Fatalf("attempt %s has unexpected sslmode: %s", sslMode, dsn)
|
|
}
|
|
if strings.Contains(lowerDSN, "sslmode=prefer") {
|
|
t.Fatalf("literal prefer reached native driver DSN: %s", dsn)
|
|
}
|
|
for _, fragment := range test.preservedFragments {
|
|
if !strings.Contains(dsn, fragment) {
|
|
t.Fatalf("native DSN lost %q: %s", fragment, dsn)
|
|
}
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestOpenAndPingDBHonorsExplicitNativeConnectionStringMode(t *testing.T) {
|
|
state := &connectionAttemptState{pingErrors: map[string]error{"require": gokb.ErrSSLNotSupported}}
|
|
db, err := openAndPingDB(connectParams{ConnectionString: "host=db.example.com sslmode=require"}, time.Second, state.open)
|
|
if db != nil {
|
|
db.Close()
|
|
}
|
|
if !errors.Is(err, gokb.ErrSSLNotSupported) {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
attempts, _ := state.snapshot()
|
|
if len(attempts) != 1 || attempts[0] != "require" {
|
|
t.Fatalf("explicit native DSN mode must not downgrade: %v", attempts)
|
|
}
|
|
}
|
|
|
|
func TestOpenAndPingDBPreferFallbackUsesOneTimeoutBudget(t *testing.T) {
|
|
state := &connectionAttemptState{pingErrors: map[string]error{"require": gokb.ErrSSLNotSupported}}
|
|
db, err := openAndPingDB(connectParams{}, time.Second, state.open)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer db.Close()
|
|
attempts, deadlines := state.snapshot()
|
|
if strings.Join(attempts, ",") != "require,disable" {
|
|
t.Fatalf("unexpected prefer attempts: %v", attempts)
|
|
}
|
|
if len(deadlines) != 2 || !deadlines[0].Equal(deadlines[1]) {
|
|
t.Fatalf("prefer attempts must share one deadline: %v", deadlines)
|
|
}
|
|
}
|
|
|
|
func TestOpenAndPingDBPreferFallbackHandlesKingbaseV7TLSFailure(t *testing.T) {
|
|
state := &connectionAttemptState{pingErrors: map[string]error{
|
|
"require": errors.New("remote error: tls: handshake failure"),
|
|
}}
|
|
db, err := openAndPingDB(connectParams{}, time.Second, state.open)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer db.Close()
|
|
attempts, _ := state.snapshot()
|
|
if strings.Join(attempts, ",") != "require,disable" {
|
|
t.Fatalf("unexpected KingBase V7 attempts: %v", attempts)
|
|
}
|
|
}
|
|
|
|
func TestOpenAndPingDBDoesNotDowngradeUnrelatedErrors(t *testing.T) {
|
|
authErr := errors.New("authentication failed")
|
|
state := &connectionAttemptState{pingErrors: map[string]error{"require": authErr}}
|
|
db, err := openAndPingDB(connectParams{}, time.Second, state.open)
|
|
if db != nil {
|
|
db.Close()
|
|
}
|
|
if !errors.Is(err, authErr) {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
attempts, _ := state.snapshot()
|
|
if strings.Join(attempts, ",") != "require" {
|
|
t.Fatalf("unrelated errors must not downgrade: %v", attempts)
|
|
}
|
|
}
|
|
|
|
func TestOpenAndPingDBDoesNotDowngradeNetworkErrors(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
err error
|
|
}{
|
|
{name: "timeout", err: context.DeadlineExceeded},
|
|
{name: "connection refused", err: errors.New("dial tcp 127.0.0.1:54321: connect: connection refused")},
|
|
{name: "generic handshake", err: errors.New("handshake failure")},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
state := &connectionAttemptState{pingErrors: map[string]error{"require": test.err}}
|
|
db, err := openAndPingDB(connectParams{}, time.Second, state.open)
|
|
if db != nil {
|
|
db.Close()
|
|
}
|
|
if !errors.Is(err, test.err) {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
attempts, _ := state.snapshot()
|
|
if strings.Join(attempts, ",") != "require" {
|
|
t.Fatalf("network errors must not downgrade: %v", attempts)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestOpenAndPingDBExplicitModesNeverDowngrade(t *testing.T) {
|
|
for _, sslMode := range []string{"disable", "require", "verify-ca", "verify-full"} {
|
|
t.Run(sslMode, func(t *testing.T) {
|
|
state := &connectionAttemptState{pingErrors: map[string]error{sslMode: gokb.ErrSSLNotSupported}}
|
|
db, err := openAndPingDB(connectParams{URLParams: "sslmode=" + sslMode}, time.Second, state.open)
|
|
if db != nil {
|
|
db.Close()
|
|
}
|
|
if !errors.Is(err, gokb.ErrSSLNotSupported) {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
attempts, _ := state.snapshot()
|
|
if len(attempts) != 1 || attempts[0] != sslMode {
|
|
t.Fatalf("explicit mode must use one attempt: %v", attempts)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestOpenAndPingDBExplicitModesNeverDowngradeKingbaseV7TLSFailure(t *testing.T) {
|
|
tlsErr := errors.New("remote error: tls: handshake failure")
|
|
for _, sslMode := range []string{"disable", "require", "verify-ca", "verify-full"} {
|
|
t.Run(sslMode, func(t *testing.T) {
|
|
state := &connectionAttemptState{pingErrors: map[string]error{sslMode: tlsErr}}
|
|
db, err := openAndPingDB(connectParams{URLParams: "sslmode=" + sslMode}, time.Second, state.open)
|
|
if db != nil {
|
|
db.Close()
|
|
}
|
|
if !errors.Is(err, tlsErr) {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
attempts, _ := state.snapshot()
|
|
if len(attempts) != 1 || attempts[0] != sslMode {
|
|
t.Fatalf("explicit mode must use one attempt: %v", attempts)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestOpenAndPingDBSSLDefaultsToVerifyFull(t *testing.T) {
|
|
state := &connectionAttemptState{pingErrors: map[string]error{}}
|
|
db, err := openAndPingDB(connectParams{SSL: true}, time.Second, state.open)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer db.Close()
|
|
attempts, _ := state.snapshot()
|
|
if len(attempts) != 1 || attempts[0] != "verify-full" {
|
|
t.Fatalf("SSL=true must stay verify-full: %v", attempts)
|
|
}
|
|
}
|
|
|
|
func TestSplitHostEndpoints(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
host string
|
|
fallbackPort int
|
|
expected []kingbaseEndpoint
|
|
}{
|
|
{
|
|
name: "single host stays whole",
|
|
host: "172.22.232.10",
|
|
fallbackPort: 54321,
|
|
expected: []kingbaseEndpoint{{host: "172.22.232.10", port: 54321}},
|
|
},
|
|
{
|
|
name: "single host with custom fallback port",
|
|
host: "db.example.com",
|
|
fallbackPort: 6000,
|
|
expected: []kingbaseEndpoint{{host: "db.example.com", port: 6000}},
|
|
},
|
|
{
|
|
name: "comma separated cluster",
|
|
host: "172.22.232.10,172.22.232.11",
|
|
fallbackPort: 54321,
|
|
expected: []kingbaseEndpoint{
|
|
{host: "172.22.232.10", port: 54321},
|
|
{host: "172.22.232.11", port: 54321},
|
|
},
|
|
},
|
|
{
|
|
name: "semicolon separated cluster",
|
|
host: "172.22.232.10; 172.22.232.11",
|
|
fallbackPort: 54321,
|
|
expected: []kingbaseEndpoint{
|
|
{host: "172.22.232.10", port: 54321},
|
|
{host: "172.22.232.11", port: 54321},
|
|
},
|
|
},
|
|
{
|
|
name: "per entry ports",
|
|
host: "10.0.0.1:1000,10.0.0.2:2000",
|
|
fallbackPort: 54321,
|
|
expected: []kingbaseEndpoint{
|
|
{host: "10.0.0.1", port: 1000},
|
|
{host: "10.0.0.2", port: 2000},
|
|
},
|
|
},
|
|
{
|
|
name: "mixed embedded and fallback ports",
|
|
host: "10.0.0.1:1000,10.0.0.2",
|
|
fallbackPort: 54322,
|
|
expected: []kingbaseEndpoint{
|
|
{host: "10.0.0.1", port: 1000},
|
|
{host: "10.0.0.2", port: 54322},
|
|
},
|
|
},
|
|
{
|
|
name: "bracketed ipv6 without port",
|
|
host: "[2001:db8::1],[2001:db8::2]",
|
|
fallbackPort: 54321,
|
|
expected: []kingbaseEndpoint{
|
|
{host: "2001:db8::1", port: 54321},
|
|
{host: "2001:db8::2", port: 54321},
|
|
},
|
|
},
|
|
{
|
|
name: "bracketed ipv6 with port",
|
|
host: "[2001:db8::1]:6000,[2001:db8::2]:6001",
|
|
fallbackPort: 54321,
|
|
expected: []kingbaseEndpoint{
|
|
{host: "2001:db8::1", port: 6000},
|
|
{host: "2001:db8::2", port: 6001},
|
|
},
|
|
},
|
|
{
|
|
name: "bare ipv6 literal",
|
|
host: "::1",
|
|
fallbackPort: 54321,
|
|
expected: []kingbaseEndpoint{{host: "::1", port: 54321}},
|
|
},
|
|
{
|
|
name: "invalid port suffix stays part of the host",
|
|
host: "db.example.com:notaport",
|
|
fallbackPort: 54321,
|
|
expected: []kingbaseEndpoint{{host: "db.example.com:notaport", port: 54321}},
|
|
},
|
|
{
|
|
name: "out of range port falls back",
|
|
host: "10.0.0.1:70000",
|
|
fallbackPort: 54321,
|
|
expected: []kingbaseEndpoint{{host: "10.0.0.1:70000", port: 54321}},
|
|
},
|
|
{
|
|
name: "empty entries are dropped",
|
|
host: "10.0.0.1,, ;10.0.0.2",
|
|
fallbackPort: 54321,
|
|
expected: []kingbaseEndpoint{
|
|
{host: "10.0.0.1", port: 54321},
|
|
{host: "10.0.0.2", port: 54321},
|
|
},
|
|
},
|
|
{
|
|
name: "empty host yields no endpoints",
|
|
host: " ",
|
|
fallbackPort: 54321,
|
|
expected: []kingbaseEndpoint{},
|
|
},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
endpoints := splitHostEndpoints(test.host, test.fallbackPort)
|
|
if len(endpoints) != len(test.expected) {
|
|
t.Fatalf("unexpected endpoint count: got %#v want %#v", endpoints, test.expected)
|
|
}
|
|
for index, endpoint := range endpoints {
|
|
if endpoint != test.expected[index] {
|
|
t.Fatalf("endpoint %d mismatch: got %#v want %#v", index, endpoint, test.expected[index])
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestClusterConnectEndpointsIgnoresNativeConnectionString(t *testing.T) {
|
|
if endpoints := clusterConnectEndpoints(connectParams{
|
|
Host: "10.0.0.1,10.0.0.2",
|
|
Port: 54321,
|
|
ConnectionString: "host=cluster.example.com port=54321",
|
|
}); endpoints != nil {
|
|
t.Fatalf("native connection string must not be split: %#v", endpoints)
|
|
}
|
|
// The JDBC URL the host app always passes is ignored by the DSN builder,
|
|
// so the host field must still be split for it.
|
|
endpoints := clusterConnectEndpoints(connectParams{
|
|
Host: "10.0.0.1,10.0.0.2",
|
|
Port: 54321,
|
|
ConnectionString: "jdbc:kingbase8://10.0.0.1:54321/test",
|
|
})
|
|
if len(endpoints) != 2 {
|
|
t.Fatalf("jdbc url must not block cluster splitting: %#v", endpoints)
|
|
}
|
|
}
|
|
|
|
func TestOpenAndPingDBSingleHostDSNUnchanged(t *testing.T) {
|
|
state := &failoverState{pingErrors: map[string]error{
|
|
"require@172.22.232.10": gokb.ErrSSLNotSupported,
|
|
}}
|
|
opener := failoverOpener{state: state}
|
|
db, err := openAndPingDB(connectParams{Host: "172.22.232.10", Port: 54321, Username: "system", Database: "test"}, time.Second, opener.open)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer db.Close()
|
|
dsns := state.connectionStrings()
|
|
if len(dsns) != 2 {
|
|
t.Fatalf("single host must keep the prefer fallback sequence: %v", dsns)
|
|
}
|
|
for _, dsn := range dsns {
|
|
if !strings.Contains(dsn, "host='172.22.232.10' port=54321 ") {
|
|
t.Fatalf("single host DSN changed: %s", dsn)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestOpenAndPingDBMultiHostFailsOverToReachableEndpoint(t *testing.T) {
|
|
state := &failoverState{pingErrors: map[string]error{
|
|
"require@203.0.113.10": errors.New("dial tcp 203.0.113.10:54321: connect: connection refused"),
|
|
}}
|
|
opener := failoverOpener{state: state}
|
|
db, err := openAndPingDB(connectParams{Host: "203.0.113.10,198.51.100.20", Port: 54321}, time.Second, opener.open)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer db.Close()
|
|
attempts := state.attempts()
|
|
if strings.Join(attempts, " -> ") != "require@203.0.113.10 -> require@198.51.100.20" {
|
|
t.Fatalf("unexpected failover order: %v", attempts)
|
|
}
|
|
dsns := state.connectionStrings()
|
|
if len(dsns) != 2 {
|
|
t.Fatalf("unexpected DSN count: %v", dsns)
|
|
}
|
|
if !strings.Contains(dsns[0], "host='203.0.113.10' port=54321") {
|
|
t.Fatalf("first DSN must target the first endpoint: %s", dsns[0])
|
|
}
|
|
if !strings.Contains(dsns[1], "host='198.51.100.20' port=54321") {
|
|
t.Fatalf("second DSN must target the second endpoint: %s", dsns[1])
|
|
}
|
|
}
|
|
|
|
func TestOpenAndPingDBMultiHostAppliesConfiguredPortToEveryEndpoint(t *testing.T) {
|
|
// Regression for #7885: the whole comma-joined host string used to reach
|
|
// gokb as one hostname (`lookup ip1,ip2: no such host`) while the
|
|
// configured port was never applied per endpoint.
|
|
state := &failoverState{pingErrors: map[string]error{
|
|
"require@10.10.0.1": errors.New("dial tcp: connection refused"),
|
|
"disable@10.10.0.1": errors.New("dial tcp: connection refused"),
|
|
}}
|
|
opener := failoverOpener{state: state}
|
|
db, err := openAndPingDB(connectParams{Host: "10.10.0.1,10.10.0.2", Port: 54321}, time.Second, opener.open)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer db.Close()
|
|
for _, dsn := range state.connectionStrings() {
|
|
if strings.Contains(dsn, "10.10.0.1,") || strings.Contains(dsn, ",10.10.0.2") {
|
|
t.Fatalf("comma-joined host leaked into DSN: %s", dsn)
|
|
}
|
|
if !strings.Contains(dsn, "port=54321") {
|
|
t.Fatalf("configured port missing from DSN: %s", dsn)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestOpenAndPingDBMultiHostPerEntryPorts(t *testing.T) {
|
|
state := &failoverState{pingErrors: map[string]error{
|
|
"require@10.0.0.1": errors.New("dial tcp: connection refused"),
|
|
}}
|
|
opener := failoverOpener{state: state}
|
|
db, err := openAndPingDB(connectParams{Host: "10.0.0.1:1000,10.0.0.2:2000", Port: 54321}, time.Second, opener.open)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer db.Close()
|
|
dsns := state.connectionStrings()
|
|
if !strings.Contains(dsns[0], "host='10.0.0.1' port=1000") {
|
|
t.Fatalf("first endpoint must use its embedded port: %s", dsns[0])
|
|
}
|
|
if !strings.Contains(dsns[1], "host='10.0.0.2' port=2000") {
|
|
t.Fatalf("second endpoint must use its embedded port: %s", dsns[1])
|
|
}
|
|
}
|
|
|
|
func TestOpenAndPingDBMultiHostSemicolonSeparatorFailsOver(t *testing.T) {
|
|
state := &failoverState{pingErrors: map[string]error{
|
|
"require@10.0.0.9": errors.New("dial tcp: connection refused"),
|
|
}}
|
|
opener := failoverOpener{state: state}
|
|
db, err := openAndPingDB(connectParams{Host: "10.0.0.9;10.0.0.10", Port: 54321}, time.Second, opener.open)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer db.Close()
|
|
attempts := state.attempts()
|
|
if len(attempts) != 2 || attempts[1] != "require@10.0.0.10" {
|
|
t.Fatalf("semicolon separated hosts must fail over in order: %v", attempts)
|
|
}
|
|
}
|
|
|
|
func TestOpenAndPingDBMultiHostAllEndpointsFail(t *testing.T) {
|
|
refused := errors.New("dial tcp: connection refused")
|
|
state := &failoverState{pingErrors: map[string]error{
|
|
"require@10.0.0.1": refused,
|
|
"require@10.0.0.2": refused,
|
|
}}
|
|
opener := failoverOpener{state: state}
|
|
db, err := openAndPingDB(connectParams{Host: "10.0.0.1,10.0.0.2", Port: 54321}, time.Second, opener.open)
|
|
if db != nil {
|
|
db.Close()
|
|
}
|
|
if err == nil {
|
|
t.Fatal("expected failure when every endpoint is down")
|
|
}
|
|
if !errors.Is(err, refused) {
|
|
t.Fatalf("aggregated error must wrap endpoint failures: %v", err)
|
|
}
|
|
for _, endpoint := range []string{"10.0.0.1:54321", "10.0.0.2:54321"} {
|
|
if !strings.Contains(err.Error(), endpoint) {
|
|
t.Fatalf("error must mention %s: %v", endpoint, err)
|
|
}
|
|
}
|
|
if !strings.Contains(err.Error(), "trying 2 endpoints") {
|
|
t.Fatalf("error must report the endpoint count: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestOpenAndPingDBMultiHostKeepsSSLFallbackPerEndpoint(t *testing.T) {
|
|
state := &failoverState{pingErrors: map[string]error{
|
|
"require@10.0.0.1": gokb.ErrSSLNotSupported,
|
|
"disable@10.0.0.1": errors.New("dial tcp: connection refused"),
|
|
"require@10.0.0.2": gokb.ErrSSLNotSupported,
|
|
}}
|
|
opener := failoverOpener{state: state}
|
|
db, err := openAndPingDB(connectParams{Host: "10.0.0.1,10.0.0.2", Port: 54321}, time.Second, opener.open)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer db.Close()
|
|
attempts := state.attempts()
|
|
expected := "require@10.0.0.1 -> disable@10.0.0.1 -> require@10.0.0.2 -> disable@10.0.0.2"
|
|
if strings.Join(attempts, " -> ") != expected {
|
|
t.Fatalf("unexpected attempt sequence: %v", attempts)
|
|
}
|
|
}
|
|
|
|
func TestConnectAndTestConnectionSharePreferFallback(t *testing.T) {
|
|
for _, test := range []struct {
|
|
name string
|
|
run func(*server, connectParams) error
|
|
}{
|
|
{name: "connect", run: func(server *server, cp connectParams) error { return server.connect(cp) }},
|
|
{name: "test_connection", run: func(server *server, cp connectParams) error { return server.testConnection(cp) }},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
state := &connectionAttemptState{pingErrors: map[string]error{"require": gokb.ErrSSLNotSupported}}
|
|
server := newServer()
|
|
server.openDatabase = state.open
|
|
cp := connectParams{URLParams: "sslmode=prefer", MySQLCompatMode: true}
|
|
if err := test.run(server, cp); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = server.disconnect() })
|
|
attempts, _ := state.snapshot()
|
|
if strings.Join(attempts, ",") != "require,disable" {
|
|
t.Fatalf("unexpected attempts: %v", attempts)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestKingbaseListIndexesQuerySupportsSQLServerMode(t *testing.T) {
|
|
query := kingbaseListIndexesQuery("sys_catalog", "sys", "public", "orders")
|
|
if !strings.Contains(query, "unnest(ix.indkey) WITH ORDINALITY") {
|
|
t.Fatalf("index query should preserve index column order without array subscripts: %s", query)
|
|
}
|
|
if strings.Contains(query, "[pos.n]") {
|
|
t.Fatalf("index query should not use dynamic array subscripts in SQL Server mode: %s", query)
|
|
}
|
|
}
|
|
|
|
func TestParseCatalogAttributeNumbers(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
raw any
|
|
expected string
|
|
wantErr bool
|
|
}{
|
|
{name: "int2vector string", raw: "1 2 4", expected: "1,2,4"},
|
|
{name: "array string", raw: "{3,5}", expected: "3,5"},
|
|
{name: "bytes", raw: []byte("6 7"), expected: "6,7"},
|
|
{name: "bracketed array string", raw: "[8 9]", expected: "8,9"},
|
|
{name: "int16 slice", raw: []int16{10, 11}, expected: "10,11"},
|
|
{name: "empty", raw: nil, expected: ""},
|
|
{name: "invalid", raw: "1 bad", wantErr: true},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
numbers, err := parseCatalogAttributeNumbers(test.raw)
|
|
if test.wantErr {
|
|
if err == nil {
|
|
t.Fatalf("expected parse error, got %v", numbers)
|
|
}
|
|
return
|
|
}
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
parts := make([]string, len(numbers))
|
|
for index, number := range numbers {
|
|
parts[index] = strconv.Itoa(number)
|
|
}
|
|
if actual := strings.Join(parts, ","); actual != test.expected {
|
|
t.Fatalf("unexpected numbers: %q", actual)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestParseConstraintEnabled(t *testing.T) {
|
|
for _, test := range []struct {
|
|
name string
|
|
raw any
|
|
want bool
|
|
}{
|
|
{name: "boolean true", raw: true, want: true},
|
|
{name: "boolean false", raw: false, want: false},
|
|
{name: "enabled string", raw: "E", want: true},
|
|
{name: "disabled string", raw: "D", want: false},
|
|
{name: "enabled bytes", raw: []byte("enabled"), want: true},
|
|
{name: "disabled bytes", raw: []byte("disabled"), want: false},
|
|
{name: "one", raw: int64(1), want: true},
|
|
{name: "zero", raw: int64(0), want: false},
|
|
{name: "null", raw: nil, want: true},
|
|
{name: "unknown", raw: "future-state", want: true},
|
|
{name: "not validated label defaults enabled", raw: "N", want: true},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
if actual := parseConstraintEnabled(test.raw); actual != test.want {
|
|
t.Fatalf("parseConstraintEnabled(%#v) = %v, want %v", test.raw, actual, test.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestListIndexesFallsBackWhenWithOrdinalityIsUnsupported(t *testing.T) {
|
|
state := &metadataDriverState{query: func(query string) (driver.Rows, error) {
|
|
switch {
|
|
case strings.Contains(query, "WITH ORDINALITY"):
|
|
return nil, &gokb.Error{Code: gokb.ErrorCode("42601"), Message: `syntax error at or near "WITH ORDINALITY"`}
|
|
case strings.Contains(query, "SELECT i.relname, am.amname") && strings.Contains(query, "ix.indkey"):
|
|
return &valueRows{
|
|
columns: []string{"relname", "amname", "indisunique", "indisprimary", "indkey"},
|
|
rows: [][]driver.Value{
|
|
{"orders_customer_idx", "btree", false, false, "2 3"},
|
|
{"orders_pkey", "btree", true, true, []byte("1")},
|
|
},
|
|
}, nil
|
|
case strings.Contains(query, "SELECT a.attnum, a.attname"):
|
|
return &valueRows{
|
|
columns: []string{"attnum", "attname"},
|
|
rows: [][]driver.Value{{int64(1), "id"}, {int64(2), "customer_id"}, {int64(3), "created_at"}},
|
|
}, nil
|
|
default:
|
|
return nil, errors.New("unexpected query: " + query)
|
|
}
|
|
}}
|
|
server := newServer()
|
|
server.db = openMetadataDB(t, state)
|
|
|
|
for range 2 {
|
|
indexes, err := server.listIndexes("PUBLIC", "orders")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(indexes) != 2 || strings.Join(indexes[0].Columns, ",") != "customer_id,created_at" || strings.Join(indexes[1].Columns, ",") != "id" {
|
|
t.Fatalf("unexpected indexes: %#v", indexes)
|
|
}
|
|
}
|
|
|
|
var ordinalityQueries int
|
|
for _, query := range state.snapshotQueries() {
|
|
if strings.Contains(query, "WITH ORDINALITY") {
|
|
ordinalityQueries++
|
|
}
|
|
}
|
|
if ordinalityQueries != 1 || !server.indexOrdinalityUnsupported {
|
|
t.Fatalf("unsupported capability was not cached: queries=%v", state.snapshotQueries())
|
|
}
|
|
}
|
|
|
|
func TestGetColumnsUsesResolvedSchemaAcrossCatalogMetadata(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
postgresCatalog bool
|
|
requestedSchema string
|
|
resolvedSchema string
|
|
visibilityFunction string
|
|
sqlServerIdentity bool
|
|
}{
|
|
{name: "sys catalog search path", resolvedSchema: "tenant_visible", visibilityFunction: "sys_catalog.sys_table_is_visible(c.oid)", sqlServerIdentity: true},
|
|
{name: "postgres catalog search path", postgresCatalog: true, resolvedSchema: "tenant_pg", visibilityFunction: "pg_catalog.pg_table_is_visible(c.oid)"},
|
|
{name: "explicit schema", requestedSchema: "tenant_explicit", resolvedSchema: "tenant_explicit"},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
catalog := "sys_catalog"
|
|
prefix := "sys"
|
|
if test.postgresCatalog {
|
|
catalog = "pg_catalog"
|
|
prefix = "pg"
|
|
}
|
|
state := &metadataDriverState{query: func(query string) (driver.Rows, error) {
|
|
switch {
|
|
case strings.Contains(query, "FROM "+catalog+"."+prefix+"_attribute a"):
|
|
if test.visibilityFunction != "" {
|
|
if !strings.Contains(query, test.visibilityFunction) {
|
|
return nil, fmt.Errorf("unqualified columns query did not use the catalog visibility function: %s", query)
|
|
}
|
|
} else if strings.Contains(query, "table_is_visible") || !strings.Contains(query, "n.nspname = '"+test.requestedSchema+"'") {
|
|
return nil, fmt.Errorf("explicit-schema columns query changed resolution behavior: %s", query)
|
|
}
|
|
return &valueRows{
|
|
columns: []string{"nspname", "attname", "format_type", "nullable", "default", "comment", "precision", "scale", "length", "identity"},
|
|
rows: [][]driver.Value{{test.resolvedSchema, "feearea", "character varying", false, nil, nil, nil, nil, nil, nil}},
|
|
}, nil
|
|
case strings.Contains(query, "FROM information_schema.table_constraints"):
|
|
if !strings.Contains(query, "tc.table_schema='"+test.resolvedSchema+"'") {
|
|
return nil, fmt.Errorf("primary-key lookup did not use resolved schema: %s", query)
|
|
}
|
|
return &valueRows{columns: []string{"column_name"}, rows: [][]driver.Value{{"feearea"}}}, nil
|
|
case strings.Contains(query, "FROM sys.identity_columns"):
|
|
if !strings.Contains(query, "n.nspname='"+test.resolvedSchema+"'") {
|
|
return nil, fmt.Errorf("identity lookup did not use resolved schema: %s", query)
|
|
}
|
|
return &valueRows{columns: []string{"attname", "seed_value", "increment_value"}, rows: [][]driver.Value{{"feearea", "1", "1"}}}, nil
|
|
default:
|
|
return nil, fmt.Errorf("unexpected query: %s", query)
|
|
}
|
|
}}
|
|
server := newServer()
|
|
server.db = openMetadataDB(t, state)
|
|
server.mode.postgresCatalog = test.postgresCatalog
|
|
server.mode.sqlServerIdentity = test.sqlServerIdentity
|
|
|
|
columns, err := server.getColumns(test.requestedSchema, "m_workflow")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(columns) != 1 || columns[0].ResolvedSchema == nil || *columns[0].ResolvedSchema != test.resolvedSchema || !columns[0].IsPrimaryKey {
|
|
t.Fatalf("resolved relation metadata was lost: %#v", columns)
|
|
}
|
|
if test.sqlServerIdentity && (columns[0].Extra == nil || *columns[0].Extra != "IDENTITY(1,1)") {
|
|
t.Fatalf("identity metadata did not use the resolved relation: %#v", columns)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestListIndexesDoesNotFallbackForUnrelatedErrors(t *testing.T) {
|
|
expectedErr := errors.New("metadata connection reset")
|
|
state := &metadataDriverState{query: func(string) (driver.Rows, error) { return nil, expectedErr }}
|
|
server := newServer()
|
|
server.db = openMetadataDB(t, state)
|
|
|
|
if _, err := server.listIndexes("PUBLIC", "orders"); !errors.Is(err, expectedErr) {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if queries := state.snapshotQueries(); len(queries) != 1 || server.indexOrdinalityUnsupported {
|
|
t.Fatalf("unrelated error triggered fallback: %v", queries)
|
|
}
|
|
}
|
|
|
|
func TestListForeignKeysUsesCatalogForV7(t *testing.T) {
|
|
state := &metadataDriverState{query: func(query string) (driver.Rows, error) {
|
|
switch {
|
|
case strings.Contains(query, "FROM information_schema.table_constraints tc"):
|
|
return nil, errors.New("V7 must not query information_schema foreign keys")
|
|
case strings.Contains(query, "FROM sys_catalog.sys_constraint c"):
|
|
return &valueRows{
|
|
columns: []string{"conname", "conkey", "confkey", "nspname", "relname"},
|
|
rows: [][]driver.Value{{"orders_customer_fkey", "2 3", "1 2", "PUBLIC", "customers"}},
|
|
}, nil
|
|
case strings.Contains(query, "SELECT a.attnum, a.attname") && strings.Contains(query, "c.relname = 'orders'"):
|
|
return &valueRows{columns: []string{"attnum", "attname"}, rows: [][]driver.Value{{int64(2), "customer_id"}, {int64(3), "customer_region"}}}, nil
|
|
case strings.Contains(query, "SELECT a.attnum, a.attname") && strings.Contains(query, "c.relname = 'customers'"):
|
|
return &valueRows{columns: []string{"attnum", "attname"}, rows: [][]driver.Value{{int64(1), "id"}, {int64(2), "region"}}}, nil
|
|
default:
|
|
return nil, errors.New("unexpected query: " + query)
|
|
}
|
|
}}
|
|
server := newServer()
|
|
server.db = openMetadataDB(t, state)
|
|
server.mode.legacyV7 = true
|
|
|
|
keys, err := server.listForeignKeys("PUBLIC", "orders")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(keys) != 2 || keys[0].Column != "customer_id" || keys[0].RefColumn != "id" || keys[1].Column != "customer_region" || keys[1].RefColumn != "region" {
|
|
t.Fatalf("unexpected foreign keys: %#v", keys)
|
|
}
|
|
}
|
|
|
|
func TestListForeignKeysKeepsEmptyInformationSchemaResultOnV8(t *testing.T) {
|
|
state := &metadataDriverState{query: func(query string) (driver.Rows, error) {
|
|
if strings.Contains(query, "FROM information_schema.table_constraints tc") {
|
|
return &valueRows{columns: []string{"constraint_name", "column_name", "table_name", "column_name"}}, nil
|
|
}
|
|
return nil, errors.New("V8 empty result must not trigger a catalog query: " + query)
|
|
}}
|
|
server := newServer()
|
|
server.db = openMetadataDB(t, state)
|
|
|
|
keys, err := server.listForeignKeys("PUBLIC", "orders")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(keys) != 0 || len(state.snapshotQueries()) != 1 {
|
|
t.Fatalf("unexpected foreign keys or query count: keys=%#v queries=%v", keys, state.snapshotQueries())
|
|
}
|
|
}
|
|
|
|
func TestListConstraintsResolvesColumnsAndForeignKeyDetails(t *testing.T) {
|
|
state := &metadataDriverState{query: func(query string) (driver.Rows, error) {
|
|
switch {
|
|
case strings.Contains(query, "FROM sys_catalog.sys_constraint c"):
|
|
return &valueRows{
|
|
columns: []string{"conname", "contype", "definition", "conkey", "ref_schema", "ref_table", "confkey", "match_type", "on_update", "on_delete", "condeferrable", "condeferred", "convalidated", "constatus"},
|
|
rows: [][]driver.Value{
|
|
{"orders_amount_check", "c", "CHECK (amount > 0)", "{4}", nil, nil, nil, " ", " ", " ", false, false, []byte("f"), []byte("D")},
|
|
{"orders_customer_fkey", "f", "FOREIGN KEY (customer_id, customer_region) REFERENCES customers(id, region) ON DELETE CASCADE", "{2,3}", "PUBLIC", "customers", "{1,2}", "s", "a", "c", true, true, "t", "E"},
|
|
},
|
|
}, nil
|
|
case strings.Contains(query, "SELECT a.attnum, a.attname") && strings.Contains(query, "c.relname = 'orders'"):
|
|
return &valueRows{columns: []string{"attnum", "attname"}, rows: [][]driver.Value{{int64(2), "customer_id"}, {int64(3), "customer_region"}, {int64(4), "amount"}}}, nil
|
|
case strings.Contains(query, "SELECT a.attnum, a.attname") && strings.Contains(query, "c.relname = 'customers'"):
|
|
return &valueRows{columns: []string{"attnum", "attname"}, rows: [][]driver.Value{{int64(1), "id"}, {int64(2), "region"}}}, nil
|
|
default:
|
|
return nil, errors.New("unexpected query: " + query)
|
|
}
|
|
}}
|
|
server := newServer()
|
|
server.db = openMetadataDB(t, state)
|
|
|
|
constraints, err := server.listConstraints("PUBLIC", "orders")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(constraints) != 2 {
|
|
t.Fatalf("unexpected constraints: %#v", constraints)
|
|
}
|
|
check, foreignKey := constraints[0], constraints[1]
|
|
if check.ConstraintType != "CHECK" || !equalStringSlices(check.Columns, []string{"amount"}) || check.Valid || check.Enabled {
|
|
t.Fatalf("unexpected check constraint: %#v", check)
|
|
}
|
|
if foreignKey.ConstraintType != "FOREIGN KEY" || !foreignKey.Valid || !foreignKey.Enabled || !equalStringSlices(foreignKey.Columns, []string{"customer_id", "customer_region"}) || !equalStringSlices(foreignKey.RefColumns, []string{"id", "region"}) {
|
|
t.Fatalf("unexpected foreign key columns: %#v", foreignKey)
|
|
}
|
|
if foreignKey.RefSchema == nil || *foreignKey.RefSchema != "PUBLIC" || foreignKey.RefTable == nil || *foreignKey.RefTable != "customers" || foreignKey.MatchType == nil || *foreignKey.MatchType != "SIMPLE" || foreignKey.OnUpdate == nil || *foreignKey.OnUpdate != "NO ACTION" || foreignKey.OnDelete == nil || *foreignKey.OnDelete != "CASCADE" || !foreignKey.Deferrable || !foreignKey.InitiallyDeferred {
|
|
t.Fatalf("unexpected foreign key details: %#v", foreignKey)
|
|
}
|
|
queries := strings.Join(state.snapshotQueries(), "\n")
|
|
if !strings.Contains(queries, "sys_catalog.sys_get_constraintdef") {
|
|
t.Fatalf("constraints must use the active catalog deparser: %s", queries)
|
|
}
|
|
}
|
|
|
|
func TestKingbaseConstraintQueryHasLegacyV7Fallback(t *testing.T) {
|
|
modern := kingbaseConstraintsQuery("sys_catalog", "sys", "public", "orders", false, false, false)
|
|
if !strings.Contains(modern, "sys_catalog.sys_get_constraintdef") || !strings.Contains(modern, "c.convalidated") || !strings.Contains(modern, "COALESCE(c.conname, '')") {
|
|
t.Fatalf("modern constraint query missing metadata fields: %s", modern)
|
|
}
|
|
if !strings.Contains(modern, "COALESCE(CAST(c.convalidated AS text), 'T')") {
|
|
t.Fatalf("modern constraint query must normalize convalidated to text: %s", modern)
|
|
}
|
|
if !strings.Contains(modern, "COALESCE(CAST(c.constatus AS text), 'E')") {
|
|
t.Fatalf("modern constraint query must normalize constatus to text: %s", modern)
|
|
}
|
|
legacy := kingbaseConstraintsQuery("sys_catalog", "sys", "public", "orders", true, true, true)
|
|
if strings.Contains(legacy, "sys_get_constraintdef") || strings.Contains(legacy, "c.convalidated") || !strings.Contains(legacy, "''") || !strings.Contains(legacy, "COALESCE(c.conname, '')") {
|
|
t.Fatalf("legacy V7 constraint query is not safe: %s", legacy)
|
|
}
|
|
}
|
|
|
|
func TestListConstraintsFallsBackWhenValidatedColumnIsUnsupported(t *testing.T) {
|
|
state := &metadataDriverState{query: func(query string) (driver.Rows, error) {
|
|
if strings.Contains(query, "c.convalidated") {
|
|
return nil, &gokb.Error{Code: gokb.ErrorCode("42703"), Message: "column c.convalidated does not exist"}
|
|
}
|
|
if strings.Contains(query, "FROM sys_catalog.sys_constraint c") {
|
|
return &valueRows{
|
|
columns: []string{"conname", "contype", "definition", "conkey", "ref_schema", "ref_table", "confkey", "match_type", "on_update", "on_delete", "condeferrable", "condeferred", "valid", "enabled"},
|
|
rows: [][]driver.Value{{"orders_pkey", "p", "PRIMARY KEY (id)", "{1}", nil, nil, nil, nil, nil, nil, false, false, true, true}},
|
|
}, nil
|
|
}
|
|
if strings.Contains(query, "SELECT a.attnum, a.attname") {
|
|
return &valueRows{columns: []string{"attnum", "attname"}, rows: [][]driver.Value{{int64(1), "id"}}}, nil
|
|
}
|
|
return nil, errors.New("unexpected query: " + query)
|
|
}}
|
|
server := newServer()
|
|
server.db = openMetadataDB(t, state)
|
|
|
|
constraints, err := server.listConstraints("PUBLIC", "orders")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(constraints) != 1 || constraints[0].Definition != "PRIMARY KEY (id)" || !constraints[0].Valid {
|
|
t.Fatalf("unexpected fallback constraints: %#v", constraints)
|
|
}
|
|
if !server.constraintValidatedUnsupported {
|
|
t.Fatal("validated-column fallback was not cached")
|
|
}
|
|
if len(state.snapshotQueries()) != 3 {
|
|
t.Fatalf("expected failed modern query, fallback query and attribute query: %v", state.snapshotQueries())
|
|
}
|
|
|
|
if _, err := server.listConstraints("PUBLIC", "orders"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
queries := state.snapshotQueries()
|
|
for _, query := range queries[3:] {
|
|
if strings.Contains(query, "c.convalidated") {
|
|
t.Fatalf("cached fallback queried unsupported convalidated column: %s", query)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestListConstraintsLegacyV7KeepsStructuralMetadata(t *testing.T) {
|
|
state := &metadataDriverState{query: func(query string) (driver.Rows, error) {
|
|
switch {
|
|
case strings.Contains(query, "FROM sys_catalog.sys_constraint c"):
|
|
if strings.Contains(query, "c.convalidated") {
|
|
return nil, errors.New("V7 query used unsupported constraint metadata: " + query)
|
|
}
|
|
if strings.Contains(query, "sys_get_constraintdef") {
|
|
return nil, &gokb.Error{Code: gokb.ErrorCode("42883"), Message: "function sys_get_constraintdef does not exist"}
|
|
}
|
|
return &valueRows{
|
|
columns: []string{"conname", "contype", "definition", "conkey", "ref_schema", "ref_table", "confkey", "match_type", "on_update", "on_delete", "condeferrable", "condeferred", "valid", "enabled"},
|
|
rows: [][]driver.Value{{"orders_pkey", "p", "", "[1]", nil, nil, nil, nil, nil, nil, false, false, true, true}},
|
|
}, nil
|
|
case strings.Contains(query, "SELECT a.attnum, a.attname"):
|
|
return &valueRows{columns: []string{"attnum", "attname"}, rows: [][]driver.Value{{int64(1), "id"}}}, nil
|
|
default:
|
|
return nil, errors.New("unexpected query: " + query)
|
|
}
|
|
}}
|
|
server := newServer()
|
|
server.db = openMetadataDB(t, state)
|
|
server.mode.legacyV7 = true
|
|
|
|
constraints, err := server.listConstraints("PUBLIC", "orders")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(constraints) != 1 || constraints[0].Name != "orders_pkey" || constraints[0].Definition != "" || !constraints[0].Valid || !equalStringSlices(constraints[0].Columns, []string{"id"}) || !server.constraintDefinitionUnsupported {
|
|
t.Fatalf("unexpected V7 constraints: %#v", constraints)
|
|
}
|
|
}
|
|
|
|
func TestListConstraintsLegacyV7UsesSupportedDeparser(t *testing.T) {
|
|
state := &metadataDriverState{query: func(query string) (driver.Rows, error) {
|
|
switch {
|
|
case strings.Contains(query, "FROM sys_catalog.sys_constraint c"):
|
|
if !strings.Contains(query, "sys_catalog.sys_get_constraintdef") || strings.Contains(query, "COALESCE(CAST(c.convalidated AS text)") || strings.Contains(query, "c.constatus") {
|
|
return nil, errors.New("unexpected V7 constraint query: " + query)
|
|
}
|
|
return &valueRows{
|
|
columns: []string{"conname", "contype", "definition", "conkey", "ref_schema", "ref_table", "confkey", "match_type", "on_update", "on_delete", "condeferrable", "condeferred", "valid", "enabled"},
|
|
rows: [][]driver.Value{{"orders_pkey", "p", "PRIMARY KEY (id)", "[1]", nil, nil, nil, nil, nil, nil, false, false, true, "E"}},
|
|
}, nil
|
|
case strings.Contains(query, "SELECT a.attnum, a.attname"):
|
|
return &valueRows{columns: []string{"attnum", "attname"}, rows: [][]driver.Value{{int64(1), "id"}}}, nil
|
|
default:
|
|
return nil, errors.New("unexpected query: " + query)
|
|
}
|
|
}}
|
|
server := newServer()
|
|
server.db = openMetadataDB(t, state)
|
|
server.mode.legacyV7 = true
|
|
|
|
constraints, err := server.listConstraints("PUBLIC", "orders")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(constraints) != 1 || constraints[0].Definition != "PRIMARY KEY (id)" || !constraints[0].Enabled || server.constraintDefinitionUnsupported {
|
|
t.Fatalf("unexpected V7 constraints: %#v", constraints)
|
|
}
|
|
}
|
|
|
|
func TestKingbaseConstraintQueryUsesPostgresCatalog(t *testing.T) {
|
|
query := kingbaseConstraintsQuery("pg_catalog", "pg", "public", "orders", false, false, false)
|
|
if !strings.Contains(query, "pg_catalog.pg_get_constraintdef") || strings.Contains(query, "sys_get_constraintdef") {
|
|
t.Fatalf("PostgreSQL catalog query used the wrong deparser: %s", query)
|
|
}
|
|
if !strings.Contains(query, "FROM pg_catalog.pg_constraint") || !strings.Contains(query, "pg_catalog.pg_namespace") {
|
|
t.Fatalf("PostgreSQL catalog query used the wrong catalog tables: %s", query)
|
|
}
|
|
}
|
|
|
|
func TestListConstraintsHandlesNullAttributeVectors(t *testing.T) {
|
|
state := &metadataDriverState{query: func(query string) (driver.Rows, error) {
|
|
switch {
|
|
case strings.Contains(query, "FROM sys_catalog.sys_constraint c"):
|
|
return &valueRows{
|
|
columns: []string{"conname", "contype", "definition", "conkey", "ref_schema", "ref_table", "confkey", "match_type", "on_update", "on_delete", "condeferrable", "condeferred", "valid", "enabled"},
|
|
rows: [][]driver.Value{{"table_check", "c", "CHECK (true)", nil, nil, nil, nil, nil, nil, nil, false, false, true, true}},
|
|
}, nil
|
|
case strings.Contains(query, "SELECT a.attnum, a.attname"):
|
|
return &valueRows{columns: []string{"attnum", "attname"}}, nil
|
|
default:
|
|
return nil, errors.New("unexpected query: " + query)
|
|
}
|
|
}}
|
|
server := newServer()
|
|
server.db = openMetadataDB(t, state)
|
|
|
|
constraints, err := server.listConstraints("PUBLIC", "orders")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(constraints) != 1 || len(constraints[0].Columns) != 0 || len(constraints[0].RefColumns) != 0 {
|
|
t.Fatalf("NULL attribute vectors should decode as empty lists: %#v", constraints)
|
|
}
|
|
}
|
|
|
|
func TestListConstraintsCachesReferencedAttributes(t *testing.T) {
|
|
state := &metadataDriverState{query: func(query string) (driver.Rows, error) {
|
|
switch {
|
|
case strings.Contains(query, "FROM sys_catalog.sys_constraint c"):
|
|
return &valueRows{
|
|
columns: []string{"conname", "contype", "definition", "conkey", "ref_schema", "ref_table", "confkey", "match_type", "on_update", "on_delete", "condeferrable", "condeferred", "valid", "enabled"},
|
|
rows: [][]driver.Value{
|
|
{"orders_customer_fkey", "f", "FOREIGN KEY (customer_id) REFERENCES customers(id)", "{2}", "PUBLIC", "customers", "{1}", "s", "a", "a", false, false, true, true},
|
|
{"orders_region_fkey", "f", "FOREIGN KEY (customer_region) REFERENCES customers(region)", "{3}", "PUBLIC", "customers", "{2}", "s", "a", "a", false, false, true, true},
|
|
},
|
|
}, nil
|
|
case strings.Contains(query, "SELECT a.attnum, a.attname") && strings.Contains(query, "c.relname = 'orders'"):
|
|
return &valueRows{columns: []string{"attnum", "attname"}, rows: [][]driver.Value{{int64(2), "customer_id"}, {int64(3), "customer_region"}}}, nil
|
|
case strings.Contains(query, "SELECT a.attnum, a.attname") && strings.Contains(query, "c.relname = 'customers'"):
|
|
return &valueRows{columns: []string{"attnum", "attname"}, rows: [][]driver.Value{{int64(1), "id"}, {int64(2), "region"}}}, nil
|
|
default:
|
|
return nil, errors.New("unexpected query: " + query)
|
|
}
|
|
}}
|
|
server := newServer()
|
|
server.db = openMetadataDB(t, state)
|
|
|
|
constraints, err := server.listConstraints("PUBLIC", "orders")
|
|
if err != nil || len(constraints) != 2 {
|
|
t.Fatalf("unexpected constraints: %v %#v", err, constraints)
|
|
}
|
|
var referencedAttributeQueries int
|
|
for _, query := range state.snapshotQueries() {
|
|
if strings.Contains(query, "SELECT a.attnum, a.attname") && strings.Contains(query, "c.relname = 'customers'") {
|
|
referencedAttributeQueries++
|
|
}
|
|
}
|
|
if referencedAttributeQueries != 1 {
|
|
t.Fatalf("referenced relation attributes were not cached: %v", state.snapshotQueries())
|
|
}
|
|
}
|
|
|
|
func TestKingbaseConstraintLabels(t *testing.T) {
|
|
for input, expected := range map[string]string{"p": "PRIMARY KEY", "f": "FOREIGN KEY", "u": "UNIQUE", "c": "CHECK", "t": "CONSTRAINT TRIGGER", "x": "EXCLUDE", "n": "NOT NULL", "custom": "custom"} {
|
|
if actual := kingbaseConstraintTypeName(input); actual != expected {
|
|
t.Fatalf("type %q: expected %q, got %q", input, expected, actual)
|
|
}
|
|
}
|
|
for input, expected := range map[string]string{"a": "NO ACTION", "r": "RESTRICT", "c": "CASCADE", "n": "SET NULL", "d": "SET DEFAULT"} {
|
|
actual := kingbaseConstraintAction(sql.NullString{String: input, Valid: true})
|
|
if actual == nil || *actual != expected {
|
|
t.Fatalf("action %q: expected %q, got %v", input, expected, actual)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestKingbaseV7VersionPattern(t *testing.T) {
|
|
for _, test := range []struct {
|
|
version string
|
|
v7 bool
|
|
}{
|
|
{version: "Kingbase V007R001C002B0014", v7: true},
|
|
{version: "KingbaseES V008R006C008B0014"},
|
|
{version: "PostgreSQL 12.1"},
|
|
} {
|
|
match := kingbaseReleasePattern.FindStringSubmatch(test.version)
|
|
actual := false
|
|
if len(match) == 2 {
|
|
major, err := strconv.Atoi(match[1])
|
|
actual = err == nil && major == 7
|
|
}
|
|
if actual != test.v7 {
|
|
t.Fatalf("version=%q: expected v7=%v, got %v", test.version, test.v7, actual)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestKingbaseCatalogFunctionsFollowMetadataMode(t *testing.T) {
|
|
registerExpressionFallbackDriver.Do(func() { sql.Register("kingbase-expression-fallback-test", fallbackDriver{}) })
|
|
tests := []struct {
|
|
name string
|
|
postgresCatalog bool
|
|
expectedIndex string
|
|
expectedTrigger string
|
|
}{
|
|
{name: "sys catalog", expectedIndex: "sys_catalog.sys_get_indexdef", expectedTrigger: "sys_catalog.sys_get_triggerdef"},
|
|
{name: "postgres catalog", postgresCatalog: true, expectedIndex: "pg_catalog.pg_get_indexdef", expectedTrigger: "pg_catalog.pg_get_triggerdef"},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
state := &fallbackDriverState{}
|
|
expressionFallbackState.Store(state)
|
|
db, err := sql.Open("kingbase-expression-fallback-test", "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
db.SetMaxOpenConns(1)
|
|
t.Cleanup(func() { _ = db.Close() })
|
|
server := newServer()
|
|
server.db = db
|
|
server.mode.postgresCatalog = test.postgresCatalog
|
|
|
|
if _, err := server.listIndexDefinitions("public", "orders"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := server.listTriggerDefinitions("public", "orders"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
state.mu.Lock()
|
|
queries := append([]string(nil), state.queries...)
|
|
state.mu.Unlock()
|
|
if len(queries) != 2 || !strings.Contains(queries[0], test.expectedIndex+"(") || !strings.Contains(queries[1], test.expectedTrigger+"(") {
|
|
t.Fatalf("catalog functions do not match metadata mode: %v", queries)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestListTriggerDefinitionsFallsBackToSingleArgument(t *testing.T) {
|
|
state := &metadataDriverState{query: func(query string) (driver.Rows, error) {
|
|
if strings.Contains(query, "sys_get_triggerdef(tg.oid, true)") {
|
|
return nil, &gokb.Error{Code: gokb.ErrorCode("42883"), Message: "function SYS_CATALOG.SYS_GET_TRIGGERDEF(OID, BOOLEAN) does not exist"}
|
|
}
|
|
if strings.Contains(query, "tg.tgisinternal") {
|
|
return nil, &gokb.Error{Code: gokb.ErrorCode("42703"), Message: "column TG.TGISINTERNAL does not exist"}
|
|
}
|
|
if strings.Contains(query, "sys_get_triggerdef(tg.oid)") {
|
|
if !strings.Contains(query, "tg.tgkind <> 'c'") {
|
|
return nil, errors.New("V7 trigger query did not exclude constraint triggers: " + query)
|
|
}
|
|
return &valueRows{columns: []string{"definition"}, rows: [][]driver.Value{{"CREATE TRIGGER orders_audit ..."}}}, nil
|
|
}
|
|
return nil, errors.New("unexpected query: " + query)
|
|
}}
|
|
server := newServer()
|
|
server.db = openMetadataDB(t, state)
|
|
|
|
for range 2 {
|
|
definitions, err := server.listTriggerDefinitions("PUBLIC", "orders")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(definitions) != 1 || definitions[0] != "CREATE TRIGGER orders_audit ..." {
|
|
t.Fatalf("unexpected definitions: %#v", definitions)
|
|
}
|
|
}
|
|
|
|
var prettyQueries int
|
|
for _, query := range state.snapshotQueries() {
|
|
if strings.Contains(query, "tg.oid, true") {
|
|
prettyQueries++
|
|
}
|
|
}
|
|
if prettyQueries != 1 || !server.triggerPrettyUnsupported || !server.triggerInternalUnsupported {
|
|
t.Fatalf("unsupported signature was not cached: %v", state.snapshotQueries())
|
|
}
|
|
}
|
|
|
|
func TestListTriggersFallsBackToV7InternalPredicate(t *testing.T) {
|
|
state := &metadataDriverState{query: func(query string) (driver.Rows, error) {
|
|
if strings.Contains(query, "tg.tgisinternal") {
|
|
return nil, &gokb.Error{Code: gokb.ErrorCode("42703"), Message: "column TG.TGISINTERNAL does not exist"}
|
|
}
|
|
if strings.Contains(query, "tg.tgkind <> 'c'") {
|
|
return &valueRows{columns: []string{"tgname", "events", "tgtype"}, rows: [][]driver.Value{{"orders_audit", "INSERT", int64(7)}}}, nil
|
|
}
|
|
return nil, errors.New("unexpected query: " + query)
|
|
}}
|
|
server := newServer()
|
|
server.db = openMetadataDB(t, state)
|
|
|
|
for range 2 {
|
|
triggers, err := server.listTriggers("PUBLIC", "orders")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(triggers) != 1 || triggers[0].Name != "orders_audit" {
|
|
t.Fatalf("unexpected triggers: %#v", triggers)
|
|
}
|
|
}
|
|
if queries := state.snapshotQueries(); len(queries) != 3 || !server.triggerInternalUnsupported {
|
|
t.Fatalf("unsupported column was not cached: %v", queries)
|
|
}
|
|
}
|
|
|
|
func TestListTriggerDefinitionsDoesNotFallbackForUnrelatedErrors(t *testing.T) {
|
|
expectedErr := errors.New("permission denied for sys_trigger")
|
|
state := &metadataDriverState{query: func(string) (driver.Rows, error) { return nil, expectedErr }}
|
|
server := newServer()
|
|
server.db = openMetadataDB(t, state)
|
|
|
|
if _, err := server.listTriggerDefinitions("PUBLIC", "orders"); !errors.Is(err, expectedErr) {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if queries := state.snapshotQueries(); len(queries) != 1 || server.triggerPrettyUnsupported || server.triggerInternalUnsupported {
|
|
t.Fatalf("unrelated error triggered fallback: %v", queries)
|
|
}
|
|
}
|
|
|
|
func TestMySQLCompatSchemaQueryKeepsUserSchemasWithSystemLikeNames(t *testing.T) {
|
|
query := kingbaseMySQLCompatListSchemasSQL
|
|
for _, prefix := range []string{"SYS", "XLOG"} {
|
|
expected := "NOT LIKE '" + prefix + `#_%' ESCAPE '#'`
|
|
if !strings.Contains(query, expected) {
|
|
t.Fatalf("schema query must only hide the internal %s_ prefix: %s", prefix, query)
|
|
}
|
|
if strings.Contains(query, "NOT LIKE '"+prefix+"%'") {
|
|
t.Fatalf("schema query must preserve user schemas such as %sLOG: %s", prefix, query)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestListSchemasQueryIncludesSystemSchemasWhenEnabled(t *testing.T) {
|
|
for _, mode := range []kingbaseMode{{}, {postgresCatalog: true}, {mysqlCompat: true}} {
|
|
query := kingbaseListSchemasSQL(mode, true)
|
|
if strings.Contains(query, "NOT LIKE") || strings.Contains(query, "<>") {
|
|
t.Fatalf("show-system query must not filter schemas: %s", query)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestListSchemasQueryKeepsDefaultTemporarySchemaFilters(t *testing.T) {
|
|
for _, mode := range []kingbaseMode{{}, {postgresCatalog: true}} {
|
|
query := kingbaseListSchemasSQL(mode, false)
|
|
if !strings.Contains(query, "temp_%") {
|
|
t.Fatalf("default query must keep temporary schema filters: %s", query)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestMetadataNormalizationHelpers(t *testing.T) {
|
|
if normalizeTableType("BASE TABLE") != "TABLE" {
|
|
t.Fatal("BASE TABLE was not normalized")
|
|
}
|
|
if decodeTriggerTiming(1<<6) != "INSTEAD OF" || decodeTriggerTiming(1<<1) != "BEFORE" || decodeTriggerTiming(0) != "AFTER" {
|
|
t.Fatal("trigger timing decoding is incorrect")
|
|
}
|
|
length := boundedVarcharLength("character varying ( 128 )")
|
|
if length == nil || *length != 128 {
|
|
t.Fatalf("bounded varchar length not parsed: %v", length)
|
|
}
|
|
if boundedVarcharLength("text") != nil {
|
|
t.Fatal("unbounded type returned a length")
|
|
}
|
|
}
|
|
|
|
func TestListDatabasesKeepsConnectableCustomTemplates(t *testing.T) {
|
|
for _, query := range []string{kingbaseListDatabasesSQL, kingbaseListDatabasesPostgresSQL} {
|
|
lowerQuery := strings.ToLower(query)
|
|
if !strings.Contains(lowerQuery, "where datallowconn") {
|
|
t.Fatalf("database query must keep the connectable filter: %s", query)
|
|
}
|
|
if strings.Contains(lowerQuery, "not datistemplate") {
|
|
t.Fatalf("database query must not hide connectable custom templates: %s", query)
|
|
}
|
|
if !strings.Contains(lowerQuery, "lower(datname) not in ('template0', 'template1')") {
|
|
t.Fatalf("database query must hide only the standard template databases: %s", query)
|
|
}
|
|
if strings.Contains(lowerQuery, "'template2'") {
|
|
t.Fatalf("database query must keep a connectable database named template2: %s", query)
|
|
}
|
|
}
|
|
|
|
state := &metadataDriverState{query: func(query string) (driver.Rows, error) {
|
|
if query != kingbaseListDatabasesSQL {
|
|
return nil, errors.New("unexpected query: " + query)
|
|
}
|
|
return &valueRows{columns: []string{"datname"}, rows: [][]driver.Value{{"JA_SICP_GEOSMARTER"}}}, nil
|
|
}}
|
|
server := newServer()
|
|
server.db = openMetadataDB(t, state)
|
|
server.params.Database = "configured"
|
|
|
|
databases, err := server.listDatabases()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(databases) != 1 || databases[0].Name != "JA_SICP_GEOSMARTER" {
|
|
t.Fatalf("unexpected databases: %#v", databases)
|
|
}
|
|
}
|
|
|
|
func TestListDatabasesFallsBackToPostgresCatalog(t *testing.T) {
|
|
state := &metadataDriverState{query: func(query string) (driver.Rows, error) {
|
|
switch {
|
|
case query == kingbaseListDatabasesSQL:
|
|
return nil, errors.New("sys catalog unavailable")
|
|
case query == kingbaseListDatabasesPostgresSQL:
|
|
return &valueRows{columns: []string{"datname"}, rows: [][]driver.Value{{"app"}, {"test"}}}, nil
|
|
default:
|
|
return nil, errors.New("unexpected query: " + query)
|
|
}
|
|
}}
|
|
server := newServer()
|
|
server.db = openMetadataDB(t, state)
|
|
server.params.Database = "configured"
|
|
|
|
databases, err := server.listDatabases()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(databases) != 2 || databases[0].Name != "app" || databases[1].Name != "test" {
|
|
t.Fatalf("unexpected databases: %#v", databases)
|
|
}
|
|
state.mu.Lock()
|
|
defer state.mu.Unlock()
|
|
if len(state.queries) != 2 || !strings.Contains(state.queries[0], "sys_catalog.sys_database") || !strings.Contains(state.queries[1], "pg_catalog.pg_database") {
|
|
t.Fatalf("catalog fallback order changed: %v", state.queries)
|
|
}
|
|
}
|
|
|
|
func TestListTablesPreservesKingbaseObjectTypesAndComments(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
postgresCatalog bool
|
|
mysqlCompat bool
|
|
wantCatalog string
|
|
}{
|
|
{name: "modern system catalog", wantCatalog: "sys_catalog.sys_class c"},
|
|
{name: "PostgreSQL catalog", postgresCatalog: true, wantCatalog: "pg_catalog.pg_class c"},
|
|
{name: "MySQL compatibility mode", mysqlCompat: true, wantCatalog: "sys_catalog.sys_class c"},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
state := &metadataDriverState{query: func(query string) (driver.Rows, error) {
|
|
if !strings.Contains(query, "FROM "+test.wantCatalog) || !strings.Contains(query, "c.relkind IN ('r','p','v','m','f')") || !strings.Contains(query, "obj_description(c.oid)") {
|
|
return nil, errors.New("unexpected query: " + query)
|
|
}
|
|
return &valueRows{
|
|
columns: []string{"relname", "relkind", "comment"},
|
|
rows: [][]driver.Value{
|
|
{"orders", "TABLE", "orders table"},
|
|
{"sales_view", "VIEW", nil},
|
|
{"sales_cache", "MATERIALIZED_VIEW", "cached sales"},
|
|
},
|
|
}, nil
|
|
}}
|
|
server := newServer()
|
|
server.db = openMetadataDB(t, state)
|
|
server.mode.postgresCatalog = test.postgresCatalog
|
|
server.mode.mysqlCompat = test.mysqlCompat
|
|
|
|
tables, err := server.listTables("public", metadataListConstraints{Filter: "sales", ObjectTypes: []string{"VIEW", "MATERIALIZED_VIEW"}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(tables) != 2 || tables[0].TableType != "VIEW" || tables[1].TableType != "MATERIALIZED_VIEW" {
|
|
t.Fatalf("unexpected tables: %#v", tables)
|
|
}
|
|
if tables[1].Comment == nil || *tables[1].Comment != "cached sales" {
|
|
t.Fatalf("materialized view comment was lost: %#v", tables[1])
|
|
}
|
|
if queries := state.snapshotQueries(); len(queries) != 1 {
|
|
t.Fatalf("supported catalog must use one request, got %d: %v", len(queries), queries)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestListTablesCachesMissingCatalogOIDCapability(t *testing.T) {
|
|
for _, test := range []struct {
|
|
name string
|
|
postgresCatalog bool
|
|
wantCatalog string
|
|
}{
|
|
{name: "system catalog", wantCatalog: "sys_catalog.sys_class c"},
|
|
{name: "PostgreSQL catalog", postgresCatalog: true, wantCatalog: "pg_catalog.pg_class c"},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
state := &metadataDriverState{query: func(query string) (driver.Rows, error) {
|
|
if !strings.Contains(query, "FROM "+test.wantCatalog) {
|
|
return nil, errors.New("fallback changed catalog: " + query)
|
|
}
|
|
if strings.Contains(query, "c.oid") {
|
|
return nil, &gokb.Error{Code: gokb.ErrorCode("42703"), Message: "kb: column c.oid does not exist"}
|
|
}
|
|
if !strings.Contains(query, "NULL AS table_comment") {
|
|
return nil, errors.New("fallback must return a NULL comment: " + query)
|
|
}
|
|
return &valueRows{
|
|
columns: []string{"relname", "relkind", "table_comment"},
|
|
rows: [][]driver.Value{{"orders", "TABLE", nil}},
|
|
}, nil
|
|
}}
|
|
server := newServer()
|
|
server.db = openMetadataDB(t, state)
|
|
server.mode.postgresCatalog = test.postgresCatalog
|
|
|
|
for call := 0; call < 2; call++ {
|
|
tables, err := server.listTables("public", metadataListConstraints{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(tables) != 1 || tables[0].Name != "orders" || tables[0].Comment != nil {
|
|
t.Fatalf("unexpected fallback result: %#v", tables)
|
|
}
|
|
}
|
|
|
|
queries := state.snapshotQueries()
|
|
if len(queries) != 3 {
|
|
t.Fatalf("missing OID must be probed only once, got %d queries: %v", len(queries), queries)
|
|
}
|
|
if !strings.Contains(queries[0], "c.oid") || strings.Contains(queries[1], "c.oid") || strings.Contains(queries[2], "c.oid") {
|
|
t.Fatalf("unexpected capability fallback sequence: %v", queries)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestTableCommentCachesMissingCatalogOIDCapability(t *testing.T) {
|
|
state := &metadataDriverState{query: func(query string) (driver.Rows, error) {
|
|
return nil, &gokb.Error{Code: gokb.ErrorCode("42703"), Message: "kb: column c.oid does not exist"}
|
|
}}
|
|
server := newServer()
|
|
server.db = openMetadataDB(t, state)
|
|
|
|
comment, err := server.getTableComment("public", "orders")
|
|
if err != nil || comment != nil {
|
|
t.Fatalf("missing OID comment must degrade to nil: comment=%v err=%v", comment, err)
|
|
}
|
|
comment, err = server.getTableComment("", "events")
|
|
if err != nil || comment != nil {
|
|
t.Fatalf("cached missing OID comment must return nil: comment=%v err=%v", comment, err)
|
|
}
|
|
if queries := state.snapshotQueries(); len(queries) != 1 {
|
|
t.Fatalf("cached capability must avoid all later comment requests, got %d: %v", len(queries), queries)
|
|
}
|
|
}
|
|
|
|
func TestTableOIDFallbackRejectsUnrelatedErrors(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
err error
|
|
}{
|
|
{name: "different missing column", err: &gokb.Error{Code: gokb.ErrorCode("42703"), Message: "kb: column c.other_column does not exist"}},
|
|
{name: "connection error", err: errors.New("metadata connection reset")},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
state := &metadataDriverState{query: func(query string) (driver.Rows, error) {
|
|
return nil, test.err
|
|
}}
|
|
server := newServer()
|
|
server.db = openMetadataDB(t, state)
|
|
|
|
if _, err := server.listTables("public", metadataListConstraints{}); !errors.Is(err, test.err) {
|
|
t.Fatalf("listTables swallowed unrelated error: %v", err)
|
|
}
|
|
if _, err := server.getTableComment("public", "orders"); !errors.Is(err, test.err) {
|
|
t.Fatalf("getTableComment swallowed unrelated error: %v", err)
|
|
}
|
|
if queries := state.snapshotQueries(); len(queries) != 2 {
|
|
t.Fatalf("unrelated errors must not trigger retries, got %d: %v", len(queries), queries)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestListCustomTypesUsesPostgresCatalog(t *testing.T) {
|
|
state := &metadataDriverState{query: func(query string) (driver.Rows, error) {
|
|
if !strings.Contains(query, "FROM pg_catalog.pg_type t") || !strings.Contains(query, "t.typtype IN ('b','c','d','e','r','m')") || !strings.Contains(query, "t.typisdefined") || !strings.Contains(query, "t.typelem = 0") || !strings.Contains(query, "(t.typrelid = 0 OR c.relkind = 'c')") || !strings.Contains(query, "d.classoid = 'pg_catalog.pg_type'::regclass") || !strings.Contains(query, "n.nspname <> 'pg_catalog'") || !strings.Contains(query, "n.nspname <> 'information_schema'") || !strings.Contains(query, "n.nspname NOT LIKE 'pg_toast%'") || !strings.Contains(query, "n.nspname NOT LIKE 'pg_temp%'") {
|
|
return nil, errors.New("unexpected query: " + query)
|
|
}
|
|
return &valueRows{
|
|
columns: []string{"typname", "description", "typtype", "has_members"},
|
|
rows: [][]driver.Value{
|
|
{"status", "order status", "e", true},
|
|
{"email", nil, "d", false},
|
|
{"address", nil, "c", true},
|
|
},
|
|
}, nil
|
|
}}
|
|
server := newServer()
|
|
server.db = openMetadataDB(t, state)
|
|
server.mode.postgresCatalog = true
|
|
|
|
types, err := server.listCustomTypes("public")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(types) != 3 {
|
|
t.Fatalf("unexpected types: %#v", types)
|
|
}
|
|
for _, item := range types {
|
|
if item.ObjectType != "TYPE" || item.Schema != "public" {
|
|
t.Fatalf("type metadata was lost: %#v", item)
|
|
}
|
|
}
|
|
if types[0].Comment == nil || *types[0].Comment != "order status" {
|
|
t.Fatalf("type comment was lost: %#v", types[0])
|
|
}
|
|
if types[1].Comment != nil {
|
|
t.Fatalf("nil comment became non-nil: %#v", types[1])
|
|
}
|
|
if types[0].CustomTypeKind == nil || *types[0].CustomTypeKind != "enum" || types[0].HasMembers == nil || !*types[0].HasMembers {
|
|
t.Fatalf("type kind/member metadata was lost: %#v", types[0])
|
|
}
|
|
if types[1].CustomTypeKind == nil || *types[1].CustomTypeKind != "domain" || types[1].HasMembers == nil || *types[1].HasMembers {
|
|
t.Fatalf("leaf type metadata was lost: %#v", types[1])
|
|
}
|
|
}
|
|
|
|
func TestListCustomTypesUsesSystemCatalog(t *testing.T) {
|
|
state := &metadataDriverState{query: func(query string) (driver.Rows, error) {
|
|
if !strings.Contains(query, "FROM sys_catalog.sys_type t") || strings.Contains(query, "FROM pg_catalog") || !strings.Contains(query, "t.typisdefined") || !strings.Contains(query, "n.nspname <> 'pg_catalog'") || !strings.Contains(query, "d.classoid = 'pg_catalog.pg_type'::regclass") {
|
|
return nil, errors.New("unexpected query: " + query)
|
|
}
|
|
return &valueRows{
|
|
columns: []string{"typname", "description", "typtype", "has_members"},
|
|
rows: [][]driver.Value{{"status", "order status", "e", true}},
|
|
}, nil
|
|
}}
|
|
server := newServer()
|
|
server.db = openMetadataDB(t, state)
|
|
server.mode.postgresCatalog = false
|
|
|
|
types, err := server.listCustomTypes("public")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(types) != 1 || types[0].Name != "status" {
|
|
t.Fatalf("unexpected types: %#v", types)
|
|
}
|
|
}
|
|
|
|
func TestListCustomTypesSkipsMySQLCompatMode(t *testing.T) {
|
|
state := &metadataDriverState{query: func(query string) (driver.Rows, error) {
|
|
return nil, errors.New("custom types query must not run in mysql compat mode: " + query)
|
|
}}
|
|
server := newServer()
|
|
server.db = openMetadataDB(t, state)
|
|
server.mode.mysqlCompat = true
|
|
|
|
types, err := server.listCustomTypes("public")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(types) != 0 {
|
|
t.Fatalf("expected no types in mysql compat mode: %#v", types)
|
|
}
|
|
}
|
|
|
|
func TestListObjectsIncludesCustomTypesWhenUnfiltered(t *testing.T) {
|
|
state := &metadataDriverState{query: func(query string) (driver.Rows, error) {
|
|
switch {
|
|
case strings.Contains(query, "sys_type t"):
|
|
return &valueRows{
|
|
columns: []string{"typname", "description", "typtype", "has_members"},
|
|
rows: [][]driver.Value{
|
|
{"status", "order status", "e", true},
|
|
{"email", nil, "d", false},
|
|
},
|
|
}, nil
|
|
case strings.Contains(query, "sys_proc p"):
|
|
return &valueRows{
|
|
columns: []string{"proname", "kind", "comment"},
|
|
rows: [][]driver.Value{{"format_name", "FUNCTION", nil}},
|
|
}, nil
|
|
case strings.Contains(query, "sys_class c"):
|
|
return &valueRows{
|
|
columns: []string{"relname", "relkind", "comment"},
|
|
rows: [][]driver.Value{{"orders", "TABLE", nil}},
|
|
}, nil
|
|
}
|
|
return nil, errors.New("unexpected query: " + query)
|
|
}}
|
|
server := newServer()
|
|
server.db = openMetadataDB(t, state)
|
|
|
|
objects, err := server.listObjects("public", metadataListConstraints{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var typeNames []string
|
|
for _, item := range objects {
|
|
if item.ObjectType == "TYPE" {
|
|
typeNames = append(typeNames, item.Name)
|
|
}
|
|
}
|
|
if len(typeNames) != 2 || typeNames[0] != "email" || typeNames[1] != "status" {
|
|
t.Fatalf("unexpected types in object list: %v (objects=%#v)", typeNames, objects)
|
|
}
|
|
if len(objects) != 4 {
|
|
t.Fatalf("expected table + function + 2 types, got %#v", objects)
|
|
}
|
|
}
|
|
|
|
func TestListObjectsIncludesMySQLCompatRoutines(t *testing.T) {
|
|
state := &metadataDriverState{query: func(query string) (driver.Rows, error) {
|
|
switch {
|
|
case strings.Contains(query, "FROM information_schema.routines"):
|
|
if !strings.Contains(query, "WHERE ROUTINE_SCHEMA = 'team''s'") {
|
|
return nil, errors.New("routine query did not quote the schema: " + query)
|
|
}
|
|
return &valueRows{
|
|
columns: []string{"routine_name", "routine_type", "routine_comment"},
|
|
rows: [][]driver.Value{
|
|
{"format_name", "FUNCTION", "formats a name"},
|
|
{"refresh_cache", "PROCEDURE", nil},
|
|
},
|
|
}, nil
|
|
default:
|
|
return nil, errors.New("unexpected query: " + query)
|
|
}
|
|
}}
|
|
server := newServer()
|
|
server.db = openMetadataDB(t, state)
|
|
server.mode.mysqlCompat = true
|
|
|
|
objects, err := server.listObjects("team's", metadataListConstraints{ObjectTypes: []string{"FUNCTION", "PROCEDURE"}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(objects) != 2 {
|
|
t.Fatalf("expected MySQL-compatible routines, got %#v", objects)
|
|
}
|
|
if objects[0].Name != "refresh_cache" || objects[0].ObjectType != "PROCEDURE" || objects[0].Schema != "team's" || objects[0].Comment != nil {
|
|
t.Fatalf("unexpected procedure: %#v", objects[0])
|
|
}
|
|
if objects[1].Name != "format_name" || objects[1].ObjectType != "FUNCTION" || objects[1].Schema != "team's" || objects[1].Comment == nil || *objects[1].Comment != "formats a name" {
|
|
t.Fatalf("unexpected function: %#v", objects[1])
|
|
}
|
|
queries := state.snapshotQueries()
|
|
if len(queries) != 1 || !strings.Contains(queries[0], "FROM information_schema.routines") {
|
|
t.Fatalf("expected exactly one routine query, got %v", queries)
|
|
}
|
|
}
|
|
|
|
func TestListObjectsFiltersSortsAndPagesMySQLCompatObjects(t *testing.T) {
|
|
state := &metadataDriverState{query: func(query string) (driver.Rows, error) {
|
|
switch {
|
|
case strings.Contains(query, "FROM sys_catalog.sys_class c"):
|
|
return &valueRows{
|
|
columns: []string{"relname", "relkind", "comment"},
|
|
rows: [][]driver.Value{{"match_table", "TABLE", nil}},
|
|
}, nil
|
|
case strings.Contains(query, "FROM information_schema.routines"):
|
|
return &valueRows{
|
|
columns: []string{"routine_name", "routine_type", "routine_comment"},
|
|
rows: [][]driver.Value{
|
|
{"z_match_fn", "FUNCTION", nil},
|
|
{"other_fn", "FUNCTION", nil},
|
|
{"match_proc", "PROCEDURE", nil},
|
|
{"a_match_fn", "FUNCTION", nil},
|
|
},
|
|
}, nil
|
|
default:
|
|
return nil, errors.New("unexpected query: " + query)
|
|
}
|
|
}}
|
|
server := newServer()
|
|
server.db = openMetadataDB(t, state)
|
|
server.mode.mysqlCompat = true
|
|
|
|
objects, err := server.listObjects("public", metadataListConstraints{
|
|
Filter: "match",
|
|
Limit: 2,
|
|
Offset: 1,
|
|
ObjectTypes: []string{"TABLE", "FUNCTION", "PROCEDURE"},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(objects) != 2 || objects[0].Name != "match_proc" || objects[0].ObjectType != "PROCEDURE" || objects[1].Name != "a_match_fn" || objects[1].ObjectType != "FUNCTION" {
|
|
t.Fatalf("unexpected filtered page: %#v", objects)
|
|
}
|
|
queries := state.snapshotQueries()
|
|
if len(queries) != 2 {
|
|
t.Fatalf("expected one table query and one routine query, got %v", queries)
|
|
}
|
|
}
|
|
|
|
func TestListObjectsFiltersMySQLCompatRoutineKindsWithoutTableScan(t *testing.T) {
|
|
for _, objectType := range []string{"FUNCTION", "PROCEDURE"} {
|
|
t.Run(objectType, func(t *testing.T) {
|
|
state := &metadataDriverState{query: func(query string) (driver.Rows, error) {
|
|
if !strings.Contains(query, "FROM information_schema.routines") {
|
|
return nil, errors.New("routine-only request must not scan tables: " + query)
|
|
}
|
|
return &valueRows{
|
|
columns: []string{"routine_name", "routine_type", "routine_comment"},
|
|
rows: [][]driver.Value{
|
|
{"format_name", "FUNCTION", nil},
|
|
{"refresh_cache", "PROCEDURE", nil},
|
|
},
|
|
}, nil
|
|
}}
|
|
server := newServer()
|
|
server.db = openMetadataDB(t, state)
|
|
server.mode.mysqlCompat = true
|
|
|
|
objects, err := server.listObjects("public", metadataListConstraints{ObjectTypes: []string{objectType}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(objects) != 1 || objects[0].ObjectType != objectType {
|
|
t.Fatalf("expected only %s, got %#v", objectType, objects)
|
|
}
|
|
queries := state.snapshotQueries()
|
|
if len(queries) != 1 || !strings.Contains(queries[0], "FROM information_schema.routines") {
|
|
t.Fatalf("expected one routine query, got %v", queries)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestListObjectsSkipsMySQLCompatRoutineQueryForNonRoutineConstraints(t *testing.T) {
|
|
for _, objectType := range []string{"TABLE", "TYPE"} {
|
|
t.Run(objectType, func(t *testing.T) {
|
|
state := &metadataDriverState{query: func(query string) (driver.Rows, error) {
|
|
if strings.Contains(query, "information_schema.routines") {
|
|
return nil, errors.New("non-routine request must not query routines: " + query)
|
|
}
|
|
if objectType == "TABLE" && strings.Contains(query, "FROM sys_catalog.sys_class c") {
|
|
return &valueRows{columns: []string{"relname", "relkind", "comment"}}, nil
|
|
}
|
|
return nil, errors.New("unexpected query: " + query)
|
|
}}
|
|
server := newServer()
|
|
server.db = openMetadataDB(t, state)
|
|
server.mode.mysqlCompat = true
|
|
|
|
if _, err := server.listObjects("public", metadataListConstraints{ObjectTypes: []string{objectType}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
queries := state.snapshotQueries()
|
|
expectedQueries := 0
|
|
if objectType == "TABLE" {
|
|
expectedQueries = 1
|
|
}
|
|
if len(queries) != expectedQueries {
|
|
t.Fatalf("unexpected %s query count: %v", objectType, queries)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestListObjectsUsesCatalogRoutinesOutsideMySQLCompat(t *testing.T) {
|
|
for _, test := range []struct {
|
|
name string
|
|
mode kingbaseMode
|
|
catalogFragment string
|
|
}{
|
|
{name: "sys", catalogFragment: "FROM sys_catalog.sys_proc p"},
|
|
{name: "pg", mode: kingbaseMode{postgresCatalog: true}, catalogFragment: "FROM pg_catalog.pg_proc p"},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
state := &metadataDriverState{query: func(query string) (driver.Rows, error) {
|
|
if strings.Contains(query, "information_schema.routines") || !strings.Contains(query, test.catalogFragment) {
|
|
return nil, errors.New("unexpected query: " + query)
|
|
}
|
|
return &valueRows{
|
|
columns: []string{"proname", "kind", "comment"},
|
|
rows: [][]driver.Value{{"format_name", "FUNCTION", nil}},
|
|
}, nil
|
|
}}
|
|
server := newServer()
|
|
server.db = openMetadataDB(t, state)
|
|
server.mode = test.mode
|
|
|
|
objects, err := server.listObjects("public", metadataListConstraints{ObjectTypes: []string{"FUNCTION"}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(objects) != 1 || objects[0].Name != "format_name" || objects[0].ObjectType != "FUNCTION" {
|
|
t.Fatalf("unexpected catalog routines: %#v", objects)
|
|
}
|
|
if queries := state.snapshotQueries(); len(queries) != 1 {
|
|
t.Fatalf("expected one catalog routine query, got %v", queries)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestListObjectsOnlyTriggersWithParentIdentity(t *testing.T) {
|
|
for _, test := range []struct {
|
|
name string
|
|
mode kingbaseMode
|
|
catalog string
|
|
prefix string
|
|
}{
|
|
{name: "sys catalog", catalog: "sys_catalog", prefix: "sys"},
|
|
{name: "pg catalog", mode: kingbaseMode{postgresCatalog: true}, catalog: "pg_catalog", prefix: "pg"},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
state := &metadataDriverState{query: func(query string) (driver.Rows, error) {
|
|
triggerTable := test.catalog + "." + test.prefix + "_trigger"
|
|
if !strings.Contains(query, "FROM "+triggerTable+" tg") || !strings.Contains(query, "NOT tg.tgisinternal") {
|
|
return nil, errors.New("trigger-only request issued an unexpected query: " + query)
|
|
}
|
|
if !strings.Contains(query, test.catalog+"."+test.prefix+"_description") || !strings.Contains(query, "n.nspname = 'team''s'") {
|
|
return nil, errors.New("trigger query lost comment or quoted schema metadata: " + query)
|
|
}
|
|
return &valueRows{
|
|
columns: []string{"tgname", "relname", "description"},
|
|
rows: [][]driver.Value{
|
|
{"audit_before", "items", "items audit"},
|
|
{"audit_before", "orders", nil},
|
|
},
|
|
}, nil
|
|
}}
|
|
server := newServer()
|
|
server.db = openMetadataDB(t, state)
|
|
server.mode = test.mode
|
|
|
|
objects, err := server.listObjects("team's", metadataListConstraints{ObjectTypes: []string{"TRIGGER"}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(objects) != 2 {
|
|
t.Fatalf("expected both table-scoped triggers, got %#v", objects)
|
|
}
|
|
for index, parent := range []string{"items", "orders"} {
|
|
object := objects[index]
|
|
if object.Name != "audit_before" || object.ObjectType != "TRIGGER" || object.Schema != "team's" || object.ParentSchema == nil || *object.ParentSchema != "team's" || object.ParentName == nil || *object.ParentName != parent {
|
|
t.Fatalf("unexpected trigger object at %d: %#v", index, object)
|
|
}
|
|
}
|
|
if queries := state.snapshotQueries(); len(queries) != 1 {
|
|
t.Fatalf("trigger-only request must use one bounded catalog query: %v", queries)
|
|
}
|
|
constraints := metadataListConstraints{ObjectTypes: []string{"TRIGGER"}}
|
|
if constraintsAllowsTableLike(constraints) || constraintsAllowRoutines(constraints) || constraintsAllowTypes(constraints) || !constraintsAllowTriggers(constraints) {
|
|
t.Fatalf("trigger-only constraint leaked into another object family: %#v", constraints)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestListObjectsTriggerFallbackExcludesConstraintTriggers(t *testing.T) {
|
|
state := &metadataDriverState{query: func(query string) (driver.Rows, error) {
|
|
if strings.Contains(query, "tg.tgisinternal") {
|
|
return nil, &gokb.Error{Code: gokb.ErrorCode("42703"), Message: "column TG.TGISINTERNAL does not exist"}
|
|
}
|
|
if !strings.Contains(query, "tg.tgkind <> 'c'") {
|
|
return nil, errors.New("legacy trigger query did not exclude constraint triggers: " + query)
|
|
}
|
|
return &valueRows{
|
|
columns: []string{"tgname", "relname", "description"},
|
|
rows: [][]driver.Value{{"audit_before", "orders", nil}},
|
|
}, nil
|
|
}}
|
|
server := newServer()
|
|
server.db = openMetadataDB(t, state)
|
|
|
|
objects, err := server.listObjects("public", metadataListConstraints{ObjectTypes: []string{"TRIGGER"}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(objects) != 1 || objects[0].ParentName == nil || *objects[0].ParentName != "orders" || !server.triggerInternalUnsupported {
|
|
t.Fatalf("unexpected legacy trigger objects: %#v", objects)
|
|
}
|
|
}
|
|
|
|
func TestListObjectsKeepsTablesWhenMySQLCompatRoutineQueryFails(t *testing.T) {
|
|
state := &metadataDriverState{query: func(query string) (driver.Rows, error) {
|
|
switch {
|
|
case strings.Contains(query, "FROM sys_catalog.sys_class c"):
|
|
return &valueRows{
|
|
columns: []string{"relname", "relkind", "comment"},
|
|
rows: [][]driver.Value{{"orders", "TABLE", "orders table"}},
|
|
}, nil
|
|
case strings.Contains(query, "FROM information_schema.routines"):
|
|
return nil, errors.New("routine catalog unavailable")
|
|
default:
|
|
return nil, errors.New("unexpected query: " + query)
|
|
}
|
|
}}
|
|
server := newServer()
|
|
server.db = openMetadataDB(t, state)
|
|
server.mode.mysqlCompat = true
|
|
|
|
objects, err := server.listObjects("public", metadataListConstraints{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(objects) != 1 || objects[0].Name != "orders" || objects[0].ObjectType != "TABLE" || objects[0].Comment == nil || *objects[0].Comment != "orders table" {
|
|
t.Fatalf("table must survive a best-effort routine query failure: %#v", objects)
|
|
}
|
|
queries := state.snapshotQueries()
|
|
if len(queries) != 2 || !strings.Contains(queries[1], "FROM information_schema.routines") {
|
|
t.Fatalf("unexpected best-effort query sequence: %v", queries)
|
|
}
|
|
}
|
|
|
|
func TestListObjectsOnlyCustomTypesWhenTypeRequested(t *testing.T) {
|
|
state := &metadataDriverState{query: func(query string) (driver.Rows, error) {
|
|
if strings.Contains(query, "FROM sys_catalog.sys_class c") || strings.Contains(query, "sys_proc p") {
|
|
return nil, errors.New("type-only request must not scan relations or routines: " + query)
|
|
}
|
|
if !strings.Contains(query, "sys_type t") {
|
|
return nil, errors.New("unexpected query: " + query)
|
|
}
|
|
return &valueRows{
|
|
columns: []string{"typname", "description", "typtype", "has_members"},
|
|
rows: [][]driver.Value{{"status", "order status", "e", true}},
|
|
}, nil
|
|
}}
|
|
server := newServer()
|
|
server.db = openMetadataDB(t, state)
|
|
|
|
// The sidebar type group sends TYPE together with the TYPE_BODY companion;
|
|
// both must resolve to a type-only request that never scans tables.
|
|
for _, objectTypes := range [][]string{{"TYPE"}, {"TYPE", "TYPE_BODY"}} {
|
|
objects, err := server.listObjects("public", metadataListConstraints{ObjectTypes: objectTypes})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(objects) != 1 || objects[0].Name != "status" || objects[0].ObjectType != "TYPE" || objects[0].Schema != "public" {
|
|
t.Fatalf("expected only the TYPE object for %v: %#v", objectTypes, objects)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestTypeBodyConstraintIsNotTableLike(t *testing.T) {
|
|
constraints := metadataListConstraints{ObjectTypes: []string{"TYPE", "TYPE_BODY"}}
|
|
if !constraintsAllowTypes(constraints) {
|
|
t.Fatal("TYPE/TYPE_BODY request must allow types")
|
|
}
|
|
if constraintsAllowsTableLike(constraints) {
|
|
t.Fatal("TYPE/TYPE_BODY request must not be table-like; normalizeTableType must not map TYPE_BODY to TABLE")
|
|
}
|
|
if constraintsAllowRoutines(constraints) {
|
|
t.Fatal("TYPE/TYPE_BODY request must not be routine-like")
|
|
}
|
|
}
|
|
|
|
func TestListObjectsSkipsCustomTypesWhenTableRequested(t *testing.T) {
|
|
state := &metadataDriverState{query: func(query string) (driver.Rows, error) {
|
|
if strings.Contains(query, "sys_type t") || strings.Contains(query, "sys_proc p") {
|
|
return nil, errors.New("table-only request must not scan types or routines: " + query)
|
|
}
|
|
if !strings.Contains(query, "sys_class c") {
|
|
return nil, errors.New("unexpected query: " + query)
|
|
}
|
|
return &valueRows{
|
|
columns: []string{"relname", "relkind", "comment"},
|
|
rows: [][]driver.Value{{"orders", "TABLE", nil}},
|
|
}, nil
|
|
}}
|
|
server := newServer()
|
|
server.db = openMetadataDB(t, state)
|
|
|
|
objects, err := server.listObjects("public", metadataListConstraints{ObjectTypes: []string{"TABLE"}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, item := range objects {
|
|
if item.ObjectType == "TYPE" {
|
|
t.Fatalf("table-only request must not return types: %#v", objects)
|
|
}
|
|
}
|
|
if len(objects) == 0 {
|
|
t.Fatalf("expected the table to remain listed: %#v", objects)
|
|
}
|
|
}
|
|
|
|
func TestListObjectsTypeOnlyPropagatesCustomTypesError(t *testing.T) {
|
|
state := &metadataDriverState{query: func(query string) (driver.Rows, error) {
|
|
return nil, errors.New("catalog unavailable: " + query)
|
|
}}
|
|
server := newServer()
|
|
server.db = openMetadataDB(t, state)
|
|
|
|
_, err := server.listObjects("public", metadataListConstraints{ObjectTypes: []string{"TYPE"}})
|
|
if err == nil {
|
|
t.Fatal("dedicated type request must propagate the catalog error")
|
|
}
|
|
if !strings.Contains(err.Error(), "list custom types") {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestListObjectsUnfilteredPropagatesCustomTypesError(t *testing.T) {
|
|
state := &metadataDriverState{query: func(query string) (driver.Rows, error) {
|
|
if strings.Contains(query, "sys_type t") {
|
|
return nil, errors.New("pg_type unavailable")
|
|
}
|
|
switch {
|
|
case strings.Contains(query, "sys_proc p"):
|
|
return &valueRows{
|
|
columns: []string{"proname", "kind", "comment"},
|
|
rows: [][]driver.Value{{"format_name", "FUNCTION", nil}},
|
|
}, nil
|
|
case strings.Contains(query, "sys_class c"):
|
|
return &valueRows{
|
|
columns: []string{"relname", "relkind", "comment"},
|
|
rows: [][]driver.Value{{"orders", "TABLE", nil}},
|
|
}, nil
|
|
}
|
|
return nil, errors.New("unexpected query: " + query)
|
|
}}
|
|
server := newServer()
|
|
server.db = openMetadataDB(t, state)
|
|
|
|
// A failing type catalog must surface as an error even for the unfiltered
|
|
// “all objects” listing, so users never see a silently incomplete list.
|
|
_, err := server.listObjects("public", metadataListConstraints{})
|
|
if err == nil {
|
|
t.Fatal("unfiltered request must propagate the type catalog error")
|
|
}
|
|
if !strings.Contains(err.Error(), "list custom types") {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestKingbaseCustomTypeQueriesFollowCatalog(t *testing.T) {
|
|
pgQueries := customTypeCatalogQueriesFor("pg_catalog", "pg", "app", "status")
|
|
for _, fragment := range []string{
|
|
"pg_catalog.pg_type", "pg_catalog.pg_namespace", "pg_catalog.pg_description", "pg_catalog.pg_proc",
|
|
"pg_catalog.pg_collation", "pg_get_expr", "pg_get_constraintdef",
|
|
"n.nspname = 'app' AND t.typname = 'status'",
|
|
} {
|
|
if !strings.Contains(pgQueries.general, fragment) && !strings.Contains(pgQueries.compositeMembers, fragment) && !strings.Contains(pgQueries.domainConstraints, fragment) {
|
|
t.Fatalf("pg catalog queries missing %q: %s", fragment, pgQueries.general)
|
|
}
|
|
}
|
|
if !strings.Contains(pgQueries.enumMembers, "pg_catalog.pg_enum") {
|
|
t.Fatalf("enum query must use pg_catalog.pg_enum: %s", pgQueries.enumMembers)
|
|
}
|
|
if !strings.Contains(pgQueries.compositeMembers, "pg_catalog.pg_attribute") {
|
|
t.Fatalf("composite query must use pg_catalog.pg_attribute: %s", pgQueries.compositeMembers)
|
|
}
|
|
if !strings.Contains(pgQueries.rangeAttributes, "pg_catalog.pg_range") {
|
|
t.Fatalf("range query must use pg_catalog.pg_range: %s", pgQueries.rangeAttributes)
|
|
}
|
|
for _, fragment := range []string{"JOIN pg_catalog.pg_type at", "quote_ident(atn.nspname)", "LEFT JOIN pg_catalog.pg_type elem"} {
|
|
if !strings.Contains(pgQueries.compositeMembers, fragment) {
|
|
t.Fatalf("composite query must schema-qualify member types; missing %q: %s", fragment, pgQueries.compositeMembers)
|
|
}
|
|
}
|
|
for _, fragment := range []string{"JOIN pg_catalog.pg_namespace n", "quote_ident(n.nspname)", "WHERE t.oid = %[1]d"} {
|
|
if !strings.Contains(pgQueries.domainBaseType, fragment) {
|
|
t.Fatalf("domain query must schema-qualify its base type; missing %q: %s", fragment, pgQueries.domainBaseType)
|
|
}
|
|
}
|
|
formattedDomainBaseType := fmt.Sprintf(pgQueries.domainBaseType, 25, -1)
|
|
if strings.Contains(formattedDomainBaseType, "%") || !strings.Contains(formattedDomainBaseType, "format_type(t.oid, -1::int4)") {
|
|
t.Fatalf("domain base type query must format both OID and typmod: %s", formattedDomainBaseType)
|
|
}
|
|
for _, query := range []string{pgQueries.rangeAttributes, pgQueries.rangeAttributesForMultirange} {
|
|
for _, fragment := range []string{"JOIN pg_catalog.pg_type st", "quote_ident(stn.nspname)", "quote_ident(ncan.nspname)", "quote_ident(ndiff.nspname)", "quote_ident(nopc.nspname)", "ncan.oid = pcan.pronamespace", "ndiff.oid = pdiff.pronamespace", "nopc.oid = opc.opcnamespace"} {
|
|
if !strings.Contains(query, fragment) {
|
|
t.Fatalf("range query must qualify catalog names with schema; missing %q: %s", fragment, query)
|
|
}
|
|
}
|
|
if strings.Contains(query, "%!") {
|
|
t.Fatalf("range query contains an unresolved format directive: %s", query)
|
|
}
|
|
}
|
|
|
|
sysQueries := customTypeCatalogQueriesFor("sys_catalog", "sys", "app", "status")
|
|
for _, fragment := range []string{"sys_catalog.sys_type", "sys_catalog.sys_namespace", "sys_catalog.sys_description", "sys_catalog.sys_proc", "sys_get_expr", "sys_get_constraintdef"} {
|
|
if !strings.Contains(sysQueries.general, fragment) && !strings.Contains(sysQueries.compositeMembers, fragment) && !strings.Contains(sysQueries.domainConstraints, fragment) {
|
|
t.Fatalf("sys catalog queries missing %q: %s", fragment, sysQueries.general)
|
|
}
|
|
}
|
|
if strings.Contains(sysQueries.general, "FROM pg_catalog") || strings.Contains(sysQueries.general, "pg_get_expr") {
|
|
t.Fatalf("sys catalog general query leaked pg_catalog references: %s", sysQueries.general)
|
|
}
|
|
if strings.Contains(pgQueries.general, "pg_get_expr") || strings.Contains(sysQueries.general, "sys_get_expr") {
|
|
t.Fatal("general type lookup must not depend on default-expression rendering")
|
|
}
|
|
if !strings.Contains(pgQueries.domainRenderedDefault, "pg_get_expr") || !strings.Contains(sysQueries.domainRenderedDefault, "sys_get_expr") {
|
|
t.Fatal("domain default renderer must follow the selected catalog")
|
|
}
|
|
}
|
|
|
|
func TestKingbaseDomainDefaultRenderFailureIsDegradable(t *testing.T) {
|
|
bin := sql.NullString{String: "{CONST ...}", Valid: true}
|
|
value, warnings := resolveCustomTypeDomainDefault(bin, sql.NullString{}, func() (string, error) {
|
|
return "", errors.New("function sys_get_expr does not exist")
|
|
})
|
|
if value != nil || len(warnings) != 1 || !strings.Contains(warnings[0], "DDL is incomplete") {
|
|
t.Fatalf("unexpected fallback result: value=%v warnings=%v", value, warnings)
|
|
}
|
|
}
|
|
|
|
func TestKingbaseDomainConstraintReadFailuresMarkDDLIncomplete(t *testing.T) {
|
|
queries := customTypeCatalogQueriesFor("pg_catalog", "pg", "app", "email")
|
|
state := &metadataDriverState{query: func(query string) (driver.Rows, error) {
|
|
switch {
|
|
case strings.Contains(query, "WHERE t.oid = 1"):
|
|
return &valueRows{columns: []string{"base_type"}, rows: [][]driver.Value{{"text"}}}, nil
|
|
case strings.Contains(query, "WHERE c.contypid = 9"):
|
|
return &valueRows{columns: []string{"conname", "definition"}, rows: [][]driver.Value{{"email_valid"}}}, nil
|
|
default:
|
|
return nil, fmt.Errorf("unexpected query: %s", query)
|
|
}
|
|
}}
|
|
server := newServer()
|
|
server.db = openMetadataDB(t, state)
|
|
properties := customTypeProperties{DomainConstraints: []customTypeDomainConstraint{}}
|
|
warnings := server.customTypeDomainAttributes(queries, &properties, 9, 1, -1, false, sql.NullString{}, sql.NullString{}, 0, sql.NullString{})
|
|
if len(warnings) != 1 || !strings.Contains(warnings[0], "domain constraints could not be decoded") {
|
|
t.Fatalf("constraint scan failures must be retained as warnings: %v", warnings)
|
|
}
|
|
ddl := server.buildCustomTypeDDL("app", "email", customTypeKindDomain, sql.NullString{}, &[]customTypeMember{}, &properties, warnings)
|
|
if ddl.Complete {
|
|
t.Fatalf("domain DDL must be incomplete after a constraint scan failure: %+v", ddl)
|
|
}
|
|
}
|
|
|
|
func TestKingbaseGetTypeDetailsRejectsMySQLCompat(t *testing.T) {
|
|
server := newServer()
|
|
server.mode.mysqlCompat = true
|
|
_, err := server.getTypeDetails("public", "status")
|
|
if err == nil || !strings.Contains(err.Error(), "MySQL compatibility mode") {
|
|
t.Fatalf("expected MySQL compat rejection, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestKingbaseGetTypeDetailsPropagatesRowIterationError(t *testing.T) {
|
|
state := &metadataDriverState{query: func(string) (driver.Rows, error) {
|
|
return &valueRows{nextErr: errors.New("row stream failed")}, nil
|
|
}}
|
|
server := newServer()
|
|
server.db = openMetadataDB(t, state)
|
|
_, err := server.getTypeDetails("public", "status")
|
|
if err == nil || !strings.Contains(err.Error(), "failed to read type") || !strings.Contains(err.Error(), "row stream failed") {
|
|
t.Fatalf("expected row iteration error to be propagated, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestKingbaseCustomTypeKindFromCode(t *testing.T) {
|
|
for code, expected := range map[string]customTypeKind{
|
|
"b": customTypeKindBase, "c": customTypeKindComposite, "d": customTypeKindDomain,
|
|
"e": customTypeKindEnum, "r": customTypeKindRange, "m": customTypeKindMultirange,
|
|
} {
|
|
kind, ok := customTypeKindFromCode(code)
|
|
if !ok || kind != expected {
|
|
t.Fatalf("customTypeKindFromCode(%q) = %v, %v", code, kind, ok)
|
|
}
|
|
}
|
|
if _, ok := customTypeKindFromCode("p"); ok {
|
|
t.Fatal("pseudo type must not map to a kind")
|
|
}
|
|
}
|
|
|
|
func TestKingbaseSystemSchemasAreRejectedForCustomTypeDetails(t *testing.T) {
|
|
for _, schema := range []string{"pg_catalog", "information_schema", "pg_toast", "pg_toast_temp_5", "pg_temp_5"} {
|
|
if !isSystemSchema(schema) {
|
|
t.Fatalf("%q should be recognized as a system schema", schema)
|
|
}
|
|
}
|
|
if isSystemSchema("public") || isSystemSchema("app") {
|
|
t.Fatal("user schemas must remain eligible for custom type details")
|
|
}
|
|
server := newServer()
|
|
if _, err := server.getTypeDetails("pg_catalog", "int4"); err == nil || !strings.Contains(err.Error(), "system schema") {
|
|
t.Fatalf("system schema must be rejected before catalog access, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestKingbaseCustomTypeDDL(t *testing.T) {
|
|
srv := newServer()
|
|
nullInput := sql.NullString{}
|
|
enumMembers := []customTypeMember{
|
|
{Ordinal: 1, EnumValue: stringPtr("draft")},
|
|
{Ordinal: 2, EnumValue: stringPtr("已归档")},
|
|
}
|
|
enumDDL := srv.buildCustomTypeDDL("app", "status", customTypeKindEnum, nullInput, &enumMembers, &customTypeProperties{}, nil)
|
|
if enumDDL.SQL != "CREATE TYPE \"app\".\"status\" AS ENUM ('draft', '已归档');" || !enumDDL.Complete {
|
|
t.Fatalf("unexpected enum DDL: %+v", enumDDL)
|
|
}
|
|
|
|
compositeMembers := []customTypeMember{
|
|
{Name: "city", DataType: "text", Ordinal: 1, Comment: stringPtr("city name")},
|
|
}
|
|
compositeDDL := srv.buildCustomTypeDDL("app", "address", customTypeKindComposite, nullInput, &compositeMembers, &customTypeProperties{}, nil)
|
|
if !strings.Contains(compositeDDL.SQL, "\"city\" text") || !strings.Contains(compositeDDL.SQL, "COMMENT ON COLUMN \"app\".\"address\".\"city\" IS 'city name';") {
|
|
t.Fatalf("unexpected composite DDL: %+v", compositeDDL)
|
|
}
|
|
|
|
notNull := true
|
|
domainProps := customTypeProperties{BaseType: stringPtr("text"), NotNull: ¬Null, DomainConstraints: []customTypeDomainConstraint{{Name: "email_valid", Definition: "CHECK ((VALUE <> ''::text))"}}}
|
|
domainDDL := srv.buildCustomTypeDDL("app", "email", customTypeKindDomain, nullInput, &[]customTypeMember{}, &domainProps, nil)
|
|
if !strings.Contains(domainDDL.SQL, "CREATE DOMAIN \"app\".\"email\" AS text") || !strings.Contains(domainDDL.SQL, "NOT NULL") || !strings.Contains(domainDDL.SQL, "CHECK ((VALUE <> ''::text))") {
|
|
t.Fatalf("unexpected domain DDL: %+v", domainDDL)
|
|
}
|
|
|
|
rangeProps := customTypeProperties{RangeSubtype: stringPtr("numeric"), RangeCanonicalFunction: stringPtr("\"extensions\".\"numeric_range_canonical\"")}
|
|
rangeDDL := srv.buildCustomTypeDDL("app", "price_range", customTypeKindRange, nullInput, &[]customTypeMember{}, &rangeProps, nil)
|
|
if !rangeDDL.Complete || !strings.Contains(rangeDDL.SQL, "subtype = numeric") || !strings.Contains(rangeDDL.SQL, "canonical = \"extensions\".\"numeric_range_canonical\"") {
|
|
t.Fatalf("unexpected range DDL: %+v", rangeDDL)
|
|
}
|
|
missingSubtype := srv.buildCustomTypeDDL("app", "price_range", customTypeKindRange, nullInput, &[]customTypeMember{}, &customTypeProperties{RangeMultirangeName: stringPtr("price_multirange")}, nil)
|
|
if missingSubtype.Complete || missingSubtype.SQL != "CREATE TYPE \"app\".\"price_range\" AS RANGE (subtype = unknown);" {
|
|
t.Fatalf("range DDL without subtype must be incomplete: %+v", missingSubtype)
|
|
}
|
|
|
|
multirangeDDL := srv.buildCustomTypeDDL("app", "_price_range", customTypeKindMultirange, nullInput, &[]customTypeMember{}, &customTypeProperties{}, nil)
|
|
if multirangeDDL.Complete || len(multirangeDDL.Warnings) == 0 {
|
|
t.Fatalf("multirange DDL must be incomplete with warnings: %+v", multirangeDDL)
|
|
}
|
|
|
|
baseDDL := srv.buildCustomTypeDDL("app", "point2d", customTypeKindBase, nullInput, &[]customTypeMember{}, &customTypeProperties{}, nil)
|
|
if baseDDL.Complete || len(baseDDL.Warnings) == 0 {
|
|
t.Fatalf("base DDL must be incomplete with warnings: %+v", baseDDL)
|
|
}
|
|
}
|
|
|
|
func TestCustomTypeDetailsJSONNeverNullsSlices(t *testing.T) {
|
|
props := customTypeProperties{DomainConstraints: []customTypeDomainConstraint{}}
|
|
details := customTypeDetails{Name: "status", Schema: "app", Kind: customTypeKindEnum, Members: []customTypeMember{}, Properties: props}
|
|
raw, err := json.Marshal(details)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
text := string(raw)
|
|
if strings.Contains(text, "null") {
|
|
t.Fatalf("empty slices must encode as [] not null: %s", text)
|
|
}
|
|
if !strings.Contains(text, `"members":[]`) || !strings.Contains(text, `"domainConstraints":[]`) {
|
|
t.Fatalf("empty slices must be present as []: %s", text)
|
|
}
|
|
}
|
|
|
|
func TestListTriggersUsesCompatibilityCatalogAndDecodesTiming(t *testing.T) {
|
|
state := &metadataDriverState{query: func(query string) (driver.Rows, error) {
|
|
if !strings.Contains(query, "FROM pg_catalog.pg_trigger") || !strings.Contains(query, "NOT tg.tgisinternal") {
|
|
return nil, errors.New("unexpected query: " + query)
|
|
}
|
|
return &valueRows{
|
|
columns: []string{"tgname", "event", "tgtype"},
|
|
rows: [][]driver.Value{{"orders_before", "INSERT,UPDATE", int64(2)}, {"orders_instead", "DELETE", int64(64)}},
|
|
}, nil
|
|
}}
|
|
server := newServer()
|
|
server.db = openMetadataDB(t, state)
|
|
server.mode.postgresCatalog = true
|
|
|
|
triggers, err := server.listTriggers("public", "orders")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(triggers) != 2 || triggers[0].Timing != "BEFORE" || triggers[1].Timing != "INSTEAD OF" {
|
|
t.Fatalf("unexpected triggers: %#v", triggers)
|
|
}
|
|
}
|
|
|
|
func TestRoutineSourceUsesKingbaseCatalogFunction(t *testing.T) {
|
|
state := &metadataDriverState{query: func(query string) (driver.Rows, error) {
|
|
if !strings.Contains(query, "SELECT sys_get_functiondef(p.oid)") || !strings.Contains(query, "FROM sys_catalog.sys_proc") {
|
|
return nil, errors.New("unexpected query: " + query)
|
|
}
|
|
return &valueRows{columns: []string{"source"}, rows: [][]driver.Value{{"CREATE FUNCTION public.format_name() RETURNS text AS $$ SELECT 'x'; $$"}}}, nil
|
|
}}
|
|
server := newServer()
|
|
server.db = openMetadataDB(t, state)
|
|
|
|
source, err := server.getObjectSource("public", "format_name", "FUNCTION")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.HasPrefix(source["source"].(string), "CREATE FUNCTION public.format_name()") {
|
|
t.Fatalf("unexpected routine source: %#v", source)
|
|
}
|
|
}
|
|
|
|
func TestMaterializedViewSourceFallsBackFromEmptySysDefinition(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
primaryRows [][]driver.Value
|
|
}{
|
|
{name: "blank definition", primaryRows: [][]driver.Value{{" \n"}}},
|
|
{name: "missing definition", primaryRows: [][]driver.Value{}},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
const expected = "SELECT product_id, sum(amount) FROM public.sales GROUP BY product_id"
|
|
state := &metadataDriverState{query: func(query string) (driver.Rows, error) {
|
|
switch {
|
|
case strings.Contains(query, "sys_get_viewdef("):
|
|
return &valueRows{columns: []string{"source"}, rows: test.primaryRows}, nil
|
|
case strings.Contains(query, "pg_get_viewdef("):
|
|
return &valueRows{columns: []string{"source"}, rows: [][]driver.Value{{expected}}}, nil
|
|
default:
|
|
return nil, errors.New("unexpected query: " + query)
|
|
}
|
|
}}
|
|
server := newServer()
|
|
server.db = openMetadataDB(t, state)
|
|
|
|
source, err := server.getObjectSource("public", "daily_sales", "MATERIALIZED_VIEW")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if source["source"] != expected {
|
|
t.Fatalf("unexpected materialized view source: %#v", source)
|
|
}
|
|
queries := state.snapshotQueries()
|
|
if len(queries) != 2 || !strings.Contains(queries[0], "sys_get_viewdef(") || !strings.Contains(queries[1], "pg_get_viewdef(") {
|
|
t.Fatalf("unexpected materialized view source fallback: %v", queries)
|
|
}
|
|
for _, query := range queries {
|
|
if !strings.Contains(query, "c.relkind = 'm'") {
|
|
t.Fatalf("materialized view query was not constrained by relation kind: %s", query)
|
|
}
|
|
}
|
|
if server.usePgViewDefinition {
|
|
t.Fatal("an empty definition for one materialized view must not change the ordinary-view function cache")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestMaterializedViewSourceRejectsEmptyDefinitionAfterFallback(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
rows [][]driver.Value
|
|
}{
|
|
{name: "blank definitions", rows: [][]driver.Value{{"\t"}}},
|
|
{name: "missing definitions", rows: [][]driver.Value{}},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
state := &metadataDriverState{query: func(query string) (driver.Rows, error) {
|
|
if strings.Contains(query, "sys_get_viewdef(") || strings.Contains(query, "pg_get_viewdef(") {
|
|
return &valueRows{columns: []string{"source"}, rows: test.rows}, nil
|
|
}
|
|
return nil, errors.New("unexpected query: " + query)
|
|
}}
|
|
server := newServer()
|
|
server.db = openMetadataDB(t, state)
|
|
|
|
_, err := server.getObjectSource("public", "daily_sales", "MATERIALIZED_VIEW")
|
|
if err == nil || !strings.Contains(err.Error(), "materialized view") || !strings.Contains(err.Error(), "empty source") {
|
|
t.Fatalf("empty materialized view definitions must fail explicitly: %v", err)
|
|
}
|
|
if queries := state.snapshotQueries(); len(queries) != 2 {
|
|
t.Fatalf("expected the bounded sys/pg definition probes, got %v", queries)
|
|
}
|
|
if server.usePgViewDefinition {
|
|
t.Fatal("empty definitions must not change the ordinary-view function cache")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestMySQLCompatViewAndMaterializedViewSourcesUseSeparateCatalogs(t *testing.T) {
|
|
state := &metadataDriverState{query: func(query string) (driver.Rows, error) {
|
|
switch {
|
|
case strings.Contains(query, "information_schema.views") && strings.Contains(query, "table_name = 'active_orders'"):
|
|
return &valueRows{columns: []string{"view_definition"}, rows: [][]driver.Value{{"SELECT * FROM orders WHERE active"}}}, nil
|
|
case strings.Contains(query, "information_schema.views"):
|
|
return nil, errors.New("materialized views must not be read from information_schema.views: " + query)
|
|
case strings.Contains(query, "sys_get_viewdef(") && strings.Contains(query, "c.relkind = 'm'"):
|
|
return &valueRows{columns: []string{"source"}, rows: [][]driver.Value{{"SELECT product_id, sum(amount) FROM sales GROUP BY product_id"}}}, nil
|
|
default:
|
|
return nil, errors.New("unexpected query: " + query)
|
|
}
|
|
}}
|
|
server := newServer()
|
|
server.db = openMetadataDB(t, state)
|
|
server.mode.mysqlCompat = true
|
|
|
|
view, err := server.getObjectSource("app", "active_orders", "VIEW")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if view["source"] != "SELECT * FROM orders WHERE active" {
|
|
t.Fatalf("unexpected ordinary view source: %#v", view)
|
|
}
|
|
materialized, err := server.getObjectSource("app", "daily_sales", "MATERIALIZED_VIEW")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.HasPrefix(fmt.Sprint(materialized["source"]), "SELECT product_id") {
|
|
t.Fatalf("unexpected materialized view source: %#v", materialized)
|
|
}
|
|
queries := state.snapshotQueries()
|
|
if len(queries) != 2 || !strings.Contains(queries[0], "information_schema.views") || !strings.Contains(queries[1], "sys_get_viewdef(") {
|
|
t.Fatalf("view kinds used the wrong metadata paths: %v", queries)
|
|
}
|
|
}
|
|
|
|
func TestMaterializedViewSourceUsesPostgresCatalogDirectly(t *testing.T) {
|
|
state := &metadataDriverState{query: func(query string) (driver.Rows, error) {
|
|
if !strings.Contains(query, "SELECT pg_get_viewdef(c.oid)") ||
|
|
!strings.Contains(query, "FROM pg_catalog.pg_class") ||
|
|
!strings.Contains(query, "c.relkind = 'm'") {
|
|
return nil, errors.New("unexpected query: " + query)
|
|
}
|
|
return &valueRows{columns: []string{"source"}, rows: [][]driver.Value{{"SELECT * FROM public.sales"}}}, nil
|
|
}}
|
|
server := newServer()
|
|
server.db = openMetadataDB(t, state)
|
|
server.mode.postgresCatalog = true
|
|
|
|
source, err := server.getObjectSource("public", "daily_sales", "MATERIALIZED_VIEW")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if source["source"] != "SELECT * FROM public.sales" {
|
|
t.Fatalf("unexpected materialized view source: %#v", source)
|
|
}
|
|
if queries := state.snapshotQueries(); len(queries) != 1 {
|
|
t.Fatalf("PostgreSQL catalog mode must use one direct pg_get_viewdef query: %v", queries)
|
|
}
|
|
}
|
|
|
|
func TestMaterializedViewUndefinedSysFunctionKeepsPgDefinitionCache(t *testing.T) {
|
|
state := &metadataDriverState{query: func(query string) (driver.Rows, error) {
|
|
switch {
|
|
case strings.Contains(query, "sys_get_viewdef("):
|
|
return nil, &gokb.Error{Code: gokb.ErrorCode("42883"), Message: "function sys_get_viewdef(oid) does not exist"}
|
|
case strings.Contains(query, "pg_get_viewdef("):
|
|
return &valueRows{columns: []string{"source"}, rows: [][]driver.Value{{"SELECT * FROM public.sales"}}}, nil
|
|
default:
|
|
return nil, errors.New("unexpected query: " + query)
|
|
}
|
|
}}
|
|
server := newServer()
|
|
server.db = openMetadataDB(t, state)
|
|
|
|
if _, err := server.getObjectSource("public", "daily_sales", "MATERIALIZED_VIEW"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := server.getObjectSource("public", "active_sales", "VIEW"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
queries := state.snapshotQueries()
|
|
if len(queries) != 3 || !strings.Contains(queries[0], "sys_get_viewdef(") ||
|
|
!strings.Contains(queries[1], "pg_get_viewdef(") || !strings.Contains(queries[2], "pg_get_viewdef(") ||
|
|
!server.usePgViewDefinition {
|
|
t.Fatalf("undefined sys_get_viewdef fallback cache changed: %v", queries)
|
|
}
|
|
}
|
|
|
|
func TestMaterializedViewSourceDoesNotFallbackOnUnrelatedErrors(t *testing.T) {
|
|
permissionErr := errors.New("permission denied for sys_class")
|
|
state := &metadataDriverState{query: func(query string) (driver.Rows, error) {
|
|
if strings.Contains(query, "sys_get_viewdef(") {
|
|
return nil, permissionErr
|
|
}
|
|
return nil, errors.New("unexpected fallback query: " + query)
|
|
}}
|
|
server := newServer()
|
|
server.db = openMetadataDB(t, state)
|
|
|
|
_, err := server.getObjectSource("public", "daily_sales", "MATERIALIZED_VIEW")
|
|
if !errors.Is(err, permissionErr) {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if queries := state.snapshotQueries(); len(queries) != 1 || !strings.Contains(queries[0], "sys_get_viewdef(") {
|
|
t.Fatalf("unrelated error triggered a fallback: %v", queries)
|
|
}
|
|
}
|
|
|
|
func TestViewSourceUsesSysDefinitionOnce(t *testing.T) {
|
|
state := &metadataDriverState{query: func(query string) (driver.Rows, error) {
|
|
if !strings.Contains(query, "SELECT sys_get_viewdef(c.oid)") || !strings.Contains(query, "FROM sys_catalog.sys_class") {
|
|
return nil, errors.New("unexpected query: " + query)
|
|
}
|
|
return &valueRows{columns: []string{"source"}, rows: [][]driver.Value{{"SELECT * FROM public.orders WHERE active"}}}, nil
|
|
}}
|
|
server := newServer()
|
|
server.db = openMetadataDB(t, state)
|
|
|
|
source, err := server.getObjectSource("public", "active_orders", "VIEW")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if source["source"] != "SELECT * FROM public.orders WHERE active" {
|
|
t.Fatalf("unexpected view source: %#v", source)
|
|
}
|
|
if queries := state.snapshotQueries(); len(queries) != 1 {
|
|
t.Fatalf("ordinary view source should use one query: %v", queries)
|
|
}
|
|
}
|
|
|
|
func TestTriggerObjectSourceUsesOwningRelation(t *testing.T) {
|
|
state := &metadataDriverState{query: func(query string) (driver.Rows, error) {
|
|
for _, fragment := range []string{
|
|
"SELECT sys_catalog.sys_get_triggerdef(tg.oid, true)",
|
|
"FROM sys_catalog.sys_trigger tg",
|
|
"n.nspname = 'public'",
|
|
"c.relname = 'orders'",
|
|
"tg.tgname = 'audit_before'",
|
|
"NOT tg.tgisinternal",
|
|
} {
|
|
if !strings.Contains(query, fragment) {
|
|
return nil, errors.New("trigger source query missing " + fragment + ": " + query)
|
|
}
|
|
}
|
|
return &valueRows{columns: []string{"definition"}, rows: [][]driver.Value{{"CREATE TRIGGER audit_before BEFORE INSERT ON public.orders EXECUTE FUNCTION public.audit_row()"}}}, nil
|
|
}}
|
|
server := newServer()
|
|
server.db = openMetadataDB(t, state)
|
|
|
|
source, err := server.getObjectSourceForRelation("public", "audit_before", "TRIGGER", "orders")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.Contains(fmt.Sprint(source["source"]), "ON public.orders") {
|
|
t.Fatalf("unexpected trigger source: %#v", source)
|
|
}
|
|
if source["editable"] != false {
|
|
t.Fatalf("trigger source must remain read-only: %#v", source)
|
|
}
|
|
}
|
|
|
|
func TestTriggerObjectSourceRejectsAmbiguousNameWithoutRelation(t *testing.T) {
|
|
state := &metadataDriverState{query: func(query string) (driver.Rows, error) {
|
|
if strings.Contains(query, "AND c.relname =") || !strings.Contains(query, "tg.tgname = 'audit_before'") {
|
|
return nil, errors.New("unexpected unscoped trigger source query: " + query)
|
|
}
|
|
return &valueRows{
|
|
columns: []string{"definition"},
|
|
rows: [][]driver.Value{
|
|
{"CREATE TRIGGER audit_before BEFORE INSERT ON public.items EXECUTE FUNCTION public.audit_row()"},
|
|
{"CREATE TRIGGER audit_before BEFORE INSERT ON public.orders EXECUTE FUNCTION public.audit_row()"},
|
|
},
|
|
}, nil
|
|
}}
|
|
server := newServer()
|
|
server.db = openMetadataDB(t, state)
|
|
|
|
_, err := server.getObjectSource("public", "audit_before", "TRIGGER")
|
|
if err == nil || !strings.Contains(err.Error(), "relation_name is required") {
|
|
t.Fatalf("ambiguous trigger name must require its owning relation: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestObjectSourceFallsBackToPgDefinitionFunctionsAndCachesChoice(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
objectType string
|
|
objectName string
|
|
sysFunction string
|
|
pgFunction string
|
|
catalogTable string
|
|
source string
|
|
}{
|
|
{
|
|
name: "function",
|
|
objectType: "FUNCTION",
|
|
objectName: "format_name",
|
|
sysFunction: "sys_get_functiondef",
|
|
pgFunction: "pg_get_functiondef",
|
|
catalogTable: "sys_catalog.sys_proc",
|
|
source: "CREATE FUNCTION public.format_name() RETURNS text AS $$ SELECT 'x'; $$",
|
|
},
|
|
{
|
|
name: "view",
|
|
objectType: "VIEW",
|
|
objectName: "active_orders",
|
|
sysFunction: "sys_get_viewdef",
|
|
pgFunction: "pg_get_viewdef",
|
|
catalogTable: "sys_catalog.sys_class",
|
|
source: "SELECT * FROM public.orders WHERE active",
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
state := &metadataDriverState{query: func(query string) (driver.Rows, error) {
|
|
if !strings.Contains(query, test.catalogTable) {
|
|
return nil, errors.New("unexpected query: " + query)
|
|
}
|
|
if strings.Contains(query, test.sysFunction+"(") {
|
|
return nil, &gokb.Error{Code: gokb.ErrorCode("42883"), Message: "function " + test.sysFunction + "(oid) does not exist"}
|
|
}
|
|
if strings.Contains(query, test.pgFunction+"(") {
|
|
return &valueRows{columns: []string{"source"}, rows: [][]driver.Value{{test.source}}}, nil
|
|
}
|
|
return nil, errors.New("unexpected query: " + query)
|
|
}}
|
|
server := newServer()
|
|
server.db = openMetadataDB(t, state)
|
|
|
|
for call := 0; call < 2; call++ {
|
|
source, err := server.getObjectSource("public", test.objectName, test.objectType)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if source["source"] != test.source {
|
|
t.Fatalf("unexpected source: %#v", source)
|
|
}
|
|
}
|
|
|
|
state.mu.Lock()
|
|
queries := append([]string(nil), state.queries...)
|
|
state.mu.Unlock()
|
|
if len(queries) != 3 ||
|
|
!strings.Contains(queries[0], test.sysFunction+"(") ||
|
|
!strings.Contains(queries[1], test.pgFunction+"(") ||
|
|
!strings.Contains(queries[2], test.pgFunction+"(") {
|
|
t.Fatalf("definition fallback choice was not cached: %v", queries)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestObjectSourceDoesNotFallbackOnUnrelatedErrors(t *testing.T) {
|
|
permissionErr := errors.New("permission denied for sys_proc")
|
|
state := &metadataDriverState{query: func(query string) (driver.Rows, error) {
|
|
if strings.Contains(query, "sys_get_functiondef(") {
|
|
return nil, permissionErr
|
|
}
|
|
return nil, errors.New("unexpected fallback query: " + query)
|
|
}}
|
|
server := newServer()
|
|
server.db = openMetadataDB(t, state)
|
|
|
|
_, err := server.getObjectSource("public", "format_name", "FUNCTION")
|
|
if !errors.Is(err, permissionErr) {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
state.mu.Lock()
|
|
defer state.mu.Unlock()
|
|
if len(state.queries) != 1 || !strings.Contains(state.queries[0], "sys_get_functiondef(") {
|
|
t.Fatalf("unrelated error triggered a fallback: %v", state.queries)
|
|
}
|
|
}
|
|
|
|
func TestObjectSourceFallsBackToV7RoutineDDLAndCachesChoice(t *testing.T) {
|
|
state := &metadataDriverState{query: func(query string) (driver.Rows, error) {
|
|
switch {
|
|
case strings.Contains(query, "sys_get_functiondef("):
|
|
return nil, &gokb.Error{Code: gokb.ErrorCode("42883"), Message: "function SYS_GET_FUNCTIONDEF(OID) does not exist"}
|
|
case strings.Contains(query, "pg_get_functiondef("):
|
|
return nil, &gokb.Error{Code: gokb.ErrorCode("42883"), Message: "function PG_GET_FUNCTIONDEF(OID) does not exist"}
|
|
case strings.Contains(query, "DBMS_METADATA.GET_FUNC_DDL"):
|
|
if !strings.Contains(query, "CAST('format_name' AS varchar(128))") || !strings.Contains(query, "CAST('PUBLIC' AS varchar(128))") {
|
|
return nil, errors.New("legacy DDL query lost routine identity: " + query)
|
|
}
|
|
return &valueRows{columns: []string{"ddl"}, rows: [][]driver.Value{{"CREATE OR REPLACE FUNCTION PUBLIC.format_name() RETURN TEXT AS ..."}}}, nil
|
|
default:
|
|
return nil, errors.New("unexpected query: " + query)
|
|
}
|
|
}}
|
|
server := newServer()
|
|
server.db = openMetadataDB(t, state)
|
|
|
|
for range 2 {
|
|
source, err := server.getObjectSource("PUBLIC", "format_name", "FUNCTION")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.Contains(fmt.Sprint(source["source"]), "CREATE OR REPLACE FUNCTION") {
|
|
t.Fatalf("unexpected source: %#v", source)
|
|
}
|
|
}
|
|
|
|
queries := state.snapshotQueries()
|
|
if len(queries) != 4 || !strings.Contains(queries[2], "DBMS_METADATA.GET_FUNC_DDL") || !strings.Contains(queries[3], "DBMS_METADATA.GET_FUNC_DDL") || !server.useLegacyRoutineDefinition {
|
|
t.Fatalf("V7 routine DDL choice was not cached: %v", queries)
|
|
}
|
|
}
|
|
|
|
func TestColumnsFallbackToPgGetExprAndCacheChoice(t *testing.T) {
|
|
registerExpressionFallbackDriver.Do(func() { sql.Register("kingbase-expression-fallback-test", fallbackDriver{}) })
|
|
state := &fallbackDriverState{}
|
|
expressionFallbackState.Store(state)
|
|
db, err := sql.Open("kingbase-expression-fallback-test", "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
db.SetMaxOpenConns(1)
|
|
t.Cleanup(func() { _ = db.Close() })
|
|
server := newServer()
|
|
server.db = db
|
|
|
|
for call := 0; call < 2; call++ {
|
|
columns, err := server.getColumns("public", "orders")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(columns) != 1 || columns[0].Extra == nil || *columns[0].Extra != "GENERATED BY DEFAULT AS IDENTITY" {
|
|
t.Fatalf("unexpected columns: %#v", columns)
|
|
}
|
|
}
|
|
state.mu.Lock()
|
|
defer state.mu.Unlock()
|
|
var sysCalls, pgCalls int
|
|
for _, query := range state.queries {
|
|
if strings.Contains(query, "sys_get_expr(") {
|
|
sysCalls++
|
|
}
|
|
if strings.Contains(query, "pg_get_expr(") {
|
|
pgCalls++
|
|
}
|
|
if (strings.Contains(query, "sys_get_expr(") || strings.Contains(query, "pg_get_expr(")) &&
|
|
!strings.Contains(query, "col_description(a.attrelid, a.attnum)") {
|
|
t.Fatalf("catalog columns must use PostgreSQL column comments: %s", query)
|
|
}
|
|
if strings.Contains(query, "pg_get_expr(") && !strings.Contains(query, "a.attidentity") {
|
|
t.Fatalf("catalog columns must include identity metadata: %s", query)
|
|
}
|
|
}
|
|
if sysCalls != 1 || pgCalls != 2 {
|
|
t.Fatalf("fallback choice was not cached: sys=%d pg=%d queries=%v", sysCalls, pgCalls, state.queries)
|
|
}
|
|
}
|
|
|
|
func TestColumnsFallbackWhenCatalogHasNoAttidentityAndCacheChoice(t *testing.T) {
|
|
registerExpressionFallbackDriver.Do(func() { sql.Register("kingbase-expression-fallback-test", fallbackDriver{}) })
|
|
state := &fallbackDriverState{rejectAttidentity: true}
|
|
expressionFallbackState.Store(state)
|
|
db, err := sql.Open("kingbase-expression-fallback-test", "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
db.SetMaxOpenConns(1)
|
|
t.Cleanup(func() { _ = db.Close() })
|
|
server := newServer()
|
|
server.db = db
|
|
|
|
for call := 0; call < 2; call++ {
|
|
columns, err := server.getColumns("public", "orders")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(columns) != 1 || columns[0].Extra != nil {
|
|
t.Fatalf("unexpected columns: %#v", columns)
|
|
}
|
|
}
|
|
|
|
state.mu.Lock()
|
|
defer state.mu.Unlock()
|
|
var identityCalls, compatibleCalls, sysCalls, pgCalls int
|
|
for _, query := range state.queries {
|
|
if strings.Contains(query, "a.attidentity") {
|
|
identityCalls++
|
|
}
|
|
if strings.Contains(query, "CAST(NULL AS varchar(1)) AS attidentity") {
|
|
compatibleCalls++
|
|
}
|
|
if strings.Contains(query, "sys_get_expr(") {
|
|
sysCalls++
|
|
}
|
|
if strings.Contains(query, "pg_get_expr(") {
|
|
pgCalls++
|
|
}
|
|
}
|
|
if identityCalls != 1 || compatibleCalls != 3 || sysCalls != 2 || pgCalls != 2 {
|
|
t.Fatalf("fallback choices were not cached: identity=%d compatible=%d sys=%d pg=%d queries=%v", identityCalls, compatibleCalls, sysCalls, pgCalls, state.queries)
|
|
}
|
|
}
|
|
|
|
func TestKingbaseIdentityClausesAreExposedAndRendered(t *testing.T) {
|
|
srv := newServer()
|
|
tests := []struct {
|
|
name string
|
|
code string
|
|
expected string
|
|
}{
|
|
{name: "always", code: "a", expected: "GENERATED ALWAYS AS IDENTITY"},
|
|
{name: "by default", code: "d", expected: "GENERATED BY DEFAULT AS IDENTITY"},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
extra := kingbaseIdentityClause(test.code)
|
|
if extra == nil || *extra != test.expected {
|
|
t.Fatalf("unexpected identity clause for %q: %#v", test.code, extra)
|
|
}
|
|
definition := srv.columnDDLDefinition(columnInfo{Name: "id", DataType: "integer", IsNullable: false, Extra: extra})
|
|
expected := `"id" integer ` + test.expected + " NOT NULL"
|
|
if definition != expected {
|
|
t.Fatalf("unexpected column DDL: %s", definition)
|
|
}
|
|
payload, err := json.Marshal(columnInfo{Name: "id", DataType: "integer", Extra: extra})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.Contains(string(payload), `"extra":"`+test.expected+`"`) {
|
|
t.Fatalf("identity clause missing from protocol payload: %s", payload)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestTableDDLIncludesIdentityIndexesTriggersAndComments(t *testing.T) {
|
|
registerExpressionFallbackDriver.Do(func() { sql.Register("kingbase-expression-fallback-test", fallbackDriver{}) })
|
|
expressionFallbackState.Store(&fallbackDriverState{})
|
|
db, err := sql.Open("kingbase-expression-fallback-test", "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
db.SetMaxOpenConns(1)
|
|
t.Cleanup(func() { _ = db.Close() })
|
|
server := newServer()
|
|
server.db = db
|
|
|
|
ddl, err := server.getTableDDL("public", "orders")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.Contains(ddl, `"id" integer GENERATED BY DEFAULT AS IDENTITY NOT NULL`) {
|
|
t.Fatalf("identity clause missing from table DDL: %s", ddl)
|
|
}
|
|
for _, expected := range []string{
|
|
`COMMENT ON TABLE "public"."orders" IS 'orders table';`,
|
|
`CREATE INDEX orders_id_idx ON public.orders USING btree (id);`,
|
|
`COMMENT ON INDEX "public"."orders_id_idx" IS 'lookup index';`,
|
|
`CREATE TRIGGER orders_audit BEFORE INSERT ON public.orders FOR EACH ROW EXECUTE FUNCTION audit_orders();`,
|
|
} {
|
|
if !strings.Contains(ddl, expected) {
|
|
t.Fatalf("table DDL missing %q:\n%s", expected, ddl)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRenderTableDDLIncludesEscapedComments(t *testing.T) {
|
|
srv := newServer()
|
|
primaryComment := "主键'编号"
|
|
emptyComment := " "
|
|
tableComment := "订单'表"
|
|
ddl := srv.renderTableDDL(
|
|
`app"schema`,
|
|
`order"items`,
|
|
[]columnInfo{
|
|
{Name: `id"value`, DataType: "integer", IsNullable: false, IsPrimaryKey: true, Comment: &primaryComment},
|
|
{Name: "note", DataType: "text", IsNullable: true, Comment: &emptyComment},
|
|
},
|
|
&tableComment,
|
|
)
|
|
|
|
expected := []string{
|
|
`CREATE TABLE "app""schema"."order""items"`,
|
|
`COMMENT ON TABLE "app""schema"."order""items" IS '订单''表';`,
|
|
`COMMENT ON COLUMN "app""schema"."order""items"."id""value" IS '主键''编号';`,
|
|
}
|
|
for _, fragment := range expected {
|
|
if !strings.Contains(ddl, fragment) {
|
|
t.Fatalf("table DDL missing %q:\n%s", fragment, ddl)
|
|
}
|
|
}
|
|
if strings.Contains(ddl, `COMMENT ON COLUMN "app""schema"."order""items"."note"`) {
|
|
t.Fatalf("blank column comment must be omitted:\n%s", ddl)
|
|
}
|
|
}
|
|
|
|
func TestColumnDDLDefinitionPreservesCompatibilityExtras(t *testing.T) {
|
|
srv := newServer()
|
|
identity := "IDENTITY(1,1)"
|
|
defaultValue := "0"
|
|
if definition := srv.columnDDLDefinition(columnInfo{Name: "id", DataType: "integer", IsNullable: false, Extra: &identity}); definition != `"id" integer IDENTITY(1,1) NOT NULL` {
|
|
t.Fatalf("unexpected SQL Server-compatible DDL: %s", definition)
|
|
}
|
|
if definition := srv.columnDDLDefinition(columnInfo{Name: "count", DataType: "integer", IsNullable: true, ColumnDefault: &defaultValue}); definition != `"count" integer DEFAULT 0` {
|
|
t.Fatalf("unexpected regular column DDL: %s", definition)
|
|
}
|
|
if extra := kingbaseIdentityClause(""); extra != nil {
|
|
t.Fatalf("non-identity column must not expose an extra clause: %#v", extra)
|
|
}
|
|
}
|
|
|
|
func TestRenderTableDDLUsesBacktickIdentifiersInMySQLCompatMode(t *testing.T) {
|
|
srv := newServer()
|
|
srv.mode.mysqlCompat = true
|
|
ddl := srv.renderTableDDL(
|
|
"audit-schema",
|
|
"events",
|
|
[]columnInfo{
|
|
{Name: "id", DataType: "integer", IsNullable: false, IsPrimaryKey: true},
|
|
},
|
|
nil,
|
|
)
|
|
expected := "CREATE TABLE `audit-schema`.`events` (\n `id` integer NOT NULL,\n PRIMARY KEY (`id`)\n);"
|
|
if ddl != expected {
|
|
t.Fatalf("MySQL-compat DDL must use backtick identifiers:\ngot: %s\nwant: %s", ddl, expected)
|
|
}
|
|
}
|
|
|
|
func TestColumnDDLDefinitionRestoresMySQLCompatibilityTypeModifiers(t *testing.T) {
|
|
srv := newServer()
|
|
length := 64
|
|
precision := 12
|
|
scale := 4
|
|
tests := []struct {
|
|
name string
|
|
column columnInfo
|
|
want string
|
|
}{
|
|
{name: "varchar length", column: columnInfo{Name: "label", DataType: "varchar", IsNullable: true, CharacterMaximumLength: &length}, want: `"label" varchar(64)`},
|
|
{name: "numeric precision and scale", column: columnInfo{Name: "amount", DataType: "numeric", IsNullable: true, NumericPrecision: &precision, NumericScale: &scale}, want: `"amount" numeric(12,4)`},
|
|
{name: "existing modifier", column: columnInfo{Name: "code", DataType: "VARCHAR(64)", IsNullable: true, CharacterMaximumLength: &length}, want: `"code" VARCHAR(64)`},
|
|
{name: "unsigned", column: columnInfo{Name: "count", DataType: "integer", FullDataType: "integer unsigned", IsNullable: true}, want: `"count" integer unsigned`},
|
|
{name: "enum values", column: columnInfo{Name: "status", DataType: "enum", FullDataType: "enum('new','done')", IsNullable: true}, want: `"status" enum('new','done')`},
|
|
{name: "datetime precision", column: columnInfo{Name: "created_at", DataType: "datetime", FullDataType: "datetime(6)", IsNullable: true}, want: `"created_at" datetime(6)`},
|
|
{name: "bit length", column: columnInfo{Name: "mask", DataType: "bit", FullDataType: "bit(8)", IsNullable: true}, want: `"mask" bit(8)`},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
if got := srv.columnDDLDefinition(test.column); got != test.want {
|
|
t.Fatalf("unexpected column DDL: got %q, want %q", got, test.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestInformationSchemaColumnsPreserveFullTypesInDDL(t *testing.T) {
|
|
state := &metadataDriverState{query: func(query string) (driver.Rows, error) {
|
|
if !strings.Contains(query, "c.column_type") || !strings.Contains(query, "THEN c.udt_name") {
|
|
return nil, errors.New("full column type was not requested")
|
|
}
|
|
return &valueRows{
|
|
columns: []string{"column_name", "data_type", "column_type", "is_nullable", "column_default", "column_comment", "numeric_precision", "numeric_scale", "character_maximum_length"},
|
|
rows: [][]driver.Value{
|
|
{"count", "integer", "integer unsigned", "YES", nil, nil, int64(32), int64(0), nil},
|
|
{"status", "enum", "enum('new','done')", "YES", nil, nil, nil, nil, nil},
|
|
{"created_at", "datetime", "datetime(6)", "YES", nil, nil, nil, nil, nil},
|
|
{"mask", "bit", "bit(8)", "YES", nil, nil, nil, nil, nil},
|
|
},
|
|
}, nil
|
|
}}
|
|
server := newServer()
|
|
server.db = openMetadataDB(t, state)
|
|
|
|
columns, err := server.informationSchemaColumns("public", "orders", map[string]bool{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(columns) != 4 || columns[0].DataType != "integer" || columns[0].FullDataType != "integer unsigned" {
|
|
t.Fatalf("unexpected metadata columns: %#v", columns)
|
|
}
|
|
ddl := server.renderTableDDL("public", "orders", columns, nil)
|
|
for _, expected := range []string{
|
|
`"count" integer unsigned`,
|
|
`"status" enum('new','done')`,
|
|
`"created_at" datetime(6)`,
|
|
`"mask" bit(8)`,
|
|
} {
|
|
if !strings.Contains(ddl, expected) {
|
|
t.Fatalf("table DDL missing %q:\n%s", expected, ddl)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestInformationSchemaColumnsResolveUserDefinedTypeWithoutColumnType(t *testing.T) {
|
|
state := &metadataDriverState{query: func(query string) (driver.Rows, error) {
|
|
if strings.Contains(query, "c.column_type") {
|
|
return nil, &gokb.Error{Code: gokb.ErrorCode("42703"), Message: "column c.column_type does not exist"}
|
|
}
|
|
if !strings.Contains(query, "THEN c.udt_name") {
|
|
return nil, errors.New("user-defined type name was not requested")
|
|
}
|
|
return &valueRows{
|
|
columns: []string{"column_name", "data_type", "column_type", "is_nullable", "column_default", "column_comment", "numeric_precision", "numeric_scale", "character_maximum_length"},
|
|
rows: [][]driver.Value{{"created_at", "USER-DEFINED", "datetime", "YES", nil, nil, nil, nil, nil}},
|
|
}, nil
|
|
}}
|
|
server := newServer()
|
|
server.db = openMetadataDB(t, state)
|
|
|
|
columns, err := server.informationSchemaColumns("public", "orders", map[string]bool{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(columns) != 1 || columns[0].DataType != "datetime" || columns[0].FullDataType != "datetime" {
|
|
t.Fatalf("unexpected user-defined metadata columns: %#v", columns)
|
|
}
|
|
payload, err := json.Marshal(columns[0])
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.Contains(string(payload), `"data_type":"datetime"`) || strings.Contains(string(payload), "USER-DEFINED") {
|
|
t.Fatalf("unresolved user-defined type leaked into get_columns payload: %s", payload)
|
|
}
|
|
if ddl := server.renderTableDDL("public", "orders", columns, nil); !strings.Contains(ddl, `"created_at" datetime`) || strings.Contains(ddl, "USER-DEFINED") {
|
|
t.Fatalf("unexpected user-defined type DDL:\n%s", ddl)
|
|
}
|
|
|
|
if _, err := server.informationSchemaColumns("public", "events", map[string]bool{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
state.mu.Lock()
|
|
queries := append([]string(nil), state.queries...)
|
|
state.mu.Unlock()
|
|
if len(queries) != 3 || strings.Contains(queries[2], "c.column_type") {
|
|
t.Fatalf("missing column_type capability must be cached: %v", queries)
|
|
}
|
|
}
|
|
|
|
func TestResolvedInformationSchemaDataType(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
dataType string
|
|
fullDataType string
|
|
want string
|
|
}{
|
|
{name: "hyphen marker", dataType: "USER-DEFINED", fullDataType: "datetime", want: "datetime"},
|
|
{name: "underscore marker with qualified type", dataType: "USER_DEFINED", fullDataType: `sys."datetime"`, want: `sys."datetime"`},
|
|
{name: "parameterized resolved type", dataType: " user-defined ", fullDataType: " datetime(6) ", want: "datetime(6)"},
|
|
{name: "ordinary type keeps protocol type", dataType: "varchar", fullDataType: "varchar(64)", want: "varchar"},
|
|
{name: "missing resolved type keeps marker", dataType: "USER-DEFINED", fullDataType: "", want: "USER-DEFINED"},
|
|
{name: "unresolved marker keeps original", dataType: "USER_DEFINED", fullDataType: "USER-DEFINED", want: "USER_DEFINED"},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
if got := resolvedInformationSchemaDataType(test.dataType, test.fullDataType); got != test.want {
|
|
t.Fatalf("unexpected resolved data type: got %q, want %q", got, test.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestInformationSchemaColumnsPreserveColumnTypeWithoutUdtName(t *testing.T) {
|
|
state := &metadataDriverState{query: func(query string) (driver.Rows, error) {
|
|
if strings.Contains(query, "c.udt_name") {
|
|
return nil, &gokb.Error{Code: gokb.ErrorCode("42703"), Message: "kb: column c.udt_name does not exist"}
|
|
}
|
|
if !strings.Contains(query, "c.column_type") {
|
|
return nil, errors.New("column type fallback was not requested")
|
|
}
|
|
return &valueRows{
|
|
columns: []string{"column_name", "data_type", "column_type", "is_nullable", "column_default", "column_comment", "numeric_precision", "numeric_scale", "character_maximum_length"},
|
|
rows: [][]driver.Value{{"status", "enum", "enum('new','done')", "YES", nil, nil, nil, nil, nil}},
|
|
}, nil
|
|
}}
|
|
server := newServer()
|
|
server.db = openMetadataDB(t, state)
|
|
|
|
for _, table := range []string{"orders", "events"} {
|
|
columns, err := server.informationSchemaColumns("public", table, map[string]bool{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(columns) != 1 || columns[0].FullDataType != "enum('new','done')" {
|
|
t.Fatalf("unexpected metadata columns: %#v", columns)
|
|
}
|
|
if ddl := server.renderTableDDL("public", table, columns, nil); !strings.Contains(ddl, `"status" enum('new','done')`) {
|
|
t.Fatalf("unexpected table DDL:\n%s", ddl)
|
|
}
|
|
}
|
|
state.mu.Lock()
|
|
queries := append([]string(nil), state.queries...)
|
|
state.mu.Unlock()
|
|
if len(queries) != 3 || !strings.Contains(queries[0], "c.udt_name") || strings.Contains(queries[1], "c.udt_name") || strings.Contains(queries[2], "c.udt_name") {
|
|
t.Fatalf("missing udt_name capability must be detected once and cached: %v", queries)
|
|
}
|
|
}
|
|
|
|
func TestInformationSchemaColumnsFallbackWithoutExtendedTypeColumns(t *testing.T) {
|
|
state := &metadataDriverState{query: func(query string) (driver.Rows, error) {
|
|
if strings.Contains(query, "c.column_type") {
|
|
return nil, &gokb.Error{Code: gokb.ErrorCode("42703"), Message: "column c.column_type does not exist"}
|
|
}
|
|
if strings.Contains(query, "c.udt_name") {
|
|
return nil, &gokb.Error{Code: gokb.ErrorCode("42703"), Message: "column c.udt_name does not exist"}
|
|
}
|
|
if !strings.Contains(query, "NULL AS column_type") {
|
|
return nil, errors.New("base type fallback was not requested")
|
|
}
|
|
return &valueRows{
|
|
columns: []string{"column_name", "data_type", "column_type", "is_nullable", "column_default", "column_comment", "numeric_precision", "numeric_scale", "character_maximum_length"},
|
|
rows: [][]driver.Value{{"label", "varchar", nil, "YES", nil, nil, nil, nil, int64(64)}},
|
|
}, nil
|
|
}}
|
|
server := newServer()
|
|
server.db = openMetadataDB(t, state)
|
|
|
|
for _, table := range []string{"orders", "events"} {
|
|
columns, err := server.informationSchemaColumns("public", table, map[string]bool{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(columns) != 1 || server.columnDDLDefinition(columns[0]) != `"label" varchar(64)` {
|
|
t.Fatalf("unexpected fallback columns: %#v", columns)
|
|
}
|
|
}
|
|
state.mu.Lock()
|
|
queries := append([]string(nil), state.queries...)
|
|
state.mu.Unlock()
|
|
if len(queries) != 4 || !strings.Contains(queries[2], "NULL AS column_type") || !strings.Contains(queries[3], "NULL AS column_type") {
|
|
t.Fatalf("missing extended type capabilities must be detected once and cached: %v", queries)
|
|
}
|
|
}
|
|
|
|
func TestInformationSchemaColumnsRetriesOnlyForMissingTypeMetadataColumns(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
firstError error
|
|
wantFallback bool
|
|
}{
|
|
{name: "missing column_type", firstError: &gokb.Error{Code: gokb.ErrorCode("42703"), Message: "column c.column_type does not exist"}, wantFallback: true},
|
|
{name: "different missing column", firstError: &gokb.Error{Code: gokb.ErrorCode("42703"), Message: "column c.other_column does not exist"}},
|
|
{name: "other metadata error", firstError: errors.New("metadata connection reset")},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
state := &metadataDriverState{query: func(query string) (driver.Rows, error) {
|
|
if strings.Contains(query, "c.column_type") {
|
|
return nil, test.firstError
|
|
}
|
|
if !strings.Contains(query, "THEN c.udt_name") || !strings.Contains(query, "END AS column_type") {
|
|
return nil, errors.New("unexpected fallback query: " + query)
|
|
}
|
|
return &valueRows{
|
|
columns: []string{"column_name", "data_type", "column_type", "is_nullable", "column_default", "column_comment", "numeric_precision", "numeric_scale", "character_maximum_length"},
|
|
rows: [][]driver.Value{{"label", "varchar", nil, "YES", nil, nil, nil, nil, int64(64)}},
|
|
}, nil
|
|
}}
|
|
server := newServer()
|
|
server.db = openMetadataDB(t, state)
|
|
|
|
columns, err := server.informationSchemaColumns("public", "orders", map[string]bool{})
|
|
if test.wantFallback {
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(columns) != 1 || server.columnDDLDefinition(columns[0]) != `"label" varchar(64)` {
|
|
t.Fatalf("unexpected fallback columns: %#v", columns)
|
|
}
|
|
} else if !errors.Is(err, test.firstError) {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
state.mu.Lock()
|
|
queries := append([]string(nil), state.queries...)
|
|
state.mu.Unlock()
|
|
wantQueries := 1
|
|
if test.wantFallback {
|
|
wantQueries = 2
|
|
}
|
|
if len(queries) != wantQueries {
|
|
t.Fatalf("unexpected query count: got %d, want %d: %v", len(queries), wantQueries, queries)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDisconnectResetsInformationSchemaCapabilityCache(t *testing.T) {
|
|
server := newServer()
|
|
server.infoColumnTypeUnsupported = true
|
|
server.infoUdtNameUnsupported = true
|
|
|
|
if err := server.disconnect(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if server.infoColumnTypeUnsupported || server.infoUdtNameUnsupported {
|
|
t.Fatal("disconnect must reset cached information_schema capabilities")
|
|
}
|
|
}
|
|
|
|
func TestConnectionLifecycleResetsCatalogOIDCapability(t *testing.T) {
|
|
state := &connectionAttemptState{pingErrors: map[string]error{}}
|
|
server := newServer()
|
|
server.openDatabase = state.open
|
|
server.catalogOIDUnsupported = true
|
|
|
|
if err := server.connect(connectParams{MySQLCompatMode: true, URLParams: "sslmode=disable"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if server.catalogOIDUnsupported {
|
|
t.Fatal("connect must reset the cached catalog OID capability")
|
|
}
|
|
|
|
server.catalogOIDUnsupported = true
|
|
if err := server.disconnect(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if server.catalogOIDUnsupported {
|
|
t.Fatal("disconnect must reset the cached catalog OID capability")
|
|
}
|
|
}
|
|
|
|
func TestConnectionLifecycleResetsConstraintCapabilityCache(t *testing.T) {
|
|
state := &connectionAttemptState{pingErrors: map[string]error{}}
|
|
server := newServer()
|
|
server.openDatabase = state.open
|
|
server.constraintDefinitionUnsupported = true
|
|
server.constraintValidatedUnsupported = true
|
|
server.constraintStatusUnsupported = true
|
|
|
|
if err := server.connect(connectParams{MySQLCompatMode: true, URLParams: "sslmode=disable"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if server.constraintDefinitionUnsupported || server.constraintValidatedUnsupported || server.constraintStatusUnsupported {
|
|
t.Fatal("connect must reset cached constraint capabilities")
|
|
}
|
|
|
|
server.constraintDefinitionUnsupported = true
|
|
server.constraintValidatedUnsupported = true
|
|
server.constraintStatusUnsupported = true
|
|
if err := server.disconnect(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if server.constraintDefinitionUnsupported || server.constraintValidatedUnsupported || server.constraintStatusUnsupported {
|
|
t.Fatal("disconnect must reset cached constraint capabilities")
|
|
}
|
|
}
|
|
|
|
func TestAppendDDLStatementEnsuresSingleTerminator(t *testing.T) {
|
|
got := appendDDLStatement("CREATE TABLE \"public\".\"orders\" (\n \"id\" integer\n)\n", "CREATE INDEX orders_id_idx ON public.orders (id)")
|
|
want := "CREATE TABLE \"public\".\"orders\" (\n \"id\" integer\n);\n\nCREATE INDEX orders_id_idx ON public.orders (id);"
|
|
if got != want {
|
|
t.Fatalf("unexpected appended DDL:\ngot: %q\nwant: %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestMySQLCompatColumnsUsePostgresColumnComments(t *testing.T) {
|
|
registerExpressionFallbackDriver.Do(func() { sql.Register("kingbase-expression-fallback-test", fallbackDriver{}) })
|
|
state := &fallbackDriverState{}
|
|
expressionFallbackState.Store(state)
|
|
db, err := sql.Open("kingbase-expression-fallback-test", "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
db.SetMaxOpenConns(1)
|
|
t.Cleanup(func() { _ = db.Close() })
|
|
server := newServer()
|
|
server.db = db
|
|
server.mode.mysqlCompat = true
|
|
|
|
columns, err := server.getColumns("public", "orders")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(columns) != 1 || columns[0].Comment == nil || *columns[0].Comment != "primary key" {
|
|
t.Fatalf("unexpected columns: %#v", columns)
|
|
}
|
|
if columns[0].Extra != nil {
|
|
t.Fatalf("MySQL-compatible metadata must not infer PostgreSQL identity: %#v", columns[0].Extra)
|
|
}
|
|
if columns[0].ResolvedSchema == nil || *columns[0].ResolvedSchema != "public" {
|
|
t.Fatalf("MySQL-compatible metadata must keep its effective schema: %#v", columns[0])
|
|
}
|
|
|
|
state.mu.Lock()
|
|
defer state.mu.Unlock()
|
|
if len(state.queries) != 2 || !strings.Contains(state.queries[1], "col_description(a.attrelid, a.attnum)") {
|
|
t.Fatalf("MySQL-compatible columns must use PostgreSQL column comments: %v", state.queries)
|
|
}
|
|
}
|
|
|
|
func TestAllCompatibilityModesUsePostgresTableComments(t *testing.T) {
|
|
registerExpressionFallbackDriver.Do(func() { sql.Register("kingbase-expression-fallback-test", fallbackDriver{}) })
|
|
state := &fallbackDriverState{}
|
|
expressionFallbackState.Store(state)
|
|
db, err := sql.Open("kingbase-expression-fallback-test", "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
db.SetMaxOpenConns(1)
|
|
t.Cleanup(func() { _ = db.Close() })
|
|
server := newServer()
|
|
server.db = db
|
|
for _, mysqlCompat := range []bool{false, true} {
|
|
state.mu.Lock()
|
|
state.queries = nil
|
|
state.mu.Unlock()
|
|
server.mode.mysqlCompat = mysqlCompat
|
|
|
|
tables, err := server.listTables("public", metadataListConstraints{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(tables) != 1 || tables[0].Comment == nil || *tables[0].Comment != "orders table" {
|
|
t.Fatalf("mysqlCompat=%v: unexpected tables: %#v", mysqlCompat, tables)
|
|
}
|
|
|
|
state.mu.Lock()
|
|
queries := append([]string(nil), state.queries...)
|
|
state.mu.Unlock()
|
|
if len(queries) != 1 || !strings.Contains(queries[0], "obj_description(c.oid)") ||
|
|
!strings.Contains(queries[0], "FROM sys_catalog.sys_class c") {
|
|
t.Fatalf("mysqlCompat=%v: table comments must share the PostgreSQL-compatible query: %v", mysqlCompat, queries)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestGetTableCommentUsesPostgresCatalogComment(t *testing.T) {
|
|
registerExpressionFallbackDriver.Do(func() { sql.Register("kingbase-expression-fallback-test", fallbackDriver{}) })
|
|
state := &fallbackDriverState{}
|
|
expressionFallbackState.Store(state)
|
|
db, err := sql.Open("kingbase-expression-fallback-test", "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
db.SetMaxOpenConns(1)
|
|
t.Cleanup(func() { _ = db.Close() })
|
|
server := newServer()
|
|
server.db = db
|
|
server.mode.mysqlCompat = true
|
|
|
|
comment, err := server.getTableComment("public", "orders")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if comment == nil || *comment != "orders table" {
|
|
t.Fatalf("unexpected table comment: %#v", comment)
|
|
}
|
|
|
|
state.mu.Lock()
|
|
defer state.mu.Unlock()
|
|
if len(state.queries) != 1 || !strings.Contains(state.queries[0], "FROM sys_catalog.sys_class c") ||
|
|
!strings.Contains(state.queries[0], "n.nspname = 'public'") || !strings.Contains(state.queries[0], "c.relname = 'orders'") {
|
|
t.Fatalf("table comment must use the PostgreSQL-compatible catalog query: %v", state.queries)
|
|
}
|
|
}
|
|
|
|
func TestDetectMySQLCompatModePrefersDatabaseModeOverSyntaxProbe(t *testing.T) {
|
|
oracle := "oracle"
|
|
state := &modeDetectionDriverState{
|
|
databaseMode: &oracle,
|
|
backtickIdentifiers: true,
|
|
}
|
|
db := openModeDetectionDB(t, state)
|
|
|
|
if detectMySQLCompatMode(db) {
|
|
t.Fatal("oracle-compatible server with sql_mode should not be treated as MySQL-compatible")
|
|
}
|
|
|
|
state.mu.Lock()
|
|
defer state.mu.Unlock()
|
|
if len(state.queries) != 1 || !strings.Contains(state.queries[0], "database_mode") {
|
|
t.Fatalf("database_mode should be authoritative when present: %v", state.queries)
|
|
}
|
|
}
|
|
|
|
func TestDetectMySQLCompatModeAcceptsExplicitMySQLMode(t *testing.T) {
|
|
mysql := "mysql"
|
|
state := &modeDetectionDriverState{databaseMode: &mysql}
|
|
db := openModeDetectionDB(t, state)
|
|
|
|
if !detectMySQLCompatMode(db) {
|
|
t.Fatal("database_mode=mysql should use MySQL compatibility")
|
|
}
|
|
|
|
state.mu.Lock()
|
|
defer state.mu.Unlock()
|
|
if len(state.queries) != 1 || !strings.Contains(state.queries[0], "database_mode") {
|
|
t.Fatalf("explicit MySQL mode should not need the syntax probe: %v", state.queries)
|
|
}
|
|
}
|
|
|
|
func TestDetectKingbaseModeReportsDatabaseMode(t *testing.T) {
|
|
for _, databaseMode := range []string{"oracle", "postgresql"} {
|
|
t.Run(databaseMode, func(t *testing.T) {
|
|
db := openModeDetectionDB(t, &modeDetectionDriverState{databaseMode: &databaseMode})
|
|
|
|
mode := detectKingbaseMode(db, false)
|
|
|
|
if mode.compatibilityMode != databaseMode {
|
|
t.Fatalf("unexpected compatibility mode: %q", mode.compatibilityMode)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestConnectionInfoReportsCompatibilityIdentifierQuote(t *testing.T) {
|
|
for _, testCase := range []struct {
|
|
name string
|
|
compatibilityMode string
|
|
mysqlCompat bool
|
|
expectedQuote string
|
|
}{
|
|
{name: "postgres compatible", compatibilityMode: "postgresql", expectedQuote: `"`},
|
|
{name: "oracle compatible", compatibilityMode: "oracle", expectedQuote: `"`},
|
|
{name: "mysql compatible", compatibilityMode: "mysql", mysqlCompat: true, expectedQuote: "`"},
|
|
} {
|
|
t.Run(testCase.name, func(t *testing.T) {
|
|
db := openModeDetectionDB(t, &modeDetectionDriverState{})
|
|
server := newServer()
|
|
server.db = db
|
|
server.mode.compatibilityMode = testCase.compatibilityMode
|
|
server.mode.mysqlCompat = testCase.mysqlCompat
|
|
|
|
info, err := server.connectionInfo()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if info["compatibilityMode"] != testCase.compatibilityMode {
|
|
t.Fatalf("unexpected compatibility mode: %#v", info["compatibilityMode"])
|
|
}
|
|
if info["identifierQuote"] != testCase.expectedQuote {
|
|
t.Fatalf("unexpected identifier quote: %#v", info["identifierQuote"])
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDetectMySQLCompatModeProbesBacktickSyntaxWhenDatabaseModeMissing(t *testing.T) {
|
|
state := &modeDetectionDriverState{backtickIdentifiers: true}
|
|
db := openModeDetectionDB(t, state)
|
|
|
|
if !detectMySQLCompatMode(db) {
|
|
t.Fatal("legacy server accepting backtick identifiers should use MySQL compatibility")
|
|
}
|
|
|
|
state.mu.Lock()
|
|
defer state.mu.Unlock()
|
|
if len(state.queries) != 2 || !strings.Contains(state.queries[1], "dbx_identifier_probe") {
|
|
t.Fatalf("expected database_mode probe followed by backtick syntax probe, got: %v", state.queries)
|
|
}
|
|
}
|
|
|
|
func TestDetectMySQLCompatModeRejectsSQLModeWithoutBacktickSyntax(t *testing.T) {
|
|
state := &modeDetectionDriverState{}
|
|
db := openModeDetectionDB(t, state)
|
|
|
|
if detectMySQLCompatMode(db) {
|
|
t.Fatal("legacy server rejecting backtick identifiers must not use MySQL compatibility")
|
|
}
|
|
}
|
|
|
|
func TestQuoteLiteralEscapesMetadataValues(t *testing.T) {
|
|
if got := quoteLiteral("a'b"); got != "'a''b'" {
|
|
t.Fatalf("unexpected literal: %s", got)
|
|
}
|
|
constraints := metadataListConstraints{Filter: "CHILD", ObjectTypes: []string{"table"}}
|
|
if !constraintsMatch(constraints, "dbx_child", "TABLE") || constraintsMatch(constraints, "dbx_parent", "TABLE") {
|
|
t.Fatal("metadata constraints were not applied")
|
|
}
|
|
}
|
|
|
|
func TestCompletionNameMatching(t *testing.T) {
|
|
request := completionAssistantRequest{Mask: "DBX_", MatchMode: "prefix"}
|
|
if !completionNameMatches("dbx_child", request) || completionNameMatches("other_dbx_child", request) {
|
|
t.Fatal("case-insensitive prefix matching failed")
|
|
}
|
|
request.MatchMode = "contains"
|
|
if !completionNameMatches("other_dbx_child", request) {
|
|
t.Fatal("contains matching failed")
|
|
}
|
|
}
|
|
|
|
func TestExecuteQueryUsesSimpleProtocolAndReleasesContext(t *testing.T) {
|
|
db, state := openFakeDB(t, 1)
|
|
server := newServer()
|
|
server.db = db
|
|
result, err := server.executeQuery(queryOptions{SQL: "SELECT 1", MaxRows: 10})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(result.Rows) != 1 || state.queryArgs != 0 {
|
|
t.Fatalf("unexpected result or bound arguments: rows=%v args=%d", result.Rows, state.queryArgs)
|
|
}
|
|
assertContextCanceled(t, state.queryCtx)
|
|
}
|
|
|
|
func TestExecuteStatementsPreserveSessionSearchPathWithoutSchema(t *testing.T) {
|
|
db, state := openFakeDB(t, 0)
|
|
server := newServer()
|
|
server.db = db
|
|
|
|
statements := []string{
|
|
"SET search_path TO app_data",
|
|
"CREATE TABLE issue_6134 (id integer)",
|
|
}
|
|
for _, statement := range statements {
|
|
if _, err := server.executeQuery(queryOptions{SQL: statement}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
state.mu.Lock()
|
|
defer state.mu.Unlock()
|
|
if strings.Join(state.execStatements, "\n") != strings.Join(statements, "\n") {
|
|
t.Fatalf("schema-less statements changed session search_path: %v", state.execStatements)
|
|
}
|
|
if len(state.execConnIDs) != len(statements) || state.execConnIDs[0] != state.execConnIDs[1] {
|
|
t.Fatalf("session statements used different connections: %v", state.execConnIDs)
|
|
}
|
|
}
|
|
|
|
func TestSchemaConnSkipsInitialAndRepeatedEmptySchema(t *testing.T) {
|
|
db, state := openFakeDB(t, 0)
|
|
server := newServer()
|
|
server.db = db
|
|
|
|
for range 2 {
|
|
conn, err := server.schemaConn(context.Background(), "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := conn.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
state.mu.Lock()
|
|
defer state.mu.Unlock()
|
|
if len(state.execStatements) != 0 {
|
|
t.Fatalf("empty schema unexpectedly reset search_path: %v", state.execStatements)
|
|
}
|
|
}
|
|
|
|
func TestSchemaConnResetsOnceAfterExplicitSchema(t *testing.T) {
|
|
db, state := openFakeDB(t, 0)
|
|
server := newServer()
|
|
server.db = db
|
|
|
|
for _, schema := range []string{"app_data", "", ""} {
|
|
conn, err := server.schemaConn(context.Background(), schema)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := conn.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
expected := []string{`SET search_path TO "app_data"`, "RESET search_path"}
|
|
state.mu.Lock()
|
|
defer state.mu.Unlock()
|
|
if strings.Join(state.execStatements, "\n") != strings.Join(expected, "\n") {
|
|
t.Fatalf("unexpected schema transition statements: %v", state.execStatements)
|
|
}
|
|
if len(state.execConnIDs) != len(expected) || state.execConnIDs[0] != state.execConnIDs[1] {
|
|
t.Fatalf("schema transitions used different connections: %v", state.execConnIDs)
|
|
}
|
|
}
|
|
|
|
func TestSchemaConnUsesBackticksInMySQLCompatMode(t *testing.T) {
|
|
db, state := openFakeDB(t, 0)
|
|
server := newServer()
|
|
server.db = db
|
|
server.mode.mysqlCompat = true
|
|
|
|
conn, err := server.schemaConn(context.Background(), "audit-schema")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := conn.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
state.mu.Lock()
|
|
defer state.mu.Unlock()
|
|
if len(state.execStatements) != 1 || state.execStatements[0] != "SET search_path TO `audit-schema`" {
|
|
t.Fatalf("unexpected MySQL compatibility schema setup: %v", state.execStatements)
|
|
}
|
|
}
|
|
|
|
func TestKingbaseIdentifierQuoteEscapesModeSpecificDelimiter(t *testing.T) {
|
|
server := newServer()
|
|
server.mode.mysqlCompat = true
|
|
if got := server.quoteIdentifier("audit`schema"); got != "`audit``schema`" {
|
|
t.Fatalf("unexpected MySQL compatibility identifier: %s", got)
|
|
}
|
|
server.mode.mysqlCompat = false
|
|
if got := server.quoteIdentifier(`audit"schema`); got != `"audit""schema"` {
|
|
t.Fatalf("unexpected PostgreSQL-compatible identifier: %s", got)
|
|
}
|
|
}
|
|
|
|
func TestSchemaConnPropagatesSchemaErrors(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
initialSchema string
|
|
schemaSet bool
|
|
requested string
|
|
statement string
|
|
}{
|
|
{
|
|
name: "set",
|
|
requested: "app_data",
|
|
statement: `SET search_path TO "app_data"`,
|
|
},
|
|
{
|
|
name: "reset",
|
|
initialSchema: "app_data",
|
|
schemaSet: true,
|
|
statement: "RESET search_path",
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
expectedErr := errors.New(test.name + " search_path failed")
|
|
db, state := openFakeDB(t, 0)
|
|
state.execErrors = map[string]error{test.statement: expectedErr}
|
|
server := newServer()
|
|
server.db = db
|
|
server.currentSchema = test.initialSchema
|
|
server.schemaSet = test.schemaSet
|
|
|
|
conn, err := server.schemaConn(context.Background(), test.requested)
|
|
if conn != nil {
|
|
t.Fatal("schemaConn returned a connection after schema setup failed")
|
|
}
|
|
if !errors.Is(err, expectedErr) {
|
|
t.Fatalf("unexpected schema error: %v", err)
|
|
}
|
|
if server.currentSchema != test.initialSchema || server.schemaSet != test.schemaSet {
|
|
t.Fatalf("failed schema setup changed server state: schema=%q set=%v", server.currentSchema, server.schemaSet)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestExecuteQueryReappliesSchemaForRepeatedRequests(t *testing.T) {
|
|
db, state := openFakeDB(t, 1)
|
|
server := newServer()
|
|
server.db = db
|
|
|
|
for range 2 {
|
|
if _, err := server.executeQuery(queryOptions{SQL: "SELECT 1", Schema: "sdy_smartsite", MaxRows: 10}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
expected := []string{`SET search_path TO "sdy_smartsite"`, `SET search_path TO "sdy_smartsite"`}
|
|
if len(state.execStatements) != len(expected) {
|
|
t.Fatalf("expected repeated schema setup, got %v", state.execStatements)
|
|
}
|
|
for index, statement := range expected {
|
|
if state.execStatements[index] != statement {
|
|
t.Fatalf("unexpected schema statement at %d: %s", index, state.execStatements[index])
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestExecuteQueryAppliesSchemaOnSamePoolConnection(t *testing.T) {
|
|
db, state := openFakeDB(t, 1)
|
|
db.SetMaxOpenConns(4)
|
|
server := newServer()
|
|
server.db = db
|
|
|
|
if _, err := server.executeQuery(queryOptions{SQL: "SELECT 1", Schema: "sdy_smartsite", MaxRows: 10}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
state.mu.Lock()
|
|
defer state.mu.Unlock()
|
|
if len(state.execStatements) != 1 || state.execStatements[0] != `SET search_path TO "sdy_smartsite"` {
|
|
t.Fatalf("unexpected schema setup: %v", state.execStatements)
|
|
}
|
|
if len(state.execConnIDs) != 1 || state.execConnIDs[0] != state.queryConnID {
|
|
t.Fatalf("schema setup and query used different connections: exec=%v query=%d", state.execConnIDs, state.queryConnID)
|
|
}
|
|
}
|
|
|
|
func TestExecuteStatementAppliesSchemaOnSamePoolConnection(t *testing.T) {
|
|
db, state := openFakeDB(t, 0)
|
|
db.SetMaxOpenConns(4)
|
|
server := newServer()
|
|
server.db = db
|
|
|
|
if _, err := server.executeQuery(queryOptions{SQL: "UPDATE orders SET status = 1", Schema: "sdy_smartsite"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
state.mu.Lock()
|
|
defer state.mu.Unlock()
|
|
expected := []string{`SET search_path TO "sdy_smartsite"`, "UPDATE orders SET status = 1"}
|
|
if strings.Join(state.execStatements, "\n") != strings.Join(expected, "\n") {
|
|
t.Fatalf("unexpected statements: %v", state.execStatements)
|
|
}
|
|
if len(state.execConnIDs) != 2 || state.execConnIDs[0] != state.execConnIDs[1] {
|
|
t.Fatalf("schema setup and statement used different connections: %v", state.execConnIDs)
|
|
}
|
|
}
|
|
|
|
func TestExecuteRecursiveCteUpdateUsesExecProtocol(t *testing.T) {
|
|
db, state := openFakeDB(t, 0)
|
|
server := newServer()
|
|
server.db = db
|
|
sqlText := `WITH RECURSIVE category_path AS (
|
|
-- 基础:根节点(parent_id = 0),path = 自身 id
|
|
SELECT
|
|
id,
|
|
CAST(id AS VARCHAR(255)) AS path
|
|
FROM system_industry_category
|
|
WHERE deleted = 0
|
|
AND parent_id = 0
|
|
UNION ALL
|
|
-- 递归:子节点 path = 父节点 path + ',' + 当前 id
|
|
SELECT
|
|
sic.id,
|
|
CAST(CONCAT(cp.path, ',', sic.id) AS VARCHAR(255)) AS path
|
|
FROM system_industry_category sic
|
|
INNER JOIN category_path cp ON sic.parent_id = cp.id
|
|
WHERE sic.deleted = 0
|
|
)
|
|
UPDATE manage_merchant m
|
|
SET industry_id = cp.path
|
|
FROM category_path cp
|
|
WHERE m.industry_leaf_id = cp.id
|
|
AND m.deleted = 0
|
|
AND m.industry_id IS NULL
|
|
AND m.industry_leaf_id IS NOT NULL`
|
|
|
|
result, err := server.executeQuery(queryOptions{SQL: sqlText})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if result.AffectedRows != 1 {
|
|
t.Fatalf("unexpected affected rows: %d", result.AffectedRows)
|
|
}
|
|
if result.Columns == nil || result.ColumnTypes == nil || result.Rows == nil {
|
|
t.Fatalf("query result arrays must not be nil: %#v", result)
|
|
}
|
|
|
|
state.mu.Lock()
|
|
defer state.mu.Unlock()
|
|
if len(state.execStatements) == 0 || state.execStatements[len(state.execStatements)-1] != sqlText {
|
|
t.Fatalf("recursive CTE update must use ExecContext: %v", state.execStatements)
|
|
}
|
|
if state.queryCtx != nil {
|
|
t.Fatal("recursive CTE update must not use QueryContext")
|
|
}
|
|
}
|
|
|
|
func TestIsQuerySQLUsesCteTerminalStatement(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
sql string
|
|
query bool
|
|
}{
|
|
{name: "recursive select", sql: `WITH RECURSIVE tree AS (SELECT 1) SELECT * FROM tree`, query: true},
|
|
{name: "recursive update", sql: `WITH RECURSIVE tree AS (SELECT 1) UPDATE target SET value = tree.value FROM tree`, query: false},
|
|
{name: "multiple ctes insert", sql: `WITH source AS (SELECT 1), ready AS (SELECT * FROM source) INSERT INTO target SELECT * FROM ready`, query: false},
|
|
{name: "data modifying cte returns rows", sql: `WITH changed AS (UPDATE target SET value = 1 RETURNING id) SELECT * FROM changed`, query: true},
|
|
{name: "cte update returning rows", sql: `WITH source AS (SELECT 1 AS id) UPDATE target SET value = source.id FROM source RETURNING target.id`, query: true},
|
|
{name: "cte values", sql: `WITH source AS (SELECT 1) VALUES (1)`, query: true},
|
|
{name: "recursive search clause keeps query path", sql: `WITH RECURSIVE tree AS (SELECT 1) SEARCH DEPTH FIRST BY id SET ordercol SELECT * FROM tree`, query: true},
|
|
{name: "plain update returning rows", sql: `UPDATE target SET value = 1 RETURNING id`, query: true},
|
|
{name: "nested returning identifier is ignored", sql: `UPDATE target SET value = (SELECT returning FROM source)`, query: false},
|
|
{name: "returning prefix identifier is ignored", sql: `UPDATE target SET returning2 = 1`, query: false},
|
|
{name: "comments and nested syntax", sql: "/* lead */ WITH source(id) AS NOT MATERIALIZED (SELECT (1 + 2), '-- )'::text)\nDELETE FROM target USING source", query: false},
|
|
{name: "malformed cte keeps legacy query path", sql: `WITH source AS SELECT 1`, query: true},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
if got := isQuerySQL(test.sql); got != test.query {
|
|
t.Fatalf("unexpected query classification: got %v, want %v", got, test.query)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestNonNilStringsNormalizesProtocolArrays(t *testing.T) {
|
|
if values := nonNilStrings(nil); values == nil || len(values) != 0 {
|
|
t.Fatalf("nil protocol array was not normalized: %#v", values)
|
|
}
|
|
}
|
|
|
|
func TestPagedQueryKeepsContextUntilSessionCloses(t *testing.T) {
|
|
db, state := openFakeDB(t, 3)
|
|
server := newServer()
|
|
server.db = db
|
|
result, err := server.executeQueryPage(queryOptions{SQL: "SELECT value", MaxRows: 10}, 1)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !result.HasMore || result.SessionID == nil {
|
|
t.Fatalf("expected an open query session: %#v", result)
|
|
}
|
|
select {
|
|
case <-state.queryCtx.Done():
|
|
t.Fatal("paged query context was canceled before session close")
|
|
default:
|
|
}
|
|
if !server.closeQuerySession(*result.SessionID) {
|
|
t.Fatal("query session was not closed")
|
|
}
|
|
assertContextCanceled(t, state.queryCtx)
|
|
}
|
|
|
|
func TestRuntimeCloseSessionWaitsForActiveRequestAndClosesTarget(t *testing.T) {
|
|
db, _ := openFakeDB(t, 0)
|
|
target := &agentSession{server: newServer()}
|
|
target.server.db = db
|
|
other := &agentSession{server: newServer()}
|
|
runtime := &runtimeServer{sessions: map[string]*agentSession{"target": target, "other": other}}
|
|
|
|
target.mu.Lock()
|
|
closed := make(chan error, 1)
|
|
go func() { closed <- runtime.closeSession("target") }()
|
|
time.Sleep(20 * time.Millisecond)
|
|
select {
|
|
case err := <-closed:
|
|
t.Fatalf("close_session returned before the active request completed: %v", err)
|
|
default:
|
|
}
|
|
if _, err := runtime.session("target"); err == nil {
|
|
t.Fatal("draining session remained available for new requests")
|
|
}
|
|
if _, err := runtime.session("other"); err != nil {
|
|
t.Fatalf("unrelated session was removed: %v", err)
|
|
}
|
|
|
|
target.mu.Unlock()
|
|
select {
|
|
case err := <-closed:
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("close_session did not finish after the active request released the session")
|
|
}
|
|
if target.server.db != nil {
|
|
t.Fatal("target database connection was not closed")
|
|
}
|
|
}
|
|
|
|
func assertContextCanceled(t *testing.T, ctx context.Context) {
|
|
t.Helper()
|
|
select {
|
|
case <-ctx.Done():
|
|
case <-time.After(time.Second):
|
|
t.Fatal("query context was not canceled")
|
|
}
|
|
}
|
|
|
|
func containsString(values []string, target string) bool {
|
|
for _, value := range values {
|
|
if value == target {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Regression test for https://github.com/t8y2/dbx/issues/7681: a timezone-less
|
|
// "timestamp"/"date"/"time" column must not be labeled as an absolute UTC
|
|
// instant (RFC3339Nano with a "Z"/offset suffix), or clients that convert it
|
|
// to a display timezone will double-apply the shift.
|
|
func TestNormalizeValueKingbaseTimezoneLessDateTime(t *testing.T) {
|
|
// gokb decodes "timestamp"/"date" wall-clock values into a time.Time in
|
|
// the process-local zone, which is not a real UTC instant.
|
|
wallClock := time.Date(2026, time.January, 30, 10, 0, 3, 0, time.UTC)
|
|
|
|
for _, columnType := range []string{"TIMESTAMP", "timestamp", "DATE", "TIME"} {
|
|
got := normalizeValue(wallClock, columnType)
|
|
want := "2026-01-30T10:00:03"
|
|
gotStr, ok := got.(string)
|
|
if !ok {
|
|
t.Fatalf("columnType=%s: expected a string, got %#v", columnType, got)
|
|
}
|
|
if gotStr != want {
|
|
t.Fatalf("columnType=%s: got %q, want %q", columnType, gotStr, want)
|
|
}
|
|
if strings.ContainsAny(gotStr, "Z+") {
|
|
t.Fatalf("columnType=%s: timezone-less value must not carry a Z/offset suffix, got %q", columnType, gotStr)
|
|
}
|
|
}
|
|
|
|
// A real timezone-aware column must keep its absolute-instant encoding.
|
|
tzAware := normalizeValue(wallClock, "TIMESTAMPTZ")
|
|
tzAwareStr, ok := tzAware.(string)
|
|
if !ok || tzAwareStr != "2026-01-30T10:00:03Z" {
|
|
t.Fatalf("TIMESTAMPTZ column: got %#v, want RFC3339Nano-encoded UTC instant", tzAware)
|
|
}
|
|
}
|