From 5b314142254c826f718b5b79f7de8f3da6e7c676 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 2 Jun 2022 17:52:53 -1000 Subject: [PATCH] Fix misalignments between sql based filtering with the entityfilter based filtering (#72936) --- homeassistant/components/recorder/filters.py | 110 +++- homeassistant/components/recorder/history.py | 6 +- tests/components/history/test_init.py | 19 +- tests/components/logbook/test_init.py | 22 +- .../test_filters_with_entityfilter.py | 516 ++++++++++++++++++ 5 files changed, 620 insertions(+), 53 deletions(-) create mode 100644 tests/components/recorder/test_filters_with_entityfilter.py diff --git a/homeassistant/components/recorder/filters.py b/homeassistant/components/recorder/filters.py index 3077f7f57f3..835496c2d6e 100644 --- a/homeassistant/components/recorder/filters.py +++ b/homeassistant/components/recorder/filters.py @@ -88,14 +88,32 @@ class Filters: self.included_domains: Iterable[str] = [] self.included_entity_globs: Iterable[str] = [] + def __repr__(self) -> str: + """Return human readable excludes/includes.""" + return ( + f"" + ) + @property def has_config(self) -> bool: """Determine if there is any filter configuration.""" + return bool(self._have_exclude or self._have_include) + + @property + def _have_exclude(self) -> bool: return bool( self.excluded_entities or self.excluded_domains or self.excluded_entity_globs - or self.included_entities + ) + + @property + def _have_include(self) -> bool: + return bool( + self.included_entities or self.included_domains or self.included_entity_globs ) @@ -103,36 +121,67 @@ class Filters: def _generate_filter_for_columns( self, columns: Iterable[Column], encoder: Callable[[Any], Any] ) -> ClauseList: - includes = [] - if self.included_domains: - includes.append(_domain_matcher(self.included_domains, columns, encoder)) - if self.included_entities: - includes.append(_entity_matcher(self.included_entities, columns, encoder)) - if self.included_entity_globs: - includes.append( - _globs_to_like(self.included_entity_globs, columns, encoder) - ) + """Generate a filter from pre-comuted sets and pattern lists. - excludes = [] - if self.excluded_domains: - excludes.append(_domain_matcher(self.excluded_domains, columns, encoder)) - if self.excluded_entities: - excludes.append(_entity_matcher(self.excluded_entities, columns, encoder)) - if self.excluded_entity_globs: - excludes.append( - _globs_to_like(self.excluded_entity_globs, columns, encoder) - ) + This must match exactly how homeassistant.helpers.entityfilter works. + """ + i_domains = _domain_matcher(self.included_domains, columns, encoder) + i_entities = _entity_matcher(self.included_entities, columns, encoder) + i_entity_globs = _globs_to_like(self.included_entity_globs, columns, encoder) + includes = [i_domains, i_entities, i_entity_globs] - if not includes and not excludes: + e_domains = _domain_matcher(self.excluded_domains, columns, encoder) + e_entities = _entity_matcher(self.excluded_entities, columns, encoder) + e_entity_globs = _globs_to_like(self.excluded_entity_globs, columns, encoder) + excludes = [e_domains, e_entities, e_entity_globs] + + have_exclude = self._have_exclude + have_include = self._have_include + + # Case 1 - no includes or excludes - pass all entities + if not have_include and not have_exclude: return None - if includes and not excludes: + # Case 2 - includes, no excludes - only include specified entities + if have_include and not have_exclude: return or_(*includes).self_group() - if not includes and excludes: + # Case 3 - excludes, no includes - only exclude specified entities + if not have_include and have_exclude: return not_(or_(*excludes).self_group()) - return or_(*includes).self_group() & not_(or_(*excludes).self_group()) + # Case 4 - both includes and excludes specified + # Case 4a - include domain or glob specified + # - if domain is included, pass if entity not excluded + # - if glob is included, pass if entity and domain not excluded + # - if domain and glob are not included, pass if entity is included + # note: if both include domain matches then exclude domains ignored. + # If glob matches then exclude domains and glob checked + if self.included_domains or self.included_entity_globs: + return or_( + (i_domains & ~(e_entities | e_entity_globs)), + ( + ~i_domains + & or_( + (i_entity_globs & ~(or_(*excludes))), + (~i_entity_globs & i_entities), + ) + ), + ).self_group() + + # Case 4b - exclude domain or glob specified, include has no domain or glob + # In this one case the traditional include logic is inverted. Even though an + # include is specified since its only a list of entity IDs its used only to + # expose specific entities excluded by domain or glob. Any entities not + # excluded are then presumed included. Logic is as follows + # - if domain or glob is excluded, pass if entity is included + # - if domain is not excluded, pass if entity not excluded by ID + if self.excluded_domains or self.excluded_entity_globs: + return (not_(or_(*excludes)) | i_entities).self_group() + + # Case 4c - neither include or exclude domain specified + # - Only pass if entity is included. Ignore entity excludes. + return i_entities def states_entity_filter(self) -> ClauseList: """Generate the entity filter query.""" @@ -158,29 +207,32 @@ def _globs_to_like( glob_strs: Iterable[str], columns: Iterable[Column], encoder: Callable[[Any], Any] ) -> ClauseList: """Translate glob to sql.""" - return or_( + matchers = [ cast(column, Text()).like( encoder(glob_str).translate(GLOB_TO_SQL_CHARS), escape="\\" ) for glob_str in glob_strs for column in columns - ) + ] + return or_(*matchers) if matchers else or_(False) def _entity_matcher( entity_ids: Iterable[str], columns: Iterable[Column], encoder: Callable[[Any], Any] ) -> ClauseList: - return or_( + matchers = [ cast(column, Text()).in_([encoder(entity_id) for entity_id in entity_ids]) for column in columns - ) + ] + return or_(*matchers) if matchers else or_(False) def _domain_matcher( domains: Iterable[str], columns: Iterable[Column], encoder: Callable[[Any], Any] ) -> ClauseList: - return or_( + matchers = [ cast(column, Text()).like(encoder(f"{domain}.%")) for domain in domains for column in columns - ) + ] + return or_(*matchers) if matchers else or_(False) diff --git a/homeassistant/components/recorder/history.py b/homeassistant/components/recorder/history.py index 7e8e97eafd4..49796bd0158 100644 --- a/homeassistant/components/recorder/history.py +++ b/homeassistant/components/recorder/history.py @@ -237,7 +237,9 @@ def _significant_states_stmt( stmt += _ignore_domains_filter if filters and filters.has_config: entity_filter = filters.states_entity_filter() - stmt += lambda q: q.filter(entity_filter) + stmt = stmt.add_criteria( + lambda q: q.filter(entity_filter), track_on=[filters] + ) stmt += lambda q: q.filter(States.last_updated > start_time) if end_time: @@ -529,7 +531,7 @@ def _get_states_for_all_stmt( stmt += _ignore_domains_filter if filters and filters.has_config: entity_filter = filters.states_entity_filter() - stmt += lambda q: q.filter(entity_filter) + stmt = stmt.add_criteria(lambda q: q.filter(entity_filter), track_on=[filters]) if join_attributes: stmt += lambda q: q.outerjoin( StateAttributes, (States.attributes_id == StateAttributes.attributes_id) diff --git a/tests/components/history/test_init.py b/tests/components/history/test_init.py index cbc5e86c37e..9dc7af59a38 100644 --- a/tests/components/history/test_init.py +++ b/tests/components/history/test_init.py @@ -246,15 +246,11 @@ def test_get_significant_states_exclude(hass_history): def test_get_significant_states_exclude_include_entity(hass_history): """Test significant states when excluding domains and include entities. - We should not get back every thermostat and media player test changes. + We should not get back every thermostat change unless its specifically included """ hass = hass_history zero, four, states = record_states(hass) - del states["media_player.test2"] - del states["media_player.test3"] - del states["thermostat.test"] del states["thermostat.test2"] - del states["script.can_cancel_this_one"] config = history.CONFIG_SCHEMA( { @@ -340,14 +336,12 @@ def test_get_significant_states_include(hass_history): def test_get_significant_states_include_exclude_domain(hass_history): """Test if significant states when excluding and including domains. - We should not get back any changes since we include only the - media_player domain but also exclude it. + We should get back all the media_player domain changes + only since the include wins over the exclude but will + exclude everything else. """ hass = hass_history zero, four, states = record_states(hass) - del states["media_player.test"] - del states["media_player.test2"] - del states["media_player.test3"] del states["thermostat.test"] del states["thermostat.test2"] del states["script.can_cancel_this_one"] @@ -372,7 +366,6 @@ def test_get_significant_states_include_exclude_entity(hass_history): """ hass = hass_history zero, four, states = record_states(hass) - del states["media_player.test"] del states["media_player.test2"] del states["media_player.test3"] del states["thermostat.test"] @@ -394,12 +387,12 @@ def test_get_significant_states_include_exclude_entity(hass_history): def test_get_significant_states_include_exclude(hass_history): """Test if significant states when in/excluding domains and entities. - We should only get back changes of the media_player.test2 entity. + We should get back changes of the media_player.test2, media_player.test3, + and thermostat.test. """ hass = hass_history zero, four, states = record_states(hass) del states["media_player.test"] - del states["thermostat.test"] del states["thermostat.test2"] del states["script.can_cancel_this_one"] diff --git a/tests/components/logbook/test_init.py b/tests/components/logbook/test_init.py index d33bbd5b8ac..651a00fb0cf 100644 --- a/tests/components/logbook/test_init.py +++ b/tests/components/logbook/test_init.py @@ -2037,7 +2037,7 @@ async def test_include_events_domain_glob(hass, hass_client, recorder_mock): _assert_entry(entries[3], name="included", entity_id=entity_id3) -async def test_include_exclude_events(hass, hass_client, recorder_mock): +async def test_include_exclude_events_no_globs(hass, hass_client, recorder_mock): """Test if events are filtered if include and exclude is configured.""" entity_id = "switch.bla" entity_id2 = "sensor.blu" @@ -2082,13 +2082,15 @@ async def test_include_exclude_events(hass, hass_client, recorder_mock): client = await hass_client() entries = await _async_fetch_logbook(client) - assert len(entries) == 4 + assert len(entries) == 6 _assert_entry( entries[0], name="Home Assistant", message="started", domain=ha.DOMAIN ) - _assert_entry(entries[1], name="blu", entity_id=entity_id2, state="10") - _assert_entry(entries[2], name="blu", entity_id=entity_id2, state="20") - _assert_entry(entries[3], name="keep", entity_id=entity_id4, state="10") + _assert_entry(entries[1], name="bla", entity_id=entity_id, state="10") + _assert_entry(entries[2], name="blu", entity_id=entity_id2, state="10") + _assert_entry(entries[3], name="bla", entity_id=entity_id, state="20") + _assert_entry(entries[4], name="blu", entity_id=entity_id2, state="20") + _assert_entry(entries[5], name="keep", entity_id=entity_id4, state="10") async def test_include_exclude_events_with_glob_filters( @@ -2145,13 +2147,15 @@ async def test_include_exclude_events_with_glob_filters( client = await hass_client() entries = await _async_fetch_logbook(client) - assert len(entries) == 4 + assert len(entries) == 6 _assert_entry( entries[0], name="Home Assistant", message="started", domain=ha.DOMAIN ) - _assert_entry(entries[1], name="blu", entity_id=entity_id2, state="10") - _assert_entry(entries[2], name="blu", entity_id=entity_id2, state="20") - _assert_entry(entries[3], name="included", entity_id=entity_id4, state="30") + _assert_entry(entries[1], name="bla", entity_id=entity_id, state="10") + _assert_entry(entries[2], name="blu", entity_id=entity_id2, state="10") + _assert_entry(entries[3], name="bla", entity_id=entity_id, state="20") + _assert_entry(entries[4], name="blu", entity_id=entity_id2, state="20") + _assert_entry(entries[5], name="included", entity_id=entity_id4, state="30") async def test_empty_config(hass, hass_client, recorder_mock): diff --git a/tests/components/recorder/test_filters_with_entityfilter.py b/tests/components/recorder/test_filters_with_entityfilter.py new file mode 100644 index 00000000000..0758d6fdc95 --- /dev/null +++ b/tests/components/recorder/test_filters_with_entityfilter.py @@ -0,0 +1,516 @@ +"""The tests for the recorder filter matching the EntityFilter component.""" +import json + +from sqlalchemy import select +from sqlalchemy.engine.row import Row + +from homeassistant.components.recorder import get_instance +from homeassistant.components.recorder.filters import ( + Filters, + extract_include_exclude_filter_conf, + sqlalchemy_filter_from_include_exclude_conf, +) +from homeassistant.components.recorder.models import EventData, States +from homeassistant.components.recorder.util import session_scope +from homeassistant.const import ATTR_ENTITY_ID, STATE_ON +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entityfilter import ( + CONF_DOMAINS, + CONF_ENTITIES, + CONF_ENTITY_GLOBS, + CONF_EXCLUDE, + CONF_INCLUDE, + convert_include_exclude_filter, +) + +from .common import async_wait_recording_done + + +async def _async_get_states_and_events_with_filter( + hass: HomeAssistant, sqlalchemy_filter: Filters, entity_ids: set[str] +) -> tuple[list[Row], list[Row]]: + """Get states from the database based on a filter.""" + for entity_id in entity_ids: + hass.states.async_set(entity_id, STATE_ON) + hass.bus.async_fire("any", {ATTR_ENTITY_ID: entity_id}) + + await async_wait_recording_done(hass) + + def _get_states_with_session(): + with session_scope(hass=hass) as session: + return session.execute( + select(States.entity_id).filter( + sqlalchemy_filter.states_entity_filter() + ) + ).all() + + filtered_states_entity_ids = { + row[0] + for row in await get_instance(hass).async_add_executor_job( + _get_states_with_session + ) + } + + def _get_events_with_session(): + with session_scope(hass=hass) as session: + return session.execute( + select(EventData.shared_data).filter( + sqlalchemy_filter.events_entity_filter() + ) + ).all() + + filtered_events_entity_ids = set() + for row in await get_instance(hass).async_add_executor_job( + _get_events_with_session + ): + event_data = json.loads(row[0]) + if ATTR_ENTITY_ID not in event_data: + continue + filtered_events_entity_ids.add(json.loads(row[0])[ATTR_ENTITY_ID]) + + return filtered_states_entity_ids, filtered_events_entity_ids + + +async def test_included_and_excluded_simple_case_no_domains(hass, recorder_mock): + """Test filters with included and excluded without domains.""" + filter_accept = {"sensor.kitchen4", "switch.kitchen"} + filter_reject = { + "light.any", + "switch.other", + "cover.any", + "sensor.weather5", + "light.kitchen", + } + conf = { + CONF_INCLUDE: { + CONF_ENTITY_GLOBS: ["sensor.kitchen*"], + CONF_ENTITIES: ["switch.kitchen"], + }, + CONF_EXCLUDE: { + CONF_ENTITY_GLOBS: ["sensor.weather*"], + CONF_ENTITIES: ["light.kitchen"], + }, + } + + extracted_filter = extract_include_exclude_filter_conf(conf) + entity_filter = convert_include_exclude_filter(extracted_filter) + sqlalchemy_filter = sqlalchemy_filter_from_include_exclude_conf(extracted_filter) + assert sqlalchemy_filter is not None + + for entity_id in filter_accept: + assert entity_filter(entity_id) is True + + for entity_id in filter_reject: + assert entity_filter(entity_id) is False + + assert not entity_filter.explicitly_included("light.any") + assert not entity_filter.explicitly_included("switch.other") + assert entity_filter.explicitly_included("sensor.kitchen4") + assert entity_filter.explicitly_included("switch.kitchen") + + assert not entity_filter.explicitly_excluded("light.any") + assert not entity_filter.explicitly_excluded("switch.other") + assert entity_filter.explicitly_excluded("sensor.weather5") + assert entity_filter.explicitly_excluded("light.kitchen") + + ( + filtered_states_entity_ids, + filtered_events_entity_ids, + ) = await _async_get_states_and_events_with_filter( + hass, sqlalchemy_filter, filter_accept | filter_reject + ) + + assert filtered_states_entity_ids == filter_accept + assert not filtered_states_entity_ids.intersection(filter_reject) + + assert filtered_events_entity_ids == filter_accept + assert not filtered_events_entity_ids.intersection(filter_reject) + + +async def test_included_and_excluded_simple_case_no_globs(hass, recorder_mock): + """Test filters with included and excluded without globs.""" + filter_accept = {"switch.bla", "sensor.blu", "sensor.keep"} + filter_reject = {"sensor.bli"} + conf = { + CONF_INCLUDE: { + CONF_DOMAINS: ["sensor", "homeassistant"], + CONF_ENTITIES: ["switch.bla"], + }, + CONF_EXCLUDE: { + CONF_DOMAINS: ["switch"], + CONF_ENTITIES: ["sensor.bli"], + }, + } + + extracted_filter = extract_include_exclude_filter_conf(conf) + entity_filter = convert_include_exclude_filter(extracted_filter) + sqlalchemy_filter = sqlalchemy_filter_from_include_exclude_conf(extracted_filter) + assert sqlalchemy_filter is not None + + for entity_id in filter_accept: + assert entity_filter(entity_id) is True + + for entity_id in filter_reject: + assert entity_filter(entity_id) is False + + ( + filtered_states_entity_ids, + filtered_events_entity_ids, + ) = await _async_get_states_and_events_with_filter( + hass, sqlalchemy_filter, filter_accept | filter_reject + ) + + assert filtered_states_entity_ids == filter_accept + assert not filtered_states_entity_ids.intersection(filter_reject) + + assert filtered_events_entity_ids == filter_accept + assert not filtered_events_entity_ids.intersection(filter_reject) + + +async def test_included_and_excluded_simple_case_without_underscores( + hass, recorder_mock +): + """Test filters with included and excluded without underscores.""" + filter_accept = {"light.any", "sensor.kitchen4", "switch.kitchen"} + filter_reject = {"switch.other", "cover.any", "sensor.weather5", "light.kitchen"} + conf = { + CONF_INCLUDE: { + CONF_DOMAINS: ["light"], + CONF_ENTITY_GLOBS: ["sensor.kitchen*"], + CONF_ENTITIES: ["switch.kitchen"], + }, + CONF_EXCLUDE: { + CONF_DOMAINS: ["cover"], + CONF_ENTITY_GLOBS: ["sensor.weather*"], + CONF_ENTITIES: ["light.kitchen"], + }, + } + + extracted_filter = extract_include_exclude_filter_conf(conf) + entity_filter = convert_include_exclude_filter(extracted_filter) + sqlalchemy_filter = sqlalchemy_filter_from_include_exclude_conf(extracted_filter) + assert sqlalchemy_filter is not None + + for entity_id in filter_accept: + assert entity_filter(entity_id) is True + + for entity_id in filter_reject: + assert entity_filter(entity_id) is False + + assert not entity_filter.explicitly_included("light.any") + assert not entity_filter.explicitly_included("switch.other") + assert entity_filter.explicitly_included("sensor.kitchen4") + assert entity_filter.explicitly_included("switch.kitchen") + + assert not entity_filter.explicitly_excluded("light.any") + assert not entity_filter.explicitly_excluded("switch.other") + assert entity_filter.explicitly_excluded("sensor.weather5") + assert entity_filter.explicitly_excluded("light.kitchen") + + ( + filtered_states_entity_ids, + filtered_events_entity_ids, + ) = await _async_get_states_and_events_with_filter( + hass, sqlalchemy_filter, filter_accept | filter_reject + ) + + assert filtered_states_entity_ids == filter_accept + assert not filtered_states_entity_ids.intersection(filter_reject) + + assert filtered_events_entity_ids == filter_accept + assert not filtered_events_entity_ids.intersection(filter_reject) + + +async def test_included_and_excluded_simple_case_with_underscores(hass, recorder_mock): + """Test filters with included and excluded with underscores.""" + filter_accept = {"light.any", "sensor.kitchen_4", "switch.kitchen"} + filter_reject = {"switch.other", "cover.any", "sensor.weather_5", "light.kitchen"} + conf = { + CONF_INCLUDE: { + CONF_DOMAINS: ["light"], + CONF_ENTITY_GLOBS: ["sensor.kitchen_*"], + CONF_ENTITIES: ["switch.kitchen"], + }, + CONF_EXCLUDE: { + CONF_DOMAINS: ["cover"], + CONF_ENTITY_GLOBS: ["sensor.weather_*"], + CONF_ENTITIES: ["light.kitchen"], + }, + } + + extracted_filter = extract_include_exclude_filter_conf(conf) + entity_filter = convert_include_exclude_filter(extracted_filter) + sqlalchemy_filter = sqlalchemy_filter_from_include_exclude_conf(extracted_filter) + assert sqlalchemy_filter is not None + + for entity_id in filter_accept: + assert entity_filter(entity_id) is True + + for entity_id in filter_reject: + assert entity_filter(entity_id) is False + + assert not entity_filter.explicitly_included("light.any") + assert not entity_filter.explicitly_included("switch.other") + assert entity_filter.explicitly_included("sensor.kitchen_4") + assert entity_filter.explicitly_included("switch.kitchen") + + assert not entity_filter.explicitly_excluded("light.any") + assert not entity_filter.explicitly_excluded("switch.other") + assert entity_filter.explicitly_excluded("sensor.weather_5") + assert entity_filter.explicitly_excluded("light.kitchen") + + ( + filtered_states_entity_ids, + filtered_events_entity_ids, + ) = await _async_get_states_and_events_with_filter( + hass, sqlalchemy_filter, filter_accept | filter_reject + ) + + assert filtered_states_entity_ids == filter_accept + assert not filtered_states_entity_ids.intersection(filter_reject) + + assert filtered_events_entity_ids == filter_accept + assert not filtered_events_entity_ids.intersection(filter_reject) + + +async def test_included_and_excluded_complex_case(hass, recorder_mock): + """Test filters with included and excluded with a complex filter.""" + filter_accept = {"light.any", "sensor.kitchen_4", "switch.kitchen"} + filter_reject = { + "camera.one", + "notify.any", + "automation.update_readme", + "automation.update_utilities_cost", + "binary_sensor.iss", + } + conf = { + CONF_INCLUDE: { + CONF_ENTITIES: ["group.trackers"], + }, + CONF_EXCLUDE: { + CONF_ENTITIES: [ + "automation.update_readme", + "automation.update_utilities_cost", + "binary_sensor.iss", + ], + CONF_DOMAINS: [ + "camera", + "group", + "media_player", + "notify", + "scene", + "sun", + "zone", + ], + }, + } + + extracted_filter = extract_include_exclude_filter_conf(conf) + entity_filter = convert_include_exclude_filter(extracted_filter) + sqlalchemy_filter = sqlalchemy_filter_from_include_exclude_conf(extracted_filter) + assert sqlalchemy_filter is not None + + for entity_id in filter_accept: + assert entity_filter(entity_id) is True + + for entity_id in filter_reject: + assert entity_filter(entity_id) is False + + ( + filtered_states_entity_ids, + filtered_events_entity_ids, + ) = await _async_get_states_and_events_with_filter( + hass, sqlalchemy_filter, filter_accept | filter_reject + ) + + assert filtered_states_entity_ids == filter_accept + assert not filtered_states_entity_ids.intersection(filter_reject) + + assert filtered_events_entity_ids == filter_accept + assert not filtered_events_entity_ids.intersection(filter_reject) + + +async def test_included_entities_and_excluded_domain(hass, recorder_mock): + """Test filters with included entities and excluded domain.""" + filter_accept = { + "media_player.test", + "media_player.test3", + "thermostat.test", + "zone.home", + "script.can_cancel_this_one", + } + filter_reject = { + "thermostat.test2", + } + conf = { + CONF_INCLUDE: { + CONF_ENTITIES: ["media_player.test", "thermostat.test"], + }, + CONF_EXCLUDE: { + CONF_DOMAINS: ["thermostat"], + }, + } + + extracted_filter = extract_include_exclude_filter_conf(conf) + entity_filter = convert_include_exclude_filter(extracted_filter) + sqlalchemy_filter = sqlalchemy_filter_from_include_exclude_conf(extracted_filter) + assert sqlalchemy_filter is not None + + for entity_id in filter_accept: + assert entity_filter(entity_id) is True + + for entity_id in filter_reject: + assert entity_filter(entity_id) is False + + ( + filtered_states_entity_ids, + filtered_events_entity_ids, + ) = await _async_get_states_and_events_with_filter( + hass, sqlalchemy_filter, filter_accept | filter_reject + ) + + assert filtered_states_entity_ids == filter_accept + assert not filtered_states_entity_ids.intersection(filter_reject) + + assert filtered_events_entity_ids == filter_accept + assert not filtered_events_entity_ids.intersection(filter_reject) + + +async def test_same_domain_included_excluded(hass, recorder_mock): + """Test filters with the same domain included and excluded.""" + filter_accept = { + "media_player.test", + "media_player.test3", + } + filter_reject = { + "thermostat.test2", + "thermostat.test", + "zone.home", + "script.can_cancel_this_one", + } + conf = { + CONF_INCLUDE: { + CONF_DOMAINS: ["media_player"], + }, + CONF_EXCLUDE: { + CONF_DOMAINS: ["media_player"], + }, + } + + extracted_filter = extract_include_exclude_filter_conf(conf) + entity_filter = convert_include_exclude_filter(extracted_filter) + sqlalchemy_filter = sqlalchemy_filter_from_include_exclude_conf(extracted_filter) + assert sqlalchemy_filter is not None + + for entity_id in filter_accept: + assert entity_filter(entity_id) is True + + for entity_id in filter_reject: + assert entity_filter(entity_id) is False + + ( + filtered_states_entity_ids, + filtered_events_entity_ids, + ) = await _async_get_states_and_events_with_filter( + hass, sqlalchemy_filter, filter_accept | filter_reject + ) + + assert filtered_states_entity_ids == filter_accept + assert not filtered_states_entity_ids.intersection(filter_reject) + + assert filtered_events_entity_ids == filter_accept + assert not filtered_events_entity_ids.intersection(filter_reject) + + +async def test_same_entity_included_excluded(hass, recorder_mock): + """Test filters with the same entity included and excluded.""" + filter_accept = { + "media_player.test", + } + filter_reject = { + "media_player.test3", + "thermostat.test2", + "thermostat.test", + "zone.home", + "script.can_cancel_this_one", + } + conf = { + CONF_INCLUDE: { + CONF_ENTITIES: ["media_player.test"], + }, + CONF_EXCLUDE: { + CONF_ENTITIES: ["media_player.test"], + }, + } + + extracted_filter = extract_include_exclude_filter_conf(conf) + entity_filter = convert_include_exclude_filter(extracted_filter) + sqlalchemy_filter = sqlalchemy_filter_from_include_exclude_conf(extracted_filter) + assert sqlalchemy_filter is not None + + for entity_id in filter_accept: + assert entity_filter(entity_id) is True + + for entity_id in filter_reject: + assert entity_filter(entity_id) is False + + ( + filtered_states_entity_ids, + filtered_events_entity_ids, + ) = await _async_get_states_and_events_with_filter( + hass, sqlalchemy_filter, filter_accept | filter_reject + ) + + assert filtered_states_entity_ids == filter_accept + assert not filtered_states_entity_ids.intersection(filter_reject) + + assert filtered_events_entity_ids == filter_accept + assert not filtered_events_entity_ids.intersection(filter_reject) + + +async def test_same_entity_included_excluded_include_domain_wins(hass, recorder_mock): + """Test filters with domain and entities and the include domain wins.""" + filter_accept = { + "media_player.test2", + "media_player.test3", + "thermostat.test", + } + filter_reject = { + "thermostat.test2", + "zone.home", + "script.can_cancel_this_one", + } + conf = { + CONF_INCLUDE: { + CONF_DOMAINS: ["media_player"], + CONF_ENTITIES: ["thermostat.test"], + }, + CONF_EXCLUDE: { + CONF_DOMAINS: ["thermostat"], + CONF_ENTITIES: ["media_player.test"], + }, + } + + extracted_filter = extract_include_exclude_filter_conf(conf) + entity_filter = convert_include_exclude_filter(extracted_filter) + sqlalchemy_filter = sqlalchemy_filter_from_include_exclude_conf(extracted_filter) + assert sqlalchemy_filter is not None + + for entity_id in filter_accept: + assert entity_filter(entity_id) is True + + for entity_id in filter_reject: + assert entity_filter(entity_id) is False + + ( + filtered_states_entity_ids, + filtered_events_entity_ids, + ) = await _async_get_states_and_events_with_filter( + hass, sqlalchemy_filter, filter_accept | filter_reject + ) + + assert filtered_states_entity_ids == filter_accept + assert not filtered_states_entity_ids.intersection(filter_reject) + + assert filtered_events_entity_ids == filter_accept + assert not filtered_events_entity_ids.intersection(filter_reject)