feat: add tool to list store procedure (#2156)

Adds the following tools for Postgres:
(1) list_stored_procedure: Retrieves stored procedure metadata returning
schema name, procedure name, procedure owner, language, definition, and
description, filtered by optional role name (procedure owner), schema
name, and limit (default 20).

<img width="3808" height="1181" alt="image"
src="https://github.com/user-attachments/assets/43513a04-95ce-478f-a59f-3e5dafdb6b23"
/>

<img width="2654" height="1288" alt="image"
src="https://github.com/user-attachments/assets/84aca162-3779-4daa-ae2f-61620560589f"
/>


> Should include a concise description of the changes (bug or feature),
it's
> impact, along with a summary of the solution

## PR Checklist

> Thank you for opening a Pull Request! Before submitting your PR, there
are a
> few things you can do to make sure it goes smoothly:

- [x] Make sure you reviewed

[CONTRIBUTING.md](https://github.com/googleapis/genai-toolbox/blob/main/CONTRIBUTING.md)
- [x] Make sure to open an issue as a

[bug/issue](https://github.com/googleapis/genai-toolbox/issues/new/choose)
  before writing your code! That way we can discuss the change, evaluate
  designs, and agree on the general idea
- [x] Ensure the tests and linter pass
- [x] Code coverage does not decrease (if any source code was changed)
- [x] Appropriate docs were updated (if necessary)
- [x] Make sure to add `!` if this involve a breaking change

🛠️ Fixes #1738
This commit is contained in:
Siddharth Ravi
2026-01-06 14:06:45 +05:30
committed by GitHub
parent 9c62f313ff
commit cf0fc515b5
16 changed files with 736 additions and 6 deletions

View File

@@ -198,6 +198,7 @@ import (
_ "github.com/googleapis/genai-toolbox/internal/tools/postgres/postgreslistroles"
_ "github.com/googleapis/genai-toolbox/internal/tools/postgres/postgreslistschemas"
_ "github.com/googleapis/genai-toolbox/internal/tools/postgres/postgreslistsequences"
_ "github.com/googleapis/genai-toolbox/internal/tools/postgres/postgresliststoredprocedure"
_ "github.com/googleapis/genai-toolbox/internal/tools/postgres/postgreslisttables"
_ "github.com/googleapis/genai-toolbox/internal/tools/postgres/postgreslisttablespaces"
_ "github.com/googleapis/genai-toolbox/internal/tools/postgres/postgreslisttablestats"

View File

@@ -1504,7 +1504,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", "list_views", "list_schemas", "database_overview", "list_triggers", "list_indexes", "list_sequences", "long_running_transactions", "list_locks", "replication_stats", "list_query_stats", "get_column_cardinality", "list_publication_tables", "list_tablespaces", "list_pg_settings", "list_database_stats", "list_roles", "list_table_stats"},
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", "list_schemas", "database_overview", "list_triggers", "list_indexes", "list_sequences", "long_running_transactions", "list_locks", "replication_stats", "list_query_stats", "get_column_cardinality", "list_publication_tables", "list_tablespaces", "list_pg_settings", "list_database_stats", "list_roles", "list_table_stats", "list_stored_procedure"},
},
},
},
@@ -1534,7 +1534,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", "list_views", "list_schemas", "database_overview", "list_triggers", "list_indexes", "list_sequences", "long_running_transactions", "list_locks", "replication_stats", "list_query_stats", "get_column_cardinality", "list_publication_tables", "list_tablespaces", "list_pg_settings", "list_database_stats", "list_roles", "list_table_stats"},
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", "list_schemas", "database_overview", "list_triggers", "list_indexes", "list_sequences", "long_running_transactions", "list_locks", "replication_stats", "list_query_stats", "get_column_cardinality", "list_publication_tables", "list_tablespaces", "list_pg_settings", "list_database_stats", "list_roles", "list_table_stats", "list_stored_procedure"},
},
},
},
@@ -1634,7 +1634,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", "list_views", "list_schemas", "database_overview", "list_triggers", "list_indexes", "list_sequences", "long_running_transactions", "list_locks", "replication_stats", "list_query_stats", "get_column_cardinality", "list_publication_tables", "list_tablespaces", "list_pg_settings", "list_database_stats", "list_roles", "list_table_stats"},
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", "list_schemas", "database_overview", "list_triggers", "list_indexes", "list_sequences", "long_running_transactions", "list_locks", "replication_stats", "list_query_stats", "get_column_cardinality", "list_publication_tables", "list_tablespaces", "list_pg_settings", "list_database_stats", "list_roles", "list_table_stats", "list_stored_procedure"},
},
},
},

