Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/configuration/datasources.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ These databases are verified by integration tests and runnable Docker examples.
| `bigquery` | `motley-slayer[bigquery]` | `bigquery://project/dataset` |
| `trino` | `motley-slayer[trino]` | `trino://user@host:8080/catalog/schema` |

A `connection_string` naming no driver (`postgresql://`, `mysql://`) uses the driver from SLayer's extra; name one explicitly to use another.
A driver you name yourself in `connection_string` (e.g. `postgresql+psycopg`) is not in any extra, so install it separately; SLayer keeps it on the async path when it can run async.

`duckdb` has no install extra because `duckdb` and `duckdb-engine` are core
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-20
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
## Context

See proposal.md — Why. Today two authorities pick the driver: SLayer's `DriverFacts` (`url_scheme`,
`sync_driver`) for URLs it builds, SQLAlchemy's per-version default for any URL naming no driver.
Every consumer — sync engine (`engine_factory._build_engine`), async engine
(`client._get_async_engine`), engine-cache keys, `_runtime_fingerprint`, the `MissingDriverError`
hint — reads `DatasourceConfig.get_connection_string()`.

Principles applied: sql §3.2 (dialect quirks only in `dialects/`) — the default-driver fact and the
pin rule live on `DriverFacts`; system §3.1/§3.2 — `core.models` reaches it only through the
existing `core.models -> sql.dialects` `#legacy` arrow (no new arrow, baseline unchanged); sql §3.9
and §3.14 unchanged. No arc42 / `.c4` / `index.yaml` edit: the delta attaches to the existing
`sql/execution` spec of node `sql`.

## Goals / Non-Goals

**Goals:** `DriverFacts` is the only authority on a driverless URL's driver; one pin step at the one
URL source; the bug class (SQLAlchemy changing a default) cannot recur for any dialect whose scheme
names a driver.

**Non-Goals:** SQLAlchemy 2.1 compatibility (snowflake-sqlalchemy import, duckdb-engine foreign-key
reflection) and the latest-SQLAlchemy CI leg — DEV-2118, which also lifts the `<2.1` cap; `postgres://`
(an unregistered SQLAlchemy backend, not a default-driver question).

## Decisions

1. **One source for the default driver.** `sync_driver` is a read-only property — the `+driver` part
of `url_scheme`, `None` when the scheme names none — on both `SqlDialect` and `DriverFacts`.
`PostgresDialect.url_scheme = "postgresql+psycopg2"`; the `sync_driver` declarations on Postgres
and MySQL are deleted. ClickHouse (`clickhouse+http`) and SQL Server (`mssql+pyodbc`) gain a
default driver without any new declaration.
2. **Backends a type serves.** `SqlDialect.url_backend_aliases: frozenset[str] = frozenset()`,
carried on `DriverFacts`; `MysqlDialect` (inherited by `MariadbDialect`) declares `{"mariadb"}`.
`DriverFacts.url_backends` is a property: `{url_backend} | url_backend_aliases`, empty when
`url_scheme` is `None`.
3. **One pin step: `DriverFacts.pin_default_driver(url: str) -> str`.** Pins iff the URL parses
(`make_url`; `ArgumentError` → pass through), its backend is in `url_backends`, its drivername
has no `+`, and `sync_driver` is set: `url.set(drivername=f"{backend}+{sync_driver}")` with the
URL's own backend, rendered `hide_password=False`. Every other input is returned byte-identical
(no re-render), so explicit drivers, foreign backends, `:memory:` and unparseable strings are
untouched.
4. **Applied once.** `get_connection_string()` returns
`dialect.driver_facts(self.type).pin_default_driver(<current result>)`; the current branching
body moves to a private helper. No consumer changes; stored `connection_string` is never rewritten.
5. **Async derivation.** `_async_connection_string` matches the backend against `url_backends` and
rebuilds with the URL's own backend (`mariadb+pymysql` → `mariadb+aiomysql`). Its "plain URL" leg
is removed — the pin means no driverless URL reaches it: driver == `sync_driver` → `async_driver`;
another sync driver → sync; async-capable driver → kept. Rewriting an explicitly named default
sync driver (`+psycopg2`, `+pymysql`) to its async counterpart is kept on purpose: after the pin a
bare URL and an explicit default-sync URL are the same URL, and the brief's "named drivers are
respected" rule governs which sync driver is chosen, not the sync→async pairing (Codex review,
rejected finding).
6. **`_is_default_driver`** matches the backend against `url_backends`; its `scheme_driver` branch
folds into `sync_driver`.
7. **Version cap** `sqlalchemy = ">=2.0,<2.1"`; the lock stays on 2.0.49.

