Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed


## [0.3.7]

### Added

- `Date` column type support ([#15](https://github.com/CollierKing/sqlalchemy-cloudflare-d1/issues/15))
- Added `D1Date` type processor that converts Python `date` objects to ISO 8601 strings on bind and parses them back on result
- Supports nullable date columns, date filtering/comparison, and ORM usage
- Works in both REST API and Worker modes
- Thanks to [@xelandernt](https://github.com/xelandernt) for the contribution ([PR #16](https://github.com/CollierKing/sqlalchemy-cloudflare-d1/pull/16))


## [0.3.6]

### Fixed
Expand Down
227 changes: 227 additions & 0 deletions examples/workers/src/entry.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,13 @@ async def fetch(self, request, env):
return await self.test_datetime_nullable()
elif path == "datetime-orm":
return await self.test_datetime_orm()
# Date column tests (GitHub issue #15)
elif path == "date-basic":
return await self.test_date_basic()
elif path == "date-nullable":
return await self.test_date_nullable()
elif path == "date-orm":
return await self.test_date_orm()
else:
return await self.index()

Expand Down Expand Up @@ -186,6 +193,9 @@ async def index(self):
"/datetime-non-utc": "Test DateTime with non-UTC timezone",
"/datetime-nullable": "Test nullable DateTime columns",
"/datetime-orm": "Test DateTime via ORM session",
"/date-basic": "Test Date column insert/retrieve",
"/date-nullable": "Test nullable Date columns",
"/date-orm": "Test Date via ORM session",
},
"package": "sqlalchemy-cloudflare-d1",
"connection_type": "WorkerConnection (D1 binding)",
Expand Down Expand Up @@ -3175,3 +3185,220 @@ class News(Base):
},
status=500,
)

async def test_date_basic(self):
"""Test Date column insert and retrieve."""
from datetime import date
from sqlalchemy import (
Column,
Date,
Integer,
MetaData,
String,
Table,
select,
)

table_name = f"test_date_{uuid.uuid4().hex[:8]}"

try:
engine = self.get_engine()
metadata = MetaData()

test_table = Table(
table_name,
metadata,
Column("id", Integer, primary_key=True),
Column("title", String(127)),
Column("birth_date", Date),
)

metadata.create_all(engine)

date_value = date(2025, 12, 29)

with engine.connect() as conn:
conn.execute(
test_table.insert().values(title="Test", birth_date=date_value)
)
conn.commit()

result = conn.execute(
select(test_table.c.title, test_table.c.birth_date)
)
row = result.fetchone()

metadata.drop_all(engine)

success = (
row is not None
and row[0] == "Test"
and isinstance(row[1], date)
and row[1].year == 2025
and row[1].month == 12
and row[1].day == 29
)

return Response.json(
{
"test": "date_basic",
"success": success,
"title": row[0] if row else None,
"birth_date_type": type(row[1]).__name__ if row else None,
"year": row[1].year if row and isinstance(row[1], date) else None,
}
)
except Exception as e:
try:
metadata.drop_all(engine)
except Exception:
pass
return Response.json(
{"test": "date_basic", "success": False, "error": str(e)},
status=500,
)

async def test_date_nullable(self):
"""Test nullable Date columns handle NULL correctly."""
from datetime import date
from sqlalchemy import (
Column,
Date,
Integer,
MetaData,
String,
Table,
select,
)

table_name = f"test_date_null_{uuid.uuid4().hex[:8]}"

try:
engine = self.get_engine()
metadata = MetaData()

test_table = Table(
table_name,
metadata,
Column("id", Integer, primary_key=True),
Column("title", String(127)),
Column("event_date", Date, nullable=True),
)

metadata.create_all(engine)

with engine.connect() as conn:
conn.execute(
test_table.insert().values(
title="With Date",
event_date=date(2025, 1, 1),
)
)
conn.execute(
test_table.insert().values(title="No Date", event_date=None)
)
conn.commit()

result = conn.execute(
select(test_table.c.title, test_table.c.event_date).order_by(
test_table.c.id
)
)
rows = result.fetchall()

metadata.drop_all(engine)

success = (
len(rows) == 2
and rows[0][0] == "With Date"
and isinstance(rows[0][1], date)
and rows[1][0] == "No Date"
and rows[1][1] is None
)

