diff --git a/cmd/root.go b/cmd/root.go index 041ce535b12..68ac441f67f 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -121,6 +121,7 @@ import ( _ "github.com/googleapis/genai-toolbox/internal/tools/postgres/postgressql" _ "github.com/googleapis/genai-toolbox/internal/tools/redis" _ "github.com/googleapis/genai-toolbox/internal/tools/spanner/spannerexecutesql" + _ "github.com/googleapis/genai-toolbox/internal/tools/spanner/spannerlisttables" _ "github.com/googleapis/genai-toolbox/internal/tools/spanner/spannersql" _ "github.com/googleapis/genai-toolbox/internal/tools/sqlite/sqliteexecutesql" _ "github.com/googleapis/genai-toolbox/internal/tools/sqlite/sqlitesql" diff --git a/docs/en/resources/tools/spanner/spanner-list-tables.md b/docs/en/resources/tools/spanner/spanner-list-tables.md new file mode 100644 index 00000000000..ffe3f101647 --- /dev/null +++ b/docs/en/resources/tools/spanner/spanner-list-tables.md @@ -0,0 +1,208 @@ +--- +title: "spanner-list-tables" +type: docs +weight: 3 +description: > + A "spanner-list-tables" tool retrieves schema information about tables in a + Google Cloud Spanner database. +--- + +## About + +A `spanner-list-tables` tool retrieves comprehensive schema information about +tables in a Cloud Spanner database. It automatically adapts to the database +dialect (GoogleSQL or PostgreSQL) and returns detailed metadata including +columns, constraints, and indexes. It's compatible with: + +- [spanner](../../sources/spanner.md) + +This tool is read-only and executes pre-defined SQL queries against the +`INFORMATION_SCHEMA` tables to gather metadata. The tool automatically detects +the database dialect from the source configuration and uses the appropriate SQL +syntax. + +## Features + +- **Automatic Dialect Detection**: Adapts queries based on whether the database + uses GoogleSQL or PostgreSQL dialect +- **Comprehensive Schema Information**: Returns columns, data types, constraints, + indexes, and table relationships +- **Flexible Filtering**: Can list all tables or filter by specific table names +- **Output Format Options**: Choose between simple (table names only) or detailed + (full schema information) output + +## Example + +### Basic Usage - List All Tables + +```yaml +sources: + my-spanner-db: + kind: spanner + project: ${SPANNER_PROJECT} + instance: ${SPANNER_INSTANCE} + database: ${SPANNER_DATABASE} + dialect: googlesql # or postgresql + +tools: + list_all_tables: + kind: spanner-list-tables + source: my-spanner-db + description: Lists all tables with their complete schema information +``` + +### List Specific Tables + +```yaml +tools: + list_specific_tables: + kind: spanner-list-tables + source: my-spanner-db + description: | + Lists schema information for specific tables. + Example usage: + { + "table_names": "users,orders,products", + "output_format": "detailed" + } +``` + +## Parameters + +The tool accepts two optional parameters: + +| **parameter** | **type** | **default** | **description** | +|-----------------|:--------:|:-----------:|------------------------------------------------------------------------------------------------------| +| table_names | string | "" | Comma-separated list of table names to filter. If empty, lists all tables in user-accessible schemas | +| output_format | string | "detailed" | Output format: "simple" returns only table names, "detailed" returns full schema information | + +## Output Format + +### Simple Format + +When `output_format` is set to "simple", the tool returns a minimal JSON structure: + +```json +[ + { + "schema_name": "public", + "object_name": "users", + "object_details": "{\"name\":\"users\"}" + }, + { + "schema_name": "public", + "object_name": "orders", + "object_details": "{\"name\":\"orders\"}" + } +] +``` + +### Detailed Format + +When `output_format` is set to "detailed" (default), the tool returns comprehensive schema information: + +```json +[ + { + "schema_name": "public", + "object_name": "users", + "object_details": "{ + \"schema_name\": \"public\", + \"object_name\": \"users\", + \"object_type\": \"BASE TABLE\", + \"columns\": [ + { + \"column_name\": \"id\", + \"data_type\": \"INT64\", + \"ordinal_position\": 1, + \"is_not_nullable\": true, + \"column_default\": null + }, + { + \"column_name\": \"email\", + \"data_type\": \"STRING(255)\", + \"ordinal_position\": 2, + \"is_not_nullable\": true, + \"column_default\": null + } + ], + \"constraints\": [ + { + \"constraint_name\": \"PK_users\", + \"constraint_type\": \"PRIMARY KEY\", + \"constraint_definition\": \"PRIMARY KEY (id)\", + \"constraint_columns\": [\"id\"], + \"foreign_key_referenced_table\": null, + \"foreign_key_referenced_columns\": [] + } + ], + \"indexes\": [ + { + \"index_name\": \"idx_users_email\", + \"index_type\": \"INDEX\", + \"is_unique\": true, + \"is_null_filtered\": false, + \"interleaved_in_table\": null, + \"index_key_columns\": [ + {\"column_name\": \"email\", \"ordering\": \"ASC\"} + ], + \"storing_columns\": [] + } + ] + }" + } +] +``` + +## Use Cases + +1. **Database Documentation**: Generate comprehensive documentation of your + database schema +2. **Schema Validation**: Verify that expected tables and columns exist +3. **Migration Planning**: Understand the current schema before making changes +4. **Development Tools**: Build tools that need to understand database structure +5. **Audit and Compliance**: Track schema changes and ensure compliance with + data governance policies + +## Example with Agent Integration + +```yaml +sources: + spanner-db: + kind: spanner + project: my-project + instance: my-instance + database: my-database + dialect: googlesql + +tools: + schema_inspector: + kind: spanner-list-tables + source: spanner-db + description: | + Use this tool to inspect database schema information. + You can: + - List all tables by leaving table_names empty + - Get specific table schemas by providing comma-separated table names + - Choose between simple (names only) or detailed (full schema) output + + Examples: + 1. List all tables with details: {"output_format": "detailed"} + 2. Get specific tables: {"table_names": "users,orders", "output_format": "detailed"} + 3. Just get table names: {"output_format": "simple"} +``` + +## Reference + +| **field** | **type** | **required** | **description** | +|---------------|:--------:|:------------:|--------------------------------------------------------------------| +| kind | string | true | Must be "spanner-list-tables" | +| source | string | true | Name of the Spanner source to query | +| description | string | false | Description of the tool that is passed to the LLM | +| authRequired | string[] | false | List of auth services required to invoke this tool | + +## Notes + +- This tool is read-only and does not modify any data +- The tool automatically handles both GoogleSQL and PostgreSQL dialects +- Large databases with many tables may take longer to query diff --git a/internal/prebuiltconfigs/tools/spanner.yaml b/internal/prebuiltconfigs/tools/spanner.yaml index 67172ed69a0..db15412fd89 100644 --- a/internal/prebuiltconfigs/tools/spanner.yaml +++ b/internal/prebuiltconfigs/tools/spanner.yaml @@ -1,218 +1,41 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + sources: spanner-source: kind: spanner project: ${SPANNER_PROJECT} instance: ${SPANNER_INSTANCE} database: ${SPANNER_DATABASE} + dialect: ${SPANNER_DIALECT:googlesql} tools: execute_sql: kind: spanner-execute-sql source: spanner-source - description: Use this tool to execute DML SQL + description: Use this tool to execute DML SQL. Please use the ${SPANNER_DIALECT:googlesql} interface for Spanner. execute_sql_dql: kind: spanner-execute-sql source: spanner-source - description: Use this tool to execute DQL SQL + description: Use this tool to execute DQL SQL. Please use the ${SPANNER_DIALECT:googlesql} interface for Spanner. readOnly: true list_tables: - kind: spanner-sql + kind: spanner-list-tables source: spanner-source - readOnly: true description: "Lists detailed schema information (object type, columns, constraints, indexes) as JSON for user-created tables (ordinary or partitioned). Filters by a comma-separated list of names. If names are omitted, lists all tables in user schemas." - statement: | - WITH FilterTableNames AS ( - SELECT DISTINCT TRIM(name) AS TABLE_NAME - FROM UNNEST(IF(@table_names = '' OR @table_names IS NULL, ['%'], SPLIT(@table_names, ','))) AS name - ), - - -- 1. Table Information - table_info_cte AS ( - SELECT - T.TABLE_SCHEMA, - T.TABLE_NAME, - T.TABLE_TYPE, - T.PARENT_TABLE_NAME, -- For interleaved tables - T.ON_DELETE_ACTION -- For interleaved tables - FROM INFORMATION_SCHEMA.TABLES AS T - WHERE - T.TABLE_SCHEMA = '' - AND T.TABLE_TYPE = 'BASE TABLE' - AND (EXISTS (SELECT 1 FROM FilterTableNames WHERE FilterTableNames.TABLE_NAME = '%') OR T.TABLE_NAME IN (SELECT TABLE_NAME FROM FilterTableNames)) - ), - - -- 2. Column Information (with JSON string for each column) - columns_info_cte AS ( - SELECT - C.TABLE_SCHEMA, - C.TABLE_NAME, - ARRAY_AGG( - CONCAT( - '{', - '"column_name":"', IFNULL(C.COLUMN_NAME, ''), '",', - '"data_type":"', IFNULL(C.SPANNER_TYPE, ''), '",', - '"ordinal_position":', CAST(C.ORDINAL_POSITION AS STRING), ',', - '"is_not_nullable":', IF(C.IS_NULLABLE = 'NO', 'true', 'false'), ',', - '"column_default":', IF(C.COLUMN_DEFAULT IS NULL, 'null', CONCAT('"', C.COLUMN_DEFAULT, '"')), - '}' - ) ORDER BY C.ORDINAL_POSITION - ) AS columns_json_array_elements - FROM INFORMATION_SCHEMA.COLUMNS AS C - WHERE EXISTS (SELECT 1 FROM table_info_cte TI WHERE C.TABLE_SCHEMA = TI.TABLE_SCHEMA AND C.TABLE_NAME = TI.TABLE_NAME) - GROUP BY C.TABLE_SCHEMA, C.TABLE_NAME - ), - - -- Helper CTE for aggregating constraint columns - constraint_columns_agg_cte AS ( - SELECT - CONSTRAINT_CATALOG, - CONSTRAINT_SCHEMA, - CONSTRAINT_NAME, - ARRAY_AGG(CONCAT('"', COLUMN_NAME, '"') ORDER BY ORDINAL_POSITION) AS column_names_json_list - FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE - GROUP BY CONSTRAINT_CATALOG, CONSTRAINT_SCHEMA, CONSTRAINT_NAME - ), - - -- 3. Constraint Information (with JSON string for each constraint) - constraints_info_cte AS ( - SELECT - TC.TABLE_SCHEMA, - TC.TABLE_NAME, - ARRAY_AGG( - CONCAT( - '{', - '"constraint_name":"', IFNULL(TC.CONSTRAINT_NAME, ''), '",', - '"constraint_type":"', IFNULL(TC.CONSTRAINT_TYPE, ''), '",', - '"constraint_definition":', - CASE TC.CONSTRAINT_TYPE - WHEN 'CHECK' THEN IF(CC.CHECK_CLAUSE IS NULL, 'null', CONCAT('"', CC.CHECK_CLAUSE, '"')) - WHEN 'PRIMARY KEY' THEN CONCAT('"', 'PRIMARY KEY (', ARRAY_TO_STRING(COALESCE(KeyCols.column_names_json_list, []), ', '), ')', '"') - WHEN 'UNIQUE' THEN CONCAT('"', 'UNIQUE (', ARRAY_TO_STRING(COALESCE(KeyCols.column_names_json_list, []), ', '), ')', '"') - WHEN 'FOREIGN KEY' THEN CONCAT('"', 'FOREIGN KEY (', ARRAY_TO_STRING(COALESCE(KeyCols.column_names_json_list, []), ', '), ') REFERENCES ', - IFNULL(RefKeyTable.TABLE_NAME, ''), - ' (', ARRAY_TO_STRING(COALESCE(RefKeyCols.column_names_json_list, []), ', '), ')', '"') - ELSE 'null' - END, ',', - '"constraint_columns":[', ARRAY_TO_STRING(COALESCE(KeyCols.column_names_json_list, []), ','), '],', - '"foreign_key_referenced_table":', IF(RefKeyTable.TABLE_NAME IS NULL, 'null', CONCAT('"', RefKeyTable.TABLE_NAME, '"')), ',', - '"foreign_key_referenced_columns":[', ARRAY_TO_STRING(COALESCE(RefKeyCols.column_names_json_list, []), ','), ']', - '}' - ) ORDER BY TC.CONSTRAINT_NAME - ) AS constraints_json_array_elements - FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS AS TC - LEFT JOIN INFORMATION_SCHEMA.CHECK_CONSTRAINTS AS CC - ON TC.CONSTRAINT_CATALOG = CC.CONSTRAINT_CATALOG AND TC.CONSTRAINT_SCHEMA = CC.CONSTRAINT_SCHEMA AND TC.CONSTRAINT_NAME = CC.CONSTRAINT_NAME - LEFT JOIN INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS AS RC - ON TC.CONSTRAINT_CATALOG = RC.CONSTRAINT_CATALOG AND TC.CONSTRAINT_SCHEMA = RC.CONSTRAINT_SCHEMA AND TC.CONSTRAINT_NAME = RC.CONSTRAINT_NAME - LEFT JOIN INFORMATION_SCHEMA.TABLE_CONSTRAINTS AS RefConstraint - ON RC.UNIQUE_CONSTRAINT_CATALOG = RefConstraint.CONSTRAINT_CATALOG AND RC.UNIQUE_CONSTRAINT_SCHEMA = RefConstraint.CONSTRAINT_SCHEMA AND RC.UNIQUE_CONSTRAINT_NAME = RefConstraint.CONSTRAINT_NAME - LEFT JOIN INFORMATION_SCHEMA.TABLES AS RefKeyTable - ON RefConstraint.TABLE_CATALOG = RefKeyTable.TABLE_CATALOG AND RefConstraint.TABLE_SCHEMA = RefKeyTable.TABLE_SCHEMA AND RefConstraint.TABLE_NAME = RefKeyTable.TABLE_NAME - LEFT JOIN constraint_columns_agg_cte AS KeyCols - ON TC.CONSTRAINT_CATALOG = KeyCols.CONSTRAINT_CATALOG AND TC.CONSTRAINT_SCHEMA = KeyCols.CONSTRAINT_SCHEMA AND TC.CONSTRAINT_NAME = KeyCols.CONSTRAINT_NAME - LEFT JOIN constraint_columns_agg_cte AS RefKeyCols - ON RC.UNIQUE_CONSTRAINT_CATALOG = RefKeyCols.CONSTRAINT_CATALOG AND RC.UNIQUE_CONSTRAINT_SCHEMA = RefKeyCols.CONSTRAINT_SCHEMA AND RC.UNIQUE_CONSTRAINT_NAME = RefKeyCols.CONSTRAINT_NAME AND TC.CONSTRAINT_TYPE = 'FOREIGN KEY' - WHERE EXISTS (SELECT 1 FROM table_info_cte TI WHERE TC.TABLE_SCHEMA = TI.TABLE_SCHEMA AND TC.TABLE_NAME = TI.TABLE_NAME) - GROUP BY TC.TABLE_SCHEMA, TC.TABLE_NAME - ), - - -- Helper CTE for aggregating index key columns (as JSON strings) - index_key_columns_agg_cte AS ( - SELECT - TABLE_CATALOG, - TABLE_SCHEMA, - TABLE_NAME, - INDEX_NAME, - ARRAY_AGG( - CONCAT( - '{"column_name":"', IFNULL(COLUMN_NAME, ''), '",', - '"ordering":"', IFNULL(COLUMN_ORDERING, ''), '"}' - ) ORDER BY ORDINAL_POSITION - ) AS key_column_json_details - FROM INFORMATION_SCHEMA.INDEX_COLUMNS - WHERE ORDINAL_POSITION IS NOT NULL -- Key columns - GROUP BY TABLE_CATALOG, TABLE_SCHEMA, TABLE_NAME, INDEX_NAME - ), - - -- Helper CTE for aggregating index storing columns (as JSON strings) - index_storing_columns_agg_cte AS ( - SELECT - TABLE_CATALOG, - TABLE_SCHEMA, - TABLE_NAME, - INDEX_NAME, - ARRAY_AGG(CONCAT('"', COLUMN_NAME, '"') ORDER BY COLUMN_NAME) AS storing_column_json_names - FROM INFORMATION_SCHEMA.INDEX_COLUMNS - WHERE ORDINAL_POSITION IS NULL -- Storing columns - GROUP BY TABLE_CATALOG, TABLE_SCHEMA, TABLE_NAME, INDEX_NAME - ), - - -- 4. Index Information (with JSON string for each index) - indexes_info_cte AS ( - SELECT - I.TABLE_SCHEMA, - I.TABLE_NAME, - ARRAY_AGG( - CONCAT( - '{', - '"index_name":"', IFNULL(I.INDEX_NAME, ''), '",', - '"index_type":"', IFNULL(I.INDEX_TYPE, ''), '",', - '"is_unique":', IF(I.IS_UNIQUE, 'true', 'false'), ',', - '"is_null_filtered":', IF(I.IS_NULL_FILTERED, 'true', 'false'), ',', - '"interleaved_in_table":', IF(I.PARENT_TABLE_NAME IS NULL, 'null', CONCAT('"', I.PARENT_TABLE_NAME, '"')), ',', - '"index_key_columns":[', ARRAY_TO_STRING(COALESCE(KeyIndexCols.key_column_json_details, []), ','), '],', - '"storing_columns":[', ARRAY_TO_STRING(COALESCE(StoringIndexCols.storing_column_json_names, []), ','), ']', - '}' - ) ORDER BY I.INDEX_NAME - ) AS indexes_json_array_elements - FROM INFORMATION_SCHEMA.INDEXES AS I - LEFT JOIN index_key_columns_agg_cte AS KeyIndexCols - ON I.TABLE_CATALOG = KeyIndexCols.TABLE_CATALOG AND I.TABLE_SCHEMA = KeyIndexCols.TABLE_SCHEMA AND I.TABLE_NAME = KeyIndexCols.TABLE_NAME AND I.INDEX_NAME = KeyIndexCols.INDEX_NAME - LEFT JOIN index_storing_columns_agg_cte AS StoringIndexCols - ON I.TABLE_CATALOG = StoringIndexCols.TABLE_CATALOG AND I.TABLE_SCHEMA = StoringIndexCols.TABLE_SCHEMA AND I.TABLE_NAME = StoringIndexCols.TABLE_NAME AND I.INDEX_NAME = StoringIndexCols.INDEX_NAME AND I.INDEX_TYPE = 'INDEX' - WHERE EXISTS (SELECT 1 FROM table_info_cte TI WHERE I.TABLE_SCHEMA = TI.TABLE_SCHEMA AND I.TABLE_NAME = TI.TABLE_NAME) - GROUP BY I.TABLE_SCHEMA, I.TABLE_NAME - ) - - -- Final SELECT to build the JSON output - SELECT - TI.TABLE_SCHEMA AS schema_name, - TI.TABLE_NAME AS object_name, - CASE - WHEN @output_format = 'simple' THEN - -- IF format is 'simple', return basic JSON - CONCAT('{"name":"', IFNULL(REPLACE(TI.TABLE_NAME, '"', '\"'), ''), '"}') - ELSE - CONCAT( - '{', - '"schema_name":"', IFNULL(TI.TABLE_SCHEMA, ''), '",', - '"object_name":"', IFNULL(TI.TABLE_NAME, ''), '",', - '"object_type":"', IFNULL(TI.TABLE_TYPE, ''), '",', - '"columns":[', ARRAY_TO_STRING(COALESCE(CI.columns_json_array_elements, []), ','), '],', - '"constraints":[', ARRAY_TO_STRING(COALESCE(CONSI.constraints_json_array_elements, []), ','), '],', - '"indexes":[', ARRAY_TO_STRING(COALESCE(II.indexes_json_array_elements, []), ','), '],', - '}' - ) - END AS object_details - FROM table_info_cte AS TI - LEFT JOIN columns_info_cte AS CI - ON TI.TABLE_SCHEMA = CI.TABLE_SCHEMA AND TI.TABLE_NAME = CI.TABLE_NAME - LEFT JOIN constraints_info_cte AS CONSI - ON TI.TABLE_SCHEMA = CONSI.TABLE_SCHEMA AND TI.TABLE_NAME = CONSI.TABLE_NAME - LEFT JOIN indexes_info_cte AS II - ON TI.TABLE_SCHEMA = II.TABLE_SCHEMA AND TI.TABLE_NAME = II.TABLE_NAME - ORDER BY TI.TABLE_SCHEMA, TI.TABLE_NAME; - - parameters: - - name: table_names - type: string - description: "Optional: A comma-separated list of table names. If empty, details for all tables in user-accessible schemas will be listed." - - name: output_format - type: string - description: "Optional: Use 'simple' to return table names only or use 'detailed' to return the full information schema." - default: "detailed" toolsets: spanner-database-tools: diff --git a/internal/tools/spanner/spannerlisttables/spannerlisttables.go b/internal/tools/spanner/spannerlisttables/spannerlisttables.go new file mode 100644 index 00000000000..0506a67443c --- /dev/null +++ b/internal/tools/spanner/spannerlisttables/spannerlisttables.go @@ -0,0 +1,606 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package spannerlisttables + +import ( + "context" + "fmt" + "strings" + + "cloud.google.com/go/spanner" + yaml "github.com/goccy/go-yaml" + "github.com/googleapis/genai-toolbox/internal/sources" + spannerdb "github.com/googleapis/genai-toolbox/internal/sources/spanner" + "github.com/googleapis/genai-toolbox/internal/tools" + "google.golang.org/api/iterator" +) + +const kind string = "spanner-list-tables" + +func init() { + if !tools.Register(kind, newConfig) { + panic(fmt.Sprintf("tool kind %q already registered", kind)) + } +} + +func newConfig(ctx context.Context, name string, decoder *yaml.Decoder) (tools.ToolConfig, error) { + actual := Config{Name: name} + if err := decoder.DecodeContext(ctx, &actual); err != nil { + return nil, err + } + return actual, nil +} + +type compatibleSource interface { + SpannerClient() *spanner.Client + DatabaseDialect() string +} + +// validate compatible sources are still compatible +var _ compatibleSource = &spannerdb.Source{} + +var compatibleSources = [...]string{spannerdb.SourceKind} + +type Config struct { + Name string `yaml:"name" validate:"required"` + Kind string `yaml:"kind" validate:"required"` + Source string `yaml:"source" validate:"required"` + Description string `yaml:"description"` + AuthRequired []string `yaml:"authRequired"` +} + +// validate interface +var _ tools.ToolConfig = Config{} + +func (cfg Config) ToolConfigKind() string { + return kind +} + +func (cfg Config) Initialize(srcs map[string]sources.Source) (tools.Tool, error) { + // verify source exists + rawS, ok := srcs[cfg.Source] + if !ok { + return nil, fmt.Errorf("no source named %q configured", cfg.Source) + } + + // verify the source is compatible + s, ok := rawS.(compatibleSource) + if !ok { + return nil, fmt.Errorf("invalid source for %q tool: source kind must be one of %q", kind, compatibleSources) + } + + // Define parameters for the tool + allParameters := tools.Parameters{ + tools.NewStringParameterWithDefault( + "table_names", + "", + "Optional: A comma-separated list of table names. If empty, details for all tables in user-accessible schemas will be listed.", + ), + tools.NewStringParameterWithDefault( + "output_format", + "detailed", + "Optional: Use 'simple' to return table names only or use 'detailed' to return the full information schema.", + ), + } + + description := cfg.Description + if description == "" { + description = "Lists detailed schema information (object type, columns, constraints, indexes) as JSON for user-created tables. Filters by a comma-separated list of names. If names are omitted, lists all tables in user schemas." + } + + mcpManifest := tools.McpManifest{ + Name: cfg.Name, + Description: description, + InputSchema: allParameters.McpManifest(), + } + + // finish tool setup + t := Tool{ + Name: cfg.Name, + Kind: kind, + AllParams: allParameters, + AuthRequired: cfg.AuthRequired, + Client: s.SpannerClient(), + dialect: s.DatabaseDialect(), + manifest: tools.Manifest{Description: description, Parameters: allParameters.Manifest(), AuthRequired: cfg.AuthRequired}, + mcpManifest: mcpManifest, + } + return t, nil +} + +// validate interface +var _ tools.Tool = Tool{} + +type Tool struct { + Name string `yaml:"name"` + Kind string `yaml:"kind"` + AuthRequired []string `yaml:"authRequired"` + AllParams tools.Parameters `yaml:"allParams"` + Client *spanner.Client + dialect string + manifest tools.Manifest + mcpManifest tools.McpManifest +} + +// processRows iterates over the spanner.RowIterator and converts each row to a map[string]any. +func processRows(iter *spanner.RowIterator) ([]any, error) { + var out []any + defer iter.Stop() + + for { + row, err := iter.Next() + if err == iterator.Done { + break + } + if err != nil { + return nil, fmt.Errorf("unable to parse row: %w", err) + } + + vMap := make(map[string]any) + cols := row.ColumnNames() + for i, c := range cols { + vMap[c] = row.ColumnValue(i) + } + out = append(out, vMap) + } + return out, nil +} + +func (t Tool) getStatement() string { + switch strings.ToLower(t.dialect) { + case "postgresql": + return postgresqlStatement + case "googlesql": + return googleSQLStatement + default: + // Default to GoogleSQL + return googleSQLStatement + } +} + +func (t Tool) Invoke(ctx context.Context, params tools.ParamValues, accessToken tools.AccessToken) (any, error) { + paramsMap := params.AsMap() + + // Get the appropriate SQL statement based on dialect + statement := t.getStatement() + + // Prepare parameters based on dialect + var stmtParams map[string]interface{} + + tableNames, _ := paramsMap["table_names"].(string) + outputFormat, _ := paramsMap["output_format"].(string) + if outputFormat == "" { + outputFormat = "detailed" + } + + switch strings.ToLower(t.dialect) { + case "postgresql": + // PostgreSQL uses positional parameters ($1, $2) + stmtParams = map[string]interface{}{ + "p1": tableNames, + "p2": outputFormat, + } + + case "googlesql": + // GoogleSQL uses named parameters (@table_names, @output_format) + stmtParams = map[string]interface{}{ + "table_names": tableNames, + "output_format": outputFormat, + } + default: + return nil, fmt.Errorf("unsupported dialect: %s", t.dialect) + } + + stmt := spanner.Statement{ + SQL: statement, + Params: stmtParams, + } + + // Execute the query (read-only) + iter := t.Client.Single().Query(ctx, stmt) + results, err := processRows(iter) + if err != nil { + return nil, fmt.Errorf("unable to execute query: %w", err) + } + + return results, nil +} + +func (t Tool) ParseParams(data map[string]any, claims map[string]map[string]any) (tools.ParamValues, error) { + return tools.ParseParams(t.AllParams, data, claims) +} + +func (t Tool) Manifest() tools.Manifest { + return t.manifest +} + +func (t Tool) McpManifest() tools.McpManifest { + return t.mcpManifest +} + +func (t Tool) Authorized(verifiedAuthServices []string) bool { + return tools.IsAuthorized(t.AuthRequired, verifiedAuthServices) +} + +func (t Tool) RequiresClientAuthorization() bool { + return false +} + +// PostgreSQL statement for listing tables +const postgresqlStatement = ` +WITH table_info_cte AS ( + SELECT + T.TABLE_SCHEMA, + T.TABLE_NAME, + T.TABLE_TYPE, + T.PARENT_TABLE_NAME, + T.ON_DELETE_ACTION + FROM INFORMATION_SCHEMA.TABLES AS T + WHERE + T.TABLE_SCHEMA = 'public' + AND T.TABLE_TYPE = 'BASE TABLE' + AND ( + NULLIF(TRIM($1), '') IS NULL OR + T.TABLE_NAME IN ( + SELECT table_name + FROM UNNEST(regexp_split_to_array($1, '\s*,\s*')) AS table_name) + ) + ), + + columns_info_cte AS ( + SELECT + C.TABLE_SCHEMA, + C.TABLE_NAME, + ARRAY_AGG( + CONCAT( + '{', + '"column_name":"', COALESCE(REPLACE(C.COLUMN_NAME, '"', '\"'), ''), '",', + '"data_type":"', COALESCE(REPLACE(C.SPANNER_TYPE, '"', '\"'), ''), '",', + '"ordinal_position":', C.ORDINAL_POSITION::TEXT, ',', + '"is_not_nullable":', CASE WHEN C.IS_NULLABLE = 'NO' THEN 'true' ELSE 'false' END, ',', + '"column_default":', CASE WHEN C.COLUMN_DEFAULT IS NULL THEN 'null' ELSE CONCAT('"', REPLACE(C.COLUMN_DEFAULT::text, '"', '\"'), '"') END, + '}' + ) ORDER BY C.ORDINAL_POSITION + ) AS columns_json_array_elements + FROM INFORMATION_SCHEMA.COLUMNS AS C + WHERE C.TABLE_SCHEMA = 'public' + AND EXISTS (SELECT 1 FROM table_info_cte TI WHERE C.TABLE_SCHEMA = TI.TABLE_SCHEMA AND C.TABLE_NAME = TI.TABLE_NAME) + GROUP BY C.TABLE_SCHEMA, C.TABLE_NAME + ), + + constraint_columns_agg_cte AS ( + SELECT + CONSTRAINT_CATALOG, + CONSTRAINT_SCHEMA, + CONSTRAINT_NAME, + ARRAY_AGG(REPLACE(COLUMN_NAME, '"', '\"') ORDER BY ORDINAL_POSITION) AS column_names_json_list + FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE + WHERE CONSTRAINT_SCHEMA = 'public' + GROUP BY CONSTRAINT_CATALOG, CONSTRAINT_SCHEMA, CONSTRAINT_NAME + ), + + constraints_info_cte AS ( + SELECT + TC.TABLE_SCHEMA, + TC.TABLE_NAME, + ARRAY_AGG( + CONCAT( + '{', + '"constraint_name":"', COALESCE(REPLACE(TC.CONSTRAINT_NAME, '"', '\"'), ''), '",', + '"constraint_type":"', COALESCE(REPLACE(TC.CONSTRAINT_TYPE, '"', '\"'), ''), '",', + '"constraint_definition":', + CASE TC.CONSTRAINT_TYPE + WHEN 'CHECK' THEN CASE WHEN CC.CHECK_CLAUSE IS NULL THEN 'null' ELSE CONCAT('"', REPLACE(CC.CHECK_CLAUSE, '"', '\"'), '"') END + WHEN 'PRIMARY KEY' THEN CONCAT('"', 'PRIMARY KEY (', array_to_string(COALESCE(KeyCols.column_names_json_list, ARRAY[]::text[]), ', '), ')', '"') + WHEN 'UNIQUE' THEN CONCAT('"', 'UNIQUE (', array_to_string(COALESCE(KeyCols.column_names_json_list, ARRAY[]::text[]), ', '), ')', '"') + WHEN 'FOREIGN KEY' THEN CONCAT('"', 'FOREIGN KEY (', array_to_string(COALESCE(KeyCols.column_names_json_list, ARRAY[]::text[]), ', '), ') REFERENCES ', + COALESCE(REPLACE(RefKeyTable.TABLE_NAME, '"', '\"'), ''), + ' (', array_to_string(COALESCE(RefKeyCols.column_names_json_list, ARRAY[]::text[]), ', '), ')', '"') + ELSE 'null' + END, ',', + '"constraint_columns":["', array_to_string(COALESCE(KeyCols.column_names_json_list, ARRAY[]::text[]), ','), '"],', + '"foreign_key_referenced_table":', CASE WHEN RefKeyTable.TABLE_NAME IS NULL THEN 'null' ELSE CONCAT('"', REPLACE(RefKeyTable.TABLE_NAME, '"', '\"'), '"') END, ',', + '"foreign_key_referenced_columns":["', array_to_string(COALESCE(RefKeyCols.column_names_json_list, ARRAY[]::text[]), ','), '"]', + '}' + ) ORDER BY TC.CONSTRAINT_NAME + ) AS constraints_json_array_elements + FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS AS TC + LEFT JOIN INFORMATION_SCHEMA.CHECK_CONSTRAINTS AS CC + ON TC.CONSTRAINT_CATALOG = CC.CONSTRAINT_CATALOG AND TC.CONSTRAINT_SCHEMA = CC.CONSTRAINT_SCHEMA AND TC.CONSTRAINT_NAME = CC.CONSTRAINT_NAME + LEFT JOIN INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS AS RC + ON TC.CONSTRAINT_CATALOG = RC.CONSTRAINT_CATALOG AND TC.CONSTRAINT_SCHEMA = RC.CONSTRAINT_SCHEMA AND TC.CONSTRAINT_NAME = RC.CONSTRAINT_NAME + LEFT JOIN INFORMATION_SCHEMA.TABLE_CONSTRAINTS AS RefConstraint + ON RC.UNIQUE_CONSTRAINT_CATALOG = RefConstraint.CONSTRAINT_CATALOG AND RC.UNIQUE_CONSTRAINT_SCHEMA = RefConstraint.CONSTRAINT_SCHEMA AND RC.UNIQUE_CONSTRAINT_NAME = RefConstraint.CONSTRAINT_NAME + LEFT JOIN INFORMATION_SCHEMA.TABLES AS RefKeyTable + ON RefConstraint.TABLE_CATALOG = RefKeyTable.TABLE_CATALOG AND RefConstraint.TABLE_SCHEMA = RefKeyTable.TABLE_SCHEMA AND RefConstraint.TABLE_NAME = RefKeyTable.TABLE_NAME + LEFT JOIN constraint_columns_agg_cte AS KeyCols + ON TC.CONSTRAINT_CATALOG = KeyCols.CONSTRAINT_CATALOG AND TC.CONSTRAINT_SCHEMA = KeyCols.CONSTRAINT_SCHEMA AND TC.CONSTRAINT_NAME = KeyCols.CONSTRAINT_NAME + LEFT JOIN constraint_columns_agg_cte AS RefKeyCols + ON RC.UNIQUE_CONSTRAINT_CATALOG = RefKeyCols.CONSTRAINT_CATALOG AND RC.UNIQUE_CONSTRAINT_SCHEMA = RefKeyCols.CONSTRAINT_SCHEMA AND RC.UNIQUE_CONSTRAINT_NAME = RefKeyCols.CONSTRAINT_NAME AND TC.CONSTRAINT_TYPE = 'FOREIGN KEY' + WHERE TC.TABLE_SCHEMA = 'public' + AND EXISTS (SELECT 1 FROM table_info_cte TI WHERE TC.TABLE_SCHEMA = TI.TABLE_SCHEMA AND TC.TABLE_NAME = TI.TABLE_NAME) + GROUP BY TC.TABLE_SCHEMA, TC.TABLE_NAME + ), + + index_key_columns_agg_cte AS ( + SELECT + TABLE_CATALOG, + TABLE_SCHEMA, + TABLE_NAME, + INDEX_NAME, + ARRAY_AGG( + CONCAT( + '{"column_name":"', COALESCE(REPLACE(COLUMN_NAME, '"', '\"'), ''), '",', + '"ordering":"', COALESCE(REPLACE(COLUMN_ORDERING, '"', '\"'), ''), '"}' + ) ORDER BY ORDINAL_POSITION + ) AS key_column_json_details + FROM INFORMATION_SCHEMA.INDEX_COLUMNS + WHERE ORDINAL_POSITION IS NOT NULL + AND TABLE_SCHEMA = 'public' + GROUP BY TABLE_CATALOG, TABLE_SCHEMA, TABLE_NAME, INDEX_NAME + ), + + index_storing_columns_agg_cte AS ( + SELECT + TABLE_CATALOG, + TABLE_SCHEMA, + TABLE_NAME, + INDEX_NAME, + ARRAY_AGG(CONCAT('"', REPLACE(COLUMN_NAME, '"', '\"'), '"') ORDER BY COLUMN_NAME) AS storing_column_json_names + FROM INFORMATION_SCHEMA.INDEX_COLUMNS + WHERE ORDINAL_POSITION IS NULL + AND TABLE_SCHEMA = 'public' + GROUP BY TABLE_CATALOG, TABLE_SCHEMA, TABLE_NAME, INDEX_NAME + ), + + indexes_info_cte AS ( + SELECT + I.TABLE_SCHEMA, + I.TABLE_NAME, + ARRAY_AGG( + CONCAT( + '{', + '"index_name":"', COALESCE(REPLACE(I.INDEX_NAME, '"', '\"'), ''), '",', + '"index_type":"', COALESCE(REPLACE(I.INDEX_TYPE, '"', '\"'), ''), '",', + '"is_unique":', CASE WHEN I.IS_UNIQUE = 'YES' THEN 'true' ELSE 'false' END, ',', + '"is_null_filtered":', CASE WHEN I.IS_NULL_FILTERED = 'YES' THEN 'true' ELSE 'false' END, ',', + '"interleaved_in_table":', CASE WHEN I.PARENT_TABLE_NAME IS NULL OR I.PARENT_TABLE_NAME = '' THEN 'null' ELSE CONCAT('"', REPLACE(I.PARENT_TABLE_NAME, '"', '\"'), '"') END, ',', + '"index_key_columns":[', COALESCE(array_to_string(KeyIndexCols.key_column_json_details, ','), ''), '],', + '"storing_columns":[', COALESCE(array_to_string(StoringIndexCols.storing_column_json_names, ','), ''), ']', + '}' + ) ORDER BY I.INDEX_NAME + ) AS indexes_json_array_elements + FROM INFORMATION_SCHEMA.INDEXES AS I + LEFT JOIN index_key_columns_agg_cte AS KeyIndexCols + ON I.TABLE_CATALOG = KeyIndexCols.TABLE_CATALOG AND I.TABLE_SCHEMA = KeyIndexCols.TABLE_SCHEMA AND I.TABLE_NAME = KeyIndexCols.TABLE_NAME AND I.INDEX_NAME = KeyIndexCols.INDEX_NAME + LEFT JOIN index_storing_columns_agg_cte AS StoringIndexCols + ON I.TABLE_CATALOG = StoringIndexCols.TABLE_CATALOG AND I.TABLE_SCHEMA = StoringIndexCols.TABLE_SCHEMA AND I.TABLE_NAME = StoringIndexCols.TABLE_NAME AND I.INDEX_NAME = StoringIndexCols.INDEX_NAME + AND I.INDEX_TYPE IN ('LOCAL', 'GLOBAL') + WHERE I.TABLE_SCHEMA = 'public' + AND EXISTS (SELECT 1 FROM table_info_cte TI WHERE I.TABLE_SCHEMA = TI.TABLE_SCHEMA AND I.TABLE_NAME = TI.TABLE_NAME) + GROUP BY I.TABLE_SCHEMA, I.TABLE_NAME + ) + +SELECT + TI.TABLE_SCHEMA AS schema_name, + TI.TABLE_NAME AS object_name, + CASE + WHEN $2 = 'simple' THEN + -- IF format is 'simple', return basic JSON + CONCAT('{"name":"', COALESCE(REPLACE(TI.TABLE_NAME, '"', '\"'), ''), '"}') + ELSE + CONCAT( + '{', + '"schema_name":"', COALESCE(REPLACE(TI.TABLE_SCHEMA, '"', '\"'), ''), '",', + '"object_name":"', COALESCE(REPLACE(TI.TABLE_NAME, '"', '\"'), ''), '",', + '"object_type":"', COALESCE(REPLACE(TI.TABLE_TYPE, '"', '\"'), ''), '",', + '"columns":[', COALESCE(array_to_string(CI.columns_json_array_elements, ','), ''), '],', + '"constraints":[', COALESCE(array_to_string(CONSI.constraints_json_array_elements, ','), ''), '],', + '"indexes":[', COALESCE(array_to_string(II.indexes_json_array_elements, ','), ''), ']', + '}' + ) + END AS object_details +FROM table_info_cte AS TI +LEFT JOIN columns_info_cte AS CI + ON TI.TABLE_SCHEMA = CI.TABLE_SCHEMA AND TI.TABLE_NAME = CI.TABLE_NAME +LEFT JOIN constraints_info_cte AS CONSI + ON TI.TABLE_SCHEMA = CONSI.TABLE_SCHEMA AND TI.TABLE_NAME = CONSI.TABLE_NAME +LEFT JOIN indexes_info_cte AS II + ON TI.TABLE_SCHEMA = II.TABLE_SCHEMA AND TI.TABLE_NAME = II.TABLE_NAME +ORDER BY TI.TABLE_SCHEMA, TI.TABLE_NAME` + +// GoogleSQL statement for listing tables +const googleSQLStatement = ` +WITH FilterTableNames AS ( + SELECT DISTINCT TRIM(name) AS TABLE_NAME + FROM UNNEST(IF(@table_names = '' OR @table_names IS NULL, ['%'], SPLIT(@table_names, ','))) AS name +), + +-- 1. Table Information +table_info_cte AS ( + SELECT + T.TABLE_SCHEMA, + T.TABLE_NAME, + T.TABLE_TYPE, + T.PARENT_TABLE_NAME, -- For interleaved tables + T.ON_DELETE_ACTION -- For interleaved tables + FROM INFORMATION_SCHEMA.TABLES AS T + WHERE + T.TABLE_SCHEMA = '' + AND T.TABLE_TYPE = 'BASE TABLE' + AND (EXISTS (SELECT 1 FROM FilterTableNames WHERE FilterTableNames.TABLE_NAME = '%') OR T.TABLE_NAME IN (SELECT TABLE_NAME FROM FilterTableNames)) +), + +-- 2. Column Information (with JSON string for each column) +columns_info_cte AS ( + SELECT + C.TABLE_SCHEMA, + C.TABLE_NAME, + ARRAY_AGG( + CONCAT( + '{', + '"column_name":"', IFNULL(C.COLUMN_NAME, ''), '",', + '"data_type":"', IFNULL(C.SPANNER_TYPE, ''), '",', + '"ordinal_position":', CAST(C.ORDINAL_POSITION AS STRING), ',', + '"is_not_nullable":', IF(C.IS_NULLABLE = 'NO', 'true', 'false'), ',', + '"column_default":', IF(C.COLUMN_DEFAULT IS NULL, 'null', CONCAT('"', C.COLUMN_DEFAULT, '"')), + '}' + ) ORDER BY C.ORDINAL_POSITION + ) AS columns_json_array_elements + FROM INFORMATION_SCHEMA.COLUMNS AS C + WHERE EXISTS (SELECT 1 FROM table_info_cte TI WHERE C.TABLE_SCHEMA = TI.TABLE_SCHEMA AND C.TABLE_NAME = TI.TABLE_NAME) + GROUP BY C.TABLE_SCHEMA, C.TABLE_NAME +), + +-- Helper CTE for aggregating constraint columns +constraint_columns_agg_cte AS ( + SELECT + CONSTRAINT_CATALOG, + CONSTRAINT_SCHEMA, + CONSTRAINT_NAME, + ARRAY_AGG(REPLACE(COLUMN_NAME, '"', '\"') ORDER BY ORDINAL_POSITION) AS column_names_json_list + FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE + GROUP BY CONSTRAINT_CATALOG, CONSTRAINT_SCHEMA, CONSTRAINT_NAME +), + +-- 3. Constraint Information (with JSON string for each constraint) +constraints_info_cte AS ( + SELECT + TC.TABLE_SCHEMA, + TC.TABLE_NAME, + ARRAY_AGG( + CONCAT( + '{', + '"constraint_name":"', IFNULL(TC.CONSTRAINT_NAME, ''), '",', + '"constraint_type":"', IFNULL(TC.CONSTRAINT_TYPE, ''), '",', + '"constraint_definition":', + CASE TC.CONSTRAINT_TYPE + WHEN 'CHECK' THEN IF(CC.CHECK_CLAUSE IS NULL, 'null', CONCAT('"', CC.CHECK_CLAUSE, '"')) + WHEN 'PRIMARY KEY' THEN CONCAT('"', 'PRIMARY KEY (', ARRAY_TO_STRING(COALESCE(KeyCols.column_names_json_list, []), ', '), ')', '"') + WHEN 'UNIQUE' THEN CONCAT('"', 'UNIQUE (', ARRAY_TO_STRING(COALESCE(KeyCols.column_names_json_list, []), ', '), ')', '"') + WHEN 'FOREIGN KEY' THEN CONCAT('"', 'FOREIGN KEY (', ARRAY_TO_STRING(COALESCE(KeyCols.column_names_json_list, []), ', '), ') REFERENCES ', + IFNULL(RefKeyTable.TABLE_NAME, ''), + ' (', ARRAY_TO_STRING(COALESCE(RefKeyCols.column_names_json_list, []), ', '), ')', '"') + ELSE 'null' + END, ',', + '"constraint_columns":["', ARRAY_TO_STRING(COALESCE(KeyCols.column_names_json_list, []), ','), '"],', + '"foreign_key_referenced_table":', IF(RefKeyTable.TABLE_NAME IS NULL, 'null', CONCAT('"', RefKeyTable.TABLE_NAME, '"')), ',', + '"foreign_key_referenced_columns":["', ARRAY_TO_STRING(COALESCE(RefKeyCols.column_names_json_list, []), ','), '"]', + '}' + ) ORDER BY TC.CONSTRAINT_NAME + ) AS constraints_json_array_elements + FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS AS TC + LEFT JOIN INFORMATION_SCHEMA.CHECK_CONSTRAINTS AS CC + ON TC.CONSTRAINT_CATALOG = CC.CONSTRAINT_CATALOG AND TC.CONSTRAINT_SCHEMA = CC.CONSTRAINT_SCHEMA AND TC.CONSTRAINT_NAME = CC.CONSTRAINT_NAME + LEFT JOIN INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS AS RC + ON TC.CONSTRAINT_CATALOG = RC.CONSTRAINT_CATALOG AND TC.CONSTRAINT_SCHEMA = RC.CONSTRAINT_SCHEMA AND TC.CONSTRAINT_NAME = RC.CONSTRAINT_NAME + LEFT JOIN INFORMATION_SCHEMA.TABLE_CONSTRAINTS AS RefConstraint + ON RC.UNIQUE_CONSTRAINT_CATALOG = RefConstraint.CONSTRAINT_CATALOG AND RC.UNIQUE_CONSTRAINT_SCHEMA = RefConstraint.CONSTRAINT_SCHEMA AND RC.UNIQUE_CONSTRAINT_NAME = RefConstraint.CONSTRAINT_NAME + LEFT JOIN INFORMATION_SCHEMA.TABLES AS RefKeyTable + ON RefConstraint.TABLE_CATALOG = RefKeyTable.TABLE_CATALOG AND RefConstraint.TABLE_SCHEMA = RefKeyTable.TABLE_SCHEMA AND RefConstraint.TABLE_NAME = RefKeyTable.TABLE_NAME + LEFT JOIN constraint_columns_agg_cte AS KeyCols + ON TC.CONSTRAINT_CATALOG = KeyCols.CONSTRAINT_CATALOG AND TC.CONSTRAINT_SCHEMA = KeyCols.CONSTRAINT_SCHEMA AND TC.CONSTRAINT_NAME = KeyCols.CONSTRAINT_NAME + LEFT JOIN constraint_columns_agg_cte AS RefKeyCols + ON RC.UNIQUE_CONSTRAINT_CATALOG = RefKeyCols.CONSTRAINT_CATALOG AND RC.UNIQUE_CONSTRAINT_SCHEMA = RefKeyCols.CONSTRAINT_SCHEMA AND RC.UNIQUE_CONSTRAINT_NAME = RefKeyCols.CONSTRAINT_NAME AND TC.CONSTRAINT_TYPE = 'FOREIGN KEY' + WHERE EXISTS (SELECT 1 FROM table_info_cte TI WHERE TC.TABLE_SCHEMA = TI.TABLE_SCHEMA AND TC.TABLE_NAME = TI.TABLE_NAME) + GROUP BY TC.TABLE_SCHEMA, TC.TABLE_NAME +), + +-- Helper CTE for aggregating index key columns (as JSON strings) +index_key_columns_agg_cte AS ( + SELECT + TABLE_CATALOG, + TABLE_SCHEMA, + TABLE_NAME, + INDEX_NAME, + ARRAY_AGG( + CONCAT( + '{"column_name":"', IFNULL(COLUMN_NAME, ''), '",', + '"ordering":"', IFNULL(COLUMN_ORDERING, ''), '"}' + ) ORDER BY ORDINAL_POSITION + ) AS key_column_json_details + FROM INFORMATION_SCHEMA.INDEX_COLUMNS + WHERE ORDINAL_POSITION IS NOT NULL -- Key columns + GROUP BY TABLE_CATALOG, TABLE_SCHEMA, TABLE_NAME, INDEX_NAME +), + +-- Helper CTE for aggregating index storing columns (as JSON strings) +index_storing_columns_agg_cte AS ( + SELECT + TABLE_CATALOG, + TABLE_SCHEMA, + TABLE_NAME, + INDEX_NAME, + ARRAY_AGG(CONCAT('"', COLUMN_NAME, '"') ORDER BY COLUMN_NAME) AS storing_column_json_names + FROM INFORMATION_SCHEMA.INDEX_COLUMNS + WHERE ORDINAL_POSITION IS NULL -- Storing columns + GROUP BY TABLE_CATALOG, TABLE_SCHEMA, TABLE_NAME, INDEX_NAME +), + +-- 4. Index Information (with JSON string for each index) +indexes_info_cte AS ( + SELECT + I.TABLE_SCHEMA, + I.TABLE_NAME, + ARRAY_AGG( + CONCAT( + '{', + '"index_name":"', IFNULL(I.INDEX_NAME, ''), '",', + '"index_type":"', IFNULL(I.INDEX_TYPE, ''), '",', + '"is_unique":', IF(I.IS_UNIQUE, 'true', 'false'), ',', + '"is_null_filtered":', IF(I.IS_NULL_FILTERED, 'true', 'false'), ',', + '"interleaved_in_table":', IF(I.PARENT_TABLE_NAME IS NULL, 'null', CONCAT('"', I.PARENT_TABLE_NAME, '"')), ',', + '"index_key_columns":[', ARRAY_TO_STRING(COALESCE(KeyIndexCols.key_column_json_details, []), ','), '],', + '"storing_columns":[', ARRAY_TO_STRING(COALESCE(StoringIndexCols.storing_column_json_names, []), ','), ']', + '}' + ) ORDER BY I.INDEX_NAME + ) AS indexes_json_array_elements + FROM INFORMATION_SCHEMA.INDEXES AS I + LEFT JOIN index_key_columns_agg_cte AS KeyIndexCols + ON I.TABLE_CATALOG = KeyIndexCols.TABLE_CATALOG AND I.TABLE_SCHEMA = KeyIndexCols.TABLE_SCHEMA AND I.TABLE_NAME = KeyIndexCols.TABLE_NAME AND I.INDEX_NAME = KeyIndexCols.INDEX_NAME + LEFT JOIN index_storing_columns_agg_cte AS StoringIndexCols + ON I.TABLE_CATALOG = StoringIndexCols.TABLE_CATALOG AND I.TABLE_SCHEMA = StoringIndexCols.TABLE_SCHEMA AND I.TABLE_NAME = StoringIndexCols.TABLE_NAME AND I.INDEX_NAME = StoringIndexCols.INDEX_NAME AND I.INDEX_TYPE = 'INDEX' + WHERE EXISTS (SELECT 1 FROM table_info_cte TI WHERE I.TABLE_SCHEMA = TI.TABLE_SCHEMA AND I.TABLE_NAME = TI.TABLE_NAME) + GROUP BY I.TABLE_SCHEMA, I.TABLE_NAME +) + +-- Final SELECT to build the JSON output +SELECT + TI.TABLE_SCHEMA AS schema_name, + TI.TABLE_NAME AS object_name, + CASE + WHEN @output_format = 'simple' THEN + -- IF format is 'simple', return basic JSON + CONCAT('{"name":"', IFNULL(REPLACE(TI.TABLE_NAME, '"', '\"'), ''), '"}') + ELSE + CONCAT( + '{', + '"schema_name":"', IFNULL(TI.TABLE_SCHEMA, ''), '",', + '"object_name":"', IFNULL(TI.TABLE_NAME, ''), '",', + '"object_type":"', IFNULL(TI.TABLE_TYPE, ''), '",', + '"columns":[', ARRAY_TO_STRING(COALESCE(CI.columns_json_array_elements, []), ','), '],', + '"constraints":[', ARRAY_TO_STRING(COALESCE(CONSI.constraints_json_array_elements, []), ','), '],', + '"indexes":[', ARRAY_TO_STRING(COALESCE(II.indexes_json_array_elements, []), ','), ']', + '}' + ) + END AS object_details +FROM table_info_cte AS TI +LEFT JOIN columns_info_cte AS CI + ON TI.TABLE_SCHEMA = CI.TABLE_SCHEMA AND TI.TABLE_NAME = CI.TABLE_NAME +LEFT JOIN constraints_info_cte AS CONSI + ON TI.TABLE_SCHEMA = CONSI.TABLE_SCHEMA AND TI.TABLE_NAME = CONSI.TABLE_NAME +LEFT JOIN indexes_info_cte AS II + ON TI.TABLE_SCHEMA = II.TABLE_SCHEMA AND TI.TABLE_NAME = II.TABLE_NAME +ORDER BY TI.TABLE_SCHEMA, TI.TABLE_NAME` diff --git a/internal/tools/spanner/spannerlisttables/spannerlisttables_test.go b/internal/tools/spanner/spannerlisttables/spannerlisttables_test.go new file mode 100644 index 00000000000..da4a9885c50 --- /dev/null +++ b/internal/tools/spanner/spannerlisttables/spannerlisttables_test.go @@ -0,0 +1,112 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package spannerlisttables_test + +import ( + "testing" + + yaml "github.com/goccy/go-yaml" + "github.com/google/go-cmp/cmp" + "github.com/googleapis/genai-toolbox/internal/server" + "github.com/googleapis/genai-toolbox/internal/testutils" + "github.com/googleapis/genai-toolbox/internal/tools/spanner/spannerlisttables" +) + +func TestParseFromYamlListTables(t *testing.T) { + ctx, err := testutils.ContextWithNewLogger() + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + tcs := []struct { + desc string + in string + want server.ToolConfigs + }{ + { + desc: "basic example", + in: ` + tools: + example_tool: + kind: spanner-list-tables + source: my-spanner-instance + description: Lists tables in the database + `, + want: server.ToolConfigs{ + "example_tool": spannerlisttables.Config{ + Name: "example_tool", + Kind: "spanner-list-tables", + Source: "my-spanner-instance", + Description: "Lists tables in the database", + AuthRequired: []string{}, + }, + }, + }, + { + desc: "with auth required", + in: ` + tools: + example_tool: + kind: spanner-list-tables + source: my-spanner-instance + description: Lists tables in the database + authRequired: + - auth1 + - auth2 + `, + want: server.ToolConfigs{ + "example_tool": spannerlisttables.Config{ + Name: "example_tool", + Kind: "spanner-list-tables", + Source: "my-spanner-instance", + Description: "Lists tables in the database", + AuthRequired: []string{"auth1", "auth2"}, + }, + }, + }, + { + desc: "minimal config", + in: ` + tools: + example_tool: + kind: spanner-list-tables + source: my-spanner-instance + `, + want: server.ToolConfigs{ + "example_tool": spannerlisttables.Config{ + Name: "example_tool", + Kind: "spanner-list-tables", + Source: "my-spanner-instance", + Description: "", + AuthRequired: []string{}, + }, + }, + }, + } + for _, tc := range tcs { + t.Run(tc.desc, func(t *testing.T) { + got := struct { + Tools server.ToolConfigs `yaml:"tools"` + }{} + // Parse contents + err := yaml.UnmarshalContext(ctx, testutils.FormatYaml(tc.in), &got) + if err != nil { + t.Fatalf("unable to unmarshal: %s", err) + } + if diff := cmp.Diff(tc.want, got.Tools); diff != "" { + t.Fatalf("incorrect parse: diff %v", diff) + } + }) + } +} diff --git a/tests/spanner/spanner_integration_test.go b/tests/spanner/spanner_integration_test.go index 6943126dd3f..201a6773be5 100644 --- a/tests/spanner/spanner_integration_test.go +++ b/tests/spanner/spanner_integration_test.go @@ -133,6 +133,7 @@ func TestSpannerToolEndpoints(t *testing.T) { toolsFile = addSpannerExecuteSqlConfig(t, toolsFile) toolsFile = addSpannerReadOnlyConfig(t, toolsFile) toolsFile = addTemplateParamConfig(t, toolsFile) + toolsFile = addSpannerListTablesConfig(t, toolsFile) cmd, cleanup, err := tests.StartCmd(ctx, toolsFile, args...) if err != nil { @@ -174,6 +175,7 @@ func TestSpannerToolEndpoints(t *testing.T) { ) runSpannerSchemaToolInvokeTest(t, accessSchemaWant) runSpannerExecuteSqlToolInvokeTest(t, select1Want, invokeParamWant, tableNameParam, tableNameAuth) + runSpannerListTablesTest(t, tableNameParam, tableNameAuth, tableNameTemplateParam) } // getSpannerToolInfo returns statements and param for my-tool for spanner-sql kind @@ -303,6 +305,24 @@ func addSpannerReadOnlyConfig(t *testing.T, config map[string]any) map[string]an return config } +// addSpannerListTablesConfig adds the spanner-list-tables tool configuration +func addSpannerListTablesConfig(t *testing.T, config map[string]any) map[string]any { + tools, ok := config["tools"].(map[string]any) + if !ok { + t.Fatalf("unable to get tools from config") + } + + // Add spanner-list-tables tool + tools["list-tables-tool"] = map[string]any{ + "kind": "spanner-list-tables", + "source": "my-instance", + "description": "Lists tables with their schema information", + } + + config["tools"] = tools + return config +} + func addTemplateParamConfig(t *testing.T, config map[string]any) map[string]any { toolsMap, ok := config["tools"].(map[string]any) if !ok { @@ -527,6 +547,119 @@ func runSpannerExecuteSqlToolInvokeTest(t *testing.T, select1Want, invokeParamWa } } + +// Helper function to verify table list results +func verifyTableListResult(t *testing.T, body map[string]interface{}, expectedTables []string, expectedSimpleFormat bool) { + // Parse the result + result, ok := body["result"].(string) + if !ok { + t.Fatalf("unable to find result in response body") + } + + var tables []interface{} + err := json.Unmarshal([]byte(result), &tables) + if err != nil { + t.Fatalf("unable to parse result as JSON array: %s", err) + } + + // If we expect specific tables, verify they exist + if len(expectedTables) > 0 { + tableNames := make(map[string]bool) + requiredKeys := []string{"schema_name", "object_name", "object_type", "columns", "constraints", "indexes"} + if expectedSimpleFormat { + requiredKeys = []string{"name"} + } + + for _, table := range tables { + tableMap, ok := table.(map[string]interface{}) + if !ok { + continue + } + + // Parse object_details JSON string into map[string]interface{} + if objectDetailsStr, ok := tableMap["object_details"].(string); ok { + var objectDetails map[string]interface{} + if err := json.Unmarshal([]byte(objectDetailsStr), &objectDetails); err != nil { + t.Errorf("failed to parse object_details JSON: %v for %v", err, objectDetailsStr) + continue + } + + for _, reqKey := range requiredKeys { + if _, hasKey := objectDetails[reqKey]; !hasKey { + t.Errorf("missing required key '%s', for object_details: %v",reqKey, objectDetails) + } + } + } + + if name, ok := tableMap["object_name"].(string); ok { + tableNames[name] = true + } + } + + for _, expected := range expectedTables { + if !tableNames[expected] { + t.Errorf("expected table %s not found in results", expected) + } + } + } +} + +// runSpannerListTablesTest tests the spanner-list-tables tool +func runSpannerListTablesTest(t *testing.T, tableNameParam, tableNameAuth, tableNameTemplateParam string) { + invokeTcs := []struct { + name string + requestBody io.Reader + expectedTables []string // empty means don't check specific tables + useSimpleFormat bool + }{ + { + name: "list all tables with detailed format", + requestBody: bytes.NewBuffer([]byte(`{}`)), + expectedTables: []string{tableNameParam, tableNameAuth, tableNameTemplateParam}, + }, + { + name: "list tables with simple format", + requestBody: bytes.NewBuffer([]byte(`{"output_format": "simple"}`)), + expectedTables: []string{tableNameParam, tableNameAuth, tableNameTemplateParam}, + useSimpleFormat: true, + }, + { + name: "list specific tables", + requestBody: bytes.NewBuffer([]byte(fmt.Sprintf(`{"table_names": "%s,%s"}`, tableNameParam, tableNameAuth))), + expectedTables: []string{tableNameParam, tableNameAuth}, + }, + { + name: "list non-existent table", + requestBody: bytes.NewBuffer([]byte(`{"table_names": "non_existent_table_xyz"}`)), + expectedTables: []string{}, + }, + } + + for _, tc := range invokeTcs { + t.Run(tc.name, func(t *testing.T) { + // Use RunRequest helper function from tests package + url := "http://127.0.0.1:5000/api/tool/list-tables-tool/invoke" + headers := map[string]string{} + + resp, respBody := tests.RunRequest(t, http.MethodPost, url, tc.requestBody, headers) + + if resp.StatusCode != http.StatusOK { + t.Fatalf("response status code is not 200, got %d: %s", resp.StatusCode, string(respBody)) + } + + + // Check response body + var body map[string]interface{} + err := json.Unmarshal(respBody, &body) + if err != nil { + t.Fatalf("error parsing response body: %s", err) + } + + verifyTableListResult(t, body, tc.expectedTables, tc.useSimpleFormat) + }) + } +} + func runSpannerSchemaToolInvokeTest(t *testing.T, accessSchemaWant string) { invokeTcs := []struct { name string diff --git a/tests/tool.go b/tests/tool.go index fb97feb2660..2cae8ae338c 100644 --- a/tests/tool.go +++ b/tests/tool.go @@ -792,7 +792,7 @@ func RunInitialize(t *testing.T, protocolVersion string) string { t.Fatalf("unexpected error during marshaling of body") } - resp, _ := runRequest(t, http.MethodPost, url, bytes.NewBuffer(reqMarshal), nil) + resp, _ := RunRequest(t, http.MethodPost, url, bytes.NewBuffer(reqMarshal), nil) if resp.StatusCode != 200 { t.Fatalf("response status code is not 200") } @@ -817,7 +817,7 @@ func RunInitialize(t *testing.T, protocolVersion string) string { t.Fatalf("unexpected error during marshaling of notifications body") } - _, _ = runRequest(t, http.MethodPost, url, bytes.NewBuffer(notiMarshal), header) + _, _ = RunRequest(t, http.MethodPost, url, bytes.NewBuffer(notiMarshal), header) return sessionId } @@ -1089,7 +1089,7 @@ func RunMCPToolCallMethod(t *testing.T, myFailToolWant, select1Want string, opti headers[key] = value } - httpResponse, respBody := runRequest(t, http.MethodPost, tc.api, bytes.NewBuffer(reqMarshal), headers) + httpResponse, respBody := RunRequest(t, http.MethodPost, tc.api, bytes.NewBuffer(reqMarshal), headers) // Check status code if httpResponse.StatusCode != tc.wantStatusCode { @@ -1105,7 +1105,8 @@ func RunMCPToolCallMethod(t *testing.T, myFailToolWant, select1Want string, opti } } -func runRequest(t *testing.T, method, url string, body io.Reader, headers map[string]string) (*http.Response, []byte) { +// RunRequest is a helper function to send HTTP requests and return the response +func RunRequest(t *testing.T, method, url string, body io.Reader, headers map[string]string) (*http.Response, []byte) { // Send request req, err := http.NewRequest(method, url, body) if err != nil {