diff --git a/homeassistant/components/sql/__init__.py b/homeassistant/components/sql/__init__.py index 33ed64be2bf6..dfca388e99e9 100644 --- a/homeassistant/components/sql/__init__.py +++ b/homeassistant/components/sql/__init__.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +from typing import Any import sqlparse import voluptuous as vol @@ -32,7 +33,13 @@ from homeassistant.helpers.trigger_template_entity import ( ) from homeassistant.helpers.typing import ConfigType -from .const import CONF_COLUMN_NAME, CONF_QUERY, DOMAIN, PLATFORMS +from .const import ( + CONF_ADVANCED_OPTIONS, + CONF_COLUMN_NAME, + CONF_QUERY, + DOMAIN, + PLATFORMS, +) from .util import redact_credentials _LOGGER = logging.getLogger(__name__) @@ -75,18 +82,6 @@ CONFIG_SCHEMA = vol.Schema( ) -def remove_configured_db_url_if_not_needed( - hass: HomeAssistant, entry: ConfigEntry -) -> None: - """Remove db url from config if it matches recorder database.""" - hass.config_entries.async_update_entry( - entry, - options={ - key: value for key, value in entry.options.items() if key != CONF_DB_URL - }, - ) - - async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up SQL from yaml config.""" if (conf := config.get(DOMAIN)) is None: @@ -107,8 +102,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: redact_credentials(entry.options.get(CONF_DB_URL)), redact_credentials(get_instance(hass).db_url), ) - if entry.options.get(CONF_DB_URL) == get_instance(hass).db_url: - remove_configured_db_url_if_not_needed(hass, entry) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -119,3 +112,47 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload SQL config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Migrate old entry.""" + _LOGGER.debug("Migrating from version %s.%s", entry.version, entry.minor_version) + + if entry.version > 1: + # This means the user has downgraded from a future version + return False + + if entry.version == 1: + old_options = {**entry.options} + new_data = {} + new_options: dict[str, Any] = {} + + if (db_url := old_options.get(CONF_DB_URL)) and db_url != get_instance( + hass + ).db_url: + new_data[CONF_DB_URL] = db_url + + new_options[CONF_COLUMN_NAME] = old_options.get(CONF_COLUMN_NAME) + new_options[CONF_QUERY] = old_options.get(CONF_QUERY) + new_options[CONF_ADVANCED_OPTIONS] = {} + + for key in ( + CONF_VALUE_TEMPLATE, + CONF_UNIT_OF_MEASUREMENT, + CONF_DEVICE_CLASS, + CONF_STATE_CLASS, + ): + if (value := old_options.get(key)) is not None: + new_options[CONF_ADVANCED_OPTIONS][key] = value + + hass.config_entries.async_update_entry( + entry, data=new_data, options=new_options, version=2 + ) + + _LOGGER.debug( + "Migration to version %s.%s successful", + entry.version, + entry.minor_version, + ) + + return True diff --git a/homeassistant/components/sql/config_flow.py b/homeassistant/components/sql/config_flow.py index 37a6f9ef1040..a614105d8bc5 100644 --- a/homeassistant/components/sql/config_flow.py +++ b/homeassistant/components/sql/config_flow.py @@ -6,7 +6,7 @@ import logging from typing import Any import sqlalchemy -from sqlalchemy.engine import Result +from sqlalchemy.engine import Engine, Result from sqlalchemy.exc import MultipleResultsFound, NoSuchColumnError, SQLAlchemyError from sqlalchemy.orm import Session, scoped_session, sessionmaker import sqlparse @@ -32,9 +32,10 @@ from homeassistant.const import ( CONF_VALUE_TEMPLATE, ) from homeassistant.core import callback +from homeassistant.data_entry_flow import section from homeassistant.helpers import selector -from .const import CONF_COLUMN_NAME, CONF_QUERY, DOMAIN +from .const import CONF_ADVANCED_OPTIONS, CONF_COLUMN_NAME, CONF_QUERY, DOMAIN from .util import resolve_db_url _LOGGER = logging.getLogger(__name__) @@ -42,40 +43,38 @@ _LOGGER = logging.getLogger(__name__) OPTIONS_SCHEMA: vol.Schema = vol.Schema( { - vol.Optional( - CONF_DB_URL, - ): selector.TextSelector(), - vol.Required( - CONF_COLUMN_NAME, - ): selector.TextSelector(), - vol.Required( - CONF_QUERY, - ): selector.TextSelector(selector.TextSelectorConfig(multiline=True)), - vol.Optional( - CONF_UNIT_OF_MEASUREMENT, - ): selector.TextSelector(), - vol.Optional( - CONF_VALUE_TEMPLATE, - ): selector.TemplateSelector(), - vol.Optional(CONF_DEVICE_CLASS): selector.SelectSelector( - selector.SelectSelectorConfig( - options=[ - cls.value - for cls in SensorDeviceClass - if cls != SensorDeviceClass.ENUM - ], - mode=selector.SelectSelectorMode.DROPDOWN, - translation_key="device_class", - sort=True, - ) + vol.Required(CONF_QUERY): selector.TextSelector( + selector.TextSelectorConfig(multiline=True) ), - vol.Optional(CONF_STATE_CLASS): selector.SelectSelector( - selector.SelectSelectorConfig( - options=[cls.value for cls in SensorStateClass], - mode=selector.SelectSelectorMode.DROPDOWN, - translation_key="state_class", - sort=True, - ) + vol.Required(CONF_COLUMN_NAME): selector.TextSelector(), + vol.Required(CONF_ADVANCED_OPTIONS): section( + vol.Schema( + { + vol.Optional(CONF_VALUE_TEMPLATE): selector.TemplateSelector(), + vol.Optional(CONF_UNIT_OF_MEASUREMENT): selector.TextSelector(), + vol.Optional(CONF_DEVICE_CLASS): selector.SelectSelector( + selector.SelectSelectorConfig( + options=[ + cls.value + for cls in SensorDeviceClass + if cls != SensorDeviceClass.ENUM + ], + mode=selector.SelectSelectorMode.DROPDOWN, + translation_key="device_class", + sort=True, + ) + ), + vol.Optional(CONF_STATE_CLASS): selector.SelectSelector( + selector.SelectSelectorConfig( + options=[cls.value for cls in SensorStateClass], + mode=selector.SelectSelectorMode.DROPDOWN, + translation_key="state_class", + sort=True, + ) + ), + } + ), + {"collapsed": True}, ), } ) @@ -83,8 +82,9 @@ OPTIONS_SCHEMA: vol.Schema = vol.Schema( CONFIG_SCHEMA: vol.Schema = vol.Schema( { vol.Required(CONF_NAME, default="Select SQL Query"): selector.TextSelector(), + vol.Optional(CONF_DB_URL): selector.TextSelector(), } -).extend(OPTIONS_SCHEMA.schema) +) def validate_sql_select(value: str) -> str: @@ -99,6 +99,31 @@ def validate_sql_select(value: str) -> str: return str(query[0]) +def validate_db_connection(db_url: str) -> bool: + """Validate db connection.""" + + engine: Engine | None = None + sess: Session | None = None + try: + engine = sqlalchemy.create_engine(db_url, future=True) + sessmaker = scoped_session(sessionmaker(bind=engine, future=True)) + sess = sessmaker() + sess.execute(sqlalchemy.text("select 1 as value")) + except SQLAlchemyError as error: + _LOGGER.debug("Execution error %s", error) + if sess: + sess.close() + if engine: + engine.dispose() + raise + + if sess: + sess.close() + engine.dispose() + + return True + + def validate_query(db_url: str, query: str, column: str) -> bool: """Validate SQL query.""" @@ -136,7 +161,9 @@ def validate_query(db_url: str, query: str, column: str) -> bool: class SQLConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for SQL integration.""" - VERSION = 1 + VERSION = 2 + + data: dict[str, Any] @staticmethod @callback @@ -151,17 +178,46 @@ class SQLConfigFlow(ConfigFlow, domain=DOMAIN): ) -> ConfigFlowResult: """Handle the user step.""" errors = {} - description_placeholders = {} if user_input is not None: db_url = user_input.get(CONF_DB_URL) + + try: + db_url_for_validation = resolve_db_url(self.hass, db_url) + await self.hass.async_add_executor_job( + validate_db_connection, db_url_for_validation + ) + except SQLAlchemyError: + errors["db_url"] = "db_url_invalid" + + if not errors: + self.data = {CONF_NAME: user_input[CONF_NAME]} + if db_url and db_url_for_validation != get_instance(self.hass).db_url: + self.data[CONF_DB_URL] = db_url + return await self.async_step_options() + + return self.async_show_form( + step_id="user", + data_schema=self.add_suggested_values_to_schema(CONFIG_SCHEMA, user_input), + errors=errors, + ) + + async def async_step_options( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the user step.""" + errors = {} + description_placeholders = {} + + if user_input is not None: query = user_input[CONF_QUERY] column = user_input[CONF_COLUMN_NAME] - db_url_for_validation = None try: query = validate_sql_select(query) - db_url_for_validation = resolve_db_url(self.hass, db_url) + db_url_for_validation = resolve_db_url( + self.hass, self.data.get(CONF_DB_URL) + ) await self.hass.async_add_executor_job( validate_query, db_url_for_validation, query, column ) @@ -178,32 +234,25 @@ class SQLConfigFlow(ConfigFlow, domain=DOMAIN): _LOGGER.debug("Invalid query: %s", err) errors["query"] = "query_invalid" - options = { - CONF_QUERY: query, - CONF_COLUMN_NAME: column, - CONF_NAME: user_input[CONF_NAME], + mod_advanced_options = { + k: v + for k, v in user_input[CONF_ADVANCED_OPTIONS].items() + if v is not None } - if uom := user_input.get(CONF_UNIT_OF_MEASUREMENT): - options[CONF_UNIT_OF_MEASUREMENT] = uom - if value_template := user_input.get(CONF_VALUE_TEMPLATE): - options[CONF_VALUE_TEMPLATE] = value_template - if device_class := user_input.get(CONF_DEVICE_CLASS): - options[CONF_DEVICE_CLASS] = device_class - if state_class := user_input.get(CONF_STATE_CLASS): - options[CONF_STATE_CLASS] = state_class - if db_url_for_validation != get_instance(self.hass).db_url: - options[CONF_DB_URL] = db_url_for_validation + user_input[CONF_ADVANCED_OPTIONS] = mod_advanced_options if not errors: + name = self.data[CONF_NAME] + self.data.pop(CONF_NAME) return self.async_create_entry( - title=user_input[CONF_NAME], - data={}, - options=options, + title=name, + data=self.data, + options=user_input, ) return self.async_show_form( - step_id="user", - data_schema=self.add_suggested_values_to_schema(CONFIG_SCHEMA, user_input), + step_id="options", + data_schema=self.add_suggested_values_to_schema(OPTIONS_SCHEMA, user_input), errors=errors, description_placeholders=description_placeholders, ) @@ -220,10 +269,9 @@ class SQLOptionsFlowHandler(OptionsFlowWithReload): description_placeholders = {} if user_input is not None: - db_url = user_input.get(CONF_DB_URL) + db_url = self.config_entry.data.get(CONF_DB_URL) query = user_input[CONF_QUERY] column = user_input[CONF_COLUMN_NAME] - name = self.config_entry.options.get(CONF_NAME, self.config_entry.title) try: query = validate_sql_select(query) @@ -252,24 +300,15 @@ class SQLOptionsFlowHandler(OptionsFlowWithReload): recorder_db, ) - options = { - CONF_QUERY: query, - CONF_COLUMN_NAME: column, - CONF_NAME: name, + mod_advanced_options = { + k: v + for k, v in user_input[CONF_ADVANCED_OPTIONS].items() + if v is not None } - if uom := user_input.get(CONF_UNIT_OF_MEASUREMENT): - options[CONF_UNIT_OF_MEASUREMENT] = uom - if value_template := user_input.get(CONF_VALUE_TEMPLATE): - options[CONF_VALUE_TEMPLATE] = value_template - if device_class := user_input.get(CONF_DEVICE_CLASS): - options[CONF_DEVICE_CLASS] = device_class - if state_class := user_input.get(CONF_STATE_CLASS): - options[CONF_STATE_CLASS] = state_class - if db_url_for_validation != get_instance(self.hass).db_url: - options[CONF_DB_URL] = db_url_for_validation + user_input[CONF_ADVANCED_OPTIONS] = mod_advanced_options return self.async_create_entry( - data=options, + data=user_input, ) return self.async_show_form( diff --git a/homeassistant/components/sql/const.py b/homeassistant/components/sql/const.py index d8d13ab16992..20e54c52abfa 100644 --- a/homeassistant/components/sql/const.py +++ b/homeassistant/components/sql/const.py @@ -9,4 +9,5 @@ PLATFORMS = [Platform.SENSOR] CONF_COLUMN_NAME = "column" CONF_QUERY = "query" +CONF_ADVANCED_OPTIONS = "advanced_options" DB_URL_RE = re.compile("//.*:.*@") diff --git a/homeassistant/components/sql/sensor.py b/homeassistant/components/sql/sensor.py index 8c0ba81d6d23..a1b7442162c0 100644 --- a/homeassistant/components/sql/sensor.py +++ b/homeassistant/components/sql/sensor.py @@ -49,7 +49,7 @@ from homeassistant.helpers.trigger_template_entity import ( ) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import CONF_COLUMN_NAME, CONF_QUERY, DOMAIN +from .const import CONF_ADVANCED_OPTIONS, CONF_COLUMN_NAME, CONF_QUERY, DOMAIN from .models import SQLData from .util import redact_credentials, resolve_db_url @@ -111,10 +111,10 @@ async def async_setup_entry( ) -> None: """Set up the SQL sensor from config entry.""" - db_url: str = resolve_db_url(hass, entry.options.get(CONF_DB_URL)) - name: str = entry.options[CONF_NAME] + db_url: str = resolve_db_url(hass, entry.data.get(CONF_DB_URL)) + name: str = entry.title query_str: str = entry.options[CONF_QUERY] - template: str | None = entry.options.get(CONF_VALUE_TEMPLATE) + template: str | None = entry.options[CONF_ADVANCED_OPTIONS].get(CONF_VALUE_TEMPLATE) column_name: str = entry.options[CONF_COLUMN_NAME] value_template: ValueTemplate | None = None @@ -128,9 +128,9 @@ async def async_setup_entry( name_template = Template(name, hass) trigger_entity_config = {CONF_NAME: name_template, CONF_UNIQUE_ID: entry.entry_id} for key in TRIGGER_ENTITY_OPTIONS: - if key not in entry.options: + if key not in entry.options[CONF_ADVANCED_OPTIONS]: continue - trigger_entity_config[key] = entry.options[key] + trigger_entity_config[key] = entry.options[CONF_ADVANCED_OPTIONS][key] await async_setup_sensor( hass, diff --git a/homeassistant/components/sql/strings.json b/homeassistant/components/sql/strings.json index cbc0deda96a3..a70a9812657f 100644 --- a/homeassistant/components/sql/strings.json +++ b/homeassistant/components/sql/strings.json @@ -14,23 +14,39 @@ "user": { "data": { "db_url": "Database URL", - "name": "[%key:common::config_flow::data::name%]", - "query": "Select query", - "column": "Column", - "unit_of_measurement": "Unit of measurement", - "value_template": "Value template", - "device_class": "Device class", - "state_class": "State class" + "name": "[%key:common::config_flow::data::name%]" }, "data_description": { "db_url": "Leave empty to use Home Assistant Recorder database", - "name": "Name that will be used for config entry and also the sensor", + "name": "Name that will be used for config entry and also the sensor" + } + }, + "options": { + "data": { + "query": "Select query", + "column": "Column" + }, + "data_description": { "query": "Query to run, needs to start with 'SELECT'", - "column": "Column for returned query to present as state", - "unit_of_measurement": "The unit of measurement for the sensor (optional)", - "value_template": "Template to extract a value from the payload (optional)", - "device_class": "The type/class of the sensor to set the icon in the frontend", - "state_class": "The state class of the sensor" + "column": "Column for returned query to present as state" + }, + "sections": { + "advanced_options": { + "name": "Advanced options", + "description": "Provide additional configuration to the sensor", + "data": { + "unit_of_measurement": "Unit of measurement", + "value_template": "Value template", + "device_class": "Device class", + "state_class": "State class" + }, + "data_description": { + "unit_of_measurement": "The unit of measurement for the sensor (optional)", + "value_template": "Template to extract a value from the payload (optional)", + "device_class": "The type/class of the sensor to set the icon in the frontend", + "state_class": "The state class of the sensor" + } + } } } } @@ -39,24 +55,30 @@ "step": { "init": { "data": { - "db_url": "[%key:component::sql::config::step::user::data::db_url%]", - "name": "[%key:common::config_flow::data::name%]", - "query": "[%key:component::sql::config::step::user::data::query%]", - "column": "[%key:component::sql::config::step::user::data::column%]", - "unit_of_measurement": "[%key:component::sql::config::step::user::data::unit_of_measurement%]", - "value_template": "[%key:component::sql::config::step::user::data::value_template%]", - "device_class": "[%key:component::sql::config::step::user::data::device_class%]", - "state_class": "[%key:component::sql::config::step::user::data::state_class%]" + "query": "[%key:component::sql::config::step::options::data::query%]", + "column": "[%key:component::sql::config::step::options::data::column%]" }, "data_description": { - "db_url": "[%key:component::sql::config::step::user::data_description::db_url%]", - "name": "[%key:component::sql::config::step::user::data_description::name%]", - "query": "[%key:component::sql::config::step::user::data_description::query%]", - "column": "[%key:component::sql::config::step::user::data_description::column%]", - "unit_of_measurement": "[%key:component::sql::config::step::user::data_description::unit_of_measurement%]", - "value_template": "[%key:component::sql::config::step::user::data_description::value_template%]", - "device_class": "[%key:component::sql::config::step::user::data_description::device_class%]", - "state_class": "[%key:component::sql::config::step::user::data_description::state_class%]" + "query": "[%key:component::sql::config::step::options::data_description::query%]", + "column": "[%key:component::sql::config::step::options::data_description::column%]" + }, + "sections": { + "advanced_options": { + "name": "[%key:component::sql::config::step::options::sections::advanced_options::name%]", + "description": "[%key:component::sql::config::step::options::sections::advanced_options::name%]", + "data": { + "unit_of_measurement": "[%key:component::sql::config::step::options::sections::advanced_options::data::unit_of_measurement%]", + "value_template": "[%key:component::sql::config::step::options::sections::advanced_options::data::value_template%]", + "device_class": "[%key:component::sql::config::step::options::sections::advanced_options::data::device_class%]", + "state_class": "[%key:component::sql::config::step::options::sections::advanced_options::data::state_class%]" + }, + "data_description": { + "unit_of_measurement": "[%key:component::sql::config::step::options::sections::advanced_options::data_description::unit_of_measurement%]", + "value_template": "[%key:component::sql::config::step::options::sections::advanced_options::data_description::value_template%]", + "device_class": "[%key:component::sql::config::step::options::sections::advanced_options::data_description::device_class%]", + "state_class": "[%key:component::sql::config::step::options::sections::advanced_options::data_description::state_class%]" + } + } } } }, diff --git a/tests/components/sql/__init__.py b/tests/components/sql/__init__.py index 5f91cba1d941..6afc0329e32a 100644 --- a/tests/components/sql/__init__.py +++ b/tests/components/sql/__init__.py @@ -10,7 +10,12 @@ from homeassistant.components.sensor import ( SensorDeviceClass, SensorStateClass, ) -from homeassistant.components.sql.const import CONF_COLUMN_NAME, CONF_QUERY, DOMAIN +from homeassistant.components.sql.const import ( + CONF_ADVANCED_OPTIONS, + CONF_COLUMN_NAME, + CONF_QUERY, + DOMAIN, +) from homeassistant.config_entries import SOURCE_USER from homeassistant.const import ( CONF_DEVICE_CLASS, @@ -30,140 +35,167 @@ from homeassistant.helpers.trigger_template_entity import ( from tests.common import MockConfigEntry ENTRY_CONFIG = { - CONF_NAME: "Get Value", CONF_QUERY: "SELECT 5 as value", CONF_COLUMN_NAME: "value", - CONF_UNIT_OF_MEASUREMENT: "MiB", - CONF_DEVICE_CLASS: SensorDeviceClass.DATA_SIZE, - CONF_STATE_CLASS: SensorStateClass.TOTAL, + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + CONF_DEVICE_CLASS: SensorDeviceClass.DATA_SIZE, + CONF_STATE_CLASS: SensorStateClass.TOTAL, + }, } ENTRY_CONFIG_WITH_VALUE_TEMPLATE = { - CONF_NAME: "Get Value", CONF_QUERY: "SELECT 5 as value", CONF_COLUMN_NAME: "value", - CONF_UNIT_OF_MEASUREMENT: "MiB", - CONF_VALUE_TEMPLATE: "{{ value }}", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + CONF_VALUE_TEMPLATE: "{{ value }}", + }, } ENTRY_CONFIG_INVALID_QUERY = { - CONF_NAME: "Get Value", CONF_QUERY: "SELECT 5 FROM as value", CONF_COLUMN_NAME: "size", - CONF_UNIT_OF_MEASUREMENT: "MiB", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + }, } ENTRY_CONFIG_INVALID_QUERY_2 = { - CONF_NAME: "Get Value", CONF_QUERY: "SELECT5 FROM as value", CONF_COLUMN_NAME: "size", - CONF_UNIT_OF_MEASUREMENT: "MiB", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + }, } ENTRY_CONFIG_INVALID_QUERY_3 = { - CONF_NAME: "Get Value", CONF_QUERY: ";;", CONF_COLUMN_NAME: "size", - CONF_UNIT_OF_MEASUREMENT: "MiB", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + }, } ENTRY_CONFIG_INVALID_QUERY_OPT = { CONF_QUERY: "SELECT 5 FROM as value", CONF_COLUMN_NAME: "size", - CONF_UNIT_OF_MEASUREMENT: "MiB", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + }, } ENTRY_CONFIG_INVALID_QUERY_2_OPT = { CONF_QUERY: "SELECT5 FROM as value", CONF_COLUMN_NAME: "size", - CONF_UNIT_OF_MEASUREMENT: "MiB", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + }, } ENTRY_CONFIG_INVALID_QUERY_3_OPT = { CONF_QUERY: ";;", CONF_COLUMN_NAME: "size", - CONF_UNIT_OF_MEASUREMENT: "MiB", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + }, } ENTRY_CONFIG_QUERY_READ_ONLY_CTE = { - CONF_NAME: "Get Value", CONF_QUERY: "WITH test AS (SELECT 1 AS row_num, 10 AS state) SELECT state FROM test WHERE row_num = 1 LIMIT 1;", CONF_COLUMN_NAME: "state", - CONF_UNIT_OF_MEASUREMENT: "MiB", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + }, } ENTRY_CONFIG_QUERY_NO_READ_ONLY = { - CONF_NAME: "Get Value", CONF_QUERY: "UPDATE states SET state = 999999 WHERE state_id = 11125", CONF_COLUMN_NAME: "state", - CONF_UNIT_OF_MEASUREMENT: "MiB", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + }, } ENTRY_CONFIG_QUERY_NO_READ_ONLY_CTE = { - CONF_NAME: "Get Value", CONF_QUERY: "WITH test AS (SELECT state FROM states) UPDATE states SET states.state = test.state;", CONF_COLUMN_NAME: "size", - CONF_UNIT_OF_MEASUREMENT: "MiB", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + }, } ENTRY_CONFIG_QUERY_READ_ONLY_CTE_OPT = { CONF_QUERY: "WITH test AS (SELECT 1 AS row_num, 10 AS state) SELECT state FROM test WHERE row_num = 1 LIMIT 1;", CONF_COLUMN_NAME: "state", - CONF_UNIT_OF_MEASUREMENT: "MiB", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + }, } ENTRY_CONFIG_QUERY_NO_READ_ONLY_OPT = { CONF_QUERY: "UPDATE 5 as value", CONF_COLUMN_NAME: "size", - CONF_UNIT_OF_MEASUREMENT: "MiB", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + }, } ENTRY_CONFIG_QUERY_NO_READ_ONLY_CTE_OPT = { CONF_QUERY: "WITH test AS (SELECT state FROM states) UPDATE states SET states.state = test.state;", CONF_COLUMN_NAME: "size", - CONF_UNIT_OF_MEASUREMENT: "MiB", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + }, } ENTRY_CONFIG_MULTIPLE_QUERIES = { - CONF_NAME: "Get Value", CONF_QUERY: "SELECT 5 as state; UPDATE states SET state = 10;", CONF_COLUMN_NAME: "state", - CONF_UNIT_OF_MEASUREMENT: "MiB", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + }, } ENTRY_CONFIG_MULTIPLE_QUERIES_OPT = { CONF_QUERY: "SELECT 5 as state; UPDATE states SET state = 10;", CONF_COLUMN_NAME: "state", - CONF_UNIT_OF_MEASUREMENT: "MiB", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + }, } ENTRY_CONFIG_INVALID_COLUMN_NAME = { - CONF_NAME: "Get Value", CONF_QUERY: "SELECT 5 as value", CONF_COLUMN_NAME: "size", - CONF_UNIT_OF_MEASUREMENT: "MiB", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + }, } ENTRY_CONFIG_INVALID_COLUMN_NAME_OPT = { CONF_QUERY: "SELECT 5 as value", CONF_COLUMN_NAME: "size", - CONF_UNIT_OF_MEASUREMENT: "MiB", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + }, } ENTRY_CONFIG_NO_RESULTS = { - CONF_NAME: "Get Value", CONF_QUERY: "SELECT kalle as value from no_table;", CONF_COLUMN_NAME: "value", - CONF_UNIT_OF_MEASUREMENT: "MiB", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + }, } YAML_CONFIG = { @@ -260,22 +292,29 @@ YAML_CONFIG_ALL_TEMPLATES = { } -async def init_integration( +async def init_integration( # pylint: disable=dangerous-default-value hass: HomeAssistant, - config: dict[str, Any] | None = None, + *, + title: str = "Select value SQL query", + config: dict[str, Any] = {}, + options: dict[str, Any] | None = None, entry_id: str = "1", source: str = SOURCE_USER, ) -> MockConfigEntry: """Set up the SQL integration in Home Assistant.""" - if not config: - config = ENTRY_CONFIG + if not options: + options = ENTRY_CONFIG + if CONF_ADVANCED_OPTIONS not in options: + options[CONF_ADVANCED_OPTIONS] = {} config_entry = MockConfigEntry( + title=title, domain=DOMAIN, source=source, - data={}, - options=config, + data=config, + options=options, entry_id=entry_id, + version=2, ) config_entry.add_to_hass(hass) diff --git a/tests/components/sql/conftest.py b/tests/components/sql/conftest.py new file mode 100644 index 000000000000..9d18a7ddd79e --- /dev/null +++ b/tests/components/sql/conftest.py @@ -0,0 +1,17 @@ +"""Fixtures for the SQL integration.""" + +from __future__ import annotations + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.sql.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/sql/test_config_flow.py b/tests/components/sql/test_config_flow.py index 3f2400c0a323..863e87b5eae9 100644 --- a/tests/components/sql/test_config_flow.py +++ b/tests/components/sql/test_config_flow.py @@ -3,14 +3,31 @@ from __future__ import annotations from pathlib import Path +from typing import Any from unittest.mock import patch +import pytest from sqlalchemy.exc import SQLAlchemyError from homeassistant import config_entries -from homeassistant.components.recorder import Recorder -from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass -from homeassistant.components.sql.const import DOMAIN +from homeassistant.components.recorder import CONF_DB_URL +from homeassistant.components.sensor import ( + CONF_STATE_CLASS, + SensorDeviceClass, + SensorStateClass, +) +from homeassistant.components.sql.const import ( + CONF_ADVANCED_OPTIONS, + CONF_COLUMN_NAME, + CONF_QUERY, + DOMAIN, +) +from homeassistant.const import ( + CONF_DEVICE_CLASS, + CONF_NAME, + CONF_UNIT_OF_MEASUREMENT, + CONF_VALUE_TEMPLATE, +) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -36,8 +53,25 @@ from . import ( from tests.common import MockConfigEntry +pytestmark = pytest.mark.usefixtures("mock_setup_entry", "recorder_mock") -async def test_form(recorder_mock: Recorder, hass: HomeAssistant) -> None: +DATA_CONFIG = {CONF_NAME: "Get Value"} +DATA_CONFIG_DB = {CONF_NAME: "Get Value", CONF_DB_URL: "sqlite://"} +OPTIONS_DATA_CONFIG = {} + + +@pytest.mark.parametrize( + ("data_config", "result_config"), + [ + (DATA_CONFIG, OPTIONS_DATA_CONFIG), + (DATA_CONFIG_DB, OPTIONS_DATA_CONFIG), + ], +) +async def test_form_simple( + hass: HomeAssistant, + data_config: dict[str, Any], + result_config: dict[str, Any], +) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( @@ -46,32 +80,33 @@ async def test_form(recorder_mock: Recorder, hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM assert result["errors"] == {} - with patch( - "homeassistant.components.sql.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - ENTRY_CONFIG, - ) - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + data_config, + ) + await hass.async_block_till_done() - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "Get Value" - assert result2["options"] == { - "name": "Get Value", - "query": "SELECT 5 as value", - "column": "value", - "unit_of_measurement": "MiB", - "device_class": SensorDeviceClass.DATA_SIZE, - "state_class": SensorStateClass.TOTAL, + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + ENTRY_CONFIG, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Get Value" + assert result["data"] == result_config + assert result["options"] == { + CONF_QUERY: "SELECT 5 as value", + CONF_COLUMN_NAME: "value", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + CONF_DEVICE_CLASS: SensorDeviceClass.DATA_SIZE, + CONF_STATE_CLASS: SensorStateClass.TOTAL, + }, } - assert len(mock_setup_entry.mock_calls) == 1 -async def test_form_with_value_template( - recorder_mock: Recorder, hass: HomeAssistant -) -> None: +async def test_form_with_value_template(hass: HomeAssistant) -> None: """Test for with value template.""" result = await hass.config_entries.flow.async_init( @@ -80,208 +115,218 @@ async def test_form_with_value_template( assert result["type"] is FlowResultType.FORM assert result["errors"] == {} - with patch( - "homeassistant.components.sql.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - ENTRY_CONFIG_WITH_VALUE_TEMPLATE, - ) - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + DATA_CONFIG, + ) + await hass.async_block_till_done() - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "Get Value" - assert result2["options"] == { - "name": "Get Value", - "query": "SELECT 5 as value", - "column": "value", - "unit_of_measurement": "MiB", - "value_template": "{{ value }}", + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + ENTRY_CONFIG_WITH_VALUE_TEMPLATE, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Get Value" + assert result["data"] == {} + assert result["options"] == { + CONF_QUERY: "SELECT 5 as value", + CONF_COLUMN_NAME: "value", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + CONF_VALUE_TEMPLATE: "{{ value }}", + }, } - assert len(mock_setup_entry.mock_calls) == 1 -async def test_flow_fails_db_url(recorder_mock: Recorder, hass: HomeAssistant) -> None: +async def test_flow_fails_db_url(hass: HomeAssistant) -> None: """Test config flow fails incorrect db url.""" - result4 = await hass.config_entries.flow.async_init( + result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result4["type"] is FlowResultType.FORM - assert result4["step_id"] == config_entries.SOURCE_USER + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == config_entries.SOURCE_USER with patch( "homeassistant.components.sql.config_flow.sqlalchemy.create_engine", side_effect=SQLAlchemyError("error_message"), ): - result4 = await hass.config_entries.flow.async_configure( - result4["flow_id"], - user_input=ENTRY_CONFIG, + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=DATA_CONFIG, ) - assert result4["errors"] == {"db_url": "db_url_invalid"} + assert result["errors"] == {CONF_DB_URL: "db_url_invalid"} -async def test_flow_fails_invalid_query( - recorder_mock: Recorder, hass: HomeAssistant -) -> None: +async def test_flow_fails_invalid_query(hass: HomeAssistant) -> None: """Test config flow fails incorrect db url.""" - result4 = await hass.config_entries.flow.async_init( + result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result4["type"] is FlowResultType.FORM - assert result4["step_id"] == config_entries.SOURCE_USER + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == config_entries.SOURCE_USER - result5 = await hass.config_entries.flow.async_configure( - result4["flow_id"], + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=DATA_CONFIG, + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=ENTRY_CONFIG_INVALID_QUERY, ) - assert result5["type"] is FlowResultType.FORM - assert result5["errors"] == { - "query": "query_invalid", + assert result["type"] is FlowResultType.FORM + assert result["errors"] == { + CONF_QUERY: "query_invalid", } - result6 = await hass.config_entries.flow.async_configure( - result4["flow_id"], + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=ENTRY_CONFIG_INVALID_QUERY_2, ) - assert result6["type"] is FlowResultType.FORM - assert result6["errors"] == { - "query": "query_invalid", + assert result["type"] is FlowResultType.FORM + assert result["errors"] == { + CONF_QUERY: "query_invalid", } - result6 = await hass.config_entries.flow.async_configure( - result4["flow_id"], + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=ENTRY_CONFIG_INVALID_QUERY_3, ) - assert result6["type"] is FlowResultType.FORM - assert result6["errors"] == { - "query": "query_invalid", + assert result["type"] is FlowResultType.FORM + assert result["errors"] == { + CONF_QUERY: "query_invalid", } - result5 = await hass.config_entries.flow.async_configure( - result4["flow_id"], + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=ENTRY_CONFIG_QUERY_NO_READ_ONLY, ) - assert result5["type"] is FlowResultType.FORM - assert result5["errors"] == { - "query": "query_no_read_only", + assert result["type"] is FlowResultType.FORM + assert result["errors"] == { + CONF_QUERY: "query_no_read_only", } - result6 = await hass.config_entries.flow.async_configure( - result4["flow_id"], + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=ENTRY_CONFIG_QUERY_NO_READ_ONLY_CTE, ) - assert result6["type"] is FlowResultType.FORM - assert result6["errors"] == { - "query": "query_no_read_only", + assert result["type"] is FlowResultType.FORM + assert result["errors"] == { + CONF_QUERY: "query_no_read_only", } - result6 = await hass.config_entries.flow.async_configure( - result4["flow_id"], + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=ENTRY_CONFIG_MULTIPLE_QUERIES, ) - assert result6["type"] is FlowResultType.FORM - assert result6["errors"] == { - "query": "multiple_queries", + assert result["type"] is FlowResultType.FORM + assert result["errors"] == { + CONF_QUERY: "multiple_queries", } - result5 = await hass.config_entries.flow.async_configure( - result4["flow_id"], + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=ENTRY_CONFIG_NO_RESULTS, ) - assert result5["type"] is FlowResultType.FORM - assert result5["errors"] == { - "query": "query_invalid", + assert result["type"] is FlowResultType.FORM + assert result["errors"] == { + CONF_QUERY: "query_invalid", } - result5 = await hass.config_entries.flow.async_configure( - result4["flow_id"], + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=ENTRY_CONFIG, ) - assert result5["type"] is FlowResultType.CREATE_ENTRY - assert result5["title"] == "Get Value" - assert result5["options"] == { - "name": "Get Value", - "query": "SELECT 5 as value", - "column": "value", - "unit_of_measurement": "MiB", - "device_class": SensorDeviceClass.DATA_SIZE, - "state_class": SensorStateClass.TOTAL, + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Get Value" + assert result["data"] == {} + assert result["options"] == { + CONF_QUERY: "SELECT 5 as value", + CONF_COLUMN_NAME: "value", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + CONF_DEVICE_CLASS: SensorDeviceClass.DATA_SIZE, + CONF_STATE_CLASS: SensorStateClass.TOTAL, + }, } -async def test_flow_fails_invalid_column_name( - recorder_mock: Recorder, hass: HomeAssistant -) -> None: +async def test_flow_fails_invalid_column_name(hass: HomeAssistant) -> None: """Test config flow fails invalid column name.""" - result4 = await hass.config_entries.flow.async_init( + result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result4["type"] is FlowResultType.FORM - assert result4["step_id"] == "user" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" - result5 = await hass.config_entries.flow.async_configure( - result4["flow_id"], + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=DATA_CONFIG, + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=ENTRY_CONFIG_INVALID_COLUMN_NAME, ) - assert result5["type"] is FlowResultType.FORM - assert result5["errors"] == { - "column": "column_invalid", + assert result["type"] is FlowResultType.FORM + assert result["errors"] == { + CONF_COLUMN_NAME: "column_invalid", } - result5 = await hass.config_entries.flow.async_configure( - result4["flow_id"], + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=ENTRY_CONFIG, ) - assert result5["type"] is FlowResultType.CREATE_ENTRY - assert result5["title"] == "Get Value" - assert result5["options"] == { - "name": "Get Value", - "query": "SELECT 5 as value", - "column": "value", - "unit_of_measurement": "MiB", - "device_class": SensorDeviceClass.DATA_SIZE, - "state_class": SensorStateClass.TOTAL, + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Get Value" + assert result["data"] == {} + assert result["options"] == { + CONF_QUERY: "SELECT 5 as value", + CONF_COLUMN_NAME: "value", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + CONF_DEVICE_CLASS: SensorDeviceClass.DATA_SIZE, + CONF_STATE_CLASS: SensorStateClass.TOTAL, + }, } -async def test_options_flow(recorder_mock: Recorder, hass: HomeAssistant) -> None: +async def test_options_flow(hass: HomeAssistant) -> None: """Test options config flow.""" entry = MockConfigEntry( domain=DOMAIN, - data={}, + data=OPTIONS_DATA_CONFIG, options={ - "db_url": "sqlite://", - "name": "Get Value", - "query": "SELECT 5 as value", - "column": "value", - "unit_of_measurement": "MiB", - "device_class": SensorDeviceClass.DATA_SIZE, - "state_class": SensorStateClass.TOTAL, + CONF_QUERY: "SELECT 5 as value", + CONF_COLUMN_NAME: "value", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + CONF_DEVICE_CLASS: SensorDeviceClass.DATA_SIZE, + CONF_STATE_CLASS: SensorStateClass.TOTAL, + }, }, + version=2, ) entry.add_to_hass(hass) - with patch( - "homeassistant.components.sql.async_setup_entry", - return_value=True, - ): - assert await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() result = await hass.config_entries.options.async_init(entry.entry_id) @@ -291,41 +336,43 @@ async def test_options_flow(recorder_mock: Recorder, hass: HomeAssistant) -> Non result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={ - "db_url": "sqlite://", - "query": "SELECT 5 as size", - "column": "size", - "unit_of_measurement": "MiB", - "value_template": "{{ value }}", - "device_class": SensorDeviceClass.DATA_SIZE, - "state_class": SensorStateClass.TOTAL, + CONF_QUERY: "SELECT 5 as size", + CONF_COLUMN_NAME: "size", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + CONF_VALUE_TEMPLATE: "{{ value }}", + CONF_DEVICE_CLASS: SensorDeviceClass.DATA_SIZE, + CONF_STATE_CLASS: SensorStateClass.TOTAL, + }, }, ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"] == { - "name": "Get Value", - "query": "SELECT 5 as size", - "column": "size", - "unit_of_measurement": "MiB", - "value_template": "{{ value }}", - "device_class": SensorDeviceClass.DATA_SIZE, - "state_class": SensorStateClass.TOTAL, + CONF_QUERY: "SELECT 5 as size", + CONF_COLUMN_NAME: "size", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + CONF_VALUE_TEMPLATE: "{{ value }}", + CONF_DEVICE_CLASS: SensorDeviceClass.DATA_SIZE, + CONF_STATE_CLASS: SensorStateClass.TOTAL, + }, } -async def test_options_flow_name_previously_removed( - recorder_mock: Recorder, hass: HomeAssistant -) -> None: +async def test_options_flow_name_previously_removed(hass: HomeAssistant) -> None: """Test options config flow where the name was missing.""" entry = MockConfigEntry( domain=DOMAIN, - data={}, + data=OPTIONS_DATA_CONFIG, options={ - "db_url": "sqlite://", - "query": "SELECT 5 as value", - "column": "value", - "unit_of_measurement": "MiB", + CONF_QUERY: "SELECT 5 as value", + CONF_COLUMN_NAME: "value", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + }, }, + version=2, title="Get Value Title", ) entry.add_to_hass(hass) @@ -338,54 +385,46 @@ async def test_options_flow_name_previously_removed( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" - with patch( - "homeassistant.components.sql.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={ - "db_url": "sqlite://", - "query": "SELECT 5 as size", - "column": "size", - "unit_of_measurement": "MiB", + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_QUERY: "SELECT 5 as size", + CONF_COLUMN_NAME: "size", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", }, - ) - await hass.async_block_till_done() + }, + ) + await hass.async_block_till_done() - assert len(mock_setup_entry.mock_calls) == 1 assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"] == { - "name": "Get Value Title", - "query": "SELECT 5 as size", - "column": "size", - "unit_of_measurement": "MiB", + CONF_QUERY: "SELECT 5 as size", + CONF_COLUMN_NAME: "size", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + }, } -async def test_options_flow_fails_db_url( - recorder_mock: Recorder, hass: HomeAssistant -) -> None: +async def test_options_flow_fails_db_url(hass: HomeAssistant) -> None: """Test options flow fails incorrect db url.""" entry = MockConfigEntry( domain=DOMAIN, - data={}, + data=OPTIONS_DATA_CONFIG, options={ - "db_url": "sqlite://", - "name": "Get Value", - "query": "SELECT 5 as value", - "column": "value", - "unit_of_measurement": "MiB", + CONF_QUERY: "SELECT 5 as value", + CONF_COLUMN_NAME: "value", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + }, }, + version=2, ) entry.add_to_hass(hass) - with patch( - "homeassistant.components.sql.async_setup_entry", - return_value=True, - ): - assert await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() result = await hass.config_entries.options.async_init(entry.entry_id) @@ -393,233 +432,221 @@ async def test_options_flow_fails_db_url( "homeassistant.components.sql.config_flow.sqlalchemy.create_engine", side_effect=SQLAlchemyError("error_message"), ): - result2 = await hass.config_entries.options.async_configure( + result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={ - "db_url": "sqlite://", - "query": "SELECT 5 as size", - "column": "size", - "unit_of_measurement": "MiB", + CONF_QUERY: "SELECT 5 as size", + CONF_COLUMN_NAME: "size", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + }, }, ) - assert result2["errors"] == {"db_url": "db_url_invalid"} + assert result["errors"] == {CONF_DB_URL: "db_url_invalid"} -async def test_options_flow_fails_invalid_query( - recorder_mock: Recorder, hass: HomeAssistant -) -> None: +async def test_options_flow_fails_invalid_query(hass: HomeAssistant) -> None: """Test options flow fails incorrect query and template.""" entry = MockConfigEntry( domain=DOMAIN, - data={}, + data=OPTIONS_DATA_CONFIG, options={ - "db_url": "sqlite://", - "name": "Get Value", - "query": "SELECT 5 as value", - "column": "value", - "unit_of_measurement": "MiB", + CONF_QUERY: "SELECT 5 as value", + CONF_COLUMN_NAME: "value", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + }, }, + version=2, ) entry.add_to_hass(hass) - with patch( - "homeassistant.components.sql.async_setup_entry", - return_value=True, - ): - assert await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() result = await hass.config_entries.options.async_init(entry.entry_id) - result2 = await hass.config_entries.options.async_configure( + result = await hass.config_entries.options.async_configure( result["flow_id"], user_input=ENTRY_CONFIG_INVALID_QUERY_OPT, ) - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == { - "query": "query_invalid", + assert result["type"] is FlowResultType.FORM + assert result["errors"] == { + CONF_QUERY: "query_invalid", } - result3 = await hass.config_entries.options.async_configure( + result = await hass.config_entries.options.async_configure( result["flow_id"], user_input=ENTRY_CONFIG_INVALID_QUERY_2_OPT, ) - assert result3["type"] is FlowResultType.FORM - assert result3["errors"] == { - "query": "query_invalid", + assert result["type"] is FlowResultType.FORM + assert result["errors"] == { + CONF_QUERY: "query_invalid", } - result3 = await hass.config_entries.options.async_configure( + result = await hass.config_entries.options.async_configure( result["flow_id"], user_input=ENTRY_CONFIG_INVALID_QUERY_3_OPT, ) - assert result3["type"] is FlowResultType.FORM - assert result3["errors"] == { - "query": "query_invalid", + assert result["type"] is FlowResultType.FORM + assert result["errors"] == { + CONF_QUERY: "query_invalid", } - result2 = await hass.config_entries.options.async_configure( + result = await hass.config_entries.options.async_configure( result["flow_id"], user_input=ENTRY_CONFIG_QUERY_NO_READ_ONLY_OPT, ) - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == { - "query": "query_no_read_only", + assert result["type"] is FlowResultType.FORM + assert result["errors"] == { + CONF_QUERY: "query_no_read_only", } - result3 = await hass.config_entries.options.async_configure( + result = await hass.config_entries.options.async_configure( result["flow_id"], user_input=ENTRY_CONFIG_QUERY_NO_READ_ONLY_CTE_OPT, ) - assert result3["type"] is FlowResultType.FORM - assert result3["errors"] == { - "query": "query_no_read_only", + assert result["type"] is FlowResultType.FORM + assert result["errors"] == { + CONF_QUERY: "query_no_read_only", } - result3 = await hass.config_entries.options.async_configure( + result = await hass.config_entries.options.async_configure( result["flow_id"], user_input=ENTRY_CONFIG_MULTIPLE_QUERIES_OPT, ) - assert result3["type"] is FlowResultType.FORM - assert result3["errors"] == { - "query": "multiple_queries", + assert result["type"] is FlowResultType.FORM + assert result["errors"] == { + CONF_QUERY: "multiple_queries", } - result4 = await hass.config_entries.options.async_configure( + result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={ - "db_url": "sqlite://", - "query": "SELECT 5 as size", - "column": "size", - "unit_of_measurement": "MiB", + CONF_QUERY: "SELECT 5 as size", + CONF_COLUMN_NAME: "size", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + }, }, ) - assert result4["type"] is FlowResultType.CREATE_ENTRY - assert result4["data"] == { - "name": "Get Value", - "query": "SELECT 5 as size", - "column": "size", - "unit_of_measurement": "MiB", + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_QUERY: "SELECT 5 as size", + CONF_COLUMN_NAME: "size", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + }, } -async def test_options_flow_fails_invalid_column_name( - recorder_mock: Recorder, hass: HomeAssistant -) -> None: +async def test_options_flow_fails_invalid_column_name(hass: HomeAssistant) -> None: """Test options flow fails invalid column name.""" entry = MockConfigEntry( domain=DOMAIN, - data={}, + data=OPTIONS_DATA_CONFIG, options={ - "name": "Get Value", - "query": "SELECT 5 as value", - "column": "value", - "unit_of_measurement": "MiB", + CONF_QUERY: "SELECT 5 as value", + CONF_COLUMN_NAME: "value", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + }, }, + version=2, ) entry.add_to_hass(hass) - with patch( - "homeassistant.components.sql.async_setup_entry", - return_value=True, - ): - assert await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() result = await hass.config_entries.options.async_init(entry.entry_id) - result2 = await hass.config_entries.options.async_configure( + result = await hass.config_entries.options.async_configure( result["flow_id"], user_input=ENTRY_CONFIG_INVALID_COLUMN_NAME_OPT, ) - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == { - "column": "column_invalid", + assert result["type"] is FlowResultType.FORM + assert result["errors"] == { + CONF_COLUMN_NAME: "column_invalid", } - result4 = await hass.config_entries.options.async_configure( + result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={ - "query": "SELECT 5 as value", - "column": "value", - "unit_of_measurement": "MiB", + CONF_QUERY: "SELECT 5 as value", + CONF_COLUMN_NAME: "value", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + }, }, ) - assert result4["type"] is FlowResultType.CREATE_ENTRY - assert result4["data"] == { - "name": "Get Value", - "query": "SELECT 5 as value", - "column": "value", - "unit_of_measurement": "MiB", + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_QUERY: "SELECT 5 as value", + CONF_COLUMN_NAME: "value", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + }, } -async def test_options_flow_db_url_empty( - recorder_mock: Recorder, hass: HomeAssistant -) -> None: +async def test_options_flow_db_url_empty(hass: HomeAssistant) -> None: """Test options config flow with leaving db_url empty.""" entry = MockConfigEntry( domain=DOMAIN, - data={}, + data=OPTIONS_DATA_CONFIG, options={ - "db_url": "sqlite://", - "name": "Get Value", - "query": "SELECT 5 as value", - "column": "value", - "unit_of_measurement": "MiB", + CONF_QUERY: "SELECT 5 as value", + CONF_COLUMN_NAME: "value", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + }, }, + version=2, ) entry.add_to_hass(hass) - with patch( - "homeassistant.components.sql.async_setup_entry", - return_value=True, - ): - assert await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() result = await hass.config_entries.options.async_init(entry.entry_id) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" - with ( - patch( - "homeassistant.components.sql.async_setup_entry", - return_value=True, - ), - ): - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={ - "query": "SELECT 5 as size", - "column": "size", - "unit_of_measurement": "MiB", + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_QUERY: "SELECT 5 as size", + CONF_COLUMN_NAME: "size", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", }, - ) - await hass.async_block_till_done() + }, + ) + await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"] == { - "name": "Get Value", - "query": "SELECT 5 as size", - "column": "size", - "unit_of_measurement": "MiB", + CONF_QUERY: "SELECT 5 as size", + CONF_COLUMN_NAME: "size", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + }, } async def test_full_flow_not_recorder_db( - recorder_mock: Recorder, hass: HomeAssistant, tmp_path: Path, ) -> None: @@ -632,30 +659,31 @@ async def test_full_flow_not_recorder_db( db_path = tmp_path / "db.db" db_path_str = f"sqlite:///{db_path}" - with ( - patch( - "homeassistant.components.sql.async_setup_entry", - return_value=True, - ), - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "db_url": db_path_str, - "name": "Get Value", - "query": "SELECT 5 as value", - "column": "value", - }, - ) - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_DB_URL: db_path_str, + CONF_NAME: "Get Value", + }, + ) - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "Get Value" - assert result2["options"] == { - "name": "Get Value", - "db_url": db_path_str, - "query": "SELECT 5 as value", - "column": "value", + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_QUERY: "SELECT 5 as value", + CONF_COLUMN_NAME: "value", + CONF_ADVANCED_OPTIONS: {}, + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Get Value" + assert result["data"] == {CONF_DB_URL: db_path_str} + assert result["options"] == { + CONF_QUERY: "SELECT 5 as value", + CONF_COLUMN_NAME: "value", + CONF_ADVANCED_OPTIONS: {}, } entry = hass.config_entries.async_entries(DOMAIN)[0] @@ -665,76 +693,42 @@ async def test_full_flow_not_recorder_db( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" - with ( - patch( - "homeassistant.components.sql.async_setup_entry", - return_value=True, - ), - ): - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={ - "query": "SELECT 5 as value", - "db_url": db_path_str, - "column": "value", - "unit_of_measurement": "MiB", - }, - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"] == { - "name": "Get Value", - "db_url": db_path_str, - "query": "SELECT 5 as value", - "column": "value", - "unit_of_measurement": "MiB", - } - - # Need to test same again to mitigate issue with db_url removal - result = await hass.config_entries.options.async_init(entry.entry_id) - result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={ - "query": "SELECT 5 as value", - "db_url": db_path_str, - "column": "value", - "unit_of_measurement": "MB", + CONF_QUERY: "SELECT 5 as value", + CONF_COLUMN_NAME: "value", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + }, }, ) await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"] == { - "name": "Get Value", - "db_url": db_path_str, - "query": "SELECT 5 as value", - "column": "value", - "unit_of_measurement": "MB", - } - - assert entry.options == { - "name": "Get Value", - "db_url": db_path_str, - "query": "SELECT 5 as value", - "column": "value", - "unit_of_measurement": "MB", + CONF_QUERY: "SELECT 5 as value", + CONF_COLUMN_NAME: "value", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + }, } -async def test_device_state_class(recorder_mock: Recorder, hass: HomeAssistant) -> None: +async def test_device_state_class(hass: HomeAssistant) -> None: """Test we get the form.""" entry = MockConfigEntry( domain=DOMAIN, - data={}, + data=OPTIONS_DATA_CONFIG, options={ - "name": "Get Value", - "query": "SELECT 5 as value", - "column": "value", - "unit_of_measurement": "MiB", + CONF_QUERY: "SELECT 5 as value", + CONF_COLUMN_NAME: "value", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + }, }, + version=2, ) entry.add_to_hass(hass) @@ -742,56 +736,54 @@ async def test_device_state_class(recorder_mock: Recorder, hass: HomeAssistant) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" - with patch( - "homeassistant.components.sql.async_setup_entry", - return_value=True, - ): - result2 = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={ - "query": "SELECT 5 as value", - "column": "value", - "unit_of_measurement": "MiB", - "device_class": SensorDeviceClass.DATA_SIZE, - "state_class": SensorStateClass.TOTAL, + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_QUERY: "SELECT 5 as value", + CONF_COLUMN_NAME: "value", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + CONF_DEVICE_CLASS: SensorDeviceClass.DATA_SIZE, + CONF_STATE_CLASS: SensorStateClass.TOTAL, }, - ) - await hass.async_block_till_done() + }, + ) + await hass.async_block_till_done() - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["data"] == { - "name": "Get Value", - "query": "SELECT 5 as value", - "column": "value", - "unit_of_measurement": "MiB", - "device_class": SensorDeviceClass.DATA_SIZE, - "state_class": SensorStateClass.TOTAL, + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_QUERY: "SELECT 5 as value", + CONF_COLUMN_NAME: "value", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + CONF_DEVICE_CLASS: SensorDeviceClass.DATA_SIZE, + CONF_STATE_CLASS: SensorStateClass.TOTAL, + }, } result = await hass.config_entries.options.async_init(entry.entry_id) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" - with patch( - "homeassistant.components.sql.async_setup_entry", - return_value=True, - ): - result3 = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={ - "query": "SELECT 5 as value", - "column": "value", - "unit_of_measurement": "MiB", + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_QUERY: "SELECT 5 as value", + CONF_COLUMN_NAME: "value", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", }, - ) - await hass.async_block_till_done() + }, + ) + await hass.async_block_till_done() - assert result3["type"] is FlowResultType.CREATE_ENTRY - assert "device_class" not in result3["data"] - assert "state_class" not in result3["data"] - assert result3["data"] == { - "name": "Get Value", - "query": "SELECT 5 as value", - "column": "value", - "unit_of_measurement": "MiB", + assert result["type"] is FlowResultType.CREATE_ENTRY + assert CONF_DEVICE_CLASS not in result["data"] + assert CONF_STATE_CLASS not in result["data"] + assert result["data"] == { + CONF_QUERY: "SELECT 5 as value", + CONF_COLUMN_NAME: "value", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + }, } diff --git a/tests/components/sql/test_init.py b/tests/components/sql/test_init.py index 409ebca27c0a..7236b7212d3b 100644 --- a/tests/components/sql/test_init.py +++ b/tests/components/sql/test_init.py @@ -7,16 +7,33 @@ from unittest.mock import patch import pytest import voluptuous as vol -from homeassistant.components.recorder import Recorder -from homeassistant.components.recorder.util import get_instance +from homeassistant.components.recorder import CONF_DB_URL, Recorder +from homeassistant.components.sensor import ( + CONF_STATE_CLASS, + SensorDeviceClass, + SensorStateClass, +) from homeassistant.components.sql import validate_sql_select -from homeassistant.components.sql.const import DOMAIN -from homeassistant.config_entries import ConfigEntryState +from homeassistant.components.sql.const import ( + CONF_ADVANCED_OPTIONS, + CONF_COLUMN_NAME, + CONF_QUERY, + DOMAIN, +) +from homeassistant.config_entries import SOURCE_USER, ConfigEntryState +from homeassistant.const import ( + CONF_DEVICE_CLASS, + CONF_NAME, + CONF_UNIT_OF_MEASUREMENT, + CONF_VALUE_TEMPLATE, +) from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from . import YAML_CONFIG_INVALID, YAML_CONFIG_NO_DB, init_integration +from tests.common import MockConfigEntry + async def test_setup_entry(recorder_mock: Recorder, hass: HomeAssistant) -> None: """Test setup entry.""" @@ -86,39 +103,71 @@ async def test_multiple_queries(hass: HomeAssistant) -> None: validate_sql_select("SELECT 5 as value; UPDATE states SET state = 10;") -async def test_remove_configured_db_url_if_not_needed_when_not_needed( - recorder_mock: Recorder, - hass: HomeAssistant, +async def test_migration_from_future( + recorder_mock: Recorder, hass: HomeAssistant ) -> None: - """Test configured db_url is replaced with None if matching the recorder db.""" - recorder_db_url = get_instance(hass).db_url + """Test migration from future version fails.""" + config_entry = MockConfigEntry( + title="Test future", + domain=DOMAIN, + source=SOURCE_USER, + data={}, + options={ + CONF_QUERY: "SELECT 5.01 as value", + CONF_COLUMN_NAME: "value", + CONF_ADVANCED_OPTIONS: {}, + }, + entry_id="1", + version=3, + ) - config = { - "db_url": recorder_db_url, - "query": "SELECT 5 as value", - "column": "value", - "name": "count_tables", + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.MIGRATION_ERROR + + +async def test_migration_from_v1_to_v2( + recorder_mock: Recorder, hass: HomeAssistant +) -> None: + """Test migration from version 1 to 2.""" + config_entry = MockConfigEntry( + title="Test migration", + domain=DOMAIN, + source=SOURCE_USER, + data={}, + options={ + CONF_DB_URL: "sqlite://", + CONF_NAME: "Test migration", + CONF_QUERY: "SELECT 5.01 as value", + CONF_COLUMN_NAME: "value", + CONF_VALUE_TEMPLATE: "{{ value | int }}", + CONF_UNIT_OF_MEASUREMENT: "MiB", + CONF_DEVICE_CLASS: SensorDeviceClass.DATA_SIZE, + CONF_STATE_CLASS: SensorStateClass.MEASUREMENT, + }, + entry_id="1", + version=1, + ) + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + assert config_entry.data == {} + assert config_entry.options == { + CONF_QUERY: "SELECT 5.01 as value", + CONF_COLUMN_NAME: "value", + CONF_ADVANCED_OPTIONS: { + CONF_VALUE_TEMPLATE: "{{ value | int }}", + CONF_UNIT_OF_MEASUREMENT: "MiB", + CONF_DEVICE_CLASS: SensorDeviceClass.DATA_SIZE, + CONF_STATE_CLASS: SensorStateClass.MEASUREMENT, + }, } - config_entry = await init_integration(hass, config) - - assert config_entry.options.get("db_url") is None - - -async def test_remove_configured_db_url_if_not_needed_when_needed( - recorder_mock: Recorder, - hass: HomeAssistant, -) -> None: - """Test configured db_url is not replaced if it differs from the recorder db.""" - db_url = "mssql://" - - config = { - "db_url": db_url, - "query": "SELECT 5 as value", - "column": "value", - "name": "count_tables", - } - - config_entry = await init_integration(hass, config) - - assert config_entry.options.get("db_url") == db_url + state = hass.states.get("sensor.test_migration") + assert state.state == "5" diff --git a/tests/components/sql/test_sensor.py b/tests/components/sql/test_sensor.py index 354840c518ea..aa14be2f643f 100644 --- a/tests/components/sql/test_sensor.py +++ b/tests/components/sql/test_sensor.py @@ -12,14 +12,27 @@ from freezegun.api import FrozenDateTimeFactory import pytest from sqlalchemy.exc import SQLAlchemyError -from homeassistant.components.recorder import Recorder -from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass -from homeassistant.components.sql.const import CONF_QUERY, DOMAIN +from homeassistant.components.recorder import CONF_DB_URL, Recorder +from homeassistant.components.sensor import ( + CONF_STATE_CLASS, + SensorDeviceClass, + SensorStateClass, +) +from homeassistant.components.sql.const import ( + CONF_ADVANCED_OPTIONS, + CONF_COLUMN_NAME, + CONF_QUERY, + DOMAIN, +) from homeassistant.components.sql.sensor import _generate_lambda_stmt from homeassistant.config_entries import SOURCE_USER from homeassistant.const import ( + CONF_DEVICE_CLASS, CONF_ICON, + CONF_NAME, CONF_UNIQUE_ID, + CONF_UNIT_OF_MEASUREMENT, + CONF_VALUE_TEMPLATE, STATE_UNAVAILABLE, STATE_UNKNOWN, UnitOfInformation, @@ -37,7 +50,6 @@ from . import ( YAML_CONFIG_FULL_TABLE_SCAN, YAML_CONFIG_FULL_TABLE_SCAN_NO_UNIQUE_ID, YAML_CONFIG_FULL_TABLE_SCAN_WITH_MULTIPLE_COLUMNS, - YAML_CONFIG_WITH_VIEW_THAT_CONTAINS_ENTITY_ID, init_integration, ) @@ -46,14 +58,11 @@ from tests.common import MockConfigEntry, async_fire_time_changed async def test_query_basic(recorder_mock: Recorder, hass: HomeAssistant) -> None: """Test the SQL sensor.""" - config = { - "db_url": "sqlite://", - "query": "SELECT 5 as value", - "column": "value", - "name": "Select value SQL query", - "unique_id": "very_unique_id", + options = { + CONF_QUERY: "SELECT 5 as value", + CONF_COLUMN_NAME: "value", } - await init_integration(hass, config) + await init_integration(hass, title="Select value SQL query", options=options) state = hass.states.get("sensor.select_value_sql_query") assert state.state == "5" @@ -62,14 +71,11 @@ async def test_query_basic(recorder_mock: Recorder, hass: HomeAssistant) -> None async def test_query_cte(recorder_mock: Recorder, hass: HomeAssistant) -> None: """Test the SQL sensor with CTE.""" - config = { - "db_url": "sqlite://", - "query": "WITH test AS (SELECT 1 AS row_num, 10 AS state) SELECT state FROM test WHERE row_num = 1 LIMIT 1;", - "column": "state", - "name": "Select value SQL query CTE", - "unique_id": "very_unique_id", + options = { + CONF_QUERY: "WITH test AS (SELECT 1 AS row_num, 10 AS state) SELECT state FROM test WHERE row_num = 1 LIMIT 1;", + CONF_COLUMN_NAME: "state", } - await init_integration(hass, config) + await init_integration(hass, title="Select value SQL query CTE", options=options) state = hass.states.get("sensor.select_value_sql_query_cte") assert state.state == "10" @@ -80,31 +86,39 @@ async def test_query_value_template( recorder_mock: Recorder, hass: HomeAssistant ) -> None: """Test the SQL sensor.""" - config = { - "db_url": "sqlite://", - "query": "SELECT 5.01 as value", - "column": "value", - "name": "count_tables", - "value_template": "{{ value | int }}", + options = { + CONF_QUERY: "SELECT 5.01 as value", + CONF_COLUMN_NAME: "value", + CONF_ADVANCED_OPTIONS: { + CONF_VALUE_TEMPLATE: "{{ value | int }}", + CONF_UNIT_OF_MEASUREMENT: "MiB", + CONF_DEVICE_CLASS: SensorDeviceClass.DATA_SIZE, + CONF_STATE_CLASS: SensorStateClass.MEASUREMENT, + }, } - await init_integration(hass, config) + await init_integration(hass, title="count_tables", options=options) state = hass.states.get("sensor.count_tables") assert state.state == "5" + assert state.attributes == { + "device_class": "data_size", + "friendly_name": "count_tables", + "state_class": "measurement", + "unit_of_measurement": "MiB", + "value": 5.01, + } async def test_query_value_template_invalid( recorder_mock: Recorder, hass: HomeAssistant ) -> None: """Test the SQL sensor.""" - config = { - "db_url": "sqlite://", - "query": "SELECT 5.01 as value", - "column": "value", - "name": "count_tables", - "value_template": "{{ value | dontwork }}", + options = { + CONF_QUERY: "SELECT 5.01 as value", + CONF_COLUMN_NAME: "value", + CONF_VALUE_TEMPLATE: "{{ value | dontwork }}", } - await init_integration(hass, config) + await init_integration(hass, title="count_tables", options=options) state = hass.states.get("sensor.count_tables") assert state.state == "5.01" @@ -112,13 +126,11 @@ async def test_query_value_template_invalid( async def test_query_limit(recorder_mock: Recorder, hass: HomeAssistant) -> None: """Test the SQL sensor with a query containing 'LIMIT' in lowercase.""" - config = { - "db_url": "sqlite://", - "query": "SELECT 5 as value limit 1", - "column": "value", - "name": "Select value SQL query", + options = { + CONF_QUERY: "SELECT 5 as value limit 1", + CONF_COLUMN_NAME: "value", } - await init_integration(hass, config) + await init_integration(hass, options=options) state = hass.states.get("sensor.select_value_sql_query") assert state.state == "5" @@ -129,13 +141,11 @@ async def test_query_no_value( recorder_mock: Recorder, hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test the SQL sensor with a query that returns no value.""" - config = { - "db_url": "sqlite://", - "query": "SELECT 5 as value where 1=2", - "column": "value", - "name": "count_tables", + options = { + CONF_QUERY: "SELECT 5 as value where 1=2", + CONF_COLUMN_NAME: "value", } - await init_integration(hass, config) + await init_integration(hass, title="count_tables", options=options) state = hass.states.get("sensor.count_tables") assert state.state == STATE_UNKNOWN @@ -163,13 +173,13 @@ async def test_query_on_disk_sqlite_no_result( await hass.async_add_executor_job(make_test_db) - config = { - "db_url": db_path_str, - "query": "SELECT value from users", - "column": "value", - "name": "count_users", + config = {CONF_DB_URL: db_path_str} + options = { + CONF_QUERY: "SELECT value from users", + CONF_COLUMN_NAME: "value", + CONF_NAME: "count_users", } - await init_integration(hass, config) + await init_integration(hass, title="count_users", options=options, config=config) state = hass.states.get("sensor.count_users") assert state.state == STATE_UNKNOWN @@ -203,17 +213,17 @@ async def test_invalid_url_setup( ) -> None: """Test invalid db url with redacted credentials.""" config = { - "db_url": url, - "query": "SELECT 5 as value", - "column": "value", - "name": "count_tables", + CONF_QUERY: "SELECT 5 as value", + CONF_COLUMN_NAME: "value", } entry = MockConfigEntry( + title="count_tables", domain=DOMAIN, source=SOURCE_USER, - data={}, + data={CONF_DB_URL: url}, options=config, entry_id="1", + version=2, ) entry.add_to_hass(hass) @@ -237,11 +247,9 @@ async def test_invalid_url_on_update( caplog: pytest.LogCaptureFixture, ) -> None: """Test invalid db url with redacted credentials on retry.""" - config = { - "db_url": "sqlite://", - "query": "SELECT 5 as value", - "column": "value", - "name": "count_tables", + options = { + CONF_QUERY: "SELECT 5 as value", + CONF_COLUMN_NAME: "value", } class MockSession: @@ -255,7 +263,7 @@ async def test_invalid_url_on_update( "homeassistant.components.sql.sensor.scoped_session", return_value=MockSession, ): - await init_integration(hass, config) + await init_integration(hass, title="count_tables", options=options) async_fire_time_changed( hass, dt_util.utcnow() + timedelta(minutes=1), @@ -343,12 +351,12 @@ async def test_config_from_old_yaml( config = { "sensor": { "platform": "sql", - "db_url": "sqlite://", + CONF_DB_URL: "sqlite://", "queries": [ { - "name": "count_tables", - "query": "SELECT 5 as value", - "column": "value", + CONF_NAME: "count_tables", + CONF_QUERY: "SELECT 5 as value", + CONF_COLUMN_NAME: "value", } ], } @@ -386,10 +394,10 @@ async def test_invalid_url_setup_from_yaml( """Test invalid db url with redacted credentials from yaml setup.""" config = { "sql": { - "db_url": url, - "query": "SELECT 5 as value", - "column": "value", - "name": "count_tables", + CONF_DB_URL: url, + CONF_QUERY: "SELECT 5 as value", + CONF_COLUMN_NAME: "value", + CONF_NAME: "count_tables", } } @@ -417,9 +425,9 @@ async def test_attributes_from_yaml_setup( state = hass.states.get("sensor.get_value") assert state.state == "5" - assert state.attributes["device_class"] == SensorDeviceClass.DATA_SIZE - assert state.attributes["state_class"] == SensorStateClass.MEASUREMENT - assert state.attributes["unit_of_measurement"] == UnitOfInformation.MEBIBYTES + assert state.attributes[CONF_DEVICE_CLASS] == SensorDeviceClass.DATA_SIZE + assert state.attributes[CONF_STATE_CLASS] == SensorStateClass.MEASUREMENT + assert state.attributes[CONF_UNIT_OF_MEASUREMENT] == UnitOfInformation.MEBIBYTES async def test_binary_data_from_yaml_setup( @@ -455,7 +463,7 @@ async def test_issue_when_using_old_query( issue = issue_registry.async_get_issue( DOMAIN, f"entity_id_query_does_full_table_scan_{unique_id}" ) - assert issue.translation_placeholders == {"query": config[CONF_QUERY]} + assert issue.translation_placeholders == {CONF_QUERY: config[CONF_QUERY]} @pytest.mark.parametrize( @@ -486,7 +494,7 @@ async def test_issue_when_using_old_query_without_unique_id( issue = issue_registry.async_get_issue( DOMAIN, f"entity_id_query_does_full_table_scan_{query}" ) - assert issue.translation_placeholders == {"query": query} + assert issue.translation_placeholders == {CONF_QUERY: query} async def test_no_issue_when_view_has_the_text_entity_id_in_it( @@ -498,7 +506,12 @@ async def test_no_issue_when_view_has_the_text_entity_id_in_it( "homeassistant.components.sql.sensor.scoped_session", ): await init_integration( - hass, YAML_CONFIG_WITH_VIEW_THAT_CONTAINS_ENTITY_ID["sql"] + hass, + title="Get entity_id", + options={ + CONF_QUERY: "SELECT value from view_sensor_db_unique_entity_ids;", + CONF_COLUMN_NAME: "value", + }, ) async_fire_time_changed( hass, @@ -516,20 +529,18 @@ async def test_multiple_sensors_using_same_db( recorder_mock: Recorder, hass: HomeAssistant ) -> None: """Test multiple sensors using the same db.""" - config = { - "db_url": "sqlite:///", - "query": "SELECT 5 as value", - "column": "value", - "name": "Select value SQL query", + options = { + CONF_QUERY: "SELECT 5 as value", + CONF_COLUMN_NAME: "value", } - config2 = { - "db_url": "sqlite:///", - "query": "SELECT 5 as value", - "column": "value", - "name": "Select value SQL query 2", + options2 = { + CONF_QUERY: "SELECT 5 as value", + CONF_COLUMN_NAME: "value", } - await init_integration(hass, config) - await init_integration(hass, config2, entry_id="2") + await init_integration(hass, title="Select value SQL query", options=options) + await init_integration( + hass, title="Select value SQL query 2", options=options2, entry_id="2" + ) state = hass.states.get("sensor.select_value_sql_query") assert state.state == "5" @@ -547,13 +558,14 @@ async def test_engine_is_disposed_at_stop( recorder_mock: Recorder, hass: HomeAssistant ) -> None: """Test we dispose of the engine at stop.""" - config = { - "db_url": "sqlite:///", - "query": "SELECT 5 as value", - "column": "value", - "name": "Select value SQL query", + config = {CONF_DB_URL: "sqlite:///"} + options = { + CONF_QUERY: "SELECT 5 as value", + CONF_COLUMN_NAME: "value", } - await init_integration(hass, config) + await init_integration( + hass, title="Select value SQL query", config=config, options=options + ) state = hass.states.get("sensor.select_value_sql_query") assert state.state == "5" @@ -572,13 +584,15 @@ async def test_attributes_from_entry_config( await init_integration( hass, - config={ - "name": "Get Value - With", - "query": "SELECT 5 as value", - "column": "value", - "unit_of_measurement": "MiB", - "device_class": SensorDeviceClass.DATA_SIZE, - "state_class": SensorStateClass.TOTAL, + title="Get Value - With", + options={ + CONF_QUERY: "SELECT 5 as value", + CONF_COLUMN_NAME: "value", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + CONF_DEVICE_CLASS: SensorDeviceClass.DATA_SIZE, + CONF_STATE_CLASS: SensorStateClass.TOTAL, + }, }, entry_id="8693d4782ced4fb1ecca4743f29ab8f1", ) @@ -586,27 +600,29 @@ async def test_attributes_from_entry_config( state = hass.states.get("sensor.get_value_with") assert state.state == "5" assert state.attributes["value"] == 5 - assert state.attributes["unit_of_measurement"] == "MiB" - assert state.attributes["device_class"] == SensorDeviceClass.DATA_SIZE - assert state.attributes["state_class"] == SensorStateClass.TOTAL + assert state.attributes[CONF_UNIT_OF_MEASUREMENT] == "MiB" + assert state.attributes[CONF_DEVICE_CLASS] == SensorDeviceClass.DATA_SIZE + assert state.attributes[CONF_STATE_CLASS] == SensorStateClass.TOTAL await init_integration( hass, - config={ - "name": "Get Value - Without", - "query": "SELECT 5 as value", - "column": "value", - "unit_of_measurement": "MiB", + title="Get Value - Without", + options={ + CONF_QUERY: "SELECT 6 as value", + CONF_COLUMN_NAME: "value", + CONF_ADVANCED_OPTIONS: { + CONF_UNIT_OF_MEASUREMENT: "MiB", + }, }, entry_id="7aec7cd8045fba4778bb0621469e3cd9", ) state = hass.states.get("sensor.get_value_without") - assert state.state == "5" - assert state.attributes["value"] == 5 - assert state.attributes["unit_of_measurement"] == "MiB" - assert "device_class" not in state.attributes - assert "state_class" not in state.attributes + assert state.state == "6" + assert state.attributes["value"] == 6 + assert state.attributes[CONF_UNIT_OF_MEASUREMENT] == "MiB" + assert CONF_DEVICE_CLASS not in state.attributes + assert CONF_STATE_CLASS not in state.attributes async def test_query_recover_from_rollback( @@ -616,14 +632,12 @@ async def test_query_recover_from_rollback( caplog: pytest.LogCaptureFixture, ) -> None: """Test the SQL sensor.""" - config = { - "db_url": "sqlite://", - "query": "SELECT 5 as value", - "column": "value", - "name": "Select value SQL query", - "unique_id": "very_unique_id", + options = { + CONF_QUERY: "SELECT 5 as value", + CONF_COLUMN_NAME: "value", + CONF_UNIQUE_ID: "very_unique_id", } - await init_integration(hass, config) + await init_integration(hass, title="Select value SQL query", options=options) platforms = async_get_platforms(hass, "sql") sql_entity = platforms[0].entities["sensor.select_value_sql_query"] @@ -671,7 +685,7 @@ async def test_availability_blocks_value_template( """Test availability blocks value_template from rendering.""" error = "Error parsing value for sensor.get_value: 'x' is undefined" config = YAML_CONFIG - config["sql"]["value_template"] = "{{ x - 0 }}" + config["sql"][CONF_VALUE_TEMPLATE] = "{{ x - 0 }}" config["sql"]["availability"] = '{{ states("sensor.input1")=="on" }}' hass.states.async_set("sensor.input1", "off")