## Risks / Trade-offs

- [The CI env cannot reproduce the 2.1 Postgres failure: pytest-postgresql pulls in psycopg 3, and
the cap keeps SQLAlchemy at 2.0] → the version-independent resolver test asserts that every pinned
URL names `sync_driver` explicitly and that `get_dialect().driver == sync_driver`; its `mysql://` /
`mariadb://` cases fail without the fix on 2.0.49.
- [Structured Postgres URLs change from `postgresql://` to `postgresql+psycopg2://`] → engine-cache
keys are in-process only; nothing persists the built URL.
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
## Why

A connection URL that names no driver (`postgresql://`, `mysql://`, `mariadb://`) is resolved by
SQLAlchemy's own per-version default driver, not the one SLayer's extra installs. SQLAlchemy 2.1
switched bare `postgresql://` to psycopg 3, so every Postgres query on a fresh
`pip install 'motley-slayer[postgres]'` fails with a `MissingDriverError` whose hint points back at
the installed extra (GitHub #500); bare `mysql://` / `mariadb://` already resolve to `mysqldb`,
which `[mysql]` does not install, on 2.0.

## What Changes

- A URL whose drivername names no driver, on a backend the datasource type serves, is pinned to that
type's declared default driver — the `+driver` part of its URL scheme — before any engine, cache
key or error hint sees it. Explicitly named drivers and every other URL pass through unchanged.
- Each dialect declares its default driver once, in its URL scheme; Postgres's scheme becomes
`postgresql+psycopg2`, so structured Postgres datasources also name their driver.
- MySQL-family types serve both the `mysql` and `mariadb` backends: `mariadb://` pins to
`mariadb+pymysql` and runs asynchronously on `mariadb+aiomysql`.
- `sqlalchemy` is capped at `<2.1`: on 2.1, snowflake-sqlalchemy fails to import and duckdb-engine's
foreign-key reflection breaks. A follow-up lifts the cap and adds a latest-SQLAlchemy CI leg.
- Docs: a `connection_string` naming no driver uses the driver from SLayer's extra.

## Capabilities

### New Capabilities

(none)

### Modified Capabilities

- `sql/execution`: new requirement pinning driverless URLs to SLayer's default driver; the
structured-config URL requirement lists Postgres's new scheme; the asynchronous-path requirement
covers the `mariadb` backend and drops the now-unreachable no-driver case.

## Impact

- Code: `slayer/sql/dialects/base.py` (`DriverFacts`, `SqlDialect` driver facts),
`slayer/sql/dialects/{postgres,mysql}.py`, `slayer/core/models.py`
(`DatasourceConfig.get_connection_string`), `slayer/sql/client.py` (`_async_connection_string`),
`slayer/sql/dialects/drivers.py` (`_is_default_driver`).
- Dependencies: `pyproject.toml` / `poetry.lock` — `sqlalchemy >=2.0,<2.1`.
- Docs: `docs/configuration/datasources.md`.
- Behaviour: structured Postgres URLs and engine-cache keys now carry `+psycopg2`; stored
`connection_string` values are never rewritten.
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
## ADDED Requirements

### Requirement: A connection URL that names no driver uses SLayer's default driver
For a datasource type that declares a default driver (postgres/postgresql: psycopg2; mysql/mariadb:
pymysql; clickhouse: http; sqlserver/mssql/tsql: pyodbc), a connection URL — the datasource's
`connection_string`, or the URL built from its structured fields — whose backend is one the type
serves (postgres/postgresql: `postgresql`; mysql/mariadb: `mysql` and `mariadb`; clickhouse:
`clickhouse`; sqlserver/mssql/tsql: `mssql`) and which names no driver SHALL connect with the type's
default driver on the URL's own backend, whatever the installed SQLAlchemy's own default driver for
that backend is. Credentials, host, port, database and query parameters SHALL be preserved.

A URL that names a driver, whose backend the type does not serve, or that cannot be parsed as a
URL, and every URL of a type without a declared default driver, SHALL be used exactly as given. A
stored `connection_string` SHALL NOT be rewritten.

#### Scenario: Bare Postgres URL uses psycopg2
- **WHEN** a postgres datasource's `connection_string` is
`postgresql://u:p%40ss@h:5432/d?sslmode=require`
- **THEN** it connects as `postgresql+psycopg2://u:p%40ss@h:5432/d?sslmode=require`

#### Scenario: Bare MySQL and MariaDB URLs use pymysql
- **WHEN** a mysql datasource's `connection_string` is `mysql://u:p%40ss@h/d?charset=utf8mb4`, or a
mariadb datasource's is `mariadb://u@h/d`
- **THEN** they connect as `mysql+pymysql://u:p%40ss@h/d?charset=utf8mb4` and
`mariadb+pymysql://u@h/d` respectively

#### Scenario: ClickHouse and SQL Server URLs use their default driver
- **WHEN** a clickhouse datasource's `connection_string` is `clickhouse://u@h/d`, or a sqlserver
datasource's is `mssql://u@h/d`
- **THEN** they connect as `clickhouse+http://u@h/d` and `mssql+pyodbc://u@h/d` respectively

#### Scenario: A named driver is kept
- **WHEN** a postgres datasource's `connection_string` is `postgresql+psycopg://u@h/d` or
`postgresql+asyncpg://u@h/d`, or a mysql datasource's is `mysql+mysqldb://u@h/d`
- **THEN** the connection URL is exactly the `connection_string`

#### Scenario: Types without a default driver are untouched
- **WHEN** a sqlite, duckdb, snowflake, trino, redshift or unregistered-type datasource connects,
including a sqlite `:memory:` datasource
- **THEN** its connection URL is exactly as given or as built today

#### Scenario: A foreign backend is untouched
- **WHEN** a postgres datasource's `connection_string` is `mysql://u@h/d`
- **THEN** the connection URL is exactly the `connection_string`

#### Scenario: Resolution does not depend on SQLAlchemy's defaults
- **WHEN** a datasource of any type that declares a default driver connects with a URL naming no
driver, or with structured fields
- **THEN** SQLAlchemy resolves that URL to the type's declared default driver

#### Scenario: Missing default driver names the extra
- **WHEN** a postgres datasource's `connection_string` is `postgresql://u@h/d` and `psycopg2` is not
importable
- **THEN** a missing-driver error naming `psycopg2` and `pip install 'motley-slayer[postgres]'` is
raised

#### Scenario: Stored configuration is not rewritten
- **WHEN** a datasource with `connection_string` `postgresql://u@h/d` is used and then read back
- **THEN** its `connection_string` is still `postgresql://u@h/d`

## MODIFIED Requirements

### Requirement: The asynchronous path honours the user's driver choice
For a datasource type with a native asynchronous driver (postgres/postgresql: asyncpg;
mysql/mariadb: aiomysql), the asynchronous connection SHALL be derived from the datasource's
connection URL — after a URL naming no driver has been given the type's default driver — as follows,
preserving the backend and every other URL part (credentials, host, port, database, query):
- a URL that names an asynchronous-capable driver SHALL be used unchanged;
- a URL that names the type's default synchronous driver (postgres: psycopg2; mysql/mariadb:
pymysql) SHALL use the type's native asynchronous driver;
- a URL that names any other synchronous-only driver, or whose backend is not one the type serves,
SHALL run on the synchronous path in a worker thread.