View File

@@ -94,7 +94,10 @@ cluster][alloydb-free-trial].
instance.
- [`postgres-list-roles`](../tools/postgres/postgres-list-roles.md)
Lists all the user-created roles in PostgreSQL database..
Lists all the user-created roles in PostgreSQL database.
- [`postgres-list-stored-procedure`](../tools/postgres/postgres-list-stored-procedure.md)
Lists all the stored procedure in PostgreSQL database.
### Pre-built Configurations

View File

@@ -91,7 +91,10 @@ to a database by following these instructions][csql-pg-quickstart].
instance.
- [`postgres-list-roles`](../tools/postgres/postgres-list-roles.md)
Lists all the user-created roles in PostgreSQL database..
Lists all the user-created roles in PostgreSQL database.
- [`postgres-list-stored-procedure`](../tools/postgres/postgres-list-stored-procedure.md)
Lists all the stored procedure in PostgreSQL database.
### Pre-built Configurations

View File

@@ -85,7 +85,10 @@ reputation for reliability, feature robustness, and performance.
server.
- [`postgres-list-roles`](../tools/postgres/postgres-list-roles.md)
Lists all the user-created roles in PostgreSQL database..
Lists all the user-created roles in PostgreSQL database.
- [`postgres-list-stored-procedure`](../tools/postgres/postgres-list-stored-procedure.md)
Lists all the stored procedure in PostgreSQL database.
### Pre-built Configurations

View File

