fix: complete P2 Task 2 schema machine contracts
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
# ruff: noqa: BLE001, S110, B008
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import TypedDict
|
||||
|
||||
import typer
|
||||
|
||||
@@ -124,13 +125,15 @@ def introspect_cmd(
|
||||
class _MachineSchemaError(Exception):
|
||||
"""An expected schema CLI failure with a stable public code."""
|
||||
|
||||
def __init__(self, code: str):
|
||||
def __init__(self, code: str, detail: str = ""):
|
||||
self.code = code
|
||||
self.detail = detail
|
||||
super().__init__(code)
|
||||
|
||||
|
||||
_MAX_STAGED_SQL_BYTES = 1 << 20
|
||||
_MAX_STAGED_SQL_TOTAL = 16 << 20
|
||||
_MAX_STAGED_SQL_FILES = 32
|
||||
|
||||
|
||||
def _schema_json(payload: dict) -> None:
|
||||
@@ -170,7 +173,10 @@ def _staged_sql_files(inputs: list[Path] | None) -> list[Path]:
|
||||
else:
|
||||
raise _MachineSchemaError("staged_sql_invalid")
|
||||
# Resolve only for ordering; content remains read from the caller's staged path.
|
||||
return sorted(set(files), key=lambda path: path.resolve().as_posix())
|
||||
ordered = sorted(set(files), key=lambda path: path.resolve().as_posix())
|
||||
if len(ordered) > _MAX_STAGED_SQL_FILES:
|
||||
raise _MachineSchemaError("staged_sql_too_many")
|
||||
return ordered
|
||||
|
||||
|
||||
def _read_staged_sql(inputs: list[Path] | None) -> tuple[list[Path], list[str]]:
|
||||
@@ -192,12 +198,43 @@ def _read_staged_sql(inputs: list[Path] | None) -> tuple[list[Path], list[str]]:
|
||||
return paths, contents
|
||||
|
||||
|
||||
class CandidateForeignKey(TypedDict):
|
||||
columns: list[str]
|
||||
ref_table: str
|
||||
ref_columns: list[str]
|
||||
|
||||
|
||||
class FKCandidate(TypedDict):
|
||||
table: str
|
||||
foreign_keys: list[CandidateForeignKey]
|
||||
|
||||
|
||||
class SuggestFksResult(TypedDict):
|
||||
status: str
|
||||
code: str
|
||||
candidates: list[FKCandidate]
|
||||
candidate_count: int
|
||||
candidate_digest: str
|
||||
orphan_count: int
|
||||
staged_sql_count: int
|
||||
mined_join_count: int
|
||||
ambiguous_columns: list[str]
|
||||
|
||||
|
||||
class CheckSchemaResult(TypedDict):
|
||||
status: str
|
||||
code: str
|
||||
orphan_count: int
|
||||
orphans: list[str]
|
||||
ignored: list[str]
|
||||
|
||||
|
||||
def _candidate_key(fk) -> tuple:
|
||||
return (tuple(fk.columns), fk.ref_table, tuple(fk.ref_columns))
|
||||
|
||||
|
||||
def suggest_fks_data(config: Path, *, from_sql: list[Path] | None = None,
|
||||
assume: list[str] | None = None) -> dict:
|
||||
assume: list[str] | None = None) -> SuggestFksResult:
|
||||
"""Return deterministic FK candidates without reviewing or mutating annotations."""
|
||||
import hashlib
|
||||
import json
|
||||
@@ -215,7 +252,7 @@ def suggest_fks_data(config: Path, *, from_sql: list[Path] | None = None,
|
||||
for value in assume or []:
|
||||
col, sep, ref = value.partition("=")
|
||||
if not sep or not col or ref not in physical.tables:
|
||||
raise _MachineSchemaError("assumption_invalid")
|
||||
raise _MachineSchemaError("assumption_invalid", value)
|
||||
assumed[col] = ref
|
||||
|
||||
def single_pk(table) -> str | None:
|
||||
@@ -303,7 +340,7 @@ def suggest_fks_data(config: Path, *, from_sql: list[Path] | None = None,
|
||||
}
|
||||
|
||||
|
||||
def check_schema_data(config: Path) -> dict:
|
||||
def check_schema_data(config: Path) -> CheckSchemaResult:
|
||||
"""Validate the physical catalog and imported annotations without writing."""
|
||||
from tht.mschema.merge import find_orphans
|
||||
from tht.mschema.models import Annotations
|
||||
@@ -315,11 +352,18 @@ def check_schema_data(config: Path) -> dict:
|
||||
except Exception:
|
||||
raise _MachineSchemaError("annotations_invalid") from None
|
||||
orphans = find_orphans(physical, annotations)
|
||||
ignored = [
|
||||
f"{table_name}.{column_name} ({column.eligibility_reason})"
|
||||
for table_name, table in physical.tables.items()
|
||||
for column_name, column in table.columns.items()
|
||||
if not column.eligible
|
||||
]
|
||||
return {
|
||||
"status": "succeeded" if not orphans else "failed",
|
||||
"code": "ok" if not orphans else "annotation_orphans",
|
||||
"orphan_count": len(orphans),
|
||||
"orphans": orphans,
|
||||
"ignored": ignored,
|
||||
}
|
||||
|
||||
|
||||
@@ -335,8 +379,7 @@ def check_cmd(
|
||||
if json_output:
|
||||
_schema_json({"status": "failed", "code": error.code})
|
||||
raise typer.Exit(code=1) from None
|
||||
_load_config_or_exit(config)
|
||||
raise typer.Exit(code=1) from None
|
||||
_render_schema_machine_error(error, config)
|
||||
except Exception:
|
||||
if json_output:
|
||||
_schema_json({"status": "failed", "code": "schema_check_failed"})
|
||||
@@ -344,11 +387,18 @@ def check_cmd(
|
||||
typer.secho("ERRORE: impossibile verificare le annotazioni.", fg=typer.colors.RED, err=True)
|
||||
raise typer.Exit(code=1) from None
|
||||
if json_output:
|
||||
# Keep the machine response to one object, including expected validation failures.
|
||||
_schema_json(payload)
|
||||
# ``ignored`` is human diagnostic context, not part of the machine contract.
|
||||
_schema_json({key: payload[key] for key in ("status", "code", "orphan_count", "orphans")})
|
||||
if payload["status"] != "succeeded":
|
||||
raise typer.Exit(code=3)
|
||||
return
|
||||
if payload["ignored"]:
|
||||
typer.secho(
|
||||
f"Colonne ignorate (testo ampio, {len(payload['ignored'])}):",
|
||||
fg=typer.colors.YELLOW,
|
||||
)
|
||||
for line in payload["ignored"]:
|
||||
typer.echo(f" - {line}")
|
||||
if payload["orphan_count"]:
|
||||
typer.secho(f"ATTENZIONE: {payload['orphan_count']} annotazioni orfane:", fg=typer.colors.YELLOW)
|
||||
for orphan in payload["orphans"]:
|
||||
@@ -357,6 +407,27 @@ def check_cmd(
|
||||
typer.secho("OK: nessuna annotazione orfana.", fg=typer.colors.GREEN)
|
||||
|
||||
|
||||
def _render_schema_machine_error(error: _MachineSchemaError, config: Path) -> None:
|
||||
"""Render expected schema failures for the legacy human command contract."""
|
||||
cfg = _load_config_or_exit(config)
|
||||
if error.code == "physical_schema_missing":
|
||||
typer.secho(
|
||||
f"ERRORE: {physical_path(cfg)} non trovato. Esegui prima `tht schema introspect`.",
|
||||
fg=typer.colors.RED,
|
||||
err=True,
|
||||
)
|
||||
elif error.code == "assumption_invalid":
|
||||
detail = getattr(error, "detail", "")
|
||||
typer.secho(
|
||||
f"ERRORE: --assume '{detail}' non valido (atteso col=tabella nel catalogo).",
|
||||
fg=typer.colors.RED,
|
||||
err=True,
|
||||
)
|
||||
else:
|
||||
typer.secho("ERRORE: impossibile elaborare il catalogo.", fg=typer.colors.RED, err=True)
|
||||
raise typer.Exit(code=1) from None
|
||||
|
||||
|
||||
@schema_app.command("suggest-fks")
|
||||
def suggest_fks_cmd(
|
||||
config: Path = CONFIG_OPT,
|
||||
@@ -379,12 +450,7 @@ def suggest_fks_cmd(
|
||||
if json_output:
|
||||
_schema_json({"status": "failed", "code": error.code})
|
||||
raise typer.Exit(code=1) from None
|
||||
if error.code == "assumption_invalid":
|
||||
typer.secho("ERRORE: --assume non valido (atteso col=tabella nel catalogo).", fg=typer.colors.RED, err=True)
|
||||
else:
|
||||
_load_config_or_exit(config)
|
||||
typer.secho("ERRORE: impossibile elaborare il catalogo.", fg=typer.colors.RED, err=True)
|
||||
raise typer.Exit(code=1) from None
|
||||
_render_schema_machine_error(error, config)
|
||||
except Exception:
|
||||
if json_output:
|
||||
_schema_json({"status": "failed", "code": "schema_suggestion_failed"})
|
||||
@@ -403,7 +469,9 @@ def suggest_fks_cmd(
|
||||
)
|
||||
if payload["ambiguous_columns"]:
|
||||
typer.secho(
|
||||
"PK ambigue saltate dalla regola same-name: " + ", ".join(payload["ambiguous_columns"]),
|
||||
"PK ambigue saltate dalla regola same-name (piu' tabelle proprietarie): "
|
||||
+ ", ".join(payload["ambiguous_columns"])
|
||||
+ ". Se servono, aggiungile a mano o passa --from-sql.",
|
||||
fg=typer.colors.YELLOW, err=True,
|
||||
)
|
||||
if not payload["candidates"]:
|
||||
@@ -422,7 +490,8 @@ def suggest_fks_cmd(
|
||||
ann.foreign_keys.extend(ForeignKey(**fk) for fk in table_payload["foreign_keys"])
|
||||
annotations.to_yaml(annotations_path(cfg))
|
||||
typer.secho(
|
||||
f"OK: {payload['candidate_count']} FK suggerite aggiunte a {annotations_path(cfg)}.",
|
||||
f"OK: {payload['candidate_count']} FK suggerite aggiunte a {annotations_path(cfg)} "
|
||||
f"({len(candidate_tables)} tabelle). Rivedile a mano prima dell'uso.",
|
||||
fg=typer.colors.GREEN,
|
||||
)
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user