sqlalchemy-cloudflare-d1 0.3.7__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.7 → sqlalchemy_cloudflare_d1-0.3.9}/CHANGELOG.md +25 -0
  2. {sqlalchemy_cloudflare_d1-0.3.7 → sqlalchemy_cloudflare_d1-0.3.9}/PKG-INFO +6 -5
  3. {sqlalchemy_cloudflare_d1-0.3.7 → sqlalchemy_cloudflare_d1-0.3.9}/README.md +5 -4
  4. {sqlalchemy_cloudflare_d1-0.3.7 → sqlalchemy_cloudflare_d1-0.3.9}/examples/workers/src/entry.py +362 -0
  5. {sqlalchemy_cloudflare_d1-0.3.7 → sqlalchemy_cloudflare_d1-0.3.9}/examples/workers/uv.lock +2 -1
  6. {sqlalchemy_cloudflare_d1-0.3.7 → sqlalchemy_cloudflare_d1-0.3.9}/pyproject.toml +1 -1
  7. {sqlalchemy_cloudflare_d1-0.3.7 → sqlalchemy_cloudflare_d1-0.3.9}/src/sqlalchemy_cloudflare_d1/connection.py +14 -4
  8. {sqlalchemy_cloudflare_d1-0.3.7 → sqlalchemy_cloudflare_d1-0.3.9}/src/sqlalchemy_cloudflare_d1/dialect.py +50 -1
  9. sqlalchemy_cloudflare_d1-0.3.9/tests/integration/test_base_url_integration.py +175 -0
  10. {sqlalchemy_cloudflare_d1-0.3.7 → sqlalchemy_cloudflare_d1-0.3.9}/tests/integration/test_restapi_integration.py +166 -0
  11. {sqlalchemy_cloudflare_d1-0.3.7 → sqlalchemy_cloudflare_d1-0.3.9}/tests/integration/test_worker_integration.py +110 -0
  12. {sqlalchemy_cloudflare_d1-0.3.7 → sqlalchemy_cloudflare_d1-0.3.9}/tests/unit/test_dialect.py +60 -0
  13. {sqlalchemy_cloudflare_d1-0.3.7 → sqlalchemy_cloudflare_d1-0.3.9}/uv.lock +1 -1
  14. {sqlalchemy_cloudflare_d1-0.3.7 → sqlalchemy_cloudflare_d1-0.3.9}/.github/DEVELOPMENT.md +0 -0
  15. {sqlalchemy_cloudflare_d1-0.3.7 → sqlalchemy_cloudflare_d1-0.3.9}/.github/workflows/ci.yml +0 -0
  16. {sqlalchemy_cloudflare_d1-0.3.7 → sqlalchemy_cloudflare_d1-0.3.9}/.github/workflows/release.yml +0 -0
  17. {sqlalchemy_cloudflare_d1-0.3.7 → sqlalchemy_cloudflare_d1-0.3.9}/.gitignore +0 -0
  18. {sqlalchemy_cloudflare_d1-0.3.7 → sqlalchemy_cloudflare_d1-0.3.9}/.pre-commit-config.yaml +0 -0
  19. {sqlalchemy_cloudflare_d1-0.3.7 → sqlalchemy_cloudflare_d1-0.3.9}/.python-version +0 -0
  20. {sqlalchemy_cloudflare_d1-0.3.7 → sqlalchemy_cloudflare_d1-0.3.9}/LICENSE +0 -0
  21. {sqlalchemy_cloudflare_d1-0.3.7 → sqlalchemy_cloudflare_d1-0.3.9}/Makefile +0 -0
  22. {sqlalchemy_cloudflare_d1-0.3.7 → sqlalchemy_cloudflare_d1-0.3.9}/assets/d1-logo.png +0 -0
  23. {sqlalchemy_cloudflare_d1-0.3.7 → sqlalchemy_cloudflare_d1-0.3.9}/assets/sqlalchemy-logo.png +0 -0
  24. {sqlalchemy_cloudflare_d1-0.3.7 → sqlalchemy_cloudflare_d1-0.3.9}/examples/restapi/example.py +0 -0
  25. {sqlalchemy_cloudflare_d1-0.3.7 → sqlalchemy_cloudflare_d1-0.3.9}/examples/workers/README.md +0 -0
  26. {sqlalchemy_cloudflare_d1-0.3.7 → sqlalchemy_cloudflare_d1-0.3.9}/examples/workers/db_init.sql +0 -0
  27. {sqlalchemy_cloudflare_d1-0.3.7 → sqlalchemy_cloudflare_d1-0.3.9}/examples/workers/pyproject.toml +0 -0
  28. {sqlalchemy_cloudflare_d1-0.3.7 → sqlalchemy_cloudflare_d1-0.3.9}/examples/workers/wrangler.jsonc +0 -0
  29. {sqlalchemy_cloudflare_d1-0.3.7 → sqlalchemy_cloudflare_d1-0.3.9}/src/sqlalchemy_cloudflare_d1/__init__.py +0 -0
  30. {sqlalchemy_cloudflare_d1-0.3.7 → sqlalchemy_cloudflare_d1-0.3.9}/src/sqlalchemy_cloudflare_d1/compiler.py +0 -0
  31. {sqlalchemy_cloudflare_d1-0.3.7 → sqlalchemy_cloudflare_d1-0.3.9}/src/sqlalchemy_cloudflare_d1/dialect_async.py +0 -0
  32. {sqlalchemy_cloudflare_d1-0.3.7 → sqlalchemy_cloudflare_d1-0.3.9}/src/sqlalchemy_cloudflare_d1/py.typed +0 -0
  33. {sqlalchemy_cloudflare_d1-0.3.7 → sqlalchemy_cloudflare_d1-0.3.9}/tests/__init__.py +0 -0
  34. {sqlalchemy_cloudflare_d1-0.3.7 → sqlalchemy_cloudflare_d1-0.3.9}/tests/conftest.py +0 -0
  35. {sqlalchemy_cloudflare_d1-0.3.7 → sqlalchemy_cloudflare_d1-0.3.9}/tests/test_utils.py +0 -0