@@ -0,0 +1,141 @@
---
title: "postgres-list-stored-procedure"
type: docs
weight: 1
description: >
The "postgres-list-stored-procedure" tool retrieves metadata for stored procedures in PostgreSQL, including procedure definitions, owners, languages, and descriptions.
aliases:
- /resources/tools/postgres-list-stored-procedure
---
## About
The `postgres-list-stored-procedure` tool queries PostgreSQL system catalogs (`pg_proc`, `pg_namespace`, `pg_roles`, and `pg_language`) to retrieve comprehensive metadata about stored procedures in the database. It filters for procedures (kind = 'p') and provides the full procedure definition along with ownership and language information.
Compatible sources:
- [alloydb-postgres](../../sources/alloydb-pg.md)
- [cloud-sql-postgres](../../sources/cloud-sql-pg.md)
- [postgres](../../sources/postgres.md)
The tool returns a JSON array where each element represents a stored procedure with its schema, name, owner, language, complete definition, and optional description. Results are sorted by schema name and procedure name, with a default limit of 20 procedures.
## Parameters
| parameter | type | required | default | description |
|--------------|---------|----------|---------|-------------|
| role_name | string | false | null | Optional: The owner name to filter stored procedures by (supports partial matching) |
| schema_name | string | false | null | Optional: The schema name to filter stored procedures by (supports partial matching) |
| limit | integer | false | 20 | Optional: The maximum number of stored procedures to return |
## Example
```yaml
tools:
list_stored_procedure:
kind: postgres-list-stored-procedure
source: postgres-source
description: "Retrieves stored procedure metadata including definitions and owners."
```
### Example Requests
**List all stored procedures (default limit 20):**
```json
{}
```
**Filter by specific owner (role):**
```json
{
"role_name": "app_user"
}
```
**Filter by schema:**
```json
{
"schema_name": "public"
}
```
**Filter by owner and schema with custom limit:**
```json
{
"role_name": "postgres",
"schema_name": "public",
"limit": 50
}
```
**Filter by partial schema name:**
```json
{
"schema_name": "audit"
}
```
### Example Response
```json
[
{
"schema_name": "public",
"name": "process_payment",
"owner": "postgres",
"language": "plpgsql",
"definition": "CREATE OR REPLACE PROCEDURE public.process_payment(p_order_id integer, p_amount numeric)\n LANGUAGE plpgsql\nAS $procedure$\nBEGIN\n UPDATE orders SET status = 'paid', amount = p_amount WHERE id = p_order_id;\n INSERT INTO payment_log (order_id, amount, timestamp) VALUES (p_order_id, p_amount, now());\n COMMIT;\nEND\n$procedure$",
"description": "Processes payment for an order and logs the transaction"
},
{
"schema_name": "public",
"name": "cleanup_old_records",
"owner": "postgres",
"language": "plpgsql",
"definition": "CREATE OR REPLACE PROCEDURE public.cleanup_old_records(p_days_old integer)\n LANGUAGE plpgsql\nAS $procedure$\nDECLARE\n v_deleted integer;\nBEGIN\n DELETE FROM audit_logs WHERE created_at < now() - (p_days_old || ' days')::interval;\n GET DIAGNOSTICS v_deleted = ROW_COUNT;\n RAISE NOTICE 'Deleted % records', v_deleted;\nEND\n$procedure$",
"description": "Removes audit log records older than specified days"
},
{
"schema_name": "audit",
"name": "audit_table_changes",
"owner": "app_user",
"language": "plpgsql",
"definition": "CREATE OR REPLACE PROCEDURE audit.audit_table_changes()\n LANGUAGE plpgsql\nAS $procedure$\nBEGIN\n INSERT INTO audit.change_log (table_name, operation, changed_at) VALUES (TG_TABLE_NAME, TG_OP, now());\nEND\n$procedure$",
"description": null
}
]
```
## Output Fields Reference
| field | type | description |
|-------------|---------|-------------|
| schema_name | string | Name of the schema containing the stored procedure. |
| name | string | Name of the stored procedure. |
| owner | string | PostgreSQL role/user who owns the stored procedure. |
| language | string | Programming language in which the procedure is written (e.g., plpgsql, sql, c). |
| definition | string | Complete SQL definition of the stored procedure, including the CREATE PROCEDURE statement. |
| description | string | Optional description or comment for the procedure (may be null if no comment is set). |
## Use Cases
- **Code review and auditing**: Export procedure definitions for version control or compliance audits.
- **Documentation generation**: Automatically extract procedure metadata and descriptions for documentation.
- **Permission auditing**: Identify procedures owned by specific users or in specific schemas.
- **Migration planning**: Retrieve all procedure definitions when planning database migrations.
- **Dependency analysis**: Review procedure definitions to understand dependencies and call chains.
- **Security assessment**: Audit which roles own and can modify stored procedures.
## Performance Considerations
- The tool filters at the database level using LIKE pattern matching, so partial matches are supported.
- Procedure definitions can be large; consider using the `limit` parameter for large databases with many procedures.
- Results are ordered by schema name and procedure name for consistent output.
- The default limit of 20 procedures is suitable for most use cases; increase as needed.
## Notes
- Only stored **procedures** are returned; functions and other callable objects are excluded via the `prokind = 'p'` filter.
- Filtering uses `LIKE` pattern matching, so filter values support partial matches (e.g., `role_name: "app"` will match "app_user", "app_admin", etc.).
- The `definition` field contains the complete, runnable CREATE PROCEDURE statement.
- The `description` field is populated from comments set via PostgreSQL's COMMENT command and may be null.

View File