A datasource type without a native asynchronous driver SHALL run on the synchronous path in a
worker thread.

#### Scenario: psycopg v3 is kept
- **WHEN** a postgres datasource's `connection_string` is `postgresql+psycopg://u:p@h:5432/d`
- **THEN** the asynchronous connection uses psycopg with the same URL, and asyncpg is not required

#### Scenario: Plain and default-sync URLs move to asyncpg
- **WHEN** a postgres datasource's URL is `postgresql://u:p@h:5432/d?sslmode=require` or
`postgresql+psycopg2://u:p@h:5432/d?sslmode=require`
- **THEN** the asynchronous connection URL is `postgresql+asyncpg://u:p@h:5432/d?sslmode=require`

#### Scenario: MySQL follows the same rule
- **WHEN** a mysql datasource's URL is `mysql://…` or `mysql+pymysql://…`, or is
`mysql+asyncmy://…`
- **THEN** the first two use aiomysql and the third is used unchanged

#### Scenario: MariaDB keeps its backend
- **WHEN** a mariadb datasource's URL is `mariadb://u@h/d` or `mariadb+pymysql://u@h/d`
- **THEN** the asynchronous connection URL is `mariadb+aiomysql://u@h/d`

#### Scenario: Another named sync driver stays synchronous
- **WHEN** a postgres datasource's URL is `postgresql+pg8000://…`, or a mysql datasource's URL is
`mysql+mysqldb://…`
- **THEN** statements run on the synchronous path in a worker thread with the named driver

#### Scenario: Type without an async driver
- **WHEN** a Snowflake datasource executes asynchronously
- **THEN** statements run on the synchronous path in a worker thread

### Requirement: Structured-config connection URLs are stable
A datasource configured by fields rather than a `connection_string` SHALL produce a connection URL
whose scheme names the type's default driver where the type declares one; a datasource type that no
registered dialect declares SHALL keep `<type>://` as its URL scheme.

