From efcfbdb2de3d047a06c0d1c42f83dacd9f63312c Mon Sep 17 00:00:00 2001 From: David Rapan Date: Sat, 1 Nov 2025 12:17:45 +0100 Subject: [PATCH 1/4] Refactor data conversion Signed-off-by: David Rapan --- homeassistant/components/sql/sensor.py | 13 ++--------- homeassistant/components/sql/services.py | 12 ++-------- homeassistant/components/sql/util.py | 16 ++++++++++++++ tests/components/sql/test_util.py | 28 +++++++++++++++++++++++- 4 files changed, 47 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/sql/sensor.py b/homeassistant/components/sql/sensor.py index 508365b5c0dce..861c3a624f7ff 100644 --- a/homeassistant/components/sql/sensor.py +++ b/homeassistant/components/sql/sensor.py @@ -2,8 +2,6 @@ from __future__ import annotations -from datetime import date -import decimal import logging from typing import Any @@ -43,6 +41,7 @@ from .const import CONF_ADVANCED_OPTIONS, CONF_COLUMN_NAME, CONF_QUERY, DOMAIN from .util import ( async_create_sessionmaker, + ensure_serializable, generate_lambda_stmt, redact_credentials, resolve_db_url, @@ -253,7 +252,6 @@ async def async_update(self) -> None: def _update(self) -> None: """Retrieve sensor data from the query.""" data = None - extra_state_attributes = {} self._attr_extra_state_attributes = {} sess: scoped_session = self.sessionmaker() try: @@ -272,14 +270,7 @@ def _update(self) -> None: _LOGGER.debug("Query %s result in %s", self._query, res.items()) data = res[self._column_name] for key, value in res.items(): - if isinstance(value, decimal.Decimal): - value = float(value) - elif isinstance(value, date): - value = value.isoformat() - elif isinstance(value, (bytes, bytearray)): - value = f"0x{value.hex()}" - extra_state_attributes[key] = value - self._attr_extra_state_attributes[key] = value + self._attr_extra_state_attributes[key] = ensure_serializable(value) if data is not None and isinstance(data, (bytes, bytearray)): data = f"0x{data.hex()}" diff --git a/homeassistant/components/sql/services.py b/homeassistant/components/sql/services.py index c7b74bd82b6ed..83db61f5bc565 100644 --- a/homeassistant/components/sql/services.py +++ b/homeassistant/components/sql/services.py @@ -2,8 +2,6 @@ from __future__ import annotations -import datetime -import decimal import logging from sqlalchemy.engine import Result @@ -26,6 +24,7 @@ from .const import CONF_QUERY, DOMAIN from .util import ( async_create_sessionmaker, + ensure_serializable, generate_lambda_stmt, redact_credentials, resolve_db_url, @@ -88,14 +87,7 @@ def _execute_and_convert_query() -> list[JsonValueType]: for row in result.mappings(): processed_row: dict[str, JsonValueType] = {} for key, value in row.items(): - if isinstance(value, decimal.Decimal): - processed_row[key] = float(value) - elif isinstance(value, datetime.date): - processed_row[key] = value.isoformat() - elif isinstance(value, (bytes, bytearray)): - processed_row[key] = f"0x{value.hex()}" - else: - processed_row[key] = value + processed_row[key] = ensure_serializable(value) rows.append(processed_row) return rows finally: diff --git a/homeassistant/components/sql/util.py b/homeassistant/components/sql/util.py index 0200a83c9e849..bb481839fed3f 100644 --- a/homeassistant/components/sql/util.py +++ b/homeassistant/components/sql/util.py @@ -2,7 +2,10 @@ from __future__ import annotations +from datetime import date +from decimal import Decimal import logging +from typing import Any import sqlalchemy from sqlalchemy import lambda_stmt @@ -223,3 +226,16 @@ def generate_lambda_stmt(query: str) -> StatementLambdaElement: """Generate the lambda statement.""" text = sqlalchemy.text(query) return lambda_stmt(lambda: text, lambda_cache=_SQL_LAMBDA_CACHE) + + +def ensure_serializable(value: Any) -> Any: + """Ensure value is serializable.""" + match value: + case Decimal(): + return float(value) + case date(): + return value.isoformat() + case bytes() | bytearray(): + return f"0x{value.hex()}" + case _: + return value diff --git a/tests/components/sql/test_util.py b/tests/components/sql/test_util.py index 737a5e4a41baa..f63626df6923b 100644 --- a/tests/components/sql/test_util.py +++ b/tests/components/sql/test_util.py @@ -1,10 +1,17 @@ """Test the sql utils.""" +from datetime import date +from decimal import Decimal + import pytest import voluptuous as vol from homeassistant.components.recorder import Recorder, get_instance -from homeassistant.components.sql.util import resolve_db_url, validate_sql_select +from homeassistant.components.sql.util import ( + ensure_serializable, + resolve_db_url, + validate_sql_select, +) from homeassistant.core import HomeAssistant @@ -64,3 +71,22 @@ async def test_invalid_sql_queries( """Test that various invalid or disallowed SQL queries raise the correct exception.""" with pytest.raises(vol.Invalid, match=expected_error_message): validate_sql_select(sql_query) + + +@pytest.mark.parametrize( + ("input", "expected_output"), + [ + (Decimal("199.99"), 199.99), + (date(2023, 1, 15), "2023-01-15"), + (b"\xde\xad\xbe\xef", "0xdeadbeef"), + ("deadbeef", "deadbeef"), + (199.99, 199.99), + (69, 69), + ], +) +async def test_data_conversion( + input: Decimal | date | bytes | str | float, + expected_output: str | float, +) -> None: + """Test data conversion to serializable type.""" + assert ensure_serializable(input) == expected_output From f264da97cfb6b6ccbf5a3b6efc43b1e2137ccdac Mon Sep 17 00:00:00 2001 From: David Rapan Date: Sun, 2 Nov 2025 15:22:55 +0100 Subject: [PATCH 2/4] Rename to convert_value and add datetime Signed-off-by: David Rapan --- homeassistant/components/sql/sensor.py | 4 ++-- homeassistant/components/sql/services.py | 4 ++-- homeassistant/components/sql/util.py | 8 ++++---- tests/components/sql/test_util.py | 9 +++++---- 4 files changed, 13 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/sql/sensor.py b/homeassistant/components/sql/sensor.py index 861c3a624f7ff..c8885cd2377bb 100644 --- a/homeassistant/components/sql/sensor.py +++ b/homeassistant/components/sql/sensor.py @@ -41,7 +41,7 @@ from .const import CONF_ADVANCED_OPTIONS, CONF_COLUMN_NAME, CONF_QUERY, DOMAIN from .util import ( async_create_sessionmaker, - ensure_serializable, + convert_value, generate_lambda_stmt, redact_credentials, resolve_db_url, @@ -270,7 +270,7 @@ def _update(self) -> None: _LOGGER.debug("Query %s result in %s", self._query, res.items()) data = res[self._column_name] for key, value in res.items(): - self._attr_extra_state_attributes[key] = ensure_serializable(value) + self._attr_extra_state_attributes[key] = convert_value(value) if data is not None and isinstance(data, (bytes, bytearray)): data = f"0x{data.hex()}" diff --git a/homeassistant/components/sql/services.py b/homeassistant/components/sql/services.py index 83db61f5bc565..dc31064d3ec49 100644 --- a/homeassistant/components/sql/services.py +++ b/homeassistant/components/sql/services.py @@ -24,7 +24,7 @@ from .const import CONF_QUERY, DOMAIN from .util import ( async_create_sessionmaker, - ensure_serializable, + convert_value, generate_lambda_stmt, redact_credentials, resolve_db_url, @@ -87,7 +87,7 @@ def _execute_and_convert_query() -> list[JsonValueType]: for row in result.mappings(): processed_row: dict[str, JsonValueType] = {} for key, value in row.items(): - processed_row[key] = ensure_serializable(value) + processed_row[key] = convert_value(value) rows.append(processed_row) return rows finally: diff --git a/homeassistant/components/sql/util.py b/homeassistant/components/sql/util.py index bb481839fed3f..5f33ecf017df8 100644 --- a/homeassistant/components/sql/util.py +++ b/homeassistant/components/sql/util.py @@ -2,7 +2,7 @@ from __future__ import annotations -from datetime import date +from datetime import date, datetime from decimal import Decimal import logging from typing import Any @@ -228,12 +228,12 @@ def generate_lambda_stmt(query: str) -> StatementLambdaElement: return lambda_stmt(lambda: text, lambda_cache=_SQL_LAMBDA_CACHE) -def ensure_serializable(value: Any) -> Any: - """Ensure value is serializable.""" +def convert_value(value: Any) -> Any: + """Convert value.""" match value: case Decimal(): return float(value) - case date(): + case date() | datetime(): return value.isoformat() case bytes() | bytearray(): return f"0x{value.hex()}" diff --git a/tests/components/sql/test_util.py b/tests/components/sql/test_util.py index f63626df6923b..c4b1afba9a952 100644 --- a/tests/components/sql/test_util.py +++ b/tests/components/sql/test_util.py @@ -1,6 +1,6 @@ """Test the sql utils.""" -from datetime import date +from datetime import UTC, date, datetime from decimal import Decimal import pytest @@ -8,7 +8,7 @@ from homeassistant.components.recorder import Recorder, get_instance from homeassistant.components.sql.util import ( - ensure_serializable, + convert_value, resolve_db_url, validate_sql_select, ) @@ -78,6 +78,7 @@ async def test_invalid_sql_queries( [ (Decimal("199.99"), 199.99), (date(2023, 1, 15), "2023-01-15"), + (datetime(2023, 1, 15, 12, 30, 45, tzinfo=UTC), "2023-01-15T12:30:45+00:00"), (b"\xde\xad\xbe\xef", "0xdeadbeef"), ("deadbeef", "deadbeef"), (199.99, 199.99), @@ -85,8 +86,8 @@ async def test_invalid_sql_queries( ], ) async def test_data_conversion( - input: Decimal | date | bytes | str | float, + input: Decimal | date | datetime | bytes | str | float, expected_output: str | float, ) -> None: """Test data conversion to serializable type.""" - assert ensure_serializable(input) == expected_output + assert convert_value(input) == expected_output From f5f9681d8a7a0f7d19736063cfd35d7f64e0fc45 Mon Sep 17 00:00:00 2001 From: David Rapan Date: Sun, 2 Nov 2025 15:33:05 +0100 Subject: [PATCH 3/4] Align test name and docs Signed-off-by: David Rapan --- tests/components/sql/test_util.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/components/sql/test_util.py b/tests/components/sql/test_util.py index c4b1afba9a952..7023fb17cc23b 100644 --- a/tests/components/sql/test_util.py +++ b/tests/components/sql/test_util.py @@ -85,9 +85,9 @@ async def test_invalid_sql_queries( (69, 69), ], ) -async def test_data_conversion( +async def test_value_conversion( input: Decimal | date | datetime | bytes | str | float, expected_output: str | float, ) -> None: - """Test data conversion to serializable type.""" + """Test value conversion.""" assert convert_value(input) == expected_output From 42930bb7da1cd7d67456fd1d4a9de91faef37282 Mon Sep 17 00:00:00 2001 From: David Rapan Date: Sun, 2 Nov 2025 15:50:41 +0100 Subject: [PATCH 4/4] Revert datetime addition to case Signed-off-by: David Rapan --- homeassistant/components/sql/util.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sql/util.py b/homeassistant/components/sql/util.py index 5f33ecf017df8..cc6f1bb5ea10a 100644 --- a/homeassistant/components/sql/util.py +++ b/homeassistant/components/sql/util.py @@ -2,7 +2,7 @@ from __future__ import annotations -from datetime import date, datetime +from datetime import date from decimal import Decimal import logging from typing import Any @@ -233,7 +233,7 @@ def convert_value(value: Any) -> Any: match value: case Decimal(): return float(value) - case date() | datetime(): + case date(): return value.isoformat() case bytes() | bytearray(): return f"0x{value.hex()}"