Profile PostgreSQL

Availability

Stable 1.4.0 includes the exact component/allowlist PostgreSQL workflow, credential-free JDBC-style endpoints, qualified column wildcards, and the aggregate-only profile-query command.

Install the optional driver:

pip install "agent-paranoid-android[postgres]"

Configure one explicit read-only scope. The password setting names another environment variable; it is not the password itself.

export POSTGRES_SOURCE_ID=warehouse
export POSTGRES_HOST=db.example.internal
export POSTGRES_DATABASE=analytics
export POSTGRES_USER=test_data_agent
export POSTGRES_PASSWORD_ENV=WAREHOUSE_PASSWORD
export WAREHOUSE_PASSWORD='replace-in-your-shell'
export POSTGRES_ALLOWED_SCHEMAS=public
export POSTGRES_ALLOWED_TABLES=public.customers,public.orders
export POSTGRES_ALLOWED_COLUMNS=public.customers.customer_id,public.customers.status,public.orders.order_id,public.orders.customer_id,public.orders.state

For an intentionally table-wide aggregate profile, an entry may instead use the exact schema.table.* form:

export POSTGRES_ALLOWED_COLUMNS='public.customers.*,public.orders.*'

The parent tables must still appear exactly in POSTGRES_ALLOWED_TABLES. Before any aggregate runs, bounded PostgreSQL catalog metadata expands each wildcard into a frozen, sorted explicit-column snapshot. Profiling queries enumerate quoted column identifiers; they never execute SELECT *. Bare, schema-wide, table-name, embedded, or over-budget wildcards fail closed.

If a platform portal supplies a JDBC endpoint, replace only the separate host, port, database, and TLS settings with a credential-free JDBC-style URL:

export POSTGRES_JDBC_URL='jdbc:postgresql://db.example.internal:5432/analytics?sslmode=verify-full'

Keep POSTGRES_USER, POSTGRES_PASSWORD_ENV, every allowlist, and every budget separate. Userinfo, passwords, unknown query properties, and session-changing options in the URL fail before a connection is opened. This syntax is parsed into the existing Psycopg adapter; Java and JDBC drivers are not used. If both URL and component settings are present, explicitly supplied values must match.

The database role must already be read-only. The client also requests a read-only transaction, TLS, statement and lock timeouts, and bounded aggregate results. It accepts no arbitrary SQL and never profiles source rows.

Create a safe profile, review a generation specification, generate, and validate. Output paths must not already exist unless the command explicitly supports --overwrite:

test-data-agent profile-postgres --output out/postgres-profile.json
test-data-agent infer-spec out/postgres-profile.json --output out/dataset-spec.yaml
test-data-agent generate out/dataset-spec.yaml --seed 12345 --output out/generated
test-data-agent validate out/dataset-spec.yaml out/generated
test-data-agent export-postgres-sql out/dataset-spec.yaml --seed 12345 --output out/generated.sql

Run the SQL only in the intended local or disposable target database:

psql --set ON_ERROR_STOP=1 --dbname synthetic_target --file out/generated.sql

The SQL file contains one transaction, deterministic quoted CREATE TABLE, foreign-key, and INSERT statements, PostgreSQL scalar literals, and NULL. It is built from validated generated records, not profile query rows. Render or validation failure leaves no partial output file. Repeating export with the same reviewed spec, seed, package, and recorded environment produces the same logical SQL; compare files directly when verifying one environment:

test-data-agent export-postgres-sql out/dataset-spec.yaml \
  --seed 12345 --output out/generated-second.sql
cmp out/generated.sql out/generated-second.sql

To profile a reviewed derived relation without creating a database view, put one fully qualified single-table query in a local file:

SELECT o.order_id, o.state, o.amount * 2 AS doubled_amount
FROM public.orders AS o
WHERE o.order_id < 999999

Keep the same physical schema/table/column allowlists and run:

test-data-agent profile-query query.sql \
  --adapter postgres \
  --source-id warehouse \
  --entity orders_query \
  --output out/query-profile.json
test-data-agent infer-spec out/query-profile.json \
  --output out/query-spec.yaml
test-data-agent generate out/query-spec.yaml \
  --seed 12345 --output out/query-generated
test-data-agent validate out/query-spec.yaml out/query-generated

The query is parsed locally and must stay inside the documented scalar, projection, and filter subset. A no-row schema probe and bounded aggregate wrappers execute in the forced read-only session. The profile contains a query fingerprint but not the SQL text, its literal, backend messages, or query rows.

The equivalent typed Python entry point keeps the query path and database configuration explicit:

from pathlib import Path

import psycopg

from test_data_agent import (
    SqlQueryAdapter,
    SqlQueryProfileRequest,
    profile_postgres_query_source,
)
from test_data_agent.postgres_config import PostgresConfig

request = SqlQueryProfileRequest(
    adapter=SqlQueryAdapter.POSTGRES,
    source_id="warehouse",
    entity="orders_query",
    query_file=Path("query.sql"),
)
profile = profile_postgres_query_source(
    request,
    config=PostgresConfig.from_env(),
    driver=psycopg,
)

Exact values remain disabled by default. A bounded, reviewed, non-sensitive business enum can be kept locally with its source-qualified identity:

test-data-agent profile-postgres \
  --local-category warehouse.public.orders.state \
  --output out/postgres-profile.json

PII, secrets, identifiers, quasi-identifiers, free text, excessive cardinality, and long values fail closed. Original literals are not sent to providers, MCP, logs, or errors. A column wildcard is not a local-value policy: exact values are considered only when the separate, fully qualified --local-category selector is present and its content checks pass.

The selector authorizes only the bounded value domain and aggregate counts. It does not retain source row order or a mapping that reconstructs source rows. The generated SQL may contain approved values because its input is the validated synthetic dataset.

The same profiling boundary is available from Python:

import psycopg

from test_data_agent.postgres_client import PostgresClient
from test_data_agent.postgres_config import PostgresConfig
from test_data_agent import generate_dataset, infer_dataset_spec, validate_dataset
from test_data_agent.postgres_profiler import dataset_profile_from_postgres

config = PostgresConfig.from_env()
profile = dataset_profile_from_postgres(PostgresClient(config, psycopg))
spec = infer_dataset_spec(profile)
# Review or edit spec here before generation.
rows = generate_dataset(spec, seed=12345)
report = validate_dataset(rows, spec)
assert report.valid

Keep profile and generated artifacts local unless their destination policy explicitly permits otherwise.

For a complete synthetic end-to-end check against a temporary local PostgreSQL cluster, run the examples/local_postgres example. It creates a SELECT-only role, proves that writes are denied, profiles two related tables, validates deterministic generation, executes the exported SQL in an empty target database, and removes the cluster. Run examples/local_postgres/run-jdbc.sh OUTPUT for the same workflow configured by a placeholder JDBC-style URL, or examples/local_postgres/run-wildcard.sh OUTPUT for bounded table-qualified wildcard expansion. Run examples/local_postgres/run-query.sh OUTPUT for the reviewed query-file path.