#### Scenario: Known types keep their scheme
- **WHEN** structured postgres, mysql, mariadb, clickhouse and sqlserver datasources build their URLs
- **THEN** the schemes are `postgresql+psycopg2`, `mysql+pymysql`, `mysql+pymysql`,
`clickhouse+http` and `mssql+pyodbc`

#### Scenario: Unknown type keeps its own scheme
- **WHEN** a structured datasource of an unregistered type `foo` builds its URL
- **THEN** the URL scheme is `foo`, not the Postgres fallback dialect's scheme
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
## 1. Tests (pr-tests stage; all fail before implementation)

- [x] 1.1 Pin-step tests for every scenario of "A connection URL that names no driver uses SLayer's default driver": bare postgres / mysql / mariadb / clickhouse / mssql URLs pinned (reserved-char credentials + query params preserved); named drivers (`postgresql+psycopg`, `postgresql+asyncpg`, `mysql+mysqldb`), foreign backend, sqlite / duckdb / `:memory:` / snowflake / trino / redshift / unregistered `foo` returned byte-identical; stored `connection_string` unchanged after use; verify they fail on the current code
- [x] 1.2 Version-independent resolver test: for every registered dialect whose scheme names a driver, `make_url(get_connection_string()).get_dialect().driver == sync_driver` for structured fields AND a bare user URL. Gotcha: CI cannot reproduce the 2.1 Postgres failure (pytest-postgresql installs psycopg 3; SQLAlchemy is capped at 2.0), so this test plus 1.1's explicit `+driver` assertions are the guard; verify the `mysql://` / `mariadb://` cases fail now
- [x] 1.3 Driver-facts tests: `sync_driver` derived from `url_scheme` (None for driverless schemes); `url_backends` = `{url_backend} | aliases`, `{"mysql","mariadb"}` for mysql/mariadb, empty for an unregistered type's facts with no scheme; Postgres scheme is `postgresql+psycopg2`
- [x] 1.4 Async tests: bare postgres / mysql URLs → `+asyncpg` / `+aiomysql`; `mariadb://` and `mariadb+pymysql://` → `mariadb+aiomysql://`; missing `psycopg2` on a bare `postgresql://` raises a missing-driver error naming the `[postgres]` extra
- [x] 1.5 Update existing tests that assert bare/old URLs to the pinned form (behaviour change per DEV-2116): `tests/test_models.py:1068`, `tests/test_datasource_snowflake.py:79`, `tests/test_dialect_driver_facts.py:86,99-100` (`test_registry_is_the_one_driver_table` stops setting `sync_driver` in `model_copy`), `tests/test_engine_factory.py:90`, `tests/test_async_driver_selection.py:33`; structured sqlserver scheme `mssql+pyodbc` in the "Known types keep their scheme" test

## 2. Implementation

- [x] 2.1 `slayer/sql/dialects/base.py`: `sync_driver` becomes a property on `SqlDialect` and `DriverFacts`; add `url_backend_aliases` field + `DriverFacts.url_backends` property; add `DriverFacts.pin_default_driver`; verify 1.3 passes
- [x] 2.2 `postgres.py`: `url_scheme = "postgresql+psycopg2"`, drop `sync_driver`; `mysql.py`: drop `sync_driver`, declare `url_backend_aliases = {"mariadb"}`; verify 1.3 passes
- [x] 2.3 `slayer/core/models.py`: `get_connection_string()` pins its result via the private-helper split; verify 1.1, 1.2 pass
- [x] 2.4 `slayer/sql/client.py` `_async_connection_string`: `url_backends` match, URL's own backend, drop the plain-URL leg; verify 1.4 passes
- [x] 2.5 `slayer/sql/dialects/drivers.py` `_is_default_driver`: `url_backends` match, fold `scheme_driver` into `sync_driver`; verify the missing-driver suite passes
- [x] 2.6 `pyproject.toml`: `sqlalchemy = ">=2.0,<2.1"`; `poetry lock` (no upgrades — lock stays on 2.0.49); verify `poetry check --lock`

## 3. Docs and gates

- [x] 3.1 One sentence in `docs/configuration/datasources.md`: a `connection_string` naming no driver (`postgresql://`, `mysql://`) uses the driver from SLayer's extra; name one explicitly to use another
- [x] 3.2 Full unit suite (`poetry run pytest -m "not integration" -n logical`), `ruff check slayer/ tests/`, `basedpyright` (no new errors vs baseline), `la-arch-check` all green
Loading
Loading