sqlalchemy-adbc 0.1.0__tar.gz → 0.2.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.
- {sqlalchemy_adbc-0.1.0 → sqlalchemy_adbc-0.2.0}/.github/workflows/ci.yml +2 -0
- {sqlalchemy_adbc-0.1.0 → sqlalchemy_adbc-0.2.0}/PKG-INFO +24 -6
- {sqlalchemy_adbc-0.1.0 → sqlalchemy_adbc-0.2.0}/README.md +23 -5
- {sqlalchemy_adbc-0.1.0 → sqlalchemy_adbc-0.2.0}/pyproject.toml +25 -1
- {sqlalchemy_adbc-0.1.0 → sqlalchemy_adbc-0.2.0}/src/sqlalchemy_adbc/flightsql.py +30 -10
- sqlalchemy_adbc-0.2.0/src/sqlalchemy_adbc/postgresql.py +46 -0
- sqlalchemy_adbc-0.2.0/src/sqlalchemy_adbc/snowflake.py +48 -0
- sqlalchemy_adbc-0.2.0/tests/test_base.py +81 -0
- sqlalchemy_adbc-0.2.0/tests/test_bigquery_url.py +45 -0
- {sqlalchemy_adbc-0.1.0 → sqlalchemy_adbc-0.2.0}/tests/test_flightsql_url.py +21 -3
- sqlalchemy_adbc-0.2.0/tests/test_postgresql_url.py +83 -0
- sqlalchemy_adbc-0.2.0/tests/test_snowflake_url.py +63 -0
- sqlalchemy_adbc-0.1.0/src/sqlalchemy_adbc/postgresql.py +0 -31
- sqlalchemy_adbc-0.1.0/src/sqlalchemy_adbc/snowflake.py +0 -38
- {sqlalchemy_adbc-0.1.0 → sqlalchemy_adbc-0.2.0}/.github/workflows/publish.yml +0 -0
- {sqlalchemy_adbc-0.1.0 → sqlalchemy_adbc-0.2.0}/.gitignore +0 -0
- {sqlalchemy_adbc-0.1.0 → sqlalchemy_adbc-0.2.0}/LICENSE +0 -0
- {sqlalchemy_adbc-0.1.0 → sqlalchemy_adbc-0.2.0}/src/sqlalchemy_adbc/__init__.py +0 -0
- {sqlalchemy_adbc-0.1.0 → sqlalchemy_adbc-0.2.0}/src/sqlalchemy_adbc/base.py +0 -0
- {sqlalchemy_adbc-0.1.0 → sqlalchemy_adbc-0.2.0}/src/sqlalchemy_adbc/bigquery.py +0 -0
- {sqlalchemy_adbc-0.1.0 → sqlalchemy_adbc-0.2.0}/src/sqlalchemy_adbc/sqlite.py +0 -0
- {sqlalchemy_adbc-0.1.0 → sqlalchemy_adbc-0.2.0}/tests/__init__.py +0 -0
- {sqlalchemy_adbc-0.1.0 → sqlalchemy_adbc-0.2.0}/tests/test_sqlite.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sqlalchemy-adbc
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.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
|
|
@@ -118,11 +118,29 @@ df = pd.read_sql("SELECT 1 AS n", engine)
|
|
|
118
118
|
## Status
|
|
119
119
|
|
|
120
120
|
Alpha — the core dialect machinery works for simple `SELECT` / `INSERT` /
|
|
121
|
-
`CREATE TABLE` use. More advanced SQLAlchemy features
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
121
|
+
`CREATE TABLE` use. More advanced SQLAlchemy features are still to come;
|
|
122
|
+
the known gaps are called out below so you don't waste time debugging a
|
|
123
|
+
missing feature:
|
|
124
|
+
|
|
125
|
+
- **Table reflection** (`MetaData.reflect()`): not implemented. The ADBC
|
|
126
|
+
driver exposes table/column metadata via the Arrow-based `GetObjects`
|
|
127
|
+
call, but this library doesn't yet wire that into SQLAlchemy's
|
|
128
|
+
`Inspector` contract. PRs welcome.
|
|
129
|
+
- **Alembic autogenerate**: follows from reflection — won't work until
|
|
130
|
+
reflection lands. `alembic upgrade` against already-written migrations
|
|
131
|
+
is fine (it's pure SQL).
|
|
132
|
+
- **PostgreSQL-specific types** (JSONB, ARRAY, UUID, TSTZMULTIRANGE,
|
|
133
|
+
…): ADBC's Arrow-over-the-wire codec returns these as strings / lists
|
|
134
|
+
of primitives, not as SQLAlchemy's typed objects. If you need ORM-level
|
|
135
|
+
round-tripping of PG-specific types, use `psycopg2` until we add a
|
|
136
|
+
type compiler.
|
|
137
|
+
- **Query parameterization**: uses ADBC's native `$1`-style placeholders
|
|
138
|
+
for PostgreSQL and `?` for SQLite. Flight SQL and Snowflake delegate
|
|
139
|
+
to the driver's default.
|
|
140
|
+
|
|
141
|
+
PRs welcome.
|
|
142
|
+
|
|
143
|
+
Tests: 52+ passing against ADBC SQLite + URL/DSN translators on Python 3.9–3.13.
|
|
126
144
|
|
|
127
145
|
## Development
|
|
128
146
|
|
|
@@ -79,11 +79,29 @@ df = pd.read_sql("SELECT 1 AS n", engine)
|
|
|
79
79
|
## Status
|
|
80
80
|
|
|
81
81
|
Alpha — the core dialect machinery works for simple `SELECT` / `INSERT` /
|
|
82
|
-
`CREATE TABLE` use. More advanced SQLAlchemy features
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
82
|
+
`CREATE TABLE` use. More advanced SQLAlchemy features are still to come;
|
|
83
|
+
the known gaps are called out below so you don't waste time debugging a
|
|
84
|
+
missing feature:
|
|
85
|
+
|
|
86
|
+
- **Table reflection** (`MetaData.reflect()`): not implemented. The ADBC
|
|
87
|
+
driver exposes table/column metadata via the Arrow-based `GetObjects`
|
|
88
|
+
call, but this library doesn't yet wire that into SQLAlchemy's
|
|
89
|
+
`Inspector` contract. PRs welcome.
|
|
90
|
+
- **Alembic autogenerate**: follows from reflection — won't work until
|
|
91
|
+
reflection lands. `alembic upgrade` against already-written migrations
|
|
92
|
+
is fine (it's pure SQL).
|
|
93
|
+
- **PostgreSQL-specific types** (JSONB, ARRAY, UUID, TSTZMULTIRANGE,
|
|
94
|
+
…): ADBC's Arrow-over-the-wire codec returns these as strings / lists
|
|
95
|
+
of primitives, not as SQLAlchemy's typed objects. If you need ORM-level
|
|
96
|
+
round-tripping of PG-specific types, use `psycopg2` until we add a
|
|
97
|
+
type compiler.
|
|
98
|
+
- **Query parameterization**: uses ADBC's native `$1`-style placeholders
|
|
99
|
+
for PostgreSQL and `?` for SQLite. Flight SQL and Snowflake delegate
|
|
100
|
+
to the driver's default.
|
|
101
|
+
|
|
102
|
+
PRs welcome.
|
|
103
|
+
|
|
104
|
+
Tests: 52+ passing against ADBC SQLite + URL/DSN translators on Python 3.9–3.13.
|
|
87
105
|
|
|
88
106
|
## Development
|
|
89
107
|
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "sqlalchemy-adbc"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.2.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" }
|
|
@@ -70,6 +70,30 @@ target-version = "py39"
|
|
|
70
70
|
select = ["E", "F", "W", "I", "B", "UP"]
|
|
71
71
|
ignore = ["E501"]
|
|
72
72
|
|
|
73
|
+
[tool.mypy]
|
|
74
|
+
# Intentionally 3.10 (not 3.9) — mypy traverses third-party packages
|
|
75
|
+
# via follow-imports, and pytest itself uses `match`/`case` syntax that
|
|
76
|
+
# the 3.9 parser can't even tokenize. Our own source is still
|
|
77
|
+
# 3.9-compatible; runtime compat is covered by the pytest matrix.
|
|
78
|
+
python_version = "3.10"
|
|
79
|
+
strict_optional = true
|
|
80
|
+
warn_redundant_casts = true
|
|
81
|
+
warn_unused_ignores = true
|
|
82
|
+
follow_imports = "silent"
|
|
83
|
+
|
|
84
|
+
# ADBC driver wheels don't ship type stubs; lazy imports in the dialect
|
|
85
|
+
# modules guard on ImportError so we only pay the type-ignore cost here.
|
|
86
|
+
[[tool.mypy.overrides]]
|
|
87
|
+
module = [
|
|
88
|
+
"adbc_driver_manager.*",
|
|
89
|
+
"adbc_driver_flightsql.*",
|
|
90
|
+
"adbc_driver_postgresql.*",
|
|
91
|
+
"adbc_driver_sqlite.*",
|
|
92
|
+
"adbc_driver_snowflake.*",
|
|
93
|
+
"adbc_driver_bigquery.*",
|
|
94
|
+
]
|
|
95
|
+
ignore_missing_imports = true
|
|
96
|
+
|
|
73
97
|
[tool.pytest.ini_options]
|
|
74
98
|
testpaths = ["tests"]
|
|
75
99
|
addopts = "-v"
|
|
@@ -29,11 +29,23 @@ class ADBCFlightSQLDialect(ADBCDialect):
|
|
|
29
29
|
supports_statement_cache = True
|
|
30
30
|
|
|
31
31
|
def build_connect_args(self, url: URL) -> tuple[list[Any], dict[str, Any]]:
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
32
|
+
# SQLAlchemy's URL.query is `immutabledict[str, str | tuple[str, ...]]`
|
|
33
|
+
# — repeated keys (``?h=a&h=b``) collapse to a tuple. Flatten to
|
|
34
|
+
# `dict[str, str]` so the ADBC driver always sees scalar values;
|
|
35
|
+
# for repeated keys we keep the last (typical shell + HTTP layer
|
|
36
|
+
# behavior) rather than silently dropping them.
|
|
37
|
+
query: dict[str, str] = {k: _scalar(v) for k, v in url.query.items()}
|
|
38
|
+
|
|
39
|
+
# TLS defaults to ON — Flight SQL is typically exposed as a
|
|
40
|
+
# public service (e.g., flight-sql.drls.io), and a bearer token
|
|
41
|
+
# sent over plaintext gRPC is a credential leak. Local/dev
|
|
42
|
+
# setups on a plaintext listener can opt out with ``?tls=false``.
|
|
43
|
+
tls = _pop_bool(query, "tls", default=True)
|
|
44
|
+
scheme = "grpc+tls" if tls else "grpc"
|
|
35
45
|
host = url.host or "localhost"
|
|
36
|
-
|
|
46
|
+
# Flight SQL convention: 443 for TLS (matches HTTPS), 50051 for
|
|
47
|
+
# plaintext gRPC (the Arrow Flight default).
|
|
48
|
+
port = url.port or (443 if tls else 50051)
|
|
37
49
|
uri = f"{scheme}://{host}:{port}"
|
|
38
50
|
|
|
39
51
|
db_kwargs: dict[str, str] = {}
|
|
@@ -42,10 +54,10 @@ class ADBCFlightSQLDialect(ADBCDialect):
|
|
|
42
54
|
# extra — tests and IDE tooling shouldn't have to pull the
|
|
43
55
|
# flightsql wheel.
|
|
44
56
|
try:
|
|
45
|
-
from adbc_driver_flightsql import DatabaseOptions
|
|
57
|
+
from adbc_driver_flightsql import DatabaseOptions
|
|
46
58
|
|
|
47
|
-
auth_key = DatabaseOptions.AUTHORIZATION_HEADER.value
|
|
48
|
-
header_prefix = DatabaseOptions.RPC_CALL_HEADER_PREFIX.value
|
|
59
|
+
auth_key: str = DatabaseOptions.AUTHORIZATION_HEADER.value
|
|
60
|
+
header_prefix: str = DatabaseOptions.RPC_CALL_HEADER_PREFIX.value
|
|
49
61
|
except ImportError: # pragma: no cover - hit only without extra
|
|
50
62
|
auth_key = "adbc.flight.sql.authorization_header"
|
|
51
63
|
header_prefix = "adbc.flight.sql.rpc.call_header."
|
|
@@ -72,8 +84,16 @@ class ADBCFlightSQLDialect(ADBCDialect):
|
|
|
72
84
|
return [uri], {"db_kwargs": db_kwargs} if db_kwargs else {}
|
|
73
85
|
|
|
74
86
|
|
|
75
|
-
def
|
|
87
|
+
def _scalar(v: str | tuple[str, ...]) -> str:
|
|
88
|
+
"""Collapse a possibly-repeated URL query value to a single string.
|
|
89
|
+
|
|
90
|
+
SQLAlchemy gives us a tuple for ``?k=a&k=b`` (both values); we keep
|
|
91
|
+
the last, matching shell/HTTP conventions where the later value wins.
|
|
92
|
+
"""
|
|
93
|
+
return v[-1] if isinstance(v, tuple) else v
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _pop_bool(d: dict[str, str], key: str, *, default: bool) -> bool:
|
|
76
97
|
if key not in d:
|
|
77
98
|
return default
|
|
78
|
-
|
|
79
|
-
return str(v).lower() in {"1", "true", "yes", "on"}
|
|
99
|
+
return d.pop(key).lower() in {"1", "true", "yes", "on"}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""ADBC PostgreSQL dialect.
|
|
2
|
+
|
|
3
|
+
We build a libpq connection *URI* rather than putting credentials in a
|
|
4
|
+
query string, but everything user-supplied is URL-quoted so that a
|
|
5
|
+
password containing ``@``, ``:``, ``/``, ``%``, ``?``, or ``#`` does not
|
|
6
|
+
corrupt the DSN or change its parse. Without quoting, a password like
|
|
7
|
+
``p@ss`` would silently re-route libpq to the wrong host.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Any
|
|
13
|
+
from urllib.parse import quote
|
|
14
|
+
|
|
15
|
+
from sqlalchemy.engine.url import URL
|
|
16
|
+
|
|
17
|
+
from sqlalchemy_adbc.base import ADBCDialect
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ADBCPostgreSQLDialect(ADBCDialect):
|
|
21
|
+
name = "adbc"
|
|
22
|
+
driver = "postgresql"
|
|
23
|
+
driver_module = "adbc_driver_postgresql.dbapi"
|
|
24
|
+
supports_statement_cache = True
|
|
25
|
+
|
|
26
|
+
def build_connect_args(self, url: URL) -> tuple[list[Any], dict[str, Any]]:
|
|
27
|
+
# SQLAlchemy's URL parser decodes percent-escapes in userinfo
|
|
28
|
+
# (`url.username`, `url.password`) but leaves the path component
|
|
29
|
+
# (`url.database`) untouched. So we re-quote userinfo before
|
|
30
|
+
# rebuilding the libpq URI — without it, a password like ``p@ss``
|
|
31
|
+
# would silently re-route to a different host — but we pass the
|
|
32
|
+
# database through as-is. Re-quoting the database would
|
|
33
|
+
# double-encode already-percent-escaped names (``my%20db`` →
|
|
34
|
+
# ``my%2520db``).
|
|
35
|
+
user = quote(url.username, safe="") if url.username else ""
|
|
36
|
+
password = f":{quote(url.password, safe='')}" if url.password else ""
|
|
37
|
+
auth = f"{user}{password}@" if user or url.password else ""
|
|
38
|
+
host = url.host or "localhost"
|
|
39
|
+
port = f":{url.port}" if url.port else ""
|
|
40
|
+
database = f"/{url.database}" if url.database else ""
|
|
41
|
+
uri = f"postgresql://{auth}{host}{port}{database}"
|
|
42
|
+
|
|
43
|
+
kwargs: dict[str, Any] = {}
|
|
44
|
+
if url.query:
|
|
45
|
+
kwargs["db_kwargs"] = dict(url.query)
|
|
46
|
+
return [uri], kwargs
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""ADBC Snowflake dialect.
|
|
2
|
+
|
|
3
|
+
URL form::
|
|
4
|
+
|
|
5
|
+
adbc+snowflake://user:password@account/database/schema?warehouse=WH&role=R
|
|
6
|
+
|
|
7
|
+
adbc_driver_snowflake expects a DSN of the form
|
|
8
|
+
``user:password@account/database/schema?warehouse=...``. We URL-quote
|
|
9
|
+
every user-supplied component so passwords with ``@``, ``:``, ``/``,
|
|
10
|
+
``%``, ``?``, ``#`` and query values with spaces don't corrupt the DSN.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import Any
|
|
16
|
+
from urllib.parse import quote
|
|
17
|
+
|
|
18
|
+
from sqlalchemy.engine.url import URL
|
|
19
|
+
|
|
20
|
+
from sqlalchemy_adbc.base import ADBCDialect
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ADBCSnowflakeDialect(ADBCDialect):
|
|
24
|
+
name = "adbc"
|
|
25
|
+
driver = "snowflake"
|
|
26
|
+
driver_module = "adbc_driver_snowflake.dbapi"
|
|
27
|
+
supports_statement_cache = True
|
|
28
|
+
|
|
29
|
+
def build_connect_args(self, url: URL) -> tuple[list[Any], dict[str, Any]]:
|
|
30
|
+
# SQLAlchemy decodes userinfo but leaves the database path
|
|
31
|
+
# untouched. Re-quoting userinfo keeps a password like ``p@ss``
|
|
32
|
+
# from being parsed as a second @-separator; the path is
|
|
33
|
+
# already in the encoded form the caller wrote.
|
|
34
|
+
user = quote(url.username, safe="") if url.username else ""
|
|
35
|
+
password = f":{quote(url.password, safe='')}" if url.password else ""
|
|
36
|
+
auth = f"{user}{password}@" if user or url.password else ""
|
|
37
|
+
account = url.host or ""
|
|
38
|
+
path = f"/{url.database}" if url.database else ""
|
|
39
|
+
|
|
40
|
+
query_str = ""
|
|
41
|
+
if url.query:
|
|
42
|
+
pairs = [
|
|
43
|
+
f"{quote(str(k), safe='')}={quote(str(v), safe='')}" for k, v in url.query.items()
|
|
44
|
+
]
|
|
45
|
+
query_str = "?" + "&".join(pairs)
|
|
46
|
+
|
|
47
|
+
dsn = f"{auth}{account}{path}{query_str}"
|
|
48
|
+
return [dsn], {}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Unit tests for the base dialect."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from sqlalchemy.engine.url import make_url
|
|
7
|
+
|
|
8
|
+
from sqlalchemy_adbc.base import ADBCDialect
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_import_dbapi_without_driver_module_raises():
|
|
12
|
+
"""Plain ADBCDialect has no driver_module — must error clearly."""
|
|
13
|
+
|
|
14
|
+
class NoDriver(ADBCDialect):
|
|
15
|
+
name = "adbc"
|
|
16
|
+
driver = "none"
|
|
17
|
+
|
|
18
|
+
with pytest.raises(RuntimeError, match="driver_module"):
|
|
19
|
+
NoDriver.import_dbapi()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_import_dbapi_subclass_imports_module():
|
|
23
|
+
"""A subclass pointing at a real module successfully imports it."""
|
|
24
|
+
|
|
25
|
+
class Fake(ADBCDialect):
|
|
26
|
+
name = "adbc"
|
|
27
|
+
driver = "fake"
|
|
28
|
+
driver_module = "importlib" # any importable module works for the smoke
|
|
29
|
+
|
|
30
|
+
assert Fake.import_dbapi().__name__ == "importlib"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_format_uri_host_port_database():
|
|
34
|
+
url = make_url("adbc://host:9999/mydb")
|
|
35
|
+
assert ADBCDialect()._format_uri(url) == "host:9999/mydb"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_format_uri_host_only():
|
|
39
|
+
url = make_url("adbc://host")
|
|
40
|
+
assert ADBCDialect()._format_uri(url) == "host"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_format_uri_database_only():
|
|
44
|
+
url = make_url("adbc:///mydb")
|
|
45
|
+
assert ADBCDialect()._format_uri(url) == "mydb"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_format_uri_empty_returns_none():
|
|
49
|
+
url = make_url("adbc://")
|
|
50
|
+
assert ADBCDialect()._format_uri(url) is None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_build_connect_args_forwards_query_as_db_kwargs():
|
|
54
|
+
class WithDriver(ADBCDialect):
|
|
55
|
+
driver_module = "adbc_driver_sqlite.dbapi"
|
|
56
|
+
|
|
57
|
+
url = make_url("adbc://host:1234/db?key=value")
|
|
58
|
+
args, kwargs = WithDriver().build_connect_args(url)
|
|
59
|
+
assert args == ["host:1234/db"]
|
|
60
|
+
assert kwargs == {"db_kwargs": {"key": "value"}}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_build_connect_args_empty_query_omits_db_kwargs():
|
|
64
|
+
class WithDriver(ADBCDialect):
|
|
65
|
+
driver_module = "adbc_driver_sqlite.dbapi"
|
|
66
|
+
|
|
67
|
+
url = make_url("adbc://host:1234/db")
|
|
68
|
+
args, kwargs = WithDriver().build_connect_args(url)
|
|
69
|
+
assert args == ["host:1234/db"]
|
|
70
|
+
assert kwargs == {}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_dialect_name_and_driver_attrs():
|
|
74
|
+
"""SQLAlchemy uses these for URL scheme resolution."""
|
|
75
|
+
assert ADBCDialect.name == "adbc"
|
|
76
|
+
assert ADBCDialect.driver == "adbc"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_supports_statement_cache_true():
|
|
80
|
+
"""Required to silence SQLAlchemy's caching-disabled warning."""
|
|
81
|
+
assert ADBCDialect.supports_statement_cache is True
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Unit tests for the BigQuery URL → ADBC kwargs translation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from sqlalchemy.engine.url import make_url
|
|
6
|
+
|
|
7
|
+
from sqlalchemy_adbc.bigquery import ADBCBigQueryDialect
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _build(url_str: str):
|
|
11
|
+
return ADBCBigQueryDialect().build_connect_args(make_url(url_str))
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_project_from_database_segment():
|
|
15
|
+
"""``adbc+bigquery:///my-project`` → project_id set."""
|
|
16
|
+
args, kwargs = _build("adbc+bigquery:///my-project")
|
|
17
|
+
assert args == []
|
|
18
|
+
assert kwargs["db_kwargs"]["adbc.bigquery.sql.project_id"] == "my-project"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_project_plus_dataset():
|
|
22
|
+
_, kwargs = _build("adbc+bigquery:///my-project?dataset=events")
|
|
23
|
+
db = kwargs["db_kwargs"]
|
|
24
|
+
assert db["adbc.bigquery.sql.project_id"] == "my-project"
|
|
25
|
+
assert db["dataset"] == "events"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_only_query_params():
|
|
29
|
+
_, kwargs = _build("adbc+bigquery://?adbc.bigquery.sql.project_id=p&x=y")
|
|
30
|
+
db = kwargs["db_kwargs"]
|
|
31
|
+
assert db["adbc.bigquery.sql.project_id"] == "p"
|
|
32
|
+
assert db["x"] == "y"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_explicit_project_kwarg_wins_over_url():
|
|
36
|
+
"""If both URL-database and ``?project_id=...`` are present, the
|
|
37
|
+
explicit query string must not be overwritten by the implicit default."""
|
|
38
|
+
_, kwargs = _build("adbc+bigquery:///implicit?adbc.bigquery.sql.project_id=explicit")
|
|
39
|
+
assert kwargs["db_kwargs"]["adbc.bigquery.sql.project_id"] == "explicit"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_no_kwargs_at_all():
|
|
43
|
+
args, kwargs = _build("adbc+bigquery://")
|
|
44
|
+
assert args == []
|
|
45
|
+
assert kwargs == {}
|
|
@@ -16,13 +16,31 @@ def _build(url_str: str):
|
|
|
16
16
|
return ADBCFlightSQLDialect().build_connect_args(make_url(url_str))
|
|
17
17
|
|
|
18
18
|
|
|
19
|
-
def
|
|
20
|
-
|
|
19
|
+
def test_tls_is_default():
|
|
20
|
+
"""No ``tls`` query param → scheme is grpc+tls (secure by default)."""
|
|
21
|
+
args, kwargs = _build("adbc+flightsql://flight-sql.example.com:443")
|
|
22
|
+
assert args == ["grpc+tls://flight-sql.example.com:443"]
|
|
23
|
+
assert "db_kwargs" not in kwargs
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_tls_explicit_opt_out():
|
|
27
|
+
"""``?tls=false`` drops back to plaintext gRPC — needed for local dev."""
|
|
28
|
+
args, kwargs = _build("adbc+flightsql://localhost:50051?tls=false")
|
|
21
29
|
assert args == ["grpc://localhost:50051"]
|
|
22
30
|
assert "db_kwargs" not in kwargs
|
|
23
31
|
|
|
24
32
|
|
|
25
|
-
def
|
|
33
|
+
def test_default_port_tls():
|
|
34
|
+
args, _ = _build("adbc+flightsql://flight-sql.example.com")
|
|
35
|
+
assert args == ["grpc+tls://flight-sql.example.com:443"]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_default_port_plaintext():
|
|
39
|
+
args, _ = _build("adbc+flightsql://localhost?tls=false")
|
|
40
|
+
assert args == ["grpc://localhost:50051"]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_tls_scheme_explicit():
|
|
26
44
|
args, _ = _build("adbc+flightsql://flight-sql.example.com:443?tls=true")
|
|
27
45
|
assert args == ["grpc+tls://flight-sql.example.com:443"]
|
|
28
46
|
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Unit tests for the PostgreSQL URL → ADBC kwargs translation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from sqlalchemy.engine.url import make_url
|
|
6
|
+
|
|
7
|
+
from sqlalchemy_adbc.postgresql import ADBCPostgreSQLDialect
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _build(url_str: str):
|
|
11
|
+
return ADBCPostgreSQLDialect().build_connect_args(make_url(url_str))
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_full_url():
|
|
15
|
+
args, kwargs = _build("adbc+postgresql://user:pass@host:5432/mydb")
|
|
16
|
+
assert args == ["postgresql://user:pass@host:5432/mydb"]
|
|
17
|
+
assert kwargs == {}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_no_credentials():
|
|
21
|
+
args, _ = _build("adbc+postgresql://host:5432/mydb")
|
|
22
|
+
assert args == ["postgresql://host:5432/mydb"]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_no_port():
|
|
26
|
+
args, _ = _build("adbc+postgresql://user:pass@host/mydb")
|
|
27
|
+
assert args == ["postgresql://user:pass@host/mydb"]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_query_options_in_db_kwargs():
|
|
31
|
+
_, kwargs = _build("adbc+postgresql://user:pass@host:5432/mydb?sslmode=require&app=x")
|
|
32
|
+
assert kwargs["db_kwargs"] == {"sslmode": "require", "app": "x"}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_password_with_at_sign_quoted():
|
|
36
|
+
"""A password containing @ must not be interpreted as host delimiter."""
|
|
37
|
+
args, _ = _build("adbc+postgresql://user:p%40ss@host:5432/db")
|
|
38
|
+
# SQLAlchemy decodes %40 → @; we must re-encode for the DSN.
|
|
39
|
+
assert args == ["postgresql://user:p%40ss@host:5432/db"]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_password_with_colon_quoted():
|
|
43
|
+
args, _ = _build("adbc+postgresql://user:a%3Ab@host/db")
|
|
44
|
+
assert args == ["postgresql://user:a%3Ab@host/db"]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_password_with_slash_quoted():
|
|
48
|
+
args, _ = _build("adbc+postgresql://user:a%2Fb@host/db")
|
|
49
|
+
assert args == ["postgresql://user:a%2Fb@host/db"]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_password_with_hash_quoted():
|
|
53
|
+
"""``#`` in a DSN starts a fragment; must be quoted."""
|
|
54
|
+
args, _ = _build("adbc+postgresql://user:a%23b@host/db")
|
|
55
|
+
assert args == ["postgresql://user:a%23b@host/db"]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_password_with_percent_quoted():
|
|
59
|
+
args, _ = _build("adbc+postgresql://user:a%25b@host/db")
|
|
60
|
+
# SQLAlchemy decodes %25 → %; we must re-encode.
|
|
61
|
+
assert args == ["postgresql://user:a%25b@host/db"]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_username_special_chars_quoted():
|
|
65
|
+
args, _ = _build("adbc+postgresql://u%40me:pw@host/db")
|
|
66
|
+
assert args == ["postgresql://u%40me:pw@host/db"]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_database_with_spaces_quoted():
|
|
70
|
+
args, _ = _build("adbc+postgresql://host/my%20db")
|
|
71
|
+
assert args == ["postgresql://host/my%20db"]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_defaults_localhost():
|
|
75
|
+
args, _ = _build("adbc+postgresql:///mydb")
|
|
76
|
+
assert args == ["postgresql://localhost/mydb"]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_password_no_username_still_builds():
|
|
80
|
+
"""SQLAlchemy URL can have a password-only userinfo (edge case)."""
|
|
81
|
+
args, _ = _build("adbc+postgresql://:pw@host/db")
|
|
82
|
+
# We emit auth=":pw@" — libpq accepts this as password-only.
|
|
83
|
+
assert args[0].startswith("postgresql://:pw@host")
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Unit tests for the Snowflake URL → ADBC DSN translation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from sqlalchemy.engine.url import make_url
|
|
6
|
+
|
|
7
|
+
from sqlalchemy_adbc.snowflake import ADBCSnowflakeDialect
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _build(url_str: str):
|
|
11
|
+
return ADBCSnowflakeDialect().build_connect_args(make_url(url_str))
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_basic_dsn():
|
|
15
|
+
args, kwargs = _build("adbc+snowflake://user:pw@myacct/db/schema?warehouse=WH")
|
|
16
|
+
assert args == ["user:pw@myacct/db/schema?warehouse=WH"]
|
|
17
|
+
assert kwargs == {}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_multiple_query_params_preserved():
|
|
21
|
+
args, _ = _build("adbc+snowflake://u:p@acct/db/s?warehouse=WH&role=R")
|
|
22
|
+
# Order of dict.items() in Python 3.7+ is insertion order; SQLAlchemy
|
|
23
|
+
# preserves URL order, so this is deterministic.
|
|
24
|
+
assert args[0].endswith("?warehouse=WH&role=R")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_password_at_sign_escaped():
|
|
28
|
+
args, _ = _build("adbc+snowflake://u:p%40w@acct/db")
|
|
29
|
+
assert args == ["u:p%40w@acct/db"]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_query_value_with_space_escaped():
|
|
33
|
+
"""warehouse names can legally contain spaces when quoted in SQL."""
|
|
34
|
+
args, _ = _build("adbc+snowflake://u:p@acct/db?warehouse=MY%20WH")
|
|
35
|
+
assert args[0].endswith("?warehouse=MY%20WH")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_query_ampersand_in_value_escaped():
|
|
39
|
+
args, _ = _build("adbc+snowflake://u:p@acct/db?role=A%26B")
|
|
40
|
+
# & inside a value must be %26 or the parser will treat it as a separator
|
|
41
|
+
assert args[0].endswith("?role=A%26B")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_no_database():
|
|
45
|
+
args, _ = _build("adbc+snowflake://u:p@acct")
|
|
46
|
+
assert args == ["u:p@acct"]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_no_query():
|
|
50
|
+
args, _ = _build("adbc+snowflake://u:p@acct/db")
|
|
51
|
+
assert args == ["u:p@acct/db"]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_username_only():
|
|
55
|
+
args, _ = _build("adbc+snowflake://u@acct/db")
|
|
56
|
+
assert args == ["u@acct/db"]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_database_path_with_schema():
|
|
60
|
+
# SQLAlchemy treats everything after the host as `database`; we must
|
|
61
|
+
# NOT over-escape ``/`` between db and schema.
|
|
62
|
+
args, _ = _build("adbc+snowflake://u:p@acct/my_db/public")
|
|
63
|
+
assert args == ["u:p@acct/my_db/public"]
|
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
"""ADBC PostgreSQL dialect."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
from typing import Any
|
|
6
|
-
|
|
7
|
-
from sqlalchemy.engine.url import URL
|
|
8
|
-
|
|
9
|
-
from sqlalchemy_adbc.base import ADBCDialect
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
class ADBCPostgreSQLDialect(ADBCDialect):
|
|
13
|
-
name = "adbc"
|
|
14
|
-
driver = "postgresql"
|
|
15
|
-
driver_module = "adbc_driver_postgresql.dbapi"
|
|
16
|
-
supports_statement_cache = True
|
|
17
|
-
|
|
18
|
-
def build_connect_args(self, url: URL) -> tuple[list[Any], dict[str, Any]]:
|
|
19
|
-
# adbc_driver_postgresql expects a standard libpq URI.
|
|
20
|
-
user = url.username or ""
|
|
21
|
-
password = f":{url.password}" if url.password else ""
|
|
22
|
-
auth = f"{user}{password}@" if user else ""
|
|
23
|
-
host = url.host or "localhost"
|
|
24
|
-
port = f":{url.port}" if url.port else ""
|
|
25
|
-
database = f"/{url.database}" if url.database else ""
|
|
26
|
-
uri = f"postgresql://{auth}{host}{port}{database}"
|
|
27
|
-
|
|
28
|
-
kwargs: dict[str, Any] = {}
|
|
29
|
-
if url.query:
|
|
30
|
-
kwargs["db_kwargs"] = dict(url.query)
|
|
31
|
-
return [uri], kwargs
|
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
"""ADBC Snowflake dialect.
|
|
2
|
-
|
|
3
|
-
URL form::
|
|
4
|
-
|
|
5
|
-
adbc+snowflake://user:password@account/database/schema?warehouse=WH&role=R
|
|
6
|
-
|
|
7
|
-
adbc_driver_snowflake expects a DSN of the form
|
|
8
|
-
``user:password@account/database/schema?warehouse=...``.
|
|
9
|
-
"""
|
|
10
|
-
|
|
11
|
-
from __future__ import annotations
|
|
12
|
-
|
|
13
|
-
from typing import Any
|
|
14
|
-
|
|
15
|
-
from sqlalchemy.engine.url import URL
|
|
16
|
-
|
|
17
|
-
from sqlalchemy_adbc.base import ADBCDialect
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
class ADBCSnowflakeDialect(ADBCDialect):
|
|
21
|
-
name = "adbc"
|
|
22
|
-
driver = "snowflake"
|
|
23
|
-
driver_module = "adbc_driver_snowflake.dbapi"
|
|
24
|
-
supports_statement_cache = True
|
|
25
|
-
|
|
26
|
-
def build_connect_args(self, url: URL) -> tuple[list[Any], dict[str, Any]]:
|
|
27
|
-
user = url.username or ""
|
|
28
|
-
password = f":{url.password}" if url.password else ""
|
|
29
|
-
auth = f"{user}{password}@" if user else ""
|
|
30
|
-
account = url.host or ""
|
|
31
|
-
path = ""
|
|
32
|
-
if url.database:
|
|
33
|
-
path = f"/{url.database}"
|
|
34
|
-
query_str = ""
|
|
35
|
-
if url.query:
|
|
36
|
-
query_str = "?" + "&".join(f"{k}={v}" for k, v in url.query.items())
|
|
37
|
-
dsn = f"{auth}{account}{path}{query_str}"
|
|
38
|
-
return [dsn], {}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|