Skip to content

Commit e92f999

Browse files
authored
Add a SQL Statement Execution fake to the testserver (#5432)
## Summary Adds `libs/testserver/testsql`, a pluggable fake of the SQL Statement Execution API, so tests can drive `/api/2.0/sql/statements` natively instead of hand-building `StatementResponse` stubs. Tests register matchers via `Server.HandleSQL` (exact) / `Server.HandleSQLPattern` (regex), each mapping a statement to a declarative result — columns, rows, an optional error, a poll count, and a chunk count. The testserver models the full lifecycle over the real HTTP endpoints: submit (honoring `wait_timeout`), poll, chunk pagination, and cancel. A matcher runs once per submission, so a matcher that closes over a map can model stateful resources (create then read back). The lifecycle routes are registered in `AddDefaultHandlers` as overridable defaults: a raw `Server.Handle` for the same pattern registered before `AddDefaultHandlers` wins, as do `test.toml` stubs in acceptance — preserving an escape hatch for responses the fake doesn't model (malformed bodies, transport errors, custom status codes). The `libs/sqlexec` HTTP tests are migrated onto the fake, replacing per-test `StatementResponse` construction with one-line matchers. ## Cross-check against the real API Behaviors were verified against a live SQL warehouse, which drove three refinements: - A no-row statement (0-row `SELECT` or no-result-set DDL) reports `total_chunk_count: 0` with an empty result; the fake now matches (previously it always emitted one empty chunk). - `status.sql_state` is populated on failures (e.g. `42P01`); added an assertion to the failed-statement integration test so the typed `StatementError.SQLState` contract is covered. - Documented on `Result.Rows` that SQL `NULL` and empty string are indistinguishable there: the wire format encodes `NULL` as JSON `null`, but the SDK models cells as `[][]string`, so `null` decodes to `""`. Recovering the distinction would need a null-aware cell type or the `ARROW_STREAM` format; no caller needs it today. ## Test plan - `sqlexec` integration suite passes live (`TestSQLExec*`, incl. the new `SQLState` assertion). This pull request and its description were written by Isaac.
1 parent 847a2ef commit e92f999

9 files changed

Lines changed: 697 additions & 68 deletions

File tree

integration/libs/sqlexec/sqlexec_test.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,9 @@ func TestSQLExecFailedStatement(t *testing.T) {
7070
assert.Equal(t, sql.StatementStateFailed, se.State)
7171
assert.NotEmpty(t, se.Code)
7272
assert.NotEmpty(t, se.Message)
73+
// The API populates status.sql_state (SQLSTATE) on failures, e.g. 42P01 for
74+
// a missing relation; assert it survives into the typed error.
75+
assert.NotEmpty(t, se.SQLState)
7376
}
7477

7578
func TestSQLExecSubmitAndCancel(t *testing.T) {

libs/sqlexec/sqlexec.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,13 @@ func (e *StatementError) Error() string {
180180
// Columns and Rows.
181181
type Result struct {
182182
Columns []string
183-
Rows [][]string
183+
// Rows holds every result row with each cell as a string. A SQL NULL and an
184+
// empty string are indistinguishable here: the API wire format encodes NULL
185+
// as JSON null (distinct from ""), but the SDK models cells as [][]string, so
186+
// JSON null is decoded as "". The distinction is recoverable only with a
187+
// null-aware cell type (e.g. [][]*string) or the ARROW_STREAM format; no
188+
// caller needs it today, so we keep the simpler string rows.
189+
Rows [][]string
184190
}
185191

186192
// Scalar returns the top-left cell of the result, or "" when there are no rows.

libs/sqlexec/sqlexec_http_test.go

Lines changed: 30 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66

77
"github.com/databricks/cli/libs/sqlexec"
88
"github.com/databricks/cli/libs/testserver"
9+
"github.com/databricks/cli/libs/testserver/testsql"
910
"github.com/databricks/databricks-sdk-go"
1011
"github.com/databricks/databricks-sdk-go/service/sql"
1112
"github.com/stretchr/testify/assert"
@@ -14,9 +15,19 @@ import (
1415

1516
// These tests drive the engine through a real SDK client over HTTP against the
1617
// in-process testserver, with the statement-execution endpoints programmed per
17-
// test. Unlike the mock-interface unit tests they exercise the full
18-
// request/response JSON serialization, and unlike the integration tests they are
19-
// hermetic and run on every PR without a warehouse.
18+
// test via server.HandleSQL matchers. Unlike the mock-interface unit tests they
19+
// exercise the full request/response JSON serialization, and unlike the
20+
// integration tests they are hermetic and run on every PR without a warehouse.
21+
// newServer returns a testserver with the default handlers installed, which is
22+
// where the SQL Statement Execution routes live (they delegate to the matchers
23+
// registered via server.HandleSQL).
24+
func newServer(t *testing.T) *testserver.Server {
25+
t.Helper()
26+
server := testserver.New(t)
27+
testserver.AddDefaultHandlers(server)
28+
return server
29+
}
30+
2031
func httpClient(t *testing.T, server *testserver.Server) *sqlexec.Client {
2132
t.Helper()
2233
w, err := databricks.NewWorkspaceClient(&databricks.Config{Host: server.URL, Token: "token"})
@@ -26,14 +37,9 @@ func httpClient(t *testing.T, server *testserver.Server) *sqlexec.Client {
2637
}
2738

2839
func TestHTTPExecuteSuccess(t *testing.T) {
29-
server := testserver.New(t)
30-
server.Handle("POST", "/api/2.0/sql/statements", func(testserver.Request) any {
31-
return sql.StatementResponse{
32-
StatementId: "s1",
33-
Status: &sql.StatementStatus{State: sql.StatementStateSucceeded},
34-
Manifest: &sql.ResultManifest{Schema: &sql.ResultSchema{Columns: []sql.ColumnInfo{{Name: "a"}, {Name: "b"}}}, TotalChunkCount: 1},
35-
Result: &sql.ResultData{DataArray: [][]string{{"1", "2"}}},
36-
}
40+
server := newServer(t)
41+
server.HandleSQL("SELECT 1 AS a, 2 AS b", func(testsql.Request) testsql.Result {
42+
return testsql.Result{Columns: []string{"a", "b"}, Rows: [][]string{{"1", "2"}}}
3743
})
3844

3945
r, err := httpClient(t, server).Execute(t.Context(), "SELECT 1 AS a, 2 AS b")
@@ -43,42 +49,20 @@ func TestHTTPExecuteSuccess(t *testing.T) {
4349
}
4450

4551
func TestHTTPExecutePolls(t *testing.T) {
46-
server := testserver.New(t)
47-
server.Handle("POST", "/api/2.0/sql/statements", func(testserver.Request) any {
48-
return sql.StatementResponse{StatementId: "s1", Status: &sql.StatementStatus{State: sql.StatementStatePending}}
49-
})
50-
polls := 0
51-
server.Handle("GET", "/api/2.0/sql/statements/{statement_id}", func(req testserver.Request) any {
52-
assert.Equal(t, "s1", req.Vars["statement_id"])
53-
polls++
54-
if polls < 2 {
55-
return sql.StatementResponse{StatementId: "s1", Status: &sql.StatementStatus{State: sql.StatementStateRunning}}
56-
}
57-
return sql.StatementResponse{
58-
StatementId: "s1",
59-
Status: &sql.StatementStatus{State: sql.StatementStateSucceeded},
60-
Result: &sql.ResultData{DataArray: [][]string{{"done"}}},
61-
}
52+
server := newServer(t)
53+
server.HandleSQL("SELECT 1", func(testsql.Request) testsql.Result {
54+
return testsql.Result{Rows: [][]string{{"done"}}, Polls: 1}
6255
})
6356

6457
got, err := httpClient(t, server).ExecuteScalar(t.Context(), "SELECT 1")
6558
require.NoError(t, err)
6659
assert.Equal(t, "done", got)
67-
assert.GreaterOrEqual(t, polls, 2)
6860
}
6961

7062
func TestHTTPExecutePaginatesChunks(t *testing.T) {
71-
server := testserver.New(t)
72-
server.Handle("POST", "/api/2.0/sql/statements", func(testserver.Request) any {
73-
return sql.StatementResponse{
74-
StatementId: "s1",
75-
Status: &sql.StatementStatus{State: sql.StatementStateSucceeded},
76-
Manifest: &sql.ResultManifest{TotalChunkCount: 3},
77-
Result: &sql.ResultData{DataArray: [][]string{{"0"}}},
78-
}
79-
})
80-
server.Handle("GET", "/api/2.0/sql/statements/{statement_id}/result/chunks/{chunk_index}", func(req testserver.Request) any {
81-
return sql.ResultData{DataArray: [][]string{{req.Vars["chunk_index"]}}}
63+
server := newServer(t)
64+
server.HandleSQL("SELECT * FROM big", func(testsql.Request) testsql.Result {
65+
return testsql.Result{Rows: [][]string{{"0"}, {"1"}, {"2"}}, Chunks: 3}
8266
})
8367

8468
r, err := httpClient(t, server).Execute(t.Context(), "SELECT * FROM big")
@@ -87,18 +71,11 @@ func TestHTTPExecutePaginatesChunks(t *testing.T) {
8771
}
8872

8973
func TestHTTPExecuteFailedReturns200(t *testing.T) {
90-
server := testserver.New(t)
74+
server := newServer(t)
9175
// A failed statement comes back as HTTP 200 with state=FAILED, not an HTTP
9276
// error; the engine must inspect the body and surface a *StatementError.
93-
server.Handle("POST", "/api/2.0/sql/statements", func(testserver.Request) any {
94-
return sql.StatementResponse{
95-
StatementId: "s1",
96-
Status: &sql.StatementStatus{
97-
State: sql.StatementStateFailed,
98-
SqlState: "42P01",
99-
Error: &sql.ServiceError{ErrorCode: sql.ServiceErrorCodeBadRequest, Message: "no such table"},
100-
},
101-
}
77+
server.HandleSQL("SELECT * FROM nope", func(testsql.Request) testsql.Result {
78+
return testsql.Result{Error: &testsql.Error{Code: sql.ServiceErrorCodeBadRequest, Message: "no such table", SQLState: "42P01"}}
10279
})
10380

10481
_, err := httpClient(t, server).Execute(t.Context(), "SELECT * FROM nope")
@@ -111,33 +88,19 @@ func TestHTTPExecuteFailedReturns200(t *testing.T) {
11188
}
11289

11390
func TestHTTPSubmitAndCancel(t *testing.T) {
114-
server := testserver.New(t)
115-
server.Handle("POST", "/api/2.0/sql/statements", func(testserver.Request) any {
116-
return sql.StatementResponse{StatementId: "s1", Status: &sql.StatementStatus{State: sql.StatementStatePending}}
117-
})
118-
canceled := false
119-
server.Handle("POST", "/api/2.0/sql/statements/{statement_id}/cancel", func(req testserver.Request) any {
120-
assert.Equal(t, "s1", req.Vars["statement_id"])
121-
canceled = true
122-
return map[string]string{}
123-
})
124-
server.Handle("GET", "/api/2.0/sql/statements/{statement_id}", func(testserver.Request) any {
125-
state := sql.StatementStatePending
126-
if canceled {
127-
state = sql.StatementStateCanceled
128-
}
129-
return sql.StatementResponse{StatementId: "s1", Status: &sql.StatementStatus{State: state}}
91+
server := newServer(t)
92+
server.HandleSQL("SELECT 1", func(testsql.Request) testsql.Result {
93+
return testsql.Result{}
13094
})
13195

13296
c := httpClient(t, server)
13397
ctx := t.Context()
13498

13599
stmt, err := c.Submit(ctx, "SELECT 1")
136100
require.NoError(t, err)
137-
assert.Equal(t, "s1", stmt.ID)
101+
assert.Equal(t, "statement-1", stmt.ID)
138102

139103
require.NoError(t, c.Cancel(ctx, stmt.ID))
140-
assert.True(t, canceled)
141104

142105
stmt, err = c.Poll(ctx, stmt)
143106
require.NoError(t, err)

libs/testserver/handlers.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -567,6 +567,17 @@ func AddDefaultHandlers(server *Server) {
567567
return MapDelete(req.Workspace, req.Workspace.SqlWarehouses, req.Vars["warehouse_id"])
568568
})
569569

570+
// SQL Statement Execution lifecycle. Tests program these by registering
571+
// matchers via Server.HandleSQL / HandleSQLPattern (see statements.go).
572+
// They live here, not in New, so they act as overridable defaults: a test
573+
// that needs raw control over a SQL endpoint (malformed body, transport
574+
// error, custom status) can register its own handler for the same pattern
575+
// before calling AddDefaultHandlers, or stub it via test.toml in acceptance.
576+
server.Handle("POST", "/api/2.0/sql/statements", server.sqlExecuteStatement)
577+
server.Handle("GET", "/api/2.0/sql/statements/{statement_id}", server.sqlGetStatement)
578+
server.Handle("GET", "/api/2.0/sql/statements/{statement_id}/result/chunks/{chunk_index}", server.sqlGetStatementResultChunk)
579+
server.Handle("POST", "/api/2.0/sql/statements/{statement_id}/cancel", server.sqlCancelStatement)
580+
570581
server.Handle("GET", "/api/2.0/preview/sql/data_sources", func(req Request) any {
571582
return req.Workspace.SqlDataSourcesList(req)
572583
})

libs/testserver/server.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import (
1717
"sync"
1818

1919
"github.com/databricks/cli/internal/testutil"
20+
"github.com/databricks/cli/libs/testserver/testsql"
2021
)
2122

2223
const testPidKey = "test-pid"
@@ -49,6 +50,8 @@ type Server struct {
4950
kills *killRules
5051
faults *FaultRules
5152

53+
sqlHandler *testsql.Handler
54+
5255
RequestCallback func(request *Request)
5356
ResponseCallback func(request *Request, response *EncodedResponse)
5457
}
@@ -228,6 +231,7 @@ func New(t testutil.TestingT) *Server {
228231
fakeOidc: &FakeOidc{url: server.URL},
229232
kills: kills,
230233
faults: faults,
234+
sqlHandler: testsql.New(),
231235
}
232236
router.Dispatch = s.serve
233237

libs/testserver/statements.go

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
package testserver
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"net/http"
7+
"regexp"
8+
"strconv"
9+
10+
"github.com/databricks/cli/libs/testserver/testsql"
11+
"github.com/databricks/databricks-sdk-go/service/sql"
12+
)
13+
14+
// HandleSQL registers a matcher that runs fn when a submitted statement equals
15+
// statement exactly (after trimming).
16+
func (s *Server) HandleSQL(statement string, fn func(testsql.Request) testsql.Result) {
17+
s.sqlHandler.Handle(statement, fn)
18+
}
19+
20+
// HandleSQLPattern registers a matcher that runs fn when re matches a submitted
21+
// statement, passing the submatches through as Request.Match.
22+
func (s *Server) HandleSQLPattern(re *regexp.Regexp, fn func(testsql.Request) testsql.Result) {
23+
s.sqlHandler.HandlePattern(re, fn)
24+
}
25+
26+
// sqlExecuteStatement handles POST /api/2.0/sql/statements. A statement that
27+
// terminates as FAILED comes back as HTTP 200 with state=FAILED; the engine
28+
// builds that response and this HTTP layer is just transport.
29+
func (s *Server) sqlExecuteStatement(req Request) any {
30+
var r sql.ExecuteStatementRequest
31+
if err := json.Unmarshal(req.Body, &r); err != nil {
32+
return Response{StatusCode: http.StatusBadRequest, Body: fmt.Sprintf("invalid execute statement request: %s", err)}
33+
}
34+
return s.sqlHandler.Submit(r.Statement, r.WaitTimeout, r.Parameters)
35+
}
36+
37+
// sqlGetStatement handles GET /api/2.0/sql/statements/{statement_id}.
38+
func (s *Server) sqlGetStatement(req Request) any {
39+
resp := s.sqlHandler.Get(req.Vars["statement_id"])
40+
if resp == nil {
41+
return Response{StatusCode: http.StatusNotFound}
42+
}
43+
return resp
44+
}
45+
46+
// sqlGetStatementResultChunk handles GET
47+
// /api/2.0/sql/statements/{statement_id}/result/chunks/{chunk_index}.
48+
func (s *Server) sqlGetStatementResultChunk(req Request) any {
49+
idx, err := strconv.Atoi(req.Vars["chunk_index"])
50+
if err != nil {
51+
return Response{StatusCode: http.StatusBadRequest, Body: fmt.Sprintf("invalid chunk index: %s", err)}
52+
}
53+
data := s.sqlHandler.Chunk(req.Vars["statement_id"], idx)
54+
if data == nil {
55+
return Response{StatusCode: http.StatusNotFound}
56+
}
57+
return data
58+
}
59+
60+
// sqlCancelStatement handles POST /api/2.0/sql/statements/{statement_id}/cancel.
61+
func (s *Server) sqlCancelStatement(req Request) any {
62+
s.sqlHandler.Cancel(req.Vars["statement_id"])
63+
return map[string]any{}
64+
}

libs/testserver/statements_test.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
package testserver
2+
3+
import (
4+
"encoding/json"
5+
"regexp"
6+
"testing"
7+
8+
"github.com/databricks/cli/libs/testserver/testsql"
9+
"github.com/databricks/databricks-sdk-go/service/sql"
10+
"github.com/stretchr/testify/assert"
11+
"github.com/stretchr/testify/require"
12+
)
13+
14+
// submitSQL runs a statement through the POST handler and returns the response.
15+
func submitSQL(t *testing.T, server *Server, statement string) *sql.StatementResponse {
16+
t.Helper()
17+
body, err := json.Marshal(sql.ExecuteStatementRequest{Statement: statement, WaitTimeout: "10s"})
18+
require.NoError(t, err)
19+
resp, ok := server.sqlExecuteStatement(Request{Body: body}).(*sql.StatementResponse)
20+
require.True(t, ok)
21+
return resp
22+
}
23+
24+
func TestHandleSQL(t *testing.T) {
25+
server := New(t)
26+
server.HandleSQL("SELECT 1 AS a", func(r testsql.Request) testsql.Result {
27+
assert.Equal(t, []string{"SELECT 1 AS a"}, r.Match)
28+
return testsql.Result{Columns: []string{"a"}, Rows: [][]string{{"1"}}}
29+
})
30+
31+
resp := submitSQL(t, server, "SELECT 1 AS a")
32+
require.NotNil(t, resp.Status)
33+
assert.Equal(t, sql.StatementStateSucceeded, resp.Status.State)
34+
require.NotNil(t, resp.Result)
35+
assert.Equal(t, [][]string{{"1"}}, resp.Result.DataArray)
36+
}
37+
38+
func TestHandleSQLPattern(t *testing.T) {
39+
server := New(t)
40+
// A regex matcher echoes back a captured submatch, exercising both the
41+
// HandleSQLPattern registration and Request.Match.
42+
server.HandleSQLPattern(regexp.MustCompile(`^SELECT (\d+)$`), func(r testsql.Request) testsql.Result {
43+
return testsql.Result{Columns: []string{"n"}, Rows: [][]string{{r.Match[1]}}}
44+
})
45+
46+
resp := submitSQL(t, server, "SELECT 42")
47+
require.NotNil(t, resp.Status)
48+
assert.Equal(t, sql.StatementStateSucceeded, resp.Status.State)
49+
require.NotNil(t, resp.Result)
50+
assert.Equal(t, [][]string{{"42"}}, resp.Result.DataArray)
51+
}

0 commit comments

Comments
 (0)