Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion src/teaql/core/value.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -15,6 +15,7 @@ class DataType(Enum):
LargeText = auto()
Json = auto()
Date = auto()
Time = auto()
Timestamp = auto()

@dataclass
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down
2 changes: 2 additions & 0 deletions src/teaql/provider/postgres/dialect.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
4 changes: 2 additions & 2 deletions src/teaql/provider/sqlite/dialect.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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)
Expand Down
4 changes: 3 additions & 1 deletion src/teaql/provider/sqlite/transport.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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"))
Expand Down
1 change: 1 addition & 0 deletions src/teaql/sql/dialect.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
16 changes: 13 additions & 3 deletions src/teaql/sql/executor.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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):
Expand Down
7 changes: 6 additions & 1 deletion src/teaql/sql/types.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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}"
Expand Down
12 changes: 11 additions & 1 deletion tests/core/test_value.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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
Expand Down
32 changes: 32 additions & 0 deletions tests/provider/sqlite/test_time_scalar.py
Original file line number Diff line number Diff line change
@@ -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)
Loading