From 24d50e1908892c7581e6314474edd78cfc710ce3 Mon Sep 17 00:00:00 2001 From: Collier King Date: Mon, 2 Feb 2026 20:43:46 -0600 Subject: [PATCH 1/2] Add DateTime support, fix D1_TYPE_ERROR on datetime inserts (Issue #13) D1 rejects Python datetime objects with D1_TYPE_ERROR. Added D1DateTime type processor that converts datetimes to ISO 8601 strings on bind and parses them back on result. Bump version to 0.3.6. --- CHANGELOG.md | 11 + examples/workers/src/entry.py | 523 ++++++++++++++++++ examples/workers/uv.lock | 2 +- pyproject.toml | 2 +- src/sqlalchemy_cloudflare_d1/dialect.py | 50 ++ tests/integration/test_restapi_integration.py | 329 +++++++++++ tests/integration/test_worker_integration.py | 151 +++++ uv.lock | 2 +- 8 files changed, 1067 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 26cd5d9..3b2af71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +## [0.3.6] + +### Fixed + +- Fixed `D1_TYPE_ERROR` when inserting Python `datetime` objects ([#13](https://github.com/CollierKing/sqlalchemy-cloudflare-d1/issues/13)) + - D1 rejects Python `datetime` objects with `D1_TYPE_ERROR: Type 'object' not supported` + - Added `D1DateTime` type processor that converts `datetime` to ISO 8601 strings on bind and parses them back on result + - Supports timezone-aware datetimes (UTC and non-UTC offsets), nullable columns, and ORM usage + - Works in both REST API and Worker modes + + ## [0.3.5] ### Fixed diff --git a/examples/workers/src/entry.py b/examples/workers/src/entry.py index 9f8f4d9..7733fe2 100644 --- a/examples/workers/src/entry.py +++ b/examples/workers/src/entry.py @@ -115,6 +115,22 @@ async def fetch(self, request, env): return await self.test_single_row_sqlalchemy() elif path == "multi-row-result": return await self.test_multi_row_result() + # Autoincrement insert tests (GitHub issue #12) + elif path == "autoincrement-insert": + return await self.test_autoincrement_insert() + elif path == "autoincrement-insert-sqlalchemy": + return await self.test_autoincrement_insert_sqlalchemy() + elif path == "autoincrement-lastrowid": + return await self.test_autoincrement_lastrowid() + # DateTime column tests (GitHub issue #13) + elif path == "datetime-basic": + return await self.test_datetime_basic() + elif path == "datetime-non-utc": + return await self.test_datetime_non_utc() + elif path == "datetime-nullable": + return await self.test_datetime_nullable() + elif path == "datetime-orm": + return await self.test_datetime_orm() else: return await self.index() @@ -166,6 +182,10 @@ async def index(self): "/single-row-result": "Test single-row SELECT returns data correctly", "/single-row-sqlalchemy": "Test single-row via SQLAlchemy engine", "/multi-row-result": "Test multi-row SELECT returns correct data", + "/datetime-basic": "Test DateTime column insert/retrieve (issue #13)", + "/datetime-non-utc": "Test DateTime with non-UTC timezone", + "/datetime-nullable": "Test nullable DateTime columns", + "/datetime-orm": "Test DateTime via ORM session", }, "package": "sqlalchemy-cloudflare-d1", "connection_type": "WorkerConnection (D1 binding)", @@ -2652,3 +2672,506 @@ async def test_on_conflict_where(self): {"test": "on_conflict_where", "success": False, "error": str(e)}, status=500, ) + + # MARK: - Autoincrement Insert Tests (Issue #12) + + async def test_autoincrement_insert(self): + """Test raw cursor INSERT without specifying primary key, check lastrowid.""" + conn = self.get_connection() + cursor = conn.cursor() + table_name = f"test_autoincr_{uuid.uuid4().hex[:8]}" + try: + await cursor.execute_async( + f"CREATE TABLE {table_name} " + "(id INTEGER PRIMARY KEY, title TEXT NOT NULL)" + ) + await cursor.execute_async( + f"INSERT INTO {table_name} (title) VALUES (?)", + ("Hello World",), + ) + last_id = cursor.lastrowid + meta = cursor._last_result_meta + + await cursor.execute_async( + f"INSERT INTO {table_name} (title) VALUES (?)", + ("Second Entry",), + ) + last_id_2 = cursor.lastrowid + meta_2 = cursor._last_result_meta + + await cursor.execute_async(f"DROP TABLE {table_name}") + + return Response.json( + { + "test": "autoincrement_insert", + "success": last_id == 1 and last_id_2 == 2, + "lastrowid_1": last_id, + "lastrowid_2": last_id_2, + "meta_1": meta, + "meta_2": meta_2, + } + ) + except Exception as e: + try: + await cursor.execute_async(f"DROP TABLE IF EXISTS {table_name}") + except Exception: + pass + return Response.json( + { + "test": "autoincrement_insert", + "success": False, + "error": str(e), + }, + status=500, + ) + + async def test_autoincrement_insert_sqlalchemy(self): + """Test SQLAlchemy ORM INSERT without specifying primary key (issue #12). + + This reproduces the exact scenario from the bug report: + session.add(News(title="...")) without setting id. + """ + from sqlalchemy import Integer, String, MetaData, Table + from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, Session + + engine = create_engine_from_binding(self.env.DB) + table_name = f"test_autoincr_orm_{uuid.uuid4().hex[:8]}" + + try: + + class Base(DeclarativeBase): + pass + + class News(Base): + __tablename__ = table_name + id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) + url: Mapped[str] = mapped_column(String(511), unique=True, index=True) + title: Mapped[str] = mapped_column(String(127)) + + Base.metadata.create_all(engine) + + with Session(engine) as session: + entry = News( + url="https://example.com/first", + title="First Title", + ) + session.add(entry) + session.commit() + session.refresh(entry) + entry_id = entry.id + entry_title = entry.title + + with Session(engine) as session: + entry2 = News( + url="https://example.com/second", + title="Second Title", + ) + session.add(entry2) + session.commit() + session.refresh(entry2) + entry2_id = entry2.id + + Base.metadata.drop_all(engine) + + return Response.json( + { + "test": "autoincrement_insert_sqlalchemy", + "success": entry_id == 1 and entry2_id == 2, + "entry_id": entry_id, + "entry_title": entry_title, + "entry2_id": entry2_id, + } + ) + except Exception as e: + try: + metadata = MetaData() + Table(table_name, metadata) + metadata.drop_all(engine) + except Exception: + pass + return Response.json( + { + "test": "autoincrement_insert_sqlalchemy", + "success": False, + "error": str(e), + "error_type": type(e).__name__, + }, + status=500, + ) + + async def test_autoincrement_lastrowid(self): + """Test that lastrowid is correctly populated from D1 meta.""" + from sqlalchemy import Integer, String, MetaData, Table, Column + + engine = create_engine_from_binding(self.env.DB) + metadata = MetaData() + table_name = f"test_autoincr_lr_{uuid.uuid4().hex[:8]}" + + test_table = Table( + table_name, + metadata, + Column("id", Integer, primary_key=True), + Column("title", String(127), nullable=False), + ) + + try: + metadata.create_all(engine) + + with engine.connect() as conn: + result = conn.execute(test_table.insert().values(title="First")) + lastrowid_1 = result.inserted_primary_key[0] + + result2 = conn.execute(test_table.insert().values(title="Second")) + lastrowid_2 = result2.inserted_primary_key[0] + conn.commit() + + metadata.drop_all(engine) + + return Response.json( + { + "test": "autoincrement_lastrowid", + "success": lastrowid_1 == 1 and lastrowid_2 == 2, + "lastrowid_1": lastrowid_1, + "lastrowid_2": lastrowid_2, + } + ) + except Exception as e: + try: + metadata.drop_all(engine) + except Exception: + pass + return Response.json( + { + "test": "autoincrement_lastrowid", + "success": False, + "error": str(e), + "error_type": type(e).__name__, + }, + status=500, + ) + + # MARK: - DateTime Column Tests (Issue #13) + + async def test_datetime_basic(self): + """Test DateTime column insert and retrieve with timezone-aware datetimes.""" + from datetime import datetime, timezone + from sqlalchemy import ( + Column, + DateTime, + Integer, + MetaData, + String, + Table, + select, + ) + + table_name = f"test_dt_{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("created_at", DateTime(timezone=True)), + ) + + metadata.create_all(engine) + + dt_value = datetime(2025, 12, 29, 16, 51, 29, tzinfo=timezone.utc) + + with engine.connect() as conn: + conn.execute( + test_table.insert().values(title="Test", created_at=dt_value) + ) + conn.commit() + + result = conn.execute( + select(test_table.c.title, test_table.c.created_at) + ) + row = result.fetchone() + + metadata.drop_all(engine) + + success = ( + row is not None + and row[0] == "Test" + and isinstance(row[1], datetime) + and row[1].year == 2025 + and row[1].month == 12 + and row[1].day == 29 + ) + + return Response.json( + { + "test": "datetime_basic", + "success": success, + "title": row[0] if row else None, + "created_at_type": type(row[1]).__name__ if row else None, + "year": row[1].year + if row and isinstance(row[1], datetime) + else None, + } + ) + except Exception as e: + try: + metadata.drop_all(engine) + except Exception: + pass + return Response.json( + {"test": "datetime_basic", "success": False, "error": str(e)}, + status=500, + ) + + async def test_datetime_non_utc(self): + """Test DateTime with non-UTC timezone offset (exact scenario from issue #13).""" + from datetime import datetime, timedelta, timezone + from sqlalchemy import ( + Column, + DateTime, + Integer, + MetaData, + String, + Table, + select, + ) + + table_name = f"test_dt_tz_{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("origin_created_at", DateTime(timezone=True)), + Column("indexed_at", DateTime(timezone=True)), + ) + + metadata.create_all(engine) + + tz_minus_3 = timezone(timedelta(hours=-3)) + origin_dt = datetime(2025, 12, 29, 16, 51, 29, tzinfo=tz_minus_3) + indexed_dt = datetime(2026, 2, 1, 18, 22, 4, 948999, tzinfo=timezone.utc) + + with engine.connect() as conn: + conn.execute( + test_table.insert().values( + title="News", + origin_created_at=origin_dt, + indexed_at=indexed_dt, + ) + ) + conn.commit() + + result = conn.execute( + select( + test_table.c.origin_created_at, + test_table.c.indexed_at, + ) + ) + row = result.fetchone() + + metadata.drop_all(engine) + + success = ( + row is not None + and isinstance(row[0], datetime) + and isinstance(row[1], datetime) + ) + + return Response.json( + { + "test": "datetime_non_utc", + "success": success, + "origin_type": type(row[0]).__name__ if row else None, + "indexed_type": type(row[1]).__name__ if row else None, + } + ) + except Exception as e: + try: + metadata.drop_all(engine) + except Exception: + pass + return Response.json( + {"test": "datetime_non_utc", "success": False, "error": str(e)}, + status=500, + ) + + async def test_datetime_nullable(self): + """Test nullable DateTime columns handle NULL correctly.""" + from datetime import datetime, timezone + from sqlalchemy import ( + Column, + DateTime, + Integer, + MetaData, + String, + Table, + select, + ) + + table_name = f"test_dt_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("published_at", DateTime(timezone=True), nullable=True), + ) + + metadata.create_all(engine) + + with engine.connect() as conn: + conn.execute( + test_table.insert().values( + title="Published", + published_at=datetime.now(timezone.utc), + ) + ) + conn.execute( + test_table.insert().values(title="Draft", published_at=None) + ) + conn.commit() + + result = conn.execute( + select(test_table.c.title, test_table.c.published_at).order_by( + test_table.c.id + ) + ) + rows = result.fetchall() + + metadata.drop_all(engine) + + success = ( + len(rows) == 2 + and rows[0][0] == "Published" + and isinstance(rows[0][1], datetime) + and rows[1][0] == "Draft" + and rows[1][1] is None + ) + + return Response.json( + { + "test": "datetime_nullable", + "success": success, + "published_has_datetime": isinstance(rows[0][1], datetime) + if rows + else False, + "draft_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": "datetime_nullable", "success": False, "error": str(e)}, + status=500, + ) + + async def test_datetime_orm(self): + """Test DateTime via ORM session (reproduces exact issue #13 scenario).""" + from datetime import datetime, timedelta, timezone + from sqlalchemy import Integer, String, Float, Text, DateTime + from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, Session + + engine = create_engine_from_binding(self.env.DB) + table_name = f"test_dt_orm_{uuid.uuid4().hex[:8]}" + + try: + + class Base(DeclarativeBase): + pass + + class News(Base): + __tablename__ = table_name + id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) + url: Mapped[str] = mapped_column(String(511), unique=True, index=True) + title: Mapped[str] = mapped_column(String(127)) + content: Mapped[str] = mapped_column(Text) + response_elapsed_seconds: Mapped[float | None] = mapped_column( + Float, nullable=True + ) + origin_created_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + indexed_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(timezone.utc), + ) + inserted_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(timezone.utc), + ) + + Base.metadata.create_all(engine) + + tz_minus_3 = timezone(timedelta(hours=-3)) + origin_dt = datetime(2025, 12, 29, 16, 51, 29, tzinfo=tz_minus_3) + + with Session(engine) as session: + news_entry = News( + url="https://example.com/issue-13-test", + title="Issue 13 DateTime Test", + content="Testing datetime bind parameter conversion.", + response_elapsed_seconds=0.504, + origin_created_at=origin_dt, + indexed_at=datetime.now(timezone.utc), + ) + session.add(news_entry) + session.commit() + session.refresh(news_entry) + entry_id = news_entry.id + entry_title = news_entry.title + origin_is_dt = isinstance(news_entry.origin_created_at, datetime) + indexed_is_dt = isinstance(news_entry.indexed_at, datetime) + inserted_is_dt = isinstance(news_entry.inserted_at, datetime) + + Base.metadata.drop_all(engine) + + success = ( + entry_id == 1 and origin_is_dt and indexed_is_dt and inserted_is_dt + ) + + return Response.json( + { + "test": "datetime_orm", + "success": success, + "entry_id": entry_id, + "entry_title": entry_title, + "origin_is_datetime": origin_is_dt, + "indexed_is_datetime": indexed_is_dt, + "inserted_is_datetime": inserted_is_dt, + } + ) + 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": "datetime_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 2ef3a0f..4a4b9ef 100644 --- a/examples/workers/uv.lock +++ b/examples/workers/uv.lock @@ -614,7 +614,7 @@ wheels = [ [[package]] name = "sqlalchemy-cloudflare-d1" -version = "0.3.4" +version = "0.3.5" source = { editable = "../../" } dependencies = [ { name = "httpx" }, diff --git a/pyproject.toml b/pyproject.toml index 283cfe3..db02fe8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sqlalchemy-cloudflare-d1" -version = "0.3.5" +version = "0.3.6" 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 64d3784..dbd603f 100644 --- a/src/sqlalchemy_cloudflare_d1/dialect.py +++ b/src/sqlalchemy_cloudflare_d1/dialect.py @@ -3,12 +3,14 @@ """ import base64 +from datetime import datetime from typing import Any, Callable, Dict, List, Optional from sqlalchemy.engine import default from sqlalchemy.engine.interfaces import Dialect from sqlalchemy.sql.sqltypes import ( Boolean, + DateTime, INTEGER, LargeBinary, NUMERIC, @@ -110,6 +112,53 @@ def process(value: Any) -> Optional[bytes]: return process +# MARK: - DateTime Type Processor + + +class D1DateTime(DateTime): + """Custom DateTime type for Cloudflare D1. + + D1 does not accept Python datetime objects as bind parameters - they arrive + as JS 'object' type and raise D1_TYPE_ERROR. This type processor converts + datetime 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 datetime to ISO 8601 string for D1.""" + + # MARK: - bind_processor + def process(value: Any) -> Optional[str]: + if value is None: + return None + if isinstance(value, datetime): + 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[datetime]]: + """Convert ISO 8601 string from D1 back to Python datetime.""" + + # MARK: - result_processor + def process(value: Any) -> Optional[datetime]: + if value is None: + return None + if isinstance(value, datetime): + return value + if isinstance(value, str): + try: + return datetime.fromisoformat(value) + except ValueError: + return value + return value + + return process + + # MARK: - Dialect @@ -145,6 +194,7 @@ class CloudflareD1Dialect(default.DefaultDialect): # Type mapping from SQLAlchemy to D1/SQLite colspecs = { Boolean: D1Boolean, + DateTime: D1DateTime, LargeBinary: D1LargeBinary, } diff --git a/tests/integration/test_restapi_integration.py b/tests/integration/test_restapi_integration.py index 6978db3..700b1f9 100644 --- a/tests/integration/test_restapi_integration.py +++ b/tests/integration/test_restapi_integration.py @@ -13,16 +13,20 @@ import os import uuid +from datetime import UTC, datetime, timedelta, timezone import pytest from sqlalchemy import ( Boolean, Column, + DateTime, + Float, Integer, LargeBinary, MetaData, String, Table, + Text, func, select, ) @@ -2351,5 +2355,330 @@ def test_multi_row_description_has_column_names(self, d1_connection): cursor.execute(f"DROP TABLE IF EXISTS {table_name}") +# MARK: - Autoincrement Insert Tests (Issue #12) + + +class TestAutoincrementInsert: + """Test that INTEGER PRIMARY KEY autoincrement works correctly. + + Verifies that inserting rows without specifying the primary key + correctly auto-generates IDs and that cursor.lastrowid / + result.inserted_primary_key return the generated values. + """ + + def test_lastrowid_via_cursor(self, d1_connection): + """Test cursor.lastrowid returns the auto-generated ID after INSERT.""" + cursor = d1_connection.cursor() + table_name = f"test_autoincr_{uuid.uuid4().hex[:8]}" + + try: + cursor.execute( + f"CREATE TABLE {table_name} " + "(id INTEGER PRIMARY KEY, title TEXT NOT NULL)" + ) + cursor.execute( + f"INSERT INTO {table_name} (title) VALUES (?)", + ("First",), + ) + assert cursor.lastrowid == 1 + + cursor.execute( + f"INSERT INTO {table_name} (title) VALUES (?)", + ("Second",), + ) + assert cursor.lastrowid == 2 + finally: + cursor.execute(f"DROP TABLE IF EXISTS {table_name}") + + def test_inserted_primary_key_via_sqlalchemy_core(self, d1_engine, test_table_name): + """Test result.inserted_primary_key returns auto-generated ID.""" + metadata = MetaData() + test_table = Table( + test_table_name, + metadata, + Column("id", Integer, primary_key=True), + Column("title", String(127), nullable=False), + ) + metadata.create_all(d1_engine) + + try: + with d1_engine.connect() as conn: + result = conn.execute(test_table.insert().values(title="First")) + assert result.inserted_primary_key[0] == 1 + + result2 = conn.execute(test_table.insert().values(title="Second")) + assert result2.inserted_primary_key[0] == 2 + conn.commit() + finally: + metadata.drop_all(d1_engine) + + def test_orm_session_autoincrement(self, d1_engine): + """Test ORM session.add() with autoincrement primary key. + + Reproduces the exact scenario from issue #12 with the same model + definition: index=True on PK, unique+index on url, String columns. + """ + from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, Session + + table_name = f"test_autoincr_orm_{uuid.uuid4().hex[:8]}" + + class Base(DeclarativeBase): + pass + + class News(Base): + __tablename__ = table_name + id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) + url: Mapped[str] = mapped_column(String(511), unique=True, index=True) + title: Mapped[str] = mapped_column(String(127)) + + Base.metadata.create_all(d1_engine) + + try: + with Session(d1_engine) as session: + entry = News( + url="https://example.com/first", + title="First Title", + ) + session.add(entry) + session.commit() + session.refresh(entry) + assert entry.id == 1 + assert entry.url == "https://example.com/first" + assert entry.title == "First Title" + + with Session(d1_engine) as session: + entry2 = News( + url="https://example.com/second", + title="Second Title", + ) + session.add(entry2) + session.commit() + session.refresh(entry2) + assert entry2.id == 2 + finally: + Base.metadata.drop_all(d1_engine) + + +# MARK: - DateTime Column Tests (Issue #13) + + +class TestDateTimeColumn: + """Test DateTime column handling (fixes GitHub issue #13). + + D1 does not accept Python datetime objects as bind parameters. The + D1DateTime type processor converts datetimes to ISO 8601 strings on bind + and parses them back on result. + """ + + def test_datetime_insert_and_retrieve(self, d1_engine, test_table_name): + """Test that DateTime columns can store and retrieve timezone-aware datetimes.""" + metadata = MetaData() + test_table = Table( + test_table_name, + metadata, + Column("id", Integer, primary_key=True), + Column("title", String(127)), + Column("created_at", DateTime(timezone=True)), + ) + + metadata.create_all(d1_engine) + + try: + dt_value = datetime(2025, 12, 29, 16, 51, 29, tzinfo=UTC) + + with d1_engine.connect() as conn: + conn.execute( + test_table.insert().values(title="Test", created_at=dt_value) + ) + conn.commit() + + result = conn.execute( + select(test_table.c.title, test_table.c.created_at) + ) + row = result.fetchone() + + assert row is not None + assert row[0] == "Test" + assert isinstance(row[1], datetime) + assert row[1].year == 2025 + assert row[1].month == 12 + assert row[1].day == 29 + finally: + metadata.drop_all(d1_engine) + + def test_datetime_with_non_utc_timezone(self, d1_engine, test_table_name): + """Test DateTime with non-UTC timezone offset (exact scenario from issue).""" + metadata = MetaData() + test_table = Table( + test_table_name, + metadata, + Column("id", Integer, primary_key=True), + Column("title", String(127)), + Column("origin_created_at", DateTime(timezone=True)), + Column("indexed_at", DateTime(timezone=True)), + ) + + metadata.create_all(d1_engine) + + try: + tz_minus_3 = timezone(timedelta(hours=-3)) + origin_dt = datetime(2025, 12, 29, 16, 51, 29, tzinfo=tz_minus_3) + indexed_dt = datetime(2026, 2, 1, 18, 22, 4, 948999, tzinfo=UTC) + + with d1_engine.connect() as conn: + conn.execute( + test_table.insert().values( + title="News Article", + origin_created_at=origin_dt, + indexed_at=indexed_dt, + ) + ) + conn.commit() + + result = conn.execute( + select( + test_table.c.origin_created_at, + test_table.c.indexed_at, + ) + ) + row = result.fetchone() + + assert row is not None + assert isinstance(row[0], datetime) + assert isinstance(row[1], datetime) + finally: + metadata.drop_all(d1_engine) + + def test_datetime_nullable(self, d1_engine, test_table_name): + """Test nullable DateTime columns handle NULL correctly.""" + metadata = MetaData() + test_table = Table( + test_table_name, + metadata, + Column("id", Integer, primary_key=True), + Column("title", String(127)), + Column("published_at", DateTime(timezone=True), nullable=True), + ) + + metadata.create_all(d1_engine) + + try: + with d1_engine.connect() as conn: + conn.execute( + test_table.insert().values( + title="Published", + published_at=datetime.now(UTC), + ) + ) + conn.execute( + test_table.insert().values(title="Draft", published_at=None) + ) + conn.commit() + + result = conn.execute( + select(test_table.c.title, test_table.c.published_at).order_by( + test_table.c.id + ) + ) + rows = result.fetchall() + + assert len(rows) == 2 + assert rows[0][0] == "Published" + assert isinstance(rows[0][1], datetime) + assert rows[1][0] == "Draft" + assert rows[1][1] is None + finally: + metadata.drop_all(d1_engine) + + def test_datetime_orm_session(self, d1_engine): + """Test DateTime via ORM session (reproduces exact issue #13 scenario).""" + from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column + + table_name = f"test_dt_orm_{uuid.uuid4().hex[:8]}" + + class Base(DeclarativeBase): + pass + + class News(Base): + __tablename__ = table_name + id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) + url: Mapped[str] = mapped_column(String(511), unique=True, index=True) + title: Mapped[str] = mapped_column(String(127)) + content: Mapped[str] = mapped_column(Text) + response_elapsed_seconds: Mapped[float | None] = mapped_column( + Float, nullable=True + ) + origin_created_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + indexed_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(UTC) + ) + inserted_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(UTC) + ) + + Base.metadata.create_all(d1_engine) + + try: + tz_minus_3 = timezone(timedelta(hours=-3)) + origin_dt = datetime(2025, 12, 29, 16, 51, 29, tzinfo=tz_minus_3) + + with Session(d1_engine) as session: + news_entry = News( + url="https://example.com/issue-13-test", + title="Issue 13 DateTime Test", + content="Testing datetime bind parameter conversion.", + response_elapsed_seconds=0.504, + origin_created_at=origin_dt, + indexed_at=datetime.now(UTC), + ) + session.add(news_entry) + session.commit() + session.refresh(news_entry) + + assert news_entry.id == 1 + assert news_entry.title == "Issue 13 DateTime Test" + assert isinstance(news_entry.origin_created_at, datetime) + assert isinstance(news_entry.indexed_at, datetime) + assert isinstance(news_entry.inserted_at, datetime) + finally: + Base.metadata.drop_all(d1_engine) + + def test_datetime_filter_query(self, d1_engine, test_table_name): + """Test filtering by DateTime column values.""" + metadata = MetaData() + test_table = Table( + test_table_name, + metadata, + Column("id", Integer, primary_key=True), + Column("title", String(127)), + Column("created_at", DateTime(timezone=True)), + ) + + metadata.create_all(d1_engine) + + try: + dt_old = datetime(2024, 1, 1, 0, 0, 0, tzinfo=UTC) + dt_new = datetime(2025, 6, 15, 12, 0, 0, tzinfo=UTC) + + with d1_engine.connect() as conn: + conn.execute(test_table.insert().values(title="Old", created_at=dt_old)) + conn.execute(test_table.insert().values(title="New", created_at=dt_new)) + conn.commit() + + # Filter for entries after 2025-01-01 + cutoff = datetime(2025, 1, 1, 0, 0, 0, tzinfo=UTC).isoformat() + result = conn.execute( + select(test_table.c.title).where(test_table.c.created_at > cutoff) + ) + rows = result.fetchall() + + assert len(rows) == 1 + assert rows[0][0] == "New" + 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 73d40b3..c874bd1 100644 --- a/tests/integration/test_worker_integration.py +++ b/tests/integration/test_worker_integration.py @@ -709,3 +709,154 @@ def test_multi_row_description_has_column_names(self, dev_server): assert data["success"] is True assert data["row_count"] == 3 assert data["description_names"] == ["id", "name", "value"] + + +# MARK: - Autoincrement Insert Tests (Issue #12) + + +class TestAutoincrementInsert: + """Test that INTEGER PRIMARY KEY autoincrement works correctly. + + Verifies that inserting rows without specifying the primary key + correctly auto-generates IDs and that cursor.lastrowid / + result.inserted_primary_key return the generated values. + """ + + def test_lastrowid_via_cursor(self, dev_server): + """Test cursor.lastrowid returns the auto-generated ID after INSERT.""" + port = dev_server + response = requests.get(f"http://localhost:{port}/autoincrement-insert") + + assert response.status_code == 200 + data = response.json() + + assert data["test"] == "autoincrement_insert" + assert data["success"] is True, ( + f"lastrowid failed: lastrowid_1={data.get('lastrowid_1')}, " + f"lastrowid_2={data.get('lastrowid_2')}, " + f"meta_1={data.get('meta_1')}" + ) + assert data["lastrowid_1"] == 1 + assert data["lastrowid_2"] == 2 + + def test_orm_session_autoincrement(self, dev_server): + """Test ORM session.add() with autoincrement primary key. + + Reproduces the exact scenario from issue #12: + session.add(Model(title="...")) without setting id. + """ + port = dev_server + response = requests.get( + f"http://localhost:{port}/autoincrement-insert-sqlalchemy" + ) + + assert ( + response.status_code == 200 + ), f"ORM autoincrement failed: {response.json()}" + data = response.json() + + assert data["test"] == "autoincrement_insert_sqlalchemy" + assert data["success"] is True, ( + f"ORM autoincrement failed: entry_id={data.get('entry_id')}, " + f"entry2_id={data.get('entry2_id')}, " + f"error={data.get('error')}" + ) + assert data["entry_id"] == 1 + assert data["entry2_id"] == 2 + + def test_inserted_primary_key_via_sqlalchemy_core(self, dev_server): + """Test result.inserted_primary_key returns auto-generated ID.""" + port = dev_server + response = requests.get(f"http://localhost:{port}/autoincrement-lastrowid") + + assert ( + response.status_code == 200 + ), f"inserted_primary_key failed: {response.json()}" + data = response.json() + + assert data["test"] == "autoincrement_lastrowid" + assert data["success"] is True, ( + f"inserted_primary_key failed: " + f"lastrowid_1={data.get('lastrowid_1')}, " + f"lastrowid_2={data.get('lastrowid_2')}, " + f"error={data.get('error')}" + ) + assert data["lastrowid_1"] == 1 + assert data["lastrowid_2"] == 2 + + +# MARK: - DateTime Column Tests (Issue #13) + + +class TestWorkerDateTimeColumn: + """Test DateTime column handling via Worker endpoints. + + These tests mirror the TestDateTimeColumn tests in test_restapi_integration.py. + """ + + def test_datetime_insert_and_retrieve(self, dev_server): + """Test DateTime column insert and retrieve with timezone-aware datetimes.""" + port = dev_server + response = requests.get(f"http://localhost:{port}/datetime-basic") + + assert response.status_code == 200, f"datetime_basic failed: {response.json()}" + data = response.json() + + assert data["test"] == "datetime_basic" + assert ( + data["success"] is True + ), f"datetime_basic failed: error={data.get('error')}" + assert data["title"] == "Test" + assert data["created_at_type"] == "datetime" + + def test_datetime_with_non_utc_timezone(self, dev_server): + """Test DateTime with non-UTC timezone offset (exact scenario from issue).""" + port = dev_server + response = requests.get(f"http://localhost:{port}/datetime-non-utc") + + assert ( + response.status_code == 200 + ), f"datetime_non_utc failed: {response.json()}" + data = response.json() + + assert data["test"] == "datetime_non_utc" + assert ( + data["success"] is True + ), f"datetime_non_utc failed: error={data.get('error')}" + assert data["origin_type"] == "datetime" + assert data["indexed_type"] == "datetime" + + def test_datetime_nullable(self, dev_server): + """Test nullable DateTime columns handle NULL correctly.""" + port = dev_server + response = requests.get(f"http://localhost:{port}/datetime-nullable") + + assert ( + response.status_code == 200 + ), f"datetime_nullable failed: {response.json()}" + data = response.json() + + assert data["test"] == "datetime_nullable" + assert ( + data["success"] is True + ), f"datetime_nullable failed: error={data.get('error')}" + assert data["published_has_datetime"] is True + assert data["draft_is_none"] is True + + def test_datetime_orm_session(self, dev_server): + """Test DateTime via ORM session (reproduces exact issue #13 scenario).""" + port = dev_server + response = requests.get(f"http://localhost:{port}/datetime-orm") + + assert response.status_code == 200, f"datetime_orm failed: {response.json()}" + data = response.json() + + assert data["test"] == "datetime_orm" + assert ( + data["success"] is True + ), f"datetime_orm failed: error={data.get('error')}" + assert data["entry_id"] == 1 + assert data["entry_title"] == "Issue 13 DateTime Test" + assert data["origin_is_datetime"] is True + assert data["indexed_is_datetime"] is True + assert data["inserted_is_datetime"] is True diff --git a/uv.lock b/uv.lock index 8e98d7a..10cafd4 100644 --- a/uv.lock +++ b/uv.lock @@ -922,7 +922,7 @@ wheels = [ [[package]] name = "sqlalchemy-cloudflare-d1" -version = "0.3.5" +version = "0.3.6" source = { editable = "." } dependencies = [ { name = "httpx" }, From 8cb825a78a979aaea121a223813ac3ab210e437e Mon Sep 17 00:00:00 2001 From: Collier King Date: Mon, 2 Feb 2026 20:54:08 -0600 Subject: [PATCH 2/2] Pin ruff to 0.12.4 across pyproject.toml and pre-commit config Pre-commit used ruff v0.5.0 while CI lint used whatever uv resolved (0.12.4), causing formatting disagreements on multi-line asserts. Now pinned to the same version everywhere. --- .pre-commit-config.yaml | 2 +- pyproject.toml | 4 +- tests/integration/test_worker_integration.py | 48 ++++++++++---------- uv.lock | 4 +- 4 files changed, 29 insertions(+), 29 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3589c7f..18b1444 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -11,7 +11,7 @@ repos: - id: debug-statements - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.5.0 + rev: v0.12.4 hooks: # Linter - id: ruff diff --git a/pyproject.toml b/pyproject.toml index db02fe8..cbf94c4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,7 +38,7 @@ dev = [ "black>=23.0.0", "isort>=5.12.0", "mypy>=1.0.0", - "ruff>=0.0.280", + "ruff==0.12.4", ] [project.urls] @@ -89,5 +89,5 @@ dev = [ "pytest-asyncio>=0.21.0", "pytest-socket>=0.7.0", "requests>=2.31.0", - "ruff>=0.0.280", + "ruff==0.12.4", ] diff --git a/tests/integration/test_worker_integration.py b/tests/integration/test_worker_integration.py index c874bd1..2482d45 100644 --- a/tests/integration/test_worker_integration.py +++ b/tests/integration/test_worker_integration.py @@ -750,9 +750,9 @@ def test_orm_session_autoincrement(self, dev_server): f"http://localhost:{port}/autoincrement-insert-sqlalchemy" ) - assert ( - response.status_code == 200 - ), f"ORM autoincrement failed: {response.json()}" + assert response.status_code == 200, ( + f"ORM autoincrement failed: {response.json()}" + ) data = response.json() assert data["test"] == "autoincrement_insert_sqlalchemy" @@ -769,9 +769,9 @@ def test_inserted_primary_key_via_sqlalchemy_core(self, dev_server): port = dev_server response = requests.get(f"http://localhost:{port}/autoincrement-lastrowid") - assert ( - response.status_code == 200 - ), f"inserted_primary_key failed: {response.json()}" + assert response.status_code == 200, ( + f"inserted_primary_key failed: {response.json()}" + ) data = response.json() assert data["test"] == "autoincrement_lastrowid" @@ -803,9 +803,9 @@ def test_datetime_insert_and_retrieve(self, dev_server): data = response.json() assert data["test"] == "datetime_basic" - assert ( - data["success"] is True - ), f"datetime_basic failed: error={data.get('error')}" + assert data["success"] is True, ( + f"datetime_basic failed: error={data.get('error')}" + ) assert data["title"] == "Test" assert data["created_at_type"] == "datetime" @@ -814,15 +814,15 @@ def test_datetime_with_non_utc_timezone(self, dev_server): port = dev_server response = requests.get(f"http://localhost:{port}/datetime-non-utc") - assert ( - response.status_code == 200 - ), f"datetime_non_utc failed: {response.json()}" + assert response.status_code == 200, ( + f"datetime_non_utc failed: {response.json()}" + ) data = response.json() assert data["test"] == "datetime_non_utc" - assert ( - data["success"] is True - ), f"datetime_non_utc failed: error={data.get('error')}" + assert data["success"] is True, ( + f"datetime_non_utc failed: error={data.get('error')}" + ) assert data["origin_type"] == "datetime" assert data["indexed_type"] == "datetime" @@ -831,15 +831,15 @@ def test_datetime_nullable(self, dev_server): port = dev_server response = requests.get(f"http://localhost:{port}/datetime-nullable") - assert ( - response.status_code == 200 - ), f"datetime_nullable failed: {response.json()}" + assert response.status_code == 200, ( + f"datetime_nullable failed: {response.json()}" + ) data = response.json() assert data["test"] == "datetime_nullable" - assert ( - data["success"] is True - ), f"datetime_nullable failed: error={data.get('error')}" + assert data["success"] is True, ( + f"datetime_nullable failed: error={data.get('error')}" + ) assert data["published_has_datetime"] is True assert data["draft_is_none"] is True @@ -852,9 +852,9 @@ def test_datetime_orm_session(self, dev_server): data = response.json() assert data["test"] == "datetime_orm" - assert ( - data["success"] is True - ), f"datetime_orm failed: error={data.get('error')}" + assert data["success"] is True, ( + f"datetime_orm failed: error={data.get('error')}" + ) assert data["entry_id"] == 1 assert data["entry_title"] == "Issue 13 DateTime Test" assert data["origin_is_datetime"] is True diff --git a/uv.lock b/uv.lock index 10cafd4..9b6b704 100644 --- a/uv.lock +++ b/uv.lock @@ -964,7 +964,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" }, ] @@ -979,7 +979,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]]