Skip to content

Commit 1cac9b5

Browse files
Genesis929Yuan325
andauthored
feat(bigquery-execute-sql): add dry run support (googleapis#1057)
Add optional `dry_run` parameter to bigquery-execute-sql, which defaults to `false`. When the `dry_run` parameter is set to `true`, the tool returns the metadata from the dry run instead of executing the query. Fixes googleapis#703 --------- Co-authored-by: Yuan Teoh <45984206+Yuan325@users.noreply.github.com>
1 parent d91bdfc commit 1cac9b5

File tree

3 files changed

+140
-6
lines changed

3 files changed

+140
-6
lines changed

docs/en/resources/tools/bigquery/bigquery-execute-sql.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,9 @@ It's compatible with the following sources:
1515

1616
- [bigquery](../../sources/bigquery.md)
1717

18-
`bigquery-execute-sql` takes one input parameter `sql` and runs the sql
19-
statement against the `source`.
18+
`bigquery-execute-sql` takes a required `sql` input parameter and runs the SQL
19+
statement against the configured `source`. It also supports an optional `dry_run`
20+
parameter to validate a query without executing it.
2021

2122
## Example
2223

internal/tools/bigquery/bigqueryexecutesql/bigqueryexecutesql.go

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ package bigqueryexecutesql
1616

1717
import (
1818
"context"
19+
"encoding/json"
1920
"fmt"
2021

2122
bigqueryapi "cloud.google.com/go/bigquery"
@@ -83,7 +84,13 @@ func (cfg Config) Initialize(srcs map[string]sources.Source) (tools.Tool, error)
8384
}
8485

8586
sqlParameter := tools.NewStringParameter("sql", "The sql to execute.")
86-
parameters := tools.Parameters{sqlParameter}
87+
dryRunParameter := tools.NewBooleanParameterWithDefault(
88+
"dry_run",
89+
false,
90+
"If set to true, the query will be validated and information about the execution "+
91+
"will be returned without running the query. Defaults to false.",
92+
)
93+
parameters := tools.Parameters{sqlParameter, dryRunParameter}
8794

8895
mcpManifest := tools.McpManifest{
8996
Name: cfg.Name,
@@ -120,17 +127,33 @@ type Tool struct {
120127
}
121128

122129
func (t Tool) Invoke(ctx context.Context, params tools.ParamValues) (any, error) {
123-
sliceParams := params.AsSlice()
124-
sql, ok := sliceParams[0].(string)
130+
paramsMap := params.AsMap()
131+
sql, ok := paramsMap["sql"].(string)
125132
if !ok {
126-
return nil, fmt.Errorf("unable to get cast %s", sliceParams[0])
133+
return nil, fmt.Errorf("unable to cast sql parameter %s", paramsMap["sql"])
134+
}
135+
dryRun, ok := paramsMap["dry_run"].(bool)
136+
if !ok {
137+
return nil, fmt.Errorf("unable to cast dry_run parameter %s", paramsMap["dry_run"])
127138
}
128139

129140
dryRunJob, err := dryRunQuery(ctx, t.RestService, t.Client.Project(), t.Client.Location, sql)
130141
if err != nil {
131142
return nil, fmt.Errorf("query validation failed during dry run: %w", err)
132143
}
133144

145+
if dryRun {
146+
if dryRunJob != nil {
147+
jobJSON, err := json.MarshalIndent(dryRunJob, "", " ")
148+
if err != nil {
149+
return nil, fmt.Errorf("failed to marshal dry run job to JSON: %w", err)
150+
}
151+
return string(jobJSON), nil
152+
}
153+
// This case should not be reached, but as a fallback, we return a message.
154+
return "Dry run was requested, but no job information was returned.", nil
155+
}
156+
134157
statementType := dryRunJob.Statistics.Query.StatementType
135158
// JobStatistics.QueryStatistics.StatementType
136159
query := t.Client.Query(sql)

tests/bigquery/bigquery_integration_test.go

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,7 @@ func TestBigQueryToolEndpoints(t *testing.T) {
162162
tests.RunToolInvokeWithTemplateParameters(t, tableNameTemplateParam, templateParamTestConfig)
163163

164164
runBigQueryExecuteSqlToolInvokeTest(t, select1Want, invokeParamWant, tableNameParam, ddlWant)
165+
runBigQueryExecuteSqlToolInvokeDryRunTest(t, datasetName)
165166
runBigQueryDataTypeTests(t)
166167
runBigQueryListDatasetToolInvokeTest(t, datasetName)
167168
runBigQueryGetDatasetInfoToolInvokeTest(t, datasetName, datasetInfoWant)
@@ -556,6 +557,115 @@ func runBigQueryExecuteSqlToolInvokeTest(t *testing.T, select1Want, invokeParamW
556557
}
557558
}
558559

560+
func runBigQueryExecuteSqlToolInvokeDryRunTest(t *testing.T, datasetName string) {
561+
// Get ID token
562+
idToken, err := tests.GetGoogleIdToken(tests.ClientId)
563+
if err != nil {
564+
t.Fatalf("error getting Google ID token: %s", err)
565+
}
566+
567+
newTableName := fmt.Sprintf("%s.new_dry_run_table_%s", datasetName, strings.ReplaceAll(uuid.New().String(), "-", ""))
568+
569+
// Test tool invoke endpoint
570+
invokeTcs := []struct {
571+
name string
572+
api string
573+
requestHeader map[string]string
574+
requestBody io.Reader
575+
want string
576+
isErr bool
577+
}{
578+
{
579+
name: "invoke my-exec-sql-tool with dryRun",
580+
api: "http://127.0.0.1:5000/api/tool/my-exec-sql-tool/invoke",
581+
requestHeader: map[string]string{},
582+
requestBody: bytes.NewBuffer([]byte(`{"sql":"SELECT 1", "dry_run": true}`)),
583+
want: `\"statementType\": \"SELECT\"`,
584+
isErr: false,
585+
},
586+
{
587+
name: "invoke my-exec-sql-tool with dryRun create table",
588+
api: "http://127.0.0.1:5000/api/tool/my-exec-sql-tool/invoke",
589+
requestHeader: map[string]string{},
590+
requestBody: bytes.NewBuffer([]byte(fmt.Sprintf(`{"sql":"CREATE TABLE %s (id INT64, name STRING)", "dry_run": true}`, newTableName))),
591+
want: `\"statementType\": \"CREATE_TABLE\"`,
592+
isErr: false,
593+
},
594+
{
595+
name: "invoke my-exec-sql-tool with dryRun execute immediate",
596+
api: "http://127.0.0.1:5000/api/tool/my-exec-sql-tool/invoke",
597+
requestHeader: map[string]string{},
598+
requestBody: bytes.NewBuffer([]byte(fmt.Sprintf(`{"sql":"EXECUTE IMMEDIATE \"CREATE TABLE %s (id INT64, name STRING)\"", "dry_run": true}`, newTableName))),
599+
want: `\"statementType\": \"SCRIPT\"`,
600+
isErr: false,
601+
},
602+
{
603+
name: "Invoke my-auth-exec-sql-tool with dryRun and auth token",
604+
api: "http://127.0.0.1:5000/api/tool/my-auth-exec-sql-tool/invoke",
605+
requestHeader: map[string]string{"my-google-auth_token": idToken},
606+
requestBody: bytes.NewBuffer([]byte(`{"sql":"SELECT 1", "dry_run": true}`)),
607+
isErr: false,
608+
want: `\"statementType\": \"SELECT\"`,
609+
},
610+
{
611+
name: "Invoke my-auth-exec-sql-tool with dryRun and invalid auth token",
612+
api: "http://127.0.0.1:5000/api/tool/my-auth-exec-sql-tool/invoke",
613+
requestHeader: map[string]string{"my-google-auth_token": "INVALID_TOKEN"},
614+
requestBody: bytes.NewBuffer([]byte(`{"sql":"SELECT 1","dry_run": true}`)),
615+
isErr: true,
616+
},
617+
{
618+
name: "Invoke my-auth-exec-sql-tool with dryRun and without auth token",
619+
api: "http://127.0.0.1:5000/api/tool/my-auth-exec-sql-tool/invoke",
620+
requestHeader: map[string]string{},
621+
requestBody: bytes.NewBuffer([]byte(`{"sql":"SELECT 1", "dry_run": true}`)),
622+
isErr: true,
623+
},
624+
}
625+
for _, tc := range invokeTcs {
626+
t.Run(tc.name, func(t *testing.T) {
627+
// Send Tool invocation request
628+
req, err := http.NewRequest(http.MethodPost, tc.api, tc.requestBody)
629+
if err != nil {
630+
t.Fatalf("unable to create request: %s", err)
631+
}
632+
req.Header.Add("Content-type", "application/json")
633+
for k, v := range tc.requestHeader {
634+
req.Header.Add(k, v)
635+
}
636+
resp, err := http.DefaultClient.Do(req)
637+
if err != nil {
638+
t.Fatalf("unable to send request: %s", err)
639+
}
640+
defer resp.Body.Close()
641+
642+
if resp.StatusCode != http.StatusOK {
643+
if tc.isErr {
644+
return
645+
}
646+
bodyBytes, _ := io.ReadAll(resp.Body)
647+
t.Fatalf("response status code is not 200, got %d: %s", resp.StatusCode, string(bodyBytes))
648+
}
649+
650+
// Check response body
651+
var body map[string]interface{}
652+
err = json.NewDecoder(resp.Body).Decode(&body)
653+
if err != nil {
654+
t.Fatalf("error parsing response body")
655+
}
656+
657+
got, ok := body["result"].(string)
658+
if !ok {
659+
t.Fatalf("unable to find result in response body")
660+
}
661+
662+
if !strings.Contains(got, tc.want) {
663+
t.Fatalf("expected %q to contain %q, but it did not", got, tc.want)
664+
}
665+
})
666+
}
667+
}
668+
559669
func runBigQueryDataTypeTests(t *testing.T) {
560670
// Test tool invoke endpoint
561671
invokeTcs := []struct {

0 commit comments

Comments
 (0)