Source code for codegen_database.validator
"""Schema item validators for dimension factories."""
from __future__ import annotations
import re
from typing import TYPE_CHECKING, Protocol
from sqlalchemy.schema import DefaultClause
from codegen_database.errors import CodegenDatabaseValidationError
if TYPE_CHECKING:
from collections.abc import Iterable
from sqlalchemy import Table
from sqlalchemy.schema import SchemaItem
from codegen_database.check import CodegenDatabaseCheck
from codegen_database.index import CodegenDatabaseIndex
_SchemaItemType = SchemaItem | CodegenDatabaseCheck | CodegenDatabaseIndex
_FUNCTION_CALL_DEFAULT = re.compile(
r'^\s*[A-Za-z_][\w.]*\(.*\)(::[\w .\[\]"]+)?\s*$', re.DOTALL
)
[docs]
def frozen_function_default(server_default: object) -> str | None:
"""Return the SQL of a ``server_default`` that PostgreSQL will freeze.
A ``server_default`` given as a plain :class:`str` is emitted as a
*quoted* SQL literal (``DEFAULT 'now()'``), which PostgreSQL evaluates
once at DDL time -- every row then shares that single frozen value.
When the string is shaped like a function call (``now()``,
``gen_random_uuid()``, ``nextval(...)`` ...) that is almost never what
was meant. Wrap it in ``func.<fn>()`` or ``text(...)`` so it renders
as a live call re-evaluated per row.
Returns the offending string, or ``None`` when the default is safe (a
SQL element / ``text()`` clause, or a plain literal constant such as
``"0"`` / ``"true"``).
"""
arg = (
server_default.arg
if isinstance(server_default, DefaultClause)
else server_default
)
if isinstance(arg, str) and _FUNCTION_CALL_DEFAULT.match(arg):
return arg
return None
[docs]
def reject_frozen_function_defaults(tables: Iterable[Table]) -> None:
"""Raise if any column defaults a function call passed as a plain string.
Guards against the silent ``DEFAULT 'now()'`` bug (frozen at migration
time) across every column in *tables* -- both user-supplied columns and
the timestamp columns the built-in plugins inject.
"""
for table in tables:
for col in table.columns:
frozen = frozen_function_default(col.server_default)
if frozen is not None:
msg = (
f"Column {col.name!r} on table {table.fullname!r} has "
f"server_default={frozen!r} as a plain string, which "
f"renders as the SQL literal DEFAULT '{frozen}'. "
f"PostgreSQL evaluates that once at migration time, so "
f"every row gets the same frozen value. Pass a SQL "
f"expression instead, e.g. func.{frozen} or "
f'text("{frozen}").'
)
raise CodegenDatabaseValidationError(msg)
class _SchemaItemValidator(Protocol):
def __call__(self, item: _SchemaItemType) -> bool: ...
[docs]
def is_schema_item_not_primary_key(
item: _SchemaItemType,
) -> bool:
"""Return True if the item is not a primary key column.
Args:
item: The schema item to inspect.
Returns:
``True`` if *item* is not a primary key column.
"""
return not getattr(item, "primary_key", False)
_default_validators: list[_SchemaItemValidator] = [
is_schema_item_not_primary_key,
]
[docs]
def validate_schema_items(
items: list,
*,
validators: list[_SchemaItemValidator] | None = None,
) -> None:
"""Validate a list of SchemaItems against the given validators.
Args:
items: Schema items to validate.
validators: Validators to run; defaults to
``[is_schema_item_not_primary_key]``.
Raises:
CodegenDatabaseValidationError: If any item fails a validator.
"""
validators = validators or _default_validators
for item in items:
for validator in validators:
if not validator(item):
name = getattr(validator, "__name__", repr(validator))
msg = f"{name} failed for {item!r}"
raise CodegenDatabaseValidationError(msg)