diff --git a/examples/workers/src/entry.py b/examples/workers/src/entry.py index 7733fe2..1d967d5 100644 --- a/examples/workers/src/entry.py +++ b/examples/workers/src/entry.py @@ -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() @@ -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)", @@ -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, + ) diff --git a/examples/workers/uv.lock b/examples/workers/uv.lock index 4a4b9ef..e020b1b 100644 --- a/examples/workers/uv.lock +++ b/examples/workers/uv.lock @@ -115,7 +115,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f8/0a/a3871375c7b9727edaeeea994bfff7c63ff7804c9829c19309ba2e058807/greenlet-3.3.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:b01548f6e0b9e9784a2c99c5651e5dc89ffcbe870bc5fb2e5ef864e9cc6b5dcb", size = 276379, upload-time = "2025-12-04T14:23:30.498Z" }, { url = "https://files.pythonhosted.org/packages/43/ab/7ebfe34dce8b87be0d11dae91acbf76f7b8246bf9d6b319c741f99fa59c6/greenlet-3.3.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:349345b770dc88f81506c6861d22a6ccd422207829d2c854ae2af8025af303e3", size = 597294, upload-time = "2025-12-04T14:50:06.847Z" }, { url = "https://files.pythonhosted.org/packages/a4/39/f1c8da50024feecd0793dbd5e08f526809b8ab5609224a2da40aad3a7641/greenlet-3.3.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e8e18ed6995e9e2c0b4ed264d2cf89260ab3ac7e13555b8032b25a74c6d18655", size = 607742, upload-time = "2025-12-04T14:57:42.349Z" }, - { url = "https://files.pythonhosted.org/packages/77/cb/43692bcd5f7a0da6ec0ec6d58ee7cddb606d055ce94a62ac9b1aa481e969/greenlet-3.3.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c024b1e5696626890038e34f76140ed1daf858e37496d33f2af57f06189e70d7", size = 622297, upload-time = "2025-12-04T15:07:13.552Z" }, { url = "https://files.pythonhosted.org/packages/75/b0/6bde0b1011a60782108c01de5913c588cf51a839174538d266de15e4bf4d/greenlet-3.3.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:047ab3df20ede6a57c35c14bf5200fcf04039d50f908270d3f9a7a82064f543b", size = 609885, upload-time = "2025-12-04T14:26:02.368Z" }, { url = "https://files.pythonhosted.org/packages/49/0e/49b46ac39f931f59f987b7cd9f34bfec8ef81d2a1e6e00682f55be5de9f4/greenlet-3.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2d9ad37fc657b1102ec880e637cccf20191581f75c64087a549e66c57e1ceb53", size = 1567424, upload-time = "2025-12-04T15:04:23.757Z" }, { url = "https://files.pythonhosted.org/packages/05/f5/49a9ac2dff7f10091935def9165c90236d8f175afb27cbed38fb1d61ab6b/greenlet-3.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:83cd0e36932e0e7f36a64b732a6f60c2fc2df28c351bae79fbaf4f8092fe7614", size = 1636017, upload-time = "2025-12-04T14:27:29.688Z" }, @@ -123,7 +122,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/02/2f/28592176381b9ab2cafa12829ba7b472d177f3acc35d8fbcf3673d966fff/greenlet-3.3.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:a1e41a81c7e2825822f4e068c48cb2196002362619e2d70b148f20a831c00739", size = 275140, upload-time = "2025-12-04T14:23:01.282Z" }, { url = "https://files.pythonhosted.org/packages/2c/80/fbe937bf81e9fca98c981fe499e59a3f45df2a04da0baa5c2be0dca0d329/greenlet-3.3.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9f515a47d02da4d30caaa85b69474cec77b7929b2e936ff7fb853d42f4bf8808", size = 599219, upload-time = "2025-12-04T14:50:08.309Z" }, { url = "https://files.pythonhosted.org/packages/c2/ff/7c985128f0514271b8268476af89aee6866df5eec04ac17dcfbc676213df/greenlet-3.3.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d2d9fd66bfadf230b385fdc90426fcd6eb64db54b40c495b72ac0feb5766c54", size = 610211, upload-time = "2025-12-04T14:57:43.968Z" }, - { url = "https://files.pythonhosted.org/packages/79/07/c47a82d881319ec18a4510bb30463ed6891f2ad2c1901ed5ec23d3de351f/greenlet-3.3.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30a6e28487a790417d036088b3bcb3f3ac7d8babaa7d0139edbaddebf3af9492", size = 624311, upload-time = "2025-12-04T15:07:14.697Z" }, { url = "https://files.pythonhosted.org/packages/fd/8e/424b8c6e78bd9837d14ff7df01a9829fc883ba2ab4ea787d4f848435f23f/greenlet-3.3.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:087ea5e004437321508a8d6f20efc4cfec5e3c30118e1417ea96ed1d93950527", size = 612833, upload-time = "2025-12-04T14:26:03.669Z" }, { url = "https://files.pythonhosted.org/packages/b5/ba/56699ff9b7c76ca12f1cdc27a886d0f81f2189c3455ff9f65246780f713d/greenlet-3.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ab97cf74045343f6c60a39913fa59710e4bd26a536ce7ab2397adf8b27e67c39", size = 1567256, upload-time = "2025-12-04T15:04:25.276Z" }, { url = "https://files.pythonhosted.org/packages/1e/37/f31136132967982d698c71a281a8901daf1a8fbab935dce7c0cf15f942cc/greenlet-3.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5375d2e23184629112ca1ea89a53389dddbffcf417dad40125713d88eb5f96e8", size = 1636483, upload-time = "2025-12-04T14:27:30.804Z" }, @@ -131,7 +129,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d7/7c/f0a6d0ede2c7bf092d00bc83ad5bafb7e6ec9b4aab2fbdfa6f134dc73327/greenlet-3.3.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:60c2ef0f578afb3c8d92ea07ad327f9a062547137afe91f38408f08aacab667f", size = 275671, upload-time = "2025-12-04T14:23:05.267Z" }, { url = "https://files.pythonhosted.org/packages/44/06/dac639ae1a50f5969d82d2e3dd9767d30d6dbdbab0e1a54010c8fe90263c/greenlet-3.3.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a5d554d0712ba1de0a6c94c640f7aeba3f85b3a6e1f2899c11c2c0428da9365", size = 646360, upload-time = "2025-12-04T14:50:10.026Z" }, { url = "https://files.pythonhosted.org/packages/e0/94/0fb76fe6c5369fba9bf98529ada6f4c3a1adf19e406a47332245ef0eb357/greenlet-3.3.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3a898b1e9c5f7307ebbde4102908e6cbfcb9ea16284a3abe15cab996bee8b9b3", size = 658160, upload-time = "2025-12-04T14:57:45.41Z" }, - { url = "https://files.pythonhosted.org/packages/93/79/d2c70cae6e823fac36c3bbc9077962105052b7ef81db2f01ec3b9bf17e2b/greenlet-3.3.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dcd2bdbd444ff340e8d6bdf54d2f206ccddbb3ccfdcd3c25bf4afaa7b8f0cf45", size = 671388, upload-time = "2025-12-04T15:07:15.789Z" }, { url = "https://files.pythonhosted.org/packages/b8/14/bab308fc2c1b5228c3224ec2bf928ce2e4d21d8046c161e44a2012b5203e/greenlet-3.3.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5773edda4dc00e173820722711d043799d3adb4f01731f40619e07ea2750b955", size = 660166, upload-time = "2025-12-04T14:26:05.099Z" }, { url = "https://files.pythonhosted.org/packages/4b/d2/91465d39164eaa0085177f61983d80ffe746c5a1860f009811d498e7259c/greenlet-3.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ac0549373982b36d5fd5d30beb8a7a33ee541ff98d2b502714a09f1169f31b55", size = 1615193, upload-time = "2025-12-04T15:04:27.041Z" }, { url = "https://files.pythonhosted.org/packages/42/1b/83d110a37044b92423084d52d5d5a3b3a73cafb51b547e6d7366ff62eff1/greenlet-3.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d198d2d977460358c3b3a4dc844f875d1adb33817f0613f663a656f463764ccc", size = 1683653, upload-time = "2025-12-04T14:27:32.366Z" }, @@ -139,7 +136,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/66/bd6317bc5932accf351fc19f177ffba53712a202f9df10587da8df257c7e/greenlet-3.3.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:d6ed6f85fae6cdfdb9ce04c9bf7a08d666cfcfb914e7d006f44f840b46741931", size = 282638, upload-time = "2025-12-04T14:25:20.941Z" }, { url = "https://files.pythonhosted.org/packages/30/cf/cc81cb030b40e738d6e69502ccbd0dd1bced0588e958f9e757945de24404/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9125050fcf24554e69c4cacb086b87b3b55dc395a8b3ebe6487b045b2614388", size = 651145, upload-time = "2025-12-04T14:50:11.039Z" }, { url = "https://files.pythonhosted.org/packages/9c/ea/1020037b5ecfe95ca7df8d8549959baceb8186031da83d5ecceff8b08cd2/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:87e63ccfa13c0a0f6234ed0add552af24cc67dd886731f2261e46e241608bee3", size = 654236, upload-time = "2025-12-04T14:57:47.007Z" }, - { url = "https://files.pythonhosted.org/packages/69/cc/1e4bae2e45ca2fa55299f4e85854606a78ecc37fead20d69322f96000504/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2662433acbca297c9153a4023fe2161c8dcfdcc91f10433171cf7e7d94ba2221", size = 662506, upload-time = "2025-12-04T15:07:16.906Z" }, { url = "https://files.pythonhosted.org/packages/57/b9/f8025d71a6085c441a7eaff0fd928bbb275a6633773667023d19179fe815/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3c6e9b9c1527a78520357de498b0e709fb9e2f49c3a513afd5a249007261911b", size = 653783, upload-time = "2025-12-04T14:26:06.225Z" }, { url = "https://files.pythonhosted.org/packages/f6/c7/876a8c7a7485d5d6b5c6821201d542ef28be645aa024cfe1145b35c120c1/greenlet-3.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:286d093f95ec98fdd92fcb955003b8a3d054b4e2cab3e2707a5039e7b50520fd", size = 1614857, upload-time = "2025-12-04T15:04:28.484Z" }, { url = "https://files.pythonhosted.org/packages/4f/dc/041be1dff9f23dac5f48a43323cd0789cb798342011c19a248d9c9335536/greenlet-3.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c10513330af5b8ae16f023e8ddbfb486ab355d04467c4679c5cfe4659975dd9", size = 1676034, upload-time = "2025-12-04T14:27:33.531Z" }, @@ -614,7 +610,7 @@ wheels = [ [[package]] name = "sqlalchemy-cloudflare-d1" -version = "0.3.5" +version = "0.3.7" source = { editable = "../../" } dependencies = [ { name = "httpx" }, @@ -631,7 +627,7 @@ requires-dist = [ { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.0.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0.0" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.21.0" }, - { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.0.280" }, + { name = "ruff", marker = "extra == 'dev'", specifier = "==0.12.4" }, { name = "sqlalchemy", specifier = ">=2.0.0" }, { name = "typing-extensions", specifier = ">=4.0.0" }, ] @@ -646,7 +642,7 @@ dev = [ { name = "pytest-asyncio", specifier = ">=0.21.0" }, { name = "pytest-socket", specifier = ">=0.7.0" }, { name = "requests", specifier = ">=2.31.0" }, - { name = "ruff", specifier = ">=0.0.280" }, + { name = "ruff", specifier = "==0.12.4" }, ] [[package]] diff --git a/pyproject.toml b/pyproject.toml index cbf94c4..c151106 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 = [ diff --git a/src/sqlalchemy_cloudflare_d1/dialect.py b/src/sqlalchemy_cloudflare_d1/dialect.py index dbd603f..49a5416 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 +from datetime import datetime, date from typing import Any, Callable, Dict, List, Optional from sqlalchemy.engine import default @@ -16,6 +16,7 @@ NUMERIC, REAL, TEXT, + Date, ) from sqlalchemy import text @@ -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 @@ -194,6 +242,7 @@ class CloudflareD1Dialect(default.DefaultDialect): # Type mapping from SQLAlchemy to D1/SQLite colspecs = { Boolean: D1Boolean, + Date: D1Date, DateTime: D1DateTime, LargeBinary: D1LargeBinary, } diff --git a/tests/integration/test_restapi_integration.py b/tests/integration/test_restapi_integration.py index 700b1f9..ada0e05 100644 --- a/tests/integration/test_restapi_integration.py +++ b/tests/integration/test_restapi_integration.py @@ -2459,6 +2459,175 @@ class News(Base): Base.metadata.drop_all(d1_engine) +# MARK - Date Column Tests (Issue #15) + + +class TestDateColumn: + """Test Date column handling. + + D1 does not accept Python date objects as bind parameters. The + D1Date type processor converts dates to ISO 8601 strings on bind + and parses them back on result. + """ + + def test_date_insert_and_retrieve(self, d1_engine, test_table_name): + """Test that Date columns can store and retrieve date values.""" + from datetime import date + from sqlalchemy import Date + + metadata = MetaData() + test_table = Table( + test_table_name, + metadata, + Column("id", Integer, primary_key=True), + Column("title", String(127)), + Column("birth_date", Date), + ) + + metadata.create_all(d1_engine) + + try: + date_value = date(2025, 12, 29) + + with d1_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() + + assert row is not None + assert row[0] == "Test" + assert isinstance(row[1], date) + assert row[1].year == 2025 + assert row[1].month == 12 + assert row[1].day == 29 + finally: + metadata.drop_all(d1_engine) + + def test_date_nullable(self, d1_engine, test_table_name): + """Test nullable Date columns handle NULL correctly.""" + from datetime import date + from sqlalchemy import Date + + metadata = MetaData() + test_table = Table( + test_table_name, + metadata, + Column("id", Integer, primary_key=True), + Column("title", String(127)), + Column("event_date", Date, nullable=True), + ) + + metadata.create_all(d1_engine) + + try: + with d1_engine.connect() as conn: + # Insert row with NULL date + conn.execute( + test_table.insert().values(title="No Date", event_date=None) + ) + + # Insert row with actual date + conn.execute( + test_table.insert().values( + title="With Date", event_date=date(2025, 1, 1) + ) + ) + conn.commit() + + result = conn.execute( + select(test_table.c.title, test_table.c.event_date) + ) + rows = result.fetchall() + + assert len(rows) == 2 + assert rows[0][1] is None + assert isinstance(rows[1][1], date) + finally: + metadata.drop_all(d1_engine) + + def test_date_orm_session(self, d1_engine): + """Test Date via ORM session.""" + from datetime import date + from sqlalchemy import Date + from sqlalchemy.orm import Mapped, Session, declarative_base, mapped_column + + Base = declarative_base() + + class Event(Base): + __tablename__ = f"events_{uuid.uuid4().hex[:8]}" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + title: Mapped[str] = mapped_column(String(127)) + event_date: Mapped[date] = mapped_column(Date) + + Base.metadata.create_all(d1_engine) + + try: + test_date = date(2025, 12, 29) + + with Session(d1_engine) as session: + event = Event(title="Test Event", event_date=test_date) + session.add(event) + session.commit() + + # Query back + retrieved_event = session.query(Event).first() + + assert retrieved_event is not None + assert retrieved_event.title == "Test Event" + assert isinstance(retrieved_event.event_date, date) + assert retrieved_event.event_date == test_date + finally: + Base.metadata.drop_all(d1_engine) + + def test_date_filter_query(self, d1_engine, test_table_name): + """Test filtering by Date column values.""" + from datetime import date + from sqlalchemy import Date + + metadata = MetaData() + test_table = Table( + test_table_name, + metadata, + Column("id", Integer, primary_key=True), + Column("title", String(127)), + Column("event_date", Date), + ) + + metadata.create_all(d1_engine) + + try: + date_old = date(2024, 1, 1) + date_new = date(2025, 6, 15) + + with d1_engine.connect() as conn: + conn.execute( + test_table.insert().values(title="Old", event_date=date_old) + ) + conn.execute( + test_table.insert().values(title="New", event_date=date_new) + ) + conn.commit() + + # Filter for entries after 2025-01-01 + cutoff = date(2025, 1, 1).isoformat() + result = conn.execute( + select(test_table.c.title).where(test_table.c.event_date > cutoff) + ) + rows = result.fetchall() + + assert len(rows) == 1 + assert rows[0][0] == "New" + finally: + metadata.drop_all(d1_engine) + + # MARK: - DateTime Column Tests (Issue #13) diff --git a/tests/integration/test_worker_integration.py b/tests/integration/test_worker_integration.py index 2482d45..daf7beb 100644 --- a/tests/integration/test_worker_integration.py +++ b/tests/integration/test_worker_integration.py @@ -785,6 +785,56 @@ def test_inserted_primary_key_via_sqlalchemy_core(self, dev_server): assert data["lastrowid_2"] == 2 +# MARK: = Date Column Tests (Issue #15) + + +class TestWorkerDateColumn: + """Test Date column handling via Worker endpoints. + + These tests mirror the TestDateColumn tests in test_restapi_integration.py. + """ + + def test_date_insert_and_retrieve(self, dev_server): + """Test Date column insert and retrieve.""" + port = dev_server + response = requests.get(f"http://localhost:{port}/date-basic") + + assert response.status_code == 200, f"date_basic failed: {response.json()}" + data = response.json() + + assert data["test"] == "date_basic" + assert data["success"] is True, f"date_basic failed: error={data.get('error')}" + assert data["birth_date_type"] == "date" + + def test_date_nullable(self, dev_server): + """Test nullable Date columns handle NULL correctly.""" + port = dev_server + response = requests.get(f"http://localhost:{port}/date-nullable") + + assert response.status_code == 200, f"date_nullable failed: {response.json()}" + data = response.json() + + assert data["test"] == "date_nullable" + assert data["success"] is True, ( + f"date_nullable failed: error={data.get('error')}" + ) + assert data["with_date_is_date"] is True + assert data["no_date_is_none"] is True + + def test_date_orm_session(self, dev_server): + """Test Date via ORM session.""" + port = dev_server + response = requests.get(f"http://localhost:{port}/date-orm") + + assert response.status_code == 200, f"date_orm failed: {response.json()}" + data = response.json() + + assert data["test"] == "date_orm" + assert data["success"] is True, f"date_orm failed: error={data.get('error')}" + assert data["event_title"] == "Date Test Event" + assert data["event_date_is_date"] is True + + # MARK: - DateTime Column Tests (Issue #13) diff --git a/uv.lock b/uv.lock index 9b6b704..2bf7eff 100644 --- a/uv.lock +++ b/uv.lock @@ -922,7 +922,7 @@ wheels = [ [[package]] name = "sqlalchemy-cloudflare-d1" -version = "0.3.6" +version = "0.3.7" source = { editable = "." } dependencies = [ { name = "httpx" },