PostgreSQL¶
Read reference data straight from a PostgreSQL database — a reporting
replica, a data warehouse, an ERP export schema — into the session context.
One read-only SELECT per source; the result becomes a context table or a
scalar.
Direction: context only.
1. Create the connection¶
Settings → Connections → Add Connection, type PostgreSQL:
| Field | Value |
|---|---|
| Connection DSN | postgresql://user@host:5432/dbname?sslmode=require — user, host, port, database, and sslmode in the DSN; no password |
| Password | The database password, stored encrypted separately from the DSN |
Test opens a connection and runs SELECT 1 inside a read-only
transaction.
POST /api/integration-connections
{
"name": "ERP reporting DB",
"type": "postgres",
"base_url": "postgresql://reporting@db.example.com:5432/erp?sslmode=require",
"credentials": "<password>"
}
Use a read-only role
Expecto enforces read-only access twice (below), but a database role
with SELECT on the needed views only is the third and strongest layer
— create one for Expecto instead of reusing an application user.
2. Declare context sources¶
{
"sources": [
{
"name": "prev_postings",
"connection_id": "<connection uuid>",
"query": {
"kind": "sql",
"sql": "SELECT account, SUM(amount) AS amount FROM postings WHERE fund = {{ context.fund_number }} AND period = {{ context.prev_period }} GROUP BY account"
},
"produces": { "table": "prev_postings" }
},
{
"name": "exchange_rate",
"connection_id": "<connection uuid>",
"query": {
"kind": "sql",
"sql": "SELECT rate FROM fx WHERE currency = 'USD' AND period = {{ session.period }}"
},
"produces": { "scalar": "exchange_rate" }
}
]
}
The query object has exactly two keys: kind: "sql" and sql.
How the SQL is protected¶
- At save time the template is parsed with SQLGlot (PostgreSQL
dialect) and must be one statement that is a
SELECTorWITH … SELECT. DML, DDL, multiple statements, and CTEs that smuggle anINSERT … RETURNINGare rejected with a 422 naming the problem. - At fetch time each
{{ … }}placeholder becomes a positional bind parameter ($1,$2, …); values are never interpolated into the SQL text. The statement runs insideBEGIN TRANSACTION READ ONLYwith astatement_timeoutof 15 s. Connecting has a 10 s timeout.
Consequences for writing queries:
- Write placeholders without quotes:
period = {{ session.period }}, not'{{ session.period }}'— the value is bound, not pasted. - A placeholder cannot stand in for an identifier (table or column name) or a keyword; only for a value.
- Cast on the SQL side if the column type differs from the bound text:
fund_id = {{ context.fund_number }}::int.
Limits¶
| Limit | Value | When exceeded |
|---|---|---|
| Rows per source | 10 000 | query_error: "Query returned more than 10000 rows; narrow it with a filter" |
| Statement time | 15 s | query_error: "Query exceeded the 15s time limit" |
| Connect time | 10 s | connection_error |
What a source produces¶
- Table — every selected column becomes a column of the context table
context._tables.<name>(DuckDB tablectx_<name>inside rules and transformers). Values are converted to JSON-friendly types:numericandDecimalto numbers, dates and timestamps to ISO strings,UUIDto text. - Scalar — the query must return exactly one column; the first
row's value becomes
context.<name>. No rows is anempty_resulterror.
For a required table target (per the project's context schema) an empty
result set is an empty_result error; for an optional one it yields an
empty table.
3. Schema, test, and fetch¶
The remaining steps are the same as for every context provider:
- Declare the produced scalar/table in the project's context schema with
filled_by: "integration"— see BuiltAPI › Wire it into the context schema. - Dry-run a source with Test query /
POST /api/integration-connections/test-source/ MCPtest_context_source, passing sample values such as{"context.fund_number": "F-1001"}. - At session creation the fetch runs in the worker, blocks the pipeline
until it completes, and can be re-run with
POST /api/sessions/{id}/context/fetch— see BuiltAPI › What happens at session creation.
Troubleshooting¶
error_kind / message |
Cause | Fix |
|---|---|---|
connection_error |
Host unreachable, port closed, TLS required but sslmode missing, connect timeout |
Check the DSN host/port and sslmode; make sure the database accepts connections from the Expecto egress. |
auth_error |
Wrong password or role not allowed to log in | Rotate the password in Edit; check pg_hba.conf for the role. |
query_error: "query.sql must be a single read-only SELECT" |
The template contains DML/DDL or several statements | Keep one SELECT; move writes elsewhere. |
query_error: "…produces a scalar; the query must select exactly one column" |
A scalar source selects several columns | Select only the value column. |
query_error: "…more than 10000 rows" |
Unbounded result | Add a WHERE on the period/fund, aggregate, or produce a narrower view. |
empty_result |
The filter matched nothing and the target is required | Check the placeholder values in the session context; mark the target optional if absence is legitimate. |