sqlalchemy-adbc 0.3.0__tar.gz → 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. {sqlalchemy_adbc-0.3.0 → sqlalchemy_adbc-0.4.0}/CHANGELOG.md +34 -0
  2. {sqlalchemy_adbc-0.3.0 → sqlalchemy_adbc-0.4.0}/PKG-INFO +23 -18
  3. {sqlalchemy_adbc-0.3.0 → sqlalchemy_adbc-0.4.0}/README.md +21 -16
  4. {sqlalchemy_adbc-0.3.0 → sqlalchemy_adbc-0.4.0}/pyproject.toml +9 -2
  5. {sqlalchemy_adbc-0.3.0 → sqlalchemy_adbc-0.4.0}/src/sqlalchemy_adbc/__init__.py +4 -4
  6. {sqlalchemy_adbc-0.3.0 → sqlalchemy_adbc-0.4.0}/src/sqlalchemy_adbc/base.py +13 -1
  7. {sqlalchemy_adbc-0.3.0 → sqlalchemy_adbc-0.4.0}/src/sqlalchemy_adbc/bigquery.py +1 -1
  8. {sqlalchemy_adbc-0.3.0 → sqlalchemy_adbc-0.4.0}/src/sqlalchemy_adbc/flightsql.py +2 -2
  9. {sqlalchemy_adbc-0.3.0 → sqlalchemy_adbc-0.4.0}/src/sqlalchemy_adbc/postgresql.py +16 -11
  10. {sqlalchemy_adbc-0.3.0 → sqlalchemy_adbc-0.4.0}/src/sqlalchemy_adbc/snowflake.py +1 -1
  11. {sqlalchemy_adbc-0.3.0 → sqlalchemy_adbc-0.4.0}/tests/test_alembic_autogenerate.py +1 -1
  12. {sqlalchemy_adbc-0.3.0 → sqlalchemy_adbc-0.4.0}/tests/test_bigquery_url.py +6 -6
  13. {sqlalchemy_adbc-0.3.0 → sqlalchemy_adbc-0.4.0}/tests/test_flightsql_url.py +9 -9
  14. {sqlalchemy_adbc-0.3.0 → sqlalchemy_adbc-0.4.0}/tests/test_postgresql_types.py +1 -1
  15. {sqlalchemy_adbc-0.3.0 → sqlalchemy_adbc-0.4.0}/tests/test_postgresql_url.py +26 -13
  16. {sqlalchemy_adbc-0.3.0 → sqlalchemy_adbc-0.4.0}/tests/test_reflection.py +7 -5
  17. {sqlalchemy_adbc-0.3.0 → sqlalchemy_adbc-0.4.0}/tests/test_snowflake_url.py +9 -9
  18. {sqlalchemy_adbc-0.3.0 → sqlalchemy_adbc-0.4.0}/tests/test_sqlite.py +5 -5
  19. sqlalchemy_adbc-0.4.0/tests/test_url_schemes.py +55 -0
  20. {sqlalchemy_adbc-0.3.0 → sqlalchemy_adbc-0.4.0}/.github/workflows/ci.yml +0 -0
  21. {sqlalchemy_adbc-0.3.0 → sqlalchemy_adbc-0.4.0}/.github/workflows/publish.yml +0 -0
  22. {sqlalchemy_adbc-0.3.0 → sqlalchemy_adbc-0.4.0}/.gitignore +0 -0
  23. {sqlalchemy_adbc-0.3.0 → sqlalchemy_adbc-0.4.0}/LICENSE +0 -0
  24. {sqlalchemy_adbc-0.3.0 → sqlalchemy_adbc-0.4.0}/src/sqlalchemy_adbc/postgresql_types.py +0 -0
  25. {sqlalchemy_adbc-0.3.0 → sqlalchemy_adbc-0.4.0}/src/sqlalchemy_adbc/reflection.py +0 -0
  26. {sqlalchemy_adbc-0.3.0 → sqlalchemy_adbc-0.4.0}/src/sqlalchemy_adbc/sqlite.py +0 -0
  27. {sqlalchemy_adbc-0.3.0 → sqlalchemy_adbc-0.4.0}/tests/__init__.py +0 -0
  28. {sqlalchemy_adbc-0.3.0 → sqlalchemy_adbc-0.4.0}/tests/test_base.py +0 -0
