SQL reintroduce yaml support (#75205)

Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
G Johansson 2023-01-08 05:14:36 +01:00 committed by GitHub
parent d2c733628f
commit 2a965a6e44
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 341 additions and 171 deletions

View file

@ -1,10 +1,48 @@
"""The sql component.""" """The sql component."""
from __future__ import annotations from __future__ import annotations
from homeassistant.config_entries import ConfigEntry import voluptuous as vol
from homeassistant.core import HomeAssistant
from .const import PLATFORMS from homeassistant.components.recorder import CONF_DB_URL
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_NAME,
CONF_UNIQUE_ID,
CONF_UNIT_OF_MEASUREMENT,
CONF_VALUE_TEMPLATE,
Platform,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import discovery
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.typing import ConfigType
from .const import CONF_COLUMN_NAME, CONF_QUERY, DOMAIN, PLATFORMS
def validate_sql_select(value: str) -> str:
"""Validate that value is a SQL SELECT query."""
if not value.lstrip().lower().startswith("select"):
raise vol.Invalid("Only SELECT queries allowed")
return value
QUERY_SCHEMA = vol.Schema(
{
vol.Required(CONF_COLUMN_NAME): cv.string,
vol.Required(CONF_NAME): cv.string,
vol.Required(CONF_QUERY): vol.All(cv.string, validate_sql_select),
vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string,
vol.Optional(CONF_VALUE_TEMPLATE): cv.template,
vol.Optional(CONF_UNIQUE_ID): cv.string,
vol.Optional(CONF_DB_URL): cv.string,
}
)
CONFIG_SCHEMA = vol.Schema(
{vol.Optional(DOMAIN): vol.All(cv.ensure_list, [QUERY_SCHEMA])},
extra=vol.ALLOW_EXTRA,
)
async def async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: async def async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
@ -12,6 +50,19 @@ async def async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None
await hass.config_entries.async_reload(entry.entry_id) await hass.config_entries.async_reload(entry.entry_id)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up SQL from yaml config."""
if (conf := config.get(DOMAIN)) is None:
return True
for sensor_conf in conf:
await discovery.async_load_platform(
hass, Platform.SENSOR, DOMAIN, sensor_conf, config
)
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up SQL from a config entry.""" """Set up SQL from a config entry."""
entry.async_on_unload(entry.add_update_listener(async_update_listener)) entry.async_on_unload(entry.add_update_listener(async_update_listener))

View file

@ -80,12 +80,6 @@ class SQLConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Get the options flow for this handler.""" """Get the options flow for this handler."""
return SQLOptionsFlowHandler(config_entry) return SQLOptionsFlowHandler(config_entry)
async def async_step_import(self, config: dict[str, Any] | None) -> FlowResult:
"""Import a configuration from config.yaml."""
self._async_abort_entries_match(config)
return await self.async_step_user(user_input=config)
async def async_step_user( async def async_step_user(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> FlowResult: ) -> FlowResult:

View file

@ -7,6 +7,5 @@ DOMAIN = "sql"
PLATFORMS = [Platform.SENSOR] PLATFORMS = [Platform.SENSOR]
CONF_COLUMN_NAME = "column" CONF_COLUMN_NAME = "column"
CONF_QUERIES = "queries"
CONF_QUERY = "query" CONF_QUERY = "query"
DB_URL_RE = re.compile("//.*:.*@") DB_URL_RE = re.compile("//.*:.*@")

View file

@ -9,25 +9,25 @@ import sqlalchemy
from sqlalchemy.engine import Result from sqlalchemy.engine import Result
from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import scoped_session, sessionmaker from sqlalchemy.orm import scoped_session, sessionmaker
import voluptuous as vol
from homeassistant.components.recorder import CONF_DB_URL, DEFAULT_DB_FILE, DEFAULT_URL from homeassistant.components.recorder import CONF_DB_URL, DEFAULT_DB_FILE, DEFAULT_URL
from homeassistant.components.sensor import ( from homeassistant.components.sensor import SensorEntity
PLATFORM_SCHEMA as PARENT_PLATFORM_SCHEMA, from homeassistant.config_entries import ConfigEntry
SensorEntity, from homeassistant.const import (
CONF_NAME,
CONF_UNIQUE_ID,
CONF_UNIT_OF_MEASUREMENT,
CONF_VALUE_TEMPLATE,
) )
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import CONF_NAME, CONF_UNIT_OF_MEASUREMENT, CONF_VALUE_TEMPLATE
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import TemplateError from homeassistant.exceptions import TemplateError
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.device_registry import DeviceEntryType
from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.template import Template from homeassistant.helpers.template import Template
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from .const import CONF_COLUMN_NAME, CONF_QUERIES, CONF_QUERY, DB_URL_RE, DOMAIN from .const import CONF_COLUMN_NAME, CONF_QUERY, DB_URL_RE, DOMAIN
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -37,62 +37,45 @@ def redact_credentials(data: str) -> str:
return DB_URL_RE.sub("//****:****@", data) return DB_URL_RE.sub("//****:****@", data)
_QUERY_SCHEME = vol.Schema(
{
vol.Required(CONF_COLUMN_NAME): cv.string,
vol.Required(CONF_NAME): cv.string,
vol.Required(CONF_QUERY): cv.string,
vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string,
vol.Optional(CONF_VALUE_TEMPLATE): cv.string,
}
)
PLATFORM_SCHEMA = PARENT_PLATFORM_SCHEMA.extend(
{vol.Required(CONF_QUERIES): [_QUERY_SCHEME], vol.Optional(CONF_DB_URL): cv.string}
)
async def async_setup_platform( async def async_setup_platform(
hass: HomeAssistant, hass: HomeAssistant,
config: ConfigType, config: ConfigType,
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None, discovery_info: DiscoveryInfoType | None = None,
) -> None: ) -> None:
"""Set up the SQL sensor platform.""" """Set up the SQL sensor from yaml."""
_LOGGER.warning( if (conf := discovery_info) is None:
# SQL config flow added in 2022.4 and should be removed in 2022.6 return
"Configuration of the SQL sensor platform in YAML is deprecated and "
"will be removed in Home Assistant 2022.6; Your existing configuration "
"has been imported into the UI automatically and can be safely removed "
"from your configuration.yaml file"
)
default_db_url = DEFAULT_URL.format( name: str = conf[CONF_NAME]
hass_config_path=hass.config.path(DEFAULT_DB_FILE) query_str: str = conf[CONF_QUERY]
) unit: str | None = conf.get(CONF_UNIT_OF_MEASUREMENT)
value_template: Template | None = conf.get(CONF_VALUE_TEMPLATE)
column_name: str = conf[CONF_COLUMN_NAME]
unique_id: str | None = conf.get(CONF_UNIQUE_ID)
db_url: str | None = conf.get(CONF_DB_URL)
for query in config[CONF_QUERIES]: if value_template is not None:
new_config = { value_template.hass = hass
CONF_DB_URL: config.get(CONF_DB_URL, default_db_url),
CONF_NAME: query[CONF_NAME], await async_setup_sensor(
CONF_QUERY: query[CONF_QUERY], hass,
CONF_UNIT_OF_MEASUREMENT: query.get(CONF_UNIT_OF_MEASUREMENT), name,
CONF_VALUE_TEMPLATE: query.get(CONF_VALUE_TEMPLATE), query_str,
CONF_COLUMN_NAME: query[CONF_COLUMN_NAME], column_name,
} unit,
hass.async_create_task( value_template,
hass.config_entries.flow.async_init( unique_id,
DOMAIN, db_url,
context={"source": SOURCE_IMPORT}, True,
data=new_config, async_add_entities,
) )
)
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None: ) -> None:
"""Set up the SQL sensor entry.""" """Set up the SQL sensor from config entry."""
db_url: str = entry.options[CONF_DB_URL] db_url: str = entry.options[CONF_DB_URL]
name: str = entry.options[CONF_NAME] name: str = entry.options[CONF_NAME]
@ -111,12 +94,56 @@ async def async_setup_entry(
if value_template is not None: if value_template is not None:
value_template.hass = hass value_template.hass = hass
await async_setup_sensor(
hass,
name,
query_str,
column_name,
unit,
value_template,
entry.entry_id,
db_url,
False,
async_add_entities,
)
async def async_setup_sensor(
hass: HomeAssistant,
name: str,
query_str: str,
column_name: str,
unit: str | None,
value_template: Template | None,
unique_id: str | None,
db_url: str | None,
yaml: bool,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the SQL sensor."""
if not db_url:
db_url = DEFAULT_URL.format(hass_config_path=hass.config.path(DEFAULT_DB_FILE))
sess: scoped_session | None = None
try: try:
engine = sqlalchemy.create_engine(db_url, future=True) engine = sqlalchemy.create_engine(db_url, future=True)
sessmaker = scoped_session(sessionmaker(bind=engine, future=True)) sessmaker = scoped_session(sessionmaker(bind=engine, future=True))
# Run a dummy query just to test the db_url
sess = sessmaker()
sess.execute("SELECT 1;")
except SQLAlchemyError as err: except SQLAlchemyError as err:
_LOGGER.error("Can not open database %s", {redact_credentials(str(err))}) _LOGGER.error(
"Couldn't connect using %s DB_URL: %s",
redact_credentials(db_url),
redact_credentials(str(err)),
)
return return
finally:
if sess:
sess.close()
# MSSQL uses TOP and not LIMIT # MSSQL uses TOP and not LIMIT
if not ("LIMIT" in query_str.upper() or "SELECT TOP" in query_str.upper()): if not ("LIMIT" in query_str.upper() or "SELECT TOP" in query_str.upper()):
@ -134,7 +161,8 @@ async def async_setup_entry(
column_name, column_name,
unit, unit,
value_template, value_template,
entry.entry_id, unique_id,
yaml,
) )
], ],
True, True,
@ -155,22 +183,25 @@ class SQLSensor(SensorEntity):
column: str, column: str,
unit: str | None, unit: str | None,
value_template: Template | None, value_template: Template | None,
entry_id: str, unique_id: str | None,
yaml: bool,
) -> None: ) -> None:
"""Initialize the SQL sensor.""" """Initialize the SQL sensor."""
self._query = query self._query = query
self._attr_name = name if yaml else None
self._attr_native_unit_of_measurement = unit self._attr_native_unit_of_measurement = unit
self._template = value_template self._template = value_template
self._column_name = column self._column_name = column
self.sessionmaker = sessmaker self.sessionmaker = sessmaker
self._attr_extra_state_attributes = {} self._attr_extra_state_attributes = {}
self._attr_unique_id = entry_id self._attr_unique_id = unique_id
self._attr_device_info = DeviceInfo( if not yaml and unique_id:
entry_type=DeviceEntryType.SERVICE, self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, entry_id)}, entry_type=DeviceEntryType.SERVICE,
manufacturer="SQL", identifiers={(DOMAIN, unique_id)},
name=name, manufacturer="SQL",
) name=name,
)
def update(self) -> None: def update(self) -> None:
"""Retrieve sensor data from the query.""" """Retrieve sensor data from the query."""