@@ -224,6 +224,10 @@ tools:
kind: postgres-list-roles
source: alloydb-pg-source
list_stored_procedure:
kind: postgres-list-stored-procedure
source: alloydb-pg-source
toolsets:
alloydb_postgres_database_tools:
- execute_sql
@@ -254,3 +258,4 @@ toolsets:
- list_database_stats
- list_roles
- list_table_stats
- list_stored_procedure

View File

@@ -226,6 +226,10 @@ tools:
kind: postgres-list-roles
source: cloudsql-pg-source
list_stored_procedure:
kind: postgres-list-stored-procedure
source: cloudsql-pg-source
toolsets:
cloud_sql_postgres_database_tools:
- execute_sql
@@ -256,3 +260,4 @@ toolsets:
- list_database_stats
- list_roles
- list_table_stats
- list_stored_procedure

View File

@@ -225,6 +225,10 @@ tools:
kind: postgres-list-roles
source: postgresql-source
list_stored_procedure:
kind: postgres-list-stored-procedure
source: postgresql-source
toolsets:
postgres_database_tools:
- execute_sql
@@ -255,3 +259,4 @@ toolsets:
- list_database_stats
- list_roles
- list_table_stats
- list_stored_procedure

View File

@@ -0,0 +1,209 @@
// Copyright 2026 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 postgresliststoredprocedure
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/googleapis/genai-toolbox/internal/util/parameters"
"github.com/jackc/pgx/v5/pgxpool"
)
const kind string = "postgres-list-stored-procedure"
const listStoredProcedure = `
SELECT
n.nspname AS schema_name,
p.proname AS name,
r.rolname AS owner,
l.lanname AS language,
pg_catalog.pg_get_functiondef(p.oid) AS definition,
pg_catalog.obj_description(p.oid, 'pg_proc') AS description
FROM pg_catalog.pg_proc p
JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace
JOIN pg_catalog.pg_roles r ON r.oid = p.proowner
JOIN pg_catalog.pg_language l ON l.oid = p.prolang
WHERE
p.prokind = 'p' AND
($1::text IS NULL OR r.rolname LIKE '%' || $1::text || '%') AND
($2::text IS NULL OR n.nspname LIKE '%' || $2::text || '%')
ORDER BY n.nspname, p.proname
LIMIT
COALESCE($3::int, 20);
`
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 := parameters.Parameters{
parameters.NewStringParameterWithRequired("role_name", "Optional: The owner name to filter the stored procedures by. Defaults to NULL.", false),
parameters.NewStringParameterWithRequired("schema_name", "Optional: The schema name to filter the stored procedures by. Defaults to NULL.", false),
parameters.NewIntParameterWithDefault("limit", 20, "Optional: The maximum number of stored procedures to return. Defaults to 20."),
}
paramManifest := allParameters.Manifest()
if cfg.Description == "" {
cfg.Description = "Retrieves stored procedure metadata returning schema name, procedure name, procedure owner, language, definition, and description, filtered by optional role name (procedure owner), schema name, and limit (default 20)."
}
mcpManifest := tools.GetMcpManifest(cfg.Name, cfg.Description, cfg.AuthRequired, allParameters, nil)
// finish tool setup
return Tool{
name: cfg.Name,
kind: cfg.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 {
Config
name string `yaml:"name"`
kind string `yaml:"kind"`
authRequired []string `yaml:"authRequired"`
allParams parameters.Parameters `yaml:"allParams"`
pool *pgxpool.Pool
manifest tools.Manifest
mcpManifest tools.McpManifest
}
func (t Tool) ToConfig() tools.ToolConfig {
return t.Config
}
func (t Tool) Invoke(ctx context.Context, resourceMgr tools.SourceProvider, params parameters.ParamValues, accessToken tools.AccessToken) (any, error) {
paramsMap := params.AsMap()
newParams, err := parameters.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, listStoredProcedure, 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) (parameters.ParamValues, error) {
return parameters.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(resourceMgr tools.SourceProvider) (bool, error) {
return false, nil
}
func (t Tool) GetAuthTokenHeaderName(resourceMgr tools.SourceProvider) (string, error) {
return "Authorization", nil
}

View File

@@ -0,0 +1,95 @@
// Copyright 2026 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 postgresliststoredprocedure_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/postgresliststoredprocedure"
)
func TestParseFromYamlPostgresListStoredProcedure(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-stored-procedure
source: my-postgres-instance
description: some description
authRequired:
- my-google-auth-service
- other-auth-service
`,
want: server.ToolConfigs{
"example_tool": postgresliststoredprocedure.Config{
Name: "example_tool",
Kind: "postgres-list-stored-procedure",
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-stored-procedure
source: my-postgres-instance
description: some description
`,
want: server.ToolConfigs{
"example_tool": postgresliststoredprocedure.Config{
Name: "example_tool",
Kind: "postgres-list-stored-procedure",
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)
}
})
}
}

