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.
Files changed (43) hide show
  1. {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.4.0}/.github/workflows/release.yml +9 -2
  2. {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.4.0}/.gitignore +3 -0
  3. {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.4.0}/CHANGELOG.md +40 -0
  4. {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.4.0}/PKG-INFO +75 -2
  5. {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.4.0}/README.md +69 -0
  6. {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.4.0}/examples/workers/src/entry.py +170 -0
  7. {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.4.0}/examples/workers/uv.lock +5 -6
  8. sqlalchemy_cloudflare_d1-0.4.0/examples/workers-hyperdrive/README.md +90 -0
  9. sqlalchemy_cloudflare_d1-0.4.0/examples/workers-hyperdrive/db_init.sql +31 -0
  10. sqlalchemy_cloudflare_d1-0.4.0/examples/workers-hyperdrive/pyproject.toml +31 -0
  11. sqlalchemy_cloudflare_d1-0.4.0/examples/workers-hyperdrive/src/entry.py +410 -0
  12. sqlalchemy_cloudflare_d1-0.4.0/examples/workers-hyperdrive/uv.lock +722 -0
  13. sqlalchemy_cloudflare_d1-0.4.0/examples/workers-hyperdrive/wrangler.jsonc +20 -0
  14. {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.4.0}/pyproject.toml +14 -1
  15. {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.4.0}/src/sqlalchemy_cloudflare_d1/__init__.py +8 -1
  16. {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.4.0}/src/sqlalchemy_cloudflare_d1/compiler.py +10 -4
  17. {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.4.0}/src/sqlalchemy_cloudflare_d1/dialect.py +101 -10
  18. sqlalchemy_cloudflare_d1-0.4.0/src/sqlalchemy_cloudflare_d1/hyperdrive.py +238 -0
  19. {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.4.0}/tests/conftest.py +70 -1
  20. {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.4.0}/tests/integration/test_restapi_integration.py +97 -0
  21. {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.4.0}/tests/integration/test_worker_integration.py +127 -0
  22. {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.4.0}/tests/unit/test_dialect.py +140 -0
  23. sqlalchemy_cloudflare_d1-0.4.0/tests/unit/test_hyperdrive.py +304 -0
  24. {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.4.0}/uv.lock +80 -5
  25. {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.4.0}/.github/DEVELOPMENT.md +0 -0
  26. {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.4.0}/.github/workflows/ci.yml +0 -0
  27. {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.4.0}/.pre-commit-config.yaml +0 -0
  28. {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.4.0}/.python-version +0 -0
  29. {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.4.0}/LICENSE +0 -0
  30. {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.4.0}/Makefile +0 -0
  31. {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.4.0}/assets/d1-logo.png +0 -0
  32. {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.4.0}/assets/sqlalchemy-logo.png +0 -0
  33. {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.4.0}/examples/restapi/example.py +0 -0
  34. {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.4.0}/examples/workers/README.md +0 -0
  35. {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.4.0}/examples/workers/db_init.sql +0 -0
  36. {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.4.0}/examples/workers/pyproject.toml +0 -0
  37. {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.4.0}/examples/workers/wrangler.jsonc +0 -0
  38. {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.4.0}/src/sqlalchemy_cloudflare_d1/connection.py +0 -0
  39. {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.4.0}/src/sqlalchemy_cloudflare_d1/dialect_async.py +0 -0
  40. {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.4.0}/src/sqlalchemy_cloudflare_d1/py.typed +0 -0
  41. {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.4.0}/tests/__init__.py +0 -0
  42. {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.4.0}/tests/integration/test_base_url_integration.py +0 -0
  43. {sqlalchemy_cloudflare_d1-0.3.10 → sqlalchemy_cloudflare_d1-0.4.0}/tests/test_utils.py +0 -0
@@ -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
- version=$(grep '^version = ' pyproject.toml | sed 's/version = "\(.*\)"/\1/')
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
- uses: pypa/gh-action-pypi-publish@76f52bc884231f62b9a034ebfe128415bbaabdfc
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
@@ -214,3 +214,6 @@ testing/
214
214
  node_modules/
215
215
  python_modules/
216
216
  .DS_Store
217
+
218
+ # pywrangler generated lock for Worker examples
219
+ pylock.toml
@@ -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.4
1
+ Metadata-Version: 2.5
2
2
  Name: sqlalchemy-cloudflare-d1
3
- Version: 0.3.10
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:
@@ -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
+ );