@@ -13,6 +13,31 @@ 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
+
27
+ ## [0.3.8]
28
+
29
+ ### Added
30
+
31
+ - `Time` column type support ([#18](https://github.com/CollierKing/sqlalchemy-cloudflare-d1/issues/18))
32
+ - Added `D1Time` type processor that converts Python `time` objects to ISO 8601 strings on bind and parses them back on result
33
+ - Supports nullable time columns, time filtering/comparison, and ORM usage
34
+ - Works in both REST API and Worker modes
35
+
36
+ ### Changed
37
+
38
+ - Updated README Type Mapping table to document all custom type processors (`D1Boolean`, `D1Date`, `D1Time`, `D1DateTime`, `D1LargeBinary`)
39
+
40
+
16
41
  ## [0.3.7]
17
42
 
18
43
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sqlalchemy-cloudflare-d1
3
- Version: 0.3.7
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
@@ -431,10 +431,11 @@ This dialect has some limitations due to D1's REST API nature:
431
431
  | `Text` | `TEXT` | |
432
432
  | `Float` | `REAL` | |
433
433
  | `Numeric` | `NUMERIC` | |
434
- | `Boolean` | `INTEGER` | Stored as 0/1 |
435
- | `DateTime` | `TEXT` | ISO format string |
436
- | `Date` | `TEXT` | ISO format string |
437
- | `Time` | `TEXT` | ISO format string |
434
+ | `Boolean` | `INTEGER` | Stored as 0/1, auto-converted via `D1Boolean` |
435
+ | `DateTime` | `TEXT` | ISO 8601 string, auto-converted via `D1DateTime` |
436
+ | `Date` | `TEXT` | ISO 8601 string, auto-converted via `D1Date` |
437
+ | `Time` | `TEXT` | ISO 8601 string, auto-converted via `D1Time` |
438
+ | `LargeBinary` | `BLOB` | Base64-encoded, auto-converted via `D1LargeBinary` |
438
439
 
439
440
  ## Error Handling
440
441
 
@@ -374,10 +374,11 @@ This dialect has some limitations due to D1's REST API nature:
374
374
  | `Text` | `TEXT` | |
375
375
  | `Float` | `REAL` | |
376
376
  | `Numeric` | `NUMERIC` | |
377
- | `Boolean` | `INTEGER` | Stored as 0/1 |
378
- | `DateTime` | `TEXT` | ISO format string |
379
- | `Date` | `TEXT` | ISO format string |
380
- | `Time` | `TEXT` | ISO format string |
377
+ | `Boolean` | `INTEGER` | Stored as 0/1, auto-converted via `D1Boolean` |
378
+ | `DateTime` | `TEXT` | ISO 8601 string, auto-converted via `D1DateTime` |
379
+ | `Date` | `TEXT` | ISO 8601 string, auto-converted via `D1Date` |
380
+ | `Time` | `TEXT` | ISO 8601 string, auto-converted via `D1Time` |
381
+ | `LargeBinary` | `BLOB` | Base64-encoded, auto-converted via `D1LargeBinary` |
381
382
 
382
383
  ## Error Handling
383
384
 
@@ -138,6 +138,18 @@ class Default(WorkerEntrypoint):
138
138
  return await self.test_date_nullable()
139
139
  elif path == "date-orm":
140
140
  return await self.test_date_orm()
141
+ # Time column tests (GitHub issue #18)
142
+ elif path == "time-basic":
143
+ return await self.test_time_basic()
144
+ elif path == "time-nullable":
145
+ return await self.test_time_nullable()
146
+ elif path == "time-orm":
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()
141
153
  else:
142
154
  return await self.index()
143
155
 
@@ -196,6 +208,11 @@ class Default(WorkerEntrypoint):
196
208
  "/date-basic": "Test Date column insert/retrieve",
197
209
  "/date-nullable": "Test nullable Date columns",
198
210
  "/date-orm": "Test Date via ORM session",
211
+ "/time-basic": "Test Time column insert/retrieve",
212
+ "/time-nullable": "Test nullable Time columns",
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",
199
216
  },
200
217
  "package": "sqlalchemy-cloudflare-d1",
201
218
  "connection_type": "WorkerConnection (D1 binding)",
@@ -3402,3 +3419,348 @@ class Default(WorkerEntrypoint):
3402
3419
  },
