Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion homeassistant/components/sql/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,9 @@
{
vol.Required(CONF_COLUMN_NAME): cv.string,
vol.Required(CONF_NAME): cv.template,
vol.Required(CONF_QUERY): vol.All(cv.string, validate_sql_select),
vol.Required(CONF_QUERY): vol.All(
cv.template, ValueTemplate.from_template, validate_sql_select
),
vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string,
vol.Optional(CONF_VALUE_TEMPLATE): vol.All(
cv.template, ValueTemplate.from_template
Expand Down
31 changes: 17 additions & 14 deletions homeassistant/components/sql/config_flow.py
Comment thread
gjohansson-ST marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
from sqlalchemy.engine import Engine, Result
from sqlalchemy.exc import MultipleResultsFound, NoSuchColumnError, SQLAlchemyError
from sqlalchemy.orm import Session, scoped_session, sessionmaker
import sqlparse
from sqlparse.exceptions import SQLParseError
import voluptuous as vol

Expand All @@ -31,21 +30,19 @@
CONF_UNIT_OF_MEASUREMENT,
CONF_VALUE_TEMPLATE,
)
from homeassistant.core import callback
from homeassistant.core import async_get_hass, callback
from homeassistant.data_entry_flow import section
from homeassistant.helpers import selector

from .const import CONF_ADVANCED_OPTIONS, CONF_COLUMN_NAME, CONF_QUERY, DOMAIN
from .util import resolve_db_url
from .util import check_and_render_sql_query, resolve_db_url

_LOGGER = logging.getLogger(__name__)


OPTIONS_SCHEMA: vol.Schema = vol.Schema(
{
vol.Required(CONF_QUERY): selector.TextSelector(
selector.TextSelectorConfig(multiline=True)
),
vol.Required(CONF_QUERY): selector.TemplateSelector(),
vol.Required(CONF_COLUMN_NAME): selector.TextSelector(),
vol.Required(CONF_ADVANCED_OPTIONS): section(
vol.Schema(
Expand Down Expand Up @@ -89,14 +86,20 @@

def validate_sql_select(value: str) -> str:
"""Validate that value is a SQL SELECT query."""
if len(query := sqlparse.parse(value.lstrip().lstrip(";"))) > 1:
raise MultipleResultsFound
if len(query) == 0 or (query_type := query[0].get_type()) == "UNKNOWN":
raise ValueError
if query_type != "SELECT":
_LOGGER.debug("The SQL query %s is of type %s", query, query_type)
raise SQLParseError
return str(query[0])
hass = async_get_hass()
try:
return check_and_render_sql_query(hass, value)
except ValueError as err:
err_text = err.args[0]
_LOGGER.debug("Invalid query '%s' results in '%s'", value, err_text)
if err_text == "Multiple SQL statements are not allowed":
raise MultipleResultsFound from err
if err_text in (
"SQL query must be of type SELECT",
"SQL query must start with SELECT",
):
Comment thread
gjohansson-ST marked this conversation as resolved.
Outdated
raise SQLParseError from err
raise


def validate_db_connection(db_url: str) -> bool:
Expand Down
51 changes: 36 additions & 15 deletions homeassistant/components/sql/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
MATCH_ALL,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import TemplateError
from homeassistant.exceptions import PlatformNotReady, TemplateError
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity_platform import (
AddConfigEntryEntitiesCallback,
Expand All @@ -41,6 +41,7 @@
from .const import CONF_ADVANCED_OPTIONS, CONF_COLUMN_NAME, CONF_QUERY, DOMAIN
from .util import (
async_create_sessionmaker,
check_and_render_sql_query,
convert_value,
generate_lambda_stmt,
redact_credentials,
Expand Down Expand Up @@ -81,7 +82,7 @@ async def async_setup_platform(
return

name: Template = conf[CONF_NAME]
query_str: str = conf[CONF_QUERY]
query_template: ValueTemplate = conf[CONF_QUERY]
value_template: ValueTemplate | None = conf.get(CONF_VALUE_TEMPLATE)
column_name: str = conf[CONF_COLUMN_NAME]
unique_id: str | None = conf.get(CONF_UNIQUE_ID)
Expand All @@ -96,7 +97,7 @@ async def async_setup_platform(
await async_setup_sensor(
hass,
trigger_entity_config,
query_str,
query_template,
column_name,
value_template,
unique_id,
Expand All @@ -119,6 +120,13 @@ async def async_setup_entry(
template: str | None = entry.options[CONF_ADVANCED_OPTIONS].get(CONF_VALUE_TEMPLATE)
column_name: str = entry.options[CONF_COLUMN_NAME]

query_template: ValueTemplate | None = None
try:
query_template = ValueTemplate(query_str, hass)
query_template.ensure_valid()
except TemplateError as err:
raise PlatformNotReady("Invalid SQL query template") from err

value_template: ValueTemplate | None = None
if template is not None:
try:
Expand All @@ -137,7 +145,7 @@ async def async_setup_entry(
await async_setup_sensor(
hass,
trigger_entity_config,
query_str,
query_template,
column_name,
value_template,
entry.entry_id,
Expand All @@ -150,7 +158,7 @@ async def async_setup_entry(
async def async_setup_sensor(
hass: HomeAssistant,
trigger_entity_config: ConfigType,
query_str: str,
query_template: ValueTemplate,
column_name: str,
value_template: ValueTemplate | None,
unique_id: str | None,
Expand All @@ -166,22 +174,25 @@ async def async_setup_sensor(
) = await async_create_sessionmaker(hass, db_url)
if sessmaker is None:
return
validate_query(hass, query_str, uses_recorder_db, unique_id)
validate_query(hass, query_template, uses_recorder_db, unique_id)

query_str = check_and_render_sql_query(hass, query_template)
upper_query = query_str.upper()
# MSSQL uses TOP and not LIMIT
mod_query_template = query_template
if not ("LIMIT" in upper_query or "SELECT TOP" in upper_query):
if "mssql" in db_url:
query_str = upper_query.replace("SELECT", "SELECT TOP 1")
_query = query_template.template.replace("SELECT", "SELECT TOP 1")
else:
query_str = query_str.replace(";", "") + " LIMIT 1;"
_query = query_template.template.replace(";", "") + " LIMIT 1;"
mod_query_template = ValueTemplate(_query, hass)

async_add_entities(
[
SQLSensor(
trigger_entity_config,
sessmaker,
query_str,
mod_query_template,
column_name,
value_template,
yaml,
Expand All @@ -200,7 +211,7 @@ def __init__(
self,
trigger_entity_config: ConfigType,
sessmaker: scoped_session,
query: str,
query: ValueTemplate,
column: str,
value_template: ValueTemplate | None,
yaml: bool,
Expand All @@ -214,7 +225,6 @@ def __init__(
self.sessionmaker = sessmaker
self._attr_extra_state_attributes = {}
self._use_database_executor = use_database_executor
self._lambda_stmt = generate_lambda_stmt(query)
if not yaml and (unique_id := trigger_entity_config.get(CONF_UNIQUE_ID)):
self._attr_name = None
self._attr_has_entity_name = True
Expand Down Expand Up @@ -255,19 +265,30 @@ def _update(self) -> None:
self._attr_extra_state_attributes = {}
sess: scoped_session = self.sessionmaker()
try:
result: Result = sess.execute(self._lambda_stmt)
rendered_query = check_and_render_sql_query(self.hass, self._query)
Comment thread
gjohansson-ST marked this conversation as resolved.
_lambda_stmt = generate_lambda_stmt(rendered_query)
result: Result = sess.execute(_lambda_stmt)
except ValueError as err:
_LOGGER.error(
"Error rendering query %s: %s",
redact_credentials(self._query.template),
redact_credentials(str(err)),
)
sess.rollback()
sess.close()
return
except SQLAlchemyError as err:
_LOGGER.error(
"Error executing query %s: %s",
self._query,
rendered_query,
redact_credentials(str(err)),
)
sess.rollback()
sess.close()
return

for res in result.mappings():
_LOGGER.debug("Query %s result in %s", self._query, res.items())
_LOGGER.debug("Query %s result in %s", rendered_query, res.items())
data = res[self._column_name]
for key, value in res.items():
self._attr_extra_state_attributes[key] = convert_value(value)
Expand All @@ -287,6 +308,6 @@ def _update(self) -> None:
self._attr_native_value = data

if data is None:
_LOGGER.warning("%s returned no results", self._query)
_LOGGER.warning("%s returned no results", rendered_query)

sess.close()
9 changes: 7 additions & 2 deletions homeassistant/components/sql/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,13 @@
)
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.trigger_template_entity import ValueTemplate
from homeassistant.util.json import JsonValueType

from .const import CONF_QUERY, DOMAIN
from .util import (
async_create_sessionmaker,
check_and_render_sql_query,
convert_value,
generate_lambda_stmt,
redact_credentials,
Expand All @@ -37,7 +39,9 @@
SERVICE_QUERY = "query"
SERVICE_QUERY_SCHEMA = vol.Schema(
{
vol.Required(CONF_QUERY): vol.All(cv.string, validate_sql_select),
vol.Required(CONF_QUERY): vol.All(
cv.template, ValueTemplate.from_template, validate_sql_select
),
Comment on lines +42 to +44

@arturpragacz arturpragacz Nov 10, 2025

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a schema for a service. It should not contain templates, as template support for services is handled automatically by Core prior to this validation.

vol.Optional(CONF_DB_URL): cv.string,
}
)
Expand Down Expand Up @@ -72,8 +76,9 @@ async def _async_query_service(
def _execute_and_convert_query() -> list[JsonValueType]:
"""Execute the query and return the results with converted types."""
sess: Session = sessmaker()
rendered_query = check_and_render_sql_query(call.hass, query_str)
try:
result: Result = sess.execute(generate_lambda_stmt(query_str))
result: Result = sess.execute(generate_lambda_stmt(rendered_query))
except SQLAlchemyError as err:
_LOGGER.debug(
"Error executing query %s: %s",
Expand Down
53 changes: 42 additions & 11 deletions homeassistant/components/sql/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@
from homeassistant.components.recorder import SupportedDialect, get_instance
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.exceptions import TemplateError
from homeassistant.helpers import issue_registry as ir
from homeassistant.helpers.template import Template

from .const import DB_URL_RE, DOMAIN
from .models import SQLData
Expand All @@ -44,16 +46,14 @@ def resolve_db_url(hass: HomeAssistant, db_url: str | None) -> str:
return get_instance(hass).db_url


def validate_sql_select(value: str) -> str:
def validate_sql_select(value: Template) -> Template:
"""Validate that value is a SQL SELECT query."""
if len(query := sqlparse.parse(value.lstrip().lstrip(";"))) > 1:
raise vol.Invalid("Multiple SQL queries are not supported")
if len(query) == 0 or (query_type := query[0].get_type()) == "UNKNOWN":
raise vol.Invalid("Invalid SQL query")
if query_type != "SELECT":
_LOGGER.debug("The SQL query %s is of type %s", query, query_type)
raise vol.Invalid("Only SELECT queries allowed")
return str(query[0])
try:
assert value.hass
check_and_render_sql_query(value.hass, value)
except ValueError as err:
raise vol.Invalid(str(err)) from err
return value


async def async_create_sessionmaker(
Expand Down Expand Up @@ -113,15 +113,15 @@ async def async_create_sessionmaker(

def validate_query(
hass: HomeAssistant,
query_str: str,
query_template: str | Template,
uses_recorder_db: bool,
unique_id: str | None = None,
) -> None:
"""Validate the query against common performance issues.

Args:
hass: The Home Assistant instance.
query_str: The SQL query string to be validated.
query_template: The SQL query string to be validated.
uses_recorder_db: A boolean indicating if the query is against the recorder database.
unique_id: The unique ID of the entity, used for creating issue registry keys.

Expand All @@ -131,6 +131,10 @@ def validate_query(
"""
if not uses_recorder_db:
return
if isinstance(query_template, Template):
query_str = query_template.async_render()
else:
query_str = Template(query_template, hass).async_render()
redacted_query = redact_credentials(query_str)

issue_key = unique_id if unique_id else redacted_query
Expand Down Expand Up @@ -239,3 +243,30 @@ def convert_value(value: Any) -> Any:
return f"0x{value.hex()}"
case _:
return value


def check_and_render_sql_query(hass: HomeAssistant, query: Template | str) -> str:
"""Check and render SQL query."""
if isinstance(query, str):
query = query.strip()
if not query:
raise ValueError("Query cannot be empty")
query = Template(query, hass=hass)

try:
query.ensure_valid()
rendered_query: str = query.async_render()
Comment thread
gjohansson-ST marked this conversation as resolved.
Outdated
except TemplateError as err:
raise ValueError("Invalid template") from err
if len(rendered_queries := sqlparse.parse(rendered_query.lstrip().lstrip(";"))) > 1:
Comment thread
gjohansson-ST marked this conversation as resolved.
raise ValueError("Multiple SQL statements are not allowed")
if (
len(rendered_queries) == 0
or (query_type := rendered_queries[0].get_type()) == "UNKNOWN"
):
raise ValueError("SQL query is empty or unknown type")
if query_type != "SELECT":
_LOGGER.debug("The SQL query %s is of type %s", rendered_query, query_type)
raise ValueError("SQL query must be of type SELECT")

return str(rendered_queries[0])
Loading
Loading