sqlalchemy-cloudflare-d1 0.3.8__tar.gz → 0.3.9__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.8 → sqlalchemy_cloudflare_d1-0.3.9}/CHANGELOG.md +11 -0
- {sqlalchemy_cloudflare_d1-0.3.8 → sqlalchemy_cloudflare_d1-0.3.9}/PKG-INFO +1 -1
- {sqlalchemy_cloudflare_d1-0.3.8 → sqlalchemy_cloudflare_d1-0.3.9}/examples/workers/src/entry.py +130 -0
- {sqlalchemy_cloudflare_d1-0.3.8 → sqlalchemy_cloudflare_d1-0.3.9}/pyproject.toml +1 -1
- {sqlalchemy_cloudflare_d1-0.3.8 → sqlalchemy_cloudflare_d1-0.3.9}/src/sqlalchemy_cloudflare_d1/connection.py +14 -4
- sqlalchemy_cloudflare_d1-0.3.9/tests/integration/test_base_url_integration.py +175 -0
- {sqlalchemy_cloudflare_d1-0.3.8 → sqlalchemy_cloudflare_d1-0.3.9}/tests/integration/test_worker_integration.py +61 -1
- {sqlalchemy_cloudflare_d1-0.3.8 → sqlalchemy_cloudflare_d1-0.3.9}/tests/unit/test_dialect.py +60 -0
- {sqlalchemy_cloudflare_d1-0.3.8 → sqlalchemy_cloudflare_d1-0.3.9}/uv.lock +1 -1
- {sqlalchemy_cloudflare_d1-0.3.8 → sqlalchemy_cloudflare_d1-0.3.9}/.github/DEVELOPMENT.md +0 -0
- {sqlalchemy_cloudflare_d1-0.3.8 → sqlalchemy_cloudflare_d1-0.3.9}/.github/workflows/ci.yml +0 -0
- {sqlalchemy_cloudflare_d1-0.3.8 → sqlalchemy_cloudflare_d1-0.3.9}/.github/workflows/release.yml +0 -0
- {sqlalchemy_cloudflare_d1-0.3.8 → sqlalchemy_cloudflare_d1-0.3.9}/.gitignore +0 -0
- {sqlalchemy_cloudflare_d1-0.3.8 → sqlalchemy_cloudflare_d1-0.3.9}/.pre-commit-config.yaml +0 -0
- {sqlalchemy_cloudflare_d1-0.3.8 → sqlalchemy_cloudflare_d1-0.3.9}/.python-version +0 -0
- {sqlalchemy_cloudflare_d1-0.3.8 → sqlalchemy_cloudflare_d1-0.3.9}/LICENSE +0 -0
- {sqlalchemy_cloudflare_d1-0.3.8 → sqlalchemy_cloudflare_d1-0.3.9}/Makefile +0 -0
- {sqlalchemy_cloudflare_d1-0.3.8 → sqlalchemy_cloudflare_d1-0.3.9}/README.md +0 -0
- {sqlalchemy_cloudflare_d1-0.3.8 → sqlalchemy_cloudflare_d1-0.3.9}/assets/d1-logo.png +0 -0
- {sqlalchemy_cloudflare_d1-0.3.8 → sqlalchemy_cloudflare_d1-0.3.9}/assets/sqlalchemy-logo.png +0 -0
- {sqlalchemy_cloudflare_d1-0.3.8 → sqlalchemy_cloudflare_d1-0.3.9}/examples/restapi/example.py +0 -0
- {sqlalchemy_cloudflare_d1-0.3.8 → sqlalchemy_cloudflare_d1-0.3.9}/examples/workers/README.md +0 -0
- {sqlalchemy_cloudflare_d1-0.3.8 → sqlalchemy_cloudflare_d1-0.3.9}/examples/workers/db_init.sql +0 -0
- {sqlalchemy_cloudflare_d1-0.3.8 → sqlalchemy_cloudflare_d1-0.3.9}/examples/workers/pyproject.toml +0 -0
- {sqlalchemy_cloudflare_d1-0.3.8 → sqlalchemy_cloudflare_d1-0.3.9}/examples/workers/uv.lock +0 -0
- {sqlalchemy_cloudflare_d1-0.3.8 → sqlalchemy_cloudflare_d1-0.3.9}/examples/workers/wrangler.jsonc +0 -0
- {sqlalchemy_cloudflare_d1-0.3.8 → sqlalchemy_cloudflare_d1-0.3.9}/src/sqlalchemy_cloudflare_d1/__init__.py +0 -0
- {sqlalchemy_cloudflare_d1-0.3.8 → sqlalchemy_cloudflare_d1-0.3.9}/src/sqlalchemy_cloudflare_d1/compiler.py +0 -0
- {sqlalchemy_cloudflare_d1-0.3.8 → sqlalchemy_cloudflare_d1-0.3.9}/src/sqlalchemy_cloudflare_d1/dialect.py +0 -0
- {sqlalchemy_cloudflare_d1-0.3.8 → sqlalchemy_cloudflare_d1-0.3.9}/src/sqlalchemy_cloudflare_d1/dialect_async.py +0 -0
- {sqlalchemy_cloudflare_d1-0.3.8 → sqlalchemy_cloudflare_d1-0.3.9}/src/sqlalchemy_cloudflare_d1/py.typed +0 -0
- {sqlalchemy_cloudflare_d1-0.3.8 → sqlalchemy_cloudflare_d1-0.3.9}/tests/__init__.py +0 -0
- {sqlalchemy_cloudflare_d1-0.3.8 → sqlalchemy_cloudflare_d1-0.3.9}/tests/conftest.py +0 -0
- {sqlalchemy_cloudflare_d1-0.3.8 → sqlalchemy_cloudflare_d1-0.3.9}/tests/integration/test_restapi_integration.py +0 -0
- {sqlalchemy_cloudflare_d1-0.3.8 → sqlalchemy_cloudflare_d1-0.3.9}/tests/test_utils.py +0 -0
|
@@ -13,6 +13,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
13
13
|
### Fixed
|
|
14
14
|
|
|
15
15
|
|
|
16
|
+
## [0.3.9]
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
- Configurable base API URL for local D1 proxy support ([#22](https://github.com/CollierKing/sqlalchemy-cloudflare-d1/issues/22))
|
|
21
|
+
- `Connection` and `AsyncConnection` now accept a `base_url` kwarg to override the Cloudflare endpoint
|
|
22
|
+
- Falls back to the `CF_D1_BASE_URL` environment variable, then the default Cloudflare URL
|
|
23
|
+
- Works with `create_engine(..., connect_args={"base_url": "http://localhost:8787"})` for the cleanest integration
|
|
24
|
+
- Enables local development against a `wrangler dev` D1 proxy without modifying source code
|
|
25
|
+
|
|
26
|
+
|
|
16
27
|
## [0.3.8]
|
|
17
28
|
|
|
18
29
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sqlalchemy-cloudflare-d1
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.9
|
|
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
|
{sqlalchemy_cloudflare_d1-0.3.8 → sqlalchemy_cloudflare_d1-0.3.9}/examples/workers/src/entry.py
RENAMED
|
@@ -145,6 +145,11 @@ class Default(WorkerEntrypoint):
|
|
|
145
145
|
return await self.test_time_nullable()
|
|
146
146
|
elif path == "time-orm":
|
|
147
147
|
return await self.test_time_orm()
|
|
148
|
+
# Parallel query tests (GitHub issue #20)
|
|
149
|
+
elif path == "parallel-queries-engine":
|
|
150
|
+
return await self.test_parallel_queries_engine()
|
|
151
|
+
elif path == "parallel-queries-async":
|
|
152
|
+
return await self.test_parallel_queries_async()
|
|
148
153
|
else:
|
|
149
154
|
return await self.index()
|
|
150
155
|
|
|
@@ -206,6 +211,8 @@ class Default(WorkerEntrypoint):
|
|
|
206
211
|
"/time-basic": "Test Time column insert/retrieve",
|
|
207
212
|
"/time-nullable": "Test nullable Time columns",
|
|
208
213
|
"/time-orm": "Test Time via ORM session",
|
|
214
|
+
"/parallel-queries-engine": "Timing: create_engine_from_binding() blocks per query (sequential)",
|
|
215
|
+
"/parallel-queries-async": "Timing: WorkerConnection async is truly concurrent",
|
|
209
216
|
},
|
|
210
217
|
"package": "sqlalchemy-cloudflare-d1",
|
|
211
218
|
"connection_type": "WorkerConnection (D1 binding)",
|
|
@@ -3634,3 +3641,126 @@ class Default(WorkerEntrypoint):
|
|
|
3634
3641
|
},
|
|
3635
3642
|
status=500,
|
|
3636
3643
|
)
|
|
3644
|
+
|
|
3645
|
+
# MARK: - Parallel Query Tests (GitHub issue #20)
|
|
3646
|
+
|
|
3647
|
+
async def test_parallel_queries_engine(self):
|
|
3648
|
+
"""Timing test showing create_engine_from_binding() queries run concurrently.
|
|
3649
|
+
|
|
3650
|
+
Although SyncWorkerConnection uses pyodide.ffi.run_sync() internally,
|
|
3651
|
+
run_sync() drives the JS event loop rather than truly blocking it.
|
|
3652
|
+
asyncio.gather() can therefore interleave multiple D1 round-trips.
|
|
3653
|
+
|
|
3654
|
+
Uses N unique parameterized queries (SELECT :n) so D1 cannot cache
|
|
3655
|
+
results across runs. Sequential time = N * one_query_latency.
|
|
3656
|
+
If concurrent, parallel time ≈ one_query_latency.
|
|
3657
|
+
"""
|
|
3658
|
+
import asyncio
|
|
3659
|
+
import time
|
|
3660
|
+
from sqlalchemy import text
|
|
3661
|
+
|
|
3662
|
+
N_QUERIES = 10
|
|
3663
|
+
|
|
3664
|
+
try:
|
|
3665
|
+
engine = self.get_engine()
|
|
3666
|
+
|
|
3667
|
+
async def single_query(i):
|
|
3668
|
+
with engine.connect() as conn:
|
|
3669
|
+
result = conn.execute(text("SELECT :n as n"), {"n": i})
|
|
3670
|
+
return result.fetchone()[0]
|
|
3671
|
+
|
|
3672
|
+
seq_start = time.time()
|
|
3673
|
+
for i in range(N_QUERIES):
|
|
3674
|
+
await single_query(i)
|
|
3675
|
+
sequential_time = time.time() - seq_start
|
|
3676
|
+
|
|
3677
|
+
par_start = time.time()
|
|
3678
|
+
results = await asyncio.gather(*[single_query(i) for i in range(N_QUERIES)])
|
|
3679
|
+
parallel_time = time.time() - par_start
|
|
3680
|
+
|
|
3681
|
+
results_correct = results == list(range(N_QUERIES))
|
|
3682
|
+
# Expect genuine speedup — parallel < 80% of sequential
|
|
3683
|
+
is_concurrent = parallel_time < sequential_time * 0.8
|
|
3684
|
+
|
|
3685
|
+
return Response.json(
|
|
3686
|
+
{
|
|
3687
|
+
"test": "parallel_queries_engine",
|
|
3688
|
+
"success": results_correct and is_concurrent,
|
|
3689
|
+
"results_correct": results_correct,
|
|
3690
|
+
"is_concurrent": is_concurrent,
|
|
3691
|
+
"sequential_time_s": round(sequential_time, 3),
|
|
3692
|
+
"parallel_time_s": round(parallel_time, 3),
|
|
3693
|
+
"n_queries": N_QUERIES,
|
|
3694
|
+
}
|
|
3695
|
+
)
|
|
3696
|
+
except Exception as e:
|
|
3697
|
+
return Response.json(
|
|
3698
|
+
{
|
|
3699
|
+
"test": "parallel_queries_engine",
|
|
3700
|
+
"success": False,
|
|
3701
|
+
"error": str(e),
|
|
3702
|
+
"error_type": type(e).__name__,
|
|
3703
|
+
},
|
|
3704
|
+
status=500,
|
|
3705
|
+
)
|
|
3706
|
+
|
|
3707
|
+
async def test_parallel_queries_async(self):
|
|
3708
|
+
"""Timing test showing WorkerConnection async queries are truly concurrent.
|
|
3709
|
+
|
|
3710
|
+
Instead of a CPU-bound CTE (which D1 serializes server-side regardless),
|
|
3711
|
+
we run many lightweight round-trips where network latency is the bottleneck.
|
|
3712
|
+
N_QUERIES simple SELECTs are fired sequentially then concurrently via
|
|
3713
|
+
asyncio.gather(). If WorkerConnection truly yields to the event loop,
|
|
3714
|
+
the parallel run should be faster because multiple D1 round-trips overlap.
|
|
3715
|
+
"""
|
|
3716
|
+
import asyncio
|
|
3717
|
+
import time
|
|
3718
|
+
|
|
3719
|
+
N_QUERIES = 10
|
|
3720
|
+
|
|
3721
|
+
try:
|
|
3722
|
+
|
|
3723
|
+
async def single_query(i):
|
|
3724
|
+
conn = self.get_connection()
|
|
3725
|
+
cursor = conn.cursor()
|
|
3726
|
+
await cursor.execute_async("SELECT ? as n", (i,))
|
|
3727
|
+
row = cursor.fetchone()
|
|
3728
|
+
conn.close()
|
|
3729
|
+
return row[0]
|
|
3730
|
+
|
|
3731
|
+
# Sequential: N_QUERIES round-trips one after another
|
|
3732
|
+
seq_start = time.time()
|
|
3733
|
+
for i in range(N_QUERIES):
|
|
3734
|
+
await single_query(i)
|
|
3735
|
+
sequential_time = time.time() - seq_start
|
|
3736
|
+
|
|
3737
|
+
# Parallel: all N_QUERIES in flight at once
|
|
3738
|
+
par_start = time.time()
|
|
3739
|
+
results = await asyncio.gather(*[single_query(i) for i in range(N_QUERIES)])
|
|
3740
|
+
parallel_time = time.time() - par_start
|
|
3741
|
+
|
|
3742
|
+
results_correct = results == list(range(N_QUERIES))
|
|
3743
|
+
# Expect genuine speedup — parallel < 80% of sequential
|
|
3744
|
+
is_concurrent = parallel_time < sequential_time * 0.8
|
|
3745
|
+
|
|
3746
|
+
return Response.json(
|
|
3747
|
+
{
|
|
3748
|
+
"test": "parallel_queries_async",
|
|
3749
|
+
"success": results_correct and is_concurrent,
|
|
3750
|
+
"results_correct": results_correct,
|
|
3751
|
+
"is_concurrent": is_concurrent,
|
|
3752
|
+
"sequential_time_s": round(sequential_time, 3),
|
|
3753
|
+
"parallel_time_s": round(parallel_time, 3),
|
|
3754
|
+
"n_queries": N_QUERIES,
|
|
3755
|
+
}
|
|
3756
|
+
)
|
|
3757
|
+
except Exception as e:
|
|
3758
|
+
return Response.json(
|
|
3759
|
+
{
|
|
3760
|
+
"test": "parallel_queries_async",
|
|
3761
|
+
"success": False,
|
|
3762
|
+
"error": str(e),
|
|
3763
|
+
"error_type": type(e).__name__,
|
|
3764
|
+
},
|
|
3765
|
+
status=500,
|
|
3766
|
+
)
|
|
@@ -6,6 +6,7 @@ Supports two connection modes:
|
|
|
6
6
|
2. Worker Binding - for use inside Cloudflare Python Workers (d1_binding)
|
|
7
7
|
"""
|
|
8
8
|
|
|
9
|
+
import os
|
|
9
10
|
from typing import Any, Dict, List, Optional, Sequence, Union
|
|
10
11
|
|
|
11
12
|
try:
|
|
@@ -475,8 +476,14 @@ class Connection:
|
|
|
475
476
|
self.database_id = database_id
|
|
476
477
|
self.api_token = api_token
|
|
477
478
|
|
|
478
|
-
# Build the D1 REST API URL
|
|
479
|
-
|
|
479
|
+
# Build the D1 REST API URL, allowing override via kwarg or CF_D1_BASE_URL env var
|
|
480
|
+
_default_base = (
|
|
481
|
+
f"https://api.cloudflare.com/client/v4/accounts/{account_id}"
|
|
482
|
+
f"/d1/database/{database_id}"
|
|
483
|
+
)
|
|
484
|
+
self.base_url = kwargs.get(
|
|
485
|
+
"base_url", os.environ.get("CF_D1_BASE_URL", _default_base)
|
|
486
|
+
)
|
|
480
487
|
|
|
481
488
|
# HTTP client
|
|
482
489
|
self.client = httpx.Client(
|
|
@@ -826,11 +833,14 @@ class AsyncConnection:
|
|
|
826
833
|
self.database_id = database_id
|
|
827
834
|
self.api_token = api_token
|
|
828
835
|
|
|
829
|
-
# Build the D1 REST API URL
|
|
830
|
-
|
|
836
|
+
# Build the D1 REST API URL, allowing override via kwarg or CF_D1_BASE_URL env var
|
|
837
|
+
_default_base = (
|
|
831
838
|
f"https://api.cloudflare.com/client/v4/accounts/{account_id}"
|
|
832
839
|
f"/d1/database/{database_id}"
|
|
833
840
|
)
|
|
841
|
+
self.base_url = kwargs.get(
|
|
842
|
+
"base_url", os.environ.get("CF_D1_BASE_URL", _default_base)
|
|
843
|
+
)
|
|
834
844
|
|
|
835
845
|
# Async HTTP client
|
|
836
846
|
self.client = httpx.AsyncClient(
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Integration tests for the configurable base URL feature (Issue #22).
|
|
2
|
+
|
|
3
|
+
These tests verify that CF_D1_BASE_URL and the base_url kwarg correctly
|
|
4
|
+
redirect HTTP requests away from the hard-coded Cloudflare endpoint.
|
|
5
|
+
|
|
6
|
+
A minimal local HTTP server mimics the D1 /raw response format so that
|
|
7
|
+
no real Cloudflare credentials are required to run these tests.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import threading
|
|
13
|
+
import urllib.parse
|
|
14
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
15
|
+
from unittest.mock import patch
|
|
16
|
+
|
|
17
|
+
import pytest
|
|
18
|
+
from sqlalchemy import create_engine, text
|
|
19
|
+
|
|
20
|
+
from sqlalchemy_cloudflare_d1.connection import Connection
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# MARK: - Local D1 Proxy Fixture
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@pytest.fixture()
|
|
27
|
+
def local_d1_server():
|
|
28
|
+
"""Start a local HTTP server that mimics the D1 REST API /raw endpoint.
|
|
29
|
+
|
|
30
|
+
Modelled after the local D1 proxy described in Issue #22. Responds with
|
|
31
|
+
the Cloudflare /raw response envelope so the Connection can parse it.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
received_requests: list[str] = []
|
|
35
|
+
|
|
36
|
+
class _D1Handler(BaseHTTPRequestHandler):
|
|
37
|
+
def do_POST(self):
|
|
38
|
+
received_requests.append(self.path)
|
|
39
|
+
body_bytes = self.rfile.read(int(self.headers["Content-Length"]))
|
|
40
|
+
json.loads(body_bytes) # parse but ignore — any SQL is fine
|
|
41
|
+
|
|
42
|
+
response = {
|
|
43
|
+
"success": True,
|
|
44
|
+
"result": [
|
|
45
|
+
{
|
|
46
|
+
"results": {
|
|
47
|
+
"columns": ["value"],
|
|
48
|
+
"rows": [[42]],
|
|
49
|
+
},
|
|
50
|
+
"meta": {},
|
|
51
|
+
"success": True,
|
|
52
|
+
}
|
|
53
|
+
],
|
|
54
|
+
"errors": [],
|
|
55
|
+
"messages": [],
|
|
56
|
+
}
|
|
57
|
+
payload = json.dumps(response).encode()
|
|
58
|
+
self.send_response(200)
|
|
59
|
+
self.send_header("Content-Type", "application/json")
|
|
60
|
+
self.send_header("Content-Length", str(len(payload)))
|
|
61
|
+
self.end_headers()
|
|
62
|
+
self.wfile.write(payload)
|
|
63
|
+
|
|
64
|
+
def log_message(self, format, *args): # noqa: A002
|
|
65
|
+
pass # suppress server log noise in test output
|
|
66
|
+
|
|
67
|
+
server = HTTPServer(("127.0.0.1", 0), _D1Handler)
|
|
68
|
+
port = server.server_address[1]
|
|
69
|
+
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
70
|
+
thread.start()
|
|
71
|
+
|
|
72
|
+
yield f"http://127.0.0.1:{port}", received_requests
|
|
73
|
+
|
|
74
|
+
server.shutdown()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# MARK: - Base URL Override Tests
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class TestBaseUrlOverride:
|
|
81
|
+
"""Verify that base_url kwarg and CF_D1_BASE_URL env var route requests
|
|
82
|
+
to a custom endpoint instead of the default Cloudflare API URL.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
def test_base_url_kwarg_redirects_requests(self, local_d1_server):
|
|
86
|
+
"""Connection uses base_url kwarg instead of the Cloudflare endpoint."""
|
|
87
|
+
base_url, received = local_d1_server
|
|
88
|
+
conn = Connection(
|
|
89
|
+
account_id="fake_account",
|
|
90
|
+
database_id="fake_db",
|
|
91
|
+
api_token="fake_token",
|
|
92
|
+
base_url=base_url,
|
|
93
|
+
)
|
|
94
|
+
try:
|
|
95
|
+
cur = conn.cursor()
|
|
96
|
+
cur.execute("SELECT 1 AS value")
|
|
97
|
+
row = cur.fetchone()
|
|
98
|
+
assert row == (42,)
|
|
99
|
+
assert any("/raw" in path for path in received)
|
|
100
|
+
finally:
|
|
101
|
+
conn.close()
|
|
102
|
+
|
|
103
|
+
def test_cf_d1_base_url_env_var_redirects_requests(self, local_d1_server):
|
|
104
|
+
"""Connection reads CF_D1_BASE_URL from the environment and routes there."""
|
|
105
|
+
base_url, received = local_d1_server
|
|
106
|
+
with patch.dict(os.environ, {"CF_D1_BASE_URL": base_url}):
|
|
107
|
+
conn = Connection(
|
|
108
|
+
account_id="fake_account",
|
|
109
|
+
database_id="fake_db",
|
|
110
|
+
api_token="fake_token",
|
|
111
|
+
)
|
|
112
|
+
try:
|
|
113
|
+
cur = conn.cursor()
|
|
114
|
+
cur.execute("SELECT 1 AS value")
|
|
115
|
+
row = cur.fetchone()
|
|
116
|
+
assert row == (42,)
|
|
117
|
+
assert any("/raw" in path for path in received)
|
|
118
|
+
finally:
|
|
119
|
+
conn.close()
|
|
120
|
+
|
|
121
|
+
def test_sqlalchemy_engine_with_connect_args(self, local_d1_server):
|
|
122
|
+
"""Engine built with connect_args={'base_url': ...} routes to the local server."""
|
|
123
|
+
base_url, received = local_d1_server
|
|
124
|
+
engine = create_engine(
|
|
125
|
+
"cloudflare_d1://fake_account:fake_token@fake_db",
|
|
126
|
+
connect_args={"base_url": base_url},
|
|
127
|
+
)
|
|
128
|
+
try:
|
|
129
|
+
with engine.connect() as conn:
|
|
130
|
+
result = conn.execute(text("SELECT 1 AS value"))
|
|
131
|
+
row = result.fetchone()
|
|
132
|
+
assert row == (42,)
|
|
133
|
+
assert any("/raw" in path for path in received)
|
|
134
|
+
finally:
|
|
135
|
+
engine.dispose()
|
|
136
|
+
|
|
137
|
+
def test_sqlalchemy_engine_with_base_url_query_param(self, local_d1_server):
|
|
138
|
+
"""Engine built from a URL with ?base_url= routes requests to the local server."""
|
|
139
|
+
base_url, received = local_d1_server
|
|
140
|
+
encoded = urllib.parse.quote(base_url, safe="")
|
|
141
|
+
engine = create_engine(
|
|
142
|
+
f"cloudflare_d1://fake_account:fake_token@fake_db?base_url={encoded}"
|
|
143
|
+
)
|
|
144
|
+
try:
|
|
145
|
+
with engine.connect() as conn:
|
|
146
|
+
result = conn.execute(text("SELECT 1 AS value"))
|
|
147
|
+
row = result.fetchone()
|
|
148
|
+
assert row == (42,)
|
|
149
|
+
assert any("/raw" in path for path in received)
|
|
150
|
+
finally:
|
|
151
|
+
engine.dispose()
|
|
152
|
+
|
|
153
|
+
def test_base_url_kwarg_takes_precedence_over_env_var(self, local_d1_server):
|
|
154
|
+
"""When both base_url kwarg and CF_D1_BASE_URL are set, kwarg wins."""
|
|
155
|
+
base_url, received = local_d1_server
|
|
156
|
+
with patch.dict(
|
|
157
|
+
os.environ, {"CF_D1_BASE_URL": "http://should-not-be-used:9999"}
|
|
158
|
+
):
|
|
159
|
+
conn = Connection(
|
|
160
|
+
account_id="fake_account",
|
|
161
|
+
database_id="fake_db",
|
|
162
|
+
api_token="fake_token",
|
|
163
|
+
base_url=base_url,
|
|
164
|
+
)
|
|
165
|
+
try:
|
|
166
|
+
cur = conn.cursor()
|
|
167
|
+
cur.execute("SELECT 1 AS value")
|
|
168
|
+
row = cur.fetchone()
|
|
169
|
+
assert row == (42,)
|
|
170
|
+
finally:
|
|
171
|
+
conn.close()
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
if __name__ == "__main__":
|
|
175
|
+
pytest.main([__file__, "-v", "-s"])
|
|
@@ -959,4 +959,64 @@ class TestWorkerTimeColumn:
|
|
|
959
959
|
assert data["test"] == "time_orm"
|
|
960
960
|
assert data["success"] is True, f"time_orm failed: error={data.get('error')}"
|
|
961
961
|
assert data["entry_title"] == "Time Test Entry"
|
|
962
|
-
|
|
962
|
+
|
|
963
|
+
|
|
964
|
+
# MARK: - Parallel Query Tests (GitHub issue #20)
|
|
965
|
+
|
|
966
|
+
|
|
967
|
+
class TestParallelQueries:
|
|
968
|
+
"""Concurrency tests proving both connection types are non-blocking.
|
|
969
|
+
|
|
970
|
+
Despite using pyodide.ffi.run_sync() internally, SyncWorkerConnection
|
|
971
|
+
drives the JS event loop rather than truly blocking it. Both connection
|
|
972
|
+
types show genuine speedup when queries are run via asyncio.gather().
|
|
973
|
+
"""
|
|
974
|
+
|
|
975
|
+
def test_engine_queries_are_concurrent(self, dev_server):
|
|
976
|
+
"""create_engine_from_binding() is concurrent — gather gives real speedup.
|
|
977
|
+
|
|
978
|
+
Both queries run a CPU-heavy recursive CTE (SUM of 1..500000).
|
|
979
|
+
Parallel time should be < 80% of sequential time, confirming that
|
|
980
|
+
run_sync() drives the JS event loop and does not block it.
|
|
981
|
+
"""
|
|
982
|
+
port = dev_server
|
|
983
|
+
response = requests.get(f"http://localhost:{port}/parallel-queries-engine")
|
|
984
|
+
|
|
985
|
+
assert response.status_code == 200, (
|
|
986
|
+
f"parallel_queries_engine failed: {response.json()}"
|
|
987
|
+
)
|
|
988
|
+
data = response.json()
|
|
989
|
+
|
|
990
|
+
assert data["test"] == "parallel_queries_engine", data
|
|
991
|
+
assert data["results_correct"] is True, f"query results wrong: {data}"
|
|
992
|
+
assert data["is_concurrent"] is True, (
|
|
993
|
+
f"expected concurrent behaviour but got no speedup: "
|
|
994
|
+
f"sequential={data['sequential_time_s']}s, "
|
|
995
|
+
f"parallel={data['parallel_time_s']}s "
|
|
996
|
+
f"({data['n_queries']} queries)"
|
|
997
|
+
)
|
|
998
|
+
|
|
999
|
+
def test_worker_connection_queries_are_concurrent(self, dev_server):
|
|
1000
|
+
"""WorkerConnection async is truly concurrent — gather gives real speedup.
|
|
1001
|
+
|
|
1002
|
+
Both queries run the same CPU-heavy recursive CTE.
|
|
1003
|
+
Parallel time should be < 80% of sequential time, confirming that
|
|
1004
|
+
await cursor.execute_async() yields to the event loop and allows
|
|
1005
|
+
both D1 calls to overlap.
|
|
1006
|
+
"""
|
|
1007
|
+
port = dev_server
|
|
1008
|
+
response = requests.get(f"http://localhost:{port}/parallel-queries-async")
|
|
1009
|
+
|
|
1010
|
+
assert response.status_code == 200, (
|
|
1011
|
+
f"parallel_queries_async failed: {response.json()}"
|
|
1012
|
+
)
|
|
1013
|
+
data = response.json()
|
|
1014
|
+
|
|
1015
|
+
assert data["test"] == "parallel_queries_async", data
|
|
1016
|
+
assert data["results_correct"] is True, f"query results wrong: {data}"
|
|
1017
|
+
assert data["is_concurrent"] is True, (
|
|
1018
|
+
f"expected concurrent behaviour but got no speedup: "
|
|
1019
|
+
f"sequential={data['sequential_time_s']}s, "
|
|
1020
|
+
f"parallel={data['parallel_time_s']}s "
|
|
1021
|
+
f"({data['n_queries']} queries)"
|
|
1022
|
+
)
|
{sqlalchemy_cloudflare_d1-0.3.8 → sqlalchemy_cloudflare_d1-0.3.9}/tests/unit/test_dialect.py
RENAMED
|
@@ -234,5 +234,65 @@ def test_async_dbapi_module():
|
|
|
234
234
|
assert hasattr(AsyncAdapt_d1_dbapi, "connect")
|
|
235
235
|
|
|
236
236
|
|
|
237
|
+
def test_connection_base_url_kwarg():
|
|
238
|
+
"""Test that Connection accepts a custom base_url kwarg."""
|
|
239
|
+
from unittest.mock import patch
|
|
240
|
+
|
|
241
|
+
from sqlalchemy_cloudflare_d1.connection import Connection
|
|
242
|
+
|
|
243
|
+
with patch("sqlalchemy_cloudflare_d1.connection.httpx") as mock_httpx:
|
|
244
|
+
mock_httpx.Client.return_value = None
|
|
245
|
+
conn = Connection(
|
|
246
|
+
account_id="acct",
|
|
247
|
+
database_id="db",
|
|
248
|
+
api_token="token",
|
|
249
|
+
base_url="http://localhost:8787",
|
|
250
|
+
)
|
|
251
|
+
assert conn.base_url == "http://localhost:8787"
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def test_connection_base_url_env_var():
|
|
255
|
+
"""Test that Connection reads CF_D1_BASE_URL from the environment."""
|
|
256
|
+
import os
|
|
257
|
+
from unittest.mock import patch
|
|
258
|
+
|
|
259
|
+
from sqlalchemy_cloudflare_d1.connection import Connection
|
|
260
|
+
|
|
261
|
+
with patch.dict(os.environ, {"CF_D1_BASE_URL": "http://localhost:9999"}):
|
|
262
|
+
with patch("sqlalchemy_cloudflare_d1.connection.httpx") as mock_httpx:
|
|
263
|
+
mock_httpx.Client.return_value = None
|
|
264
|
+
conn = Connection(account_id="acct", database_id="db", api_token="token")
|
|
265
|
+
assert conn.base_url == "http://localhost:9999"
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def test_connection_base_url_default():
|
|
269
|
+
"""Test that Connection falls back to the Cloudflare URL when no override is set."""
|
|
270
|
+
import os
|
|
271
|
+
from unittest.mock import patch
|
|
272
|
+
|
|
273
|
+
from sqlalchemy_cloudflare_d1.connection import Connection
|
|
274
|
+
|
|
275
|
+
env = {k: v for k, v in os.environ.items() if k != "CF_D1_BASE_URL"}
|
|
276
|
+
with patch.dict(os.environ, env, clear=True):
|
|
277
|
+
with patch("sqlalchemy_cloudflare_d1.connection.httpx") as mock_httpx:
|
|
278
|
+
mock_httpx.Client.return_value = None
|
|
279
|
+
conn = Connection(account_id="acct", database_id="db", api_token="token")
|
|
280
|
+
assert conn.base_url == (
|
|
281
|
+
"https://api.cloudflare.com/client/v4/accounts/acct/d1/database/db"
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def test_base_url_passthrough_via_url_query():
|
|
286
|
+
"""Test that base_url can be passed via the connection string query params."""
|
|
287
|
+
from sqlalchemy.engine.url import make_url
|
|
288
|
+
|
|
289
|
+
dialect = CloudflareD1Dialect()
|
|
290
|
+
url = make_url(
|
|
291
|
+
"cloudflare_d1://acct:token@db?base_url=http%3A%2F%2Flocalhost%3A8787"
|
|
292
|
+
)
|
|
293
|
+
_, kwargs = dialect.create_connect_args(url)
|
|
294
|
+
assert kwargs["base_url"] == "http://localhost:8787"
|
|
295
|
+
|
|
296
|
+
|
|
237
297
|
if __name__ == "__main__":
|
|
238
298
|
pytest.main([__file__])
|
|
File without changes
|
|
File without changes
|
{sqlalchemy_cloudflare_d1-0.3.8 → sqlalchemy_cloudflare_d1-0.3.9}/.github/workflows/release.yml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sqlalchemy_cloudflare_d1-0.3.8 → sqlalchemy_cloudflare_d1-0.3.9}/assets/sqlalchemy-logo.png
RENAMED
|
File without changes
|
{sqlalchemy_cloudflare_d1-0.3.8 → sqlalchemy_cloudflare_d1-0.3.9}/examples/restapi/example.py
RENAMED
|
File without changes
|
{sqlalchemy_cloudflare_d1-0.3.8 → sqlalchemy_cloudflare_d1-0.3.9}/examples/workers/README.md
RENAMED
|
File without changes
|
{sqlalchemy_cloudflare_d1-0.3.8 → sqlalchemy_cloudflare_d1-0.3.9}/examples/workers/db_init.sql
RENAMED
|
File without changes
|
{sqlalchemy_cloudflare_d1-0.3.8 → sqlalchemy_cloudflare_d1-0.3.9}/examples/workers/pyproject.toml
RENAMED
|
File without changes
|
|
File without changes
|
{sqlalchemy_cloudflare_d1-0.3.8 → sqlalchemy_cloudflare_d1-0.3.9}/examples/workers/wrangler.jsonc
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|