This is page 57 of 59. Use http://codebase.md/googleapis/genai-toolbox?lines=true&page={x} to view the full context.
# Directory Structure
```
├── .ci
│ ├── continuous.release.cloudbuild.yaml
│ ├── generate_release_table.sh
│ ├── integration.cloudbuild.yaml
│ ├── quickstart_test
│ │ ├── go.integration.cloudbuild.yaml
│ │ ├── js.integration.cloudbuild.yaml
│ │ ├── py.integration.cloudbuild.yaml
│ │ ├── run_go_tests.sh
│ │ ├── run_js_tests.sh
│ │ ├── run_py_tests.sh
│ │ └── setup_hotels_sample.sql
│ ├── test_with_coverage.sh
│ └── versioned.release.cloudbuild.yaml
├── .github
│ ├── auto-label.yaml
│ ├── blunderbuss.yml
│ ├── CODEOWNERS
│ ├── header-checker-lint.yml
│ ├── ISSUE_TEMPLATE
│ │ ├── bug_report.yml
│ │ ├── config.yml
│ │ ├── feature_request.yml
│ │ └── question.yml
│ ├── label-sync.yml
│ ├── labels.yaml
│ ├── PULL_REQUEST_TEMPLATE.md
│ ├── release-please.yml
│ ├── renovate.json5
│ ├── sync-repo-settings.yaml
│ └── workflows
│ ├── cloud_build_failure_reporter.yml
│ ├── deploy_dev_docs.yaml
│ ├── deploy_previous_version_docs.yaml
│ ├── deploy_versioned_docs.yaml
│ ├── docs_deploy.yaml
│ ├── docs_preview_clean.yaml
│ ├── docs_preview_deploy.yaml
│ ├── lint.yaml
│ ├── schedule_reporter.yml
│ ├── sync-labels.yaml
│ └── tests.yaml
├── .gitignore
├── .gitmodules
├── .golangci.yaml
├── .hugo
│ ├── archetypes
│ │ └── default.md
│ ├── assets
│ │ ├── icons
│ │ │ └── logo.svg
│ │ └── scss
│ │ ├── _styles_project.scss
│ │ └── _variables_project.scss
│ ├── go.mod
│ ├── go.sum
│ ├── hugo.toml
│ ├── layouts
│ │ ├── _default
│ │ │ └── home.releases.releases
│ │ ├── index.llms-full.txt
│ │ ├── index.llms.txt
│ │ ├── partials
│ │ │ ├── hooks
│ │ │ │ └── head-end.html
│ │ │ ├── navbar-version-selector.html
│ │ │ ├── page-meta-links.html
│ │ │ └── td
│ │ │ └── render-heading.html
│ │ ├── robot.txt
│ │ └── shortcodes
│ │ ├── include.html
│ │ ├── ipynb.html
│ │ └── regionInclude.html
│ ├── package-lock.json
│ ├── package.json
│ └── static
│ ├── favicons
│ │ ├── android-chrome-192x192.png
│ │ ├── android-chrome-512x512.png
│ │ ├── apple-touch-icon.png
│ │ ├── favicon-16x16.png
│ │ ├── favicon-32x32.png
│ │ └── favicon.ico
│ └── js
│ └── w3.js
├── CHANGELOG.md
├── cmd
│ ├── options_test.go
│ ├── options.go
│ ├── root_test.go
│ ├── root.go
│ └── version.txt
├── CODE_OF_CONDUCT.md
├── CONTRIBUTING.md
├── DEVELOPER.md
├── Dockerfile
├── docs
│ └── en
│ ├── _index.md
│ ├── about
│ │ ├── _index.md
│ │ └── faq.md
│ ├── concepts
│ │ ├── _index.md
│ │ └── telemetry
│ │ ├── index.md
│ │ ├── telemetry_flow.png
│ │ └── telemetry_traces.png
│ ├── getting-started
│ │ ├── _index.md
│ │ ├── colab_quickstart.ipynb
│ │ ├── configure.md
│ │ ├── introduction
│ │ │ ├── _index.md
│ │ │ └── architecture.png
│ │ ├── local_quickstart_go.md
│ │ ├── local_quickstart_js.md
│ │ ├── local_quickstart.md
│ │ ├── mcp_quickstart
│ │ │ ├── _index.md
│ │ │ ├── inspector_tools.png
│ │ │ └── inspector.png
│ │ └── quickstart
│ │ ├── go
│ │ │ ├── adkgo
│ │ │ │ ├── go.mod
│ │ │ │ ├── go.sum
│ │ │ │ └── quickstart.go
│ │ │ ├── genAI
│ │ │ │ ├── go.mod
│ │ │ │ ├── go.sum
│ │ │ │ └── quickstart.go
│ │ │ ├── genkit
│ │ │ │ ├── go.mod
│ │ │ │ ├── go.sum
│ │ │ │ └── quickstart.go
│ │ │ ├── langchain
│ │ │ │ ├── go.mod
│ │ │ │ ├── go.sum
│ │ │ │ └── quickstart.go
│ │ │ ├── openAI
│ │ │ │ ├── go.mod
│ │ │ │ ├── go.sum
│ │ │ │ └── quickstart.go
│ │ │ └── quickstart_test.go
│ │ ├── golden.txt
│ │ ├── js
│ │ │ ├── genAI
│ │ │ │ ├── package-lock.json
│ │ │ │ ├── package.json
│ │ │ │ └── quickstart.js
│ │ │ ├── genkit
│ │ │ │ ├── package-lock.json
│ │ │ │ ├── package.json
│ │ │ │ └── quickstart.js
│ │ │ ├── langchain
│ │ │ │ ├── package-lock.json
│ │ │ │ ├── package.json
│ │ │ │ └── quickstart.js
│ │ │ ├── llamaindex
│ │ │ │ ├── package-lock.json
│ │ │ │ ├── package.json
│ │ │ │ └── quickstart.js
│ │ │ └── quickstart.test.js
│ │ ├── python
│ │ │ ├── __init__.py
│ │ │ ├── adk
│ │ │ │ ├── quickstart.py
│ │ │ │ └── requirements.txt
│ │ │ ├── core
│ │ │ │ ├── quickstart.py
│ │ │ │ └── requirements.txt
│ │ │ ├── langchain
│ │ │ │ ├── quickstart.py
│ │ │ │ └── requirements.txt
│ │ │ ├── llamaindex
│ │ │ │ ├── quickstart.py
│ │ │ │ └── requirements.txt
│ │ │ └── quickstart_test.py
│ │ └── shared
│ │ ├── cloud_setup.md
│ │ ├── configure_toolbox.md
│ │ └── database_setup.md
│ ├── how-to
│ │ ├── _index.md
│ │ ├── connect_via_geminicli.md
│ │ ├── connect_via_mcp.md
│ │ ├── connect-ide
│ │ │ ├── _index.md
│ │ │ ├── alloydb_pg_admin_mcp.md
│ │ │ ├── alloydb_pg_mcp.md
│ │ │ ├── bigquery_mcp.md
│ │ │ ├── cloud_sql_mssql_admin_mcp.md
│ │ │ ├── cloud_sql_mssql_mcp.md
│ │ │ ├── cloud_sql_mysql_admin_mcp.md
│ │ │ ├── cloud_sql_mysql_mcp.md
│ │ │ ├── cloud_sql_pg_admin_mcp.md
│ │ │ ├── cloud_sql_pg_mcp.md
│ │ │ ├── firestore_mcp.md
│ │ │ ├── looker_mcp.md
│ │ │ ├── mssql_mcp.md
│ │ │ ├── mysql_mcp.md
│ │ │ ├── neo4j_mcp.md
│ │ │ ├── postgres_mcp.md
│ │ │ ├── spanner_mcp.md
│ │ │ └── sqlite_mcp.md
│ │ ├── deploy_docker.md
│ │ ├── deploy_gke.md
│ │ ├── deploy_toolbox.md
│ │ ├── export_telemetry.md
│ │ └── toolbox-ui
│ │ ├── edit-headers.gif
│ │ ├── edit-headers.png
│ │ ├── index.md
│ │ ├── optional-param-checked.png
│ │ ├── optional-param-unchecked.png
│ │ ├── run-tool.gif
│ │ ├── tools.png
│ │ └── toolsets.png
│ ├── reference
│ │ ├── _index.md
│ │ ├── cli.md
│ │ └── prebuilt-tools.md
│ ├── resources
│ │ ├── _index.md
│ │ ├── authServices
│ │ │ ├── _index.md
│ │ │ └── google.md
│ │ ├── sources
│ │ │ ├── _index.md
│ │ │ ├── alloydb-admin.md
│ │ │ ├── alloydb-pg.md
│ │ │ ├── bigquery.md
│ │ │ ├── bigtable.md
│ │ │ ├── cassandra.md
│ │ │ ├── clickhouse.md
│ │ │ ├── cloud-healthcare.md
│ │ │ ├── cloud-monitoring.md
│ │ │ ├── cloud-sql-admin.md
│ │ │ ├── cloud-sql-mssql.md
│ │ │ ├── cloud-sql-mysql.md
│ │ │ ├── cloud-sql-pg.md
│ │ │ ├── couchbase.md
│ │ │ ├── dataplex.md
│ │ │ ├── dgraph.md
│ │ │ ├── elasticsearch.md
│ │ │ ├── firebird.md
│ │ │ ├── firestore.md
│ │ │ ├── http.md
│ │ │ ├── looker.md
│ │ │ ├── mindsdb.md
│ │ │ ├── mongodb.md
│ │ │ ├── mssql.md
│ │ │ ├── mysql.md
│ │ │ ├── neo4j.md
│ │ │ ├── oceanbase.md
│ │ │ ├── oracle.md
│ │ │ ├── postgres.md
│ │ │ ├── redis.md
│ │ │ ├── serverless-spark.md
│ │ │ ├── singlestore.md
│ │ │ ├── spanner.md
│ │ │ ├── sqlite.md
│ │ │ ├── tidb.md
│ │ │ ├── trino.md
│ │ │ ├── valkey.md
│ │ │ └── yugabytedb.md
│ │ └── tools
│ │ ├── _index.md
│ │ ├── alloydb
│ │ │ ├── _index.md
│ │ │ ├── alloydb-create-cluster.md
│ │ │ ├── alloydb-create-instance.md
│ │ │ ├── alloydb-create-user.md
│ │ │ ├── alloydb-get-cluster.md
│ │ │ ├── alloydb-get-instance.md
│ │ │ ├── alloydb-get-user.md
│ │ │ ├── alloydb-list-clusters.md
│ │ │ ├── alloydb-list-instances.md
│ │ │ ├── alloydb-list-users.md
│ │ │ └── alloydb-wait-for-operation.md
│ │ ├── alloydbainl
│ │ │ ├── _index.md
│ │ │ └── alloydb-ai-nl.md
│ │ ├── bigquery
│ │ │ ├── _index.md
│ │ │ ├── bigquery-analyze-contribution.md
│ │ │ ├── bigquery-conversational-analytics.md
│ │ │ ├── bigquery-execute-sql.md
│ │ │ ├── bigquery-forecast.md
│ │ │ ├── bigquery-get-dataset-info.md
│ │ │ ├── bigquery-get-table-info.md
│ │ │ ├── bigquery-list-dataset-ids.md
│ │ │ ├── bigquery-list-table-ids.md
│ │ │ ├── bigquery-search-catalog.md
│ │ │ └── bigquery-sql.md
│ │ ├── bigtable
│ │ │ ├── _index.md
│ │ │ └── bigtable-sql.md
│ │ ├── cassandra
│ │ │ ├── _index.md
│ │ │ └── cassandra-cql.md
│ │ ├── clickhouse
│ │ │ ├── _index.md
│ │ │ ├── clickhouse-execute-sql.md
│ │ │ ├── clickhouse-list-databases.md
│ │ │ ├── clickhouse-list-tables.md
│ │ │ └── clickhouse-sql.md
│ │ ├── cloudhealthcare
│ │ │ ├── _index.md
│ │ │ ├── cloud-healthcare-fhir-fetch-page.md
│ │ │ ├── cloud-healthcare-fhir-patient-everything.md
│ │ │ ├── cloud-healthcare-fhir-patient-search.md
│ │ │ ├── cloud-healthcare-get-dataset.md
│ │ │ ├── cloud-healthcare-get-dicom-store-metrics.md
│ │ │ ├── cloud-healthcare-get-dicom-store.md
│ │ │ ├── cloud-healthcare-get-fhir-resource.md
│ │ │ ├── cloud-healthcare-get-fhir-store-metrics.md
│ │ │ ├── cloud-healthcare-get-fhir-store.md
│ │ │ ├── cloud-healthcare-list-dicom-stores.md
│ │ │ ├── cloud-healthcare-list-fhir-stores.md
│ │ │ ├── cloud-healthcare-retrieve-rendered-dicom-instance.md
│ │ │ ├── cloud-healthcare-search-dicom-instances.md
│ │ │ ├── cloud-healthcare-search-dicom-series.md
│ │ │ └── cloud-healthcare-search-dicom-studies.md
│ │ ├── cloudmonitoring
│ │ │ ├── _index.md
│ │ │ └── cloud-monitoring-query-prometheus.md
│ │ ├── cloudsql
│ │ │ ├── _index.md
│ │ │ ├── cloudsqlcreatedatabase.md
│ │ │ ├── cloudsqlcreateusers.md
│ │ │ ├── cloudsqlgetinstances.md
│ │ │ ├── cloudsqllistdatabases.md
│ │ │ ├── cloudsqllistinstances.md
│ │ │ ├── cloudsqlmssqlcreateinstance.md
│ │ │ ├── cloudsqlmysqlcreateinstance.md
│ │ │ ├── cloudsqlpgcreateinstances.md
│ │ │ └── cloudsqlwaitforoperation.md
│ │ ├── couchbase
│ │ │ ├── _index.md
│ │ │ └── couchbase-sql.md
│ │ ├── dataform
│ │ │ ├── _index.md
│ │ │ └── dataform-compile-local.md
│ │ ├── dataplex
│ │ │ ├── _index.md
│ │ │ ├── dataplex-lookup-entry.md
│ │ │ ├── dataplex-search-aspect-types.md
│ │ │ └── dataplex-search-entries.md
│ │ ├── dgraph
│ │ │ ├── _index.md
│ │ │ └── dgraph-dql.md
│ │ ├── elasticsearch
│ │ │ ├── _index.md
│ │ │ └── elasticsearch-esql.md
│ │ ├── firebird
│ │ │ ├── _index.md
│ │ │ ├── firebird-execute-sql.md
│ │ │ └── firebird-sql.md
│ │ ├── firestore
│ │ │ ├── _index.md
│ │ │ ├── firestore-add-documents.md
│ │ │ ├── firestore-delete-documents.md
│ │ │ ├── firestore-get-documents.md
│ │ │ ├── firestore-get-rules.md
│ │ │ ├── firestore-list-collections.md
│ │ │ ├── firestore-query-collection.md
│ │ │ ├── firestore-query.md
│ │ │ ├── firestore-update-document.md
│ │ │ └── firestore-validate-rules.md
│ │ ├── http
│ │ │ ├── _index.md
│ │ │ └── http.md
│ │ ├── looker
│ │ │ ├── _index.md
│ │ │ ├── looker-add-dashboard-element.md
│ │ │ ├── looker-conversational-analytics.md
│ │ │ ├── looker-create-project-file.md
│ │ │ ├── looker-delete-project-file.md
│ │ │ ├── looker-dev-mode.md
│ │ │ ├── looker-get-connection-databases.md
│ │ │ ├── looker-get-connection-schemas.md
│ │ │ ├── looker-get-connection-table-columns.md
│ │ │ ├── looker-get-connection-tables.md
│ │ │ ├── looker-get-connections.md
│ │ │ ├── looker-get-dashboards.md
│ │ │ ├── looker-get-dimensions.md
│ │ │ ├── looker-get-explores.md
│ │ │ ├── looker-get-filters.md
│ │ │ ├── looker-get-looks.md
│ │ │ ├── looker-get-measures.md
│ │ │ ├── looker-get-models.md
│ │ │ ├── looker-get-parameters.md
│ │ │ ├── looker-get-project-file.md
│ │ │ ├── looker-get-project-files.md
│ │ │ ├── looker-get-projects.md
│ │ │ ├── looker-health-analyze.md
│ │ │ ├── looker-health-pulse.md
│ │ │ ├── looker-health-vacuum.md
│ │ │ ├── looker-make-dashboard.md
│ │ │ ├── looker-make-look.md
│ │ │ ├── looker-query-sql.md
│ │ │ ├── looker-query-url.md
│ │ │ ├── looker-query.md
│ │ │ ├── looker-run-dashboard.md
│ │ │ ├── looker-run-look.md
│ │ │ └── looker-update-project-file.md
│ │ ├── mindsdb
│ │ │ ├── _index.md
│ │ │ ├── mindsdb-execute-sql.md
│ │ │ └── mindsdb-sql.md
│ │ ├── mongodb
│ │ │ ├── _index.md
│ │ │ ├── mongodb-aggregate.md
│ │ │ ├── mongodb-delete-many.md
│ │ │ ├── mongodb-delete-one.md
│ │ │ ├── mongodb-find-one.md
│ │ │ ├── mongodb-find.md
│ │ │ ├── mongodb-insert-many.md
│ │ │ ├── mongodb-insert-one.md
│ │ │ ├── mongodb-update-many.md
│ │ │ └── mongodb-update-one.md
│ │ ├── mssql
│ │ │ ├── _index.md
│ │ │ ├── mssql-execute-sql.md
│ │ │ ├── mssql-list-tables.md
│ │ │ └── mssql-sql.md
│ │ ├── mysql
│ │ │ ├── _index.md
│ │ │ ├── mysql-execute-sql.md
│ │ │ ├── mysql-list-active-queries.md
│ │ │ ├── mysql-list-table-fragmentation.md
│ │ │ ├── mysql-list-tables-missing-unique-indexes.md
│ │ │ ├── mysql-list-tables.md
│ │ │ └── mysql-sql.md
│ │ ├── neo4j
│ │ │ ├── _index.md
│ │ │ ├── neo4j-cypher.md
│ │ │ ├── neo4j-execute-cypher.md
│ │ │ └── neo4j-schema.md
│ │ ├── oceanbase
│ │ │ ├── _index.md
│ │ │ ├── oceanbase-execute-sql.md
│ │ │ └── oceanbase-sql.md
│ │ ├── oracle
│ │ │ ├── _index.md
│ │ │ ├── oracle-execute-sql.md
│ │ │ └── oracle-sql.md
│ │ ├── postgres
│ │ │ ├── _index.md
│ │ │ ├── postgres-execute-sql.md
│ │ │ ├── postgres-list-active-queries.md
│ │ │ ├── postgres-list-available-extensions.md
│ │ │ ├── postgres-list-installed-extensions.md
│ │ │ ├── postgres-list-schemas.md
│ │ │ ├── postgres-list-tables.md
│ │ │ ├── postgres-list-views.md
│ │ │ └── postgres-sql.md
│ │ ├── redis
│ │ │ ├── _index.md
│ │ │ └── redis.md
│ │ ├── serverless-spark
│ │ │ ├── _index.md
│ │ │ ├── serverless-spark-cancel-batch.md
│ │ │ ├── serverless-spark-get-batch.md
│ │ │ └── serverless-spark-list-batches.md
│ │ ├── singlestore
│ │ │ ├── _index.md
│ │ │ ├── singlestore-execute-sql.md
│ │ │ └── singlestore-sql.md
│ │ ├── spanner
│ │ │ ├── _index.md
│ │ │ ├── spanner-execute-sql.md
│ │ │ ├── spanner-list-tables.md
│ │ │ └── spanner-sql.md
│ │ ├── sqlite
│ │ │ ├── _index.md
│ │ │ ├── sqlite-execute-sql.md
│ │ │ └── sqlite-sql.md
│ │ ├── tidb
│ │ │ ├── _index.md
│ │ │ ├── tidb-execute-sql.md
│ │ │ └── tidb-sql.md
│ │ ├── trino
│ │ │ ├── _index.md
│ │ │ ├── trino-execute-sql.md
│ │ │ └── trino-sql.md
│ │ ├── utility
│ │ │ ├── _index.md
│ │ │ └── wait.md
│ │ ├── valkey
│ │ │ ├── _index.md
│ │ │ └── valkey.md
│ │ └── yuagbytedb
│ │ ├── _index.md
│ │ └── yugabytedb-sql.md
│ ├── samples
│ │ ├── _index.md
│ │ ├── alloydb
│ │ │ ├── _index.md
│ │ │ ├── ai-nl
│ │ │ │ ├── alloydb_ai_nl.ipynb
│ │ │ │ └── index.md
│ │ │ └── mcp_quickstart.md
│ │ ├── bigquery
│ │ │ ├── _index.md
│ │ │ ├── colab_quickstart_bigquery.ipynb
│ │ │ ├── local_quickstart.md
│ │ │ └── mcp_quickstart
│ │ │ ├── _index.md
│ │ │ ├── inspector_tools.png
│ │ │ └── inspector.png
│ │ └── looker
│ │ ├── _index.md
│ │ ├── looker_gemini_oauth
│ │ │ ├── _index.md
│ │ │ ├── authenticated.png
│ │ │ ├── authorize.png
│ │ │ └── registration.png
│ │ ├── looker_gemini.md
│ │ └── looker_mcp_inspector
│ │ ├── _index.md
│ │ ├── inspector_tools.png
│ │ └── inspector.png
│ └── sdks
│ ├── _index.md
│ ├── go-sdk.md
│ ├── js-sdk.md
│ └── python-sdk.md
├── gemini-extension.json
├── go.mod
├── go.sum
├── internal
│ ├── auth
│ │ ├── auth.go
│ │ └── google
│ │ └── google.go
│ ├── log
│ │ ├── handler.go
│ │ ├── log_test.go
│ │ ├── log.go
│ │ └── logger.go
│ ├── prebuiltconfigs
│ │ ├── prebuiltconfigs_test.go
│ │ ├── prebuiltconfigs.go
│ │ └── tools
│ │ ├── alloydb-postgres-admin.yaml
│ │ ├── alloydb-postgres-observability.yaml
│ │ ├── alloydb-postgres.yaml
│ │ ├── bigquery.yaml
│ │ ├── clickhouse.yaml
│ │ ├── cloud-healthcare.yaml
│ │ ├── cloud-sql-mssql-admin.yaml
│ │ ├── cloud-sql-mssql-observability.yaml
│ │ ├── cloud-sql-mssql.yaml
│ │ ├── cloud-sql-mysql-admin.yaml
│ │ ├── cloud-sql-mysql-observability.yaml
│ │ ├── cloud-sql-mysql.yaml
│ │ ├── cloud-sql-postgres-admin.yaml
│ │ ├── cloud-sql-postgres-observability.yaml
│ │ ├── cloud-sql-postgres.yaml
│ │ ├── dataplex.yaml
│ │ ├── elasticsearch.yaml
│ │ ├── firestore.yaml
│ │ ├── looker-conversational-analytics.yaml
│ │ ├── looker.yaml
│ │ ├── mindsdb.yaml
│ │ ├── mssql.yaml
│ │ ├── mysql.yaml
│ │ ├── neo4j.yaml
│ │ ├── oceanbase.yaml
│ │ ├── postgres.yaml
│ │ ├── serverless-spark.yaml
│ │ ├── singlestore.yaml
│ │ ├── spanner-postgres.yaml
│ │ ├── spanner.yaml
│ │ └── sqlite.yaml
│ ├── server
│ │ ├── api_test.go
│ │ ├── api.go
│ │ ├── common_test.go
│ │ ├── config.go
│ │ ├── mcp
│ │ │ ├── jsonrpc
│ │ │ │ ├── jsonrpc_test.go
│ │ │ │ └── jsonrpc.go
│ │ │ ├── mcp.go
│ │ │ ├── util
│ │ │ │ └── lifecycle.go
│ │ │ ├── v20241105
│ │ │ │ ├── method.go
│ │ │ │ └── types.go
│ │ │ ├── v20250326
│ │ │ │ ├── method.go
│ │ │ │ └── types.go
│ │ │ └── v20250618
│ │ │ ├── method.go
│ │ │ └── types.go
│ │ ├── mcp_test.go
│ │ ├── mcp.go
│ │ ├── server_test.go
│ │ ├── server.go
│ │ ├── static
│ │ │ ├── assets
│ │ │ │ └── mcptoolboxlogo.png
│ │ │ ├── css
│ │ │ │ └── style.css
│ │ │ ├── index.html
│ │ │ ├── js
│ │ │ │ ├── auth.js
│ │ │ │ ├── loadTools.js
│ │ │ │ ├── mainContent.js
│ │ │ │ ├── navbar.js
│ │ │ │ ├── runTool.js
│ │ │ │ ├── toolDisplay.js
│ │ │ │ ├── tools.js
│ │ │ │ └── toolsets.js
│ │ │ ├── tools.html
│ │ │ └── toolsets.html
│ │ ├── web_test.go
│ │ └── web.go
│ ├── sources
│ │ ├── alloydbadmin
│ │ │ ├── alloydbadmin_test.go
│ │ │ └── alloydbadmin.go
│ │ ├── alloydbpg
│ │ │ ├── alloydb_pg_test.go
│ │ │ └── alloydb_pg.go
│ │ ├── bigquery
│ │ │ ├── bigquery_test.go
│ │ │ ├── bigquery.go
│ │ │ └── cache.go
│ │ ├── bigtable
│ │ │ ├── bigtable_test.go
│ │ │ └── bigtable.go
│ │ ├── cassandra
│ │ │ ├── cassandra_test.go
│ │ │ └── cassandra.go
│ │ ├── clickhouse
│ │ │ ├── clickhouse_test.go
│ │ │ └── clickhouse.go
│ │ ├── cloudhealthcare
│ │ │ ├── cloud_healthcare_test.go
│ │ │ └── cloud_healthcare.go
│ │ ├── cloudmonitoring
│ │ │ ├── cloud_monitoring_test.go
│ │ │ └── cloud_monitoring.go
│ │ ├── cloudsqladmin
│ │ │ ├── cloud_sql_admin_test.go
│ │ │ └── cloud_sql_admin.go
│ │ ├── cloudsqlmssql
│ │ │ ├── cloud_sql_mssql_test.go
│ │ │ └── cloud_sql_mssql.go
│ │ ├── cloudsqlmysql
│ │ │ ├── cloud_sql_mysql_test.go
│ │ │ └── cloud_sql_mysql.go
│ │ ├── cloudsqlpg
│ │ │ ├── cloud_sql_pg_test.go
│ │ │ └── cloud_sql_pg.go
│ │ ├── couchbase
│ │ │ ├── couchbase_test.go
│ │ │ └── couchbase.go
│ │ ├── dataplex
│ │ │ ├── dataplex_test.go
│ │ │ └── dataplex.go
│ │ ├── dgraph
│ │ │ ├── dgraph_test.go
│ │ │ └── dgraph.go
│ │ ├── dialect.go
│ │ ├── elasticsearch
│ │ │ ├── elasticsearch_test.go
│ │ │ └── elasticsearch.go
│ │ ├── firebird
│ │ │ ├── firebird_test.go
│ │ │ └── firebird.go
│ │ ├── firestore
│ │ │ ├── firestore_test.go
│ │ │ └── firestore.go
│ │ ├── http
│ │ │ ├── http_test.go
│ │ │ └── http.go
│ │ ├── ip_type.go
│ │ ├── looker
│ │ │ ├── looker_test.go
│ │ │ └── looker.go
│ │ ├── mindsdb
│ │ │ ├── mindsdb_test.go
│ │ │ └── mindsdb.go
│ │ ├── mongodb
│ │ │ ├── mongodb_test.go
│ │ │ └── mongodb.go
│ │ ├── mssql
│ │ │ ├── mssql_test.go
│ │ │ └── mssql.go
│ │ ├── mysql
│ │ │ ├── mysql_test.go
│ │ │ └── mysql.go
│ │ ├── neo4j
│ │ │ ├── neo4j_test.go
│ │ │ └── neo4j.go
│ │ ├── oceanbase
│ │ │ ├── oceanbase_test.go
│ │ │ └── oceanbase.go
│ │ ├── oracle
│ │ │ └── oracle.go
│ │ ├── postgres
│ │ │ ├── postgres_test.go
│ │ │ └── postgres.go
│ │ ├── redis
│ │ │ ├── redis_test.go
│ │ │ └── redis.go
│ │ ├── serverlessspark
│ │ │ ├── serverlessspark_test.go
│ │ │ └── serverlessspark.go
│ │ ├── singlestore
│ │ │ ├── singlestore_test.go
│ │ │ └── singlestore.go
│ │ ├── sources.go
│ │ ├── spanner
│ │ │ ├── spanner_test.go
│ │ │ └── spanner.go
│ │ ├── sqlite
│ │ │ ├── sqlite_test.go
│ │ │ └── sqlite.go
│ │ ├── tidb
│ │ │ ├── tidb_test.go
│ │ │ └── tidb.go
│ │ ├── trino
│ │ │ ├── trino_test.go
│ │ │ └── trino.go
│ │ ├── util.go
│ │ ├── valkey
│ │ │ ├── valkey_test.go
│ │ │ └── valkey.go
│ │ └── yugabytedb
│ │ ├── yugabytedb_test.go
│ │ └── yugabytedb.go
│ ├── telemetry
│ │ ├── instrumentation.go
│ │ └── telemetry.go
│ ├── testutils
│ │ └── testutils.go
│ ├── tools
│ │ ├── alloydb
│ │ │ ├── alloydbcreatecluster
│ │ │ │ ├── alloydbcreatecluster_test.go
│ │ │ │ └── alloydbcreatecluster.go
│ │ │ ├── alloydbcreateinstance
│ │ │ │ ├── alloydbcreateinstance_test.go
│ │ │ │ └── alloydbcreateinstance.go
│ │ │ ├── alloydbcreateuser
│ │ │ │ ├── alloydbcreateuser_test.go
│ │ │ │ └── alloydbcreateuser.go
│ │ │ ├── alloydbgetcluster
│ │ │ │ ├── alloydbgetcluster_test.go
│ │ │ │ └── alloydbgetcluster.go
│ │ │ ├── alloydbgetinstance
│ │ │ │ ├── alloydbgetinstance_test.go
│ │ │ │ └── alloydbgetinstance.go
│ │ │ ├── alloydbgetuser
│ │ │ │ ├── alloydbgetuser_test.go
│ │ │ │ └── alloydbgetuser.go
│ │ │ ├── alloydblistclusters
│ │ │ │ ├── alloydblistclusters_test.go
│ │ │ │ └── alloydblistclusters.go
│ │ │ ├── alloydblistinstances
│ │ │ │ ├── alloydblistinstances_test.go
│ │ │ │ └── alloydblistinstances.go
│ │ │ ├── alloydblistusers
│ │ │ │ ├── alloydblistusers_test.go
│ │ │ │ └── alloydblistusers.go
│ │ │ └── alloydbwaitforoperation
│ │ │ ├── alloydbwaitforoperation_test.go
│ │ │ └── alloydbwaitforoperation.go
│ │ ├── alloydbainl
│ │ │ ├── alloydbainl_test.go
│ │ │ └── alloydbainl.go
│ │ ├── bigquery
│ │ │ ├── bigqueryanalyzecontribution
│ │ │ │ ├── bigqueryanalyzecontribution_test.go
│ │ │ │ └── bigqueryanalyzecontribution.go
│ │ │ ├── bigquerycommon
│ │ │ │ ├── table_name_parser_test.go
│ │ │ │ ├── table_name_parser.go
│ │ │ │ └── util.go
│ │ │ ├── bigqueryconversationalanalytics
│ │ │ │ ├── bigqueryconversationalanalytics_test.go
│ │ │ │ └── bigqueryconversationalanalytics.go
│ │ │ ├── bigqueryexecutesql
│ │ │ │ ├── bigqueryexecutesql_test.go
│ │ │ │ └── bigqueryexecutesql.go
│ │ │ ├── bigqueryforecast
│ │ │ │ ├── bigqueryforecast_test.go
│ │ │ │ └── bigqueryforecast.go
│ │ │ ├── bigquerygetdatasetinfo
│ │ │ │ ├── bigquerygetdatasetinfo_test.go
│ │ │ │ └── bigquerygetdatasetinfo.go
│ │ │ ├── bigquerygettableinfo
│ │ │ │ ├── bigquerygettableinfo_test.go
│ │ │ │ └── bigquerygettableinfo.go
│ │ │ ├── bigquerylistdatasetids
│ │ │ │ ├── bigquerylistdatasetids_test.go
│ │ │ │ └── bigquerylistdatasetids.go
│ │ │ ├── bigquerylisttableids
│ │ │ │ ├── bigquerylisttableids_test.go
│ │ │ │ └── bigquerylisttableids.go
│ │ │ ├── bigquerysearchcatalog
│ │ │ │ ├── bigquerysearchcatalog_test.go
│ │ │ │ └── bigquerysearchcatalog.go
│ │ │ └── bigquerysql
│ │ │ ├── bigquerysql_test.go
│ │ │ └── bigquerysql.go
│ │ ├── bigtable
│ │ │ ├── bigtable_test.go
│ │ │ └── bigtable.go
│ │ ├── cassandra
│ │ │ └── cassandracql
│ │ │ ├── cassandracql_test.go
│ │ │ └── cassandracql.go
│ │ ├── clickhouse
│ │ │ ├── clickhouseexecutesql
│ │ │ │ ├── clickhouseexecutesql_test.go
│ │ │ │ └── clickhouseexecutesql.go
│ │ │ ├── clickhouselistdatabases
│ │ │ │ ├── clickhouselistdatabases_test.go
│ │ │ │ └── clickhouselistdatabases.go
│ │ │ ├── clickhouselisttables
│ │ │ │ ├── clickhouselisttables_test.go
│ │ │ │ └── clickhouselisttables.go
│ │ │ └── clickhousesql
│ │ │ ├── clickhousesql_test.go
│ │ │ └── clickhousesql.go
│ │ ├── cloudhealthcare
│ │ │ ├── cloudhealthcarefhirfetchpage
│ │ │ │ ├── cloudhealthcarefhirfetchpage_test.go
│ │ │ │ └── cloudhealthcarefhirfetchpage.go
│ │ │ ├── cloudhealthcarefhirpatienteverything
│ │ │ │ ├── cloudhealthcarefhirpatienteverything_test.go
│ │ │ │ └── cloudhealthcarefhirpatienteverything.go
│ │ │ ├── cloudhealthcarefhirpatientsearch
│ │ │ │ ├── cloudhealthcarefhirpatientsearch_test.go
│ │ │ │ └── cloudhealthcarefhirpatientsearch.go
│ │ │ ├── cloudhealthcaregetdataset
│ │ │ │ ├── cloudhealthcaregetdataset_test.go
│ │ │ │ └── cloudhealthcaregetdataset.go
│ │ │ ├── cloudhealthcaregetdicomstore
│ │ │ │ ├── cloudhealthcaregetdicomstore_test.go
│ │ │ │ └── cloudhealthcaregetdicomstore.go
│ │ │ ├── cloudhealthcaregetdicomstoremetrics
│ │ │ │ ├── cloudhealthcaregetdicomstoremetrics_test.go
│ │ │ │ └── cloudhealthcaregetdicomstoremetrics.go
│ │ │ ├── cloudhealthcaregetfhirresource
│ │ │ │ ├── cloudhealthcaregetfhirresource_test.go
│ │ │ │ └── cloudhealthcaregetfhirresource.go
│ │ │ ├── cloudhealthcaregetfhirstore
│ │ │ │ ├── cloudhealthcaregetfhirstore_test.go
│ │ │ │ └── cloudhealthcaregetfhirstore.go
│ │ │ ├── cloudhealthcaregetfhirstoremetrics
│ │ │ │ ├── cloudhealthcaregetfhirstoremetrics_test.go
│ │ │ │ └── cloudhealthcaregetfhirstoremetrics.go
│ │ │ ├── cloudhealthcarelistdicomstores
│ │ │ │ ├── cloudhealthcarelistdicomstores_test.go
│ │ │ │ └── cloudhealthcarelistdicomstores.go
│ │ │ ├── cloudhealthcarelistfhirstores
│ │ │ │ ├── cloudhealthcarelistfhirstores_test.go
│ │ │ │ └── cloudhealthcarelistfhirstores.go
│ │ │ ├── cloudhealthcareretrieverendereddicominstance
│ │ │ │ ├── cloudhealthcareretrieverendereddicominstance_test.go
│ │ │ │ └── cloudhealthcareretrieverendereddicominstance.go
│ │ │ ├── cloudhealthcaresearchdicominstances
│ │ │ │ ├── cloudhealthcaresearchdicominstances_test.go
│ │ │ │ └── cloudhealthcaresearchdicominstances.go
│ │ │ ├── cloudhealthcaresearchdicomseries
│ │ │ │ ├── cloudhealthcaresearchdicomseries_test.go
│ │ │ │ └── cloudhealthcaresearchdicomseries.go
│ │ │ ├── cloudhealthcaresearchdicomstudies
│ │ │ │ ├── cloudhealthcaresearchdicomstudies_test.go
│ │ │ │ └── cloudhealthcaresearchdicomstudies.go
│ │ │ └── common
│ │ │ └── util.go
│ │ ├── cloudmonitoring
│ │ │ ├── cloudmonitoring_test.go
│ │ │ └── cloudmonitoring.go
│ │ ├── cloudsql
│ │ │ ├── cloudsqlcreatedatabase
│ │ │ │ ├── cloudsqlcreatedatabase_test.go
│ │ │ │ └── cloudsqlcreatedatabase.go
│ │ │ ├── cloudsqlcreateusers
│ │ │ │ ├── cloudsqlcreateusers_test.go
│ │ │ │ └── cloudsqlcreateusers.go
│ │ │ ├── cloudsqlgetinstances
│ │ │ │ ├── cloudsqlgetinstances_test.go
│ │ │ │ └── cloudsqlgetinstances.go
│ │ │ ├── cloudsqllistdatabases
│ │ │ │ ├── cloudsqllistdatabases_test.go
│ │ │ │ └── cloudsqllistdatabases.go
│ │ │ ├── cloudsqllistinstances
│ │ │ │ ├── cloudsqllistinstances_test.go
│ │ │ │ └── cloudsqllistinstances.go
│ │ │ └── cloudsqlwaitforoperation
│ │ │ ├── cloudsqlwaitforoperation_test.go
│ │ │ └── cloudsqlwaitforoperation.go
│ │ ├── cloudsqlmssql
│ │ │ └── cloudsqlmssqlcreateinstance
│ │ │ ├── cloudsqlmssqlcreateinstance_test.go
│ │ │ └── cloudsqlmssqlcreateinstance.go
│ │ ├── cloudsqlmysql
│ │ │ └── cloudsqlmysqlcreateinstance
│ │ │ ├── cloudsqlmysqlcreateinstance_test.go
│ │ │ └── cloudsqlmysqlcreateinstance.go
│ │ ├── cloudsqlpg
│ │ │ └── cloudsqlpgcreateinstances
│ │ │ ├── cloudsqlpgcreateinstances_test.go
│ │ │ └── cloudsqlpgcreateinstances.go
│ │ ├── common_test.go
│ │ ├── common.go
│ │ ├── couchbase
│ │ │ ├── couchbase_test.go
│ │ │ └── couchbase.go
│ │ ├── dataform
│ │ │ └── dataformcompilelocal
│ │ │ ├── dataformcompilelocal_test.go
│ │ │ └── dataformcompilelocal.go
│ │ ├── dataplex
│ │ │ ├── dataplexlookupentry
│ │ │ │ ├── dataplexlookupentry_test.go
│ │ │ │ └── dataplexlookupentry.go
│ │ │ ├── dataplexsearchaspecttypes
│ │ │ │ ├── dataplexsearchaspecttypes_test.go
│ │ │ │ └── dataplexsearchaspecttypes.go
│ │ │ └── dataplexsearchentries
│ │ │ ├── dataplexsearchentries_test.go
│ │ │ └── dataplexsearchentries.go
│ │ ├── dgraph
│ │ │ ├── dgraph_test.go
│ │ │ └── dgraph.go
│ │ ├── elasticsearch
│ │ │ └── elasticsearchesql
│ │ │ ├── elasticsearchesql_test.go
│ │ │ └── elasticsearchesql.go
│ │ ├── firebird
│ │ │ ├── firebirdexecutesql
│ │ │ │ ├── firebirdexecutesql_test.go
│ │ │ │ └── firebirdexecutesql.go
│ │ │ └── firebirdsql
│ │ │ ├── firebirdsql_test.go
│ │ │ └── firebirdsql.go
│ │ ├── firestore
│ │ │ ├── firestoreadddocuments
│ │ │ │ ├── firestoreadddocuments_test.go
│ │ │ │ └── firestoreadddocuments.go
│ │ │ ├── firestoredeletedocuments
│ │ │ │ ├── firestoredeletedocuments_test.go
│ │ │ │ └── firestoredeletedocuments.go
│ │ │ ├── firestoregetdocuments
│ │ │ │ ├── firestoregetdocuments_test.go
│ │ │ │ └── firestoregetdocuments.go
│ │ │ ├── firestoregetrules
│ │ │ │ ├── firestoregetrules_test.go
│ │ │ │ └── firestoregetrules.go
│ │ │ ├── firestorelistcollections
│ │ │ │ ├── firestorelistcollections_test.go
│ │ │ │ └── firestorelistcollections.go
│ │ │ ├── firestorequery
│ │ │ │ ├── firestorequery_test.go
│ │ │ │ └── firestorequery.go
│ │ │ ├── firestorequerycollection
│ │ │ │ ├── firestorequerycollection_test.go
│ │ │ │ └── firestorequerycollection.go
│ │ │ ├── firestoreupdatedocument
│ │ │ │ ├── firestoreupdatedocument_test.go
│ │ │ │ └── firestoreupdatedocument.go
│ │ │ ├── firestorevalidaterules
│ │ │ │ ├── firestorevalidaterules_test.go
│ │ │ │ └── firestorevalidaterules.go
│ │ │ └── util
│ │ │ ├── converter_test.go
│ │ │ ├── converter.go
│ │ │ ├── validator_test.go
│ │ │ └── validator.go
│ │ ├── http
│ │ │ ├── http_test.go
│ │ │ └── http.go
│ │ ├── http_method.go
│ │ ├── looker
│ │ │ ├── lookeradddashboardelement
│ │ │ │ ├── lookeradddashboardelement_test.go
│ │ │ │ └── lookeradddashboardelement.go
│ │ │ ├── lookercommon
│ │ │ │ ├── lookercommon_test.go
│ │ │ │ └── lookercommon.go
│ │ │ ├── lookerconversationalanalytics
│ │ │ │ ├── lookerconversationalanalytics_test.go
│ │ │ │ └── lookerconversationalanalytics.go
│ │ │ ├── lookercreateprojectfile
│ │ │ │ ├── lookercreateprojectfile_test.go
│ │ │ │ └── lookercreateprojectfile.go
│ │ │ ├── lookerdeleteprojectfile
│ │ │ │ ├── lookerdeleteprojectfile_test.go
│ │ │ │ └── lookerdeleteprojectfile.go
│ │ │ ├── lookerdevmode
│ │ │ │ ├── lookerdevmode_test.go
│ │ │ │ └── lookerdevmode.go
│ │ │ ├── lookergetconnectiondatabases
│ │ │ │ ├── lookergetconnectiondatabases_test.go
│ │ │ │ └── lookergetconnectiondatabases.go
│ │ │ ├── lookergetconnections
│ │ │ │ ├── lookergetconnections_test.go
│ │ │ │ └── lookergetconnections.go
│ │ │ ├── lookergetconnectionschemas
│ │ │ │ ├── lookergetconnectionschemas_test.go
│ │ │ │ └── lookergetconnectionschemas.go
│ │ │ ├── lookergetconnectiontablecolumns
│ │ │ │ ├── lookergetconnectiontablecolumns_test.go
│ │ │ │ └── lookergetconnectiontablecolumns.go
│ │ │ ├── lookergetconnectiontables
│ │ │ │ ├── lookergetconnectiontables_test.go
│ │ │ │ └── lookergetconnectiontables.go
│ │ │ ├── lookergetdashboards
│ │ │ │ ├── lookergetdashboards_test.go
│ │ │ │ └── lookergetdashboards.go
│ │ │ ├── lookergetdimensions
│ │ │ │ ├── lookergetdimensions_test.go
│ │ │ │ └── lookergetdimensions.go
│ │ │ ├── lookergetexplores
│ │ │ │ ├── lookergetexplores_test.go
│ │ │ │ └── lookergetexplores.go
│ │ │ ├── lookergetfilters
│ │ │ │ ├── lookergetfilters_test.go
│ │ │ │ └── lookergetfilters.go
│ │ │ ├── lookergetlooks
│ │ │ │ ├── lookergetlooks_test.go
│ │ │ │ └── lookergetlooks.go
│ │ │ ├── lookergetmeasures
│ │ │ │ ├── lookergetmeasures_test.go
│ │ │ │ └── lookergetmeasures.go
│ │ │ ├── lookergetmodels
│ │ │ │ ├── lookergetmodels_test.go
│ │ │ │ └── lookergetmodels.go
│ │ │ ├── lookergetparameters
│ │ │ │ ├── lookergetparameters_test.go
│ │ │ │ └── lookergetparameters.go
│ │ │ ├── lookergetprojectfile
│ │ │ │ ├── lookergetprojectfile_test.go
│ │ │ │ └── lookergetprojectfile.go
│ │ │ ├── lookergetprojectfiles
│ │ │ │ ├── lookergetprojectfiles_test.go
│ │ │ │ └── lookergetprojectfiles.go
│ │ │ ├── lookergetprojects
│ │ │ │ ├── lookergetprojects_test.go
│ │ │ │ └── lookergetprojects.go
│ │ │ ├── lookerhealthanalyze
│ │ │ │ ├── lookerhealthanalyze_test.go
│ │ │ │ └── lookerhealthanalyze.go
│ │ │ ├── lookerhealthpulse
│ │ │ │ ├── lookerhealthpulse_test.go
│ │ │ │ └── lookerhealthpulse.go
│ │ │ ├── lookerhealthvacuum
│ │ │ │ ├── lookerhealthvacuum_test.go
│ │ │ │ └── lookerhealthvacuum.go
│ │ │ ├── lookermakedashboard
│ │ │ │ ├── lookermakedashboard_test.go
│ │ │ │ └── lookermakedashboard.go
│ │ │ ├── lookermakelook
│ │ │ │ ├── lookermakelook_test.go
│ │ │ │ └── lookermakelook.go
│ │ │ ├── lookerquery
│ │ │ │ ├── lookerquery_test.go
│ │ │ │ └── lookerquery.go
│ │ │ ├── lookerquerysql
│ │ │ │ ├── lookerquerysql_test.go
│ │ │ │ └── lookerquerysql.go
│ │ │ ├── lookerqueryurl
│ │ │ │ ├── lookerqueryurl_test.go
│ │ │ │ └── lookerqueryurl.go
│ │ │ ├── lookerrundashboard
│ │ │ │ ├── lookerrundashboard_test.go
│ │ │ │ └── lookerrundashboard.go
│ │ │ ├── lookerrunlook
│ │ │ │ ├── lookerrunlook_test.go
│ │ │ │ └── lookerrunlook.go
│ │ │ └── lookerupdateprojectfile
│ │ │ ├── lookerupdateprojectfile_test.go
│ │ │ └── lookerupdateprojectfile.go
│ │ ├── mindsdb
│ │ │ ├── mindsdbexecutesql
│ │ │ │ ├── mindsdbexecutesql_test.go
│ │ │ │ └── mindsdbexecutesql.go
│ │ │ └── mindsdbsql
│ │ │ ├── mindsdbsql_test.go
│ │ │ └── mindsdbsql.go
│ │ ├── mongodb
│ │ │ ├── mongodbaggregate
│ │ │ │ ├── mongodbaggregate_test.go
│ │ │ │ └── mongodbaggregate.go
│ │ │ ├── mongodbdeletemany
│ │ │ │ ├── mongodbdeletemany_test.go
│ │ │ │ └── mongodbdeletemany.go
│ │ │ ├── mongodbdeleteone
│ │ │ │ ├── mongodbdeleteone_test.go
│ │ │ │ └── mongodbdeleteone.go
│ │ │ ├── mongodbfind
│ │ │ │ ├── mongodbfind_test.go
│ │ │ │ └── mongodbfind.go
│ │ │ ├── mongodbfindone
│ │ │ │ ├── mongodbfindone_test.go
│ │ │ │ └── mongodbfindone.go
│ │ │ ├── mongodbinsertmany
│ │ │ │ ├── mongodbinsertmany_test.go
│ │ │ │ └── mongodbinsertmany.go
│ │ │ ├── mongodbinsertone
│ │ │ │ ├── mongodbinsertone_test.go
│ │ │ │ └── mongodbinsertone.go
│ │ │ ├── mongodbupdatemany
│ │ │ │ ├── mongodbupdatemany_test.go
│ │ │ │ └── mongodbupdatemany.go
│ │ │ └── mongodbupdateone
│ │ │ ├── mongodbupdateone_test.go
│ │ │ └── mongodbupdateone.go
│ │ ├── mssql
│ │ │ ├── mssqlexecutesql
│ │ │ │ ├── mssqlexecutesql_test.go
│ │ │ │ └── mssqlexecutesql.go
│ │ │ ├── mssqllisttables
│ │ │ │ ├── mssqllisttables_test.go
│ │ │ │ └── mssqllisttables.go
│ │ │ └── mssqlsql
│ │ │ ├── mssqlsql_test.go
│ │ │ └── mssqlsql.go
│ │ ├── mysql
│ │ │ ├── mysqlcommon
│ │ │ │ └── mysqlcommon.go
│ │ │ ├── mysqlexecutesql
│ │ │ │ ├── mysqlexecutesql_test.go
│ │ │ │ └── mysqlexecutesql.go
│ │ │ ├── mysqllistactivequeries
│ │ │ │ ├── mysqllistactivequeries_test.go
│ │ │ │ └── mysqllistactivequeries.go
│ │ │ ├── mysqllisttablefragmentation
│ │ │ │ ├── mysqllisttablefragmentation_test.go
│ │ │ │ └── mysqllisttablefragmentation.go
│ │ │ ├── mysqllisttables
│ │ │ │ ├── mysqllisttables_test.go
│ │ │ │ └── mysqllisttables.go
│ │ │ ├── mysqllisttablesmissinguniqueindexes
│ │ │ │ ├── mysqllisttablesmissinguniqueindexes_test.go
│ │ │ │ └── mysqllisttablesmissinguniqueindexes.go
│ │ │ └── mysqlsql
│ │ │ ├── mysqlsql_test.go
│ │ │ └── mysqlsql.go
│ │ ├── neo4j
│ │ │ ├── neo4jcypher
│ │ │ │ ├── neo4jcypher_test.go
│ │ │ │ └── neo4jcypher.go
│ │ │ ├── neo4jexecutecypher
│ │ │ │ ├── classifier
│ │ │ │ │ ├── classifier_test.go
│ │ │ │ │ └── classifier.go
│ │ │ │ ├── neo4jexecutecypher_test.go
│ │ │ │ └── neo4jexecutecypher.go
│ │ │ └── neo4jschema
│ │ │ ├── cache
│ │ │ │ ├── cache_test.go
│ │ │ │ └── cache.go
│ │ │ ├── helpers
│ │ │ │ ├── helpers_test.go
│ │ │ │ └── helpers.go
│ │ │ ├── neo4jschema_test.go
│ │ │ ├── neo4jschema.go
│ │ │ └── types
│ │ │ └── types.go
│ │ ├── oceanbase
│ │ │ ├── oceanbaseexecutesql
│ │ │ │ ├── oceanbaseexecutesql_test.go
│ │ │ │ └── oceanbaseexecutesql.go
│ │ │ └── oceanbasesql
│ │ │ ├── oceanbasesql_test.go
│ │ │ └── oceanbasesql.go
│ │ ├── oracle
│ │ │ ├── oracleexecutesql
│ │ │ │ └── oracleexecutesql.go
│ │ │ └── oraclesql
│ │ │ └── oraclesql.go
│ │ ├── parameters_test.go
│ │ ├── parameters.go
│ │ ├── postgres
│ │ │ ├── postgresexecutesql
│ │ │ │ ├── postgresexecutesql_test.go
│ │ │ │ └── postgresexecutesql.go
│ │ │ ├── postgreslistactivequeries
│ │ │ │ ├── postgreslistactivequeries_test.go
│ │ │ │ └── postgreslistactivequeries.go
│ │ │ ├── postgreslistavailableextensions
│ │ │ │ ├── postgreslistavailableextensions_test.go
│ │ │ │ └── postgreslistavailableextensions.go
│ │ │ ├── postgreslistinstalledextensions
│ │ │ │ ├── postgreslistinstalledextensions_test.go
│ │ │ │ └── postgreslistinstalledextensions.go
│ │ │ ├── postgreslistschemas
│ │ │ │ ├── postgreslistschemas_test.go
│ │ │ │ └── postgreslistschemas.go
│ │ │ ├── postgreslisttables
│ │ │ │ ├── postgreslisttables_test.go
│ │ │ │ └── postgreslisttables.go
│ │ │ ├── postgreslistviews
│ │ │ │ ├── postgreslistviews_test.go
│ │ │ │ └── postgreslistviews.go
│ │ │ └── postgressql
│ │ │ ├── postgressql_test.go
│ │ │ └── postgressql.go
│ │ ├── redis
│ │ │ ├── redis_test.go
│ │ │ └── redis.go
│ │ ├── serverlessspark
│ │ │ ├── serverlesssparkcancelbatch
│ │ │ │ ├── serverlesssparkcancelbatch_test.go
│ │ │ │ └── serverlesssparkcancelbatch.go
│ │ │ ├── serverlesssparkgetbatch
│ │ │ │ ├── serverlesssparkgetbatch_test.go
│ │ │ │ └── serverlesssparkgetbatch.go
│ │ │ └── serverlesssparklistbatches
│ │ │ ├── serverlesssparklistbatches_test.go
│ │ │ └── serverlesssparklistbatches.go
│ │ ├── singlestore
│ │ │ ├── singlestoreexecutesql
│ │ │ │ ├── singlestoreexecutesql_test.go
│ │ │ │ └── singlestoreexecutesql.go
│ │ │ └── singlestoresql
│ │ │ ├── singlestoresql_test.go
│ │ │ └── singlestoresql.go
│ │ ├── spanner
│ │ │ ├── spannerexecutesql
│ │ │ │ ├── spannerexecutesql_test.go
│ │ │ │ └── spannerexecutesql.go
│ │ │ ├── spannerlisttables
│ │ │ │ ├── spannerlisttables_test.go
│ │ │ │ └── spannerlisttables.go
│ │ │ └── spannersql
│ │ │ ├── spanner_test.go
│ │ │ └── spannersql.go
│ │ ├── sqlite
│ │ │ ├── sqliteexecutesql
│ │ │ │ ├── sqliteexecutesql_test.go
│ │ │ │ └── sqliteexecutesql.go
│ │ │ └── sqlitesql
│ │ │ ├── sqlitesql_test.go
│ │ │ └── sqlitesql.go
│ │ ├── tidb
│ │ │ ├── tidbexecutesql
│ │ │ │ ├── tidbexecutesql_test.go
│ │ │ │ └── tidbexecutesql.go
│ │ │ └── tidbsql
│ │ │ ├── tidbsql_test.go
│ │ │ └── tidbsql.go
│ │ ├── tools_test.go
│ │ ├── tools.go
│ │ ├── toolsets.go
│ │ ├── trino
│ │ │ ├── trinoexecutesql
│ │ │ │ ├── trinoexecutesql_test.go
│ │ │ │ └── trinoexecutesql.go
│ │ │ └── trinosql
│ │ │ ├── trinosql_test.go
│ │ │ └── trinosql.go
│ │ ├── utility
│ │ │ └── wait
│ │ │ ├── wait_test.go
│ │ │ └── wait.go
│ │ ├── valkey
│ │ │ ├── valkey_test.go
│ │ │ └── valkey.go
│ │ └── yugabytedbsql
│ │ ├── yugabytedbsql_test.go
│ │ └── yugabytedbsql.go
│ └── util
│ ├── orderedmap
│ │ ├── orderedmap_test.go
│ │ └── orderedmap.go
│ └── util.go
├── LICENSE
├── logo.png
├── main.go
├── MCP-TOOLBOX-EXTENSION.md
├── README.md
└── tests
├── alloydb
│ ├── alloydb_integration_test.go
│ └── alloydb_wait_for_operation_test.go
├── alloydbainl
│ └── alloydb_ai_nl_integration_test.go
├── alloydbpg
│ └── alloydb_pg_integration_test.go
├── auth.go
├── bigquery
│ └── bigquery_integration_test.go
├── bigtable
│ └── bigtable_integration_test.go
├── cassandra
│ └── cassandra_integration_test.go
├── clickhouse
│ └── clickhouse_integration_test.go
├── cloudhealthcare
│ └── cloud_healthcare_integration_test.go
├── cloudmonitoring
│ └── cloud_monitoring_integration_test.go
├── cloudsql
│ ├── cloud_sql_create_database_test.go
│ ├── cloud_sql_create_users_test.go
│ ├── cloud_sql_get_instances_test.go
│ ├── cloud_sql_list_databases_test.go
│ ├── cloudsql_list_instances_test.go
│ └── cloudsql_wait_for_operation_test.go
├── cloudsqlmssql
│ ├── cloud_sql_mssql_create_instance_integration_test.go
│ └── cloud_sql_mssql_integration_test.go
├── cloudsqlmysql
│ ├── cloud_sql_mysql_create_instance_integration_test.go
│ └── cloud_sql_mysql_integration_test.go
├── cloudsqlpg
│ ├── cloud_sql_pg_create_instances_test.go
│ └── cloud_sql_pg_integration_test.go
├── common.go
├── couchbase
│ └── couchbase_integration_test.go
├── dataform
│ └── dataform_integration_test.go
├── dataplex
│ └── dataplex_integration_test.go
├── dgraph
│ └── dgraph_integration_test.go
├── elasticsearch
│ └── elasticsearch_integration_test.go
├── firebird
│ └── firebird_integration_test.go
├── firestore
│ └── firestore_integration_test.go
├── http
│ └── http_integration_test.go
├── looker
│ └── looker_integration_test.go
├── mindsdb
│ └── mindsdb_integration_test.go
├── mongodb
│ └── mongodb_integration_test.go
├── mssql
│ └── mssql_integration_test.go
├── mysql
│ └── mysql_integration_test.go
├── neo4j
│ └── neo4j_integration_test.go
├── oceanbase
│ └── oceanbase_integration_test.go
├── option.go
├── oracle
│ └── oracle_integration_test.go
├── postgres
│ └── postgres_integration_test.go
├── redis
│ └── redis_test.go
├── server.go
├── serverlessspark
│ └── serverless_spark_integration_test.go
├── singlestore
│ └── singlestore_integration_test.go
├── source.go
├── spanner
│ └── spanner_integration_test.go
├── sqlite
│ └── sqlite_integration_test.go
├── tidb
│ └── tidb_integration_test.go
├── tool.go
├── trino
│ └── trino_integration_test.go
├── utility
│ └── wait_integration_test.go
├── valkey
│ └── valkey_test.go
└── yugabytedb
└── yugabytedb_integration_test.go
```
# Files
--------------------------------------------------------------------------------
/tests/tool.go:
--------------------------------------------------------------------------------
```go
1 | // Copyright 2025 Google LLC
2 | //
3 | // Licensed under the Apache License, Version 2.0 (the "License");
4 | // you may not use this file except in compliance with the License.
5 | // You may obtain a copy of the License at
6 | //
7 | // http://www.apache.org/licenses/LICENSE-2.0
8 | //
9 | // Unless required by applicable law or agreed to in writing, software
10 | // distributed under the License is distributed on an "AS IS" BASIS,
11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | // See the License for the specific language governing permissions and
13 | // limitations under the License.
14 |
15 | package tests
16 |
17 | import (
18 | "bytes"
19 | "context"
20 | "database/sql"
21 | "encoding/json"
22 | "fmt"
23 | "io"
24 | "net/http"
25 | "reflect"
26 | "sort"
27 | "strings"
28 | "sync"
29 | "testing"
30 | "time"
31 |
32 | "github.com/google/go-cmp/cmp"
33 | "github.com/google/go-cmp/cmp/cmpopts"
34 | "github.com/google/uuid"
35 | "github.com/googleapis/genai-toolbox/internal/server/mcp/jsonrpc"
36 | "github.com/googleapis/genai-toolbox/internal/sources"
37 | "github.com/jackc/pgx/v5/pgxpool"
38 | )
39 |
40 | // RunToolGet runs the tool get endpoint
41 | func RunToolGetTest(t *testing.T) {
42 | // Test tool get endpoint
43 | tcs := []struct {
44 | name string
45 | api string
46 | want map[string]any
47 | }{
48 | {
49 | name: "get my-simple-tool",
50 | api: "http://127.0.0.1:5000/api/tool/my-simple-tool/",
51 | want: map[string]any{
52 | "my-simple-tool": map[string]any{
53 | "description": "Simple tool to test end to end functionality.",
54 | "parameters": []any{},
55 | "authRequired": []any{},
56 | },
57 | },
58 | },
59 | }
60 | for _, tc := range tcs {
61 | t.Run(tc.name, func(t *testing.T) {
62 | resp, err := http.Get(tc.api)
63 | if err != nil {
64 | t.Fatalf("error when sending a request: %s", err)
65 | }
66 | defer resp.Body.Close()
67 | if resp.StatusCode != 200 {
68 | t.Fatalf("response status code is not 200")
69 | }
70 |
71 | var body map[string]interface{}
72 | err = json.NewDecoder(resp.Body).Decode(&body)
73 | if err != nil {
74 | t.Fatalf("error parsing response body")
75 | }
76 |
77 | got, ok := body["tools"]
78 | if !ok {
79 | t.Fatalf("unable to find tools in response body")
80 | }
81 | if !reflect.DeepEqual(got, tc.want) {
82 | t.Fatalf("got %q, want %q", got, tc.want)
83 | }
84 | })
85 | }
86 | }
87 |
88 | func RunToolGetTestByName(t *testing.T, name string, want map[string]any) {
89 | // Test tool get endpoint
90 | tcs := []struct {
91 | name string
92 | api string
93 | want map[string]any
94 | }{
95 | {
96 | name: fmt.Sprintf("get %s", name),
97 | api: fmt.Sprintf("http://127.0.0.1:5000/api/tool/%s/", name),
98 | want: want,
99 | },
100 | }
101 | for _, tc := range tcs {
102 | t.Run(tc.name, func(t *testing.T) {
103 | resp, err := http.Get(tc.api)
104 | if err != nil {
105 | t.Fatalf("error when sending a request: %s", err)
106 | }
107 | defer resp.Body.Close()
108 | if resp.StatusCode != 200 {
109 | t.Fatalf("response status code is not 200")
110 | }
111 |
112 | var body map[string]interface{}
113 | err = json.NewDecoder(resp.Body).Decode(&body)
114 | if err != nil {
115 | t.Fatalf("error parsing response body")
116 | }
117 |
118 | got, ok := body["tools"]
119 | if !ok {
120 | t.Fatalf("unable to find tools in response body")
121 | }
122 | if !reflect.DeepEqual(got, tc.want) {
123 | t.Fatalf("got %q, want %q", got, tc.want)
124 | }
125 | })
126 | }
127 | }
128 |
129 | // RunToolInvokeSimpleTest runs the tool invoke endpoint with no parameters
130 | func RunToolInvokeSimpleTest(t *testing.T, name string, simpleWant string) {
131 | // Test tool invoke endpoint
132 | invokeTcs := []struct {
133 | name string
134 | api string
135 | requestHeader map[string]string
136 | requestBody io.Reader
137 | want string
138 | isErr bool
139 | }{
140 | {
141 | name: fmt.Sprintf("invoke %s", name),
142 | api: fmt.Sprintf("http://127.0.0.1:5000/api/tool/%s/invoke", name),
143 | requestHeader: map[string]string{},
144 | requestBody: bytes.NewBuffer([]byte(`{}`)),
145 | want: simpleWant,
146 | isErr: false,
147 | },
148 | }
149 | for _, tc := range invokeTcs {
150 | t.Run(tc.name, func(t *testing.T) {
151 | // Send Tool invocation request
152 | resp, respBody := RunRequest(t, http.MethodPost, tc.api, tc.requestBody, tc.requestHeader)
153 | if resp.StatusCode != http.StatusOK {
154 | if tc.isErr {
155 | return
156 | }
157 | t.Fatalf("response status code is not 200, got %d: %s", resp.StatusCode, string(respBody))
158 | }
159 |
160 | // Check response body
161 | var body map[string]interface{}
162 | err := json.Unmarshal(respBody, &body)
163 | if err != nil {
164 | t.Fatalf("error parsing response body")
165 | }
166 |
167 | got, ok := body["result"].(string)
168 | if !ok {
169 | t.Fatalf("unable to find result in response body")
170 | }
171 |
172 | if !strings.Contains(got, tc.want) {
173 | t.Fatalf("unexpected value: got %q, want %q", got, tc.want)
174 | }
175 | })
176 | }
177 | }
178 |
179 | func RunToolInvokeParametersTest(t *testing.T, name string, params []byte, simpleWant string) {
180 | // Test tool invoke endpoint
181 | invokeTcs := []struct {
182 | name string
183 | api string
184 | requestHeader map[string]string
185 | requestBody io.Reader
186 | want string
187 | isErr bool
188 | }{
189 | {
190 | name: fmt.Sprintf("invoke %s", name),
191 | api: fmt.Sprintf("http://127.0.0.1:5000/api/tool/%s/invoke", name),
192 | requestHeader: map[string]string{},
193 | requestBody: bytes.NewBuffer(params),
194 | want: simpleWant,
195 | isErr: false,
196 | },
197 | }
198 | for _, tc := range invokeTcs {
199 | t.Run(tc.name, func(t *testing.T) {
200 | // Send Tool invocation request
201 | resp, respBody := RunRequest(t, http.MethodPost, tc.api, tc.requestBody, tc.requestHeader)
202 | if resp.StatusCode != http.StatusOK {
203 | if tc.isErr {
204 | return
205 | }
206 | t.Fatalf("response status code is not 200, got %d: %s", resp.StatusCode, string(respBody))
207 | }
208 |
209 | // Check response body
210 | var body map[string]interface{}
211 | err := json.Unmarshal(respBody, &body)
212 | if err != nil {
213 | t.Fatalf("error parsing response body")
214 | }
215 |
216 | got, ok := body["result"].(string)
217 | if !ok {
218 | t.Fatalf("unable to find result in response body")
219 | }
220 |
221 | if !strings.Contains(got, tc.want) {
222 | t.Fatalf("unexpected value: got %q, want %q", got, tc.want)
223 | }
224 | })
225 | }
226 | }
227 |
228 | // RunToolInvoke runs the tool invoke endpoint
229 | func RunToolInvokeTest(t *testing.T, select1Want string, options ...InvokeTestOption) {
230 | // Resolve options
231 | // Default values for InvokeTestConfig
232 | configs := &InvokeTestConfig{
233 | myToolId3NameAliceWant: "[{\"id\":1,\"name\":\"Alice\"},{\"id\":3,\"name\":\"Sid\"}]",
234 | myToolById4Want: "[{\"id\":4,\"name\":null}]",
235 | myArrayToolWant: "[{\"id\":1,\"name\":\"Alice\"},{\"id\":3,\"name\":\"Sid\"}]",
236 | nullWant: "null",
237 | supportOptionalNullParam: true,
238 | supportArrayParam: true,
239 | supportClientAuth: false,
240 | supportSelect1Want: true,
241 | supportSelect1Auth: true,
242 | }
243 |
244 | // Apply provided options
245 | for _, option := range options {
246 | option(configs)
247 | }
248 |
249 | // Get ID token
250 | idToken, err := GetGoogleIdToken(ClientId)
251 | if err != nil {
252 | t.Fatalf("error getting Google ID token: %s", err)
253 | }
254 |
255 | // Get access token
256 | accessToken, err := sources.GetIAMAccessToken(t.Context())
257 | if err != nil {
258 | t.Fatalf("error getting access token from ADC: %s", err)
259 | }
260 | accessToken = "Bearer " + accessToken
261 |
262 | // Test tool invoke endpoint
263 | invokeTcs := []struct {
264 | name string
265 | api string
266 | enabled bool
267 | requestHeader map[string]string
268 | requestBody io.Reader
269 | wantStatusCode int
270 | wantBody string
271 | }{
272 | {
273 | name: "invoke my-simple-tool",
274 | api: "http://127.0.0.1:5000/api/tool/my-simple-tool/invoke",
275 | enabled: configs.supportSelect1Want,
276 | requestHeader: map[string]string{},
277 | requestBody: bytes.NewBuffer([]byte(`{}`)),
278 | wantBody: select1Want,
279 | wantStatusCode: http.StatusOK,
280 | },
281 | {
282 | name: "invoke my-tool",
283 | api: "http://127.0.0.1:5000/api/tool/my-tool/invoke",
284 | enabled: true,
285 | requestHeader: map[string]string{},
286 | requestBody: bytes.NewBuffer([]byte(`{"id": 3, "name": "Alice"}`)),
287 | wantBody: configs.myToolId3NameAliceWant,
288 | wantStatusCode: http.StatusOK,
289 | },
290 | {
291 | name: "invoke my-tool-by-id with nil response",
292 | api: "http://127.0.0.1:5000/api/tool/my-tool-by-id/invoke",
293 | enabled: true,
294 | requestHeader: map[string]string{},
295 | requestBody: bytes.NewBuffer([]byte(`{"id": 4}`)),
296 | wantBody: configs.myToolById4Want,
297 | wantStatusCode: http.StatusOK,
298 | },
299 | {
300 | name: "invoke my-tool-by-name with nil response",
301 | api: "http://127.0.0.1:5000/api/tool/my-tool-by-name/invoke",
302 | enabled: configs.supportOptionalNullParam,
303 | requestHeader: map[string]string{},
304 | requestBody: bytes.NewBuffer([]byte(`{}`)),
305 | wantBody: configs.nullWant,
306 | wantStatusCode: http.StatusOK,
307 | },
308 | {
309 | name: "Invoke my-tool without parameters",
310 | api: "http://127.0.0.1:5000/api/tool/my-tool/invoke",
311 | enabled: true,
312 | requestHeader: map[string]string{},
313 | requestBody: bytes.NewBuffer([]byte(`{}`)),
314 | wantBody: "",
315 | wantStatusCode: http.StatusBadRequest,
316 | },
317 | {
318 | name: "Invoke my-tool with insufficient parameters",
319 | api: "http://127.0.0.1:5000/api/tool/my-tool/invoke",
320 | enabled: true,
321 | requestHeader: map[string]string{},
322 | requestBody: bytes.NewBuffer([]byte(`{"id": 1}`)),
323 | wantBody: "",
324 | wantStatusCode: http.StatusBadRequest,
325 | },
326 | {
327 | name: "invoke my-array-tool",
328 | api: "http://127.0.0.1:5000/api/tool/my-array-tool/invoke",
329 | enabled: configs.supportArrayParam,
330 | requestHeader: map[string]string{},
331 | requestBody: bytes.NewBuffer([]byte(`{"idArray": [1,2,3], "nameArray": ["Alice", "Sid", "RandomName"], "cmdArray": ["HGETALL", "row3"]}`)),
332 | wantBody: configs.myArrayToolWant,
333 | wantStatusCode: http.StatusOK,
334 | },
335 | {
336 | name: "Invoke my-auth-tool with auth token",
337 | api: "http://127.0.0.1:5000/api/tool/my-auth-tool/invoke",
338 | enabled: configs.supportSelect1Auth,
339 | requestHeader: map[string]string{"my-google-auth_token": idToken},
340 | requestBody: bytes.NewBuffer([]byte(`{}`)),
341 | wantBody: configs.myAuthToolWant,
342 | wantStatusCode: http.StatusOK,
343 | },
344 | {
345 | name: "Invoke my-auth-tool with invalid auth token",
346 | api: "http://127.0.0.1:5000/api/tool/my-auth-tool/invoke",
347 | enabled: configs.supportSelect1Auth,
348 | requestHeader: map[string]string{"my-google-auth_token": "INVALID_TOKEN"},
349 | requestBody: bytes.NewBuffer([]byte(`{}`)),
350 | wantBody: "",
351 | wantStatusCode: http.StatusUnauthorized,
352 | },
353 | {
354 | name: "Invoke my-auth-tool without auth token",
355 | api: "http://127.0.0.1:5000/api/tool/my-auth-tool/invoke",
356 | enabled: true,
357 | requestHeader: map[string]string{},
358 | requestBody: bytes.NewBuffer([]byte(`{}`)),
359 | wantBody: "",
360 | wantStatusCode: http.StatusUnauthorized,
361 | },
362 | {
363 | name: "Invoke my-auth-required-tool with auth token",
364 | api: "http://127.0.0.1:5000/api/tool/my-auth-required-tool/invoke",
365 | enabled: configs.supportSelect1Auth,
366 | requestHeader: map[string]string{"my-google-auth_token": idToken},
367 | requestBody: bytes.NewBuffer([]byte(`{}`)),
368 | wantBody: select1Want,
369 | wantStatusCode: http.StatusOK,
370 | },
371 | {
372 | name: "Invoke my-auth-required-tool with invalid auth token",
373 | api: "http://127.0.0.1:5000/api/tool/my-auth-required-tool/invoke",
374 | enabled: true,
375 | requestHeader: map[string]string{"my-google-auth_token": "INVALID_TOKEN"},
376 | requestBody: bytes.NewBuffer([]byte(`{}`)),
377 | wantBody: "",
378 | wantStatusCode: http.StatusUnauthorized,
379 | },
380 | {
381 | name: "Invoke my-auth-required-tool without auth token",
382 | api: "http://127.0.0.1:5000/api/tool/my-auth-tool/invoke",
383 | enabled: true,
384 | requestHeader: map[string]string{},
385 | requestBody: bytes.NewBuffer([]byte(`{}`)),
386 | wantBody: "",
387 | wantStatusCode: http.StatusUnauthorized,
388 | },
389 | {
390 | name: "Invoke my-client-auth-tool with auth token",
391 | api: "http://127.0.0.1:5000/api/tool/my-client-auth-tool/invoke",
392 | enabled: configs.supportClientAuth,
393 | requestHeader: map[string]string{"Authorization": accessToken},
394 | requestBody: bytes.NewBuffer([]byte(`{}`)),
395 | wantBody: select1Want,
396 | wantStatusCode: http.StatusOK,
397 | },
398 | {
399 | name: "Invoke my-client-auth-tool without auth token",
400 | api: "http://127.0.0.1:5000/api/tool/my-client-auth-tool/invoke",
401 | enabled: configs.supportClientAuth,
402 | requestHeader: map[string]string{},
403 | requestBody: bytes.NewBuffer([]byte(`{}`)),
404 | wantStatusCode: http.StatusUnauthorized,
405 | },
406 | {
407 |
408 | name: "Invoke my-client-auth-tool with invalid auth token",
409 | api: "http://127.0.0.1:5000/api/tool/my-client-auth-tool/invoke",
410 | enabled: configs.supportClientAuth,
411 | requestHeader: map[string]string{"Authorization": "Bearer invalid-token"},
412 | requestBody: bytes.NewBuffer([]byte(`{}`)),
413 | wantStatusCode: http.StatusUnauthorized,
414 | },
415 | }
416 | for _, tc := range invokeTcs {
417 | t.Run(tc.name, func(t *testing.T) {
418 | if !tc.enabled {
419 | return
420 | }
421 | // Send Tool invocation request
422 | resp, respBody := RunRequest(t, http.MethodPost, tc.api, tc.requestBody, tc.requestHeader)
423 |
424 | // Check status code
425 | if resp.StatusCode != tc.wantStatusCode {
426 | t.Errorf("StatusCode mismatch: got %d, want %d. Response body: %s", resp.StatusCode, tc.wantStatusCode, string(respBody))
427 | }
428 |
429 | // skip response body check
430 | if tc.wantBody == "" {
431 | return
432 | }
433 |
434 | // Check response body
435 | var body map[string]interface{}
436 | err = json.Unmarshal(respBody, &body)
437 | if err != nil {
438 | t.Fatalf("error parsing response body: %s", err)
439 | }
440 |
441 | got, ok := body["result"].(string)
442 | if !ok {
443 | t.Fatalf("unable to find result in response body")
444 | }
445 |
446 | if got != tc.wantBody {
447 | t.Fatalf("unexpected value: got %q, want %q", got, tc.wantBody)
448 | }
449 | })
450 | }
451 | }
452 |
453 | // RunToolInvokeWithTemplateParameters runs tool invoke test cases with template parameters.
454 | func RunToolInvokeWithTemplateParameters(t *testing.T, tableName string, options ...TemplateParamOption) {
455 | // Resolve options
456 | // Default values for TemplateParameterTestConfig
457 | configs := &TemplateParameterTestConfig{
458 | ddlWant: "null",
459 | selectAllWant: "[{\"age\":21,\"id\":1,\"name\":\"Alex\"},{\"age\":100,\"id\":2,\"name\":\"Alice\"}]",
460 | selectId1Want: "[{\"age\":21,\"id\":1,\"name\":\"Alex\"}]",
461 | selectNameWant: "[{\"age\":21,\"id\":1,\"name\":\"Alex\"}]",
462 | selectEmptyWant: "null",
463 | insert1Want: "null",
464 |
465 | nameFieldArray: `["name"]`,
466 | nameColFilter: "name",
467 | createColArray: `["id INT","name VARCHAR(20)","age INT"]`,
468 |
469 | supportDdl: true,
470 | supportInsert: true,
471 | }
472 |
473 | // Apply provided options
474 | for _, option := range options {
475 | option(configs)
476 | }
477 |
478 | selectOnlyNamesWant := "[{\"name\":\"Alex\"},{\"name\":\"Alice\"}]"
479 |
480 | // Test tool invoke endpoint
481 | invokeTcs := []struct {
482 | name string
483 | enabled bool
484 | ddl bool
485 | insert bool
486 | api string
487 | requestHeader map[string]string
488 | requestBody io.Reader
489 | want string
490 | isErr bool
491 | }{
492 | {
493 | name: "invoke create-table-templateParams-tool",
494 | ddl: true,
495 | api: "http://127.0.0.1:5000/api/tool/create-table-templateParams-tool/invoke",
496 | requestHeader: map[string]string{},
497 | requestBody: bytes.NewBuffer([]byte(fmt.Sprintf(`{"tableName": "%s", "columns":%s}`, tableName, configs.createColArray))),
498 | want: configs.ddlWant,
499 | isErr: false,
500 | },
501 | {
502 | name: "invoke insert-table-templateParams-tool",
503 | insert: true,
504 | api: "http://127.0.0.1:5000/api/tool/insert-table-templateParams-tool/invoke",
505 | requestHeader: map[string]string{},
506 | requestBody: bytes.NewBuffer([]byte(fmt.Sprintf(`{"tableName": "%s", "columns":["id","name","age"], "values":"1, 'Alex', 21"}`, tableName))),
507 | want: configs.insert1Want,
508 | isErr: false,
509 | },
510 | {
511 | name: "invoke insert-table-templateParams-tool",
512 | insert: true,
513 | api: "http://127.0.0.1:5000/api/tool/insert-table-templateParams-tool/invoke",
514 | requestHeader: map[string]string{},
515 | requestBody: bytes.NewBuffer([]byte(fmt.Sprintf(`{"tableName": "%s", "columns":["id","name","age"], "values":"2, 'Alice', 100"}`, tableName))),
516 | want: configs.insert1Want,
517 | isErr: false,
518 | },
519 | {
520 | name: "invoke select-templateParams-tool",
521 | api: "http://127.0.0.1:5000/api/tool/select-templateParams-tool/invoke",
522 | requestHeader: map[string]string{},
523 | requestBody: bytes.NewBuffer([]byte(fmt.Sprintf(`{"tableName": "%s"}`, tableName))),
524 | want: configs.selectAllWant,
525 | isErr: false,
526 | },
527 | {
528 | name: "invoke select-templateParams-combined-tool",
529 | api: "http://127.0.0.1:5000/api/tool/select-templateParams-combined-tool/invoke",
530 | requestHeader: map[string]string{},
531 | requestBody: bytes.NewBuffer([]byte(fmt.Sprintf(`{"id": 1, "tableName": "%s"}`, tableName))),
532 | want: configs.selectId1Want,
533 | isErr: false,
534 | },
535 | {
536 | name: "invoke select-templateParams-combined-tool with no results",
537 | api: "http://127.0.0.1:5000/api/tool/select-templateParams-combined-tool/invoke",
538 | requestHeader: map[string]string{},
539 | requestBody: bytes.NewBuffer([]byte(fmt.Sprintf(`{"id": 999, "tableName": "%s"}`, tableName))),
540 | want: configs.selectEmptyWant,
541 | isErr: false,
542 | },
543 | {
544 | name: "invoke select-fields-templateParams-tool",
545 | enabled: configs.supportSelectFields,
546 | api: "http://127.0.0.1:5000/api/tool/select-fields-templateParams-tool/invoke",
547 | requestHeader: map[string]string{},
548 | requestBody: bytes.NewBuffer([]byte(fmt.Sprintf(`{"tableName": "%s", "fields":%s}`, tableName, configs.nameFieldArray))),
549 | want: selectOnlyNamesWant,
550 | isErr: false,
551 | },
552 | {
553 | name: "invoke select-filter-templateParams-combined-tool",
554 | api: "http://127.0.0.1:5000/api/tool/select-filter-templateParams-combined-tool/invoke",
555 | requestHeader: map[string]string{},
556 | requestBody: bytes.NewBuffer([]byte(fmt.Sprintf(`{"name": "Alex", "tableName": "%s", "columnFilter": "%s"}`, tableName, configs.nameColFilter))),
557 | want: configs.selectNameWant,
558 | isErr: false,
559 | },
560 | {
561 | name: "invoke drop-table-templateParams-tool",
562 | ddl: true,
563 | api: "http://127.0.0.1:5000/api/tool/drop-table-templateParams-tool/invoke",
564 | requestHeader: map[string]string{},
565 | requestBody: bytes.NewBuffer([]byte(fmt.Sprintf(`{"tableName": "%s"}`, tableName))),
566 | want: configs.ddlWant,
567 | isErr: false,
568 | },
569 | }
570 | for _, tc := range invokeTcs {
571 | t.Run(tc.name, func(t *testing.T) {
572 | if !tc.enabled {
573 | return
574 | }
575 | // if test case is DDL and source support ddl test cases
576 | ddlAllow := !tc.ddl || (tc.ddl && configs.supportDdl)
577 | // if test case is insert statement and source support insert test cases
578 | insertAllow := !tc.insert || (tc.insert && configs.supportInsert)
579 | if ddlAllow && insertAllow {
580 | // Send Tool invocation request
581 | resp, respBody := RunRequest(t, http.MethodPost, tc.api, tc.requestBody, tc.requestHeader)
582 | if resp.StatusCode != http.StatusOK {
583 | if tc.isErr {
584 | return
585 | }
586 | t.Fatalf("response status code is not 200, got %d: %s", resp.StatusCode, string(respBody))
587 | }
588 |
589 | // Check response body
590 | var body map[string]interface{}
591 | err := json.Unmarshal(respBody, &body)
592 | if err != nil {
593 | t.Fatalf("error parsing response body")
594 | }
595 |
596 | got, ok := body["result"].(string)
597 | if !ok {
598 | t.Fatalf("unable to find result in response body")
599 | }
600 |
601 | if got != tc.want {
602 | t.Fatalf("unexpected value: got %q, want %q", got, tc.want)
603 | }
604 | }
605 | })
606 | }
607 | }
608 |
609 | func RunExecuteSqlToolInvokeTest(t *testing.T, createTableStatement, select1Want string, options ...ExecuteSqlOption) {
610 | // Resolve options
611 | // Default values for ExecuteSqlTestConfig
612 | configs := &ExecuteSqlTestConfig{
613 | select1Statement: `"SELECT 1"`,
614 | }
615 |
616 | // Apply provided options
617 | for _, option := range options {
618 | option(configs)
619 | }
620 |
621 | // Get ID token
622 | idToken, err := GetGoogleIdToken(ClientId)
623 | if err != nil {
624 | t.Fatalf("error getting Google ID token: %s", err)
625 | }
626 |
627 | // Test tool invoke endpoint
628 | invokeTcs := []struct {
629 | name string
630 | api string
631 | requestHeader map[string]string
632 | requestBody io.Reader
633 | want string
634 | isErr bool
635 | }{
636 | {
637 | name: "invoke my-exec-sql-tool",
638 | api: "http://127.0.0.1:5000/api/tool/my-exec-sql-tool/invoke",
639 | requestHeader: map[string]string{},
640 | requestBody: bytes.NewBuffer([]byte(fmt.Sprintf(`{"sql": %s}`, configs.select1Statement))),
641 | want: select1Want,
642 | isErr: false,
643 | },
644 | {
645 | name: "invoke my-exec-sql-tool create table",
646 | api: "http://127.0.0.1:5000/api/tool/my-exec-sql-tool/invoke",
647 | requestHeader: map[string]string{},
648 | requestBody: bytes.NewBuffer([]byte(fmt.Sprintf(`{"sql": %s}`, createTableStatement))),
649 | want: "null",
650 | isErr: false,
651 | },
652 | {
653 | name: "invoke my-exec-sql-tool select table",
654 | api: "http://127.0.0.1:5000/api/tool/my-exec-sql-tool/invoke",
655 | requestHeader: map[string]string{},
656 | requestBody: bytes.NewBuffer([]byte(`{"sql":"SELECT * FROM t"}`)),
657 | want: "null",
658 | isErr: false,
659 | },
660 | {
661 | name: "invoke my-exec-sql-tool drop table",
662 | api: "http://127.0.0.1:5000/api/tool/my-exec-sql-tool/invoke",
663 | requestHeader: map[string]string{},
664 | requestBody: bytes.NewBuffer([]byte(`{"sql":"DROP TABLE t"}`)),
665 | want: "null",
666 | isErr: false,
667 | },
668 | {
669 | name: "invoke my-exec-sql-tool without body",
670 | api: "http://127.0.0.1:5000/api/tool/my-exec-sql-tool/invoke",
671 | requestHeader: map[string]string{},
672 | requestBody: bytes.NewBuffer([]byte(`{}`)),
673 | isErr: true,
674 | },
675 | {
676 | name: "Invoke my-auth-exec-sql-tool with auth token",
677 | api: "http://127.0.0.1:5000/api/tool/my-auth-exec-sql-tool/invoke",
678 | requestHeader: map[string]string{"my-google-auth_token": idToken},
679 | requestBody: bytes.NewBuffer([]byte(fmt.Sprintf(`{"sql": %s}`, configs.select1Statement))),
680 | isErr: false,
681 | want: select1Want,
682 | },
683 | {
684 | name: "Invoke my-auth-exec-sql-tool with invalid auth token",
685 | api: "http://127.0.0.1:5000/api/tool/my-auth-exec-sql-tool/invoke",
686 | requestHeader: map[string]string{"my-google-auth_token": "INVALID_TOKEN"},
687 | requestBody: bytes.NewBuffer([]byte(fmt.Sprintf(`{"sql": %s}`, configs.select1Statement))),
688 | isErr: true,
689 | },
690 | {
691 | name: "Invoke my-auth-exec-sql-tool without auth token",
692 | api: "http://127.0.0.1:5000/api/tool/my-auth-exec-sql-tool/invoke",
693 | requestHeader: map[string]string{},
694 | requestBody: bytes.NewBuffer([]byte(fmt.Sprintf(`{"sql": %s}`, configs.select1Statement))),
695 | isErr: true,
696 | },
697 | {
698 | name: "invoke my-exec-sql-tool with invalid SELECT SQL",
699 | api: "http://127.0.0.1:5000/api/tool/my-exec-sql-tool/invoke",
700 | requestHeader: map[string]string{},
701 | requestBody: bytes.NewBuffer([]byte(`{"sql":"SELECT * FROM non_existent_table"}`)),
702 | isErr: true,
703 | },
704 | {
705 | name: "invoke my-exec-sql-tool with invalid ALTER SQL",
706 | api: "http://127.0.0.1:5000/api/tool/my-exec-sql-tool/invoke",
707 | requestHeader: map[string]string{},
708 | requestBody: bytes.NewBuffer([]byte(`{"sql":"ALTER TALE t ALTER COLUMN id DROP NOT NULL"}`)),
709 | isErr: true,
710 | },
711 | }
712 | for _, tc := range invokeTcs {
713 | t.Run(tc.name, func(t *testing.T) {
714 | // Send Tool invocation request
715 | resp, respBody := RunRequest(t, http.MethodPost, tc.api, tc.requestBody, tc.requestHeader)
716 | if resp.StatusCode != http.StatusOK {
717 | if tc.isErr {
718 | return
719 | }
720 | t.Fatalf("response status code is not 200, got %d: %s", resp.StatusCode, string(respBody))
721 | }
722 |
723 | // Check response body
724 | var body map[string]interface{}
725 | err = json.Unmarshal(respBody, &body)
726 | if err != nil {
727 | t.Fatalf("error parsing response body")
728 | }
729 |
730 | got, ok := body["result"].(string)
731 | if !ok {
732 | t.Fatalf("unable to find result in response body")
733 | }
734 |
735 | if got != tc.want {
736 | t.Fatalf("unexpected value: got %q, want %q", got, tc.want)
737 | }
738 | })
739 | }
740 | }
741 |
742 | // RunInitialize runs the initialize lifecycle for mcp to set up client-server connection
743 | func RunInitialize(t *testing.T, protocolVersion string) string {
744 | url := "http://127.0.0.1:5000/mcp"
745 |
746 | initializeRequestBody := map[string]any{
747 | "jsonrpc": "2.0",
748 | "id": "mcp-initialize",
749 | "method": "initialize",
750 | "params": map[string]any{
751 | "protocolVersion": protocolVersion,
752 | },
753 | }
754 | reqMarshal, err := json.Marshal(initializeRequestBody)
755 | if err != nil {
756 | t.Fatalf("unexpected error during marshaling of body")
757 | }
758 |
759 | resp, _ := RunRequest(t, http.MethodPost, url, bytes.NewBuffer(reqMarshal), nil)
760 | if resp.StatusCode != 200 {
761 | t.Fatalf("response status code is not 200")
762 | }
763 |
764 | if contentType := resp.Header.Get("Content-type"); contentType != "application/json" {
765 | t.Fatalf("unexpected content-type header: want %s, got %s", "application/json", contentType)
766 | }
767 |
768 | sessionId := resp.Header.Get("Mcp-Session-Id")
769 |
770 | header := map[string]string{}
771 | if sessionId != "" {
772 | header["Mcp-Session-Id"] = sessionId
773 | }
774 |
775 | initializeNotificationBody := map[string]any{
776 | "jsonrpc": "2.0",
777 | "method": "notifications/initialized",
778 | }
779 | notiMarshal, err := json.Marshal(initializeNotificationBody)
780 | if err != nil {
781 | t.Fatalf("unexpected error during marshaling of notifications body")
782 | }
783 |
784 | _, _ = RunRequest(t, http.MethodPost, url, bytes.NewBuffer(notiMarshal), header)
785 | return sessionId
786 | }
787 |
788 | // RunMCPToolCallMethod runs the tool/call for mcp endpoint
789 | func RunMCPToolCallMethod(t *testing.T, myFailToolWant, select1Want string, options ...McpTestOption) {
790 | // Resolve options
791 | // Default values for MCPTestConfig
792 | configs := &MCPTestConfig{
793 | myToolId3NameAliceWant: `{"jsonrpc":"2.0","id":"my-tool","result":{"content":[{"type":"text","text":"{\"id\":1,\"name\":\"Alice\"}"},{"type":"text","text":"{\"id\":3,\"name\":\"Sid\"}"}]}}`,
794 | supportClientAuth: false,
795 | supportSelect1Auth: true,
796 | }
797 |
798 | // Apply provided options
799 | for _, option := range options {
800 | option(configs)
801 | }
802 |
803 | sessionId := RunInitialize(t, "2024-11-05")
804 |
805 | // Get access token
806 | accessToken, err := sources.GetIAMAccessToken(t.Context())
807 | if err != nil {
808 | t.Fatalf("error getting access token from ADC: %s", err)
809 | }
810 | accessToken = "Bearer " + accessToken
811 |
812 | idToken, err := GetGoogleIdToken(ClientId)
813 | if err != nil {
814 | t.Fatalf("error getting Google ID token: %s", err)
815 | }
816 |
817 | // Test tool invoke endpoint
818 | invokeTcs := []struct {
819 | name string
820 | api string
821 | enabled bool // switch to turn on/off the test case
822 | requestBody jsonrpc.JSONRPCRequest
823 | requestHeader map[string]string
824 | wantStatusCode int
825 | wantBody string
826 | }{
827 | {
828 | name: "MCP Invoke my-tool",
829 | api: "http://127.0.0.1:5000/mcp",
830 | enabled: true,
831 | requestHeader: map[string]string{},
832 | requestBody: jsonrpc.JSONRPCRequest{
833 | Jsonrpc: "2.0",
834 | Id: "my-tool",
835 | Request: jsonrpc.Request{
836 | Method: "tools/call",
837 | },
838 | Params: map[string]any{
839 | "name": "my-tool",
840 | "arguments": map[string]any{
841 | "id": int(3),
842 | "name": "Alice",
843 | },
844 | },
845 | },
846 | wantStatusCode: http.StatusOK,
847 | wantBody: configs.myToolId3NameAliceWant,
848 | },
849 | {
850 | name: "MCP Invoke invalid tool",
851 | api: "http://127.0.0.1:5000/mcp",
852 | enabled: true,
853 | requestHeader: map[string]string{},
854 | requestBody: jsonrpc.JSONRPCRequest{
855 | Jsonrpc: "2.0",
856 | Id: "invalid-tool",
857 | Request: jsonrpc.Request{
858 | Method: "tools/call",
859 | },
860 | Params: map[string]any{
861 | "name": "foo",
862 | "arguments": map[string]any{},
863 | },
864 | },
865 | wantStatusCode: http.StatusOK,
866 | wantBody: `{"jsonrpc":"2.0","id":"invalid-tool","error":{"code":-32602,"message":"invalid tool name: tool with name \"foo\" does not exist"}}`,
867 | },
868 | {
869 | name: "MCP Invoke my-tool without parameters",
870 | api: "http://127.0.0.1:5000/mcp",
871 | enabled: true,
872 | requestHeader: map[string]string{},
873 | requestBody: jsonrpc.JSONRPCRequest{
874 | Jsonrpc: "2.0",
875 | Id: "invoke-without-parameter",
876 | Request: jsonrpc.Request{
877 | Method: "tools/call",
878 | },
879 | Params: map[string]any{
880 | "name": "my-tool",
881 | "arguments": map[string]any{},
882 | },
883 | },
884 | wantStatusCode: http.StatusOK,
885 | wantBody: `{"jsonrpc":"2.0","id":"invoke-without-parameter","error":{"code":-32602,"message":"provided parameters were invalid: parameter \"id\" is required"}}`,
886 | },
887 | {
888 | name: "MCP Invoke my-tool with insufficient parameters",
889 | api: "http://127.0.0.1:5000/mcp",
890 | enabled: true,
891 | requestHeader: map[string]string{},
892 | requestBody: jsonrpc.JSONRPCRequest{
893 | Jsonrpc: "2.0",
894 | Id: "invoke-insufficient-parameter",
895 | Request: jsonrpc.Request{
896 | Method: "tools/call",
897 | },
898 | Params: map[string]any{
899 | "name": "my-tool",
900 | "arguments": map[string]any{"id": 1},
901 | },
902 | },
903 | wantStatusCode: http.StatusOK,
904 | wantBody: `{"jsonrpc":"2.0","id":"invoke-insufficient-parameter","error":{"code":-32602,"message":"provided parameters were invalid: parameter \"name\" is required"}}`,
905 | },
906 | {
907 | name: "MCP Invoke my-auth-required-tool",
908 | api: "http://127.0.0.1:5000/mcp",
909 | enabled: configs.supportSelect1Auth,
910 | requestHeader: map[string]string{"my-google-auth_token": idToken},
911 | requestBody: jsonrpc.JSONRPCRequest{
912 | Jsonrpc: "2.0",
913 | Id: "invoke my-auth-required-tool",
914 | Request: jsonrpc.Request{
915 | Method: "tools/call",
916 | },
917 | Params: map[string]any{
918 | "name": "my-auth-required-tool",
919 | "arguments": map[string]any{},
920 | },
921 | },
922 | wantStatusCode: http.StatusOK,
923 | wantBody: select1Want,
924 | },
925 | {
926 | name: "MCP Invoke my-auth-required-tool with invalid auth token",
927 | api: "http://127.0.0.1:5000/mcp",
928 | requestHeader: map[string]string{"my-google-auth_token": "INVALID_TOKEN"},
929 | requestBody: jsonrpc.JSONRPCRequest{
930 | Jsonrpc: "2.0",
931 | Id: "invoke my-auth-required-tool with invalid token",
932 | Request: jsonrpc.Request{
933 | Method: "tools/call",
934 | },
935 | Params: map[string]any{
936 | "name": "my-auth-required-tool",
937 | "arguments": map[string]any{},
938 | },
939 | },
940 | wantStatusCode: http.StatusUnauthorized,
941 | wantBody: "{\"jsonrpc\":\"2.0\",\"id\":\"invoke my-auth-required-tool with invalid token\",\"error\":{\"code\":-32600,\"message\":\"unauthorized Tool call: Please make sure your specify correct auth headers: unauthorized\"}}",
942 | },
943 | {
944 | name: "MCP Invoke my-auth-required-tool without auth token",
945 | api: "http://127.0.0.1:5000/mcp",
946 | requestHeader: map[string]string{},
947 | requestBody: jsonrpc.JSONRPCRequest{
948 | Jsonrpc: "2.0",
949 | Id: "invoke my-auth-required-tool without token",
950 | Request: jsonrpc.Request{
951 | Method: "tools/call",
952 | },
953 | Params: map[string]any{
954 | "name": "my-auth-required-tool",
955 | "arguments": map[string]any{},
956 | },
957 | },
958 | wantStatusCode: http.StatusUnauthorized,
959 | wantBody: "{\"jsonrpc\":\"2.0\",\"id\":\"invoke my-auth-required-tool without token\",\"error\":{\"code\":-32600,\"message\":\"unauthorized Tool call: Please make sure your specify correct auth headers: unauthorized\"}}",
960 | },
961 |
962 | {
963 | name: "MCP Invoke my-client-auth-tool",
964 | enabled: configs.supportClientAuth,
965 | api: "http://127.0.0.1:5000/mcp",
966 | requestHeader: map[string]string{"Authorization": accessToken},
967 | requestBody: jsonrpc.JSONRPCRequest{
968 | Jsonrpc: "2.0",
969 | Id: "invoke my-client-auth-tool",
970 | Request: jsonrpc.Request{
971 | Method: "tools/call",
972 | },
973 | Params: map[string]any{
974 | "name": "my-client-auth-tool",
975 | "arguments": map[string]any{},
976 | },
977 | },
978 | wantStatusCode: http.StatusOK,
979 | wantBody: "{\"jsonrpc\":\"2.0\",\"id\":\"invoke my-client-auth-tool\",\"result\":{\"content\":[{\"type\":\"text\",\"text\":\"{\\\"f0_\\\":1}\"}]}}",
980 | },
981 | {
982 | name: "MCP Invoke my-client-auth-tool without access token",
983 | enabled: configs.supportClientAuth,
984 | api: "http://127.0.0.1:5000/mcp",
985 | requestHeader: map[string]string{},
986 | requestBody: jsonrpc.JSONRPCRequest{
987 | Jsonrpc: "2.0",
988 | Id: "invoke my-client-auth-tool",
989 | Request: jsonrpc.Request{
990 | Method: "tools/call",
991 | },
992 | Params: map[string]any{
993 | "name": "my-client-auth-tool",
994 | "arguments": map[string]any{},
995 | },
996 | },
997 | wantStatusCode: http.StatusUnauthorized,
998 | wantBody: "{\"jsonrpc\":\"2.0\",\"id\":\"invoke my-client-auth-tool\",\"error\":{\"code\":-32600,\"message\":\"missing access token in the 'Authorization' header\"}",
999 | },
1000 | {
1001 | name: "MCP Invoke my-client-auth-tool with invalid access token",
1002 | enabled: configs.supportClientAuth,
1003 | api: "http://127.0.0.1:5000/mcp",
1004 | requestHeader: map[string]string{"Authorization": "Bearer invalid-token"},
1005 | requestBody: jsonrpc.JSONRPCRequest{
1006 | Jsonrpc: "2.0",
1007 | Id: "invoke my-client-auth-tool",
1008 | Request: jsonrpc.Request{
1009 | Method: "tools/call",
1010 | },
1011 | Params: map[string]any{
1012 | "name": "my-client-auth-tool",
1013 | "arguments": map[string]any{},
1014 | },
1015 | },
1016 | wantStatusCode: http.StatusUnauthorized,
1017 | },
1018 | {
1019 | name: "MCP Invoke my-fail-tool",
1020 | api: "http://127.0.0.1:5000/mcp",
1021 | enabled: true,
1022 | requestHeader: map[string]string{},
1023 | requestBody: jsonrpc.JSONRPCRequest{
1024 | Jsonrpc: "2.0",
1025 | Id: "invoke-fail-tool",
1026 | Request: jsonrpc.Request{
1027 | Method: "tools/call",
1028 | },
1029 | Params: map[string]any{
1030 | "name": "my-fail-tool",
1031 | "arguments": map[string]any{"id": 1},
1032 | },
1033 | },
1034 | wantStatusCode: http.StatusOK,
1035 | wantBody: myFailToolWant,
1036 | },
1037 | }
1038 | for _, tc := range invokeTcs {
1039 | t.Run(tc.name, func(t *testing.T) {
1040 | if !tc.enabled {
1041 | return
1042 | }
1043 | reqMarshal, err := json.Marshal(tc.requestBody)
1044 | if err != nil {
1045 | t.Fatalf("unexpected error during marshaling of request body")
1046 | }
1047 |
1048 | // add headers
1049 | headers := map[string]string{}
1050 | if sessionId != "" {
1051 | headers["Mcp-Session-Id"] = sessionId
1052 | }
1053 | for key, value := range tc.requestHeader {
1054 | headers[key] = value
1055 | }
1056 |
1057 | httpResponse, respBody := RunRequest(t, http.MethodPost, tc.api, bytes.NewBuffer(reqMarshal), headers)
1058 |
1059 | // Check status code
1060 | if httpResponse.StatusCode != tc.wantStatusCode {
1061 | t.Errorf("StatusCode mismatch: got %d, want %d", httpResponse.StatusCode, tc.wantStatusCode)
1062 | }
1063 |
1064 | // Check response body
1065 | got := string(bytes.TrimSpace(respBody))
1066 | if !strings.Contains(got, tc.wantBody) {
1067 | t.Fatalf("Expected substring not found:\ngot: %q\nwant: %q (to be contained within got)", got, tc.wantBody)
1068 | }
1069 | })
1070 | }
1071 | }
1072 |
1073 | func setupPostgresSchemas(t *testing.T, ctx context.Context, pool *pgxpool.Pool, schemaName string) func() {
1074 | createSchemaStmt := fmt.Sprintf("CREATE SCHEMA %s", schemaName)
1075 | _, err := pool.Exec(ctx, createSchemaStmt)
1076 | if err != nil {
1077 | t.Fatalf("failed to create schema: %v", err)
1078 | }
1079 |
1080 | return func() {
1081 | dropSchemaStmt := fmt.Sprintf("DROP SCHEMA %s", schemaName)
1082 | _, err := pool.Exec(ctx, dropSchemaStmt)
1083 | if err != nil {
1084 | t.Fatalf("failed to drop schema: %v", err)
1085 | }
1086 | }
1087 | }
1088 |
1089 | func RunPostgresListTablesTest(t *testing.T, tableNameParam, tableNameAuth, user string) {
1090 | // TableNameParam columns to construct want
1091 | paramTableColumns := fmt.Sprintf(`[
1092 | {"data_type": "integer", "column_name": "id", "column_default": "nextval('%s_id_seq'::regclass)", "is_not_nullable": true, "ordinal_position": 1, "column_comment": null},
1093 | {"data_type": "text", "column_name": "name", "column_default": null, "is_not_nullable": false, "ordinal_position": 2, "column_comment": null}
1094 | ]`, tableNameParam)
1095 |
1096 | // TableNameAuth columns to construct want
1097 | authTableColumns := fmt.Sprintf(`[
1098 | {"data_type": "integer", "column_name": "id", "column_default": "nextval('%s_id_seq'::regclass)", "is_not_nullable": true, "ordinal_position": 1, "column_comment": null},
1099 | {"data_type": "text", "column_name": "name", "column_default": null, "is_not_nullable": false, "ordinal_position": 2, "column_comment": null},
1100 | {"data_type": "text", "column_name": "email", "column_default": null, "is_not_nullable": false, "ordinal_position": 3, "column_comment": null}
1101 | ]`, tableNameAuth)
1102 |
1103 | const (
1104 | // Template to construct detailed output want
1105 | detailedObjectTemplate = `{
1106 | "object_name": "%[1]s", "schema_name": "public",
1107 | "object_details": {
1108 | "owner": "%[3]s", "comment": null,
1109 | "indexes": [{"is_primary": true, "is_unique": true, "index_name": "%[1]s_pkey", "index_method": "btree", "index_columns": ["id"], "index_definition": "CREATE UNIQUE INDEX %[1]s_pkey ON public.%[1]s USING btree (id)"}],
1110 | "triggers": [], "columns": %[2]s, "object_name": "%[1]s", "object_type": "TABLE", "schema_name": "public",
1111 | "constraints": [{"constraint_name": "%[1]s_pkey", "constraint_type": "PRIMARY KEY", "constraint_columns": ["id"], "constraint_definition": "PRIMARY KEY (id)", "foreign_key_referenced_table": null, "foreign_key_referenced_columns": null}]
1112 | }
1113 | }`
1114 |
1115 | // Template to construct simple output want
1116 | simpleObjectTemplate = `{"object_name":"%s", "schema_name":"public", "object_details":{"name":"%s"}}`
1117 | )
1118 |
1119 | // Helper to build json for detailed want
1120 | getDetailedWant := func(tableName, columnJSON string) string {
1121 | return fmt.Sprintf(detailedObjectTemplate, tableName, columnJSON, user)
1122 | }
1123 |
1124 | // Helper to build template for simple want
1125 | getSimpleWant := func(tableName string) string {
1126 | return fmt.Sprintf(simpleObjectTemplate, tableName, tableName)
1127 | }
1128 |
1129 | invokeTcs := []struct {
1130 | name string
1131 | api string
1132 | requestBody io.Reader
1133 | wantStatusCode int
1134 | want string
1135 | isAllTables bool
1136 | }{
1137 | {
1138 | name: "invoke list_tables all tables detailed output",
1139 | api: "http://127.0.0.1:5000/api/tool/list_tables/invoke",
1140 | requestBody: bytes.NewBuffer([]byte(`{"table_names": ""}`)),
1141 | wantStatusCode: http.StatusOK,
1142 | want: fmt.Sprintf("[%s,%s]", getDetailedWant(tableNameAuth, authTableColumns), getDetailedWant(tableNameParam, paramTableColumns)),
1143 | isAllTables: true,
1144 | },
1145 | {
1146 | name: "invoke list_tables all tables simple output",
1147 | api: "http://127.0.0.1:5000/api/tool/list_tables/invoke",
1148 | requestBody: bytes.NewBuffer([]byte(`{"table_names": "", "output_format": "simple"}`)),
1149 | wantStatusCode: http.StatusOK,
1150 | want: fmt.Sprintf("[%s,%s]", getSimpleWant(tableNameAuth), getSimpleWant(tableNameParam)),
1151 | isAllTables: true,
1152 | },
1153 | {
1154 | name: "invoke list_tables detailed output",
1155 | api: "http://127.0.0.1:5000/api/tool/list_tables/invoke",
1156 | requestBody: bytes.NewBuffer([]byte(fmt.Sprintf(`{"table_names": "%s"}`, tableNameAuth))),
1157 | wantStatusCode: http.StatusOK,
1158 | want: fmt.Sprintf("[%s]", getDetailedWant(tableNameAuth, authTableColumns)),
1159 | },
1160 | {
1161 | name: "invoke list_tables simple output",
1162 | api: "http://127.0.0.1:5000/api/tool/list_tables/invoke",
1163 | requestBody: bytes.NewBuffer([]byte(fmt.Sprintf(`{"table_names": "%s", "output_format": "simple"}`, tableNameAuth))),
1164 | wantStatusCode: http.StatusOK,
1165 | want: fmt.Sprintf("[%s]", getSimpleWant(tableNameAuth)),
1166 | },
1167 | {
1168 | name: "invoke list_tables with invalid output format",
1169 | api: "http://127.0.0.1:5000/api/tool/list_tables/invoke",
1170 | requestBody: bytes.NewBuffer([]byte(`{"table_names": "", "output_format": "abcd"}`)),
1171 | wantStatusCode: http.StatusBadRequest,
1172 | },
1173 | {
1174 | name: "invoke list_tables with malformed table_names parameter",
1175 | api: "http://127.0.0.1:5000/api/tool/list_tables/invoke",
1176 | requestBody: bytes.NewBuffer([]byte(`{"table_names": 12345, "output_format": "detailed"}`)),
1177 | wantStatusCode: http.StatusBadRequest,
1178 | },
1179 | {
1180 | name: "invoke list_tables with multiple table names",
1181 | api: "http://127.0.0.1:5000/api/tool/list_tables/invoke",
1182 | requestBody: bytes.NewBuffer([]byte(fmt.Sprintf(`{"table_names": "%s,%s"}`, tableNameParam, tableNameAuth))),
1183 | wantStatusCode: http.StatusOK,
1184 | want: fmt.Sprintf("[%s,%s]", getDetailedWant(tableNameAuth, authTableColumns), getDetailedWant(tableNameParam, paramTableColumns)),
1185 | },
1186 | {
1187 | name: "invoke list_tables with non-existent table",
1188 | api: "http://127.0.0.1:5000/api/tool/list_tables/invoke",
1189 | requestBody: bytes.NewBuffer([]byte(`{"table_names": "non_existent_table"}`)),
1190 | wantStatusCode: http.StatusOK,
1191 | want: `null`,
1192 | },
1193 | {
1194 | name: "invoke list_tables with one existing and one non-existent table",
1195 | api: "http://127.0.0.1:5000/api/tool/list_tables/invoke",
1196 | requestBody: bytes.NewBuffer([]byte(fmt.Sprintf(`{"table_names": "%s,non_existent_table"}`, tableNameParam))),
1197 | wantStatusCode: http.StatusOK,
1198 | want: fmt.Sprintf("[%s]", getDetailedWant(tableNameParam, paramTableColumns)),
1199 | },
1200 | }
1201 | for _, tc := range invokeTcs {
1202 | t.Run(tc.name, func(t *testing.T) {
1203 | resp, respBytes := RunRequest(t, http.MethodPost, tc.api, tc.requestBody, nil)
1204 | if resp.StatusCode != tc.wantStatusCode {
1205 | t.Fatalf("response status code is not 200, got %d: %s", resp.StatusCode, string(respBytes))
1206 | }
1207 |
1208 | if tc.wantStatusCode == http.StatusOK {
1209 | var bodyWrapper map[string]json.RawMessage
1210 |
1211 | if err := json.Unmarshal(respBytes, &bodyWrapper); err != nil {
1212 | t.Fatalf("error parsing response wrapper: %s, body: %s", err, string(respBytes))
1213 | }
1214 |
1215 | resultJSON, ok := bodyWrapper["result"]
1216 | if !ok {
1217 | t.Fatal("unable to find 'result' in response body")
1218 | }
1219 |
1220 | var resultString string
1221 | if err := json.Unmarshal(resultJSON, &resultString); err != nil {
1222 | t.Fatalf("'result' is not a JSON-encoded string: %s", err)
1223 | }
1224 |
1225 | var got, want []any
1226 |
1227 | if err := json.Unmarshal([]byte(resultString), &got); err != nil {
1228 | t.Fatalf("failed to unmarshal actual result string: %v", err)
1229 | }
1230 | if err := json.Unmarshal([]byte(tc.want), &want); err != nil {
1231 | t.Fatalf("failed to unmarshal expected want string: %v", err)
1232 | }
1233 |
1234 | // Checking only the default public schema where the test tables are created to avoid brittle tests.
1235 | if tc.isAllTables {
1236 | var filteredGot []any
1237 | for _, item := range got {
1238 | if tableMap, ok := item.(map[string]interface{}); ok {
1239 | if schema, ok := tableMap["schema_name"]; ok && schema == "public" {
1240 | filteredGot = append(filteredGot, item)
1241 | }
1242 | }
1243 | }
1244 | got = filteredGot
1245 | }
1246 |
1247 | sort.SliceStable(got, func(i, j int) bool {
1248 | return fmt.Sprintf("%v", got[i]) < fmt.Sprintf("%v", got[j])
1249 | })
1250 | sort.SliceStable(want, func(i, j int) bool {
1251 | return fmt.Sprintf("%v", want[i]) < fmt.Sprintf("%v", want[j])
1252 | })
1253 |
1254 | if !reflect.DeepEqual(got, want) {
1255 | t.Errorf("Unexpected result: got %#v, want: %#v", got, want)
1256 | }
1257 | }
1258 | })
1259 | }
1260 | }
1261 |
1262 | func setUpPostgresViews(t *testing.T, ctx context.Context, pool *pgxpool.Pool, viewName, tableName string) func() {
1263 | createView := fmt.Sprintf("CREATE VIEW %s AS SELECT name FROM %s", viewName, tableName)
1264 | _, err := pool.Exec(ctx, createView)
1265 | if err != nil {
1266 | t.Fatalf("failed to create view: %v", err)
1267 | }
1268 | return func() {
1269 | dropView := fmt.Sprintf("DROP VIEW %s", viewName)
1270 | _, err := pool.Exec(ctx, dropView)
1271 | if err != nil {
1272 | t.Fatalf("failed to drop view: %v", err)
1273 | }
1274 | }
1275 | }
1276 |
1277 | func RunPostgresListViewsTest(t *testing.T, ctx context.Context, pool *pgxpool.Pool, tableName string) {
1278 | viewName1 := "test_view_1" + strings.ReplaceAll(uuid.New().String(), "-", "")
1279 | dropViewfunc1 := setUpPostgresViews(t, ctx, pool, viewName1, tableName)
1280 | defer dropViewfunc1()
1281 |
1282 | invokeTcs := []struct {
1283 | name string
1284 | requestBody io.Reader
1285 | wantStatusCode int
1286 | want string
1287 | }{
1288 | {
1289 | name: "invoke list_views with newly created view",
1290 | requestBody: bytes.NewBuffer([]byte(fmt.Sprintf(`{"viewname": "%s"}`, viewName1))),
1291 | wantStatusCode: http.StatusOK,
1292 | want: fmt.Sprintf(`[{"schemaname":"public","viewname":"%s","viewowner":"postgres"}]`, viewName1),
1293 | },
1294 | {
1295 | name: "invoke list_views with non-existent_view",
1296 | requestBody: bytes.NewBuffer([]byte(`{"viewname": "non_existent_view"}`)),
1297 | wantStatusCode: http.StatusOK,
1298 | want: `null`,
1299 | },
1300 | }
1301 | for _, tc := range invokeTcs {
1302 | t.Run(tc.name, func(t *testing.T) {
1303 | const api = "http://127.0.0.1:5000/api/tool/list_views/invoke"
1304 | resp, body := RunRequest(t, http.MethodPost, api, tc.requestBody, nil)
1305 |
1306 | if resp.StatusCode != tc.wantStatusCode {
1307 | t.Fatalf("wrong status code: got %d, want %d, body: %s", resp.StatusCode, tc.wantStatusCode, string(body))
1308 | }
1309 | if tc.wantStatusCode != http.StatusOK {
1310 | return
1311 | }
1312 |
1313 | var bodyWrapper struct {
1314 | Result json.RawMessage `json:"result"`
1315 | }
1316 | if err := json.Unmarshal(body, &bodyWrapper); err != nil {
1317 | t.Fatalf("error decoding response wrapper: %v", err)
1318 | }
1319 |
1320 | var resultString string
1321 | if err := json.Unmarshal(bodyWrapper.Result, &resultString); err != nil {
1322 | resultString = string(bodyWrapper.Result)
1323 | }
1324 |
1325 | var got, want any
1326 | if err := json.Unmarshal([]byte(resultString), &got); err != nil {
1327 | t.Fatalf("failed to unmarshal nested result string: %v", err)
1328 | }
1329 | if err := json.Unmarshal([]byte(tc.want), &want); err != nil {
1330 | t.Fatalf("failed to unmarshal want string: %v", err)
1331 | }
1332 |
1333 | if diff := cmp.Diff(want, got); diff != "" {
1334 | t.Errorf("Unexpected result (-want +got):\n%s", diff)
1335 | }
1336 | })
1337 | }
1338 | }
1339 |
1340 | func RunPostgresListSchemasTest(t *testing.T, ctx context.Context, pool *pgxpool.Pool) {
1341 | schemaName := "test_schema_" + strings.ReplaceAll(uuid.New().String(), "-", "")
1342 | cleanup := setupPostgresSchemas(t, ctx, pool, schemaName)
1343 | defer cleanup()
1344 |
1345 | wantSchema := map[string]any{"functions": float64(0), "grants": map[string]any{}, "owner": "postgres", "schema_name": schemaName, "tables": float64(0), "views": float64(0)}
1346 |
1347 | invokeTcs := []struct {
1348 | name string
1349 | requestBody io.Reader
1350 | wantStatusCode int
1351 | want []map[string]any
1352 | }{
1353 | {
1354 | name: "invoke list_schemas with schema_name",
1355 | requestBody: bytes.NewBuffer([]byte(fmt.Sprintf(`{"schema_name": "%s"}`, schemaName))),
1356 | wantStatusCode: http.StatusOK,
1357 | want: []map[string]any{wantSchema},
1358 | },
1359 | {
1360 | name: "invoke list_schemas with non-existent schema",
1361 | requestBody: bytes.NewBuffer([]byte(`{"schema_name": "non_existent_schema"}`)),
1362 | wantStatusCode: http.StatusOK,
1363 | want: nil,
1364 | },
1365 | }
1366 | for _, tc := range invokeTcs {
1367 | t.Run(tc.name, func(t *testing.T) {
1368 | const api = "http://127.0.0.1:5000/api/tool/list_schemas/invoke"
1369 | resp, respBody := RunRequest(t, http.MethodPost, api, tc.requestBody, nil)
1370 | if resp.StatusCode != tc.wantStatusCode {
1371 | t.Fatalf("wrong status code: got %d, want %d, body: %s", resp.StatusCode, tc.wantStatusCode, string(respBody))
1372 | }
1373 | if tc.wantStatusCode != http.StatusOK {
1374 | return
1375 | }
1376 |
1377 | var bodyWrapper struct {
1378 | Result json.RawMessage `json:"result"`
1379 | }
1380 | if err := json.Unmarshal(respBody, &bodyWrapper); err != nil {
1381 | t.Fatalf("error decoding response wrapper: %v", err)
1382 | }
1383 |
1384 | var resultString string
1385 | if err := json.Unmarshal(bodyWrapper.Result, &resultString); err != nil {
1386 | resultString = string(bodyWrapper.Result)
1387 | }
1388 |
1389 | var got []map[string]any
1390 | if err := json.Unmarshal([]byte(resultString), &got); err != nil {
1391 | t.Fatalf("failed to unmarshal nested result string: %v", err)
1392 | }
1393 |
1394 | if diff := cmp.Diff(tc.want, got); diff != "" {
1395 | t.Errorf("Unexpected result (-want +got):\n%s", diff)
1396 | }
1397 | })
1398 | }
1399 | }
1400 |
1401 | func RunPostgresListActiveQueriesTest(t *testing.T, ctx context.Context, pool *pgxpool.Pool) {
1402 | type queryListDetails struct {
1403 | ProcessId any `json:"pid"`
1404 | User string `json:"user"`
1405 | Datname string `json:"datname"`
1406 | ApplicationName string `json:"application_name"`
1407 | ClientAddress string `json:"client_addr"`
1408 | State string `json:"state"`
1409 | WaitEventType string `json:"wait_event_type"`
1410 | WaitEvent string `json:"wait_event"`
1411 | BackendStart any `json:"backend_start"`
1412 | TransactionStart any `json:"xact_start"`
1413 | QueryStart any `json:"query_start"`
1414 | QueryDuration any `json:"query_duration"`
1415 | Query string `json:"query"`
1416 | }
1417 |
1418 | singleQueryWanted := queryListDetails{
1419 | ProcessId: any(nil),
1420 | User: "",
1421 | Datname: "",
1422 | ApplicationName: "",
1423 | ClientAddress: "",
1424 | State: "",
1425 | WaitEventType: "",
1426 | WaitEvent: "",
1427 | BackendStart: any(nil),
1428 | TransactionStart: any(nil),
1429 | QueryStart: any(nil),
1430 | QueryDuration: any(nil),
1431 | Query: "SELECT pg_sleep(10);",
1432 | }
1433 |
1434 | invokeTcs := []struct {
1435 | name string
1436 | requestBody io.Reader
1437 | clientSleepSecs int
1438 | waitSecsBeforeCheck int
1439 | wantStatusCode int
1440 | want any
1441 | }{
1442 | // exclude background monitoring apps such as "wal_uploader"
1443 | {
1444 | name: "invoke list_active_queries when the system is idle",
1445 | requestBody: bytes.NewBufferString(`{"exclude_application_names": "wal_uploader"}`),
1446 | clientSleepSecs: 0,
1447 | waitSecsBeforeCheck: 0,
1448 | wantStatusCode: http.StatusOK,
1449 | want: []queryListDetails(nil),
1450 | },
1451 | {
1452 | name: "invoke list_active_queries when there is 1 ongoing but lower than the threshold",
1453 | requestBody: bytes.NewBufferString(`{"min_duration": "100 seconds", "exclude_application_names": "wal_uploader"}`),
1454 | clientSleepSecs: 1,
1455 | waitSecsBeforeCheck: 1,
1456 | wantStatusCode: http.StatusOK,
1457 | want: []queryListDetails(nil),
1458 | },
1459 | {
1460 | name: "invoke list_active_queries when 1 ongoing query should show up",
1461 | requestBody: bytes.NewBufferString(`{"min_duration": "1 seconds", "exclude_application_names": "wal_uploader"}`),
1462 | clientSleepSecs: 10,
1463 | waitSecsBeforeCheck: 5,
1464 | wantStatusCode: http.StatusOK,
1465 | want: []queryListDetails{singleQueryWanted},
1466 | },
1467 | }
1468 |
1469 | var wg sync.WaitGroup
1470 | for _, tc := range invokeTcs {
1471 | t.Run(tc.name, func(t *testing.T) {
1472 | if tc.clientSleepSecs > 0 {
1473 | wg.Add(1)
1474 |
1475 | go func() {
1476 | defer wg.Done()
1477 |
1478 | err := pool.Ping(ctx)
1479 | if err != nil {
1480 | t.Errorf("unable to connect to test database: %s", err)
1481 | return
1482 | }
1483 | _, err = pool.Exec(ctx, fmt.Sprintf("SELECT pg_sleep(%d);", tc.clientSleepSecs))
1484 | if err != nil {
1485 | t.Errorf("Executing 'SELECT pg_sleep' failed: %s", err)
1486 | }
1487 | }()
1488 | }
1489 |
1490 | if tc.waitSecsBeforeCheck > 0 {
1491 | time.Sleep(time.Duration(tc.waitSecsBeforeCheck) * time.Second)
1492 | }
1493 |
1494 | const api = "http://127.0.0.1:5000/api/tool/list_active_queries/invoke"
1495 | resp, respBody := RunRequest(t, http.MethodPost, api, tc.requestBody, nil)
1496 | if resp.StatusCode != tc.wantStatusCode {
1497 | t.Fatalf("wrong status code: got %d, want %d, body: %s", resp.StatusCode, tc.wantStatusCode, string(respBody))
1498 | }
1499 | if tc.wantStatusCode != http.StatusOK {
1500 | return
1501 | }
1502 |
1503 | var bodyWrapper struct {
1504 | Result json.RawMessage `json:"result"`
1505 | }
1506 | if err := json.Unmarshal(respBody, &bodyWrapper); err != nil {
1507 | t.Fatalf("error decoding response wrapper: %v", err)
1508 | }
1509 |
1510 | var resultString string
1511 | if err := json.Unmarshal(bodyWrapper.Result, &resultString); err != nil {
1512 | resultString = string(bodyWrapper.Result)
1513 | }
1514 |
1515 | var got any
1516 | var details []queryListDetails
1517 | if err := json.Unmarshal([]byte(resultString), &details); err != nil {
1518 | t.Fatalf("failed to unmarshal nested ObjectDetails string: %v", err)
1519 | }
1520 | got = details
1521 |
1522 | if diff := cmp.Diff(tc.want, got, cmp.Comparer(func(a, b queryListDetails) bool {
1523 | return a.Query == b.Query
1524 | })); diff != "" {
1525 | t.Errorf("Unexpected result: got %#v, want: %#v", got, tc.want)
1526 | }
1527 | })
1528 | }
1529 | wg.Wait()
1530 | }
1531 |
1532 | func RunPostgresListAvailableExtensionsTest(t *testing.T) {
1533 | invokeTcs := []struct {
1534 | name string
1535 | api string
1536 | requestBody io.Reader
1537 | wantStatusCode int
1538 | }{
1539 | {
1540 | name: "invoke list_available_extensions output",
1541 | api: "http://127.0.0.1:5000/api/tool/list_available_extensions/invoke",
1542 | wantStatusCode: http.StatusOK,
1543 | requestBody: bytes.NewBuffer([]byte(`{}`)),
1544 | },
1545 | }
1546 | for _, tc := range invokeTcs {
1547 | t.Run(tc.name, func(t *testing.T) {
1548 | resp, respBody := RunRequest(t, http.MethodPost, tc.api, tc.requestBody, nil)
1549 | if resp.StatusCode != tc.wantStatusCode {
1550 | t.Fatalf("response status code is not 200, got %d: %s", resp.StatusCode, string(respBody))
1551 | }
1552 |
1553 | // Intentionally not adding the output check as output depends on the postgres instance used where the the functional test runs.
1554 | // Adding the check will make the test flaky.
1555 | })
1556 | }
1557 | }
1558 |
1559 | func RunPostgresListInstalledExtensionsTest(t *testing.T) {
1560 | invokeTcs := []struct {
1561 | name string
1562 | api string
1563 | requestBody io.Reader
1564 | wantStatusCode int
1565 | }{
1566 | {
1567 | name: "invoke list_installed_extensions output",
1568 | api: "http://127.0.0.1:5000/api/tool/list_installed_extensions/invoke",
1569 | wantStatusCode: http.StatusOK,
1570 | requestBody: bytes.NewBuffer([]byte(`{}`)),
1571 | },
1572 | }
1573 | for _, tc := range invokeTcs {
1574 | t.Run(tc.name, func(t *testing.T) {
1575 | resp, bodyBytes := RunRequest(t, http.MethodPost, tc.api, tc.requestBody, nil)
1576 | if resp.StatusCode != tc.wantStatusCode {
1577 | t.Fatalf("response status code is not 200, got %d: %s", resp.StatusCode, string(bodyBytes))
1578 | }
1579 |
1580 | // Intentionally not adding the output check as output depends on the postgres instance used where the the functional test runs.
1581 | // Adding the check will make the test flaky.
1582 | })
1583 | }
1584 | }
1585 |
1586 | // RunMySQLListTablesTest run tests against the mysql-list-tables tool
1587 | func RunMySQLListTablesTest(t *testing.T, databaseName, tableNameParam, tableNameAuth string) {
1588 | type tableInfo struct {
1589 | ObjectName string `json:"object_name"`
1590 | SchemaName string `json:"schema_name"`
1591 | ObjectDetails string `json:"object_details"`
1592 | }
1593 |
1594 | type column struct {
1595 | DataType string `json:"data_type"`
1596 | ColumnName string `json:"column_name"`
1597 | ColumnComment string `json:"column_comment"`
1598 | ColumnDefault any `json:"column_default"`
1599 | IsNotNullable int `json:"is_not_nullable"`
1600 | OrdinalPosition int `json:"ordinal_position"`
1601 | }
1602 |
1603 | type objectDetails struct {
1604 | Owner any `json:"owner"`
1605 | Columns []column `json:"columns"`
1606 | Comment string `json:"comment"`
1607 | Indexes []any `json:"indexes"`
1608 | Triggers []any `json:"triggers"`
1609 | Constraints []any `json:"constraints"`
1610 | ObjectName string `json:"object_name"`
1611 | ObjectType string `json:"object_type"`
1612 | SchemaName string `json:"schema_name"`
1613 | }
1614 |
1615 | paramTableWant := objectDetails{
1616 | ObjectName: tableNameParam,
1617 | SchemaName: databaseName,
1618 | ObjectType: "TABLE",
1619 | Columns: []column{
1620 | {DataType: "int", ColumnName: "id", IsNotNullable: 1, OrdinalPosition: 1},
1621 | {DataType: "varchar(255)", ColumnName: "name", OrdinalPosition: 2},
1622 | },
1623 | Indexes: []any{map[string]any{"index_columns": []any{"id"}, "index_name": "PRIMARY", "is_primary": float64(1), "is_unique": float64(1)}},
1624 | Triggers: []any{},
1625 | Constraints: []any{map[string]any{"constraint_columns": []any{"id"}, "constraint_name": "PRIMARY", "constraint_type": "PRIMARY KEY", "foreign_key_referenced_columns": any(nil), "foreign_key_referenced_table": any(nil), "constraint_definition": ""}},
1626 | }
1627 |
1628 | authTableWant := objectDetails{
1629 | ObjectName: tableNameAuth,
1630 | SchemaName: databaseName,
1631 | ObjectType: "TABLE",
1632 | Columns: []column{
1633 | {DataType: "int", ColumnName: "id", IsNotNullable: 1, OrdinalPosition: 1},
1634 | {DataType: "varchar(255)", ColumnName: "name", OrdinalPosition: 2},
1635 | {DataType: "varchar(255)", ColumnName: "email", OrdinalPosition: 3},
1636 | },
1637 | Indexes: []any{map[string]any{"index_columns": []any{"id"}, "index_name": "PRIMARY", "is_primary": float64(1), "is_unique": float64(1)}},
1638 | Triggers: []any{},
1639 | Constraints: []any{map[string]any{"constraint_columns": []any{"id"}, "constraint_name": "PRIMARY", "constraint_type": "PRIMARY KEY", "foreign_key_referenced_columns": any(nil), "foreign_key_referenced_table": any(nil), "constraint_definition": ""}},
1640 | }
1641 |
1642 | invokeTcs := []struct {
1643 | name string
1644 | requestBody io.Reader
1645 | wantStatusCode int
1646 | want any
1647 | isSimple bool
1648 | isAllTables bool
1649 | }{
1650 | {
1651 | name: "invoke list_tables for all tables detailed output",
1652 | requestBody: bytes.NewBufferString(`{"table_names":""}`),
1653 | wantStatusCode: http.StatusOK,
1654 | want: []objectDetails{authTableWant, paramTableWant},
1655 | isAllTables: true,
1656 | },
1657 | {
1658 | name: "invoke list_tables detailed output",
1659 | requestBody: bytes.NewBufferString(fmt.Sprintf(`{"table_names": "%s"}`, tableNameAuth)),
1660 | wantStatusCode: http.StatusOK,
1661 | want: []objectDetails{authTableWant},
1662 | },
1663 | {
1664 | name: "invoke list_tables simple output",
1665 | requestBody: bytes.NewBufferString(fmt.Sprintf(`{"table_names": "%s", "output_format": "simple"}`, tableNameAuth)),
1666 | wantStatusCode: http.StatusOK,
1667 | want: []map[string]any{{"name": tableNameAuth}},
1668 | isSimple: true,
1669 | },
1670 | {
1671 | name: "invoke list_tables with multiple table names",
1672 | requestBody: bytes.NewBufferString(fmt.Sprintf(`{"table_names": "%s,%s"}`, tableNameParam, tableNameAuth)),
1673 | wantStatusCode: http.StatusOK,
1674 | want: []objectDetails{authTableWant, paramTableWant},
1675 | },
1676 | {
1677 | name: "invoke list_tables with one existing and one non-existent table",
1678 | requestBody: bytes.NewBufferString(fmt.Sprintf(`{"table_names": "%s,non_existent_table"}`, tableNameAuth)),
1679 | wantStatusCode: http.StatusOK,
1680 | want: []objectDetails{authTableWant},
1681 | },
1682 | {
1683 | name: "invoke list_tables with non-existent table",
1684 | requestBody: bytes.NewBufferString(`{"table_names": "non_existent_table"}`),
1685 | wantStatusCode: http.StatusOK,
1686 | want: nil,
1687 | },
1688 | }
1689 | for _, tc := range invokeTcs {
1690 | t.Run(tc.name, func(t *testing.T) {
1691 | const api = "http://127.0.0.1:5000/api/tool/list_tables/invoke"
1692 | resp, body := RunRequest(t, http.MethodPost, api, tc.requestBody, nil)
1693 | if resp.StatusCode != tc.wantStatusCode {
1694 | t.Fatalf("wrong status code: got %d, want %d, body: %s", resp.StatusCode, tc.wantStatusCode, string(body))
1695 | }
1696 | if tc.wantStatusCode != http.StatusOK {
1697 | return
1698 | }
1699 |
1700 | var bodyWrapper struct {
1701 | Result json.RawMessage `json:"result"`
1702 | }
1703 | if err := json.Unmarshal(body, &bodyWrapper); err != nil {
1704 | t.Fatalf("error decoding response wrapper: %v", err)
1705 | }
1706 |
1707 | var resultString string
1708 | if err := json.Unmarshal(bodyWrapper.Result, &resultString); err != nil {
1709 | resultString = string(bodyWrapper.Result)
1710 | }
1711 |
1712 | var got any
1713 | if tc.isSimple {
1714 | var tables []tableInfo
1715 | if err := json.Unmarshal([]byte(resultString), &tables); err != nil {
1716 | t.Fatalf("failed to unmarshal outer JSON array into []tableInfo: %v", err)
1717 | }
1718 | var details []map[string]any
1719 | for _, table := range tables {
1720 | var d map[string]any
1721 | if err := json.Unmarshal([]byte(table.ObjectDetails), &d); err != nil {
1722 | t.Fatalf("failed to unmarshal nested ObjectDetails string: %v", err)
1723 | }
1724 | details = append(details, d)
1725 | }
1726 | got = details
1727 | } else {
1728 | if resultString == "null" {
1729 | got = nil
1730 | } else {
1731 | var tables []tableInfo
1732 | if err := json.Unmarshal([]byte(resultString), &tables); err != nil {
1733 | t.Fatalf("failed to unmarshal outer JSON array into []tableInfo: %v", err)
1734 | }
1735 | var details []objectDetails
1736 | for _, table := range tables {
1737 | var d objectDetails
1738 | if err := json.Unmarshal([]byte(table.ObjectDetails), &d); err != nil {
1739 | t.Fatalf("failed to unmarshal nested ObjectDetails string: %v", err)
1740 | }
1741 | details = append(details, d)
1742 | }
1743 | got = details
1744 | }
1745 | }
1746 |
1747 | opts := []cmp.Option{
1748 | cmpopts.SortSlices(func(a, b objectDetails) bool { return a.ObjectName < b.ObjectName }),
1749 | cmpopts.SortSlices(func(a, b column) bool { return a.ColumnName < b.ColumnName }),
1750 | cmpopts.SortSlices(func(a, b map[string]any) bool { return a["name"].(string) < b["name"].(string) }),
1751 | }
1752 |
1753 | // Checking only the current database where the test tables are created to avoid brittle tests.
1754 | if tc.isAllTables {
1755 | var filteredGot []objectDetails
1756 | if got != nil {
1757 | for _, item := range got.([]objectDetails) {
1758 | if item.SchemaName == databaseName {
1759 | filteredGot = append(filteredGot, item)
1760 | }
1761 | }
1762 | }
1763 | if len(filteredGot) == 0 {
1764 | got = nil
1765 | } else {
1766 | got = filteredGot
1767 | }
1768 | }
1769 |
1770 | if diff := cmp.Diff(tc.want, got, opts...); diff != "" {
1771 | t.Errorf("Unexpected result: got %#v, want: %#v", got, tc.want)
1772 | }
1773 | })
1774 | }
1775 | }
1776 |
1777 | // RunMySQLListActiveQueriesTest run tests against the mysql-list-active-queries tests
1778 | func RunMySQLListActiveQueriesTest(t *testing.T, ctx context.Context, pool *sql.DB) {
1779 | type queryListDetails struct {
1780 | ProcessId any `json:"process_id"`
1781 | Query string `json:"query"`
1782 | TrxStarted any `json:"trx_started"`
1783 | TrxDuration any `json:"trx_duration_seconds"`
1784 | TrxWaitDuration any `json:"trx_wait_duration_seconds"`
1785 | QueryTime any `json:"query_time"`
1786 | TrxState string `json:"trx_state"`
1787 | ProcessState string `json:"process_state"`
1788 | User string `json:"user"`
1789 | TrxRowsLocked any `json:"trx_rows_locked"`
1790 | TrxRowsModified any `json:"trx_rows_modified"`
1791 | Db string `json:"db"`
1792 | }
1793 |
1794 | singleQueryWanted := queryListDetails{
1795 | ProcessId: any(nil),
1796 | Query: "SELECT sleep(10)",
1797 | TrxStarted: any(nil),
1798 | TrxDuration: any(nil),
1799 | TrxWaitDuration: any(nil),
1800 | QueryTime: any(nil),
1801 | TrxState: "",
1802 | ProcessState: "User sleep",
1803 | User: "",
1804 | TrxRowsLocked: any(nil),
1805 | TrxRowsModified: any(nil),
1806 | Db: "",
1807 | }
1808 |
1809 | invokeTcs := []struct {
1810 | name string
1811 | requestBody io.Reader
1812 | clientSleepSecs int
1813 | waitSecsBeforeCheck int
1814 | wantStatusCode int
1815 | want any
1816 | }{
1817 | {
1818 | name: "invoke list_active_queries when the system is idle",
1819 | requestBody: bytes.NewBufferString(`{}`),
1820 | clientSleepSecs: 0,
1821 | waitSecsBeforeCheck: 0,
1822 | wantStatusCode: http.StatusOK,
1823 | want: []queryListDetails(nil),
1824 | },
1825 | {
1826 | name: "invoke list_active_queries when there is 1 ongoing but lower than the threshold",
1827 | requestBody: bytes.NewBufferString(`{"min_duration_secs": 100}`),
1828 | clientSleepSecs: 10,
1829 | waitSecsBeforeCheck: 1,
1830 | wantStatusCode: http.StatusOK,
1831 | want: []queryListDetails(nil),
1832 | },
1833 | {
1834 | name: "invoke list_active_queries when 1 ongoing query should show up",
1835 | requestBody: bytes.NewBufferString(`{"min_duration_secs": 5}`),
1836 | clientSleepSecs: 0,
1837 | waitSecsBeforeCheck: 5,
1838 | wantStatusCode: http.StatusOK,
1839 | want: []queryListDetails{singleQueryWanted},
1840 | },
1841 | {
1842 | name: "invoke list_active_queries when 2 ongoing query should show up",
1843 | requestBody: bytes.NewBufferString(`{"min_duration_secs": 2}`),
1844 | clientSleepSecs: 10,
1845 | waitSecsBeforeCheck: 3,
1846 | wantStatusCode: http.StatusOK,
1847 | want: []queryListDetails{singleQueryWanted, singleQueryWanted},
1848 | },
1849 | }
1850 |
1851 | var wg sync.WaitGroup
1852 | for _, tc := range invokeTcs {
1853 | t.Run(tc.name, func(t *testing.T) {
1854 | if tc.clientSleepSecs > 0 {
1855 | wg.Add(1)
1856 |
1857 | go func() {
1858 | defer wg.Done()
1859 |
1860 | err := pool.PingContext(ctx)
1861 | if err != nil {
1862 | t.Errorf("unable to connect to test database: %s", err)
1863 | return
1864 | }
1865 | _, err = pool.ExecContext(ctx, fmt.Sprintf("SELECT sleep(%d);", tc.clientSleepSecs))
1866 | if err != nil {
1867 | t.Errorf("Executing 'SELECT sleep' failed: %s", err)
1868 | }
1869 | }()
1870 | }
1871 |
1872 | if tc.waitSecsBeforeCheck > 0 {
1873 | time.Sleep(time.Duration(tc.waitSecsBeforeCheck) * time.Second)
1874 | }
1875 |
1876 | const api = "http://127.0.0.1:5000/api/tool/list_active_queries/invoke"
1877 | resp, respBody := RunRequest(t, http.MethodPost, api, tc.requestBody, nil)
1878 | if resp.StatusCode != tc.wantStatusCode {
1879 | t.Fatalf("wrong status code: got %d, want %d, body: %s", resp.StatusCode, tc.wantStatusCode, string(respBody))
1880 | }
1881 | if tc.wantStatusCode != http.StatusOK {
1882 | return
1883 | }
1884 |
1885 | var bodyWrapper struct {
1886 | Result json.RawMessage `json:"result"`
1887 | }
1888 | if err := json.Unmarshal(respBody, &bodyWrapper); err != nil {
1889 | t.Fatalf("error decoding response wrapper: %v", err)
1890 | }
1891 |
1892 | var resultString string
1893 | if err := json.Unmarshal(bodyWrapper.Result, &resultString); err != nil {
1894 | resultString = string(bodyWrapper.Result)
1895 | }
1896 |
1897 | var got any
1898 | var details []queryListDetails
1899 | if err := json.Unmarshal([]byte(resultString), &details); err != nil {
1900 | t.Fatalf("failed to unmarshal nested ObjectDetails string: %v", err)
1901 | }
1902 | got = details
1903 |
1904 | if diff := cmp.Diff(tc.want, got, cmp.Comparer(func(a, b queryListDetails) bool {
1905 | return a.Query == b.Query && a.ProcessState == b.ProcessState
1906 | })); diff != "" {
1907 | t.Errorf("Unexpected result: got %#v, want: %#v", got, tc.want)
1908 | }
1909 | })
1910 | }
1911 | wg.Wait()
1912 | }
1913 |
1914 | func RunMySQLListTablesMissingUniqueIndexes(t *testing.T, ctx context.Context, pool *sql.DB, databaseName string) {
1915 | type listDetails struct {
1916 | TableSchema string `json:"table_schema"`
1917 | TableName string `json:"table_name"`
1918 | }
1919 |
1920 | // bunch of wanted
1921 | nonUniqueKeyTableName := "t03_non_unqiue_key_table"
1922 | noKeyTableName := "t04_no_key_table"
1923 | nonUniqueKeyTableWant := listDetails{
1924 | TableSchema: databaseName,
1925 | TableName: nonUniqueKeyTableName,
1926 | }
1927 | noKeyTableWant := listDetails{
1928 | TableSchema: databaseName,
1929 | TableName: noKeyTableName,
1930 | }
1931 |
1932 | invokeTcs := []struct {
1933 | name string
1934 | requestBody io.Reader
1935 | newTableName string
1936 | newTablePrimaryKey bool
1937 | newTableUniqueKey bool
1938 | newTableNonUniqueKey bool
1939 | wantStatusCode int
1940 | want any
1941 | }{
1942 | {
1943 | name: "invoke list_tables_missing_unique_indexes when nothing to be found",
1944 | requestBody: bytes.NewBufferString(`{}`),
1945 | newTableName: "",
1946 | newTablePrimaryKey: false,
1947 | newTableUniqueKey: false,
1948 | newTableNonUniqueKey: false,
1949 | wantStatusCode: http.StatusOK,
1950 | want: []listDetails(nil),
1951 | },
1952 | {
1953 | name: "invoke list_tables_missing_unique_indexes pk table will not show",
1954 | requestBody: bytes.NewBufferString(`{}`),
1955 | newTableName: "t01",
1956 | newTablePrimaryKey: true,
1957 | newTableUniqueKey: false,
1958 | newTableNonUniqueKey: false,
1959 | wantStatusCode: http.StatusOK,
1960 | want: []listDetails(nil),
1961 | },
1962 | {
1963 | name: "invoke list_tables_missing_unique_indexes uk table will not show",
1964 | requestBody: bytes.NewBufferString(`{}`),
1965 | newTableName: "t02",
1966 | newTablePrimaryKey: false,
1967 | newTableUniqueKey: true,
1968 | newTableNonUniqueKey: false,
1969 | wantStatusCode: http.StatusOK,
1970 | want: []listDetails(nil),
1971 | },
1972 | {
1973 | name: "invoke list_tables_missing_unique_indexes non-unique key only table will show",
1974 | requestBody: bytes.NewBufferString(`{}`),
1975 | newTableName: nonUniqueKeyTableName,
1976 | newTablePrimaryKey: false,
1977 | newTableUniqueKey: false,
1978 | newTableNonUniqueKey: true,
1979 | wantStatusCode: http.StatusOK,
1980 | want: []listDetails{nonUniqueKeyTableWant},
1981 | },
1982 | {
1983 | name: "invoke list_tables_missing_unique_indexes table with no key at all will show",
1984 | requestBody: bytes.NewBufferString(`{}`),
1985 | newTableName: noKeyTableName,
1986 | newTablePrimaryKey: false,
1987 | newTableUniqueKey: false,
1988 | newTableNonUniqueKey: false,
1989 | wantStatusCode: http.StatusOK,
1990 | want: []listDetails{nonUniqueKeyTableWant, noKeyTableWant},
1991 | },
1992 | {
1993 | name: "invoke list_tables_missing_unique_indexes table w/ both pk & uk will not show",
1994 | requestBody: bytes.NewBufferString(`{}`),
1995 | newTableName: "t05",
1996 | newTablePrimaryKey: true,
1997 | newTableUniqueKey: true,
1998 | newTableNonUniqueKey: false,
1999 | wantStatusCode: http.StatusOK,
2000 | want: []listDetails{nonUniqueKeyTableWant, noKeyTableWant},
2001 | },
2002 | {
2003 | name: "invoke list_tables_missing_unique_indexes table w/ uk & nk will not show",
2004 | requestBody: bytes.NewBufferString(`{}`),
2005 | newTableName: "t06",
2006 | newTablePrimaryKey: false,
2007 | newTableUniqueKey: true,
2008 | newTableNonUniqueKey: true,
2009 | wantStatusCode: http.StatusOK,
2010 | want: []listDetails{nonUniqueKeyTableWant, noKeyTableWant},
2011 | },
2012 | {
2013 | name: "invoke list_tables_missing_unique_indexes table w/ pk & nk will not show",
2014 | requestBody: bytes.NewBufferString(`{}`),
2015 | newTableName: "t07",
2016 | newTablePrimaryKey: true,
2017 | newTableUniqueKey: false,
2018 | newTableNonUniqueKey: true,
2019 | wantStatusCode: http.StatusOK,
2020 | want: []listDetails{nonUniqueKeyTableWant, noKeyTableWant},
2021 | },
2022 | {
2023 | name: "invoke list_tables_missing_unique_indexes with a non-exist database, nothing to show",
2024 | requestBody: bytes.NewBufferString(`{"table_schema": "non-exist-database"}`),
2025 | newTableName: "",
2026 | newTablePrimaryKey: false,
2027 | newTableUniqueKey: false,
2028 | newTableNonUniqueKey: false,
2029 | wantStatusCode: http.StatusOK,
2030 | want: []listDetails(nil),
2031 | },
2032 | {
2033 | name: "invoke list_tables_missing_unique_indexes with the right database, show everything",
2034 | requestBody: bytes.NewBufferString(fmt.Sprintf(`{"table_schema": "%s"}`, databaseName)),
2035 | newTableName: "",
2036 | newTablePrimaryKey: false,
2037 | newTableUniqueKey: false,
2038 | newTableNonUniqueKey: false,
2039 | wantStatusCode: http.StatusOK,
2040 | want: []listDetails{nonUniqueKeyTableWant, noKeyTableWant},
2041 | },
2042 | {
2043 | name: "invoke list_tables_missing_unique_indexes with limited output",
2044 | requestBody: bytes.NewBufferString(`{"limit": 1}`),
2045 | newTableName: "",
2046 | newTablePrimaryKey: false,
2047 | newTableUniqueKey: false,
2048 | newTableNonUniqueKey: false,
2049 | wantStatusCode: http.StatusOK,
2050 | want: []listDetails{nonUniqueKeyTableWant},
2051 | },
2052 | }
2053 |
2054 | createTableHelper := func(t *testing.T, tableName, databaseName string, primaryKey, uniqueKey, nonUniqueKey bool, ctx context.Context, pool *sql.DB) func() {
2055 | var stmt strings.Builder
2056 | stmt.WriteString(fmt.Sprintf("CREATE TABLE %s (", tableName))
2057 | stmt.WriteString("c1 INT")
2058 | if primaryKey {
2059 | stmt.WriteString(" PRIMARY KEY")
2060 | }
2061 | stmt.WriteString(", c2 INT, c3 CHAR(8)")
2062 | if uniqueKey {
2063 | stmt.WriteString(", UNIQUE(c2)")
2064 | }
2065 | if nonUniqueKey {
2066 | stmt.WriteString(", INDEX(c3)")
2067 | }
2068 | stmt.WriteString(")")
2069 |
2070 | t.Logf("Creating table: %s", stmt.String())
2071 | if _, err := pool.ExecContext(ctx, stmt.String()); err != nil {
2072 | t.Fatalf("failed executing %s: %v", stmt.String(), err)
2073 | }
2074 |
2075 | return func() {
2076 | t.Logf("Dropping table: %s", tableName)
2077 | if _, err := pool.ExecContext(ctx, fmt.Sprintf("DROP TABLE %s", tableName)); err != nil {
2078 | t.Errorf("failed to drop table %s: %v", tableName, err)
2079 | }
2080 | }
2081 | }
2082 |
2083 | var cleanups []func()
2084 | defer func() {
2085 | for i := len(cleanups) - 1; i >= 0; i-- {
2086 | cleanups[i]()
2087 | }
2088 | }()
2089 |
2090 | for _, tc := range invokeTcs {
2091 | t.Run(tc.name, func(t *testing.T) {
2092 | if tc.newTableName != "" {
2093 | cleanup := createTableHelper(t, tc.newTableName, databaseName, tc.newTablePrimaryKey, tc.newTableUniqueKey, tc.newTableNonUniqueKey, ctx, pool)
2094 | cleanups = append(cleanups, cleanup)
2095 | }
2096 |
2097 | const api = "http://127.0.0.1:5000/api/tool/list_tables_missing_unique_indexes/invoke"
2098 | resp, respBody := RunRequest(t, http.MethodPost, api, tc.requestBody, nil)
2099 | if resp.StatusCode != tc.wantStatusCode {
2100 | t.Fatalf("wrong status code: got %d, want %d, body: %s", resp.StatusCode, tc.wantStatusCode, string(respBody))
2101 | }
2102 | if tc.wantStatusCode != http.StatusOK {
2103 | return
2104 | }
2105 |
2106 | var bodyWrapper struct {
2107 | Result json.RawMessage `json:"result"`
2108 | }
2109 | if err := json.Unmarshal(respBody, &bodyWrapper); err != nil {
2110 | t.Fatalf("error decoding response wrapper: %v", err)
2111 | }
2112 |
2113 | var resultString string
2114 | if err := json.Unmarshal(bodyWrapper.Result, &resultString); err != nil {
2115 | resultString = string(bodyWrapper.Result)
2116 | }
2117 |
2118 | var got any
2119 | var details []listDetails
2120 | if err := json.Unmarshal([]byte(resultString), &details); err != nil {
2121 | t.Fatalf("failed to unmarshal nested listDetails string: %v", err)
2122 | }
2123 | got = details
2124 |
2125 | if diff := cmp.Diff(tc.want, got, cmp.Comparer(func(a, b listDetails) bool {
2126 | return a.TableSchema == b.TableSchema && a.TableName == b.TableName
2127 | })); diff != "" {
2128 | t.Errorf("Unexpected result: got %#v, want: %#v", got, tc.want)
2129 | }
2130 | })
2131 | }
2132 | }
2133 |
2134 | func RunMySQLListTableFragmentationTest(t *testing.T, databaseName, tableNameParam, tableNameAuth string) {
2135 | type tableFragmentationDetails struct {
2136 | TableSchema string `json:"table_schema"`
2137 | TableName string `json:"table_name"`
2138 | DataSize any `json:"data_size"`
2139 | IndexSize any `json:"index_size"`
2140 | DataFree any `json:"data_free"`
2141 | FragmentationPercentage any `json:"fragmentation_percentage"`
2142 | }
2143 |
2144 | paramTableEntryWanted := tableFragmentationDetails{
2145 | TableSchema: databaseName,
2146 | TableName: tableNameParam,
2147 | DataSize: any(nil),
2148 | IndexSize: any(nil),
2149 | DataFree: any(nil),
2150 | FragmentationPercentage: any(nil),
2151 | }
2152 | authTableEntryWanted := tableFragmentationDetails{
2153 | TableSchema: databaseName,
2154 | TableName: tableNameAuth,
2155 | DataSize: any(nil),
2156 | IndexSize: any(nil),
2157 | DataFree: any(nil),
2158 | FragmentationPercentage: any(nil),
2159 | }
2160 |
2161 | invokeTcs := []struct {
2162 | name string
2163 | requestBody io.Reader
2164 | wantStatusCode int
2165 | want any
2166 | }{
2167 | {
2168 | name: "invoke list_table_fragmentation on all, no data_free threshold, expected to have 2 results",
2169 | requestBody: bytes.NewBufferString(`{"data_free_threshold_bytes": 0}`),
2170 | wantStatusCode: http.StatusOK,
2171 | want: []tableFragmentationDetails{authTableEntryWanted, paramTableEntryWanted},
2172 | },
2173 | {
2174 | name: "invoke list_table_fragmentation on all, no data_free threshold, limit to 1, expected to have 1 results",
2175 | requestBody: bytes.NewBufferString(`{"data_free_threshold_bytes": 0, "limit": 1}`),
2176 | wantStatusCode: http.StatusOK,
2177 | want: []tableFragmentationDetails{authTableEntryWanted},
2178 | },
2179 | {
2180 | name: "invoke list_table_fragmentation on all databases and 1 specific table name, no data_free threshold, expected to have 1 result",
2181 | requestBody: bytes.NewBufferString(fmt.Sprintf(`{"table_name": "%s","data_free_threshold_bytes": 0}`, tableNameAuth)),
2182 | wantStatusCode: http.StatusOK,
2183 | want: []tableFragmentationDetails{authTableEntryWanted},
2184 | },
2185 | {
2186 | name: "invoke list_table_fragmentation on 1 database and 1 specific table name, no data_free threshold, expected to have 1 result",
2187 | requestBody: bytes.NewBufferString(fmt.Sprintf(`{"table_schema": "%s", "table_name": "%s", "data_free_threshold_bytes": 0}`, databaseName, tableNameParam)),
2188 | wantStatusCode: http.StatusOK,
2189 | want: []tableFragmentationDetails{paramTableEntryWanted},
2190 | },
2191 | {
2192 | name: "invoke list_table_fragmentation on 1 database and 1 specific table name, high data_free threshold, expected to have 0 result",
2193 | requestBody: bytes.NewBufferString(fmt.Sprintf(`{"table_schema": "%s", "table_name": "%s", "data_free_threshold_bytes": 1000000000}`, databaseName, tableNameParam)),
2194 | wantStatusCode: http.StatusOK,
2195 | want: []tableFragmentationDetails(nil),
2196 | },
2197 | {
2198 | name: "invoke list_table_fragmentation on 1 non-exist database, no data_free threshold, expected to have 0 result",
2199 | requestBody: bytes.NewBufferString(`{"table_schema": "non_existent_database", "data_free_threshold_bytes": 0}`),
2200 | wantStatusCode: http.StatusOK,
2201 | want: []tableFragmentationDetails(nil),
2202 | },
2203 | {
2204 | name: "invoke list_table_fragmentation on 1 non-exist table, no data_free threshold, expected to have 0 result",
2205 | requestBody: bytes.NewBufferString(`{"table_name": "non_existent_table", "data_free_threshold_bytes": 0}`),
2206 | wantStatusCode: http.StatusOK,
2207 | want: []tableFragmentationDetails(nil),
2208 | },
2209 | }
2210 | for _, tc := range invokeTcs {
2211 | t.Run(tc.name, func(t *testing.T) {
2212 | const api = "http://127.0.0.1:5000/api/tool/list_table_fragmentation/invoke"
2213 | resp, respBody := RunRequest(t, http.MethodPost, api, tc.requestBody, nil)
2214 | if resp.StatusCode != tc.wantStatusCode {
2215 | t.Fatalf("wrong status code: got %d, want %d, body: %s", resp.StatusCode, tc.wantStatusCode, string(respBody))
2216 | }
2217 | if tc.wantStatusCode != http.StatusOK {
2218 | return
2219 | }
2220 |
2221 | var bodyWrapper struct {
2222 | Result json.RawMessage `json:"result"`
2223 | }
2224 | if err := json.Unmarshal(respBody, &bodyWrapper); err != nil {
2225 | t.Fatalf("error decoding response wrapper: %v", err)
2226 | }
2227 |
2228 | var resultString string
2229 | if err := json.Unmarshal(bodyWrapper.Result, &resultString); err != nil {
2230 | resultString = string(bodyWrapper.Result)
2231 | }
2232 |
2233 | var got any
2234 | var details []tableFragmentationDetails
2235 | if err := json.Unmarshal([]byte(resultString), &details); err != nil {
2236 | t.Fatalf("failed to unmarshal outer JSON array into []tableInfo: %v", err)
2237 | }
2238 | got = details
2239 |
2240 | if diff := cmp.Diff(tc.want, got, cmp.Comparer(func(a, b tableFragmentationDetails) bool {
2241 | return a.TableSchema == b.TableSchema && a.TableName == b.TableName
2242 | })); diff != "" {
2243 | t.Errorf("Unexpected result: got %#v, want: %#v", got, tc.want)
2244 | }
2245 | })
2246 | }
2247 | }
2248 |
2249 | // RunMSSQLListTablesTest run tests againsts the mssql-list-tables tools.
2250 | func RunMSSQLListTablesTest(t *testing.T, tableNameParam, tableNameAuth string) {
2251 | // TableNameParam columns to construct want.
2252 | const paramTableColumns = `[
2253 | {"column_name": "id", "data_type": "INT", "column_ordinal_position": 1, "is_not_nullable": true},
2254 | {"column_name": "name", "data_type": "VARCHAR(255)", "column_ordinal_position": 2, "is_not_nullable": false}
2255 | ]`
2256 |
2257 | // TableNameAuth columns to construct want
2258 | const authTableColumns = `[
2259 | {"column_name": "id", "data_type": "INT", "column_ordinal_position": 1, "is_not_nullable": true},
2260 | {"column_name": "name", "data_type": "VARCHAR(255)", "column_ordinal_position": 2, "is_not_nullable": false},
2261 | {"column_name": "email", "data_type": "VARCHAR(255)", "column_ordinal_position": 3, "is_not_nullable": false}
2262 | ]`
2263 |
2264 | const (
2265 | // Template to construct detailed output want.
2266 | detailedObjectTemplate = `{
2267 | "schema_name": "dbo",
2268 | "object_name": "%[1]s",
2269 | "object_details": {
2270 | "owner": "dbo",
2271 | "triggers": [],
2272 | "columns": %[2]s,
2273 | "object_name": "%[1]s",
2274 | "object_type": "TABLE",
2275 | "schema_name": "dbo"
2276 | }
2277 | }`
2278 |
2279 | // Template to construct simple output want
2280 | simpleObjectTemplate = `{"object_name":"%s", "schema_name":"dbo", "object_details":{"name":"%s"}}`
2281 | )
2282 |
2283 | // Helper to build json for detailed want
2284 | getDetailedWant := func(tableName, columnJSON string) string {
2285 | return fmt.Sprintf(detailedObjectTemplate, tableName, columnJSON)
2286 | }
2287 |
2288 | // Helper to build template for simple want
2289 | getSimpleWant := func(tableName string) string {
2290 | return fmt.Sprintf(simpleObjectTemplate, tableName, tableName)
2291 | }
2292 |
2293 | invokeTcs := []struct {
2294 | name string
2295 | api string
2296 | requestBody string
2297 | wantStatusCode int
2298 | want string
2299 | isAllTables bool
2300 | }{
2301 | {
2302 | name: "invoke list_tables for all tables detailed output",
2303 | api: "http://127.0.0.1:5000/api/tool/list_tables/invoke",
2304 | requestBody: `{"table_names": ""}`,
2305 | wantStatusCode: http.StatusOK,
2306 | want: fmt.Sprintf("[%s,%s]", getDetailedWant(tableNameAuth, authTableColumns), getDetailedWant(tableNameParam, paramTableColumns)),
2307 | isAllTables: true,
2308 | },
2309 | {
2310 | name: "invoke list_tables for all tables simple output",
2311 | api: "http://127.0.0.1:5000/api/tool/list_tables/invoke",
2312 | requestBody: `{"table_names": "", "output_format": "simple"}`,
2313 | wantStatusCode: http.StatusOK,
2314 | want: fmt.Sprintf("[%s,%s]", getSimpleWant(tableNameAuth), getSimpleWant(tableNameParam)),
2315 | isAllTables: true,
2316 | },
2317 | {
2318 | name: "invoke list_tables detailed output",
2319 | api: "http://127.0.0.1:5000/api/tool/list_tables/invoke",
2320 | requestBody: fmt.Sprintf(`{"table_names": "%s"}`, tableNameAuth),
2321 | wantStatusCode: http.StatusOK,
2322 | want: fmt.Sprintf("[%s]", getDetailedWant(tableNameAuth, authTableColumns)),
2323 | },
2324 | {
2325 | name: "invoke list_tables simple output",
2326 | api: "http://127.0.0.1:5000/api/tool/list_tables/invoke",
2327 | requestBody: fmt.Sprintf(`{"table_names": "%s", "output_format": "simple"}`, tableNameAuth),
2328 | wantStatusCode: http.StatusOK,
2329 | want: fmt.Sprintf("[%s]", getSimpleWant(tableNameAuth)),
2330 | },
2331 | {
2332 | name: "invoke list_tables with invalid output format",
2333 | api: "http://127.0.0.1:5000/api/tool/list_tables/invoke",
2334 | requestBody: `{"table_names": "", "output_format": "abcd"}`,
2335 | wantStatusCode: http.StatusBadRequest,
2336 | },
2337 | {
2338 | name: "invoke list_tables with malformed table_names parameter",
2339 | api: "http://127.0.0.1:5000/api/tool/list_tables/invoke",
2340 | requestBody: `{"table_names": 12345, "output_format": "detailed"}`,
2341 | wantStatusCode: http.StatusBadRequest,
2342 | },
2343 | {
2344 | name: "invoke list_tables with multiple table names",
2345 | api: "http://127.0.0.1:5000/api/tool/list_tables/invoke",
2346 | requestBody: fmt.Sprintf(`{"table_names": "%s,%s"}`, tableNameParam, tableNameAuth),
2347 | wantStatusCode: http.StatusOK,
2348 | want: fmt.Sprintf("[%s,%s]", getDetailedWant(tableNameAuth, authTableColumns), getDetailedWant(tableNameParam, paramTableColumns)),
2349 | },
2350 | {
2351 | name: "invoke list_tables with non-existent table",
2352 | api: "http://127.0.0.1:5000/api/tool/list_tables/invoke",
2353 | requestBody: `{"table_names": "non_existent_table"}`,
2354 | wantStatusCode: http.StatusOK,
2355 | want: `null`,
2356 | },
2357 | {
2358 | name: "invoke list_tables with one existing and one non-existent table",
2359 | api: "http://127.0.0.1:5000/api/tool/list_tables/invoke",
2360 | requestBody: fmt.Sprintf(`{"table_names": "%s,non_existent_table"}`, tableNameParam),
2361 | wantStatusCode: http.StatusOK,
2362 | want: fmt.Sprintf("[%s]", getDetailedWant(tableNameParam, paramTableColumns)),
2363 | },
2364 | }
2365 | for _, tc := range invokeTcs {
2366 | t.Run(tc.name, func(t *testing.T) {
2367 | resp, respBytes := RunRequest(t, http.MethodPost, tc.api, bytes.NewBuffer([]byte(tc.requestBody)), nil)
2368 |
2369 | if resp.StatusCode != tc.wantStatusCode {
2370 | t.Fatalf("response status code is not %d, got %d: %s", tc.wantStatusCode, resp.StatusCode, string(respBytes))
2371 | }
2372 |
2373 | if tc.wantStatusCode == http.StatusOK {
2374 | var bodyWrapper map[string]json.RawMessage
2375 |
2376 | if err := json.Unmarshal(respBytes, &bodyWrapper); err != nil {
2377 | t.Fatalf("error parsing response wrapper: %s, body: %s", err, string(respBytes))
2378 | }
2379 |
2380 | resultJSON, ok := bodyWrapper["result"]
2381 | if !ok {
2382 | t.Fatal("unable to find 'result' in response body")
2383 | }
2384 |
2385 | var resultString string
2386 | if err := json.Unmarshal(resultJSON, &resultString); err != nil {
2387 | if string(resultJSON) == "null" {
2388 | resultString = "null"
2389 | } else {
2390 | t.Fatalf("'result' is not a JSON-encoded string: %s", err)
2391 | }
2392 | }
2393 |
2394 | var got, want []any
2395 |
2396 | if err := json.Unmarshal([]byte(resultString), &got); err != nil {
2397 | t.Fatalf("failed to unmarshal actual result string: %v", err)
2398 | }
2399 | if err := json.Unmarshal([]byte(tc.want), &want); err != nil {
2400 | t.Fatalf("failed to unmarshal expected want string: %v", err)
2401 | }
2402 |
2403 | for _, item := range got {
2404 | itemMap, ok := item.(map[string]any)
2405 | if !ok {
2406 | continue
2407 | }
2408 |
2409 | detailsStr, ok := itemMap["object_details"].(string)
2410 | if !ok {
2411 | continue
2412 | }
2413 |
2414 | var detailsMap map[string]any
2415 | if err := json.Unmarshal([]byte(detailsStr), &detailsMap); err != nil {
2416 | t.Fatalf("failed to unmarshal nested object_details string: %v", err)
2417 | }
2418 |
2419 | // clean unpredictable fields
2420 | delete(detailsMap, "constraints")
2421 | delete(detailsMap, "indexes")
2422 |
2423 | itemMap["object_details"] = detailsMap
2424 | }
2425 |
2426 | // Checking only the default dbo schema where the test tables are created to avoid brittle tests.
2427 | if tc.isAllTables {
2428 | var filteredGot []any
2429 | for _, item := range got {
2430 | if tableMap, ok := item.(map[string]interface{}); ok {
2431 | if schema, ok := tableMap["schema_name"]; ok && schema == "dbo" {
2432 | filteredGot = append(filteredGot, item)
2433 | }
2434 | }
2435 | }
2436 | got = filteredGot
2437 | }
2438 |
2439 | sort.SliceStable(got, func(i, j int) bool {
2440 | return fmt.Sprintf("%v", got[i]) < fmt.Sprintf("%v", got[j])
2441 | })
2442 | sort.SliceStable(want, func(i, j int) bool {
2443 | return fmt.Sprintf("%v", want[i]) < fmt.Sprintf("%v", want[j])
2444 | })
2445 |
2446 | if !reflect.DeepEqual(got, want) {
2447 | gotJSON, _ := json.MarshalIndent(got, "", " ")
2448 | wantJSON, _ := json.MarshalIndent(want, "", " ")
2449 | t.Errorf("Unexpected result:\ngot:\n%s\n\nwant:\n%s", string(gotJSON), string(wantJSON))
2450 | }
2451 | }
2452 | })
2453 | }
2454 | }
2455 |
2456 | // RunRequest is a helper function to send HTTP requests and return the response
2457 | func RunRequest(t *testing.T, method, url string, body io.Reader, headers map[string]string) (*http.Response, []byte) {
2458 | // Send request
2459 | req, err := http.NewRequest(method, url, body)
2460 | if err != nil {
2461 | t.Fatalf("unable to create request: %s", err)
2462 | }
2463 |
2464 | req.Header.Set("Content-type", "application/json")
2465 |
2466 | for k, v := range headers {
2467 | req.Header.Set(k, v)
2468 | }
2469 |
2470 | resp, err := http.DefaultClient.Do(req)
2471 | if err != nil {
2472 | t.Fatalf("unable to send request: %s", err)
2473 | }
2474 | respBody, err := io.ReadAll(resp.Body)
2475 | if err != nil {
2476 | t.Fatalf("unable to read request body: %s", err)
2477 | }
2478 |
2479 | defer resp.Body.Close()
2480 | return resp, respBody
2481 | }
2482 |
```