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.
Files changed (23) hide show
  1. {sqlalchemy_adbc-0.1.0 → sqlalchemy_adbc-0.2.0}/.github/workflows/ci.yml +2 -0
  2. {sqlalchemy_adbc-0.1.0 → sqlalchemy_adbc-0.2.0}/PKG-INFO +24 -6
  3. {sqlalchemy_adbc-0.1.0 → sqlalchemy_adbc-0.2.0}/README.md +23 -5
  4. {sqlalchemy_adbc-0.1.0 → sqlalchemy_adbc-0.2.0}/pyproject.toml +25 -1
  5. {sqlalchemy_adbc-0.1.0 → sqlalchemy_adbc-0.2.0}/src/sqlalchemy_adbc/flightsql.py +30 -10
  6. sqlalchemy_adbc-0.2.0/src/sqlalchemy_adbc/postgresql.py +46 -0
  7. sqlalchemy_adbc-0.2.0/src/sqlalchemy_adbc/snowflake.py +48 -0
  8. sqlalchemy_adbc-0.2.0/tests/test_base.py +81 -0
  9. sqlalchemy_adbc-0.2.0/tests/test_bigquery_url.py +45 -0
  10. {sqlalchemy_adbc-0.1.0 → sqlalchemy_adbc-0.2.0}/tests/test_flightsql_url.py +21 -3
  11. sqlalchemy_adbc-0.2.0/tests/test_postgresql_url.py +83 -0
  12. sqlalchemy_adbc-0.2.0/tests/test_snowflake_url.py +63 -0
  13. sqlalchemy_adbc-0.1.0/src/sqlalchemy_adbc/postgresql.py +0 -31
  14. sqlalchemy_adbc-0.1.0/src/sqlalchemy_adbc/snowflake.py +0 -38
  15. {sqlalchemy_adbc-0.1.0 → sqlalchemy_adbc-0.2.0}/.github/workflows/publish.yml +0 -0
  16. {sqlalchemy_adbc-0.1.0 → sqlalchemy_adbc-0.2.0}/.gitignore +0 -0
  17. {sqlalchemy_adbc-0.1.0 → sqlalchemy_adbc-0.2.0}/LICENSE +0 -0
  18. {sqlalchemy_adbc-0.1.0 → sqlalchemy_adbc-0.2.0}/src/sqlalchemy_adbc/__init__.py +0 -0
  19. {sqlalchemy_adbc-0.1.0 → sqlalchemy_adbc-0.2.0}/src/sqlalchemy_adbc/base.py +0 -0
  20. {sqlalchemy_adbc-0.1.0 → sqlalchemy_adbc-0.2.0}/src/sqlalchemy_adbc/bigquery.py +0 -0
  21. {sqlalchemy_adbc-0.1.0 → sqlalchemy_adbc-0.2.0}/src/sqlalchemy_adbc/sqlite.py +0 -0
  22. {sqlalchemy_adbc-0.1.0 → sqlalchemy_adbc-0.2.0}/tests/__init__.py +0 -0
  23. {sqlalchemy_adbc-0.1.0 → sqlalchemy_adbc-0.2.0}/tests/test_sqlite.py +0 -0
@@ -24,6 +24,8 @@ jobs:
24
24
  run: ruff check src/ tests/
25
25
  - name: Ruff format
26
26
  run: ruff format --check src/ tests/
27
+ - name: Mypy
28
+ run: mypy src tests
27
29
 
28
30
  test:
29
31
  runs-on: ubuntu-latest
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sqlalchemy-adbc
3
- Version: 0.1.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 (table reflection,
122
- dialect-specific types, DDL compilers per backend) are still to come. PRs
123
- welcome.
124
-
125
- Ship tests: 11 passing against ADBC SQLite on Python 3.9–3.13.
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 (table reflection,
83
- dialect-specific types, DDL compilers per backend) are still to come. PRs
84
- welcome.
85
-
86
- Ship tests: 11 passing against ADBC SQLite on Python 3.9–3.13.
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.1.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
- query = dict(url.query)
33
-
34
- scheme = "grpc+tls" if _pop_bool(query, "tls", default=False) else "grpc"
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
- port = url.port or 443
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 # type: ignore[import-not-found]
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 _pop_bool(d: dict[str, Any], key: str, *, default: bool) -> bool:
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
- v = d.pop(key)
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 test_plain_grpc():
20
- args, kwargs = _build("adbc+flightsql://localhost:50051")
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 test_tls_scheme():
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