SDK Interface Reference
The SDK, nl2sql-sdk (imported as nl2sql_sdk), holds the interfaces an
extension implements. Its core depends on pydantic and the standard library only.
- Adapters:
nl2sql_sdk.DatasourceAdapter(interface version 2), with the models it is built from (ConnectionModel,Limits,IndexScope) and returns (ConnectionCheck,SchemaSnapshot,ResultFrame) - The SQLAlchemy base:
nl2sql_sdk.sqlalchemy.SQLAlchemyAdapterandSQLAlchemyConnection, installed withpip install "nl2sql-sdk[sqlalchemy]". The one part of the SDK that imports SQLAlchemy; see SQLAlchemy adapters. - The conformance kit:
nl2sql_sdk.testing.AdapterConformance, installed withpip install "nl2sql-sdk[testing]", a pytest test class every adapter should pass; see the conformance kit. - Secret providers:
nl2sql_sdk.secrets.SecretProvider - Stores: the four protocols in
nl2sql_sdk.stores - Policy checks:
nl2sql_sdk.policy.PolicyCheck, withUserContextandGrant
Everything but the stores is also exported from nl2sql_sdk itself.
Interface: DatasourceAdapter
from nl2sql_sdk import (AdapterRequest, ConnectionCheck, ConnectionModel, DatasourceAdapter,
DatasourceCapability, IndexScope, Limits, ResultFrame, SchemaSnapshot)
The contract, version 2:
class DatasourceAdapter(Protocol):
interface_version: ClassVar[int] # 2
title: ClassVar[str] # "PostgreSQL", for people and forms
connection_model: ClassVar[type[ConnectionModel]]
driver_modules: ClassVar[tuple[str, ...]]
install_hint: ClassVar[str]
def __init__(self, datasource_id: str, connection: ConnectionModel, limits: Limits,
*, folder: Path | None = None) -> None: ... # never connects
def capabilities(self) -> set[DatasourceCapability]: ...
def get_dialect(self) -> str: ... # a sqlglot dialect name
def default_schema(self) -> str | None: ...
def test_connection(self) -> ConnectionCheck: ...
def fetch_schema_snapshot(self, scope: IndexScope,
on_table: Callable[[str], None] | None = None) -> SchemaSnapshot: ...
def execute(self, request: AdapterRequest) -> ResultFrame: ...
def close(self) -> None: ...
Class attributes
The class attributes let the engine check an adapter, and show it to people, before it builds one.
| Attribute | Type | Description |
|---|---|---|
interface_version |
int |
The version of this interface the adapter was written for: 2. The engine accepts exactly the version it was built for, and any other version is a load error. |
title |
str |
The database's name for people, such as "PostgreSQL". |
connection_model |
type[ConnectionModel] |
The model the datasource's connection block is validated against. See The connection model. |
driver_modules |
tuple[str, ...] |
The modules the adapter needs to connect, for example ("psycopg2",). The engine looks for each one with importlib.util.find_spec before it builds the adapter. |
install_hint |
str |
What to install when a driver module is missing, for example pip install "nl2sql-engine[postgres]". |
The adapter module must import without its driver, so import the driver where it is used and not at the top of the module. The built-in adapters declare:
| Adapter | title |
connection_model |
driver_modules |
Extra |
|---|---|---|---|---|
sqlite |
SQLite | SqliteConnection |
("sqlite3",) |
none: sqlite3 ships with Python |
postgres |
PostgreSQL | PostgresConnection |
("psycopg2",) |
nl2sql-engine[postgres] |
mysql |
MySQL | MysqlConnection |
("pymysql", "cryptography") |
nl2sql-engine[mysql] |
mssql |
SQL Server | MssqlConnection |
("pyodbc",) |
nl2sql-engine[mssql], plus Microsoft's ODBC driver |
duckdb |
DuckDB | DuckdbConnection |
("duckdb", "duckdb_engine") |
nl2sql-engine[duckdb] |
Each connection model is public, next to its adapter
(from nl2sql.adapters.postgres.adapter import PostgresConnection).
The connection model
ConnectionModel is a pydantic model with extra="forbid" and
hide_input_in_errors=True. Subclass it with one field per setting:
from typing import Literal
from pydantic import Field, SecretStr
from nl2sql_sdk import ConnectionModel
class PostgresConnection(ConnectionModel):
host: str = Field(title="Host", examples=["db.internal"])
port: int = Field(5432, title="Port")
database: str = Field(title="Database")
user: str = Field(title="User")
password: SecretStr = Field(title="Password")
sslmode: Literal["disable", "prefer", "require", "verify-full"] = Field(
"prefer", title="SSL mode", json_schema_extra={"group": "advanced"})
- Strict. An unknown key is an error that names the field it was probably
meant to be:
connection.hostname: unknown key 'hostname'. Did you mean 'host'?. No error message holds a value from the connection. - Validated when the adapter is built. The engine resolves every
${provider:key}reference in theconnectionblock, validates it againstconnection_model, and passes the instance to the constructor. From Python,Datasource(connection=PostgresConnection(...))passes an instance, which is used as given; another adapter's model is refused. - Secrets. Declare a credential as
SecretStr: it never prints, and its JSON Schema is"format": "password", "writeOnly": true. - The form.
connection_model.model_json_schema()describes the form for connecting a database:Field(title, description, examples)label it, andjson_schema_extra={"group": "advanced"}marks a field to fold away.
The constructor
adapter_cls(datasource_id, connection, limits, *, folder=None):
| Argument | What it is |
|---|---|
datasource_id |
The datasource's id. Keep it as self.datasource_id. |
connection |
The validated connection_model instance. |
limits |
The datasource's Limits: row_limit, timeout_ms, max_bytes, each None when unset. Enforce timeout_ms the way your driver allows. The engine caps a query's rows itself, from the datasource's limits. |
folder |
The folder of the nl2sql.yaml the datasource came from, or None when the config was built in code. Read a relative file path from it. |
The constructor must not connect: the adapter connects on first use, and
engine.check() connects through test_connection().
Methods
capabilities() -> set[DatasourceCapability]
Declares what the adapter does. Declare a flag only when it holds on a real server.
| Flag | Meaning |
|---|---|
SUPPORTS_SQL |
The adapter runs SQL requests (plan_type="sql"). The SQL agent serves only a datasource that declares it. |
SUPPORTS_BOUND_PARAMETERS |
The adapter binds AdapterRequest.parameters by name to the :name placeholders in the SQL, and never pastes a value into the SQL text. The engine sends parameters (for a verified query) only to an adapter that declares it. Every built-in SQLAlchemy adapter does. |
ENFORCES_READ_ONLY |
The session cannot write: an INSERT or CREATE TABLE through execute fails. |
ENFORCES_TIMEOUT |
A statement running past limits.timeout_ms is stopped by the server or the driver. |
get_dialect() -> str
The sqlglot dialect name the engine renders SQL in: postgres, tsql,
mysql, duckdb, sqlite, or any other name sqlglot.Dialect.get_or_raise
accepts. It is not the SQLAlchemy dialect name (postgresql, mssql).
default_schema() -> str | None
The schema an unqualified table name resolves to, or None when unknown.
Policies read a plan table with no schema as a table in this schema; without
one, main is assumed.
test_connection() -> ConnectionCheck
Connects and reports what it found. It returns a failure rather than raising it:
| Field | Meaning |
|---|---|
ok |
Whether the adapter connected. |
stage |
Which stage failed: config, driver, network, auth, database or permission; None when the adapter cannot tell. |
message |
Why, in one line, with no secret in it. |
fix |
What to do about it, when the adapter knows. |
server_version |
The server's version, when connected. |
engine.check() calls it for each datasource and reports the stage, message
and fix in the datasource's status.
fetch_schema_snapshot(scope, on_table=None) -> SchemaSnapshot
The schema of the tables and views the datasource's index scope wants, with
their metadata. scope is an IndexScope (see
index): call
scope.wants_table(ref) and scope.wants_column(ref, column) rather than
matching its patterns yourself, and leave out your database's system schemas.
on_table is called with each table's schema.table as it is read, for
progress.
The snapshot's issues list holds an IndexIssue (table, column,
stage, message, and error_type, the exception's class name when an
exception caused it) for each table or column indexed without something, and
each table's row_count_source says where its row_count came from:
catalog (an estimate) or exact (a count).
execute(request: AdapterRequest) -> ResultFrame
Executes a plan-specific request and returns a normalized ResultFrame.
- Args:
AdapterRequestwithplan_type,payload, andparameters(values to bind; empty for generated SQL) - Returns:
ResultFramewithcolumns,rows,row_count, and error metadata
A query the database rejects is a result, not an exception: return
ResultFrame(success=False, error=ResultError(...)). Two fields of the
ResultError decide what the engine does with it:
| Field | Meaning |
|---|---|
retryable |
True when the query itself was wrong and a different query could succeed: bad syntax, an unknown table, column or function, a type mismatch. The engine reports EXECUTION_FAILED and sends the sub-query back to the planner, with safe_message as the feedback, while retries remain. False (the default) for a refusal the engine cannot plan its way out of: permission, a timeout, a lost connection, a read-only database. The engine reports DB_EXECUTION_ERROR and ends the sub-query. |
safe_message |
Shown to the planner and the user. Keep it to one short line: no SQL echo, no stack trace, no driver dump, and for a refusal nothing that names a host or a user. |
Only the adapter knows its driver, so the classification is the adapter's;
the engine names no database. SQLAlchemy adapters inherit it from
SQLAlchemyAdapter (see SQLAlchemy adapters). An exception
that escapes execute is still caught, as a non-retryable EXECUTOR_CRASH.
close()
Releases the adapter's connections. The engine calls it once, from
engine.close(), and when a datasource is removed.
Optional hook: render_sql(expression)
nl2sql_sdk.protocols.SqlRenderingAdapterProtocol. The engine builds
every query as a sqlglot expression tree and, when the adapter has
render_sql, passes it the finished tree and executes the text it returns.
Without the hook the engine renders expression.sql(dialect=adapter.get_dialect()),
so an adapter written before the hook keeps working. SQLAlchemyAdapter
implements that same default.
Override it only for what sqlglot cannot express for your database. The
SQLite adapter, for example, rewrites EXTRACT and date truncation with
STRFTIME and DATE(x, 'start of month'). Postgres, MySQL, SQL Server and
DuckDB use the default.
get_dialect() must return a sqlglot dialect name (postgres, tsql,
mysql, duckdb, sqlite, ...), not the SQLAlchemy name.
Optional hook: execute_batches(request, batch_size)
nl2sql_sdk.protocols.StreamingAdapterProtocol. When the adapter has
execute_batches, the engine reads the result through it, RESULT_FETCH_BATCH_SIZE
rows at a time (default 10,000), and writes each batch to the sub-query's
result artifact as it arrives, so a large result is never held whole in memory
(see Artifact Store). Without
the hook the engine calls execute and writes its one ResultFrame, so an
adapter written before the hook keeps working unchanged.
The contract:
- It is a generator (or returns an iterator) of
ResultFrame. - Every frame carries the result's
columnsand at mostbatch_sizerows. An empty result is one frame with its columns and no rows. - A failure, before the first row or between batches, is one frame with
success=Falseand anerror, classified exactly asexecutewould (retryable,safe_message), and it is the last frame. The engine discards whatever it had written. - The request is the same
AdapterRequestexecutegets; refuse one you cannot run the same way.
SQLAlchemyAdapter implements it with fetchmany on a result opened with
stream_results, a server-side cursor on drivers that have one (see
SQLAlchemy adapters).
The hook is an optional protocol rather than a required method, so an adapter without it needs no change.
Result types every adapter returns
These are part of the contract, so the same plan gives the same values on every database:
| Plan operation | Result type | Example |
|---|---|---|
DATE_PART(unit, date), unit year, quarter, month, day |
INTEGER | 2011 |
DATE_TRUNC(unit, date), same units |
ISO date string YYYY-MM-DD |
'2011-04-01' |
The engine already wraps a date part in CAST(... AS INT) and a truncation
in a YYYY-MM-DD format; an adapter that rewrites the inner nodes keeps
those wrappers.
Interface: SecretProvider
A secret provider is where ${id:key} references are read from. The secrets
config names one by type (see Secrets).
from typing import ClassVar, Optional, Self
from pydantic import BaseModel, ConfigDict, SecretStr
class AcmeVaultConfig(BaseModel):
model_config = ConfigDict(extra="forbid")
url: str
token: SecretStr
class AcmeVaultSecretProvider:
interface_version: ClassVar[int] = 1
config_model: ClassVar[type[BaseModel]] = AcmeVaultConfig
def __init__(self, client):
self.client = client
@classmethod
def from_config(cls, cfg: AcmeVaultConfig) -> Self:
try:
import acme_vault # the client library, imported only here
except ImportError:
raise ImportError("Missing dependency 'acme-vault'. Install 'nl2sql-acme-vault'.") from None
return cls(acme_vault.Client(cfg.url, cfg.token.get_secret_value()))
def get_secret(self, key: str) -> Optional[str]:
return self.client.read(key)
| Member | Description |
|---|---|
interface_version |
1. The engine accepts exactly the version it was built for. |
config_model |
A pydantic model for the provider's entry in the secrets config, apart from id and type. Set extra="forbid", so a misspelt key fails when the config loads, and hold credentials as SecretStr, so they never print. |
from_config(cfg) |
Builds the provider from a validated config_model instance, after the engine has resolved any ${env:...} in it. Raise ImportError naming what to install when the client library is missing. |
get_secret(key) |
Returns the secret stored under key, or None when there is none. |
A provider reads its settings from its entry only, never from the environment. The module that defines it must import without its client library, so the engine can validate the entry before building anything.
Interfaces: the stores
A store keeps one part of the engine's data: the vector index, the state
(schema snapshots, plan cache, feedback), the run traces or the results.
The storage config names one per slot by type (see
Storage). The four interfaces live in
nl2sql_sdk.stores, which imports only pydantic and the standard library.
Every store class has the same four members, whatever its slot:
| Member | Description |
|---|---|
interface_version |
2. The engine accepts exactly the version it was built for. Version 2 added StateStore.put_schema_diff and get_schema_diff (schema drift); the version is one per kind, so a store of any slot declares 2. |
slot |
"index", "state", "traces" or "results": the sub-block it fills. The engine refuses a store in another slot. |
config_model |
A pydantic model for the store's sub-block apart from type. Set extra="forbid", so a misspelt key is an error, and hold credentials as SecretStr. |
from_config(cfg, *, folder) |
Builds the store from a validated config_model instance, after the engine has resolved any ${provider:key} in it. folder is storage.path. It should write nothing: nl2sql config check builds the stores too. |
The slots' methods:
| Interface | Methods |
|---|---|
IndexStore |
open(namespace, dimension), add(namespace, entries), query(namespace, vector, k, where=None, with_vectors=False), get(namespace, where, with_vectors=False), delete(namespace, where), pointers(namespace), set_pointers(namespace, updates) (atomic), namespaces(), drop(namespace), close() |
StateStore |
migrate(), put_snapshot(snapshot), get_snapshot(datasource_id, schema_version), list_versions(datasource_id) (newest first), keep_snapshots(datasource_id, keep) (also deletes the diffs of the snapshots it deletes), put_schema_diff(diff) (keyed by diff.datasource_id and diff.to_version, which it requires; replaces one stored before), get_schema_diff(datasource_id, schema_version) (or None), get_plan, put_plan, clear_plans(), add_feedback(row), list_feedback(limit=None), clear_feedback(), lock(name, ttl_s), close() |
TraceStore |
write(trace_id, document), get(trace_id), purge(older_than) |
ResultStore |
write(key, chunks), open(key), exists(key), purge(older_than). Bytes only: the engine writes the Parquet. |
The models they exchange: Entry (id, text, vector, metadata), Hit
(an entry with its distance, and its vector when asked for), Filter
(eq, ne and in, all of which must hold) and BuildPointer (which build
of a datasource is live, and the schema_version it was built from). The
engine embeds; a store keeps the vectors it is handed.
A results store, sketched:
from datetime import datetime
from pathlib import Path
from typing import BinaryIO, ClassVar, Iterable, Self
from pydantic import BaseModel, ConfigDict
class BucketConfig(BaseModel):
model_config = ConfigDict(extra="forbid")
bucket: str
class BucketResults:
interface_version: ClassVar[int] = 2
slot: ClassVar[str] = "results"
config_model: ClassVar[type[BaseModel]] = BucketConfig
def __init__(self, client, bucket: str):
self.client, self.bucket = client, bucket
@classmethod
def from_config(cls, cfg: BucketConfig, *, folder: Path) -> Self:
import acme_blob # the client library, imported only here
return cls(acme_blob.Client(), cfg.bucket)
def write(self, key: str, chunks: Iterable[bytes]) -> int:
return self.client.put(self.bucket, key, b"".join(chunks))
def open(self, key: str) -> BinaryIO:
return self.client.open(self.bucket, key)
def exists(self, key: str) -> bool:
return self.client.exists(self.bucket, key)
def purge(self, older_than: datetime) -> int:
return self.client.delete_before(self.bucket, older_than)
Declare it in the nl2sql.stores entry point group
(bucket = "acme_nl2sql.results:BucketResults"), or register it in code with
registry.add_store("bucket", BucketResults). Then results: {type: bucket,
bucket: nl2sql-results}.
Built-ins only, for now
The engine moves onto these interfaces one slot at a time during 0.6 (the index, then the state, then the traces and results). Until a slot's move lands, the engine refuses a plugin store in that slot.
Interface: PolicyCheck
A policy check asks an outside service what a caller may read. The policies
config names one by type in its check block (see
A policy check). It lives in
nl2sql_sdk.policy, with UserContext (who is asking) and Grant (allow
and deny patterns, written like a role's).
| Member | Description |
|---|---|
interface_version |
1. The engine accepts exactly the version it was built for. |
config_model |
A pydantic model for the check block apart from type. Set extra="forbid", so a misspelt key is an error. |
from_config(cfg) |
Builds the check from a validated config_model instance, after the engine has resolved any ${provider:key} in it. |
grant(user, datasource_id) |
Returns the Grant for user in datasource_id. Asked once per caller and datasource per run. Intersected with the roles; any exception denies the datasource for the run. |
Declare it in the nl2sql.policy_checks entry point group, or register it in
code with registry.add_policy_check(name, cls) or
@registry.policy_check(name). No check is built in.
The conformance kit: nl2sql_sdk.testing
AdapterConformance, installed with pip install "nl2sql-sdk[testing]"
(adds pytest and sqlglot), is a pytest test class an adapter's own tests
subclass. It checks what this page promises through DatasourceAdapter
alone: the dialect, the connection model, test_connection's stages, the
index scope, profiling, bound parameters, every declared capability, error
classification and streaming. Like the SQLAlchemy base, it is an extra: the
SDK core never imports it. How to run it, with its fixtures and the full
list of checks: Running the conformance kit.