Files
2026-07-13 13:22:34 +08:00

257 lines
8.0 KiB
Python

import difflib
import logging
import re
from pathlib import Path
from typing import NamedTuple
import pytest
from sqlalchemy import create_engine, inspect
from sqlalchemy.schema import CreateTable, MetaData, UniqueConstraint
_logger = logging.getLogger(__name__)
_DIALECT_REFLECTED_UNIQUE_CONSTRAINTS = {
"mysql": {
"uq_experiments_workspace_name",
"uq_endpoints_workspace_name",
"uq_secrets_workspace_secret_name",
"uq_model_definitions_workspace_name",
},
"mssql": {
"uq_experiments_workspace_name",
"uq_endpoints_workspace_name",
"uq_secrets_workspace_secret_name",
"uq_model_definitions_workspace_name",
},
}
import mlflow
from mlflow.environment_variables import MLFLOW_TRACKING_URI
pytestmark = pytest.mark.notrackingurimock
def get_database_dialect(uri):
return create_engine(uri).dialect.name
def get_tracking_uri():
return MLFLOW_TRACKING_URI.get()
def dump_schema(db_uri):
engine = create_engine(db_uri)
created_tables_metadata = MetaData()
created_tables_metadata.reflect(bind=engine)
_reattach_missing_unique_constraints(engine, created_tables_metadata)
# Write out table schema as described in
# https://docs.sqlalchemy.org/en/13/faq/metadata_schema.html#how-can-i-get-the-create-table-drop-table-output-as-a-string
lines = []
for table in created_tables_metadata.sorted_tables:
# Apply `str.rstrip` to remove trailing whitespaces
lines += map(str.rstrip, str(CreateTable(table)).splitlines())
return "\n".join(lines)
def _reattach_missing_unique_constraints(engine, metadata):
constraint_names = _DIALECT_REFLECTED_UNIQUE_CONSTRAINTS.get(engine.dialect.name)
if not constraint_names:
return
inspector = inspect(engine)
for table in metadata.sorted_tables:
existing_unique_columns = {
tuple(constraint.columns.keys())
for constraint in table.constraints
if isinstance(constraint, UniqueConstraint)
}
# Not all dialects reflect `UniqueConstraint` objects the same way. MySQL reports
# them as indexes; MSSQL doesn't implement `get_unique_constraints` at all. We
# normalize the reflection results via `_get_unique_constraints` so the same code
# path can reattach missing `UniqueConstraint`s across dialects.
for unique in _get_unique_constraints(inspector, engine.dialect.name, table.name):
name = unique.get("name")
columns = tuple(unique.get("column_names") or ())
duplicates_index = unique.get("duplicates_index")
if not columns or name not in constraint_names:
continue
if engine.dialect.name == "mysql" and not duplicates_index:
# MySQL exposes unique constraints as unique indexes. SQLAlchemy treats those as
# indexes during reflection, so the reflected metadata lacks the original
# `UniqueConstraint`. Only recreate constraints that are backed by an actual
# unique index reported via `duplicates_index`.
continue
if columns in existing_unique_columns:
continue
if missing_columns := tuple(column for column in columns if column not in table.c):
_logger.warning(
"Skipping recreation of unique constraint '%s' on table '%s' due to "
"missing columns: %s",
name,
table.name,
", ".join(missing_columns),
)
continue
constraint = UniqueConstraint(*[table.c[column] for column in columns], name=name)
table.append_constraint(constraint)
existing_unique_columns.add(columns)
def _get_unique_constraints(inspector, dialect, table_name):
try:
unique_constraints = inspector.get_unique_constraints(table_name)
except NotImplementedError:
unique_constraints = None
if unique_constraints is None:
unique_constraints = []
if not unique_constraints:
unique_constraints = [
{
"name": index.get("name"),
"column_names": index.get("column_names"),
"duplicates_index": index.get("unique"),
}
for index in inspector.get_indexes(table_name)
if index.get("unique")
]
return unique_constraints
class _CreateTable(NamedTuple):
table: str
columns: str
_CREATE_TABLE_REGEX = re.compile(
r"""
CREATE TABLE (?P<table>\S+?) \(
(?P<columns>.+?)
\)
""".strip(),
flags=re.DOTALL,
)
def parse_create_tables(schema):
return [
_CreateTable(
table=m.group("table"),
columns=set(m.group("columns").splitlines()),
)
for m in _CREATE_TABLE_REGEX.finditer(schema)
]
def schema_equal(schema_a, schema_b):
create_tables_a = parse_create_tables(schema_a)
create_tables_b = parse_create_tables(schema_b)
assert create_tables_a != []
assert create_tables_b != []
return create_tables_a == create_tables_b
def get_schema_path(db_uri):
return Path(__file__).parent / "schemas" / (get_database_dialect(db_uri) + ".sql")
def iter_parameter_sets():
a = """
CREATE TABLE table (
col VARCHAR(10)
)
"""
b = """
CREATE TABLE table (
col VARCHAR(10)
)
"""
yield pytest.param(a, b, True, id="identical schemas")
a = """
CREATE TABLE table1 (
col VARCHAR(10)
)
"""
b = """
CREATE TABLE table2 (
col VARCHAR(10)
)
"""
yield pytest.param(a, b, False, id="different table names")
a = """
CREATE TABLE table (
col1 VARCHAR(10)
)
"""
b = """
CREATE TABLE table (
col2 VARCHAR(10)
)
"""
yield pytest.param(a, b, False, id="different column names")
@pytest.mark.parametrize(("a", "b", "expected"), iter_parameter_sets())
def test_schema_equal(a, b, expected):
assert schema_equal(a, b) is expected
def initialize_database():
with mlflow.start_run():
pass
def get_schema_update_command(dialect):
this_script = Path(__file__).relative_to(Path.cwd())
docker_compose_yml = this_script.parent / "compose.yml"
return f"docker compose -f {docker_compose_yml} run --rm mlflow-{dialect} python {this_script}"
def test_schema_is_up_to_date():
initialize_database()
tracking_uri = get_tracking_uri()
schema_path = get_schema_path(tracking_uri)
existing_schema = schema_path.read_text()
latest_schema = dump_schema(tracking_uri)
dialect = get_database_dialect(tracking_uri)
update_command = get_schema_update_command(dialect)
message = (
f"{schema_path.relative_to(Path.cwd())} is not up-to-date. "
f"Please run this command to update it: {update_command}"
)
diff = "".join(
difflib.ndiff(
existing_schema.splitlines(keepends=True), latest_schema.splitlines(keepends=True)
)
)
rel_path = schema_path.relative_to(Path.cwd())
message = f"""
=================================== EXPECTED ===================================
{latest_schema}
==================================== ACTUAL ====================================
{existing_schema}
===================================== DIFF =====================================
{diff}
================================== HOW TO FIX ==================================
Manually copy & paste the expected schema in {rel_path} or run the following command:
{update_command}
"""
assert schema_equal(existing_schema, latest_schema), message
def main():
tracking_uri = get_tracking_uri()
assert tracking_uri, f"Environment variable {MLFLOW_TRACKING_URI} must be set"
get_database_dialect(tracking_uri) # Ensure `tracking_uri` is a database URI
mlflow.set_tracking_uri(tracking_uri)
initialize_database()
schema_path = get_schema_path(tracking_uri)
existing_schema = schema_path.read_text()
latest_schema = dump_schema(tracking_uri)
if not schema_equal(existing_schema, latest_schema):
schema_path.write_text(latest_schema)
if __name__ == "__main__":
main()