diff --git a/cmd/root.go b/cmd/root.go index 5fb121b28b..4231a69dd3 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -153,6 +153,7 @@ import ( _ "github.com/googleapis/genai-toolbox/internal/tools/postgres/postgreslistavailableextensions" _ "github.com/googleapis/genai-toolbox/internal/tools/postgres/postgreslistinstalledextensions" _ "github.com/googleapis/genai-toolbox/internal/tools/postgres/postgreslisttables" + _ "github.com/googleapis/genai-toolbox/internal/tools/postgres/postgreslistviews" _ "github.com/googleapis/genai-toolbox/internal/tools/postgres/postgressql" _ "github.com/googleapis/genai-toolbox/internal/tools/redis" _ "github.com/googleapis/genai-toolbox/internal/tools/serverlessspark/serverlesssparkgetbatch" diff --git a/cmd/root_test.go b/cmd/root_test.go index d057c5b9a4..472bf4309c 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -1397,7 +1397,7 @@ func TestPrebuiltTools(t *testing.T) { wantToolset: server.ToolsetConfigs{ "alloydb_postgres_database_tools": tools.ToolsetConfig{ Name: "alloydb_postgres_database_tools", - ToolNames: []string{"execute_sql", "list_tables", "list_active_queries", "list_available_extensions", "list_installed_extensions", "list_autovacuum_configurations", "list_memory_configurations", "list_top_bloated_tables", "list_replication_slots", "list_invalid_indexes", "get_query_plan"}, + ToolNames: []string{"execute_sql", "list_tables", "list_active_queries", "list_available_extensions", "list_installed_extensions", "list_autovacuum_configurations", "list_memory_configurations", "list_top_bloated_tables", "list_replication_slots", "list_invalid_indexes", "get_query_plan", "list_views"}, }, }, }, @@ -1427,7 +1427,7 @@ func TestPrebuiltTools(t *testing.T) { wantToolset: server.ToolsetConfigs{ "cloud_sql_postgres_database_tools": tools.ToolsetConfig{ Name: "cloud_sql_postgres_database_tools", - ToolNames: []string{"execute_sql", "list_tables", "list_active_queries", "list_available_extensions", "list_installed_extensions", "list_autovacuum_configurations", "list_memory_configurations", "list_top_bloated_tables", "list_replication_slots", "list_invalid_indexes", "get_query_plan"}, + ToolNames: []string{"execute_sql", "list_tables", "list_active_queries", "list_available_extensions", "list_installed_extensions", "list_autovacuum_configurations", "list_memory_configurations", "list_top_bloated_tables", "list_replication_slots", "list_invalid_indexes", "get_query_plan", "list_views"}, }, }, }, @@ -1527,7 +1527,7 @@ func TestPrebuiltTools(t *testing.T) { wantToolset: server.ToolsetConfigs{ "postgres_database_tools": tools.ToolsetConfig{ Name: "postgres_database_tools", - ToolNames: []string{"execute_sql", "list_tables", "list_active_queries", "list_available_extensions", "list_installed_extensions", "list_autovacuum_configurations", "list_memory_configurations", "list_top_bloated_tables", "list_replication_slots", "list_invalid_indexes", "get_query_plan"}, + ToolNames: []string{"execute_sql", "list_tables", "list_active_queries", "list_available_extensions", "list_installed_extensions", "list_autovacuum_configurations", "list_memory_configurations", "list_top_bloated_tables", "list_replication_slots", "list_invalid_indexes", "get_query_plan", "list_views"}, }, }, }, diff --git a/docs/en/reference/prebuilt-tools.md b/docs/en/reference/prebuilt-tools.md index 2a07d740be..12804425ac 100644 --- a/docs/en/reference/prebuilt-tools.md +++ b/docs/en/reference/prebuilt-tools.md @@ -43,6 +43,8 @@ details on how to connect your AI tools (IDEs) to databases via Toolbox and MCP. * `list_replication_slots`: Lists replication slots in the database. * `list_invalid_indexes`: Lists invalid indexes in the database. * `get_query_plan`: Generate the execution plan of a statement. + * `list_views`: Lists views in the database from pg_views with a default + limit of 50 rows. Returns schemaname, viewname and the ownername. ## AlloyDB Postgres Admin @@ -210,6 +212,8 @@ details on how to connect your AI tools (IDEs) to databases via Toolbox and MCP. * `list_replication_slots`: Lists replication slots in the database. * `list_invalid_indexes`: Lists invalid indexes in the database. * `get_query_plan`: Generate the execution plan of a statement. + * `list_views`: Lists views in the database from pg_views with a default + limit of 50 rows. Returns schemaname, viewname and the ownername. ## Cloud SQL for PostgreSQL Observability @@ -492,6 +496,8 @@ details on how to connect your AI tools (IDEs) to databases via Toolbox and MCP. * `list_replication_slots`: Lists replication slots in the database. * `list_invalid_indexes`: Lists invalid indexes in the database. * `get_query_plan`: Generate the execution plan of a statement. + * `list_views`: Lists views in the database from pg_views with a default + limit of 50 rows. Returns schemaname, viewname and the ownername. ## Google Cloud Serverless for Apache Spark diff --git a/docs/en/resources/sources/alloydb-pg.md b/docs/en/resources/sources/alloydb-pg.md index 18987b3893..fd9904542e 100644 --- a/docs/en/resources/sources/alloydb-pg.md +++ b/docs/en/resources/sources/alloydb-pg.md @@ -45,6 +45,9 @@ cluster][alloydb-free-trial]. - [`postgres-list-installed-extensions`](../tools/postgres/postgres-list-installed-extensions.md) List installed extensions in a PostgreSQL database. +- [`postgres-list-views`](../tools/postgres/postgres-list-views.md) + List views in an AlloyDB for PostgreSQL database. + ### Pre-built Configurations - [AlloyDB using MCP](https://googleapis.github.io/genai-toolbox/how-to/connect-ide/alloydb_pg_mcp/) diff --git a/docs/en/resources/sources/cloud-sql-pg.md b/docs/en/resources/sources/cloud-sql-pg.md index 42e50f74b2..07299e4149 100644 --- a/docs/en/resources/sources/cloud-sql-pg.md +++ b/docs/en/resources/sources/cloud-sql-pg.md @@ -41,6 +41,9 @@ to a database by following these instructions][csql-pg-quickstart]. - [`postgres-list-installed-extensions`](../tools/postgres/postgres-list-installed-extensions.md) List installed extensions in a PostgreSQL database. +- [`postgres-list-views`](../tools/postgres/postgres-list-views.md) + List views in a PostgreSQL database. + ### Pre-built Configurations - [Cloud SQL for Postgres using diff --git a/docs/en/resources/sources/postgres.md b/docs/en/resources/sources/postgres.md index 7ecb3158ef..ca776892ef 100644 --- a/docs/en/resources/sources/postgres.md +++ b/docs/en/resources/sources/postgres.md @@ -35,6 +35,9 @@ reputation for reliability, feature robustness, and performance. - [`postgres-list-installed-extensions`](../tools/postgres/postgres-list-installed-extensions.md) List installed extensions in a PostgreSQL database. +- [`postgres-list-views`](../tools/postgres/postgres-list-views.md) + List views in a PostgreSQL database. + ### Pre-built Configurations - [PostgreSQL using MCP](https://googleapis.github.io/genai-toolbox/how-to/connect-ide/postgres_mcp/) diff --git a/docs/en/resources/tools/postgres/postgres-list-views.md b/docs/en/resources/tools/postgres/postgres-list-views.md new file mode 100644 index 0000000000..1328156539 --- /dev/null +++ b/docs/en/resources/tools/postgres/postgres-list-views.md @@ -0,0 +1,40 @@ +--- +title: "postgres-list-views" +type: docs +weight: 1 +description: > + The "postgres-list-views" tool lists views in a Postgres database, with a default limit of 50 rows. +aliases: +- /resources/tools/postgres-list-views +--- + +## About + +The `postgres-list-views` tool retrieves a list of top N (default 50) views from a Postgres database, excluding those in system schemas (`pg_catalog`, `information_schema`). It's compatible with any of the following sources: + +- [alloydb-postgres](../../sources/alloydb-pg.md) +- [cloud-sql-postgres](../../sources/cloud-sql-pg.md) +- [postgres](../../sources/postgres.md) + +`postgres-list-views` lists detailed view information (schemaname, viewname, ownername) as JSON for views in a database. The tool takes the following input parameters: + +- `viewname` (optional): A string pattern to filter view names. The search uses SQL + LIKE operator to filter the views. Default: `""` +- `limit` (optional): The maximum number of rows to return. Default: `50`. + +## Example + +```yaml +tools: + list_views: + kind: postgres-list-views + source: cloudsql-pg-source +``` + +## Reference + +| **field** | **type** | **required** | **description** | +|-------------|:--------:|:-------------:|------------------------------------------------------| +| kind | string | true | Must be "postgres-list-views". | +| source | string | true | Name of the source the SQL should execute on. | +| description | string | false | Description of the tool that is passed to the agent. | diff --git a/internal/prebuiltconfigs/tools/alloydb-postgres.yaml b/internal/prebuiltconfigs/tools/alloydb-postgres.yaml index 10f8234aa6..77e0312497 100644 --- a/internal/prebuiltconfigs/tools/alloydb-postgres.yaml +++ b/internal/prebuiltconfigs/tools/alloydb-postgres.yaml @@ -156,6 +156,9 @@ tools: description: "The SQL statement for which you want to generate plan (omit the EXPLAIN keyword)." required: true + list_views: + kind: postgres-list-views + source: alloydb-pg-source toolsets: alloydb_postgres_database_tools: - execute_sql @@ -169,3 +172,4 @@ toolsets: - list_replication_slots - list_invalid_indexes - get_query_plan + - list_views diff --git a/internal/prebuiltconfigs/tools/cloud-sql-postgres.yaml b/internal/prebuiltconfigs/tools/cloud-sql-postgres.yaml index 148d9f11cb..c328e50853 100644 --- a/internal/prebuiltconfigs/tools/cloud-sql-postgres.yaml +++ b/internal/prebuiltconfigs/tools/cloud-sql-postgres.yaml @@ -155,6 +155,10 @@ tools: description: "The SQL statement for which you want to generate plan (omit the EXPLAIN keyword)." required: true + list_views: + kind: postgres-list-views + source: cloudsql-pg-source + toolsets: cloud_sql_postgres_database_tools: - execute_sql @@ -168,3 +172,4 @@ toolsets: - list_replication_slots - list_invalid_indexes - get_query_plan + - list_views diff --git a/internal/prebuiltconfigs/tools/postgres.yaml b/internal/prebuiltconfigs/tools/postgres.yaml index 204f75758b..a6ece3e646 100644 --- a/internal/prebuiltconfigs/tools/postgres.yaml +++ b/internal/prebuiltconfigs/tools/postgres.yaml @@ -154,6 +154,10 @@ tools: description: "The SQL statement for which you want to generate plan (omit the EXPLAIN keyword)." required: true + list_views: + kind: postgres-list-views + source: postgresql-source + toolsets: postgres_database_tools: - execute_sql @@ -167,3 +171,4 @@ toolsets: - list_replication_slots - list_invalid_indexes - get_query_plan + - list_views diff --git a/internal/tools/postgres/postgreslistviews/postgreslistviews.go b/internal/tools/postgres/postgreslistviews/postgreslistviews.go new file mode 100644 index 0000000000..b48cc6ad18 --- /dev/null +++ b/internal/tools/postgres/postgreslistviews/postgreslistviews.go @@ -0,0 +1,186 @@ +// 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 postgreslistviews + +import ( + "context" + "fmt" + + yaml "github.com/goccy/go-yaml" + "github.com/googleapis/genai-toolbox/internal/sources" + "github.com/googleapis/genai-toolbox/internal/sources/alloydbpg" + "github.com/googleapis/genai-toolbox/internal/sources/cloudsqlpg" + "github.com/googleapis/genai-toolbox/internal/sources/postgres" + "github.com/googleapis/genai-toolbox/internal/tools" + "github.com/jackc/pgx/v5/pgxpool" +) + +const kind string = "postgres-list-views" + +const listViewsStatement = ` + SELECT schemaname, viewname, viewowner + FROM pg_views + WHERE + schemaname NOT IN ('pg_catalog', 'information_schema') + AND ($1::text IS NULL OR viewname LIKE '%' || $1::text || '%') + ORDER BY viewname + LIMIT COALESCE($2::int, 50); +` + +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 { + PostgresPool() *pgxpool.Pool +} + +// validate compatible sources are still compatible +var _ compatibleSource = &alloydbpg.Source{} +var _ compatibleSource = &cloudsqlpg.Source{} +var _ compatibleSource = &postgres.Source{} + +var compatibleSources = [...]string{alloydbpg.SourceKind, cloudsqlpg.SourceKind, postgres.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) + } + + allParameters := tools.Parameters{ + tools.NewStringParameterWithDefault("viewname", "", "Optional: A specific view name to search for."), + tools.NewIntParameterWithDefault("limit", 50, "Optional: The maximum number of rows to return."), + } + paramManifest := allParameters.Manifest() + description := cfg.Description + if description == "" { + description = "Lists views in the database from pg_views with a default limit of 50 rows. Returns schemaname, viewname and the ownername." + } + mcpManifest := tools.GetMcpManifest(cfg.Name, description, cfg.AuthRequired, allParameters) + + // finish tool setup + return Tool{ + name: cfg.Name, + kind: kind, + authRequired: cfg.AuthRequired, + allParams: allParameters, + pool: s.PostgresPool(), + manifest: tools.Manifest{ + Description: cfg.Description, + Parameters: paramManifest, + AuthRequired: cfg.AuthRequired, + }, + mcpManifest: mcpManifest, + }, 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"` + pool *pgxpool.Pool + manifest tools.Manifest + mcpManifest tools.McpManifest +} + +func (t Tool) Invoke(ctx context.Context, params tools.ParamValues, accessToken tools.AccessToken) (any, error) { + paramsMap := params.AsMap() + + newParams, err := tools.GetParams(t.allParams, paramsMap) + if err != nil { + return nil, fmt.Errorf("unable to extract standard params %w", err) + } + sliceParams := newParams.AsSlice() + + results, err := t.pool.Query(ctx, listViewsStatement, sliceParams...) + if err != nil { + return nil, fmt.Errorf("unable to execute query: %w", err) + } + defer results.Close() + + fields := results.FieldDescriptions() + var out []map[string]any + + for results.Next() { + values, err := results.Values() + if err != nil { + return nil, fmt.Errorf("unable to parse row: %w", err) + } + rowMap := make(map[string]any) + for i, field := range fields { + rowMap[string(field.Name)] = values[i] + } + out = append(out, rowMap) + } + + return out, 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 +} diff --git a/internal/tools/postgres/postgreslistviews/postgreslistviews_test.go b/internal/tools/postgres/postgreslistviews/postgreslistviews_test.go new file mode 100644 index 0000000000..ffa234569f --- /dev/null +++ b/internal/tools/postgres/postgreslistviews/postgreslistviews_test.go @@ -0,0 +1,95 @@ +// 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 postgreslistviews_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/postgres/postgreslistviews" +) + +func TestParseFromYamlPostgresListViews(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: postgres-list-views + source: my-postgres-instance + description: some description + authRequired: + - my-google-auth-service + - other-auth-service + `, + want: server.ToolConfigs{ + "example_tool": postgreslistviews.Config{ + Name: "example_tool", + Kind: "postgres-list-views", + Source: "my-postgres-instance", + Description: "some description", + AuthRequired: []string{"my-google-auth-service", "other-auth-service"}, + }, + }, + }, + { + desc: "basic example", + in: ` + tools: + example_tool: + kind: postgres-list-views + source: my-postgres-instance + description: some description + `, + want: server.ToolConfigs{ + "example_tool": postgreslistviews.Config{ + Name: "example_tool", + Kind: "postgres-list-views", + Source: "my-postgres-instance", + Description: "some 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/postgres/postgres_integration_test.go b/tests/postgres/postgres_integration_test.go index 65897eaf32..df7bf4a9ec 100644 --- a/tests/postgres/postgres_integration_test.go +++ b/tests/postgres/postgres_integration_test.go @@ -45,6 +45,7 @@ var ( PostgresListActiveQueriesToolKind = "postgres-list-active-queries" PostgresListInstalledExtensionsToolKind = "postgres-list-installed-extensions" PostgresListAvailableExtensionsToolKind = "postgres-list-available-extensions" + PostgresListViewsToolKind = "postgres-list-views" PostgresDatabase = os.Getenv("POSTGRES_DATABASE") PostgresHost = os.Getenv("POSTGRES_HOST") PostgresPort = os.Getenv("POSTGRES_PORT") @@ -104,6 +105,11 @@ func addPrebuiltToolConfig(t *testing.T, config map[string]any) map[string]any { "description": "Lists available extensions in the database.", } + tools["list_views"] = map[string]any{ + "kind": PostgresListViewsToolKind, + "source": "my-instance", + } + config["tools"] = tools return config } @@ -189,6 +195,7 @@ func TestPostgres(t *testing.T) { // Run specific Postgres tool tests runPostgresListTablesTest(t, tableNameParam, tableNameAuth) + runPostgresListViewsTest(t, ctx, pool, tableNameParam) runPostgresListActiveQueriesTest(t, ctx, pool) runPostgresListAvailableExtensionsTest(t) runPostgresListInstalledExtensionsTest(t) @@ -525,6 +532,94 @@ func runPostgresListActiveQueriesTest(t *testing.T, ctx context.Context, pool *p wg.Wait() } +func setUpPostgresViews(t *testing.T, ctx context.Context, pool *pgxpool.Pool, viewName, tableName string) func() { + createView := fmt.Sprintf("CREATE VIEW %s AS SELECT name FROM %s", viewName, tableName) + _, err := pool.Exec(ctx, createView) + if err != nil { + t.Fatalf("failed to create view: %v", err) + } + return func() { + dropView := fmt.Sprintf("DROP VIEW %s", viewName) + _, err := pool.Exec(ctx, dropView) + if err != nil { + t.Fatalf("failed to drop view: %v", err) + } + } +} + +func runPostgresListViewsTest(t *testing.T, ctx context.Context, pool *pgxpool.Pool, tableName string) { + viewName1 := "test_view_1" + strings.ReplaceAll(uuid.New().String(), "-", "") + dropViewfunc1 := setUpPostgresViews(t, ctx, pool, viewName1, tableName) + defer dropViewfunc1() + + invokeTcs := []struct { + name string + requestBody io.Reader + wantStatusCode int + want string + }{ + { + name: "invoke list_views with newly created view", + requestBody: bytes.NewBuffer([]byte(fmt.Sprintf(`{"viewname": "%s"}`, viewName1))), + wantStatusCode: http.StatusOK, + want: fmt.Sprintf(`[{"schemaname":"public","viewname":"%s","viewowner":"postgres"}]`, viewName1), + }, + { + name: "invoke list_views with non-existent_view", + requestBody: bytes.NewBuffer([]byte(`{"viewname": "non_existent_view"}`)), + wantStatusCode: http.StatusOK, + want: `null`, + }, + } + for _, tc := range invokeTcs { + t.Run(tc.name, func(t *testing.T) { + const api = "http://127.0.0.1:5000/api/tool/list_views/invoke" + req, err := http.NewRequest(http.MethodPost, api, tc.requestBody) + if err != nil { + t.Fatalf("unable to create request: %v", err) + } + req.Header.Add("Content-type", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("unable to send request: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != tc.wantStatusCode { + body, _ := io.ReadAll(resp.Body) + t.Fatalf("wrong status code: got %d, want %d, body: %s", resp.StatusCode, tc.wantStatusCode, string(body)) + } + if tc.wantStatusCode != http.StatusOK { + return + } + + var bodyWrapper struct { + Result json.RawMessage `json:"result"` + } + if err := json.NewDecoder(resp.Body).Decode(&bodyWrapper); err != nil { + t.Fatalf("error decoding response wrapper: %v", err) + } + + var resultString string + if err := json.Unmarshal(bodyWrapper.Result, &resultString); err != nil { + resultString = string(bodyWrapper.Result) + } + + var got, want any + if err := json.Unmarshal([]byte(resultString), &got); err != nil { + t.Fatalf("failed to unmarshal nested result string: %v", err) + } + if err := json.Unmarshal([]byte(tc.want), &want); err != nil { + t.Fatalf("failed to unmarshal want string: %v", err) + } + + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("Unexpected result (-want +got):\n%s", diff) + } + }) + } +} + func runPostgresListAvailableExtensionsTest(t *testing.T) { invokeTcs := []struct { name string