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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed


## [0.3.8]

### Added

- `Time` column type support ([#18](https://github.com/CollierKing/sqlalchemy-cloudflare-d1/issues/18))
- Added `D1Time` type processor that converts Python `time` objects to ISO 8601 strings on bind and parses them back on result
- Supports nullable time columns, time filtering/comparison, and ORM usage
- Works in both REST API and Worker modes

### Changed

- Updated README Type Mapping table to document all custom type processors (`D1Boolean`, `D1Date`, `D1Time`, `D1DateTime`, `D1LargeBinary`)


## [0.3.7]

### Added
Expand Down
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -374,10 +374,11 @@ This dialect has some limitations due to D1's REST API nature:
| `Text` | `TEXT` | |
| `Float` | `REAL` | |
| `Numeric` | `NUMERIC` | |
| `Boolean` | `INTEGER` | Stored as 0/1 |
| `DateTime` | `TEXT` | ISO format string |
| `Date` | `TEXT` | ISO format string |
| `Time` | `TEXT` | ISO format string |
| `Boolean` | `INTEGER` | Stored as 0/1, auto-converted via `D1Boolean` |
| `DateTime` | `TEXT` | ISO 8601 string, auto-converted via `D1DateTime` |
| `Date` | `TEXT` | ISO 8601 string, auto-converted via `D1Date` |
| `Time` | `TEXT` | ISO 8601 string, auto-converted via `D1Time` |
| `LargeBinary` | `BLOB` | Base64-encoded, auto-converted via `D1LargeBinary` |

## Error Handling

Expand Down
232 changes: 232 additions & 0 deletions examples/workers/src/entry.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,13 @@ async def fetch(self, request, env):
return await self.test_date_nullable()
elif path == "date-orm":
return await self.test_date_orm()
# Time column tests (GitHub issue #18)
elif path == "time-basic":
return await self.test_time_basic()
elif path == "time-nullable":
return await self.test_time_nullable()
elif path == "time-orm":
return await self.test_time_orm()
else:
return await self.index()

Expand Down Expand Up @@ -196,6 +203,9 @@ async def index(self):
"/date-basic": "Test Date column insert/retrieve",
"/date-nullable": "Test nullable Date columns",
"/date-orm": "Test Date via ORM session",
"/time-basic": "Test Time column insert/retrieve",
"/time-nullable": "Test nullable Time columns",
"/time-orm": "Test Time via ORM session",
},
"package": "sqlalchemy-cloudflare-d1",
"connection_type": "WorkerConnection (D1 binding)",
Expand Down Expand Up @@ -3402,3 +3412,225 @@ class Event(Base):
},
status=500,
)

# MARK: - Time Column Tests

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

table_name = f"test_time_{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_time", Time),
)

metadata.create_all(engine)

time_value = time(14, 30, 45)

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

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

metadata.drop_all(engine)

success = (
row is not None
and row[0] == "Test"
and isinstance(row[1], time)
and row[1].hour == 14
and row[1].minute == 30
and row[1].second == 45
)

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

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

table_name = f"test_time_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_time", Time, nullable=True),
)

metadata.create_all(engine)

with engine.connect() as conn:
conn.execute(
test_table.insert().values(
title="With Time",
event_time=time(9, 0, 0),
)
)
conn.execute(
test_table.insert().values(title="No Time", event_time=None)
)
conn.commit()

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

metadata.drop_all(engine)

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

return Response.json(
{
"test": "time_nullable",
"success": success,
"with_time_is_time": isinstance(rows[0][1], time)
if rows
else False,
"no_time_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": "time_nullable", "success": False, "error": str(e)},
status=500,
)

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

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

try:

class Base(DeclarativeBase):
pass

class Schedule(Base):
__tablename__ = table_name
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
title: Mapped[str] = mapped_column(String(127))
start_time: Mapped[time] = mapped_column(Time)

Base.metadata.create_all(engine)

test_time = time(14, 30, 45)

with Session(engine) as session:
entry = Schedule(
title="Time Test Entry",
start_time=test_time,
)
session.add(entry)
session.commit()
session.refresh(entry)
entry_title = entry.title
start_time_is_time = isinstance(entry.start_time, time)
start_time_value = entry.start_time

Base.metadata.drop_all(engine)

success = start_time_is_time and start_time_value == test_time

return Response.json(
{
"test": "time_orm",
"success": success,
"entry_title": entry_title,
"start_time_is_time": start_time_is_time,
}
)
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": "time_orm",
"success": False,
"error": str(e),
"error_type": type(e).__name__,
},
status=500,
)
3 changes: 2 additions & 1 deletion examples/workers/uv.lock

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

2 changes: 1 addition & 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.7"
version = "0.3.8"
description = "A SQLAlchemy dialect for Cloudflare's D1 Serverless SQLite Database"
readme = "README.md"
authors = [
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, date
from datetime import date, datetime, time
from typing import Any, Callable, Dict, List, Optional

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

Expand Down Expand Up @@ -160,6 +161,53 @@ def process(value: Any) -> Optional[date]:
return process


# MARK: - Time Type Processor


class D1Time(Time):
"""Custom Time type for Cloudflare D1.

D1 does not accept Python time objects as bind parameters - they arrive
as JS `object` type and raise D1_TYPE_ERROR. This type processor converts
time 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 time to ISO 8601 string for D1."""

# MARK: - bind_processor
def process(value: Any) -> Optional[str]:
if value is None:
return None
if isinstance(value, time):
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[time]]:
"""Convert ISO 8601 string from D1 back to Python time."""

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

return process


# MARK: - DateTime Type Processor


Expand Down Expand Up @@ -245,6 +293,7 @@ class CloudflareD1Dialect(default.DefaultDialect):
Date: D1Date,
DateTime: D1DateTime,
LargeBinary: D1LargeBinary,
Time: D1Time,
}

# Reserved words (SQLite keywords)
Expand Down
Loading