360 lines
10 KiB
Go
360 lines
10 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"database/sql/driver"
|
|
"errors"
|
|
"io"
|
|
"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]
|
|
|
|
type fakeDriverState struct {
|
|
queryArgs int
|
|
queryCtx context.Context
|
|
rowCount int
|
|
}
|
|
|
|
type fakeDriver struct{}
|
|
|
|
type fakeConn struct{}
|
|
|
|
type fakeRows struct {
|
|
current int
|
|
count int
|
|
}
|
|
|
|
type fallbackDriverState struct {
|
|
mu sync.Mutex
|
|
queries []string
|
|
}
|
|
|
|
type fallbackDriver struct{}
|
|
|
|
type fallbackConn struct {
|
|
state *fallbackDriverState
|
|
}
|
|
|
|
type valueRows struct {
|
|
columns []string
|
|
rows [][]driver.Value
|
|
index int
|
|
}
|
|
|
|
func (fakeDriver) Open(string) (driver.Conn, error) { return fakeConn{}, 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 (fakeConn) QueryContext(ctx context.Context, _ string, args []driver.NamedValue) (driver.Rows, error) {
|
|
state := testDriverState.Load()
|
|
state.queryArgs = len(args)
|
|
state.queryCtx = ctx
|
|
return &fakeRows{count: state.rowCount}, nil
|
|
}
|
|
|
|
func (fakeConn) ExecContext(context.Context, string, []driver.NamedValue) (driver.Result, error) {
|
|
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, "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(") {
|
|
return &valueRows{
|
|
columns: []string{"column_name", "data_type", "is_nullable", "column_default", "column_comment", "numeric_precision", "numeric_scale", "character_maximum_length"},
|
|
rows: [][]driver.Value{{"id", "integer", false, "nextval('orders_id_seq'::regclass)", nil, int64(32), int64(0), nil}},
|
|
}, nil
|
|
}
|
|
return nil, errors.New("unexpected query: " + query)
|
|
}
|
|
|
|
func (rows *valueRows) Columns() []string { return rows.columns }
|
|
|
|
func (*valueRows) Close() error { return nil }
|
|
|
|
func (rows *valueRows) Next(values []driver.Value) error {
|
|
if rows.index >= len(rows.rows) {
|
|
return io.EOF
|
|
}
|
|
copy(values, rows.rows[rows.index])
|
|
rows.index++
|
|
return nil
|
|
}
|
|
|
|
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 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)
|
|
}
|
|
}
|
|
|
|
func TestBuildDSNQuotesCredentialsAndFiltersKeys(t *testing.T) {
|
|
dsn := buildDSN(connectParams{
|
|
Host: "db host",
|
|
Port: 54321,
|
|
Database: "test'db",
|
|
Username: "system",
|
|
Password: `p'ass\\word`,
|
|
URLParams: "application_name=dbx&bad-key=ignored",
|
|
})
|
|
for _, expected := range []string{
|
|
`host='db host'`, `dbname='test\'db'`, `password='p\'ass\\\\word'`, `application_name='dbx'`,
|
|
} {
|
|
if !strings.Contains(dsn, expected) {
|
|
t.Fatalf("DSN missing %q: %s", expected, dsn)
|
|
}
|
|
}
|
|
if strings.Contains(dsn, "bad-key") {
|
|
t.Fatalf("unsafe parameter key was accepted: %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 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 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].ColumnDefault == nil || *columns[0].ColumnDefault != "nextval('orders_id_seq'::regclass)" {
|
|
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 sysCalls != 1 || pgCalls != 2 {
|
|
t.Fatalf("fallback choice was not cached: sys=%d pg=%d queries=%v", sysCalls, pgCalls, state.queries)
|
|
}
|
|
}
|
|
|
|
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 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
|
|
}
|