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.
Files changed (35) hide show
  1. {sqlalchemy_cloudflare_d1-0.3.8 → sqlalchemy_cloudflare_d1-0.3.9}/CHANGELOG.md +11 -0
  2. {sqlalchemy_cloudflare_d1-0.3.8 → sqlalchemy_cloudflare_d1-0.3.9}/PKG-INFO +1 -1
  3. {sqlalchemy_cloudflare_d1-0.3.8 → sqlalchemy_cloudflare_d1-0.3.9}/examples/workers/src/entry.py +130 -0
  4. {sqlalchemy_cloudflare_d1-0.3.8 → sqlalchemy_cloudflare_d1-0.3.9}/pyproject.toml +1 -1
  5. {sqlalchemy_cloudflare_d1-0.3.8 → sqlalchemy_cloudflare_d1-0.3.9}/src/sqlalchemy_cloudflare_d1/connection.py +14 -4
  6. sqlalchemy_cloudflare_d1-0.3.9/tests/integration/test_base_url_integration.py +175 -0
  7. {sqlalchemy_cloudflare_d1-0.3.8 → sqlalchemy_cloudflare_d1-0.3.9}/tests/integration/test_worker_integration.py +61 -1
  8. {sqlalchemy_cloudflare_d1-0.3.8 → sqlalchemy_cloudflare_d1-0.3.9}/tests/unit/test_dialect.py +60 -0
  9. {sqlalchemy_cloudflare_d1-0.3.8 → sqlalchemy_cloudflare_d1-0.3.9}/uv.lock +1 -1
  10. {sqlalchemy_cloudflare_d1-0.3.8 → sqlalchemy_cloudflare_d1-0.3.9}/.github/DEVELOPMENT.md +0 -0
  11. {sqlalchemy_cloudflare_d1-0.3.8 → sqlalchemy_cloudflare_d1-0.3.9}/.github/workflows/ci.yml +0 -0
  12. {sqlalchemy_cloudflare_d1-0.3.8 → sqlalchemy_cloudflare_d1-0.3.9}/.github/workflows/release.yml +0 -0
  13. {sqlalchemy_cloudflare_d1-0.3.8 → sqlalchemy_cloudflare_d1-0.3.9}/.gitignore +0 -0
  14. {sqlalchemy_cloudflare_d1-0.3.8 → sqlalchemy_cloudflare_d1-0.3.9}/.pre-commit-config.yaml +0 -0
  15. {sqlalchemy_cloudflare_d1-0.3.8 → sqlalchemy_cloudflare_d1-0.3.9}/.python-version +0 -0
  16. {sqlalchemy_cloudflare_d1-0.3.8 → sqlalchemy_cloudflare_d1-0.3.9}/LICENSE +0 -0
  17. {sqlalchemy_cloudflare_d1-0.3.8 → sqlalchemy_cloudflare_d1-0.3.9}/Makefile +0 -0
  18. {sqlalchemy_cloudflare_d1-0.3.8 → sqlalchemy_cloudflare_d1-0.3.9}/README.md +0 -0
  19. {sqlalchemy_cloudflare_d1-0.3.8 → sqlalchemy_cloudflare_d1-0.3.9}/assets/d1-logo.png +0 -0
  20. {sqlalchemy_cloudflare_d1-0.3.8 → sqlalchemy_cloudflare_d1-0.3.9}/assets/sqlalchemy-logo.png +0 -0
  21. {sqlalchemy_cloudflare_d1-0.3.8 → sqlalchemy_cloudflare_d1-0.3.9}/examples/restapi/example.py +0 -0
  22. {sqlalchemy_cloudflare_d1-0.3.8 → sqlalchemy_cloudflare_d1-0.3.9}/examples/workers/README.md +0 -0
  23. {sqlalchemy_cloudflare_d1-0.3.8 → sqlalchemy_cloudflare_d1-0.3.9}/examples/workers/db_init.sql +0 -0
  24. {sqlalchemy_cloudflare_d1-0.3.8 → sqlalchemy_cloudflare_d1-0.3.9}/examples/workers/pyproject.toml +0 -0
  25. {sqlalchemy_cloudflare_d1-0.3.8 → sqlalchemy_cloudflare_d1-0.3.9}/examples/workers/uv.lock +0 -0
  26. {sqlalchemy_cloudflare_d1-0.3.8 → sqlalchemy_cloudflare_d1-0.3.9}/examples/workers/wrangler.jsonc +0 -0
  27. {sqlalchemy_cloudflare_d1-0.3.8 → sqlalchemy_cloudflare_d1-0.3.9}/src/sqlalchemy_cloudflare_d1/__init__.py +0 -0
  28. {sqlalchemy_cloudflare_d1-0.3.8 → sqlalchemy_cloudflare_d1-0.3.9}/src/sqlalchemy_cloudflare_d1/compiler.py +0 -0
  29. {sqlalchemy_cloudflare_d1-0.3.8 → sqlalchemy_cloudflare_d1-0.3.9}/src/sqlalchemy_cloudflare_d1/dialect.py +0 -0
  30. {sqlalchemy_cloudflare_d1-0.3.8 → sqlalchemy_cloudflare_d1-0.3.9}/src/sqlalchemy_cloudflare_d1/dialect_async.py +0 -0
  31. {sqlalchemy_cloudflare_d1-0.3.8 → sqlalchemy_cloudflare_d1-0.3.9}/src/sqlalchemy_cloudflare_d1/py.typed +0 -0
  32. {sqlalchemy_cloudflare_d1-0.3.8 → sqlalchemy_cloudflare_d1-0.3.9}/tests/__init__.py +0 -0
  33. {sqlalchemy_cloudflare_d1-0.3.8 → sqlalchemy_cloudflare_d1-0.3.9}/tests/conftest.py +0 -0
  34. {sqlalchemy_cloudflare_d1-0.3.8 → sqlalchemy_cloudflare_d1-0.3.9}/tests/integration/test_restapi_integration.py +0 -0
  35. {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.8
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
@@ -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
+ )
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sqlalchemy-cloudflare-d1"
3
- version = "0.3.8"
3
+ version = "0.3.9"
4
4
  description = "A SQLAlchemy dialect for Cloudflare's D1 Serverless SQLite Database"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -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
- self.base_url = f"https://api.cloudflare.com/client/v4/accounts/{account_id}/d1/database/{database_id}"
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
- self.base_url = (
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
- assert data["start_time_is_time"] is True
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
+ )
@@ -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__])
@@ -922,7 +922,7 @@ wheels = [
922
922
 
923
923
  [[package]]
924
924
  name = "sqlalchemy-cloudflare-d1"
925
- version = "0.3.8"
925
+ version = "0.3.9"
926
926
  source = { editable = "." }
927
927
  dependencies = [
928
928
  { name = "httpx" },