return Response.json(
{
"test": "date_nullable",
"success": success,
"with_date_is_date": isinstance(rows[0][1], date)
if rows
else False,
"no_date_is_none": rows[1][1] is None if len(rows) > 1 else False,
}
)
except Exception as e:
try:
metadata.drop_all(engine)
except Exception:
pass
return Response.json(
{"test": "date_nullable", "success": False, "error": str(e)},
status=500,
)

async def test_date_orm(self):
"""Test Date via ORM session."""
from datetime import date
from sqlalchemy import Integer, String, Date
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, Session

engine = create_engine_from_binding(self.env.DB)
table_name = f"test_date_orm_{uuid.uuid4().hex[:8]}"

try:

class Base(DeclarativeBase):
pass

class Event(Base):
__tablename__ = table_name
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
title: Mapped[str] = mapped_column(String(127))
event_date: Mapped[date] = mapped_column(Date)

Base.metadata.create_all(engine)

test_date = date(2025, 12, 29)

with Session(engine) as session:
event = Event(
title="Date Test Event",
event_date=test_date,
)
session.add(event)
session.commit()
session.refresh(event)
event_title = event.title
event_date_is_date = isinstance(event.event_date, date)
event_date_value = event.event_date

Base.metadata.drop_all(engine)

success = event_date_is_date and event_date_value == test_date

return Response.json(
{
"test": "date_orm",
"success": success,
"event_title": event_title,
"event_date_is_date": event_date_is_date,
}
)
except Exception as e:
try:
from sqlalchemy import MetaData, Table

md = MetaData()
Table(table_name, md)
md.drop_all(engine)
except Exception:
pass
return Response.json(
{
"test": "date_orm",
"success": False,
"error": str(e),
"error_type": type(e).__name__,
},
status=500,
)
6 changes: 3 additions & 3 deletions examples/workers/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "sqlalchemy-cloudflare-d1"
version = "0.3.6"
version = "0.3.7"
description = "A SQLAlchemy dialect for Cloudflare's D1 Serverless SQLite Database"
readme = "README.md"
authors = [
Expand Down Expand Up @@ -83,6 +83,7 @@ addopts = "--ignore=tests/integration"
[dependency-groups]
dev = [
"codespell>=2.4.1",
"greenlet>=3.2.3",
"mypy>=1.17.0",
"pandas>=2.0.0",
"pytest>=8.4.1",
Expand Down
51 changes: 50 additions & 1 deletion src/sqlalchemy_cloudflare_d1/dialect.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"""

import base64
from datetime import datetime
from datetime import datetime, date
from typing import Any, Callable, Dict, List, Optional

from sqlalchemy.engine import default
Expand All @@ -16,6 +16,7 @@
NUMERIC,
REAL,
TEXT,
Date,
)
from sqlalchemy import text

Expand Down Expand Up @@ -112,6 +113,53 @@ def process(value: Any) -> Optional[bytes]:
return process


# MARK: - Date Type Processor


class D1Date(Date):
"""Custom Date type for Cloudflare D1.

D1 does not accept Python date objects as bind parameters - they arrive
as JS `object` type and raise D1_TYPE_ERROR. This type processor converts
date objects to ISO 8601 strings on bind and parses them back on result.
"""

def bind_processor(self, dialect: Dialect) -> Callable[[Any], Optional[str]]:
"""Convert Python date to ISO 8601 string for D1."""

# MARK: - bind_processor
def process(value: Any) -> Optional[str]:
if value is None:
return None
if isinstance(value, date):
return value.isoformat()
if isinstance(value, str):
return value
return str(value)

return process

def result_processor(
self, dialect: Dialect, coltype: Any
) -> Callable[[Any], Optional[date]]:
"""Convert ISO 8601 string from D1 back to Python date."""

# MARK: - result_processor
def process(value: Any) -> Optional[date]:
if value is None:
return None
if isinstance(value, date):
return value
if isinstance(value, str):
try:
return date.fromisoformat(value)
except ValueError:
return value
return value

return process


# MARK: - DateTime Type Processor


Expand Down Expand Up @@ -194,6 +242,7 @@ class CloudflareD1Dialect(default.DefaultDialect):
# Type mapping from SQLAlchemy to D1/SQLite
colspecs = {
Boolean: D1Boolean,
Date: D1Date,
DateTime: D1DateTime,
LargeBinary: D1LargeBinary,
}
Expand Down
Loading