Warehouse connectors¶
dosi-engine executes by SQL pushdown: it compiles a metric query to dialect SQL
and the warehouse runs it. A connector (adapter) handles connection setup,
statement submission, and normalizing results into a ResultSet (columns +
rows of Null | Bool | Int | Float | Str, dates as ISO-8601 strings) so
results compare equal across engines.
This page is the setup guide for each connector. Arrow-native result transfer (DuckDB, ClickHouse, StarRocks/Doris, Databricks) is described in arrow.md; the internal maturity ladder, status matrix, and changelog live in design/connector-maturity.md.
Connection profiles¶
Connection profiles use the Datus agent.yml
datasources: vocabulary — dosi-exec keeps its own minimal QueryExecutor
interface (deliberately not aligned with datus-db-adapters), but the
configuration is shared so a Datus setup needs no translation.
--connections <path> (env DOSI_CONNECTIONS) accepts either a full
agent.yml (profiles under services.datasources) or a standalone file
with the same map under a top-level datasources:. Without the flag, the
file is discovered at ./dosi-connections.yaml,
~/.config/dosi/connections.yaml, ./conf/agent.yml, then
~/.datus/conf/agent.yml — an existing Datus install works with zero config.
The pre-rename ./osi-connections.yaml and ~/.config/osi/connections.yaml
are still discovered as fallbacks, each just after its dosi- counterpart.
datasources:
local-duck:
type: duckdb
uri: duckdb:///warehouse.duckdb # duckdb:////abs vs duckdb:///rel; omit for in-memory
prod-sr:
type: starrocks
host: sr.internal
port: 9030
username: osi
password: ${SR_PASSWORD} # ${VAR} / ${VAR:-fallback} from the environment
database: analytics
default: true # used by --execute without --connection
ch:
type: clickhouse
uri: http://localhost:8123 # or host/port
username: default
database: default
trino:
type: trino
host: trino.internal
port: 8080
catalog: hive # session catalog for the compiled schema.table names
schema: sales
snowflake:
type: snowflake
account: ab12345.us-east-2.aws # full identifier; JWT uses the leading locator
username: DOSI_TEST
warehouse: COMPUTE_WH
role: SYSADMIN # optional
database: ANALYTICS # session default for two-part names
private_key_file: ~/.config/dosi/snowflake_key.p8 # PKCS#8 PEM
private_key_file_pwd: ${DOSI_SF_KEY_PASS} # omit if the key is unencrypted
Semantics worth knowing:
- A secret written exactly as
${VAR}resolves lazily — the file loads with the variable unset, and the error (with anexporthint) fires only when that connection is used. A plaintextpassword:works but warns on stderr. - A broken entry (unset
${VAR}in a datasource you aren't using, an unsupportedtype: sqlite, …) never takes the file down; its error surfaces when the entry is named. Unknown entry keys are ignored (Datus adapters keep private settings there); the pre-1.0 osi spellings (dialect,user,password_env,path,url, a top-levelconnections:list) are rejected with a migration hint. - At most one entry may set
default: true.
Run: dosi query --metrics revenue --group-by orders.status --execute --connection prod-sr.
--execute without --connection uses the default: true profile if one
is marked, else local DuckDB (--db <file> or in-memory).
Connectors¶
DuckDB¶
In-process via the bundled duckdb crate (the default exec-duckdb
feature): one lazily-opened connection per executor, results decoded once as
Arrow RecordBatches (query_arrow) with rows derived through the shared
normalizer. init_sql runs once at connection open; execute_batch works on
in-memory databases (state persists on the connection). No external binary
needed. Profile fields: uri (duckdb:///...; omit = in-memory).
--no-default-features swaps in the historical CLI shell-out body (system
duckdb binary, JSON mode, fresh process per query — in-memory
execute_batch is a config error there). Kept one release for lean builds.
datasources:
local_duckdb:
type: duckdb
uri: duckdb:////absolute/path/warehouse.duckdb # duckdb:///rel/path = relative; omit = in-memory
MySQL / TiDB / StarRocks / Doris¶
One adapter (dosi-exec/src/mysql.rs), MySQL wire protocol, text protocol on
purpose (StarRocks/Doris prepared-statement support is patchy). All four pass
the full corpus against live servers (MySQL 8.4, TiDB v8.5, StarRocks 3.3,
Doris 2.1).
MySQL and TiDB have no FULL OUTER JOIN, but that is no longer a query
limitation: a multi-branch merge (multiple metrics at different grains) is
lowered there to the portable UNION-keys CTE + LEFT JOIN shape, the same
one the Postgres family uses. StarRocks and Doris keep the FULL OUTER form.
All four share the same profile shape — only type differs. A driver URL
also works: uri: mysql://user:pass@host:port/db.
Arrow Flight SQL (exec-flightsql, StarRocks/Doris only): add
arrow_flight_port: to the profile and queries switch to the Flight
executor — the FE plans, the client pulls Arrow batches directly from the
BEs (columnar end to end, no row serialization through the FE). The key's
value is the FE's flight port (StarRocks fe.conf arrow_flight_port,
StarRocks ≥3.5.1; Doris fe.conf arrow_flight_sql_port, Doris ≥2.1.5 — one
osi key for both). Remove the key to fall back to MySQL wire.
prod-sr:
type: starrocks
host: sr.internal
port: 9030 # MySQL wire — still used as seeding/DDL fallback
arrow_flight_port: 9408 # presence opts queries into Arrow Flight SQL
username: osi
password: ${SR_PASSWORD}
database: analytics
Findings from first live contact (StarRocks 4.0.5, 2026-07-13 — all handled in the adapter or documented):
- No DoPut:
acceptPutStatementis unimplemented — the adapter runs every statement (DDL/DML/USE) through the query path (GetFlightInfo) instead ofexecute_update. - Database-level DDL fails over Flight on SR 4.0 (
CREATE/DROP DATABASE→ generic RPC error); table-level DDL and DML reach the analyzer. Seed and manage schemas over MySQL wire (the corpus flight run does exactly that). - BE reachability: the FE hands clients each BE's own address for direct
reads. Client and BE must agree on that address — in Docker publish the BE
flight port 1:1 (
9419:9419), or use the engines' FE-proxy fallbacks (StarRocksarrow_flight_proxy*session vars; Dorispublic_host+arrow_flight_sql_proxy_port). - The FE flight service can reset the very first connection after startup — the adapter retries the connect once.
datasources:
mysql_prod:
type: mysql # or tidb
host: ${MYSQL_HOST}
port: 3306 # tidb default: 4000
username: osi
password: ${MYSQL_PASSWORD}
database: analytics
starrocks:
type: starrocks # or doris
host: ${STARROCKS_HOST}
port: 9030 # FE MySQL-protocol port (doris: 9030 too)
username: ${STARROCKS_USER}
password: ${STARROCKS_PASSWORD}
database: ${STARROCKS_DATABASE}
Postgres¶
postgres crate (sync facade). Decode strategy: prepare for authoritative
type OIDs, simple_query for text values — NUMERIC (SUM/AVG output)
normalizes without a decimal dependency. Sessions are pinned to UTC
(SET TIME ZONE 'UTC') so TIMESTAMPTZ text trims to zone-less ISO regardless
of the server default. 32/32 corpus vs Postgres 16 — re-verified against a
server configured with timezone=America/Los_Angeles (PST).
datasources:
warehouse_pg:
type: postgres
host: pg.internal # or uri: postgres://osi:pass@pg.internal/analytics
port: 5432
username: osi
password: ${PG_PASSWORD}
database: analytics
Hologres¶
Alibaba Cloud Hologres speaks the Postgres wire protocol, so it rides the
Postgres driver and inherits its decode strategy verbatim (prepare for type
OIDs, simple_query for text; sessions pinned to UTC). Compiled SQL is
generated as PostgreSQL. Two things are Hologres-specific:
- Batch execution sends one statement at a time. A multi-statement script
is a single implicit transaction, and Hologres rejects a transaction that
mixes DDL with DML — so
execute_batchsplits the script (respecting string literals, quoted identifiers, dollar-quoted bodies, and comments) and sends each statement separately on the same connection. This is the same restriction the datus-hologres adapter raises fromexecute_queries. - The console endpoint may carry its own port.
host:accepts<instance>.hologres.aliyuncs.comor<instance>.hologres.aliyuncs.com:80; a port in both places must agree. A bare hostname defaults to port 80 (what Hologres publishes), not 5432.
Credentials are an Alibaba Cloud AccessKey pair. username/password and
access_key_id/access_key_secret are aliases — use one spelling, not both;
the AccessKey spellings are rejected on any other type: so they can never
silently drop a credential.
datasources:
hologres:
type: hologres
host: ${HOLOGRES_HOST} # or host:port; bare hostname defaults to :80
port: ${HOLOGRES_PORT:-80}
username: ${HOLOGRES_ACCESS_KEY_ID} # alias: access_key_id
password: ${HOLOGRES_ACCESS_KEY_SECRET} # alias: access_key_secret
database: ${HOLOGRES_DATABASE}
schema: public # default; pins search_path
sslmode: disable # see the TLS note below
TLS: sslmode accepts the full libpq vocabulary, with libpq's exact
verification ladder: require encrypts without verifying (so self-signed
server certificates work) — unless sslrootcert: is also set, in which case
the chain is verified like verify-ca (libpq's documented back-compat
behavior); verify-ca checks the chain against
the CA bundle named by sslrootcert: (system roots excluded — trust exactly
that CA), and verify-full adds hostname verification. verify-* without
sslrootcert is rejected at construction with an actionable error. Hologres
public endpoints on port 80 are plaintext (sslmode: disable), which is the
configuration the corpus is verified against.
Build with --features exec-hologres (implied by exec-all). The corpus run
is gated on HOLOGRES_HOST + HOLOGRES_ACCESS_KEY_ID /
HOLOGRES_ACCESS_KEY_SECRET / HOLOGRES_DATABASE (the same variables the
Python adapter documents), or on a plain DOSI_TEST_HOLOGRES_URL.
95/95 corpus vs live Hologres (2026-08-01), no declared skips — the first non-DuckDB engine with a clean sweep.
Two dialect details are handled for you:
DATEDIFFis rewritten, not rejected. Hologres hasDATEDIFF, but with the unit last (DATEDIFF(end, start, 'day'), computingd1 - d2), where models are written in the unit-first form. The compiler rotates the argument list, which fixes the unit position and the sign together. A call already written in Hologres' own spelling is left alone. Hologres' unit vocabulary is a subset — noweek, noquarter.- Sessions are pinned to UTC, which matters more here than on Postgres.
Hologres does not accept
DATEforDATE_TRUNC(its signature takesTIME|TIMESTAMP|TIMESTAMPTZ), so a date widens to TIMESTAMPTZ and the result carries a zone — and instances default toPRC(+08). The pin keeps time-grain buckets zone-less and comparable across engines.
One difference dosi does not normalize: Hologres uses C collation, so
ORDER BY on text sorts by byte. That matches DuckDB but differs from a stock
en_US.UTF-8 Postgres.
GaussDB¶
Huawei GaussDB / openGauss is PostgreSQL-derived (9.2 lineage): compiled SQL
is generated as PostgreSQL, and the decode strategy is the Postgres
executor's verbatim (prepare for type OIDs, simple_query for text; sessions
pinned to UTC). What sets it apart is the wire protocol: GaussDB replaces
PostgreSQL's SASL authentication with a private SHA256 handshake whose
auth-code numbers collide with PG's SASL — stock Postgres drivers (libpq,
tokio-postgres) fail before the password is even checked. The executor
therefore uses the gaussdb driver (a rust-postgres fork carrying Datus'
RFC 5802 fixes; see design/gaussdb-sha256-auth.md) and speaks that
handshake natively, so servers with pg_hba method sha256 — the GaussDB
production default — work without any server-side configuration change.
Authentication support, by the server's hba method × the account's password
storage format (password_encryption_type at the time the password was set):
| password storage hba | md5 |
sha256 |
|---|---|---|
MD5 (type=0) |
✅ | ✅ (server falls back to MD5) |
SHA256 (type=2, default) |
❌ rejected with hint¹ | ✅ native SHA256 |
MD5+SHA256 (type=1) |
✅ | ✅ native SHA256 |
¹ In this hybrid the server omits the PBKDF2 iteration count from the
handshake, so no client can derive the key. The error hint says so: re-set
the account password under password_encryption_type = 1, or switch the hba
method to sha256. SM3-stored passwords (type=3) are likewise rejected
with an actionable error.
datasources:
gaussdb:
type: gaussdb
host: gauss.internal # or uri: postgres://osi:pass@gauss.internal:8000/analytics
port: 8000 # GaussDB(DWS) default; openGauss uses 5432
username: osi
password: ${GAUSSDB_PASSWORD}
database: analytics
schema: main # optional; pins search_path
sslmode: require # managed instances enable ssl=on
# sslrootcert: /etc/ssl/gauss-ca.pem # only for verify-ca / verify-full
Compatibility modes: GaussDB databases are created in PG, A
(Oracle — the GaussDB default) or B (MySQL) compatibility mode; newer
GaussDB kernels add M (full-MySQL). PG, A and B are supported: the engine's emitted SQL surface is verified against
each mode by the full corpus (149/150 on PG, A and B alike — the one
skip is the same 3-arg DATEDIFF case Postgres itself declares), and the
per-construct semantic differences are recorded and continuously asserted in
design/gaussdb-compat-contract.yaml.
Declare the mode you expect in the profile and the executor will verify it on first connect, refusing to run on a mismatch (a wrong assumption changes semantics silently, so this fails hard):
M-compatibility databases are refused at first connect with an
actionable error: M is not a semantic variant but a different SQL dialect
entirely — MySQL syntax where even CAST(x AS varchar) is a syntax error
(verified live on GaussDB Kernel 505), so PostgreSQL-dialect SQL cannot run
there.
Two mode-specific data semantics are declared rather than papered over,
because they live in the stored data, not in queries: in A mode the empty
string is NULL (writing '' stores NULL; col = '' never matches — use
col = '' OR col IS NULL for a portable emptiness test), and in B mode
string equality ignores trailing spaces (MySQL PAD SPACE). Two further
divergences are fleet-level rather than GaussDB-specific and are documented
in the contract file: integer division yields reals in every GaussDB mode
(the MySQL/ClickHouse side of an existing engine split), and B sorts NULLs
first ascending (exactly like MySQL/TiDB).
TLS: same libpq sslmode/sslrootcert treatment as the Postgres
connector, on the fork's native-tls implementation. Managed Huawei-cloud
instances typically enable ssl=on with a self-signed certificate —
sslmode: require is the right setting there (verified live against a
managed GaussDB Kernel 505.2.1).
Build with --features exec-gaussdb (implied by exec-all). The corpus run
is gated on DOSI_TEST_GAUSSDB_URL (a postgres:// endpoint);
tests/docker/ ships an openGauss service preconfigured with a
PG-compatibility database and an sha256-hba login.
149/150 corpus vs live openGauss 7.0.0-RC2 (2026-08-13), over the native
SHA256 handshake — the one declared skip is the same 3-arg DATEDIFF case
Postgres itself declares (rejected with the identical SQLSTATE 42883).
Oracle¶
Oracle Database has a first-class generator: compiled SQL uses FETCH FIRST
for limits, TRUNC(x, 'FMT') for time-grain truncation, quoted interval
literals (INTERVAL '1' MONTH), and table aliases without AS. Oracle 23ai+
even ships a native 3-arg DATEDIFF, so the case every other engine declares
as a skip runs there (19c/21c would need the skip — declare it when a live
run of those vintages exists).
The executor uses the oracle crate over ODPI-C. There is no build-time
Oracle dependency — ODPI-C compiles with the crate and dlopens the
Oracle Instant Client (free, ~80MB) at first connect. Only machines that
actually reach Oracle need it installed; everyone else is unaffected. If it
is missing, connecting fails with an actionable DPI-1047 hint. Install:
Linux dnf install oracle-instantclient-basic (or unzip + LD_LIBRARY_PATH),
macOS: mount the DMG and symlink libclntsh.dylib into ~/lib — the
architecture must match the binary (an x86_64 build under Rosetta needs the
Intel client).
Sessions are pinned at connect: TIME_ZONE = 'UTC' (the zone-less Value
contract) and ISO NLS_DATE_FORMAT/NLS_TIMESTAMP_FORMAT — the compiled
CAST('2024-01-01' AS DATE) literals parse via NLS, whose DD-MON-RR
default would reject them.
datasources:
oracle:
type: oracle
host: db.internal # or uri: oracle://osi:pass@db.internal:1521/ORCLPDB1
port: 1521
username: osi
password: ${ORACLE_PASSWORD}
database: ORCLPDB1 # the service name
Oracle schema == user: compiled SQL references main.<table>, so the login
user (or a schema it can see) is main — the corpus container sets
APP_USER: main. One semantic difference dosi does not normalize:
Oracle treats '' as NULL.
Build with --features exec-oracle (implied by exec-all). The corpus run
is gated on DOSI_TEST_ORACLE_URL (oracle://user:pass@host:port/service);
tests/docker/ ships a gvenzl/oracle-free (23ai) service.
150/150 corpus vs Oracle 23ai Free (2026-08-13), zero declared skips —
joining DuckDB, Snowflake, and Hologres as the zero-skip engines, courtesy
of 23ai's native DATEDIFF.
ClickHouse¶
HTTP interface with JSONCompact output and
output_format_json_quote_64bit_integers=0. One statement per request →
seeding splits batches client-side. Profile: uri (or host/port),
username, password, database. 45/45 corpus vs ClickHouse 24.8
(2026-07-13, incl. per-case Arrow parity).
Arrow path (exec-http-arrow): the same POST with
default_format=ArrowStream — batches decode as a true stream
(lz4-framed; output_format_arrow_string_as_string=1 keeps strings Utf8).
Gotcha handled in the adapter: ClickHouse's Arrow writer exports Date as
bare UInt16 (epoch days) and DateTime as bare UInt32 (epoch seconds), and
v24.8 has no output setting to preserve them — the adapter runs
DESCRIBE (query) first (type inference only, no execution) and
re-types those columns to Date32/Timestamp on the fly (a pure
reinterpretation; DateTime64 and Date32 already export correctly).
datasources:
ch:
type: clickhouse
uri: http://ch.internal:8123 # or host/port (port defaults to 8123)
username: default
password: ${CLICKHOUSE_PASSWORD}
database: analytics
Trino¶
REST /v1/statement with nextUri polling (50→200 ms backoff). HTTP 503 is
retried per the protocol ("busy, ask again"), bounded at ~10 s before
surfacing a timeout error. Tests seed through the built-in memory
catalog. Profile: uri (or host/port), username (X-Trino-User),
catalog/schema (or database as catalog[.schema]) session defaults.
32/32 corpus vs Trino 467.
datasources:
trino:
type: trino
host: trino.internal
port: 8080 # or uri: http://trino.internal:8080
username: osi # sent as X-Trino-User
catalog: hive # session catalog for compiled schema.table names
schema: sales # optional session schema
Snowflake¶
dosi-exec/src/snowflake.rs on the exec-snowflake feature (ureq + rustls
TLS, rsa/sha2/pkcs8 for auth — all gated, no SDK). Talks the SQL API v2
(/api/v2/statements) with key-pair JWT auth: the JWT iss/sub use the
account locator (uppercased, region stripped) and a SHA-256 fingerprint of
the public key; the PKCS#8 private key may be PBES2-encrypted (passphrase via
private_key_file_pwd: ${VAR}). Profile: account, username, warehouse,
optional role/schema/database, private_key_file.
datasources:
snowflake:
type: snowflake
account: ${SNOWFLAKE_ACCOUNT} # full identifier, e.g. ab12345.us-east-2.aws
username: ${SNOWFLAKE_USER}
warehouse: COMPUTE_WH
role: SYSADMIN # optional
database: ANALYTICS # session default for two-part names
schema: PUBLIC # optional
private_key_file: ~/.config/dosi/snowflake_key.p8 # PKCS#8 PEM
private_key_file_pwd: ${SNOWFLAKE_KEY_PASSPHRASE} # omit if unencrypted
Two gotchas the corpus surfaced, both handled in the adapter:
- Stateless API — each request is independent, so
USEnever persists. Warehouse/database/schema context ships in every request body; seed tables are fully qualified (DB.main.<table>) so the bootstrapCREATE DATABASEisn't blocked by a not-yet-existent context DB, while queries carry the database context so a two-partmain.<table>resolves. - DATE encoding — the API returns DATE as a count of days since the
epoch (e.g.
19723= 2024-01-01), not an ISO string; the decoder converts it. Unquoted identifiers come back upper-cased, so the corpus compares column names case-insensitively.
45/45 corpus live against a live account (2026-07-12). CI is credential-gated
in L2 weekly (repo secrets DOSI_TEST_SNOWFLAKE_*), skipped where unset.
Databricks¶
dosi-exec/src/databricks.rs on the exec-databricks feature (ureq + rustls
TLS; no SDK). Talks the Statement Execution API
(/api/2.0/sql/statements) against a SQL warehouse with Personal Access
Token (Bearer) auth. Results are requested disposition: INLINE,
format: JSON_ARRAY; a statement that doesn't finish inside wait_timeout
is polled to a terminal state, and results spanning multiple inline chunks
are stitched via result/chunks/{n}. Profile: host, warehouse (the SQL
warehouse id), password (the PAT), catalog, optional schema.
datasources:
databricks:
type: databricks
host: dbc-xxxx.cloud.databricks.com # a pasted https://.../ URL is tolerated
warehouse: e9678899fc3ffa09 # SQL warehouse id (from Connection details)
password: ${DATABRICKS_TOKEN} # a dapi... PAT
catalog: workspace # Unity Catalog catalog (session context)
schema: analytics # optional default schema for queries
Two things the corpus surfaced, both handled:
- Catalog as context, not
USE— the API is stateless per request, so thecatalogships in every request body. Seeding runs with the catalog context only (fully-qualifiedmain.<table>names) so the bootstrapCREATE SCHEMA mainisn't gated on a not-yet-existent default schema; queries carry catalog (+ optional schema) so two-partmain.<table>resolves. The corpus therefore needs a writable catalog — the read-onlysamplescatalog can't be seeded. - Encoding — every cell is a JSON string keyed by
manifest.schema.columns[].type_name; unlike Snowflake, DATE already comes back ISO (2024-01-01), so no epoch-days shim. TIMESTAMP does arrive ISO-8601 (2020-01-01T00:00:00.000Z), canonicalized to the reference's2020-01-01 00:00:00. Unquoted identifiers return lower-cased, so the corpus compares column names case-insensitively.
Arrow-native path (exec-databricks-arrow): the same executor also
implements execute_arrow with format=ARROW_STREAM +
disposition=EXTERNAL_LINKS — each result chunk is a self-contained Arrow-IPC
stream on a presigned cloud-storage URL, fetched without the workspace
token (the URL is already signed) and decoded once with arrow-ipc; the row
execute() derives from those batches via the shared normalizer. No
re-typing (unlike ClickHouse): Databricks exports real Date32/Timestamp
Arrow types. A zero-row result carries no links, so the schema is synthesized
from the manifest (found by the parity gate). This is the recommended path for
Databricks over the Foundry ADBC driver: the wire is already columnar Arrow,
so the driver's C-Data-Interface zero-copy handoff saves nothing a direct
arrow-ipc decode doesn't — and it avoids a heavy Go .so (see
design/arrow-adbc.md §4).
45/45 corpus live against a free-tier Serverless SQL warehouse (2026-07-13),
row and Arrow paths both green (Arrow parity vs the row path and the DuckDB
reference). CI is credential-gated in L2 weekly (repo secrets
DOSI_TEST_DATABRICKS_*), skipped where unset.