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.
- {sqlalchemy_cloudflare_d1-0.3.7 → sqlalchemy_cloudflare_d1-0.3.9}/CHANGELOG.md +25 -0
- {sqlalchemy_cloudflare_d1-0.3.7 → sqlalchemy_cloudflare_d1-0.3.9}/PKG-INFO +6 -5
- {sqlalchemy_cloudflare_d1-0.3.7 → sqlalchemy_cloudflare_d1-0.3.9}/README.md +5 -4
- {sqlalchemy_cloudflare_d1-0.3.7 → sqlalchemy_cloudflare_d1-0.3.9}/examples/workers/src/entry.py +362 -0
- {sqlalchemy_cloudflare_d1-0.3.7 → sqlalchemy_cloudflare_d1-0.3.9}/examples/workers/uv.lock +2 -1
- {sqlalchemy_cloudflare_d1-0.3.7 → sqlalchemy_cloudflare_d1-0.3.9}/pyproject.toml +1 -1
- {sqlalchemy_cloudflare_d1-0.3.7 → sqlalchemy_cloudflare_d1-0.3.9}/src/sqlalchemy_cloudflare_d1/connection.py +14 -4
- {sqlalchemy_cloudflare_d1-0.3.7 → sqlalchemy_cloudflare_d1-0.3.9}/src/sqlalchemy_cloudflare_d1/dialect.py +50 -1
- sqlalchemy_cloudflare_d1-0.3.9/tests/integration/test_base_url_integration.py +175 -0
- {sqlalchemy_cloudflare_d1-0.3.7 → sqlalchemy_cloudflare_d1-0.3.9}/tests/integration/test_restapi_integration.py +166 -0
- {sqlalchemy_cloudflare_d1-0.3.7 → sqlalchemy_cloudflare_d1-0.3.9}/tests/integration/test_worker_integration.py +110 -0
- {sqlalchemy_cloudflare_d1-0.3.7 → sqlalchemy_cloudflare_d1-0.3.9}/tests/unit/test_dialect.py +60 -0
- {sqlalchemy_cloudflare_d1-0.3.7 → sqlalchemy_cloudflare_d1-0.3.9}/uv.lock +1 -1
- {sqlalchemy_cloudflare_d1-0.3.7 → sqlalchemy_cloudflare_d1-0.3.9}/.github/DEVELOPMENT.md +0 -0
- {sqlalchemy_cloudflare_d1-0.3.7 → sqlalchemy_cloudflare_d1-0.3.9}/.github/workflows/ci.yml +0 -0
- {sqlalchemy_cloudflare_d1-0.3.7 → sqlalchemy_cloudflare_d1-0.3.9}/.github/workflows/release.yml +0 -0
- {sqlalchemy_cloudflare_d1-0.3.7 → sqlalchemy_cloudflare_d1-0.3.9}/.gitignore +0 -0
- {sqlalchemy_cloudflare_d1-0.3.7 → sqlalchemy_cloudflare_d1-0.3.9}/.pre-commit-config.yaml +0 -0
- {sqlalchemy_cloudflare_d1-0.3.7 → sqlalchemy_cloudflare_d1-0.3.9}/.python-version +0 -0
- {sqlalchemy_cloudflare_d1-0.3.7 → sqlalchemy_cloudflare_d1-0.3.9}/LICENSE +0 -0
- {sqlalchemy_cloudflare_d1-0.3.7 → sqlalchemy_cloudflare_d1-0.3.9}/Makefile +0 -0
- {sqlalchemy_cloudflare_d1-0.3.7 → sqlalchemy_cloudflare_d1-0.3.9}/assets/d1-logo.png +0 -0
- {sqlalchemy_cloudflare_d1-0.3.7 → sqlalchemy_cloudflare_d1-0.3.9}/assets/sqlalchemy-logo.png +0 -0
- {sqlalchemy_cloudflare_d1-0.3.7 → sqlalchemy_cloudflare_d1-0.3.9}/examples/restapi/example.py +0 -0
- {sqlalchemy_cloudflare_d1-0.3.7 → sqlalchemy_cloudflare_d1-0.3.9}/examples/workers/README.md +0 -0
- {sqlalchemy_cloudflare_d1-0.3.7 → sqlalchemy_cloudflare_d1-0.3.9}/examples/workers/db_init.sql +0 -0
- {sqlalchemy_cloudflare_d1-0.3.7 → sqlalchemy_cloudflare_d1-0.3.9}/examples/workers/pyproject.toml +0 -0
- {sqlalchemy_cloudflare_d1-0.3.7 → sqlalchemy_cloudflare_d1-0.3.9}/examples/workers/wrangler.jsonc +0 -0
- {sqlalchemy_cloudflare_d1-0.3.7 → sqlalchemy_cloudflare_d1-0.3.9}/src/sqlalchemy_cloudflare_d1/__init__.py +0 -0
- {sqlalchemy_cloudflare_d1-0.3.7 → sqlalchemy_cloudflare_d1-0.3.9}/src/sqlalchemy_cloudflare_d1/compiler.py +0 -0
- {sqlalchemy_cloudflare_d1-0.3.7 → sqlalchemy_cloudflare_d1-0.3.9}/src/sqlalchemy_cloudflare_d1/dialect_async.py +0 -0
- {sqlalchemy_cloudflare_d1-0.3.7 → sqlalchemy_cloudflare_d1-0.3.9}/src/sqlalchemy_cloudflare_d1/py.typed +0 -0
- {sqlalchemy_cloudflare_d1-0.3.7 → sqlalchemy_cloudflare_d1-0.3.9}/tests/__init__.py +0 -0
- {sqlalchemy_cloudflare_d1-0.3.7 → sqlalchemy_cloudflare_d1-0.3.9}/tests/conftest.py +0 -0
- {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.
|
|
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
|
|
436
|
-
| `Date` | `TEXT` | ISO
|
|
437
|
-
| `Time` | `TEXT` | ISO
|
|
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
|
|
379
|
-
| `Date` | `TEXT` | ISO
|
|
380
|
-
| `Time` | `TEXT` | ISO
|
|
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
|
|
{sqlalchemy_cloudflare_d1-0.3.7 → sqlalchemy_cloudflare_d1-0.3.9}/examples/workers/src/entry.py
RENAMED
|
@@ -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.
|
|
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" },
|
|
@@ -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(
|
|
@@ -3,7 +3,7 @@ SQLAlchemy dialect for Cloudflare D1.
|
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
5
|
import base64
|
|
6
|
-
from datetime import datetime,
|
|
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
|
+
)
|
{sqlalchemy_cloudflare_d1-0.3.7 → 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.7 → 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
|
{sqlalchemy_cloudflare_d1-0.3.7 → sqlalchemy_cloudflare_d1-0.3.9}/assets/sqlalchemy-logo.png
RENAMED
|
File without changes
|
{sqlalchemy_cloudflare_d1-0.3.7 → sqlalchemy_cloudflare_d1-0.3.9}/examples/restapi/example.py
RENAMED
|
File without changes
|
{sqlalchemy_cloudflare_d1-0.3.7 → sqlalchemy_cloudflare_d1-0.3.9}/examples/workers/README.md
RENAMED
|
File without changes
|
{sqlalchemy_cloudflare_d1-0.3.7 → sqlalchemy_cloudflare_d1-0.3.9}/examples/workers/db_init.sql
RENAMED
|
File without changes
|
{sqlalchemy_cloudflare_d1-0.3.7 → sqlalchemy_cloudflare_d1-0.3.9}/examples/workers/pyproject.toml
RENAMED
|
File without changes
|
{sqlalchemy_cloudflare_d1-0.3.7 → 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
|