View file

@ -6,7 +6,12 @@ from typing import Any
from homeassistant.components.recorder import CONF_DB_URL from homeassistant.components.recorder import CONF_DB_URL
from homeassistant.components.sql.const import CONF_COLUMN_NAME, CONF_QUERY, DOMAIN from homeassistant.components.sql.const import CONF_COLUMN_NAME, CONF_QUERY, DOMAIN
from homeassistant.config_entries import SOURCE_USER from homeassistant.config_entries import SOURCE_USER
from homeassistant.const import CONF_NAME, CONF_UNIT_OF_MEASUREMENT from homeassistant.const import (
CONF_NAME,
CONF_UNIQUE_ID,
CONF_UNIT_OF_MEASUREMENT,
CONF_VALUE_TEMPLATE,
)
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry from tests.common import MockConfigEntry
@ -42,6 +47,36 @@ ENTRY_CONFIG_NO_RESULTS = {
CONF_UNIT_OF_MEASUREMENT: "MiB", CONF_UNIT_OF_MEASUREMENT: "MiB",
} }
YAML_CONFIG = {
"sql": {
CONF_DB_URL: "sqlite://",
CONF_NAME: "Get Value",
CONF_QUERY: "SELECT 5 as value",
CONF_COLUMN_NAME: "value",
CONF_UNIT_OF_MEASUREMENT: "MiB",
CONF_UNIQUE_ID: "unique_id_12345",
CONF_VALUE_TEMPLATE: "{{ value }}",
}
}
YAML_CONFIG_INVALID = {
"sql": {
CONF_DB_URL: "sqlite://",
CONF_QUERY: "SELECT 5 as value",
CONF_COLUMN_NAME: "value",
CONF_UNIT_OF_MEASUREMENT: "MiB",
CONF_UNIQUE_ID: "unique_id_12345",
}
}
YAML_CONFIG_NO_DB = {
"sql": {
CONF_NAME: "Get Value",
CONF_QUERY: "SELECT 5 as value",
CONF_COLUMN_NAME: "value",
}
}
async def init_integration( async def init_integration(
hass: HomeAssistant, hass: HomeAssistant,

View file

@ -1,7 +1,7 @@
"""Test the SQL config flow.""" """Test the SQL config flow."""
from __future__ import annotations from __future__ import annotations
from unittest.mock import patch from unittest.mock import AsyncMock, patch
from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.exc import SQLAlchemyError
@ -21,7 +21,7 @@ from . import (
from tests.common import MockConfigEntry from tests.common import MockConfigEntry
async def test_form(recorder_mock, hass: HomeAssistant) -> None: async def test_form(recorder_mock: AsyncMock, hass: HomeAssistant) -> None:
"""Test we get the form.""" """Test we get the form."""
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
@ -53,57 +53,7 @@ async def test_form(recorder_mock, hass: HomeAssistant) -> None:
assert len(mock_setup_entry.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1
async def test_import_flow_success(recorder_mock, hass: HomeAssistant) -> None: async def test_flow_fails_db_url(recorder_mock: AsyncMock, hass: HomeAssistant) -> None:
"""Test a successful import of yaml."""
with patch(
"homeassistant.components.sql.async_setup_entry",
return_value=True,
) as mock_setup_entry:
result2 = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_IMPORT},
data=ENTRY_CONFIG,
)
await hass.async_block_till_done()
assert result2["type"] == FlowResultType.CREATE_ENTRY
assert result2["title"] == "Get Value"
assert result2["options"] == {
"db_url": "sqlite://",
"name": "Get Value",
"query": "SELECT 5 as value",
"column": "value",
"unit_of_measurement": "MiB",
"value_template": None,
}
assert len(mock_setup_entry.mock_calls) == 1
async def test_import_flow_already_exist(recorder_mock, hass: HomeAssistant) -> None:
"""Test import of yaml already exist."""
MockConfigEntry(
domain=DOMAIN,
data=ENTRY_CONFIG,
).add_to_hass(hass)
with patch(
"homeassistant.components.sql.async_setup_entry",
return_value=True,
):
result3 = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_IMPORT},
data=ENTRY_CONFIG,
)
await hass.async_block_till_done()
assert result3["type"] == FlowResultType.ABORT
assert result3["reason"] == "already_configured"
async def test_flow_fails_db_url(recorder_mock, hass: HomeAssistant) -> None:
"""Test config flow fails incorrect db url.""" """Test config flow fails incorrect db url."""
result4 = await hass.config_entries.flow.async_init( result4 = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER} DOMAIN, context={"source": config_entries.SOURCE_USER}
@ -124,7 +74,9 @@ async def test_flow_fails_db_url(recorder_mock, hass: HomeAssistant) -> None:
assert result4["errors"] == {"db_url": "db_url_invalid"} assert result4["errors"] == {"db_url": "db_url_invalid"}
async def test_flow_fails_invalid_query(recorder_mock, hass: HomeAssistant) -> None: async def test_flow_fails_invalid_query(
recorder_mock: AsyncMock, hass: HomeAssistant
) -> None:
"""Test config flow fails incorrect db url.""" """Test config flow fails incorrect db url."""
result4 = await hass.config_entries.flow.async_init( result4 = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER} DOMAIN, context={"source": config_entries.SOURCE_USER}
@ -170,7 +122,7 @@ async def test_flow_fails_invalid_query(recorder_mock, hass: HomeAssistant) -> N
} }
async def test_options_flow(recorder_mock, hass: HomeAssistant) -> None: async def test_options_flow(recorder_mock: AsyncMock, hass: HomeAssistant) -> None:
"""Test options config flow.""" """Test options config flow."""
entry = MockConfigEntry( entry = MockConfigEntry(
domain=DOMAIN, domain=DOMAIN,
@ -219,7 +171,7 @@ async def test_options_flow(recorder_mock, hass: HomeAssistant) -> None:
async def test_options_flow_name_previously_removed( async def test_options_flow_name_previously_removed(
recorder_mock, hass: HomeAssistant recorder_mock: AsyncMock, hass: HomeAssistant
) -> None: ) -> None:
"""Test options config flow where the name was missing.""" """Test options config flow where the name was missing."""
entry = MockConfigEntry( entry = MockConfigEntry(
@ -270,7 +222,9 @@ async def test_options_flow_name_previously_removed(
} }
async def test_options_flow_fails_db_url(recorder_mock, hass: HomeAssistant) -> None: async def test_options_flow_fails_db_url(
recorder_mock: AsyncMock, hass: HomeAssistant
) -> None:
"""Test options flow fails incorrect db url.""" """Test options flow fails incorrect db url."""
entry = MockConfigEntry( entry = MockConfigEntry(
domain=DOMAIN, domain=DOMAIN,
@ -313,7 +267,7 @@ async def test_options_flow_fails_db_url(recorder_mock, hass: HomeAssistant) ->
async def test_options_flow_fails_invalid_query( async def test_options_flow_fails_invalid_query(
recorder_mock, hass: HomeAssistant recorder_mock: AsyncMock, hass: HomeAssistant
) -> None: ) -> None:
"""Test options flow fails incorrect query and template.""" """Test options flow fails incorrect query and template."""
entry = MockConfigEntry( entry = MockConfigEntry(
@ -369,7 +323,9 @@ async def test_options_flow_fails_invalid_query(
} }
async def test_options_flow_db_url_empty(recorder_mock, hass: HomeAssistant) -> None: async def test_options_flow_db_url_empty(
recorder_mock: AsyncMock, hass: HomeAssistant
) -> None:
"""Test options config flow with leaving db_url empty.""" """Test options config flow with leaving db_url empty."""
entry = MockConfigEntry( entry = MockConfigEntry(
domain=DOMAIN, domain=DOMAIN,

View file

@ -1,17 +1,27 @@
"""Test for SQL component Init.""" """Test for SQL component Init."""
from __future__ import annotations
from unittest.mock import AsyncMock, patch
import pytest
import voluptuous as vol
from homeassistant import config_entries from homeassistant import config_entries
from homeassistant.components.sql import validate_sql_select
from homeassistant.components.sql.const import DOMAIN
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
from . import init_integration from . import YAML_CONFIG_INVALID, YAML_CONFIG_NO_DB, init_integration
async def test_setup_entry(hass: HomeAssistant) -> None: async def test_setup_entry(recorder_mock: AsyncMock, hass: HomeAssistant) -> None:
"""Test setup entry.""" """Test setup entry."""
config_entry = await init_integration(hass) config_entry = await init_integration(hass)
assert config_entry.state == config_entries.ConfigEntryState.LOADED assert config_entry.state == config_entries.ConfigEntryState.LOADED
async def test_unload_entry(hass: HomeAssistant) -> None: async def test_unload_entry(recorder_mock: AsyncMock, hass: HomeAssistant) -> None:
"""Test unload an entry.""" """Test unload an entry."""
config_entry = await init_integration(hass) config_entry = await init_integration(hass)
assert config_entry.state == config_entries.ConfigEntryState.LOADED assert config_entry.state == config_entries.ConfigEntryState.LOADED
@ -19,3 +29,29 @@ async def test_unload_entry(hass: HomeAssistant) -> None:
assert await hass.config_entries.async_unload(config_entry.entry_id) assert await hass.config_entries.async_unload(config_entry.entry_id)
await hass.async_block_till_done() await hass.async_block_till_done()
assert config_entry.state is config_entries.ConfigEntryState.NOT_LOADED assert config_entry.state is config_entries.ConfigEntryState.NOT_LOADED
async def test_setup_config(recorder_mock: AsyncMock, hass: HomeAssistant) -> None:
"""Test setup from yaml config."""
with patch(
"homeassistant.components.sql.config_flow.sqlalchemy.create_engine",
):
assert await async_setup_component(hass, DOMAIN, YAML_CONFIG_NO_DB)
await hass.async_block_till_done()
async def test_setup_invalid_config(
recorder_mock: AsyncMock, hass: HomeAssistant
) -> None:
"""Test setup from yaml with invalid config."""
with patch(
"homeassistant.components.sql.config_flow.sqlalchemy.create_engine",
):
assert not await async_setup_component(hass, DOMAIN, YAML_CONFIG_INVALID)
await hass.async_block_till_done()
async def test_invalid_query(hass: HomeAssistant) -> None:
"""Test invalid query."""
with pytest.raises(vol.Invalid):
validate_sql_select("DROP TABLE *")

View file

@ -1,22 +1,25 @@
"""The test for the sql sensor platform.""" """The test for the sql sensor platform."""
from unittest.mock import patch from __future__ import annotations
from datetime import timedelta
from unittest.mock import AsyncMock, patch
import pytest import pytest
from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.exc import SQLAlchemyError
from homeassistant.components.sql.const import DOMAIN from homeassistant.components.sql.const import DOMAIN
from homeassistant.config_entries import SOURCE_USER from homeassistant.config_entries import SOURCE_USER
from homeassistant.const import CONF_NAME, STATE_UNKNOWN from homeassistant.const import STATE_UNKNOWN
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_component import async_update_entity
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
from homeassistant.util import dt
from . import init_integration from . import YAML_CONFIG, init_integration
from tests.common import MockConfigEntry from tests.common import MockConfigEntry, async_fire_time_changed
async def test_query(hass: HomeAssistant) -> None: async def test_query(recorder_mock: AsyncMock, hass: HomeAssistant) -> None:
"""Test the SQL sensor.""" """Test the SQL sensor."""
config = { config = {
"db_url": "sqlite://", "db_url": "sqlite://",
@ -31,31 +34,9 @@ async def test_query(hass: HomeAssistant) -> None:
assert state.attributes["value"] == 5 assert state.attributes["value"] == 5
async def test_import_query(hass: HomeAssistant) -> None: async def test_query_value_template(
"""Test the SQL sensor.""" recorder_mock: AsyncMock, hass: HomeAssistant
config = { ) -> None:
"sensor": {
"platform": "sql",
"db_url": "sqlite://",
"queries": [
{
"name": "count_tables",
"query": "SELECT 5 as value",
"column": "value",
}
],
}
}
assert await async_setup_component(hass, "sensor", config)
await hass.async_block_till_done()
assert hass.config_entries.async_entries(DOMAIN)
options = hass.config_entries.async_entries(DOMAIN)[0].options
assert options[CONF_NAME] == "count_tables"
async def test_query_value_template(hass: HomeAssistant) -> None:
"""Test the SQL sensor.""" """Test the SQL sensor."""
config = { config = {
"db_url": "sqlite://", "db_url": "sqlite://",
@ -70,7 +51,9 @@ async def test_query_value_template(hass: HomeAssistant) -> None:
assert state.state == "5" assert state.state == "5"
async def test_query_value_template_invalid(hass: HomeAssistant) -> None: async def test_query_value_template_invalid(
recorder_mock: AsyncMock, hass: HomeAssistant
) -> None:
"""Test the SQL sensor.""" """Test the SQL sensor."""
config = { config = {
"db_url": "sqlite://", "db_url": "sqlite://",
@ -85,7 +68,7 @@ async def test_query_value_template_invalid(hass: HomeAssistant) -> None:
assert state.state == "5.01" assert state.state == "5.01"
async def test_query_limit(hass: HomeAssistant) -> None: async def test_query_limit(recorder_mock: AsyncMock, hass: HomeAssistant) -> None:
"""Test the SQL sensor with a query containing 'LIMIT' in lowercase.""" """Test the SQL sensor with a query containing 'LIMIT' in lowercase."""
config = { config = {
"db_url": "sqlite://", "db_url": "sqlite://",
@ -101,7 +84,7 @@ async def test_query_limit(hass: HomeAssistant) -> None:
async def test_query_no_value( async def test_query_no_value(
hass: HomeAssistant, caplog: pytest.LogCaptureFixture recorder_mock: AsyncMock, hass: HomeAssistant, caplog: pytest.LogCaptureFixture
) -> None: ) -> None:
"""Test the SQL sensor with a query that returns no value.""" """Test the SQL sensor with a query that returns no value."""
config = { config = {
@ -120,7 +103,7 @@ async def test_query_no_value(
async def test_query_mssql_no_result( async def test_query_mssql_no_result(
hass: HomeAssistant, caplog: pytest.LogCaptureFixture recorder_mock: AsyncMock, hass: HomeAssistant, caplog: pytest.LogCaptureFixture
) -> None: ) -> None:
"""Test the SQL sensor with a query that returns no value.""" """Test the SQL sensor with a query that returns no value."""
config = { config = {
@ -158,6 +141,7 @@ async def test_query_mssql_no_result(
], ],
) )
async def test_invalid_url_setup( async def test_invalid_url_setup(
recorder_mock: AsyncMock,
hass: HomeAssistant, hass: HomeAssistant,
caplog: pytest.LogCaptureFixture, caplog: pytest.LogCaptureFixture,
url: str, url: str,
@ -218,13 +202,97 @@ async def test_invalid_url_on_update(
await hass.config_entries.async_setup(entry.entry_id) await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done() await hass.async_block_till_done()
with patch( with patch("homeassistant.components.recorder",), patch(
"homeassistant.components.sql.sensor.sqlalchemy.engine.cursor.CursorResult", "homeassistant.components.sql.sensor.sqlalchemy.engine.cursor.CursorResult",
side_effect=SQLAlchemyError( side_effect=SQLAlchemyError(
"sqlite://homeassistant:hunter2@homeassistant.local" "sqlite://homeassistant:hunter2@homeassistant.local"
), ),
): ):
await async_update_entity(hass, "sensor.count_tables") async_fire_time_changed(
hass,
dt.utcnow() + timedelta(minutes=1),
)
await hass.async_block_till_done()
assert "sqlite://homeassistant:hunter2@homeassistant.local" not in caplog.text assert "sqlite://homeassistant:hunter2@homeassistant.local" not in caplog.text
assert "sqlite://****:****@homeassistant.local" in caplog.text assert "sqlite://****:****@homeassistant.local" in caplog.text
async def test_query_from_yaml(recorder_mock: AsyncMock, hass: HomeAssistant) -> None:
"""Test the SQL sensor from yaml config."""
assert await async_setup_component(hass, DOMAIN, YAML_CONFIG)
await hass.async_block_till_done()
state = hass.states.get("sensor.get_value")
assert state.state == "5"
async def test_config_from_old_yaml(
recorder_mock: AsyncMock, hass: HomeAssistant
) -> None:
"""Test the SQL sensor from old yaml config does not create any entity."""
config = {
"sensor": {
"platform": "sql",
"db_url": "sqlite://",
"queries": [
{
"name": "count_tables",
"query": "SELECT 5 as value",
"column": "value",
}
],
}
}
assert await async_setup_component(hass, "sensor", config)
await hass.async_block_till_done()
state = hass.states.get("sensor.count_tables")
assert not state
@pytest.mark.parametrize(
"url,expected_patterns,not_expected_patterns",
[
(
"sqlite://homeassistant:hunter2@homeassistant.local",
["sqlite://****:****@homeassistant.local"],
["sqlite://homeassistant:hunter2@homeassistant.local"],
),
(
"sqlite://homeassistant.local",
["sqlite://homeassistant.local"],
[],
),
],
)
async def test_invalid_url_setup_from_yaml(
recorder_mock: AsyncMock,
hass: HomeAssistant,
caplog: pytest.LogCaptureFixture,
url: str,
expected_patterns: str,
not_expected_patterns: str,
):
"""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",
}
}
with patch(
"homeassistant.components.sql.sensor.sqlalchemy.create_engine",
side_effect=SQLAlchemyError(url),
):
assert await async_setup_component(hass, DOMAIN, config)
await hass.async_block_till_done()
for pattern in not_expected_patterns:
assert pattern not in caplog.text
for pattern in expected_patterns:
assert pattern in caplog.text