8. Dialect abstraction and the ODBC (SQL Server) adapter¶
- Status: Accepted
- Date: 2026-09-17
Context¶
Warp supported PostgreSQL (asyncpg) and MySQL (aiomysql). Everything that
differs between the two — placeholder style ($1 vs %s), identifier quoting,
ILIKE vs LIKE, RETURNING, LIMIT/OFFSET, the default schema and the
catalog queries for comments and row counts — was expressed as
if dialect == "postgresql" branches spread over six modules. Adding a third
engine meant touching all of them.
SQL Server was the requested third engine. There is no maintained native
asyncio driver for it; the standard route is ODBC (pyodbc, wrapped for
asyncio by aioodbc) with Microsoft's msodbcsql18 driver. ODBC also opens the
door to other data sources (Oracle, DB2, …) through the same code path.
Decision¶
- One
Dialectobject per engine (adapters/outbound/db/dialect.py), a frozen dataclass holding the placeholder style, quote characters,LIKEoperator, how written rows are returned (returning/output/refetch), pagination style (LIMIT/OFFSETorOFFSET … FETCH), percent escaping, default schema and the comment / row-count catalog queries. The query builder, the named-parameter binder, identifier quoting, the metadata readers and the composition root consume theDialect; none of them branch on a database-type string any more. PostgreSQL and MySQL SQL is unchanged byte for byte. - A single
ODBCAdapterwith two profiles selected by the configtype: mssql/sqlserver— first-class: introspection throughINFORMATION_SCHEMAandsys.*(identity/computed markers, keys, indexes), writes that return the row withOUTPUT(falling back per table toSCOPE_IDENTITY()+ re-select when triggers makeOUTPUTillegal, error 334),TOP/OFFSET … FETCH, adatetimeoffsetoutput converter, comments fromMS_Descriptionextended properties and row counts fromsys.partitions. Covered by real-database integration tests in CI.odbc— best effort: ANSI quoting,?placeholders, introspection via the ODBC catalog functions, re-select after writes, no catalog intelligence. Unit-tested only; documented as such.- Packaging:
aioodbclives in anodbcextra and is imported lazily inconnect(), so the factory and the unit tests run without it. The ODBC driver is a system package: the Docker image installsmsodbcsql18by default (--build-arg WITH_MSSQL_ODBC=0opts out), CI installs it on the Ubuntu runner, developers install it locally (brew install msodbcsql18/ apt). The lock (now the oneuv.lock, ADR-0003 addendum) includesaioodbc/pyodbc. - Tests: SQL Server integration tests run in CI (
WARP_REQUIRE_MSSQL=1turns "unavailable" into a failure) and auto-skip on machines without the driver or without an x86-64 Docker engine (the SQL Server image has no arm64 build).WARP_MSSQL_HOSTpoints them at an existing server instead.
Consequences¶
- Adding an engine is now one
Dialectentry plus, when the driver's execution model differs, one adapter; the SQL helpers need no changes. - Oracle (added later) proved the claim, and found three gaps in it. The
first
Dialectsplit engine differences along axes that happened to coincide for PostgreSQL, MySQL and SQL Server, so three of them had to become fields of their own before a fourth engine fitted:sample_style(Oracle paginates withOFFSET ... FETCHlike SQL Server but has noSELECT TOP (n)),unsorted_pagination_filler(Oracle needs noORDER BYbefore the row-limiting clause, and rejects SQL Server'sORDER BY (SELECT NULL)because aSELECTwithoutFROMis invalid), andidentifier_case(Oracle folds unquoted identifiers to upper case, so quotingusersdoes not find a table stored asUSERS). Adding Oracle itself was then oneDialectentry, one introspection profile on the existingODBCAdapter, and its integration tests. ColumnSchema.is_auto_generatedgives create models and required-column checks one definition of "the database fills this in" (auto-increment, serial, identity, computed). PostgreSQL identity columns are now recognised too.- SQL Server identifiers are bracket-quoted through the same strict sanitizer, so the identifier-injection guarantees are unchanged.
- The generic profile cannot detect identity columns unless the driver reports
them in
TYPE_NAME, andDELETEcosts an extraSELECTbecause rowcounts may be unreported; both are acceptable for a best-effort path. aioodbc0.5'sstatistics()wrapper drops pyodbc's mandatory table argument; the adapter works around it and degrades to "no index metadata" rather than failing.