diff --git a/src/teaql/core/value.py b/src/teaql/core/value.py index 7efbbe4..d3344ff 100644 --- a/src/teaql/core/value.py +++ b/src/teaql/core/value.py @@ -1,6 +1,6 @@ from enum import Enum, auto from dataclasses import dataclass -from datetime import date, datetime, timezone +from datetime import date, datetime, time, timezone from decimal import Decimal from typing import Any, Dict, List, Optional, Union import json @@ -15,6 +15,7 @@ class DataType(Enum): LargeText = auto() Json = auto() Date = auto() + Time = auto() Timestamp = auto() @dataclass @@ -94,6 +95,10 @@ def Json(v: Any) -> 'Value': def Date(v: date) -> 'Value': return Value(v) + @staticmethod + def Time(v: time) -> 'Value': + return Value(v, DataType.Time) + @staticmethod def Timestamp(v: Timestamp) -> 'Value': return Value(v) @@ -137,6 +142,8 @@ def from_any(cls, value: Any) -> 'Value': return cls.Text(value) if isinstance(value, date) and not isinstance(value, datetime): return cls.Date(value) + if isinstance(value, time): + return cls.Time(value) if isinstance(value, Timestamp): return cls.Timestamp(value) if isinstance(value, datetime): @@ -215,6 +222,16 @@ def try_date(self) -> Optional[date]: pass return None + def try_time(self) -> Optional[time]: + if isinstance(self._data, time): + return self._data + if isinstance(self._data, str): + try: + return time.fromisoformat(self._data) + except ValueError: + pass + return None + def try_timestamp(self) -> Optional[Timestamp]: if isinstance(self._data, Timestamp): return self._data @@ -252,6 +269,8 @@ def to_json_value(self) -> Any: return str(self._data) if isinstance(self._data, date): return self._data.isoformat() + if isinstance(self._data, time): + return self._data.isoformat() if isinstance(self._data, Timestamp): return self._data.millis if isinstance(self._data, dict): diff --git a/src/teaql/provider/postgres/dialect.py b/src/teaql/provider/postgres/dialect.py index 1a93e0f..e246e7d 100644 --- a/src/teaql/provider/postgres/dialect.py +++ b/src/teaql/provider/postgres/dialect.py @@ -30,6 +30,8 @@ def schema_type_sql(self, data_type: DataType, property_desc: PropertyDescriptor return "JSONB" if data_type == DataType.Date: return "DATE" + if data_type == DataType.Time: + return "TIME" if data_type == DataType.Timestamp: return "TIMESTAMPTZ" diff --git a/src/teaql/provider/sqlite/dialect.py b/src/teaql/provider/sqlite/dialect.py index 7491c81..d198cab 100644 --- a/src/teaql/provider/sqlite/dialect.py +++ b/src/teaql/provider/sqlite/dialect.py @@ -25,7 +25,7 @@ def schema_type_sql(self, data_type: Any, property_desc: PropertyDescriptor) -> if dt_str in ("f64", "f32", "float", "double"): return "REAL" if dt_str in ("decimal", "numeric"): return "NUMERIC" if dt_str in ("text", "string", "varchar"): return "VARCHAR(255)" - if dt_str in ("largetext", "json", "date", "timestamp"): return "TEXT" + if dt_str in ("largetext", "json", "date", "time", "timestamp"): return "TEXT" raise UnsupportedSchemaTypeError(data_type) if data_type == DataType.Bool: @@ -38,7 +38,7 @@ def schema_type_sql(self, data_type: Any, property_desc: PropertyDescriptor) -> return "NUMERIC" if data_type == DataType.Text: return "VARCHAR(255)" - if data_type in (DataType.LargeText, DataType.Json, DataType.Date, DataType.Timestamp): + if data_type in (DataType.LargeText, DataType.Json, DataType.Date, DataType.Time, DataType.Timestamp): return "TEXT" raise UnsupportedSchemaTypeError(data_type) diff --git a/src/teaql/provider/sqlite/transport.py b/src/teaql/provider/sqlite/transport.py index 3a6835e..e35ff17 100644 --- a/src/teaql/provider/sqlite/transport.py +++ b/src/teaql/provider/sqlite/transport.py @@ -1,7 +1,7 @@ import json import aiosqlite from decimal import Decimal -from datetime import date +from datetime import date, time from typing import List, Dict, Any, Optional, AsyncIterator from teaql.sql.executor import SqlTransactionTransport, SqlTransactionTransportTx from teaql.sql.types import CompiledQuery @@ -54,6 +54,8 @@ def _bind_values(self, params: List[Value]) -> tuple: res.append(str(v._data)) elif getattr(v, '_type_hint', None) == DataType.Json or isinstance(v._data, (dict, list)): res.append(json.dumps(v._data)) + elif getattr(v, '_type_hint', None) == DataType.Time or isinstance(v._data, time): + res.append(v._data.isoformat() if isinstance(v._data, time) else str(v._data)) elif getattr(v, '_type_hint', None) == DataType.Date or isinstance(v._data, date): if hasattr(v._data, "strftime"): res.append(v._data.strftime("%Y-%m-%d")) diff --git a/src/teaql/sql/dialect.py b/src/teaql/sql/dialect.py index 82e5af0..3ff916b 100644 --- a/src/teaql/sql/dialect.py +++ b/src/teaql/sql/dialect.py @@ -91,6 +91,7 @@ def schema_type_sql(self, data_type: DataType, property_desc: PropertyDescriptor if data_type == DataType.F64: return "REAL" if data_type == DataType.Decimal: return "NUMERIC" if data_type == DataType.Text: return "VARCHAR(255)" + if data_type == DataType.Time: return "TIME" if data_type in (DataType.LargeText, DataType.Json, DataType.Date, DataType.Timestamp): return "TEXT" raise UnsupportedSchemaTypeError(data_type) diff --git a/src/teaql/sql/executor.py b/src/teaql/sql/executor.py index 1a449f2..5bbcbb9 100644 --- a/src/teaql/sql/executor.py +++ b/src/teaql/sql/executor.py @@ -1,7 +1,7 @@ from abc import ABC, abstractmethod from typing import List, Dict, Any, Optional, AsyncIterator from copy import deepcopy -from datetime import date, datetime +from datetime import date, datetime, time as wall_time import asyncio import hashlib import logging @@ -167,20 +167,30 @@ def _backend_name(self) -> str: @staticmethod def _normalize_loaded_dates(rows, descriptor, query=None): - """Declared Date fields stay native through list, cursor and save readback. + """Declared Date/Time fields stay native through list, cursor and save readback. Do not guess from text content or convert computed aggregate aliases. Missing columns and loaded SQL NULL retain their distinct states. """ aliases = {aggregate.alias for aggregate in query.aggregates} if query is not None else set() properties = [prop for prop in descriptor.properties - if prop.property_type == DataType.Date or str(prop.property_type).lower() == 'date'] + if prop.property_type in (DataType.Date, DataType.Time) + or str(prop.property_type).lower() in ('date', 'time')] for row in rows: for prop in properties: for key in {prop.name, prop.column_name_val} - aliases: if key not in row or row[key] is None: continue current = row[key] + if prop.property_type == DataType.Time or str(prop.property_type).lower() == 'time': + if isinstance(current, str): + try: + row[key] = wall_time.fromisoformat(current) + except ValueError: + raise ValueError(f'invalid loaded Time field {descriptor._name}.{prop.name}') from None + elif not isinstance(current, wall_time): + raise ValueError(f'invalid loaded Time field {descriptor._name}.{prop.name}') + continue if isinstance(current, datetime): row[key] = current.date() elif isinstance(current, str): diff --git a/src/teaql/sql/types.py b/src/teaql/sql/types.py index ccd82c0..342419c 100644 --- a/src/teaql/sql/types.py +++ b/src/teaql/sql/types.py @@ -1,7 +1,7 @@ from enum import Enum, auto from dataclasses import dataclass from typing import List, Optional -from datetime import date, datetime, timezone +from datetime import date, datetime, time, timezone from decimal import Decimal import json from contextlib import contextmanager @@ -179,6 +179,11 @@ def _sql_literal(value: Value, dialect: DatabaseKind) -> str: return "TRUE" if raw else "FALSE" if isinstance(raw, (int, float, Decimal)): return str(raw) + if isinstance(raw, time): + literal = _quote_sql_string(raw.isoformat()) + if dialect == DatabaseKind.PostgreSql: return f"TIME {literal}" + if dialect == DatabaseKind.MySql: return f"CAST({literal} AS TIME)" + return literal if isinstance(raw, date): literal = _quote_sql_string(raw.isoformat()) if dialect == DatabaseKind.PostgreSql: return f"DATE {literal}" diff --git a/tests/core/test_value.py b/tests/core/test_value.py index e6c910e..b402190 100644 --- a/tests/core/test_value.py +++ b/tests/core/test_value.py @@ -1,5 +1,5 @@ import pytest -from datetime import date, datetime, timezone +from datetime import date, datetime, time, timezone from decimal import Decimal from teaql.core.value import Value, Timestamp, DataType @@ -99,6 +99,16 @@ def test_value_try_date_rejects_invalid_dates_and_unrelated_variants(): assert Value.Text("2024-02-29T00:00:00Z").try_date() is None assert Value.Null().try_date() is None +def test_value_time_is_a_native_scalar_and_json_safe(): + clock = time(9, 30, 15, 123000) + value = Value.from_any(clock) + assert value._type_hint == DataType.Time + assert value.try_time() == clock + assert value.to_json_value() == "09:30:15.123000" + assert Value.Text("09:30:15.123000").try_time() == clock + assert Value.Text("25:30:15").try_time() is None + assert Value.Null().try_time() is None + def test_value_try_timestamp_accepts_timestamp_and_supported_text_formats(): utc_timestamp = Timestamp(int(datetime(2024, 1, 2, 3, 4, 5, tzinfo=timezone.utc).timestamp() * 1000)) # Python datetime isoformat doesn't have to be identical, but timestamp should match diff --git a/tests/provider/sqlite/test_time_scalar.py b/tests/provider/sqlite/test_time_scalar.py new file mode 100644 index 0000000..0303f5f --- /dev/null +++ b/tests/provider/sqlite/test_time_scalar.py @@ -0,0 +1,32 @@ +from datetime import time +from types import SimpleNamespace + +import pytest + +from teaql.core.value import DataType, Value +from teaql.provider.postgres.dialect import PostgresDialect +from teaql.provider.mysql.dialect import MysqlDialect +from teaql.provider.sqlite.dialect import SqliteDialect +from teaql.provider.sqlite.transport import SqliteTransport +from teaql.sql.executor import SqlDataServiceExecutor +from teaql.sql.types import DatabaseKind, _sql_literal + + +def test_time_scalar_has_portable_schema_and_sql_bindings(): + value = Value.Time(time(9, 30, 15)) + assert SqliteDialect().schema_type_sql(DataType.Time, None) == "TEXT" + assert PostgresDialect().schema_type_sql(DataType.Time, None) == "TIME" + assert MysqlDialect().schema_type_sql(DataType.Time, None) == "TIME" + assert SqliteTransport(":memory:")._bind_values([value]) == ("09:30:15",) + assert _sql_literal(value, DatabaseKind.Sqlite) == "'09:30:15'" + assert _sql_literal(value, DatabaseKind.PostgreSql) == "TIME '09:30:15'" + + +def test_only_declared_time_columns_are_normalized(): + prop = SimpleNamespace(name="required_time", column_name_val="required_time", property_type=DataType.Time) + descriptor = SimpleNamespace(_name="ScalarTypeProbe", properties=[prop]) + rows = [{"required_time": "09:30:15", "computed": "09:30:15"}] + SqlDataServiceExecutor._normalize_loaded_dates(rows, descriptor) + assert rows == [{"required_time": time(9, 30, 15), "computed": "09:30:15"}] + with pytest.raises(ValueError, match="invalid loaded Time field"): + SqlDataServiceExecutor._normalize_loaded_dates([{"required_time": "25:30:15"}], descriptor)