Home Assistant Unofficial Reference 2024.12.1
schema.py
Go to the documentation of this file.
1 """Schema repairs."""
2 
3 from __future__ import annotations
4 
5 from collections.abc import Iterable, Mapping
6 import logging
7 from typing import TYPE_CHECKING
8 
9 from sqlalchemy import MetaData, Table
10 from sqlalchemy.exc import OperationalError
11 from sqlalchemy.orm import DeclarativeBase
12 from sqlalchemy.orm.attributes import InstrumentedAttribute
13 
14 from ..const import SupportedDialect
15 from ..db_schema import DOUBLE_PRECISION_TYPE_SQL, DOUBLE_TYPE
16 from ..util import session_scope
17 
18 if TYPE_CHECKING:
19  from .. import Recorder
20 
21 _LOGGER = logging.getLogger(__name__)
22 
23 MYSQL_ERR_INCORRECT_STRING_VALUE = 1366
24 
25 # This name can't be represented unless 4-byte UTF-8 unicode is supported
26 UTF8_NAME = "๐“†š๐“ƒ—"
27 
28 # This number can't be accurately represented as a 32-bit float
29 PRECISE_NUMBER = 1.000000000000001
30 
31 
33  table_object: type[DeclarativeBase],
34 ) -> list[str]:
35  """Get the column names for the columns that need to be checked for precision."""
36  return [
37  column.key
38  for column in table_object.__table__.columns
39  if column.type is DOUBLE_TYPE
40  ]
41 
42 
44  instance: Recorder,
45  table_object: type[DeclarativeBase],
46  columns: tuple[InstrumentedAttribute, ...],
47 ) -> set[str]:
48  """Do some basic checks for common schema errors caused by manual migration."""
49  schema_errors: set[str] = set()
50  # Lack of full utf8 support is only an issue for MySQL / MariaDB
51  if instance.dialect_name != SupportedDialect.MYSQL:
52  return schema_errors
53 
54  try:
56  instance, table_object, columns
57  )
58  except Exception:
59  _LOGGER.exception("Error when validating DB schema")
60 
61  _log_schema_errors(table_object, schema_errors)
62  return schema_errors
63 
64 
66  instance: Recorder,
67  table_object: type[DeclarativeBase],
68 ) -> set[str]:
69  """Verify the table has the correct collation."""
70  schema_errors: set[str] = set()
71  # Lack of full utf8 support is only an issue for MySQL / MariaDB
72  if instance.dialect_name != SupportedDialect.MYSQL:
73  return schema_errors
74 
75  try:
77  instance, table_object
78  )
79  except Exception:
80  _LOGGER.exception("Error when validating DB schema")
81 
82  _log_schema_errors(table_object, schema_errors)
83  return schema_errors
84 
85 
87  instance: Recorder,
88  table_object: type[DeclarativeBase],
89 ) -> set[str]:
90  """Ensure the table has the correct collation to avoid union errors with mixed collations."""
91  schema_errors: set[str] = set()
92  # Mark the session as read_only to ensure that the test data is not committed
93  # to the database and we always rollback when the scope is exited
94  with session_scope(session=instance.get_session(), read_only=True) as session:
95  table = table_object.__tablename__
96  metadata_obj = MetaData()
97  reflected_table = Table(table, metadata_obj, autoload_with=instance.engine)
98  connection = session.connection()
99  dialect_kwargs = reflected_table.dialect_kwargs
100  # Check if the table has a collation set, if its not set than its
101  # using the server default collation for the database
102 
103  collate = (
104  dialect_kwargs.get("mysql_collate")
105  or dialect_kwargs.get("mariadb_collate")
106  or connection.dialect._fetch_setting(connection, "collation_server") # type: ignore[attr-defined] # noqa: SLF001
107  )
108  if collate and collate != "utf8mb4_unicode_ci":
109  _LOGGER.debug(
110  "Database %s collation is not utf8mb4_unicode_ci",
111  table,
112  )
113  schema_errors.add(f"{table}.utf8mb4_unicode_ci")
114  return schema_errors
115 
116 
118  instance: Recorder,
119  table_object: type[DeclarativeBase],
120  columns: tuple[InstrumentedAttribute, ...],
121 ) -> set[str]:
122  """Do some basic checks for common schema errors caused by manual migration."""
123  schema_errors: set[str] = set()
124  # Mark the session as read_only to ensure that the test data is not committed
125  # to the database and we always rollback when the scope is exited
126  with session_scope(session=instance.get_session(), read_only=True) as session:
127  db_object = table_object(**{column.key: UTF8_NAME for column in columns})
128  table = table_object.__tablename__
129  # Try inserting some data which needs utf8mb4 support
130  session.add(db_object)
131  try:
132  session.flush()
133  except OperationalError as err:
134  if err.orig and err.orig.args[0] == MYSQL_ERR_INCORRECT_STRING_VALUE:
135  _LOGGER.debug(
136  "Database %s statistics_meta does not support 4-byte UTF-8",
137  table,
138  )
139  schema_errors.add(f"{table}.4-byte UTF-8")
140  return schema_errors
141  raise
142  finally:
143  session.rollback()
144  return schema_errors
145 
146 
148  instance: Recorder,
149  table_object: type[DeclarativeBase],
150 ) -> set[str]:
151  """Do some basic checks for common schema errors caused by manual migration."""
152  schema_errors: set[str] = set()
153  # Wrong precision is only an issue for MySQL / MariaDB / PostgreSQL
154  if instance.dialect_name not in (
155  SupportedDialect.MYSQL,
156  SupportedDialect.POSTGRESQL,
157  ):
158  return schema_errors
159  try:
160  schema_errors = _validate_db_schema_precision(instance, table_object)
161  except Exception:
162  _LOGGER.exception("Error when validating DB schema")
163 
164  _log_schema_errors(table_object, schema_errors)
165  return schema_errors
166 
167 
169  instance: Recorder,
170  table_object: type[DeclarativeBase],
171 ) -> set[str]:
172  """Do some basic checks for common schema errors caused by manual migration."""
173  schema_errors: set[str] = set()
174  columns = _get_precision_column_types(table_object)
175  # Mark the session as read_only to ensure that the test data is not committed
176  # to the database and we always rollback when the scope is exited
177  with session_scope(session=instance.get_session(), read_only=True) as session:
178  db_object = table_object(**{column: PRECISE_NUMBER for column in columns})
179  table = table_object.__tablename__
180  try:
181  session.add(db_object)
182  session.flush()
183  session.refresh(db_object)
185  schema_errors=schema_errors,
186  stored={column: getattr(db_object, column) for column in columns},
187  expected={column: PRECISE_NUMBER for column in columns},
188  columns=columns,
189  table_name=table,
190  supports="double precision",
191  )
192  finally:
193  session.rollback()
194  return schema_errors
195 
196 
198  table_object: type[DeclarativeBase], schema_errors: set[str]
199 ) -> None:
200  """Log schema errors."""
201  if not schema_errors:
202  return
203  _LOGGER.debug(
204  "Detected %s schema errors: %s",
205  table_object.__tablename__,
206  ", ".join(sorted(schema_errors)),
207  )
208 
209 
211  schema_errors: set[str],
212  stored: Mapping,
213  expected: Mapping,
214  columns: Iterable[str],
215  table_name: str,
216  supports: str,
217 ) -> None:
218  """Check that the columns in the table support the given feature.
219 
220  Errors are logged and added to the schema_errors set.
221  """
222  for column in columns:
223  if stored[column] == expected[column]:
224  continue
225  schema_errors.add(f"{table_name}.{supports}")
226  _LOGGER.error(
227  "Column %s in database table %s does not support %s (stored=%s != expected=%s)",
228  column,
229  table_name,
230  supports,
231  stored[column],
232  expected[column],
233  )
234 
235 
237  instance: Recorder, table_object: type[DeclarativeBase], schema_errors: set[str]
238 ) -> None:
239  """Correct utf8 issues detected by validate_db_schema."""
240  table_name = table_object.__tablename__
241  if (
242  f"{table_name}.4-byte UTF-8" in schema_errors
243  or f"{table_name}.utf8mb4_unicode_ci" in schema_errors
244  ):
245  from ..migration import ( # pylint: disable=import-outside-toplevel
246  _correct_table_character_set_and_collation,
247  )
248 
249  _correct_table_character_set_and_collation(table_name, instance.get_session)
250 
251 
253  instance: Recorder,
254  table_object: type[DeclarativeBase],
255  schema_errors: set[str],
256 ) -> None:
257  """Correct precision issues detected by validate_db_schema."""
258  table_name = table_object.__tablename__
259 
260  if f"{table_name}.double precision" in schema_errors:
261  from ..migration import ( # pylint: disable=import-outside-toplevel
262  _modify_columns,
263  )
264 
265  precision_columns = _get_precision_column_types(table_object)
266  # Attempt to convert timestamp columns to ยตs precision
267  session_maker = instance.get_session
268  engine = instance.engine
269  assert engine is not None, "Engine should be set"
271  session_maker,
272  engine,
273  table_name,
274  [f"{column} {DOUBLE_PRECISION_TYPE_SQL}" for column in precision_columns],
275  )
set[str] _validate_table_schema_supports_utf8(Recorder instance, type[DeclarativeBase] table_object, tuple[InstrumentedAttribute,...] columns)
Definition: schema.py:121
None _log_schema_errors(type[DeclarativeBase] table_object, set[str] schema_errors)
Definition: schema.py:199
None correct_db_schema_precision(Recorder instance, type[DeclarativeBase] table_object, set[str] schema_errors)
Definition: schema.py:256
None _check_columns(set[str] schema_errors, Mapping stored, Mapping expected, Iterable[str] columns, str table_name, str supports)
Definition: schema.py:217
set[str] validate_table_schema_has_correct_collation(Recorder instance, type[DeclarativeBase] table_object)
Definition: schema.py:68
None correct_db_schema_utf8(Recorder instance, type[DeclarativeBase] table_object, set[str] schema_errors)
Definition: schema.py:238
set[str] _validate_table_schema_has_correct_collation(Recorder instance, type[DeclarativeBase] table_object)
Definition: schema.py:89
set[str] validate_db_schema_precision(Recorder instance, type[DeclarativeBase] table_object)
Definition: schema.py:150
set[str] _validate_db_schema_precision(Recorder instance, type[DeclarativeBase] table_object)
Definition: schema.py:171
set[str] validate_table_schema_supports_utf8(Recorder instance, type[DeclarativeBase] table_object, tuple[InstrumentedAttribute,...] columns)
Definition: schema.py:47
list[str] _get_precision_column_types(type[DeclarativeBase] table_object)
Definition: schema.py:34
None _modify_columns(Callable[[], Session] session_maker, Engine engine, str table_name, list[str] columns_def)
Definition: migration.py:585
None _correct_table_character_set_and_collation(str table, Callable[[], Session] session_maker)
Definition: migration.py:1876
Generator[Session] session_scope(*HomeAssistant|None hass=None, Session|None session=None, Callable[[Exception], bool]|None exception_filter=None, bool read_only=False)
Definition: recorder.py:86