View File

@@ -201,6 +201,7 @@ func TestAlloyDBPgToolEndpoints(t *testing.T) {
tests.RunPostgresListPgSettingsTest(t, ctx, pool)
tests.RunPostgresListDatabaseStatsTest(t, ctx, pool)
tests.RunPostgresListRolesTest(t, ctx, pool)
tests.RunPostgresListStoredProcedureTest(t, ctx, pool)
}
// Test connection with different IP type

View File

@@ -185,6 +185,7 @@ func TestCloudSQLPgSimpleToolEndpoints(t *testing.T) {
tests.RunPostgresListPgSettingsTest(t, ctx, pool)
tests.RunPostgresListDatabaseStatsTest(t, ctx, pool)
tests.RunPostgresListRolesTest(t, ctx, pool)
tests.RunPostgresListStoredProcedureTest(t, ctx, pool)
}
// Test connection with different IP type

View File

@@ -213,6 +213,7 @@ func AddPostgresPrebuiltConfig(t *testing.T, config map[string]any) map[string]a
PostgresListPGSettingsToolKind = "postgres-list-pg-settings"
PostgresListDatabaseStatsToolKind = "postgres-list-database-stats"
PostgresListRolesToolKind = "postgres-list-roles"
PostgresListStoredProcedureToolKind = "postgres-list-stored-procedure"
)
tools, ok := config["tools"].(map[string]any)
@@ -310,6 +311,11 @@ func AddPostgresPrebuiltConfig(t *testing.T, config map[string]any) map[string]a
"kind": PostgresListRolesToolKind,
"source": "my-instance",
}
tools["list_stored_procedure"] = map[string]any{
"kind": PostgresListStoredProcedureToolKind,
"source": "my-instance",
}
config["tools"] = tools
return config
}

View File

@@ -164,4 +164,5 @@ func TestPostgres(t *testing.T) {
tests.RunPostgresListPgSettingsTest(t, ctx, pool)
tests.RunPostgresListDatabaseStatsTest(t, ctx, pool)
tests.RunPostgresListRolesTest(t, ctx, pool)
tests.RunPostgresListStoredProcedureTest(t, ctx, pool)
}

View File