3403
3420
  status=500,
3404
3421
  )
3422
+
3423
+ # MARK: - Time Column Tests
3424
+
3425
+ async def test_time_basic(self):
3426
+ """Test Time column insert and retrieve."""
3427
+ from datetime import time
3428
+ from sqlalchemy import (
3429
+ Column,
3430
+ Integer,
3431
+ MetaData,
3432
+ String,
3433
+ Table,
3434
+ Time,
3435
+ select,
3436
+ )
3437
+
3438
+ table_name = f"test_time_{uuid.uuid4().hex[:8]}"
3439
+
3440
+ try:
3441
+ engine = self.get_engine()
3442
+ metadata = MetaData()
3443
+
3444
+ test_table = Table(
3445
+ table_name,
3446
+ metadata,
3447
+ Column("id", Integer, primary_key=True),
3448
+ Column("title", String(127)),
3449
+ Column("event_time", Time),
3450
+ )
3451
+
3452
+ metadata.create_all(engine)
3453
+
3454
+ time_value = time(14, 30, 45)
3455
+
3456
+ with engine.connect() as conn:
3457
+ conn.execute(
3458
+ test_table.insert().values(title="Test", event_time=time_value)
3459
+ )
3460
+ conn.commit()
3461
+
3462
+ result = conn.execute(
3463
+ select(test_table.c.title, test_table.c.event_time)
3464
+ )
3465
+ row = result.fetchone()
3466
+
3467
+ metadata.drop_all(engine)
3468
+
3469
+ success = (
3470
+ row is not None
3471
+ and row[0] == "Test"
3472
+ and isinstance(row[1], time)
3473
+ and row[1].hour == 14
3474
+ and row[1].minute == 30
3475
+ and row[1].second == 45
3476
+ )
3477
+
3478
+ return Response.json(
3479
+ {
3480
+ "test": "time_basic",
3481
+ "success": success,
3482
+ "title": row[0] if row else None,
3483
+ "event_time_type": type(row[1]).__name__ if row else None,
3484
+ "hour": row[1].hour if row and isinstance(row[1], time) else None,
3485
+ "minute": row[1].minute
3486
+ if row and isinstance(row[1], time)
3487
+ else None,
3488
+ }
3489
+ )
3490
+ except Exception as e:
3491
+ try:
3492
+ metadata.drop_all(engine)
3493
+ except Exception:
3494
+ pass
3495
+ return Response.json(
3496
+ {"test": "time_basic", "success": False, "error": str(e)},
3497
+ status=500,
3498
+ )
3499
+
3500
+ async def test_time_nullable(self):
3501
+ """Test nullable Time columns handle NULL correctly."""
3502
+ from datetime import time
3503
+ from sqlalchemy import (
3504
+ Column,
3505
+ Integer,
3506
+ MetaData,
3507
+ String,
3508
+ Table,
3509
+ Time,
3510
+ select,
3511
+ )
3512
+
3513
+ table_name = f"test_time_null_{uuid.uuid4().hex[:8]}"
3514
+
3515
+ try:
3516
+ engine = self.get_engine()
3517
+ metadata = MetaData()
3518
+
3519
+ test_table = Table(
3520
+ table_name,
3521
+ metadata,
3522
+ Column("id", Integer, primary_key=True),
3523
+ Column("title", String(127)),
3524
+ Column("event_time", Time, nullable=True),
3525
+ )
3526
+
3527
+ metadata.create_all(engine)
3528
+
3529
+ with engine.connect() as conn:
3530
+ conn.execute(
3531
+ test_table.insert().values(
3532
+ title="With Time",
3533
+ event_time=time(9, 0, 0),
3534
+ )
3535
+ )
3536
+ conn.execute(
3537
+ test_table.insert().values(title="No Time", event_time=None)
3538
+ )
3539
+ conn.commit()
3540
+
3541
+ result = conn.execute(
3542
+ select(test_table.c.title, test_table.c.event_time).order_by(
3543
+ test_table.c.id
3544
+ )
3545
+ )
3546
+ rows = result.fetchall()
3547
+
3548
+ metadata.drop_all(engine)
3549
+
3550
+ success = (
3551
+ len(rows) == 2
3552
+ and rows[0][0] == "With Time"
3553
+ and isinstance(rows[0][1], time)
3554
+ and rows[1][0] == "No Time"
3555
+ and rows[1][1] is None
3556
+ )
3557
+
3558
+ return Response.json(
3559
+ {
3560
+ "test": "time_nullable",
3561
+ "success": success,
3562
+ "with_time_is_time": isinstance(rows[0][1], time)
3563
+ if rows
3564
+ else False,
3565
+ "no_time_is_none": rows[1][1] is None if len(rows) > 1 else False,
3566
+ }
3567
+ )
3568
+ except Exception as e:
3569
+ try:
3570
+ metadata.drop_all(engine)
3571
+ except Exception:
3572
+ pass
3573
+ return Response.json(
3574
+ {"test": "time_nullable", "success": False, "error": str(e)},
3575
+ status=500,
3576
+ )
3577
+
3578
+ async def test_time_orm(self):
3579
+ """Test Time via ORM session."""
3580
+ from datetime import time
3581
+ from sqlalchemy import Integer, String, Time
3582
+ from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, Session
3583
+
3584
+ engine = create_engine_from_binding(self.env.DB)
3585
+ table_name = f"test_time_orm_{uuid.uuid4().hex[:8]}"
3586
+
3587
+ try:
3588
+
3589
+ class Base(DeclarativeBase):
3590
+ pass
3591
+
3592
+ class Schedule(Base):
3593
+ __tablename__ = table_name
3594
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
3595
+ title: Mapped[str] = mapped_column(String(127))
3596
+ start_time: Mapped[time] = mapped_column(Time)
3597
+
3598
+ Base.metadata.create_all(engine)
3599
+
3600
+ test_time = time(14, 30, 45)
3601
+
3602
+ with Session(engine) as session:
3603
+ entry = Schedule(
3604
+ title="Time Test Entry",
3605
+ start_time=test_time,
3606
+ )
3607
+ session.add(entry)
3608
+ session.commit()
3609
+ session.refresh(entry)
3610
+ entry_title = entry.title
3611
+ start_time_is_time = isinstance(entry.start_time, time)
3612
+ start_time_value = entry.start_time
3613
+
3614
+ Base.metadata.drop_all(engine)
3615
+
3616
+ success = start_time_is_time and start_time_value == test_time
3617
+
3618
+ return Response.json(
3619
+ {
3620
+ "test": "time_orm",
3621
+ "success": success,
3622
+ "entry_title": entry_title,
3623
+ "start_time_is_time": start_time_is_time,
3624
+ }
3625
+ )
3626
+ except Exception as e:
3627
+ try:
3628
+ from sqlalchemy import MetaData, Table
3629
+
3630
+ md = MetaData()
3631
+ Table(table_name, md)
3632
+ md.drop_all(engine)
3633
+ except Exception:
3634
+ pass
3635
+ return Response.json(
3636
+ {
3637
+ "test": "time_orm",
3638
+ "success": False,
3639
+ "error": str(e),
3640
+ "error_type": type(e).__name__,
3641
+ },
3642
+ status=500,
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
+ )
@@ -614,7 +614,7 @@ wheels = [
614
614
 
615
615
  [[package]]
616
616
  name = "sqlalchemy-cloudflare-d1"
617
- version = "0.3.7"
617
+ version = "0.3.8"
618
618
  source = { editable = "../../" }
619
619
  dependencies = [
620
620
  { name = "httpx" },
@@ -640,6 +640,7 @@ provides-extras = ["async", "dev"]
640
640
  [package.metadata.requires-dev]
641
641
  dev = [
642
642
  { name = "codespell", specifier = ">=2.4.1" },
643
+ { name = "greenlet", specifier = ">=3.2.3" },
643
644
  { name = "mypy", specifier = ">=1.17.0" },
644
645
  { name = "pandas", specifier = ">=2.0.0" },
645
646
  { name = "pytest", specifier = ">=8.4.1" },
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sqlalchemy-cloudflare-d1"
3
- version = "0.3.7"
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(
@@ -3,7 +3,7 @@ SQLAlchemy dialect for Cloudflare D1.
3
3
  """
4
4
 
5
5
  import base64
6
- from datetime import datetime, date
6
+ from datetime import date, datetime, time
7
7
  from typing import Any, Callable, Dict, List, Optional
8
8
 
9
9
  from sqlalchemy.engine import default
@@ -17,6 +17,7 @@ from sqlalchemy.sql.sqltypes import (
17
17
  REAL,
18
18
  TEXT,
19
19
  Date,
20
+ Time,
20
21
  )
21
22
  from sqlalchemy import text
22
23
 
@@ -160,6 +161,53 @@ class D1Date(Date):
160
161
  return process
161
162
 
162
163
 
164
+ # MARK: - Time Type Processor
165
+
166
+
167
+ class D1Time(Time):
168
+ """Custom Time type for Cloudflare D1.
169
+
170
+ D1 does not accept Python time objects as bind parameters - they arrive
171
+ as JS `object` type and raise D1_TYPE_ERROR. This type processor converts
172
+ time objects to ISO 8601 strings on bind and parses them back on result.
173
+ """
174
+
175
+ def bind_processor(self, dialect: Dialect) -> Callable[[Any], Optional[str]]:
176
+ """Convert Python time to ISO 8601 string for D1."""
177
+
178
+ # MARK: - bind_processor
179
+ def process(value: Any) -> Optional[str]:
180
+ if value is None:
181
+ return None
182
+ if isinstance(value, time):
183
+ return value.isoformat()
184
+ if isinstance(value, str):
185
+ return value
186
+ return str(value)
187
+
188
+ return process
189
+
190
+ def result_processor(
191
+ self, dialect: Dialect, coltype: Any
192
+ ) -> Callable[[Any], Optional[time]]:
193
+ """Convert ISO 8601 string from D1 back to Python time."""
194
+
195
+ # MARK: - result_processor
196
+ def process(value: Any) -> Optional[time]:
197
+ if value is None:
198
+ return None
199
+ if isinstance(value, time):
200
+ return value
201
+ if isinstance(value, str):
202
+ try:
203
+ return time.fromisoformat(value)
204
+ except ValueError:
205
+ return value
206
+ return value
207
+
208
+ return process
209
+
210
+
163
211
  # MARK: - DateTime Type Processor
164
212
 
165
213
 
@@ -245,6 +293,7 @@ class CloudflareD1Dialect(default.DefaultDialect):
245
293
  Date: D1Date,
246
294
  DateTime: D1DateTime,
247
295
  LargeBinary: D1LargeBinary,
296
+ Time: D1Time,
248
297
  }
249
298
 
250
299
  # Reserved words (SQLite keywords)
@@ -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"])
@@ -2849,5 +2849,171 @@ class TestDateTimeColumn:
2849
2849
  metadata.drop_all(d1_engine)
2850
2850
 
2851
2851
 
2852
+ # MARK: - Time Column Tests (Issue #18)
2853
+
2854
+
2855
+ class TestTimeColumn:
2856
+ """Test Time column handling.
2857
+
2858
+ D1 does not accept Python time objects as bind parameters. The
2859
+ D1Time type processor converts times to ISO 8601 strings on bind
2860
+ and parses them back on result.
2861
+ """
2862
+
2863
+ def test_time_insert_and_retrieve(self, d1_engine, test_table_name):
2864
+ """Test that Time columns can store and retrieve time values."""
2865
+ from datetime import time
2866
+ from sqlalchemy import Time
2867
+
2868
+ metadata = MetaData()
2869
+ test_table = Table(
2870
+ test_table_name,
2871
+ metadata,
2872
+ Column("id", Integer, primary_key=True),
2873
+ Column("title", String(127)),
2874
+ Column("event_time", Time),
2875
+ )
2876
+
2877
+ metadata.create_all(d1_engine)
2878
+
2879
+ try:
2880
+ time_value = time(14, 30, 45)
2881
+
2882
+ with d1_engine.connect() as conn:
2883
+ conn.execute(
2884
+ test_table.insert().values(title="Test", event_time=time_value)
2885
+ )
2886
+ conn.commit()
2887
+
2888
+ result = conn.execute(
2889
+ select(test_table.c.title, test_table.c.event_time)
2890
+ )
2891
+ row = result.fetchone()
2892
+
2893
+ assert row is not None
2894
+ assert row[0] == "Test"
2895
+ assert isinstance(row[1], time)
2896
+ assert row[1].hour == 14
2897
+ assert row[1].minute == 30
2898
+ assert row[1].second == 45
2899
+ finally:
2900
+ metadata.drop_all(d1_engine)
2901
+
2902
+ def test_time_nullable(self, d1_engine, test_table_name):
2903
+ """Test nullable Time columns handle NULL correctly."""
2904
+ from datetime import time
2905
+ from sqlalchemy import Time
2906
+
2907
+ metadata = MetaData()
2908
+ test_table = Table(
2909
+ test_table_name,
2910
+ metadata,
2911
+ Column("id", Integer, primary_key=True),
2912
+ Column("title", String(127)),
2913
+ Column("event_time", Time, nullable=True),
2914
+ )
2915
+
2916
+ metadata.create_all(d1_engine)
2917
+
2918
+ try:
2919
+ with d1_engine.connect() as conn:
2920
+ conn.execute(
2921
+ test_table.insert().values(title="No Time", event_time=None)
2922
+ )
2923
+ conn.execute(
2924
+ test_table.insert().values(
2925
+ title="With Time", event_time=time(9, 0, 0)
2926
+ )
2927
+ )
2928
+ conn.commit()
2929
+
2930
+ result = conn.execute(
2931
+ select(test_table.c.title, test_table.c.event_time)
2932
+ )
2933
+ rows = result.fetchall()
2934
+
2935
+ assert len(rows) == 2
2936
+ assert rows[0][1] is None
2937
+ assert isinstance(rows[1][1], time)
2938
+ finally:
2939
+ metadata.drop_all(d1_engine)
2940
+
2941
+ def test_time_orm_session(self, d1_engine):
2942
+ """Test Time via ORM session."""
2943
+ from datetime import time
2944
+ from sqlalchemy import Time
2945
+ from sqlalchemy.orm import Mapped, Session, declarative_base, mapped_column
2946
+
2947
+ Base = declarative_base()
2948
+
2949
+ class Schedule(Base):
2950
+ __tablename__ = f"schedules_{uuid.uuid4().hex[:8]}"
2951
+
2952
+ id: Mapped[int] = mapped_column(Integer, primary_key=True)
2953
+ title: Mapped[str] = mapped_column(String(127))
2954
+ start_time: Mapped[time] = mapped_column(Time)
2955
+
2956
+ Base.metadata.create_all(d1_engine)
2957
+
2958
+ try:
2959
+ test_time = time(14, 30, 45)
2960
+
2961
+ with Session(d1_engine) as session:
2962
+ entry = Schedule(title="Test Entry", start_time=test_time)
2963
+ session.add(entry)
2964
+ session.commit()
2965
+
2966
+ retrieved = session.query(Schedule).first()
2967
+
2968
+ assert retrieved is not None
2969
+ assert retrieved.title == "Test Entry"
2970
+ assert isinstance(retrieved.start_time, time)
2971
+ assert retrieved.start_time == test_time
2972
+ finally:
2973
+ Base.metadata.drop_all(d1_engine)
2974
+
2975
+ def test_time_filter_query(self, d1_engine, test_table_name):
2976
+ """Test filtering by Time column values."""
2977
+ from datetime import time
2978
+ from sqlalchemy import Time
2979
+
2980
+ metadata = MetaData()
2981
+ test_table = Table(
2982
+ test_table_name,
2983
+ metadata,
2984
+ Column("id", Integer, primary_key=True),
2985
+ Column("title", String(127)),
2986
+ Column("event_time", Time),
2987
+ )
2988
+
2989
+ metadata.create_all(d1_engine)
2990
+
2991
+ try:
2992
+ time_morning = time(8, 0, 0)
2993
+ time_afternoon = time(15, 30, 0)
2994
+
2995
+ with d1_engine.connect() as conn:
2996
+ conn.execute(
2997
+ test_table.insert().values(title="Morning", event_time=time_morning)
2998
+ )
2999
+ conn.execute(
3000
+ test_table.insert().values(
3001
+ title="Afternoon", event_time=time_afternoon
3002
+ )
3003
+ )
3004
+ conn.commit()
3005
+
3006
+ cutoff = time(12, 0, 0).isoformat()
3007
+ result = conn.execute(
3008
+ select(test_table.c.title).where(test_table.c.event_time > cutoff)
3009
+ )
3010
+ rows = result.fetchall()
3011
+
3012
+ assert len(rows) == 1
3013
+ assert rows[0][0] == "Afternoon"
3014
+ finally:
3015
+ metadata.drop_all(d1_engine)
3016
+
3017
+
2852
3018
  if __name__ == "__main__":
2853
3019
  pytest.main([__file__, "-v", "-s"])
@@ -910,3 +910,113 @@ class TestWorkerDateTimeColumn:
910
910
  assert data["origin_is_datetime"] is True
911
911
  assert data["indexed_is_datetime"] is True
912
912
  assert data["inserted_is_datetime"] is True
913
+
914
+
915
+ # MARK: - Time Column Tests (Issue #18)
916
+
917
+
918
+ class TestWorkerTimeColumn:
919
+ """Test Time column handling via Worker endpoints.
920
+
921
+ These tests mirror the TestTimeColumn tests in test_restapi_integration.py.
922
+ """
923
+
924
+ def test_time_insert_and_retrieve(self, dev_server):
925
+ """Test Time column insert and retrieve."""
926
+ port = dev_server
927
+ response = requests.get(f"http://localhost:{port}/time-basic")
928
+
929
+ assert response.status_code == 200, f"time_basic failed: {response.json()}"
930
+ data = response.json()
931
+
932
+ assert data["test"] == "time_basic"
933
+ assert data["success"] is True, f"time_basic failed: error={data.get('error')}"
934
+ assert data["event_time_type"] == "time"
935
+
936
+ def test_time_nullable(self, dev_server):
937
+ """Test nullable Time columns handle NULL correctly."""
938
+ port = dev_server
939
+ response = requests.get(f"http://localhost:{port}/time-nullable")
940
+
941
+ assert response.status_code == 200, f"time_nullable failed: {response.json()}"
942
+ data = response.json()
943
+
944
+ assert data["test"] == "time_nullable"
945
+ assert data["success"] is True, (
946
+ f"time_nullable failed: error={data.get('error')}"
947
+ )
948
+ assert data["with_time_is_time"] is True
949
+ assert data["no_time_is_none"] is True
950
+
951
+ def test_time_orm_session(self, dev_server):
952
+ """Test Time via ORM session."""
953
+ port = dev_server
954
+ response = requests.get(f"http://localhost:{port}/time-orm")
955
+
956
+ assert response.status_code == 200, f"time_orm failed: {response.json()}"
957
+ data = response.json()
958
+
959
+ assert data["test"] == "time_orm"
960
+ assert data["success"] is True, f"time_orm failed: error={data.get('error')}"
961
+ assert data["entry_title"] == "Time Test Entry"
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.7"
925
+ version = "0.3.9"
926
926
  source = { editable = "." }
927
927
  dependencies = [
928
928
  { name = "httpx" },