From 6004e50d2833aa814ef99c37e5411646a9595bd9 Mon Sep 17 00:00:00 2001 From: Collier King Date: Sat, 14 Feb 2026 15:08:33 -0600 Subject: [PATCH] Add Time type support, update README type mapping table (Issue #18) Add D1Time type processor for sqlalchemy.Time columns, preventing D1_TYPE_ERROR when inserting Python time objects. Update README type mapping table to document all custom type processors. Bump to v0.3.8. --- CHANGELOG.md | 14 ++ README.md | 9 +- examples/workers/src/entry.py | 232 ++++++++++++++++++ examples/workers/uv.lock | 3 +- pyproject.toml | 2 +- src/sqlalchemy_cloudflare_d1/dialect.py | 51 +++- tests/integration/test_restapi_integration.py | 166 +++++++++++++ tests/integration/test_worker_integration.py | 50 ++++ uv.lock | 2 +- 9 files changed, 521 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 49ed263..c0b76b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index a1732e7..ab62d0f 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/examples/workers/src/entry.py b/examples/workers/src/entry.py index 1d967d5..d8a3235 100644 --- a/examples/workers/src/entry.py +++ b/examples/workers/src/entry.py @@ -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() @@ -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)", @@ -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, + ) diff --git a/examples/workers/uv.lock b/examples/workers/uv.lock index 25825d2..d70f25f 100644 --- a/examples/workers/uv.lock +++ b/examples/workers/uv.lock @@ -614,7 +614,7 @@ wheels = [ [[package]] name = "sqlalchemy-cloudflare-d1" -version = "0.3.7" +version = "0.3.8" source = { editable = "../../" } dependencies = [ { name = "httpx" }, @@ -640,6 +640,7 @@ provides-extras = ["async", "dev"] [package.metadata.requires-dev] dev = [ { name = "codespell", specifier = ">=2.4.1" }, + { name = "greenlet", specifier = ">=3.2.3" }, { name = "mypy", specifier = ">=1.17.0" }, { name = "pandas", specifier = ">=2.0.0" }, { name = "pytest", specifier = ">=8.4.1" }, diff --git a/pyproject.toml b/pyproject.toml index 8a2ddd8..527167d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 = [ diff --git a/src/sqlalchemy_cloudflare_d1/dialect.py b/src/sqlalchemy_cloudflare_d1/dialect.py index 49a5416..e9da755 100644 --- a/src/sqlalchemy_cloudflare_d1/dialect.py +++ b/src/sqlalchemy_cloudflare_d1/dialect.py @@ -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 @@ -17,6 +17,7 @@ REAL, TEXT, Date, + Time, ) from sqlalchemy import text @@ -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 @@ -245,6 +293,7 @@ class CloudflareD1Dialect(default.DefaultDialect): Date: D1Date, DateTime: D1DateTime, LargeBinary: D1LargeBinary, + Time: D1Time, } # Reserved words (SQLite keywords) diff --git a/tests/integration/test_restapi_integration.py b/tests/integration/test_restapi_integration.py index ada0e05..761af46 100644 --- a/tests/integration/test_restapi_integration.py +++ b/tests/integration/test_restapi_integration.py @@ -2849,5 +2849,171 @@ def test_datetime_filter_query(self, d1_engine, test_table_name): metadata.drop_all(d1_engine) +# MARK: - Time Column Tests (Issue #18) + + +class TestTimeColumn: + """Test Time column handling. + + D1 does not accept Python time objects as bind parameters. The + D1Time type processor converts times to ISO 8601 strings on bind + and parses them back on result. + """ + + def test_time_insert_and_retrieve(self, d1_engine, test_table_name): + """Test that Time columns can store and retrieve time values.""" + from datetime import time + from sqlalchemy import Time + + metadata = MetaData() + test_table = Table( + test_table_name, + metadata, + Column("id", Integer, primary_key=True), + Column("title", String(127)), + Column("event_time", Time), + ) + + metadata.create_all(d1_engine) + + try: + time_value = time(14, 30, 45) + + with d1_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() + + assert row is not None + assert row[0] == "Test" + assert isinstance(row[1], time) + assert row[1].hour == 14 + assert row[1].minute == 30 + assert row[1].second == 45 + finally: + metadata.drop_all(d1_engine) + + def test_time_nullable(self, d1_engine, test_table_name): + """Test nullable Time columns handle NULL correctly.""" + from datetime import time + from sqlalchemy import Time + + metadata = MetaData() + test_table = Table( + test_table_name, + metadata, + Column("id", Integer, primary_key=True), + Column("title", String(127)), + Column("event_time", Time, nullable=True), + ) + + metadata.create_all(d1_engine) + + try: + with d1_engine.connect() as conn: + conn.execute( + test_table.insert().values(title="No Time", event_time=None) + ) + conn.execute( + test_table.insert().values( + title="With Time", event_time=time(9, 0, 0) + ) + ) + conn.commit() + + result = conn.execute( + select(test_table.c.title, test_table.c.event_time) + ) + rows = result.fetchall() + + assert len(rows) == 2 + assert rows[0][1] is None + assert isinstance(rows[1][1], time) + finally: + metadata.drop_all(d1_engine) + + def test_time_orm_session(self, d1_engine): + """Test Time via ORM session.""" + from datetime import time + from sqlalchemy import Time + from sqlalchemy.orm import Mapped, Session, declarative_base, mapped_column + + Base = declarative_base() + + class Schedule(Base): + __tablename__ = f"schedules_{uuid.uuid4().hex[:8]}" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + title: Mapped[str] = mapped_column(String(127)) + start_time: Mapped[time] = mapped_column(Time) + + Base.metadata.create_all(d1_engine) + + try: + test_time = time(14, 30, 45) + + with Session(d1_engine) as session: + entry = Schedule(title="Test Entry", start_time=test_time) + session.add(entry) + session.commit() + + retrieved = session.query(Schedule).first() + + assert retrieved is not None + assert retrieved.title == "Test Entry" + assert isinstance(retrieved.start_time, time) + assert retrieved.start_time == test_time + finally: + Base.metadata.drop_all(d1_engine) + + def test_time_filter_query(self, d1_engine, test_table_name): + """Test filtering by Time column values.""" + from datetime import time + from sqlalchemy import Time + + metadata = MetaData() + test_table = Table( + test_table_name, + metadata, + Column("id", Integer, primary_key=True), + Column("title", String(127)), + Column("event_time", Time), + ) + + metadata.create_all(d1_engine) + + try: + time_morning = time(8, 0, 0) + time_afternoon = time(15, 30, 0) + + with d1_engine.connect() as conn: + conn.execute( + test_table.insert().values(title="Morning", event_time=time_morning) + ) + conn.execute( + test_table.insert().values( + title="Afternoon", event_time=time_afternoon + ) + ) + conn.commit() + + cutoff = time(12, 0, 0).isoformat() + result = conn.execute( + select(test_table.c.title).where(test_table.c.event_time > cutoff) + ) + rows = result.fetchall() + + assert len(rows) == 1 + assert rows[0][0] == "Afternoon" + finally: + metadata.drop_all(d1_engine) + + if __name__ == "__main__": pytest.main([__file__, "-v", "-s"]) diff --git a/tests/integration/test_worker_integration.py b/tests/integration/test_worker_integration.py index daf7beb..f0462a4 100644 --- a/tests/integration/test_worker_integration.py +++ b/tests/integration/test_worker_integration.py @@ -910,3 +910,53 @@ def test_datetime_orm_session(self, dev_server): assert data["origin_is_datetime"] is True assert data["indexed_is_datetime"] is True assert data["inserted_is_datetime"] is True + + +# MARK: - Time Column Tests (Issue #18) + + +class TestWorkerTimeColumn: + """Test Time column handling via Worker endpoints. + + These tests mirror the TestTimeColumn tests in test_restapi_integration.py. + """ + + def test_time_insert_and_retrieve(self, dev_server): + """Test Time column insert and retrieve.""" + port = dev_server + response = requests.get(f"http://localhost:{port}/time-basic") + + assert response.status_code == 200, f"time_basic failed: {response.json()}" + data = response.json() + + assert data["test"] == "time_basic" + assert data["success"] is True, f"time_basic failed: error={data.get('error')}" + assert data["event_time_type"] == "time" + + def test_time_nullable(self, dev_server): + """Test nullable Time columns handle NULL correctly.""" + port = dev_server + response = requests.get(f"http://localhost:{port}/time-nullable") + + assert response.status_code == 200, f"time_nullable failed: {response.json()}" + data = response.json() + + assert data["test"] == "time_nullable" + assert data["success"] is True, ( + f"time_nullable failed: error={data.get('error')}" + ) + assert data["with_time_is_time"] is True + assert data["no_time_is_none"] is True + + def test_time_orm_session(self, dev_server): + """Test Time via ORM session.""" + port = dev_server + response = requests.get(f"http://localhost:{port}/time-orm") + + assert response.status_code == 200, f"time_orm failed: {response.json()}" + data = response.json() + + assert data["test"] == "time_orm" + assert data["success"] is True, f"time_orm failed: error={data.get('error')}" + assert data["entry_title"] == "Time Test Entry" + assert data["start_time_is_time"] is True diff --git a/uv.lock b/uv.lock index 8c5dbc8..929c813 100644 --- a/uv.lock +++ b/uv.lock @@ -922,7 +922,7 @@ wheels = [ [[package]] name = "sqlalchemy-cloudflare-d1" -version = "0.3.7" +version = "0.3.8" source = { editable = "." } dependencies = [ { name = "httpx" },