@@ -6,6 +6,40 @@ this project adheres to [Semantic Versioning](https://semver.org/).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.4.0] — 2026-11-07
10
+
11
+ URL schemes now follow SQLAlchemy's `<backend>+adbc://` convention,
12
+ plus compatibility fixes for SQLAlchemy 2.1 and ADBC 1.13. Existing
13
+ `adbc+<backend>://` URLs keep working and emit a `DeprecationWarning`.
14
+
15
+ ### Changed
16
+
17
+ - **URL schemes are now `<backend>+adbc://`** — `postgresql+adbc://`,
18
+ `sqlite+adbc://`, `flightsql+adbc://`, `snowflake+adbc://`,
19
+ `bigquery+adbc://`. This follows SQLAlchemy's `dialect+driver`
20
+ convention: ADBC is the driver. `URL.get_backend_name()` now agrees
21
+ with `dialect.name`, so tools that pick behavior from the URL (for
22
+ example Superset's engine specs) see the real backend.
23
+ ([#15](https://github.com/drls-io/sqlalchemy-adbc/issues/15))
24
+
25
+ ### Deprecated
26
+
27
+ - **`adbc+<backend>://` URL spellings** — still registered and
28
+ functional, but `create_engine` emits a `DeprecationWarning`. They
29
+ will be removed in 1.0.
30
+
31
+ ### Fixed
32
+
33
+ - **PostgreSQL database names with SQLAlchemy 2.1** — SQLAlchemy 2.1
34
+ percent-decodes `URL.database`, so the libpq URI lost its escapes
35
+ (`my%20db` became `my db`). The dialect now detects the parser's
36
+ behavior and quotes the database path exactly once on both 2.0 and
37
+ 2.1. ([#19](https://github.com/drls-io/sqlalchemy-adbc/issues/19))
38
+ - **CI on ADBC 1.13** — `adbc_driver_sqlite` 1.13.0 reports NOT NULL
39
+ correctly, which turned the strict xfail for column nullability into
40
+ a hard failure. The xfail now applies only to drivers older than
41
+ 1.13. ([#13](https://github.com/drls-io/sqlalchemy-adbc/issues/13))
42
+
9
43
  ## [0.3.0] — 2026-04-21
10
44
 
11
45
  Largest release so far: table reflection, Alembic autogenerate,
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: sqlalchemy-adbc
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: Generic SQLAlchemy dialect for any ADBC driver (Flight SQL, PostgreSQL, SQLite, Snowflake, BigQuery).
5
5
  Project-URL: Homepage, https://github.com/drls-io/sqlalchemy-adbc
6
6
  Project-URL: Repository, https://github.com/drls-io/sqlalchemy-adbc
@@ -81,18 +81,18 @@ import sqlalchemy
81
81
 
82
82
  # Flight SQL
83
83
  engine = sqlalchemy.create_engine(
84
- "adbc+flightsql://flight-sql.example.com:443?tls=true",
84
+ "flightsql+adbc://flight-sql.example.com:443?tls=true",
85
85
  connect_args={"db_kwargs": {
86
86
  "adbc.flight.sql.authorization_header": f"Bearer {token}",
87
87
  }},
88
88
  )
89
89
 
90
90
  # SQLite
91
- engine = sqlalchemy.create_engine("adbc+sqlite:///path/to/db.sqlite")
91
+ engine = sqlalchemy.create_engine("sqlite+adbc:///path/to/db.sqlite")
92
92
 
93
93
  # PostgreSQL
94
94
  engine = sqlalchemy.create_engine(
95
- "adbc+postgresql://user:pass@host:5432/mydb"
95
+ "postgresql+adbc://user:pass@host:5432/mydb"
96
96
  )
97
97
 
98
98
  # Works with pandas, Polars, marimo SQL cells, etc.
@@ -102,14 +102,18 @@ df = pd.read_sql("SELECT 1 AS n", engine)
102
102
 
103
103
  ## URL forms
104
104
 
105
- | Driver | URL |
105
+ URLs follow SQLAlchemy's `<backend>+<driver>://` convention, with `adbc`
106
+ as the driver. The pre-0.4 `adbc+<backend>://` spelling still works but
107
+ emits a `DeprecationWarning` and will be removed in 1.0.
108
+
109
+ | Backend | URL |
106
110
  |--------------|------------------------------------------------------------|
107
- | Flight SQL | `adbc+flightsql://host:port?tls=true&authorization=...` |
108
- | Flight SQL | `adbc+flightsql://user:TOKEN@host:port?tls=true` (bearer) |
109
- | PostgreSQL | `adbc+postgresql://user:pass@host:port/database` |
110
- | SQLite | `adbc+sqlite:///path/to/db` or `adbc+sqlite:///:memory:` |
111
- | Snowflake | `adbc+snowflake://user:pass@account/db/schema?warehouse=W` |
112
- | BigQuery | `adbc+bigquery:///my-project?dataset=my_dataset` |
111
+ | Flight SQL | `flightsql+adbc://host:port?tls=true&authorization=...` |
112
+ | Flight SQL | `flightsql+adbc://user:TOKEN@host:port?tls=true` (bearer) |
113
+ | PostgreSQL | `postgresql+adbc://user:pass@host:port/database` |
114
+ | SQLite | `sqlite+adbc:///path/to/db` or `sqlite+adbc:///:memory:` |
115
+ | Snowflake | `snowflake+adbc://user:pass@account/db/schema?warehouse=W` |
116
+ | BigQuery | `bigquery+adbc:///my-project?dataset=my_dataset` |
113
117
 
114
118
  ### Flight SQL query-string options
115
119
 
@@ -151,17 +155,18 @@ published wheels on [PyPI](https://pypi.org/project/sqlalchemy-adbc/).
151
155
  …) and **hstore** — not yet mapped to typed decorators; returned
152
156
  as raw strings. JSONB / JSON / UUID / INET / CIDR / MACADDR /
153
157
  MACADDR8 all round-trip through typed decorators as of 0.3.0
154
- ([#3](https://github.com/drls-io/sqlalchemy-adbc/issues/3) —
155
- JSONB/UUID/INET portion closed)
158
+ ([#11](https://github.com/drls-io/sqlalchemy-adbc/issues/11))
156
159
  - **Drivers exercised in CI** — SQLite fully, PostgreSQL
157
160
  integration-tested via service container. Snowflake / BigQuery /
158
161
  Flight SQL inherit the shared reflection path but have no
159
162
  per-driver CI
160
- - **ADBC SQLite NOT NULL reporting** — upstream driver always
161
- reports nullable=True; reflection faithfully reflects that
162
- (tracked, auto-reactivating xfail)
163
+ ([#12](https://github.com/drls-io/sqlalchemy-adbc/issues/12))
164
+ - **ADBC SQLite NOT NULL reporting** — fixed upstream in
165
+ `adbc_driver_sqlite` 1.13 (Python ≥ 3.11). On older drivers every
166
+ column reflects as nullable=True
167
+ ([#13](https://github.com/drls-io/sqlalchemy-adbc/issues/13))
163
168
 
164
- Tests: 106 passing on Python 3.10–3.13, including 17 PostgreSQL
169
+ Tests: 121 passing on Python 3.10–3.13, including 17 PostgreSQL
165
170
  integration tests against a service-container Postgres.
166
171
 
167
172
  ## Development
@@ -37,18 +37,18 @@ import sqlalchemy
37
37
 
38
38
  # Flight SQL
39
39
  engine = sqlalchemy.create_engine(
40
- "adbc+flightsql://flight-sql.example.com:443?tls=true",
40
+ "flightsql+adbc://flight-sql.example.com:443?tls=true",
41
41
  connect_args={"db_kwargs": {
42
42
  "adbc.flight.sql.authorization_header": f"Bearer {token}",
43
43
  }},
44
44
  )
45
45
 
46
46
  # SQLite
47
- engine = sqlalchemy.create_engine("adbc+sqlite:///path/to/db.sqlite")
47
+ engine = sqlalchemy.create_engine("sqlite+adbc:///path/to/db.sqlite")
48
48
 
49
49
  # PostgreSQL
50
50
  engine = sqlalchemy.create_engine(
51
- "adbc+postgresql://user:pass@host:5432/mydb"
51
+ "postgresql+adbc://user:pass@host:5432/mydb"
52
52
  )
53
53
 
54
54
  # Works with pandas, Polars, marimo SQL cells, etc.
@@ -58,14 +58,18 @@ df = pd.read_sql("SELECT 1 AS n", engine)
58
58
 
59
59
  ## URL forms
60
60
 
61
- | Driver | URL |
61
+ URLs follow SQLAlchemy's `<backend>+<driver>://` convention, with `adbc`
62
+ as the driver. The pre-0.4 `adbc+<backend>://` spelling still works but
63
+ emits a `DeprecationWarning` and will be removed in 1.0.
64
+
65
+ | Backend | URL |
62
66
  |--------------|------------------------------------------------------------|
63
- | Flight SQL | `adbc+flightsql://host:port?tls=true&authorization=...` |
64
- | Flight SQL | `adbc+flightsql://user:TOKEN@host:port?tls=true` (bearer) |
65
- | PostgreSQL | `adbc+postgresql://user:pass@host:port/database` |
66
- | SQLite | `adbc+sqlite:///path/to/db` or `adbc+sqlite:///:memory:` |
67
- | Snowflake | `adbc+snowflake://user:pass@account/db/schema?warehouse=W` |
68
- | BigQuery | `adbc+bigquery:///my-project?dataset=my_dataset` |
67
+ | Flight SQL | `flightsql+adbc://host:port?tls=true&authorization=...` |
68
+ | Flight SQL | `flightsql+adbc://user:TOKEN@host:port?tls=true` (bearer) |
69
+ | PostgreSQL | `postgresql+adbc://user:pass@host:port/database` |
70
+ | SQLite | `sqlite+adbc:///path/to/db` or `sqlite+adbc:///:memory:` |
71
+ | Snowflake | `snowflake+adbc://user:pass@account/db/schema?warehouse=W` |
72
+ | BigQuery | `bigquery+adbc:///my-project?dataset=my_dataset` |
69
73
 
70
74
  ### Flight SQL query-string options
71
75
 
@@ -107,17 +111,18 @@ published wheels on [PyPI](https://pypi.org/project/sqlalchemy-adbc/).
107
111
  …) and **hstore** — not yet mapped to typed decorators; returned
108
112
  as raw strings. JSONB / JSON / UUID / INET / CIDR / MACADDR /
109
113
  MACADDR8 all round-trip through typed decorators as of 0.3.0
110
- ([#3](https://github.com/drls-io/sqlalchemy-adbc/issues/3) —
111
- JSONB/UUID/INET portion closed)
114
+ ([#11](https://github.com/drls-io/sqlalchemy-adbc/issues/11))
112
115
  - **Drivers exercised in CI** — SQLite fully, PostgreSQL
113
116
  integration-tested via service container. Snowflake / BigQuery /
114
117
  Flight SQL inherit the shared reflection path but have no
115
118
  per-driver CI
116
- - **ADBC SQLite NOT NULL reporting** — upstream driver always
117
- reports nullable=True; reflection faithfully reflects that
118
- (tracked, auto-reactivating xfail)
119
+ ([#12](https://github.com/drls-io/sqlalchemy-adbc/issues/12))
120
+ - **ADBC SQLite NOT NULL reporting** — fixed upstream in
121
+ `adbc_driver_sqlite` 1.13 (Python ≥ 3.11). On older drivers every
122
+ column reflects as nullable=True
123
+ ([#13](https://github.com/drls-io/sqlalchemy-adbc/issues/13))
119
124
 
120
- Tests: 106 passing on Python 3.10–3.13, including 17 PostgreSQL
125
+ Tests: 121 passing on Python 3.10–3.13, including 17 PostgreSQL
121
126
  integration tests against a service-container Postgres.
122
127
 
123
128
  ## Development
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "sqlalchemy-adbc"
7
- version = "0.3.0"
7
+ version = "0.4.0"
8
8
  description = "Generic SQLAlchemy dialect for any ADBC driver (Flight SQL, PostgreSQL, SQLite, Snowflake, BigQuery)."
9
9
  readme = "README.md"
10
10
  license = { text = "Apache-2.0" }
@@ -58,8 +58,15 @@ Issues = "https://github.com/drls-io/sqlalchemy-adbc/issues"
58
58
 
59
59
  # SQLAlchemy discovers dialects via the `sqlalchemy.dialects` entry point
60
60
  # group. The name form is `<name>` or `<name>.<driver>` — the second is what
61
- # lets us write `adbc+flightsql://` etc.
61
+ # lets us write `flightsql+adbc://` etc.
62
62
  [project.entry-points."sqlalchemy.dialects"]
63
+ # SQLAlchemy URLs are <backend>+<driver>; ADBC is the driver (#15).
64
+ "flightsql.adbc" = "sqlalchemy_adbc.flightsql:ADBCFlightSQLDialect"
65
+ "sqlite.adbc" = "sqlalchemy_adbc.sqlite:ADBCSQLiteDialect"
66
+ "postgresql.adbc" = "sqlalchemy_adbc.postgresql:ADBCPostgreSQLDialect"
67
+ "snowflake.adbc" = "sqlalchemy_adbc.snowflake:ADBCSnowflakeDialect"
68
+ "bigquery.adbc" = "sqlalchemy_adbc.bigquery:ADBCBigQueryDialect"
69
+ # Deprecated adbc+<backend> spellings; removed in 1.0.
63
70
  "adbc" = "sqlalchemy_adbc.base:ADBCDialect"
64
71
  "adbc.flightsql" = "sqlalchemy_adbc.flightsql:ADBCFlightSQLDialect"
65
72
  "adbc.sqlite" = "sqlalchemy_adbc.sqlite:ADBCSQLiteDialect"
@@ -3,10 +3,10 @@
3
3
  Register dialects via the ``sqlalchemy.dialects`` entry-point group (see
4
4
  pyproject.toml). URL forms::
5
5
 
6
- adbc+flightsql://host:port/...?tls=true
7
- adbc+sqlite:///path/to/db.sqlite
8
- adbc+postgresql://user:pass@host:port/db
9
- adbc+snowflake://user:pass@account/db/schema?warehouse=WH
6
+ flightsql+adbc://host:port/...?tls=true
7
+ sqlite+adbc:///path/to/db.sqlite
8
+ postgresql+adbc://user:pass@host:port/db
9
+ snowflake+adbc://user:pass@account/db/schema?warehouse=WH
10
10
  """
11
11
 
12
12
  from sqlalchemy_adbc.base import ADBCDialect
@@ -16,6 +16,7 @@ Subclasses implement:
16
16
  from __future__ import annotations
17
17
 
18
18
  import importlib
19
+ import warnings
19
20
  from typing import Any
20
21
 
21
22
  from sqlalchemy.engine.default import DefaultDialect
@@ -33,8 +34,10 @@ class ADBCDialect(DefaultDialect):
33
34
  """Base class for SQLAlchemy dialects backed by ADBC drivers.
34
35
 
35
36
  This class is registered as the plain ``adbc://`` scheme. In practice
36
- callers should use a driver-specific variant like ``adbc+flightsql://``;
37
+ callers should use a backend-specific variant like ``flightsql+adbc://``;
37
38
  the plain scheme requires ``adbc_driver_name`` in the URL query string.
39
+ The pre-0.4 ``adbc+<backend>://`` spellings remain registered as
40
+ deprecated aliases.
38
41
  """
39
42
 
40
43
  # SQLAlchemy convention: `name` = backend identity (used by
@@ -76,6 +79,15 @@ class ADBCDialect(DefaultDialect):
76
79
 
77
80
  Subclasses should override ``build_connect_args`` instead of this.
78
81
  """
82
+ if url.drivername.startswith("adbc+"):
83
+ backend = url.get_driver_name()
84
+ warnings.warn(
85
+ f"URL scheme 'adbc+{backend}://' is deprecated; use "
86
+ f"'{backend}+adbc://' (SQLAlchemy's backend+driver order). "
87
+ "The old spelling will be removed in 1.0.",
88
+ DeprecationWarning,
89
+ stacklevel=2,
90
+ )
79
91
  return self.build_connect_args(url)
80
92
 
81
93
  def build_connect_args(self, url: URL) -> tuple[list[Any], dict[str, Any]]:
@@ -2,7 +2,7 @@
2
2
 
3
3
  URL form::
4
4
 
5
- adbc+bigquery:///<project>?dataset=<dataset>&credentials_path=/path/to/sa.json
5
+ bigquery+adbc:///<project>?dataset=<dataset>&credentials_path=/path/to/sa.json
6
6
 
7
7
  adbc_driver_bigquery reads the GCP project and optional dataset via
8
8
  db_kwargs; we pass the URL query string through verbatim.
@@ -2,7 +2,7 @@
2
2
 
3
3
  URL form::
4
4
 
5
- adbc+flightsql://host:port?tls=true&authorization=Bearer%20...
5
+ flightsql+adbc://host:port?tls=true&authorization=Bearer%20...
6
6
 
7
7
  All query-string keys are forwarded to the driver as ``db_kwargs``. The
8
8
  dialect also understands a few shorthands:
@@ -69,7 +69,7 @@ class ADBCFlightSQLDialect(ADBCDialect):
69
69
  db_kwargs[auth_key] = query.pop("authorization")
70
70
 
71
71
  # Caller can also set the bearer via SQLAlchemy URL password:
72
- # adbc+flightsql://user:<token>@host:443?tls=true
72
+ # flightsql+adbc://user:<token>@host:443?tls=true
73
73
  # The "user" part is ignored by Flight SQL; we just read the
74
74
  # password into the Authorization header.
75
75
  if url.password:
@@ -10,14 +10,19 @@ corrupt the DSN or change its parse. Without quoting, a password like
10
10
  from __future__ import annotations
11
11
 
12
12
  from typing import Any
13
- from urllib.parse import quote
13
+ from urllib.parse import quote, unquote
14
14
 
15
15
  from sqlalchemy.engine.interfaces import ReflectedColumn, ReflectedIndex
16
- from sqlalchemy.engine.url import URL
16
+ from sqlalchemy.engine.url import URL, make_url
17
17
 
18
18
  from sqlalchemy_adbc.base import ADBCDialect
19
19
  from sqlalchemy_adbc.postgresql_types import pg_ischema_names
20
20
 
21
+ # SQLAlchemy 2.0 leaves ``URL.database`` percent-encoded; 2.1 decodes it
22
+ # like userinfo. Probe once so the libpq URI is quoted exactly once on
23
+ # either version.
24
+ _URL_DECODES_DATABASE = make_url("postgresql://h/a%20b").database == "a b"
25
+
21
26
 
22
27
  class ADBCPostgreSQLDialect(ADBCDialect):
23
28
  # `name = "postgresql"` makes Alembic pick the standard Postgres
@@ -45,20 +50,20 @@ class ADBCPostgreSQLDialect(ADBCDialect):
45
50
  super().__init__(**kwargs)
46
51
 
47
52
  def build_connect_args(self, url: URL) -> tuple[list[Any], dict[str, Any]]:
48
- # SQLAlchemy's URL parser decodes percent-escapes in userinfo
49
- # (`url.username`, `url.password`) but leaves the path component
50
- # (`url.database`) untouched. So we re-quote userinfo before
51
- # rebuilding the libpq URI — without it, a password like ``p@ss``
52
- # would silently re-route to a different host — but we pass the
53
- # database through as-is. Re-quoting the database would
54
- # double-encode already-percent-escaped names (``my%20db`` →
55
- # ``my%2520db``).
53
+ # Every user-supplied component is reduced to its raw value and
54
+ # then quoted once, so a password like ``p@ss`` cannot re-route
55
+ # libpq to another host and a database named ``my%20db`` is not
56
+ # collapsed to ``my db``. Userinfo is always decoded by SQLAlchemy;
57
+ # the database path only from 2.1 (``_URL_DECODES_DATABASE``).
56
58
  user = quote(url.username, safe="") if url.username else ""
57
59
  password = f":{quote(url.password, safe='')}" if url.password else ""
58
60
  auth = f"{user}{password}@" if user or url.password else ""
59
61
  host = url.host or "localhost"
60
62
  port = f":{url.port}" if url.port else ""
61
- database = f"/{url.database}" if url.database else ""
63
+ database = ""
64
+ if url.database:
65
+ raw = url.database if _URL_DECODES_DATABASE else unquote(url.database)
66
+ database = f"/{quote(raw, safe='')}"
62
67
  uri = f"postgresql://{auth}{host}{port}{database}"
63
68
 
64
69
  kwargs: dict[str, Any] = {}
@@ -2,7 +2,7 @@
2
2
 
3
3
  URL form::
4
4
 
5
- adbc+snowflake://user:password@account/database/schema?warehouse=WH&role=R
5
+ snowflake+adbc://user:password@account/database/schema?warehouse=WH&role=R
6
6
 
7
7
  adbc_driver_snowflake expects a DSN of the form
8
8
  ``user:password@account/database/schema?warehouse=...``. We URL-quote
@@ -25,7 +25,7 @@ from alembic.migration import MigrationContext # noqa: E402
25
25
 
26
26
 
27
27
  def _make_engine() -> sa.Engine:
28
- return sa.create_engine("adbc+sqlite:///:memory:")
28
+ return sa.create_engine("sqlite+adbc:///:memory:")
29
29
 
30
30
 
31
31
  def _diff(engine: sa.Engine, target: sa.MetaData, **opts: Any) -> tuple[str, str]:
@@ -12,21 +12,21 @@ def _build(url_str: str):
12
12
 
13
13
 
14
14
  def test_project_from_database_segment():
15
- """``adbc+bigquery:///my-project`` → project_id set."""
16
- args, kwargs = _build("adbc+bigquery:///my-project")
15
+ """``bigquery+adbc:///my-project`` → project_id set."""
16
+ args, kwargs = _build("bigquery+adbc:///my-project")
17
17
  assert args == []
18
18
  assert kwargs["db_kwargs"]["adbc.bigquery.sql.project_id"] == "my-project"
19
19
 
20
20
 
21
21
  def test_project_plus_dataset():
22
- _, kwargs = _build("adbc+bigquery:///my-project?dataset=events")
22
+ _, kwargs = _build("bigquery+adbc:///my-project?dataset=events")
23
23
  db = kwargs["db_kwargs"]
24
24
  assert db["adbc.bigquery.sql.project_id"] == "my-project"
25
25
  assert db["dataset"] == "events"
26
26
 
27
27
 
28
28
  def test_only_query_params():
29
- _, kwargs = _build("adbc+bigquery://?adbc.bigquery.sql.project_id=p&x=y")
29
+ _, kwargs = _build("bigquery+adbc://?adbc.bigquery.sql.project_id=p&x=y")
30
30
  db = kwargs["db_kwargs"]
31
31
  assert db["adbc.bigquery.sql.project_id"] == "p"
32
32
  assert db["x"] == "y"
@@ -35,11 +35,11 @@ def test_only_query_params():
35
35
  def test_explicit_project_kwarg_wins_over_url():
36
36
  """If both URL-database and ``?project_id=...`` are present, the
37
37
  explicit query string must not be overwritten by the implicit default."""
38
- _, kwargs = _build("adbc+bigquery:///implicit?adbc.bigquery.sql.project_id=explicit")
38
+ _, kwargs = _build("bigquery+adbc:///implicit?adbc.bigquery.sql.project_id=explicit")
39
39
  assert kwargs["db_kwargs"]["adbc.bigquery.sql.project_id"] == "explicit"
40
40
 
41
41
 
42
42
  def test_no_kwargs_at_all():
43
- args, kwargs = _build("adbc+bigquery://")
43
+ args, kwargs = _build("bigquery+adbc://")
44
44
  assert args == []
45
45
  assert kwargs == {}
@@ -18,35 +18,35 @@ def _build(url_str: str):
18
18
 
19
19
  def test_tls_is_default():
20
20
  """No ``tls`` query param → scheme is grpc+tls (secure by default)."""
21
- args, kwargs = _build("adbc+flightsql://flight-sql.example.com:443")
21
+ args, kwargs = _build("flightsql+adbc://flight-sql.example.com:443")
22
22
  assert args == ["grpc+tls://flight-sql.example.com:443"]
23
23
  assert "db_kwargs" not in kwargs
24
24
 
25
25
 
26
26
  def test_tls_explicit_opt_out():
27
27
  """``?tls=false`` drops back to plaintext gRPC — needed for local dev."""
28
- args, kwargs = _build("adbc+flightsql://localhost:50051?tls=false")
28
+ args, kwargs = _build("flightsql+adbc://localhost:50051?tls=false")
29
29
  assert args == ["grpc://localhost:50051"]
30
30
  assert "db_kwargs" not in kwargs
31
31
 
32
32
 
33
33
  def test_default_port_tls():
34
- args, _ = _build("adbc+flightsql://flight-sql.example.com")
34
+ args, _ = _build("flightsql+adbc://flight-sql.example.com")
35
35
  assert args == ["grpc+tls://flight-sql.example.com:443"]
36
36
 
37
37
 
38
38
  def test_default_port_plaintext():
39
- args, _ = _build("adbc+flightsql://localhost?tls=false")
39
+ args, _ = _build("flightsql+adbc://localhost?tls=false")
40
40
  assert args == ["grpc://localhost:50051"]
41
41
 
42
42
 
43
43
  def test_tls_scheme_explicit():
44
- args, _ = _build("adbc+flightsql://flight-sql.example.com:443?tls=true")
44
+ args, _ = _build("flightsql+adbc://flight-sql.example.com:443?tls=true")
45
45
  assert args == ["grpc+tls://flight-sql.example.com:443"]
46
46
 
47
47
 
48
48
  def test_bearer_from_password():
49
- args, kwargs = _build("adbc+flightsql://user:TOKEN@host:443?tls=true")
49
+ args, kwargs = _build("flightsql+adbc://user:TOKEN@host:443?tls=true")
50
50
  assert args == ["grpc+tls://host:443"]
51
51
  # We can't assert on the exact key name without the driver installed —
52
52
  # the flightsql dialect has a fallback constant for this case.
@@ -55,13 +55,13 @@ def test_bearer_from_password():
55
55
 
56
56
 
57
57
  def test_authorization_passthrough():
58
- args, kwargs = _build("adbc+flightsql://host:443?tls=true&authorization=Bearer%20abc")
58
+ args, kwargs = _build("flightsql+adbc://host:443?tls=true&authorization=Bearer%20abc")
59
59
  db = kwargs["db_kwargs"]
60
60
  assert any(v == "Bearer abc" for v in db.values())
61
61
 
62
62
 
63
63
  def test_custom_headers():
64
- _, kwargs = _build("adbc+flightsql://host:443?tls=true&header.x-workspace-id=ws-1")
64
+ _, kwargs = _build("flightsql+adbc://host:443?tls=true&header.x-workspace-id=ws-1")
65
65
  db = kwargs["db_kwargs"]
66
66
  # Header keys use the RPC_CALL_HEADER_PREFIX; just check the value made
67
67
  # it through.
@@ -69,5 +69,5 @@ def test_custom_headers():
69
69
 
70
70
 
71
71
  def test_unknown_query_passthrough():
72
- _, kwargs = _build("adbc+flightsql://host:443?tls=true&some_opt=value")
72
+ _, kwargs = _build("flightsql+adbc://host:443?tls=true&some_opt=value")
73
73
  assert kwargs["db_kwargs"]["some_opt"] == "value"
@@ -29,7 +29,7 @@ from sqlalchemy import inspect
29
29
  PG_URL = os.environ.get("DRLS_PG_URL")
30
30
  pytestmark = pytest.mark.skipif(
31
31
  not PG_URL,
32
- reason="Requires DRLS_PG_URL (e.g. adbc+postgresql://user:pw@host/db)",
32
+ reason="Requires DRLS_PG_URL (e.g. postgresql+adbc://user:pw@host/db)",
33
33
  )
34
34
 
35
35
  # Lazy-imported so the pytest collection doesn't fail when the extra
@@ -12,72 +12,85 @@ def _build(url_str: str):
12
12
 
13
13
 
14
14
  def test_full_url():
15
- args, kwargs = _build("adbc+postgresql://user:pass@host:5432/mydb")
15
+ args, kwargs = _build("postgresql+adbc://user:pass@host:5432/mydb")
16
16
  assert args == ["postgresql://user:pass@host:5432/mydb"]
17
17
  assert kwargs == {}
18
18
 
19
19
 
20
20
  def test_no_credentials():
21
- args, _ = _build("adbc+postgresql://host:5432/mydb")
21
+ args, _ = _build("postgresql+adbc://host:5432/mydb")
22
22
  assert args == ["postgresql://host:5432/mydb"]
23
23
 
24
24
 
25
25
  def test_no_port():
26
- args, _ = _build("adbc+postgresql://user:pass@host/mydb")
26
+ args, _ = _build("postgresql+adbc://user:pass@host/mydb")
27
27
  assert args == ["postgresql://user:pass@host/mydb"]
28
28
 
29
29
 
30
30
  def test_query_options_in_db_kwargs():
31
- _, kwargs = _build("adbc+postgresql://user:pass@host:5432/mydb?sslmode=require&app=x")
31
+ _, kwargs = _build("postgresql+adbc://user:pass@host:5432/mydb?sslmode=require&app=x")
32
32
  assert kwargs["db_kwargs"] == {"sslmode": "require", "app": "x"}
33
33
 
34
34
 
35
35
  def test_password_with_at_sign_quoted():
36
36
  """A password containing @ must not be interpreted as host delimiter."""
37
- args, _ = _build("adbc+postgresql://user:p%40ss@host:5432/db")
37
+ args, _ = _build("postgresql+adbc://user:p%40ss@host:5432/db")
38
38
  # SQLAlchemy decodes %40 → @; we must re-encode for the DSN.
39
39
  assert args == ["postgresql://user:p%40ss@host:5432/db"]
40
40
 
41
41
 
42
42
  def test_password_with_colon_quoted():
43
- args, _ = _build("adbc+postgresql://user:a%3Ab@host/db")
43
+ args, _ = _build("postgresql+adbc://user:a%3Ab@host/db")
44
44
  assert args == ["postgresql://user:a%3Ab@host/db"]
45
45
 
46
46
 
47
47
  def test_password_with_slash_quoted():
48
- args, _ = _build("adbc+postgresql://user:a%2Fb@host/db")
48
+ args, _ = _build("postgresql+adbc://user:a%2Fb@host/db")
49
49
  assert args == ["postgresql://user:a%2Fb@host/db"]
50
50
 
51
51
 
52
52
  def test_password_with_hash_quoted():
53
53
  """``#`` in a DSN starts a fragment; must be quoted."""
54
- args, _ = _build("adbc+postgresql://user:a%23b@host/db")
54
+ args, _ = _build("postgresql+adbc://user:a%23b@host/db")
55
55
  assert args == ["postgresql://user:a%23b@host/db"]
56
56
 
57
57
 
58
58
  def test_password_with_percent_quoted():
59
- args, _ = _build("adbc+postgresql://user:a%25b@host/db")
59
+ args, _ = _build("postgresql+adbc://user:a%25b@host/db")
60
60
  # SQLAlchemy decodes %25 → %; we must re-encode.
61
61
  assert args == ["postgresql://user:a%25b@host/db"]
62
62
 
63
63
 
64
64
  def test_username_special_chars_quoted():
65
- args, _ = _build("adbc+postgresql://u%40me:pw@host/db")
65
+ args, _ = _build("postgresql+adbc://u%40me:pw@host/db")
66
66
  assert args == ["postgresql://u%40me:pw@host/db"]
67
67
 
68
68
 
69
69
  def test_database_with_spaces_quoted():
70
- args, _ = _build("adbc+postgresql://host/my%20db")
70
+ args, _ = _build("postgresql+adbc://host/my%20db")
71
71
  assert args == ["postgresql://host/my%20db"]
72
72
 
73
73
 
74
+ def test_database_with_encoded_percent_sequence_roundtrips():
75
+ # A database literally named ``my%20db`` arrives as ``%2520``. It must
76
+ # come out exactly as it went in, not collapsed to a space.
77
+ args, _ = _build("postgresql+adbc://host/my%2520db")
78
+ assert args == ["postgresql://host/my%2520db"]
79
+
80
+
81
+ def test_database_with_encoded_slash_quoted():
82
+ # libpq reads ``/`` as a path separator, so it must stay escaped.
83
+ args, _ = _build("postgresql+adbc://host/a%2Fb")
84
+ assert args == ["postgresql://host/a%2Fb"]
85
+
86
+
74
87
  def test_defaults_localhost():
75
- args, _ = _build("adbc+postgresql:///mydb")
88
+ args, _ = _build("postgresql+adbc:///mydb")
76
89
  assert args == ["postgresql://localhost/mydb"]
77
90
 
78
91
 
79
92
  def test_password_no_username_still_builds():
80
93
  """SQLAlchemy URL can have a password-only userinfo (edge case)."""
81
- args, _ = _build("adbc+postgresql://:pw@host/db")
94
+ args, _ = _build("postgresql+adbc://:pw@host/db")
82
95
  # We emit auth=":pw@" — libpq accepts this as password-only.
83
96
  assert args[0].startswith("postgresql://:pw@host")
@@ -8,15 +8,18 @@ and are manually smoke-tested.
8
8
 
9
9
  from __future__ import annotations
10
10
 
11
+ from importlib.metadata import version
11
12
  from typing import Any
12
13
 
13
14
  import pytest
14
15
  import sqlalchemy as sa
15
16
  from sqlalchemy import inspect
16
17
 
18
+ _ADBC_SQLITE_VERSION = tuple(int(part) for part in version("adbc-driver-sqlite").split(".")[:2])
19
+
17
20
 
18
21
  def _make_engine():
19
- return sa.create_engine("adbc+sqlite:///:memory:")
22
+ return sa.create_engine("sqlite+adbc:///:memory:")
20
23
 
21
24
 
22
25
  def _seed(engine) -> None:
@@ -362,10 +365,9 @@ def test_metadata_reflect_full_schema():
362
365
 
363
366
 
364
367
  @pytest.mark.xfail(
365
- reason="ADBC SQLite driver reports xdbc_is_nullable='YES' for every "
366
- "column, including NOT NULL ones — upstream limitation in "
367
- "adbc_driver_sqlite GetObjects. Reflection code is correct; this "
368
- "test unblocks automatically when the driver is fixed.",
368
+ _ADBC_SQLITE_VERSION < (1, 13),
369
+ reason="adbc_driver_sqlite < 1.13 reports xdbc_is_nullable='YES' for "
370
+ "every column, including NOT NULL ones (fixed upstream in 1.13.0).",
369
371
  strict=True,
370
372
  )
371
373
  def test_metadata_reflect_column_nullability():
@@ -12,52 +12,52 @@ def _build(url_str: str):
12
12
 
13
13
 
14
14
  def test_basic_dsn():
15
- args, kwargs = _build("adbc+snowflake://user:pw@myacct/db/schema?warehouse=WH")
15
+ args, kwargs = _build("snowflake+adbc://user:pw@myacct/db/schema?warehouse=WH")
16
16
  assert args == ["user:pw@myacct/db/schema?warehouse=WH"]
17
17
  assert kwargs == {}
18
18
 
19
19
 
20
20
  def test_multiple_query_params_preserved():
21
- args, _ = _build("adbc+snowflake://u:p@acct/db/s?warehouse=WH&role=R")
21
+ args, _ = _build("snowflake+adbc://u:p@acct/db/s?warehouse=WH&role=R")
22
22
  # Order of dict.items() in Python 3.7+ is insertion order; SQLAlchemy
23
23
  # preserves URL order, so this is deterministic.
24
24
  assert args[0].endswith("?warehouse=WH&role=R")
25
25
 
26
26
 
27
27
  def test_password_at_sign_escaped():
28
- args, _ = _build("adbc+snowflake://u:p%40w@acct/db")
28
+ args, _ = _build("snowflake+adbc://u:p%40w@acct/db")
29
29
  assert args == ["u:p%40w@acct/db"]
30
30
 
31
31
 
32
32
  def test_query_value_with_space_escaped():
33
33
  """warehouse names can legally contain spaces when quoted in SQL."""
34
- args, _ = _build("adbc+snowflake://u:p@acct/db?warehouse=MY%20WH")
34
+ args, _ = _build("snowflake+adbc://u:p@acct/db?warehouse=MY%20WH")
35
35
  assert args[0].endswith("?warehouse=MY%20WH")
36
36
 
37
37
 
38
38
  def test_query_ampersand_in_value_escaped():
39
- args, _ = _build("adbc+snowflake://u:p@acct/db?role=A%26B")
39
+ args, _ = _build("snowflake+adbc://u:p@acct/db?role=A%26B")
40
40
  # & inside a value must be %26 or the parser will treat it as a separator
41
41
  assert args[0].endswith("?role=A%26B")
42
42
 
43
43
 
44
44
  def test_no_database():
45
- args, _ = _build("adbc+snowflake://u:p@acct")
45
+ args, _ = _build("snowflake+adbc://u:p@acct")
46
46
  assert args == ["u:p@acct"]
47
47
 
48
48
 
49
49
  def test_no_query():
50
- args, _ = _build("adbc+snowflake://u:p@acct/db")
50
+ args, _ = _build("snowflake+adbc://u:p@acct/db")
51
51
  assert args == ["u:p@acct/db"]
52
52
 
53
53
 
54
54
  def test_username_only():
55
- args, _ = _build("adbc+snowflake://u@acct/db")
55
+ args, _ = _build("snowflake+adbc://u@acct/db")
56
56
  assert args == ["u@acct/db"]
57
57
 
58
58
 
59
59
  def test_database_path_with_schema():
60
60
  # SQLAlchemy treats everything after the host as `database`; we must
61
61
  # NOT over-escape ``/`` between db and schema.
62
- args, _ = _build("adbc+snowflake://u:p@acct/my_db/public")
62
+ args, _ = _build("snowflake+adbc://u:p@acct/my_db/public")
63
63
  assert args == ["u:p@acct/my_db/public"]
@@ -10,7 +10,7 @@ pytest.importorskip("adbc_driver_sqlite")
10
10
 
11
11
 
12
12
  def test_engine_created():
13
- engine = sqlalchemy.create_engine("adbc+sqlite:///:memory:")
13
+ engine = sqlalchemy.create_engine("sqlite+adbc:///:memory:")
14
14
  # `.name` is the backend identity (used by Alembic's DDL impl
15
15
  # registry); `.driver` is the wire path. Reporting name="sqlite"
16
16
  # lets Alembic autogenerate find the standard SQLite DDL impl.
@@ -19,7 +19,7 @@ def test_engine_created():
19
19
 
20
20
 
21
21
  def test_select_one():
22
- engine = sqlalchemy.create_engine("adbc+sqlite:///:memory:")
22
+ engine = sqlalchemy.create_engine("sqlite+adbc:///:memory:")
23
23
  with engine.connect() as conn:
24
24
  result = conn.execute(text("SELECT 1 AS n"))
25
25
  row = result.fetchone()
@@ -28,7 +28,7 @@ def test_select_one():
28
28
 
29
29
 
30
30
  def test_create_insert_select():
31
- engine = sqlalchemy.create_engine("adbc+sqlite:///:memory:")
31
+ engine = sqlalchemy.create_engine("sqlite+adbc:///:memory:")
32
32
  with engine.begin() as conn:
33
33
  conn.execute(text("CREATE TABLE t (id INTEGER, name TEXT)"))
34
34
  conn.execute(text("INSERT INTO t VALUES (1, 'a'), (2, 'b'), (3, 'c')"))
@@ -41,7 +41,7 @@ def test_create_insert_select():
41
41
 
42
42
  def test_file_backed(tmp_path):
43
43
  db_path = tmp_path / "test.db"
44
- engine = sqlalchemy.create_engine(f"adbc+sqlite:///{db_path}")
44
+ engine = sqlalchemy.create_engine(f"sqlite+adbc:///{db_path}")
45
45
  with engine.begin() as conn:
46
46
  conn.execute(text("CREATE TABLE kv (k TEXT PRIMARY KEY, v TEXT)"))
47
47
  conn.execute(text("INSERT INTO kv VALUES ('hello', 'world')"))
@@ -52,7 +52,7 @@ def test_file_backed(tmp_path):
52
52
 
53
53
 
54
54
  def test_do_ping():
55
- engine = sqlalchemy.create_engine("adbc+sqlite:///:memory:")
55
+ engine = sqlalchemy.create_engine("sqlite+adbc:///:memory:")
56
56
  with engine.connect() as conn:
57
57
  # pool_pre_ping path exercises dialect.do_ping
58
58
  raw = conn.connection.dbapi_connection
@@ -0,0 +1,55 @@
1
+ """URL scheme registration.
2
+
3
+ SQLAlchemy URLs are ``<backend>+<driver>://``. ADBC is the driver, so the
4
+ documented form is ``postgresql+adbc://``. The original ``adbc+postgresql://``
5
+ spelling stays registered as a deprecated alias (#15).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import warnings
11
+
12
+ import pytest
13
+ import sqlalchemy as sa
14
+ from sqlalchemy.engine.url import make_url
15
+
16
+ from sqlalchemy_adbc.bigquery import ADBCBigQueryDialect
17
+ from sqlalchemy_adbc.flightsql import ADBCFlightSQLDialect
18
+ from sqlalchemy_adbc.postgresql import ADBCPostgreSQLDialect
19
+ from sqlalchemy_adbc.snowflake import ADBCSnowflakeDialect
20
+ from sqlalchemy_adbc.sqlite import ADBCSQLiteDialect
21
+
22
+ BACKENDS = [
23
+ ("flightsql", ADBCFlightSQLDialect),
24
+ ("sqlite", ADBCSQLiteDialect),
25
+ ("postgresql", ADBCPostgreSQLDialect),
26
+ ("snowflake", ADBCSnowflakeDialect),
27
+ ("bigquery", ADBCBigQueryDialect),
28
+ ]
29
+
30
+
31
+ @pytest.mark.parametrize(("backend", "dialect_cls"), BACKENDS)
32
+ def test_backend_plus_adbc_resolves(backend, dialect_cls):
33
+ url = make_url(f"{backend}+adbc://host/db")
34
+ assert url.get_dialect() is dialect_cls
35
+ # URL and dialect agree on which side is the backend and which the driver.
36
+ assert url.get_backend_name() == dialect_cls.name
37
+ assert url.get_driver_name() == dialect_cls.driver == "adbc"
38
+
39
+
40
+ @pytest.mark.parametrize(("backend", "dialect_cls"), BACKENDS)
41
+ def test_legacy_adbc_plus_backend_still_resolves(backend, dialect_cls):
42
+ assert make_url(f"adbc+{backend}://host/db").get_dialect() is dialect_cls
43
+
44
+
45
+ def test_legacy_scheme_warns_on_engine_creation():
46
+ with pytest.warns(DeprecationWarning, match=r"sqlite\+adbc://"):
47
+ sa.create_engine("adbc+sqlite:///:memory:")
48
+
49
+
50
+ def test_documented_scheme_does_not_warn():
51
+ with warnings.catch_warnings():
52
+ warnings.simplefilter("error")
53
+ engine = sa.create_engine("sqlite+adbc:///:memory:")
54
+ with engine.connect() as conn:
55
+ assert conn.exec_driver_sql("SELECT 1").scalar() == 1
File without changes