@@ -4390,6 +4390,257 @@ func RunPostgresListTableStatsTest(t *testing.T, ctx context.Context, pool *pgxp
}
}
// RunPostgresListStoredProcedureTest runs tests for the postgres list-stored-procedure tool
func RunPostgresListStoredProcedureTest(t *testing.T, ctx context.Context, pool *pgxpool.Pool) {
type storedProcedureDetails struct {
SchemaName string `json:"schema_name"`
Name string `json:"name"`
Owner string `json:"owner"`
Language string `json:"language"`
Definition string `json:"definition"`
Description any `json:"description"`
}
// Create test schema
testSchemaName := "test_proc_schema_" + strings.ReplaceAll(uuid.New().String(), "-", "")
createSchemaStmt := fmt.Sprintf("CREATE SCHEMA %s", testSchemaName)
if _, err := pool.Exec(ctx, createSchemaStmt); err != nil {
t.Fatalf("unable to create test schema: %v", err)
}
defer func() {
dropSchemaStmt := fmt.Sprintf("DROP SCHEMA IF EXISTS %s CASCADE", testSchemaName)
if _, err := pool.Exec(ctx, dropSchemaStmt); err != nil {
t.Logf("warning: unable to drop test schema: %v", err)
}
}()
// Create test procedures
proc1Name := "test_proc_1_" + strings.ReplaceAll(uuid.New().String(), "-", "")
createProc1Stmt := fmt.Sprintf(`
CREATE PROCEDURE %s.%s(p_count INT)
LANGUAGE plpgsql
AS $$
BEGIN
INSERT INTO test_table VALUES (p_count);
COMMIT;
END;
$$
`, testSchemaName, proc1Name)
if _, err := pool.Exec(ctx, createProc1Stmt); err != nil {
t.Fatalf("unable to create test procedure 1: %v", err)
}
// Add a comment/description to the procedure
commentStmt := fmt.Sprintf("COMMENT ON PROCEDURE %s.%s(INT) IS 'Test procedure that inserts a record'", testSchemaName, proc1Name)
if _, err := pool.Exec(ctx, commentStmt); err != nil {
t.Logf("warning: unable to add comment to procedure: %v", err)
}
// Create a second test procedure
proc2Name := "test_proc_2_" + strings.ReplaceAll(uuid.New().String(), "-", "")
createProc2Stmt := fmt.Sprintf(`
CREATE PROCEDURE %s.%s()
LANGUAGE plpgsql
AS $$
DECLARE
v_count INT;
BEGIN
SELECT COUNT(*) INTO v_count FROM test_table;
RAISE NOTICE 'Total records: %%', v_count;
END;
$$
`, testSchemaName, proc2Name)
if _, err := pool.Exec(ctx, createProc2Stmt); err != nil {
t.Fatalf("unable to create test procedure 2: %v", err)
}
invokeTcs := []struct {
name string
requestBody io.Reader
wantStatusCode int
shouldHaveData bool
expectedCount int
filterByRole string
filterBySchema string
}{
{
name: "list stored procedures with no arguments (default limit 20)",
requestBody: bytes.NewBufferString(`{}`),
wantStatusCode: http.StatusOK,
shouldHaveData: false, // may or may not have data depending on what's in the database
},
{
name: "list stored procedures filtering by specific schema",
requestBody: bytes.NewBufferString(fmt.Sprintf(`{"schema_name": "%s"}`, testSchemaName)),
wantStatusCode: http.StatusOK,
shouldHaveData: true,
expectedCount: 2,
filterBySchema: testSchemaName,
},
{
name: "list stored procedures filtering by procedure owner (postgres)",
requestBody: bytes.NewBufferString(`{"role_name": "postgres"}`),
wantStatusCode: http.StatusOK,
shouldHaveData: false, // might have procedures owned by postgres
},
{
name: "list stored procedures with custom limit",
requestBody: bytes.NewBufferString(`{"limit": 5}`),
wantStatusCode: http.StatusOK,
shouldHaveData: false,
},
{
name: "list stored procedures filtering by schema and role",
requestBody: bytes.NewBufferString(fmt.Sprintf(`{"schema_name": "%s", "role_name": "postgres"}`, testSchemaName)),
wantStatusCode: http.StatusOK,
shouldHaveData: true,
expectedCount: 2,
filterBySchema: testSchemaName,
filterByRole: "postgres",
},
{
name: "list stored procedures with non-existent schema",
requestBody: bytes.NewBufferString(`{"schema_name": "non_existent_schema_xyz"}`),
wantStatusCode: http.StatusOK,
shouldHaveData: false,
},
{
name: "list stored procedures with non-existent role",
requestBody: bytes.NewBufferString(`{"role_name": "non_existent_role_xyz"}`),
wantStatusCode: http.StatusOK,
shouldHaveData: false,
},
{
name: "list stored procedures with partial schema name match",
requestBody: bytes.NewBufferString(`{"schema_name": "test_proc"}`),
wantStatusCode: http.StatusOK,
shouldHaveData: true,
expectedCount: 2,
},
{
name: "list stored procedures with limit 1",
requestBody: bytes.NewBufferString(fmt.Sprintf(`{"schema_name": "%s", "limit": 1}`, testSchemaName)),
wantStatusCode: http.StatusOK,
shouldHaveData: true,
expectedCount: 1,
filterBySchema: testSchemaName,
},
}
for _, tc := range invokeTcs {
t.Run(tc.name, func(t *testing.T) {
const api = "http://127.0.0.1:5000/api/tool/list_stored_procedure/invoke"
resp, respBody := RunRequest(t, http.MethodPost, api, tc.requestBody, nil)
if resp.StatusCode != tc.wantStatusCode {
t.Fatalf("wrong status code: got %d, want %d, body: %s", resp.StatusCode, tc.wantStatusCode, string(respBody))
}
if tc.wantStatusCode != http.StatusOK {
return
}
var bodyWrapper struct {
Result json.RawMessage `json:"result"`
}
if err := json.Unmarshal(respBody, &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 []storedProcedureDetails
if resultString != "null" {
if err := json.Unmarshal([]byte(resultString), &got); err != nil {
t.Fatalf("failed to unmarshal result: %v, result string: %s", err, resultString)
}
}
// Verify expected data presence
if tc.shouldHaveData {
if len(got) == 0 {
t.Fatalf("expected data but got empty result")
}
// If filtering by schema, verify all results are from that schema
if tc.filterBySchema != "" {
for _, proc := range got {
if proc.SchemaName != tc.filterBySchema && !strings.Contains(proc.SchemaName, tc.filterBySchema) {
t.Errorf("procedure schema %s does not match filter %s", proc.SchemaName, tc.filterBySchema)
}
}
}
// If filtering by role, verify all results are owned by that role
if tc.filterByRole != "" {
for _, proc := range got {
if proc.Owner != tc.filterByRole {
t.Errorf("procedure owner %s does not match filter %s", proc.Owner, tc.filterByRole)
}
}
}
// Verify expected count if specified
if tc.expectedCount > 0 && len(got) != tc.expectedCount {
t.Errorf("expected %d procedures but got %d", tc.expectedCount, len(got))
}
}
// Verify result structure and data types
for _, proc := range got {
// Verify all required fields are present and non-empty
if proc.SchemaName == "" {
t.Errorf("schema_name should not be empty")
}
if proc.Name == "" {
t.Errorf("procedure name should not be empty")
}
if proc.Owner == "" {
t.Errorf("owner should not be empty")
}
if proc.Language == "" {
t.Errorf("language should not be empty")
}
if proc.Definition == "" {
t.Errorf("definition should not be empty")
}
// Verify definition contains CREATE PROCEDURE
if !strings.Contains(proc.Definition, "CREATE PROCEDURE") {
t.Logf("warning: definition may not be a valid CREATE PROCEDURE statement: %s", proc.Definition)
}
// Verify language is a valid PostgreSQL language
validLanguages := []string{"plpgsql", "sql", "c", "internal", "plperl", "pltcl", "plpython"}
found := false
for _, lang := range validLanguages {
if proc.Language == lang {
found = true
break
}
}
if !found {
t.Logf("warning: language %s may not be a standard PostgreSQL language", proc.Language)
}
}
// Verify results are sorted by schema_name and name
if len(got) > 1 {
for i := 0; i < len(got)-1; i++ {
currentKey := fmt.Sprintf("%s.%s", got[i].SchemaName, got[i].Name)
nextKey := fmt.Sprintf("%s.%s", got[i+1].SchemaName, got[i+1].Name)
if currentKey > nextKey {
t.Logf("warning: results may not be sorted by schema_name and name")
}
}
}
})
}
}
// 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