sqlalchemy-cloudflare-d1 0.3.10__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.
- {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.4.0}/.github/workflows/release.yml +9 -2
- {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.4.0}/.gitignore +3 -0
- {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.4.0}/CHANGELOG.md +40 -0
- {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.4.0}/PKG-INFO +75 -2
- {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.4.0}/README.md +69 -0
- {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.4.0}/examples/workers/src/entry.py +170 -0
- {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.4.0}/examples/workers/uv.lock +5 -6
- sqlalchemy_cloudflare_d1-0.4.0/examples/workers-hyperdrive/README.md +90 -0
- sqlalchemy_cloudflare_d1-0.4.0/examples/workers-hyperdrive/db_init.sql +31 -0
- sqlalchemy_cloudflare_d1-0.4.0/examples/workers-hyperdrive/pyproject.toml +31 -0
- sqlalchemy_cloudflare_d1-0.4.0/examples/workers-hyperdrive/src/entry.py +410 -0
- sqlalchemy_cloudflare_d1-0.4.0/examples/workers-hyperdrive/uv.lock +722 -0
- sqlalchemy_cloudflare_d1-0.4.0/examples/workers-hyperdrive/wrangler.jsonc +20 -0
- {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.4.0}/pyproject.toml +14 -1
- {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.4.0}/src/sqlalchemy_cloudflare_d1/__init__.py +8 -1
- {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.4.0}/src/sqlalchemy_cloudflare_d1/compiler.py +10 -4
- {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.4.0}/src/sqlalchemy_cloudflare_d1/dialect.py +101 -10
- sqlalchemy_cloudflare_d1-0.4.0/src/sqlalchemy_cloudflare_d1/hyperdrive.py +238 -0
- {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.4.0}/tests/conftest.py +70 -1
- {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.4.0}/tests/integration/test_restapi_integration.py +97 -0
- {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.4.0}/tests/integration/test_worker_integration.py +127 -0
- {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.4.0}/tests/unit/test_dialect.py +140 -0
- sqlalchemy_cloudflare_d1-0.4.0/tests/unit/test_hyperdrive.py +304 -0
- {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.4.0}/uv.lock +80 -5
- {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.4.0}/.github/DEVELOPMENT.md +0 -0
- {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.4.0}/.github/workflows/ci.yml +0 -0
- {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.4.0}/.pre-commit-config.yaml +0 -0
- {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.4.0}/.python-version +0 -0
- {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.4.0}/LICENSE +0 -0
- {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.4.0}/Makefile +0 -0
- {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.4.0}/assets/d1-logo.png +0 -0
- {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.4.0}/assets/sqlalchemy-logo.png +0 -0
- {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.4.0}/examples/restapi/example.py +0 -0
- {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.4.0}/examples/workers/README.md +0 -0
- {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.4.0}/examples/workers/db_init.sql +0 -0
- {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.4.0}/examples/workers/pyproject.toml +0 -0
- {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.4.0}/examples/workers/wrangler.jsonc +0 -0
- {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.4.0}/src/sqlalchemy_cloudflare_d1/connection.py +0 -0
- {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.4.0}/src/sqlalchemy_cloudflare_d1/dialect_async.py +0 -0
- {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.4.0}/src/sqlalchemy_cloudflare_d1/py.typed +0 -0
- {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.4.0}/tests/__init__.py +0 -0
- {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.4.0}/tests/integration/test_base_url_integration.py +0 -0
- {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.4.0}/tests/test_utils.py +0 -0
{sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.4.0}/.github/workflows/release.yml
RENAMED
|
@@ -62,7 +62,13 @@ jobs:
|
|
|
62
62
|
run: |
|
|
63
63
|
set -eu
|
|
64
64
|
pkg_name=$(grep '^name = ' pyproject.toml | sed 's/name = "\(.*\)"/\1/')
|
|
65
|
-
|
|
65
|
+
# The version is dynamic: hatchling reads __version__ from the
|
|
66
|
+
# package, so there is no `version = ` line in pyproject to grep.
|
|
67
|
+
version=$(uv run python -c "import sqlalchemy_cloudflare_d1; print(sqlalchemy_cloudflare_d1.__version__)")
|
|
68
|
+
if [ -z "$version" ]; then
|
|
69
|
+
echo "::error::could not determine package version" >&2
|
|
70
|
+
exit 1
|
|
71
|
+
fi
|
|
66
72
|
echo "pkg-name=$pkg_name" >> $GITHUB_OUTPUT
|
|
67
73
|
echo "version=$version" >> $GITHUB_OUTPUT
|
|
68
74
|
|
|
@@ -89,7 +95,8 @@ jobs:
|
|
|
89
95
|
path: dist/
|
|
90
96
|
|
|
91
97
|
- name: Publish package distributions to PyPI
|
|
92
|
-
|
|
98
|
+
# v1.14.2 - bundles twine 7.0.0, which understands Metadata-Version 2.5
|
|
99
|
+
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33
|
|
93
100
|
with:
|
|
94
101
|
packages-dir: dist/
|
|
95
102
|
verbose: true
|
|
@@ -13,6 +13,46 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
13
13
|
### Fixed
|
|
14
14
|
|
|
15
15
|
|
|
16
|
+
## [0.4.0]
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
- Cloudflare Hyperdrive support for Python Workers
|
|
21
|
+
- `create_engine_from_hyperdrive()` builds a SQLAlchemy engine from a Hyperdrive binding, deriving the URL from the binding's connection fields
|
|
22
|
+
- `hyperdrive_connection()` yields a connection while holding an isolate-wide `asyncio.Lock`, since concurrent synchronous driver I/O is unsupported in Workers
|
|
23
|
+
- Driver is auto-detected from the binding's `scheme`, falling back to `connectionString`, and can be overridden with `driver=`
|
|
24
|
+
- Engines use `NullPool`: a Worker cannot reuse sockets across requests, and Hyperdrive pools server-side
|
|
25
|
+
- New `[hyperdrive]` extra installing `pg8000`, and `[hyperdrive-mysql]` installing `pymysql`, kept separate so a PostgreSQL deployment does not carry a MySQL driver
|
|
26
|
+
- A missing driver raises an error naming the extra that provides it, rather than a bare import error from inside SQLAlchemy
|
|
27
|
+
- `examples/workers-hyperdrive/` example Worker, verified both locally and deployed against a live Hyperdrive config
|
|
28
|
+
- 6 Worker integration tests and 22 unit tests covering the Hyperdrive path
|
|
29
|
+
|
|
30
|
+
### Notes
|
|
31
|
+
|
|
32
|
+
- No custom dialect is involved. Hyperdrive speaks the PostgreSQL and MySQL wire protocols, so SQLAlchemy's own `postgresql+pg8000` and `mysql+pymysql` do the work; this package supplies only the binding-to-engine glue.
|
|
33
|
+
- PostgreSQL via `pg8000` is the supported path. MySQL via `pymysql` is best effort — verified manually against MySQL 8, but with no automated coverage.
|
|
34
|
+
- `psycopg` is not supported: psycopg3 requires libpq, which the Workers Pyodide build does not provide.
|
|
35
|
+
- `asyncpg` and `aiomysql` cannot be used despite being Cloudflare's recommended drivers. They are async-only, and driving them from SQLAlchemy requires greenlet, which is unavailable in Python Workers. Only synchronous SQLAlchemy works.
|
|
36
|
+
|
|
37
|
+
### Fixed
|
|
38
|
+
|
|
39
|
+
- `__version__` in `sqlalchemy_cloudflare_d1` was stale at `0.3.1` while the package version had moved on; it now tracks the released version
|
|
40
|
+
- The Hyperdrive I/O lock is created per event loop rather than at import. On Python 3.9 `asyncio.Lock()` binds to whichever loop is current when constructed, so a module-level lock raised "attached to a different loop" under any other loop
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
## [0.3.11]
|
|
44
|
+
|
|
45
|
+
### Added
|
|
46
|
+
|
|
47
|
+
- Added REST API and Worker integration coverage for composite primary key DDL and constraint reflection
|
|
48
|
+
|
|
49
|
+
### Fixed
|
|
50
|
+
|
|
51
|
+
- Fixed composite primary key DDL emitting duplicate `PRIMARY KEY` clauses, which D1 rejects with `SQLITE_ERROR`
|
|
52
|
+
- Fixed foreign key reflection to include SQLAlchemy's expected `referred_schema` key
|
|
53
|
+
- Implemented unique constraint reflection for D1 tables, including inline and named table-level unique constraints
|
|
54
|
+
|
|
55
|
+
|
|
16
56
|
## [0.3.10]
|
|
17
57
|
|
|
18
58
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: sqlalchemy-cloudflare-d1
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: A SQLAlchemy dialect for Cloudflare's D1 Serverless SQLite Database
|
|
5
5
|
Project-URL: Homepage, https://github.com/collierking/sqlalchemy-cloudflare-d1
|
|
6
6
|
Project-URL: Repository, https://github.com/collierking/sqlalchemy-cloudflare-d1
|
|
@@ -53,6 +53,10 @@ Requires-Dist: mypy>=1.0.0; extra == 'dev'
|
|
|
53
53
|
Requires-Dist: pytest-asyncio>=0.21.0; extra == 'dev'
|
|
54
54
|
Requires-Dist: pytest>=7.0.0; extra == 'dev'
|
|
55
55
|
Requires-Dist: ruff==0.12.4; extra == 'dev'
|
|
56
|
+
Provides-Extra: hyperdrive
|
|
57
|
+
Requires-Dist: pg8000>=1.30.0; extra == 'hyperdrive'
|
|
58
|
+
Provides-Extra: hyperdrive-mysql
|
|
59
|
+
Requires-Dist: pymysql>=1.1.0; extra == 'hyperdrive-mysql'
|
|
56
60
|
Description-Content-Type: text/markdown
|
|
57
61
|
|
|
58
62
|
<p align="center">
|
|
@@ -70,6 +74,7 @@ A SQLAlchemy dialect for [Cloudflare's D1 Serverless SQLite Database](https://de
|
|
|
70
74
|
- Full SQLAlchemy ORM and Core support
|
|
71
75
|
- **Sync and async engines** via D1 REST API (`create_engine` and `create_async_engine`)
|
|
72
76
|
- **Python Workers support** with direct D1 binding (`create_engine_from_binding`)
|
|
77
|
+
- **Cloudflare Hyperdrive support** for PostgreSQL/MySQL in Workers (`create_engine_from_hyperdrive`)
|
|
73
78
|
- SQLite/D1 compatible SQL compilation
|
|
74
79
|
- Prepared statement support with parameter binding
|
|
75
80
|
- pandas `DataFrame.to_sql()` with upsert support
|
|
@@ -408,6 +413,74 @@ engine = create_engine(
|
|
|
408
413
|
)
|
|
409
414
|
```
|
|
410
415
|
|
|
416
|
+
## Cloudflare Hyperdrive
|
|
417
|
+
|
|
418
|
+
Hyperdrive fronts an existing PostgreSQL or MySQL database. It needs no custom
|
|
419
|
+
dialect — SQLAlchemy's own `postgresql+pg8000` speaks the wire protocol — so
|
|
420
|
+
this package supplies only the binding-to-engine glue for Python Workers.
|
|
421
|
+
|
|
422
|
+
```bash
|
|
423
|
+
pip install sqlalchemy-cloudflare-d1[hyperdrive] # PostgreSQL (pg8000)
|
|
424
|
+
pip install sqlalchemy-cloudflare-d1[hyperdrive-mysql] # MySQL (pymysql)
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
The extras are separate so a PostgreSQL deployment does not carry a MySQL
|
|
428
|
+
driver. Install the one matching your Hyperdrive origin — the driver must also
|
|
429
|
+
be declared in your Worker's `pyproject.toml` dependencies.
|
|
430
|
+
|
|
431
|
+
```python
|
|
432
|
+
from sqlalchemy import MetaData, Table, select
|
|
433
|
+
from sqlalchemy_cloudflare_d1.hyperdrive import (
|
|
434
|
+
create_engine_from_hyperdrive,
|
|
435
|
+
hyperdrive_connection,
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
class Default(WorkerEntrypoint):
|
|
439
|
+
async def fetch(self, request):
|
|
440
|
+
engine = create_engine_from_hyperdrive(self.env.HYPERDRIVE)
|
|
441
|
+
|
|
442
|
+
metadata = MetaData()
|
|
443
|
+
users = Table("users", metadata, autoload_with=engine)
|
|
444
|
+
|
|
445
|
+
async with hyperdrive_connection(engine) as conn:
|
|
446
|
+
rows = conn.execute(select(users).limit(10)).fetchall()
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
The driver is chosen from the binding's scheme and can be overridden with
|
|
450
|
+
`driver=`. The engine uses `NullPool`, since a Worker cannot reuse sockets
|
|
451
|
+
across requests and Hyperdrive pools server-side. `hyperdrive_connection()`
|
|
452
|
+
holds an isolate-wide `asyncio.Lock`, because concurrent synchronous driver
|
|
453
|
+
I/O is not supported.
|
|
454
|
+
|
|
455
|
+
### Driver support
|
|
456
|
+
|
|
457
|
+
Cloudflare documents five drivers as working in Python Workers. Only two can
|
|
458
|
+
back a SQLAlchemy engine — verified from inside a Worker:
|
|
459
|
+
|
|
460
|
+
| Driver | Status | Notes |
|
|
461
|
+
|---|---|---|
|
|
462
|
+
| `pg8000` | ✅ Supported | PostgreSQL. Covered by integration tests running in a Worker |
|
|
463
|
+
| `pymysql` | ⚠️ Best effort | MySQL. Confirmed working in a Worker against MySQL 8, but that check was manual — there is **no automated test coverage**, so nothing guards against a regression |
|
|
464
|
+
| `psycopg` | ❌ | Needs libpq, and the Workers Pyodide build has none (`no pq wrapper available`) |
|
|
465
|
+
| `asyncpg` | ❌ | Async-only — see below |
|
|
466
|
+
| `aiomysql` | ❌ | Async-only — see below |
|
|
467
|
+
|
|
468
|
+
PostgreSQL via `pg8000` is the supported path. MySQL works but is not exercised
|
|
469
|
+
by CI, so treat it as best effort.
|
|
470
|
+
|
|
471
|
+
**Only synchronous SQLAlchemy works.** `asyncpg` and `aiomysql` run fine in
|
|
472
|
+
Python Workers on their own — that is why Cloudflare recommends them — but
|
|
473
|
+
driving them from SQLAlchemy means `create_async_engine()`, which requires
|
|
474
|
+
greenlet, and greenlet is unavailable in Workers. So the two recommended
|
|
475
|
+
drivers are precisely the two that cannot back a SQLAlchemy engine.
|
|
476
|
+
|
|
477
|
+
Both supported drivers need TLS explicitly switched off, which
|
|
478
|
+
`create_engine_from_hyperdrive()` handles: pg8000 gets `ssl_context=False` and
|
|
479
|
+
pymysql gets `ssl_disabled=True`. Hyperdrive terminates TLS itself, and
|
|
480
|
+
attempting it from inside a Worker corrupts the connection.
|
|
481
|
+
|
|
482
|
+
See `examples/workers-hyperdrive/` for a complete Worker.
|
|
483
|
+
|
|
411
484
|
## Limitations
|
|
412
485
|
|
|
413
486
|
This dialect has some limitations due to D1's REST API nature:
|
|
@@ -13,6 +13,7 @@ A SQLAlchemy dialect for [Cloudflare's D1 Serverless SQLite Database](https://de
|
|
|
13
13
|
- Full SQLAlchemy ORM and Core support
|
|
14
14
|
- **Sync and async engines** via D1 REST API (`create_engine` and `create_async_engine`)
|
|
15
15
|
- **Python Workers support** with direct D1 binding (`create_engine_from_binding`)
|
|
16
|
+
- **Cloudflare Hyperdrive support** for PostgreSQL/MySQL in Workers (`create_engine_from_hyperdrive`)
|
|
16
17
|
- SQLite/D1 compatible SQL compilation
|
|
17
18
|
- Prepared statement support with parameter binding
|
|
18
19
|
- pandas `DataFrame.to_sql()` with upsert support
|
|
@@ -351,6 +352,74 @@ engine = create_engine(
|
|
|
351
352
|
)
|
|
352
353
|
```
|
|
353
354
|
|
|
355
|
+
## Cloudflare Hyperdrive
|
|
356
|
+
|
|
357
|
+
Hyperdrive fronts an existing PostgreSQL or MySQL database. It needs no custom
|
|
358
|
+
dialect — SQLAlchemy's own `postgresql+pg8000` speaks the wire protocol — so
|
|
359
|
+
this package supplies only the binding-to-engine glue for Python Workers.
|
|
360
|
+
|
|
361
|
+
```bash
|
|
362
|
+
pip install sqlalchemy-cloudflare-d1[hyperdrive] # PostgreSQL (pg8000)
|
|
363
|
+
pip install sqlalchemy-cloudflare-d1[hyperdrive-mysql] # MySQL (pymysql)
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
The extras are separate so a PostgreSQL deployment does not carry a MySQL
|
|
367
|
+
driver. Install the one matching your Hyperdrive origin — the driver must also
|
|
368
|
+
be declared in your Worker's `pyproject.toml` dependencies.
|
|
369
|
+
|
|
370
|
+
```python
|
|
371
|
+
from sqlalchemy import MetaData, Table, select
|
|
372
|
+
from sqlalchemy_cloudflare_d1.hyperdrive import (
|
|
373
|
+
create_engine_from_hyperdrive,
|
|
374
|
+
hyperdrive_connection,
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
class Default(WorkerEntrypoint):
|
|
378
|
+
async def fetch(self, request):
|
|
379
|
+
engine = create_engine_from_hyperdrive(self.env.HYPERDRIVE)
|
|
380
|
+
|
|
381
|
+
metadata = MetaData()
|
|
382
|
+
users = Table("users", metadata, autoload_with=engine)
|
|
383
|
+
|
|
384
|
+
async with hyperdrive_connection(engine) as conn:
|
|
385
|
+
rows = conn.execute(select(users).limit(10)).fetchall()
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
The driver is chosen from the binding's scheme and can be overridden with
|
|
389
|
+
`driver=`. The engine uses `NullPool`, since a Worker cannot reuse sockets
|
|
390
|
+
across requests and Hyperdrive pools server-side. `hyperdrive_connection()`
|
|
391
|
+
holds an isolate-wide `asyncio.Lock`, because concurrent synchronous driver
|
|
392
|
+
I/O is not supported.
|
|
393
|
+
|
|
394
|
+
### Driver support
|
|
395
|
+
|
|
396
|
+
Cloudflare documents five drivers as working in Python Workers. Only two can
|
|
397
|
+
back a SQLAlchemy engine — verified from inside a Worker:
|
|
398
|
+
|
|
399
|
+
| Driver | Status | Notes |
|
|
400
|
+
|---|---|---|
|
|
401
|
+
| `pg8000` | ✅ Supported | PostgreSQL. Covered by integration tests running in a Worker |
|
|
402
|
+
| `pymysql` | ⚠️ Best effort | MySQL. Confirmed working in a Worker against MySQL 8, but that check was manual — there is **no automated test coverage**, so nothing guards against a regression |
|
|
403
|
+
| `psycopg` | ❌ | Needs libpq, and the Workers Pyodide build has none (`no pq wrapper available`) |
|
|
404
|
+
| `asyncpg` | ❌ | Async-only — see below |
|
|
405
|
+
| `aiomysql` | ❌ | Async-only — see below |
|
|
406
|
+
|
|
407
|
+
PostgreSQL via `pg8000` is the supported path. MySQL works but is not exercised
|
|
408
|
+
by CI, so treat it as best effort.
|
|
409
|
+
|
|
410
|
+
**Only synchronous SQLAlchemy works.** `asyncpg` and `aiomysql` run fine in
|
|
411
|
+
Python Workers on their own — that is why Cloudflare recommends them — but
|
|
412
|
+
driving them from SQLAlchemy means `create_async_engine()`, which requires
|
|
413
|
+
greenlet, and greenlet is unavailable in Workers. So the two recommended
|
|
414
|
+
drivers are precisely the two that cannot back a SQLAlchemy engine.
|
|
415
|
+
|
|
416
|
+
Both supported drivers need TLS explicitly switched off, which
|
|
417
|
+
`create_engine_from_hyperdrive()` handles: pg8000 gets `ssl_context=False` and
|
|
418
|
+
pymysql gets `ssl_disabled=True`. Hyperdrive terminates TLS itself, and
|
|
419
|
+
attempting it from inside a Worker corrupts the connection.
|
|
420
|
+
|
|
421
|
+
See `examples/workers-hyperdrive/` for a complete Worker.
|
|
422
|
+
|
|
354
423
|
## Limitations
|
|
355
424
|
|
|
356
425
|
This dialect has some limitations due to D1's REST API nature:
|
{sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.4.0}/examples/workers/src/entry.py
RENAMED
|
@@ -41,8 +41,12 @@ class Default(WorkerEntrypoint):
|
|
|
41
41
|
return await self.test_sqlalchemy_select()
|
|
42
42
|
elif path == "sqlalchemy-crud":
|
|
43
43
|
return await self.test_sqlalchemy_crud()
|
|
44
|
+
elif path == "sqlalchemy-composite-pk":
|
|
45
|
+
return await self.test_sqlalchemy_composite_pk()
|
|
44
46
|
elif path == "sqlalchemy-reflect":
|
|
45
47
|
return await self.test_sqlalchemy_reflect()
|
|
48
|
+
elif path == "sqlalchemy-reflect-constraints":
|
|
49
|
+
return await self.test_sqlalchemy_reflect_constraints()
|
|
46
50
|
# Empty result set tests (GitHub issue #4)
|
|
47
51
|
elif path == "empty-result":
|
|
48
52
|
return await self.test_empty_result()
|
|
@@ -188,7 +192,9 @@ class Default(WorkerEntrypoint):
|
|
|
188
192
|
"/parameterized": "Test parameterized queries",
|
|
189
193
|
"/sqlalchemy-select": "Test SQLAlchemy Core SELECT (no raw SQL)",
|
|
190
194
|
"/sqlalchemy-crud": "Test SQLAlchemy Core CRUD (no raw SQL)",
|
|
195
|
+
"/sqlalchemy-composite-pk": "Test SQLAlchemy composite primary key DDL",
|
|
191
196
|
"/sqlalchemy-reflect": "Test SQLAlchemy table reflection",
|
|
197
|
+
"/sqlalchemy-reflect-constraints": "Test SQLAlchemy constraint reflection",
|
|
192
198
|
"/empty-result": "Test empty result set description (issue #4)",
|
|
193
199
|
"/empty-result-sqlalchemy": "Test SQLAlchemy empty result (issue #4)",
|
|
194
200
|
"/json-filter": "Test filtering on JSON array columns",
|
|
@@ -548,6 +554,76 @@ class Default(WorkerEntrypoint):
|
|
|
548
554
|
status=500,
|
|
549
555
|
)
|
|
550
556
|
|
|
557
|
+
async def test_sqlalchemy_composite_pk(self):
|
|
558
|
+
"""Test SQLAlchemy composite primary key DDL against D1 binding."""
|
|
559
|
+
from sqlalchemy import Column, MetaData, String, Table, select
|
|
560
|
+
|
|
561
|
+
table_name = f"test_composite_pk_{uuid.uuid4().hex[:8]}"
|
|
562
|
+
|
|
563
|
+
try:
|
|
564
|
+
engine = self.get_engine()
|
|
565
|
+
metadata = MetaData()
|
|
566
|
+
|
|
567
|
+
test_table = Table(
|
|
568
|
+
table_name,
|
|
569
|
+
metadata,
|
|
570
|
+
Column("tenant_id", String, primary_key=True),
|
|
571
|
+
Column("record_key", String, primary_key=True),
|
|
572
|
+
Column("value", String),
|
|
573
|
+
)
|
|
574
|
+
|
|
575
|
+
metadata.create_all(engine)
|
|
576
|
+
|
|
577
|
+
with engine.connect() as conn:
|
|
578
|
+
conn.execute(
|
|
579
|
+
test_table.insert().values(
|
|
580
|
+
tenant_id="tenant_a",
|
|
581
|
+
record_key="label_a",
|
|
582
|
+
value="value_a",
|
|
583
|
+
)
|
|
584
|
+
)
|
|
585
|
+
conn.commit()
|
|
586
|
+
|
|
587
|
+
result = conn.execute(
|
|
588
|
+
select(
|
|
589
|
+
test_table.c.tenant_id,
|
|
590
|
+
test_table.c.record_key,
|
|
591
|
+
test_table.c.value,
|
|
592
|
+
)
|
|
593
|
+
)
|
|
594
|
+
row = result.fetchone()
|
|
595
|
+
columns = list(result.keys())
|
|
596
|
+
|
|
597
|
+
metadata.drop_all(engine)
|
|
598
|
+
|
|
599
|
+
success = row is not None and tuple(row) == (
|
|
600
|
+
"tenant_a",
|
|
601
|
+
"label_a",
|
|
602
|
+
"value_a",
|
|
603
|
+
)
|
|
604
|
+
|
|
605
|
+
return Response.json(
|
|
606
|
+
{
|
|
607
|
+
"test": "sqlalchemy_composite_pk",
|
|
608
|
+
"success": success,
|
|
609
|
+
"table_name": table_name,
|
|
610
|
+
"columns": columns,
|
|
611
|
+
"row": list(row) if row is not None else None,
|
|
612
|
+
}
|
|
613
|
+
)
|
|
614
|
+
except Exception as e:
|
|
615
|
+
try:
|
|
616
|
+
engine = self.get_engine()
|
|
617
|
+
metadata = MetaData()
|
|
618
|
+
test_table = Table(table_name, metadata)
|
|
619
|
+
metadata.drop_all(engine)
|
|
620
|
+
except Exception:
|
|
621
|
+
pass
|
|
622
|
+
return Response.json(
|
|
623
|
+
{"test": "sqlalchemy_composite_pk", "success": False, "error": str(e)},
|
|
624
|
+
status=500,
|
|
625
|
+
)
|
|
626
|
+
|
|
551
627
|
async def test_sqlalchemy_reflect(self):
|
|
552
628
|
"""Test SQLAlchemy table reflection.
|
|
553
629
|
|
|
@@ -628,6 +704,100 @@ class Default(WorkerEntrypoint):
|
|
|
628
704
|
status=500,
|
|
629
705
|
)
|
|
630
706
|
|
|
707
|
+
async def test_sqlalchemy_reflect_constraints(self):
|
|
708
|
+
"""Test SQLAlchemy foreign key and unique constraint reflection."""
|
|
709
|
+
parent_table_name = f"test_reflect_parent_{uuid.uuid4().hex[:8]}"
|
|
710
|
+
child_table_name = f"test_reflect_child_{uuid.uuid4().hex[:8]}"
|
|
711
|
+
unique_constraint_name = f"uq_{uuid.uuid4().hex[:12]}"
|
|
712
|
+
|
|
713
|
+
try:
|
|
714
|
+
from sqlalchemy import (
|
|
715
|
+
Column,
|
|
716
|
+
ForeignKey,
|
|
717
|
+
Integer,
|
|
718
|
+
MetaData,
|
|
719
|
+
String,
|
|
720
|
+
Table,
|
|
721
|
+
UniqueConstraint,
|
|
722
|
+
inspect,
|
|
723
|
+
)
|
|
724
|
+
|
|
725
|
+
engine = self.get_engine()
|
|
726
|
+
metadata = MetaData()
|
|
727
|
+
|
|
728
|
+
Table(
|
|
729
|
+
parent_table_name,
|
|
730
|
+
metadata,
|
|
731
|
+
Column("id", Integer, primary_key=True),
|
|
732
|
+
Column("slug", String, unique=True),
|
|
733
|
+
)
|
|
734
|
+
Table(
|
|
735
|
+
child_table_name,
|
|
736
|
+
metadata,
|
|
737
|
+
Column("id", Integer, primary_key=True),
|
|
738
|
+
Column("parent_id", Integer, ForeignKey(f"{parent_table_name}.id")),
|
|
739
|
+
Column("tenant_id", String),
|
|
740
|
+
Column("record_key", String),
|
|
741
|
+
UniqueConstraint(
|
|
742
|
+
"tenant_id",
|
|
743
|
+
"record_key",
|
|
744
|
+
name=unique_constraint_name,
|
|
745
|
+
),
|
|
746
|
+
)
|
|
747
|
+
|
|
748
|
+
metadata.create_all(engine)
|
|
749
|
+
|
|
750
|
+
inspector = inspect(engine)
|
|
751
|
+
foreign_keys = inspector.get_foreign_keys(child_table_name)
|
|
752
|
+
unique_constraints = inspector.get_unique_constraints(child_table_name)
|
|
753
|
+
parent_unique_constraints = inspector.get_unique_constraints(
|
|
754
|
+
parent_table_name
|
|
755
|
+
)
|
|
756
|
+
|
|
757
|
+
metadata.drop_all(engine)
|
|
758
|
+
|
|
759
|
+
expected_child_unique = {
|
|
760
|
+
"name": unique_constraint_name,
|
|
761
|
+
"column_names": ["tenant_id", "record_key"],
|
|
762
|
+
}
|
|
763
|
+
expected_parent_unique = {"name": None, "column_names": ["slug"]}
|
|
764
|
+
foreign_key = foreign_keys[0] if foreign_keys else {}
|
|
765
|
+
success = (
|
|
766
|
+
foreign_key.get("constrained_columns") == ["parent_id"]
|
|
767
|
+
and foreign_key.get("referred_schema") is None
|
|
768
|
+
and foreign_key.get("referred_table") == parent_table_name
|
|
769
|
+
and foreign_key.get("referred_columns") == ["id"]
|
|
770
|
+
and expected_child_unique in unique_constraints
|
|
771
|
+
and expected_parent_unique in parent_unique_constraints
|
|
772
|
+
)
|
|
773
|
+
|
|
774
|
+
return Response.json(
|
|
775
|
+
{
|
|
776
|
+
"test": "sqlalchemy_reflect_constraints",
|
|
777
|
+
"success": success,
|
|
778
|
+
"foreign_keys": foreign_keys,
|
|
779
|
+
"unique_constraints": unique_constraints,
|
|
780
|
+
"parent_unique_constraints": parent_unique_constraints,
|
|
781
|
+
}
|
|
782
|
+
)
|
|
783
|
+
except Exception as e:
|
|
784
|
+
try:
|
|
785
|
+
conn = self.get_connection()
|
|
786
|
+
cursor = conn.cursor()
|
|
787
|
+
await cursor.execute_async(f"DROP TABLE IF EXISTS {child_table_name}")
|
|
788
|
+
await cursor.execute_async(f"DROP TABLE IF EXISTS {parent_table_name}")
|
|
789
|
+
conn.close()
|
|
790
|
+
except Exception:
|
|
791
|
+
pass
|
|
792
|
+
return Response.json(
|
|
793
|
+
{
|
|
794
|
+
"test": "sqlalchemy_reflect_constraints",
|
|
795
|
+
"success": False,
|
|
796
|
+
"error": str(e),
|
|
797
|
+
},
|
|
798
|
+
status=500,
|
|
799
|
+
)
|
|
800
|
+
|
|
631
801
|
# MARK: - Empty Result Set Tests (GitHub issue #4)
|
|
632
802
|
|
|
633
803
|
async def test_empty_result(self):
|
|
@@ -115,7 +115,6 @@ wheels = [
|
|
|
115
115
|
{ url = "https://files.pythonhosted.org/packages/f8/0a/a3871375c7b9727edaeeea994bfff7c63ff7804c9829c19309ba2e058807/greenlet-3.3.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:b01548f6e0b9e9784a2c99c5651e5dc89ffcbe870bc5fb2e5ef864e9cc6b5dcb", size = 276379, upload-time = "2025-12-04T14:23:30.498Z" },
|
|
116
116
|
{ url = "https://files.pythonhosted.org/packages/43/ab/7ebfe34dce8b87be0d11dae91acbf76f7b8246bf9d6b319c741f99fa59c6/greenlet-3.3.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:349345b770dc88f81506c6861d22a6ccd422207829d2c854ae2af8025af303e3", size = 597294, upload-time = "2025-12-04T14:50:06.847Z" },
|
|
117
117
|
{ url = "https://files.pythonhosted.org/packages/a4/39/f1c8da50024feecd0793dbd5e08f526809b8ab5609224a2da40aad3a7641/greenlet-3.3.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e8e18ed6995e9e2c0b4ed264d2cf89260ab3ac7e13555b8032b25a74c6d18655", size = 607742, upload-time = "2025-12-04T14:57:42.349Z" },
|
|
118
|
-
{ url = "https://files.pythonhosted.org/packages/77/cb/43692bcd5f7a0da6ec0ec6d58ee7cddb606d055ce94a62ac9b1aa481e969/greenlet-3.3.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c024b1e5696626890038e34f76140ed1daf858e37496d33f2af57f06189e70d7", size = 622297, upload-time = "2025-12-04T15:07:13.552Z" },
|
|
119
118
|
{ url = "https://files.pythonhosted.org/packages/75/b0/6bde0b1011a60782108c01de5913c588cf51a839174538d266de15e4bf4d/greenlet-3.3.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:047ab3df20ede6a57c35c14bf5200fcf04039d50f908270d3f9a7a82064f543b", size = 609885, upload-time = "2025-12-04T14:26:02.368Z" },
|
|
120
119
|
{ url = "https://files.pythonhosted.org/packages/49/0e/49b46ac39f931f59f987b7cd9f34bfec8ef81d2a1e6e00682f55be5de9f4/greenlet-3.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2d9ad37fc657b1102ec880e637cccf20191581f75c64087a549e66c57e1ceb53", size = 1567424, upload-time = "2025-12-04T15:04:23.757Z" },
|
|
121
120
|
{ url = "https://files.pythonhosted.org/packages/05/f5/49a9ac2dff7f10091935def9165c90236d8f175afb27cbed38fb1d61ab6b/greenlet-3.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:83cd0e36932e0e7f36a64b732a6f60c2fc2df28c351bae79fbaf4f8092fe7614", size = 1636017, upload-time = "2025-12-04T14:27:29.688Z" },
|
|
@@ -123,7 +122,6 @@ wheels = [
|
|
|
123
122
|
{ url = "https://files.pythonhosted.org/packages/02/2f/28592176381b9ab2cafa12829ba7b472d177f3acc35d8fbcf3673d966fff/greenlet-3.3.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:a1e41a81c7e2825822f4e068c48cb2196002362619e2d70b148f20a831c00739", size = 275140, upload-time = "2025-12-04T14:23:01.282Z" },
|
|
124
123
|
{ url = "https://files.pythonhosted.org/packages/2c/80/fbe937bf81e9fca98c981fe499e59a3f45df2a04da0baa5c2be0dca0d329/greenlet-3.3.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9f515a47d02da4d30caaa85b69474cec77b7929b2e936ff7fb853d42f4bf8808", size = 599219, upload-time = "2025-12-04T14:50:08.309Z" },
|
|
125
124
|
{ url = "https://files.pythonhosted.org/packages/c2/ff/7c985128f0514271b8268476af89aee6866df5eec04ac17dcfbc676213df/greenlet-3.3.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d2d9fd66bfadf230b385fdc90426fcd6eb64db54b40c495b72ac0feb5766c54", size = 610211, upload-time = "2025-12-04T14:57:43.968Z" },
|
|
126
|
-
{ url = "https://files.pythonhosted.org/packages/79/07/c47a82d881319ec18a4510bb30463ed6891f2ad2c1901ed5ec23d3de351f/greenlet-3.3.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30a6e28487a790417d036088b3bcb3f3ac7d8babaa7d0139edbaddebf3af9492", size = 624311, upload-time = "2025-12-04T15:07:14.697Z" },
|
|
127
125
|
{ url = "https://files.pythonhosted.org/packages/fd/8e/424b8c6e78bd9837d14ff7df01a9829fc883ba2ab4ea787d4f848435f23f/greenlet-3.3.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:087ea5e004437321508a8d6f20efc4cfec5e3c30118e1417ea96ed1d93950527", size = 612833, upload-time = "2025-12-04T14:26:03.669Z" },
|
|
128
126
|
{ url = "https://files.pythonhosted.org/packages/b5/ba/56699ff9b7c76ca12f1cdc27a886d0f81f2189c3455ff9f65246780f713d/greenlet-3.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ab97cf74045343f6c60a39913fa59710e4bd26a536ce7ab2397adf8b27e67c39", size = 1567256, upload-time = "2025-12-04T15:04:25.276Z" },
|
|
129
127
|
{ url = "https://files.pythonhosted.org/packages/1e/37/f31136132967982d698c71a281a8901daf1a8fbab935dce7c0cf15f942cc/greenlet-3.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5375d2e23184629112ca1ea89a53389dddbffcf417dad40125713d88eb5f96e8", size = 1636483, upload-time = "2025-12-04T14:27:30.804Z" },
|
|
@@ -131,7 +129,6 @@ wheels = [
|
|
|
131
129
|
{ url = "https://files.pythonhosted.org/packages/d7/7c/f0a6d0ede2c7bf092d00bc83ad5bafb7e6ec9b4aab2fbdfa6f134dc73327/greenlet-3.3.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:60c2ef0f578afb3c8d92ea07ad327f9a062547137afe91f38408f08aacab667f", size = 275671, upload-time = "2025-12-04T14:23:05.267Z" },
|
|
132
130
|
{ url = "https://files.pythonhosted.org/packages/44/06/dac639ae1a50f5969d82d2e3dd9767d30d6dbdbab0e1a54010c8fe90263c/greenlet-3.3.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a5d554d0712ba1de0a6c94c640f7aeba3f85b3a6e1f2899c11c2c0428da9365", size = 646360, upload-time = "2025-12-04T14:50:10.026Z" },
|
|
133
131
|
{ url = "https://files.pythonhosted.org/packages/e0/94/0fb76fe6c5369fba9bf98529ada6f4c3a1adf19e406a47332245ef0eb357/greenlet-3.3.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3a898b1e9c5f7307ebbde4102908e6cbfcb9ea16284a3abe15cab996bee8b9b3", size = 658160, upload-time = "2025-12-04T14:57:45.41Z" },
|
|
134
|
-
{ url = "https://files.pythonhosted.org/packages/93/79/d2c70cae6e823fac36c3bbc9077962105052b7ef81db2f01ec3b9bf17e2b/greenlet-3.3.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dcd2bdbd444ff340e8d6bdf54d2f206ccddbb3ccfdcd3c25bf4afaa7b8f0cf45", size = 671388, upload-time = "2025-12-04T15:07:15.789Z" },
|
|
135
132
|
{ url = "https://files.pythonhosted.org/packages/b8/14/bab308fc2c1b5228c3224ec2bf928ce2e4d21d8046c161e44a2012b5203e/greenlet-3.3.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5773edda4dc00e173820722711d043799d3adb4f01731f40619e07ea2750b955", size = 660166, upload-time = "2025-12-04T14:26:05.099Z" },
|
|
136
133
|
{ url = "https://files.pythonhosted.org/packages/4b/d2/91465d39164eaa0085177f61983d80ffe746c5a1860f009811d498e7259c/greenlet-3.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ac0549373982b36d5fd5d30beb8a7a33ee541ff98d2b502714a09f1169f31b55", size = 1615193, upload-time = "2025-12-04T15:04:27.041Z" },
|
|
137
134
|
{ url = "https://files.pythonhosted.org/packages/42/1b/83d110a37044b92423084d52d5d5a3b3a73cafb51b547e6d7366ff62eff1/greenlet-3.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d198d2d977460358c3b3a4dc844f875d1adb33817f0613f663a656f463764ccc", size = 1683653, upload-time = "2025-12-04T14:27:32.366Z" },
|
|
@@ -139,7 +136,6 @@ wheels = [
|
|
|
139
136
|
{ url = "https://files.pythonhosted.org/packages/a0/66/bd6317bc5932accf351fc19f177ffba53712a202f9df10587da8df257c7e/greenlet-3.3.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:d6ed6f85fae6cdfdb9ce04c9bf7a08d666cfcfb914e7d006f44f840b46741931", size = 282638, upload-time = "2025-12-04T14:25:20.941Z" },
|
|
140
137
|
{ url = "https://files.pythonhosted.org/packages/30/cf/cc81cb030b40e738d6e69502ccbd0dd1bced0588e958f9e757945de24404/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9125050fcf24554e69c4cacb086b87b3b55dc395a8b3ebe6487b045b2614388", size = 651145, upload-time = "2025-12-04T14:50:11.039Z" },
|
|
141
138
|
{ url = "https://files.pythonhosted.org/packages/9c/ea/1020037b5ecfe95ca7df8d8549959baceb8186031da83d5ecceff8b08cd2/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:87e63ccfa13c0a0f6234ed0add552af24cc67dd886731f2261e46e241608bee3", size = 654236, upload-time = "2025-12-04T14:57:47.007Z" },
|
|
142
|
-
{ url = "https://files.pythonhosted.org/packages/69/cc/1e4bae2e45ca2fa55299f4e85854606a78ecc37fead20d69322f96000504/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2662433acbca297c9153a4023fe2161c8dcfdcc91f10433171cf7e7d94ba2221", size = 662506, upload-time = "2025-12-04T15:07:16.906Z" },
|
|
143
139
|
{ url = "https://files.pythonhosted.org/packages/57/b9/f8025d71a6085c441a7eaff0fd928bbb275a6633773667023d19179fe815/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3c6e9b9c1527a78520357de498b0e709fb9e2f49c3a513afd5a249007261911b", size = 653783, upload-time = "2025-12-04T14:26:06.225Z" },
|
|
144
140
|
{ url = "https://files.pythonhosted.org/packages/f6/c7/876a8c7a7485d5d6b5c6821201d542ef28be645aa024cfe1145b35c120c1/greenlet-3.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:286d093f95ec98fdd92fcb955003b8a3d054b4e2cab3e2707a5039e7b50520fd", size = 1614857, upload-time = "2025-12-04T15:04:28.484Z" },
|
|
145
141
|
{ url = "https://files.pythonhosted.org/packages/4f/dc/041be1dff9f23dac5f48a43323cd0789cb798342011c19a248d9c9335536/greenlet-3.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c10513330af5b8ae16f023e8ddbfb486ab355d04467c4679c5cfe4659975dd9", size = 1676034, upload-time = "2025-12-04T14:27:33.531Z" },
|
|
@@ -614,7 +610,6 @@ wheels = [
|
|
|
614
610
|
|
|
615
611
|
[[package]]
|
|
616
612
|
name = "sqlalchemy-cloudflare-d1"
|
|
617
|
-
version = "0.3.10"
|
|
618
613
|
source = { editable = "../../" }
|
|
619
614
|
dependencies = [
|
|
620
615
|
{ name = "httpx" },
|
|
@@ -629,13 +624,15 @@ requires-dist = [
|
|
|
629
624
|
{ name = "httpx", specifier = ">=0.24.0" },
|
|
630
625
|
{ name = "isort", marker = "extra == 'dev'", specifier = ">=5.12.0" },
|
|
631
626
|
{ name = "mypy", marker = "extra == 'dev'", specifier = ">=1.0.0" },
|
|
627
|
+
{ name = "pg8000", marker = "extra == 'hyperdrive'", specifier = ">=1.30.0" },
|
|
628
|
+
{ name = "pymysql", marker = "extra == 'hyperdrive-mysql'", specifier = ">=1.1.0" },
|
|
632
629
|
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0.0" },
|
|
633
630
|
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.21.0" },
|
|
634
631
|
{ name = "ruff", marker = "extra == 'dev'", specifier = "==0.12.4" },
|
|
635
632
|
{ name = "sqlalchemy", specifier = ">=2.0.0" },
|
|
636
633
|
{ name = "typing-extensions", specifier = ">=4.0.0" },
|
|
637
634
|
]
|
|
638
|
-
provides-extras = ["async", "dev"]
|
|
635
|
+
provides-extras = ["async", "dev", "hyperdrive", "hyperdrive-mysql"]
|
|
639
636
|
|
|
640
637
|
[package.metadata.requires-dev]
|
|
641
638
|
dev = [
|
|
@@ -643,6 +640,8 @@ dev = [
|
|
|
643
640
|
{ name = "greenlet", specifier = ">=3.2.3" },
|
|
644
641
|
{ name = "mypy", specifier = ">=1.17.0" },
|
|
645
642
|
{ name = "pandas", specifier = ">=2.0.0" },
|
|
643
|
+
{ name = "pg8000", specifier = ">=1.31.5" },
|
|
644
|
+
{ name = "pymysql", specifier = ">=1.2.3" },
|
|
646
645
|
{ name = "pytest", specifier = ">=8.4.1" },
|
|
647
646
|
{ name = "pytest-asyncio", specifier = ">=0.21.0" },
|
|
648
647
|
{ name = "pytest-socket", specifier = ">=0.7.0" },
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# SQLAlchemy over Hyperdrive — Python Worker example
|
|
2
|
+
|
|
3
|
+
Uses SQLAlchemy Core against a PostgreSQL database through a
|
|
4
|
+
[Hyperdrive](https://developers.cloudflare.com/hyperdrive/examples/python-workers/)
|
|
5
|
+
binding, inside a Python Worker.
|
|
6
|
+
|
|
7
|
+
Unlike the D1 example in `../workers`, no custom dialect is involved —
|
|
8
|
+
Hyperdrive speaks the PostgreSQL wire protocol, so SQLAlchemy's own
|
|
9
|
+
`postgresql+pg8000` dialect does the work. This package only supplies the
|
|
10
|
+
binding-to-engine glue.
|
|
11
|
+
|
|
12
|
+
## Requirements
|
|
13
|
+
|
|
14
|
+
- `compatibility_date` of `2026-09-08` or later, plus the `python_workers` flag
|
|
15
|
+
- A Hyperdrive config pointing at a reachable PostgreSQL database
|
|
16
|
+
- A **synchronous** driver: `pg8000` for PostgreSQL (the supported path), or
|
|
17
|
+
`pymysql` for MySQL (best effort, no automated coverage). Of
|
|
18
|
+
the five drivers Cloudflare documents, these are the only two that can back a
|
|
19
|
+
SQLAlchemy engine — `psycopg` needs libpq (absent from the Workers Pyodide
|
|
20
|
+
build) and `asyncpg`/`aiomysql` are async-only, which SQLAlchemy cannot drive
|
|
21
|
+
without greenlet. `/drivers` re-checks this matrix against your own binding.
|
|
22
|
+
|
|
23
|
+
## Setup
|
|
24
|
+
|
|
25
|
+
1. Create a Hyperdrive config and put its ID in `wrangler.jsonc`:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
npx wrangler hyperdrive create sqlalchemy-example \
|
|
29
|
+
--connection-string="$POSTGRES_URL"
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
2. Sync dependencies, then copy the local package into `python_modules`:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
uv lock && uv sync
|
|
36
|
+
uv run pywrangler sync --force
|
|
37
|
+
mkdir -p python_modules/sqlalchemy_cloudflare_d1
|
|
38
|
+
cp ../../src/sqlalchemy_cloudflare_d1/*.py python_modules/sqlalchemy_cloudflare_d1/
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
`sqlalchemy-cloudflare-d1` is deliberately absent from `pyproject.toml`.
|
|
42
|
+
pywrangler installs Worker dependencies with `--no-build`, so an editable
|
|
43
|
+
local path has no wheel to install and the sync fails outright.
|
|
44
|
+
|
|
45
|
+
3. Create the table, either by running `db_init.sql` against the origin
|
|
46
|
+
database or by hitting `/setup` once the Worker is up.
|
|
47
|
+
|
|
48
|
+
## Run
|
|
49
|
+
|
|
50
|
+
Hyperdrive has **no local emulation**. `wrangler dev` always connects to the
|
|
51
|
+
Postgres named by an environment variable, never to the real Hyperdrive config,
|
|
52
|
+
and `"remote": true` is not valid on a hyperdrive binding. So local dev needs:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
export CLOUDFLARE_HYPERDRIVE_LOCAL_CONNECTION_STRING_HYPERDRIVE="postgresql://user:pass@host:5432/db?sslmode=require"
|
|
56
|
+
uv run pywrangler dev --port 8788
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
The Worker still sees a normal Hyperdrive binding — wrangler proxies it through
|
|
60
|
+
a local `*.hyperdrive.local` endpoint. Only a deployed Worker exercises the real
|
|
61
|
+
config, including its pooling and caching.
|
|
62
|
+
|
|
63
|
+
## Endpoints
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
curl http://localhost:8788/setup # create table + seed rows (idempotent)
|
|
67
|
+
curl http://localhost:8788/health # open a connection, SELECT 1
|
|
68
|
+
curl http://localhost:8788/driver # report resolved dialect and pool
|
|
69
|
+
curl http://localhost:8788/select # SELECT via Table + select()
|
|
70
|
+
curl http://localhost:8788/crud # insert / update / delete round-trip
|
|
71
|
+
curl http://localhost:8788/reflect # reflect columns and primary key
|
|
72
|
+
curl http://localhost:8788/concurrent # overlapping queries, serialized
|
|
73
|
+
curl http://localhost:8788/drivers # which drivers connect from a Worker
|
|
74
|
+
curl http://localhost:8788/teardown # drop the table again
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Notes
|
|
78
|
+
|
|
79
|
+
- **TLS must be explicitly disabled, and each driver spells it differently.**
|
|
80
|
+
pg8000 treats `ssl_context=None` as "attempt SSL" (only `False` disables it),
|
|
81
|
+
and pymysql needs `ssl_disabled=True`. Otherwise the handshake over the
|
|
82
|
+
Workers socket shim corrupts the connection — `KeyError: b'\x00'` for pg8000,
|
|
83
|
+
a `secureTransport` error for pymysql. `create_engine_from_hyperdrive()`
|
|
84
|
+
handles both. Hyperdrive terminates TLS itself, so the Worker hop needs no
|
|
85
|
+
encryption.
|
|
86
|
+
- `create_engine_from_hyperdrive()` uses `NullPool`: a Worker cannot reuse
|
|
87
|
+
sockets across requests, and Hyperdrive maintains the real pool server-side.
|
|
88
|
+
- Wrap database work in `hyperdrive_connection()` rather than calling
|
|
89
|
+
`engine.connect()` directly. It holds an isolate-wide `asyncio.Lock` so
|
|
90
|
+
synchronous driver I/O from overlapping requests is serialized.
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
-- Schema for the Hyperdrive example Worker.
|
|
2
|
+
--
|
|
3
|
+
-- The table is namespaced because a Hyperdrive config often points at a shared
|
|
4
|
+
-- database. Nothing here drops or alters pre-existing objects.
|
|
5
|
+
--
|
|
6
|
+
-- Run against the PostgreSQL database that the Hyperdrive config points at:
|
|
7
|
+
-- psql "$POSTGRES_URL" -f db_init.sql
|
|
8
|
+
|
|
9
|
+
CREATE TABLE IF NOT EXISTS sqlalchemy_hyperdrive_example (
|
|
10
|
+
id SERIAL PRIMARY KEY,
|
|
11
|
+
name TEXT NOT NULL,
|
|
12
|
+
quantity INTEGER NOT NULL DEFAULT 0
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
INSERT INTO sqlalchemy_hyperdrive_example (name, quantity)
|
|
16
|
+
SELECT 'widget', 10
|
|
17
|
+
WHERE NOT EXISTS (
|
|
18
|
+
SELECT 1 FROM sqlalchemy_hyperdrive_example WHERE name = 'widget'
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
INSERT INTO sqlalchemy_hyperdrive_example (name, quantity)
|
|
22
|
+
SELECT 'gadget', 5
|
|
23
|
+
WHERE NOT EXISTS (
|
|
24
|
+
SELECT 1 FROM sqlalchemy_hyperdrive_example WHERE name = 'gadget'
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
INSERT INTO sqlalchemy_hyperdrive_example (name, quantity)
|
|
28
|
+
SELECT 'sprocket', 0
|
|
29
|
+
WHERE NOT EXISTS (
|
|
30
|
+
SELECT 1 FROM sqlalchemy_hyperdrive_example WHERE name = 'sprocket'
|
|
31
|
+
);
|