Connecting via ADBC (Arrow Flight SQL)¶
OBSL's Arrow Flight SQL surface (FLIGHT_ENABLED=true) speaks the protocol
ADBC's flightsql driver talks, so any ADBC client can query a loaded model
and get Arrow back without a translation hop. Where the
Postgres wire surface exists so BI tools can use
a connector they already ship, this one exists for programs that want columnar
data: a client that reads Arrow gets the server's Arrow, unconverted.
Everything on this page is asserted by tests/integration/test_adbc_flightsql.py,
which drives a real ADBC client against a real Flight server. Run it with
uv run pytest -m adbc_flight.
Start the server¶
DB_VENDOR=duckdb \
DUCKDB_DATABASE=/path/to/warehouse.duckdb \
MODEL_FILES=/path/to/model.yaml \
FLIGHT_ENABLED=true \
FLIGHT_PORT=8815 \
uv run orionbelt-api
FLIGHT_ENABLED implies QUERY_EXECUTE. The gRPC URI is then
grpc://<host>:8815 — or grpc+tls:// behind TLS.
Connect¶
from adbc_driver_flightsql import dbapi
with dbapi.connect("grpc://127.0.0.1:8815") as conn:
with conn.cursor() as cur:
cur.execute('SELECT "Customer Country", "Total Revenue" FROM sales')
table = cur.fetch_arrow_table()
fetch_arrow_table() is the point: the rows never become Python objects on
the way past.
Picking a model¶
A deployment can serve several models (MODEL_FILES loads each into its own
named session). Flight reads the model from the gRPC database /
x-obsl-model header, and ADBC sets arbitrary call headers:
conn = dbapi.connect(
"grpc://127.0.0.1:8815",
db_kwargs={"adbc.flight.sql.rpc.call_header.x-obsl-model": "sales"},
)
Authenticating¶
With AUTH_MODE=api_key, the Flight server validates the handshake credential
against the same key store the REST surface uses. The key goes in as the
Basic-auth password:
conn = dbapi.connect(
"grpc://127.0.0.1:8815",
db_kwargs={"username": "obsl", "password": "<your API key>"},
)
What you can send¶
The Flight surface takes OBSQL — SELECT <dimension|measure> FROM <model>,
where the names are the model's, not the warehouse's. It is a semantic layer,
not a SQL proxy, so a statement is compiled against the model rather than
forwarded:
Raw physical columns are addressable too, qualified by data object:
What is refused, and why¶
| Statement | Result |
|---|---|
SELECT * FROM sales |
Refused. A model has no * — a star would have to invent a column list, and which columns a semantic layer returns is a governance decision. |
SELECT ... FROM nonexistent_relation |
Refused, as an error rather than an empty result. |
| Arbitrary warehouse SQL | Not forwarded. There is no escape hatch to the underlying database. |
A refusal arrives as an exception, not as zero rows — a client can tell "nothing matched" from "that was not allowed".
Prepared statements and parameters¶
Prepared statements bind over DoPut, and the client can discover what to bind:
sql = (
'SELECT "Customer Country", "Total Revenue" FROM sales '
'WHERE "Customer Country" = ?'
)
with conn.cursor() as cur:
parameters = cur.adbc_prepare(sql)
print(parameters) # $1: string
cur.execute(sql, parameters=("US",))
us = cur.fetch_arrow_table()
cur.execute(sql, parameters=("DE",)) # the same handle, bound again
de = cur.fetch_arrow_table()
The parameter schema is the model's, not an inference: the column a
placeholder is compared against has a declared type, so a decimal(18, 2)
measure binds at that width and a count binds as an integer. A raw-mode
parameter takes the type its data object declares.
A bound value is substituted as a parsed literal, never spliced into text, so it cannot change the shape of the statement it lands in — and what executes has been through the same governance an inline literal gets.
| Rebinding a handle | Supported — that is what preparing is for. |
| Parameter sets (one execution per row) | Refused by name. Bind and execute once per row. |
| Wrong parameter count | Refused. Binding too few silently shifts every later value onto the wrong column. |
| Executing without binding | Refused, naming how many parameters are missing. |
Catalog and discovery¶
GetCatalogs, GetDbSchemas, GetTables, GetColumns and GetTableTypes
are answered from the model — no warehouse hop. So are the SQL forms BI tools
send: SHOW TABLES, DESCRIBE <model>, and selects against
information_schema.tables / .columns and their pg_catalog equivalents.
These honour the statement they are given:
SELECT table_name FROM information_schema.tables WHERE table_type = 'VIEW'
SELECT column_name FROM information_schema.columns WHERE ordinal_position > ?
Supported predicates are =, <>, <, <=, >, >=, LIKE, ILIKE,
IN, IS NULL, and AND/OR/NOT over them, with the column on either
side. A predicate outside that set leaves the result unfiltered rather than
failing, since clients probe a long tail of system tables — but a parameter
cannot be bound into one, because a bound value that is then ignored is worse
than a refusal.
Types¶
A column's type is a property of the model, not of the engine behind it. Where
a warehouse cannot express a declared type, OBSL reconciles it: MySQL has no
boolean, so a declared one arrives as true/false rather than 1/0, and
Dremio's date64[ms] narrows to the date32[day] every other engine returns.
Reconciliation is deliberately narrow — an engine answering more precisely
than the declaration is not drift, so a decimal(38, 2) behind a measure
declared float is left alone. See
Arrow type fidelity for the per-engine
measurements and what is refused.
Known limitations¶
| Parameter sets | Not implemented; refused rather than truncated to the first row. |
| Catalog projection | Row selection and column projection apply; other clauses (ORDER BY, LIMIT) on catalog views do not. |
CommandGetXdbcTypeInfo |
Streams a single info: utf8 column rather than the spec shape. ADBC accepts it because the advertised schema matches the stream. |
Statistics (GetStatistics) |
Not implemented. |
| Transactions | Flight SQL exposes no transaction to begin — OBSL is read-only. ADBC warns that autocommit cannot be disabled; the warning is expected. |