From a82743cf65d7e54665dbd6a871d1970c5be96d03 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sun, 30 Aug 2026 20:37:15 +0200 Subject: [PATCH] Use positional row access in recorder history queries (#175532) --- .../components/recorder/history/__init__.py | 58 +++++++- .../components/recorder/models/state.py | 41 +++--- tests/components/recorder/test_models.py | 125 ++++++++---------- 3 files changed, 125 insertions(+), 99 deletions(-) diff --git a/homeassistant/components/recorder/history/__init__.py b/homeassistant/components/recorder/history/__init__.py index 454662121f83..c2e325e1ddd0 100644 --- a/homeassistant/components/recorder/history/__init__.py +++ b/homeassistant/components/recorder/history/__init__.py @@ -83,6 +83,28 @@ def _stmt_and_join_attributes( return _select +def _row_field_indices( + no_attributes: bool, + include_last_changed: bool, + include_last_reported: bool, +) -> tuple[int | None, int | None, int | None]: + """Return the (last_changed_ts, last_reported_ts, attributes) row indices. + + Must match the column order selected by _stmt_and_join_attributes. + """ + next_idx = len(_FIELD_MAP) + last_changed_ts_idx = last_reported_ts_idx = attributes_idx = None + if include_last_changed: + last_changed_ts_idx = next_idx + next_idx += 1 + if include_last_reported: + last_reported_ts_idx = next_idx + next_idx += 1 + if not no_attributes: + attributes_idx = next_idx + return last_changed_ts_idx, last_reported_ts_idx, attributes_idx + + def _stmt_and_join_attributes_for_start_state( no_attributes: bool, include_last_changed: bool, @@ -316,6 +338,9 @@ def get_significant_states_with_session( minimal_response, compressed_state_format, no_attributes=no_attributes, + field_indices=_row_field_indices( + no_attributes, not significant_changes_only, False + ), ) @@ -513,6 +538,7 @@ def state_changes_during_period( entity_id_to_metadata_id, descending=descending, no_attributes=no_attributes, + field_indices=_row_field_indices(no_attributes, False, True), ), ) @@ -607,6 +633,7 @@ def get_last_state_changes( entity_ids, entity_id_to_metadata_id, no_attributes=False, + field_indices=_row_field_indices(False, False, number_of_states > 1), ), ) @@ -796,6 +823,8 @@ def _sorted_states_to_dict( compressed_state_format: bool = False, descending: bool = False, no_attributes: bool = False, + *, + field_indices: tuple[int | None, int | None, int | None], ) -> dict[str, list[State | dict[str, Any]]]: """Convert SQL results into JSON friendly data structure. @@ -809,8 +838,19 @@ def _sorted_states_to_dict( axis correctly. """ field_map = _FIELD_MAP + last_changed_ts_idx, last_reported_ts_idx, attributes_idx = field_indices state_class: Callable[ - [Row, dict[str, dict[str, Any]], float | None, str, str, float | None, bool], + [ + dict[str, dict[str, Any]], + float | None, + str, + str, + float | None, + Any, + float | None, + float | None, + bool, + ], State | dict[str, Any], ] if compressed_state_format: @@ -856,12 +896,18 @@ def _sorted_states_to_dict( ent_results.extend( [ state_class( - db_state, attr_cache, start_time_ts, entity_id, db_state[state_idx], db_state[last_updated_ts_idx], + None if attributes_idx is None else db_state[attributes_idx], + None + if last_changed_ts_idx is None + else db_state[last_changed_ts_idx], + None + if last_reported_ts_idx is None + else db_state[last_reported_ts_idx], False, ) for db_state in group @@ -880,12 +926,18 @@ def _sorted_states_to_dict( prev_state = first_state[state_idx] ent_results.append( state_class( - first_state, attr_cache, start_time_ts, entity_id, prev_state, first_state[last_updated_ts_idx], + None if attributes_idx is None else first_state[attributes_idx], + None + if last_changed_ts_idx is None + else first_state[last_changed_ts_idx], + None + if last_reported_ts_idx is None + else first_state[last_reported_ts_idx], no_attributes, ) ) diff --git a/homeassistant/components/recorder/models/state.py b/homeassistant/components/recorder/models/state.py index 991ef309aadc..f80f53111e34 100644 --- a/homeassistant/components/recorder/models/state.py +++ b/homeassistant/components/recorder/models/state.py @@ -5,7 +5,6 @@ import logging from typing import TYPE_CHECKING, Any, override from propcache.api import cached_property -from sqlalchemy.engine.row import Row from homeassistant.const import ( COMPRESSED_STATE_ATTRIBUTES, @@ -39,20 +38,24 @@ class LazyState(State): def __init__( # pylint: disable=super-init-not-called self, - row: Row, attr_cache: dict[str, dict[str, Any]], start_time_ts: float | None, entity_id: str, state: str, last_updated_ts: float | None, - no_attributes: bool, + attributes_source: Any = None, + last_changed_ts: float | None = None, + last_reported_ts: float | None = None, + no_attributes: bool = False, ) -> None: """Init the lazy state.""" - self._row = row + self._attributes_source = attributes_source self.entity_id = entity_id self.state = state or "" self._attributes: dict[str, Any] | None = None self._last_updated_ts: float | None = last_updated_ts or start_time_ts + self._last_changed_ts = last_changed_ts + self._last_reported_ts = last_reported_ts self.attr_cache = attr_cache self.context = EMPTY_CONTEXT @@ -60,14 +63,7 @@ class LazyState(State): @override def attributes(self) -> dict[str, Any]: # type: ignore[override] """State attributes.""" - return decode_attributes_from_source( - getattr(self._row, "attributes", None), self.attr_cache - ) - - @cached_property - def _last_changed_ts(self) -> float | None: - """Last changed timestamp.""" - return getattr(self._row, "last_changed_ts", None) + return decode_attributes_from_source(self._attributes_source, self.attr_cache) @cached_property @override @@ -77,11 +73,6 @@ class LazyState(State): self._last_changed_ts or self._last_updated_ts # type: ignore[arg-type] ) - @cached_property - def _last_reported_ts(self) -> float | None: - """Last reported timestamp.""" - return getattr(self._row, "last_reported_ts", None) - @cached_property @override def last_reported(self) -> datetime: # type: ignore[override] @@ -147,26 +138,24 @@ class LazyState(State): def row_to_compressed_state( - row: Row, attr_cache: dict[str, dict[str, Any]], start_time_ts: float | None, entity_id: str, state: str, last_updated_ts: float | None, - no_attributes: bool, + attributes_source: Any = None, + last_changed_ts: float | None = None, + last_reported_ts: float | None = None, + no_attributes: bool = False, ) -> dict[str, Any]: """Convert a database row to a compressed state schema 41 and later.""" comp_state: dict[str, Any] = {COMPRESSED_STATE_STATE: state} if not no_attributes: comp_state[COMPRESSED_STATE_ATTRIBUTES] = decode_attributes_from_source( - getattr(row, "attributes", None), attr_cache + attributes_source, attr_cache ) row_last_updated_ts: float = last_updated_ts or start_time_ts # type: ignore[assignment] comp_state[COMPRESSED_STATE_LAST_UPDATED] = row_last_updated_ts - if ( - (row_last_changed_ts := getattr(row, "last_changed_ts", None)) - and row_last_changed_ts - and row_last_updated_ts != row_last_changed_ts - ): - comp_state[COMPRESSED_STATE_LAST_CHANGED] = row_last_changed_ts + if last_changed_ts and row_last_updated_ts != last_changed_ts: + comp_state[COMPRESSED_STATE_LAST_CHANGED] = last_changed_ts return comp_state diff --git a/tests/components/recorder/test_models.py b/tests/components/recorder/test_models.py index 24c79b73a4e0..d9d103a951fd 100644 --- a/tests/components/recorder/test_models.py +++ b/tests/components/recorder/test_models.py @@ -1,7 +1,6 @@ """The tests for the Recorder component.""" from datetime import datetime, timedelta -from unittest.mock import PropertyMock import pytest @@ -301,42 +300,31 @@ async def test_lazy_state_handles_include_json( caplog: pytest.LogCaptureFixture, ) -> None: """Test that the LazyState class handles invalid json.""" - row = PropertyMock( - entity_id="sensor.invalid", - shared_attrs="{INVALID_JSON}", - ) - assert LazyState(row, {}, None, row.entity_id, "", 1, False).attributes == {} + lstate = LazyState({}, None, "sensor.invalid", "", 1, "{INVALID_JSON}") + assert lstate.attributes == {} assert "Error converting row to state attributes" in caplog.text -async def test_lazy_state_can_decode_attributes( - caplog: pytest.LogCaptureFixture, -) -> None: +async def test_lazy_state_can_decode_attributes() -> None: """Test that the LazyState prefers can decode attributes.""" - row = PropertyMock( - entity_id="sensor.invalid", - attributes='{"shared":true}', - ) - assert LazyState(row, {}, None, row.entity_id, "", 1, False).attributes == { - "shared": True - } + lstate = LazyState({}, None, "sensor.invalid", "", 1, '{"shared":true}') + assert lstate.attributes == {"shared": True} -async def test_lazy_state_handles_different_last_updated_and_last_changed( - caplog: pytest.LogCaptureFixture, -) -> None: +async def test_lazy_state_handles_different_last_updated_and_last_changed() -> None: """Test that the LazyState handles different last_updated and last_changed.""" now = datetime(2021, 6, 12, 3, 4, 1, 323, tzinfo=dt_util.UTC) - row = PropertyMock( - entity_id="sensor.valid", - state="off", - attributes='{"shared":true}', - last_updated_ts=now.timestamp(), - last_reported_ts=now.timestamp(), - last_changed_ts=(now - timedelta(seconds=60)).timestamp(), - ) + last_updated_ts = now.timestamp() + last_changed_ts = (now - timedelta(seconds=60)).timestamp() lstate = LazyState( - row, {}, None, row.entity_id, row.state, row.last_updated_ts, False + {}, + None, + "sensor.valid", + "off", + last_updated_ts, + '{"shared":true}', + last_changed_ts, + last_updated_ts, ) assert lstate.as_dict() == { "attributes": {"shared": True}, @@ -345,9 +333,9 @@ async def test_lazy_state_handles_different_last_updated_and_last_changed( "last_updated": "2021-06-12T03:04:01.000323+00:00", "state": "off", } - assert lstate.last_updated.timestamp() == row.last_updated_ts - assert lstate.last_changed.timestamp() == row.last_changed_ts - assert lstate.last_reported.timestamp() == row.last_updated_ts + assert lstate.last_updated.timestamp() == last_updated_ts + assert lstate.last_changed.timestamp() == last_changed_ts + assert lstate.last_reported.timestamp() == last_updated_ts assert lstate.as_dict() == { "attributes": {"shared": True}, "entity_id": "sensor.valid", @@ -355,26 +343,24 @@ async def test_lazy_state_handles_different_last_updated_and_last_changed( "last_updated": "2021-06-12T03:04:01.000323+00:00", "state": "off", } - assert lstate.last_changed_timestamp == row.last_changed_ts - assert lstate.last_updated_timestamp == row.last_updated_ts - assert lstate.last_reported_timestamp == row.last_updated_ts + assert lstate.last_changed_timestamp == last_changed_ts + assert lstate.last_updated_timestamp == last_updated_ts + assert lstate.last_reported_timestamp == last_updated_ts -async def test_lazy_state_handles_same_last_updated_and_last_changed( - caplog: pytest.LogCaptureFixture, -) -> None: +async def test_lazy_state_handles_same_last_updated_and_last_changed() -> None: """Test that the LazyState handles same last_updated and last_changed.""" now = datetime(2021, 6, 12, 3, 4, 1, 323, tzinfo=dt_util.UTC) - row = PropertyMock( - entity_id="sensor.valid", - state="off", - attributes='{"shared":true}', - last_updated_ts=now.timestamp(), - last_changed_ts=now.timestamp(), - last_reported_ts=None, - ) + last_updated_ts = now.timestamp() lstate = LazyState( - row, {}, None, row.entity_id, row.state, row.last_updated_ts, False + {}, + None, + "sensor.valid", + "off", + last_updated_ts, + '{"shared":true}', + last_updated_ts, + None, ) assert lstate.as_dict() == { "attributes": {"shared": True}, @@ -383,9 +369,9 @@ async def test_lazy_state_handles_same_last_updated_and_last_changed( "last_updated": "2021-06-12T03:04:01.000323+00:00", "state": "off", } - assert lstate.last_updated.timestamp() == row.last_updated_ts - assert lstate.last_changed.timestamp() == row.last_changed_ts - assert lstate.last_reported.timestamp() == row.last_updated_ts + assert lstate.last_updated.timestamp() == last_updated_ts + assert lstate.last_changed.timestamp() == last_updated_ts + assert lstate.last_reported.timestamp() == last_updated_ts assert lstate.as_dict() == { "attributes": {"shared": True}, "entity_id": "sensor.valid", @@ -393,26 +379,25 @@ async def test_lazy_state_handles_same_last_updated_and_last_changed( "last_updated": "2021-06-12T03:04:01.000323+00:00", "state": "off", } - assert lstate.last_changed_timestamp == row.last_changed_ts - assert lstate.last_updated_timestamp == row.last_updated_ts - assert lstate.last_reported_timestamp == row.last_updated_ts + assert lstate.last_changed_timestamp == last_updated_ts + assert lstate.last_updated_timestamp == last_updated_ts + assert lstate.last_reported_timestamp == last_updated_ts -async def test_lazy_state_handles_different_last_reported( - caplog: pytest.LogCaptureFixture, -) -> None: +async def test_lazy_state_handles_different_last_reported() -> None: """Test that the LazyState handles last_reported different from last_updated.""" now = datetime(2021, 6, 12, 3, 4, 1, 323, tzinfo=dt_util.UTC) - row = PropertyMock( - entity_id="sensor.valid", - state="off", - attributes='{"shared":true}', - last_updated_ts=(now - timedelta(seconds=60)).timestamp(), - last_reported_ts=now.timestamp(), - last_changed_ts=(now - timedelta(seconds=60)).timestamp(), - ) + last_reported_ts = now.timestamp() + last_updated_ts = (now - timedelta(seconds=60)).timestamp() lstate = LazyState( - row, {}, None, row.entity_id, row.state, row.last_updated_ts, False + {}, + None, + "sensor.valid", + "off", + last_updated_ts, + '{"shared":true}', + last_updated_ts, + last_reported_ts, ) assert lstate.as_dict() == { "attributes": {"shared": True}, @@ -421,9 +406,9 @@ async def test_lazy_state_handles_different_last_reported( "last_updated": "2021-06-12T03:03:01.000323+00:00", "state": "off", } - assert lstate.last_updated.timestamp() == row.last_updated_ts - assert lstate.last_changed.timestamp() == row.last_changed_ts - assert lstate.last_reported.timestamp() == row.last_reported_ts - assert lstate.last_changed_timestamp == row.last_changed_ts - assert lstate.last_updated_timestamp == row.last_updated_ts - assert lstate.last_reported_timestamp == row.last_reported_ts + assert lstate.last_updated.timestamp() == last_updated_ts + assert lstate.last_changed.timestamp() == last_updated_ts + assert lstate.last_reported.timestamp() == last_reported_ts + assert lstate.last_changed_timestamp == last_updated_ts + assert lstate.last_updated_timestamp == last_updated_ts + assert lstate.last_reported_timestamp == last_reported_ts