From 9fbca06f7cafd47b16383b7f2f7f089eb5175d1c Mon Sep 17 00:00:00 2001 From: mptyl Date: Sat, 18 Jul 2026 13:24:20 +0200 Subject: [PATCH] feat(harness): make `tht schema columns` glob/pattern-aware At F4 (schema_linking) a model asking for a whole table family, e.g. `fact_sost_impianto_*`, used to hit a bare "tabella non nel catalogo" and stall without recovering. Now a pattern (containing * ? [) resolves to every matching catalog table and returns their columns (JSON becomes an array of per-table objects); exact names keep the original single-object contract. A non-glob miss also suggests sibling tables sharing the leading segment, to aid recovery. Verified live: `fact_sost_impianto_*` resolves the 20-table family on the psd catalog. Harness suite green (811 passed). Co-Authored-By: Claude Opus 4.8 --- harness/tests/test_schema_columns_cmd.py | 59 +++++++++++++++++++++++ harness/tht/cli/schema_cmd.py | 61 +++++++++++++++++++----- 2 files changed, 108 insertions(+), 12 deletions(-) diff --git a/harness/tests/test_schema_columns_cmd.py b/harness/tests/test_schema_columns_cmd.py index 1847b3e7..d9694e31 100644 --- a/harness/tests/test_schema_columns_cmd.py +++ b/harness/tests/test_schema_columns_cmd.py @@ -38,3 +38,62 @@ def test_schema_columns_json(tmp_path, monkeypatch): assert data["table"] == "dim_patient" assert data["description"] == "Anagrafica" assert {"name": "cod_paz", "description": "Codice paziente", "type": "bigint", "pk": True} in data["columns"] + + +def _write_family_catalog(tmp_path): + def _t(comment): + return TablePhysical( + comment=comment, + columns={"cod_paz": ColumnPhysical(type="bigint", pk=True, comment="Codice paziente")}, + ) + + phys = PhysicalSchema( + database="d", schema="s", introspected_at=datetime(2026, 1, 1), + tables={ + "fact_sost_impianto_pmk": _t("Sost PMK"), + "fact_sost_impianto_crt_d": _t("Sost CRT-D"), + "fact_impianto_pacemaker": _t("Impianto PMK"), + }, + ) + out = tmp_path / "artifacts" / "mschema" / "physical.yaml" + phys.to_yaml(out) + return out + + +def _cfg(tmp_path): + cfg = tmp_path / "workspace.yaml" + cfg.write_text( + "database: {database: d, schema: s, user: u, password: p, transport: direct}\n" + f"paths: {{artifacts: {tmp_path/'artifacts'}, indexes: {tmp_path/'i'}, sessions: {tmp_path/'s'}}}\n" + ) + return cfg + + +def test_schema_columns_glob_resolves_the_matching_family(tmp_path): + _write_family_catalog(tmp_path) + res = CliRunner().invoke( + app, ["schema", "columns", "fact_sost_impianto_*", "--json", "-c", str(_cfg(tmp_path))] + ) + assert res.exit_code == 0, res.output + data = json.loads(res.output) + assert isinstance(data, list) # a pattern yields an array of per-table objects + assert sorted(d["table"] for d in data) == ["fact_sost_impianto_crt_d", "fact_sost_impianto_pmk"] + + +def test_schema_columns_glob_without_matches_errors(tmp_path): + _write_family_catalog(tmp_path) + res = CliRunner().invoke( + app, ["schema", "columns", "fact_assente_*", "-c", str(_cfg(tmp_path))] + ) + assert res.exit_code == 1 + assert "nessuna tabella corrisponde al pattern" in res.output + + +def test_schema_columns_exact_miss_suggests_siblings(tmp_path): + _write_family_catalog(tmp_path) + res = CliRunner().invoke( + app, ["schema", "columns", "fact_sost_impianto_xyz", "-c", str(_cfg(tmp_path))] + ) + assert res.exit_code == 1 + assert "tabella non nel catalogo" in res.output + assert "fact_sost_impianto_crt_d" in res.output # sibling suggested to aid recovery diff --git a/harness/tht/cli/schema_cmd.py b/harness/tht/cli/schema_cmd.py index f8b3c1b7..9110b40c 100644 --- a/harness/tht/cli/schema_cmd.py +++ b/harness/tht/cli/schema_cmd.py @@ -393,21 +393,58 @@ def columns_cmd( ) raise typer.Exit(code=1) physical = PhysicalSchema.from_yaml(phys_file) + + def _payload(name, tbl): + return { + "table": name, + "description": tbl.comment, + "columns": [ + {"name": n, "description": col.comment, "type": col.type, "pk": col.pk} + for n, col in tbl.columns.items() + ], + } + + def _emit_human(p): + typer.echo(f"{p['table']}: {p['description']}") + for c in p["columns"]: + typer.echo(f" {'*' if c['pk'] else ' '} {c['name']} ({c['type']}) — {c['description']}") + + # Glob-friendly: a pattern (containing * ? [) resolves to every matching catalog + # table, so a model can ask for a whole family (e.g. fact_sost_impianto_*) in one + # call instead of stalling on an unknown wildcard. Exact names keep the original + # single-object contract; JSON for a pattern is an array of per-table objects. + if any(ch in table for ch in "*?["): + from fnmatch import fnmatch + + matches = sorted(n for n in physical.tables if fnmatch(n, table)) + if not matches: + typer.secho( + f"ERRORE: nessuna tabella corrisponde al pattern: {table}", + fg=typer.colors.RED, err=True, + ) + raise typer.Exit(code=1) + payloads = [_payload(n, physical.tables[n]) for n in matches] + if json_out: + typer.echo(_json.dumps(payloads, ensure_ascii=False)) + return + for i, p in enumerate(payloads): + if i: + typer.echo("") + _emit_human(p) + return + tbl = physical.tables.get(table) if tbl is None: - typer.secho(f"ERRORE: tabella non nel catalogo: {table}", fg=typer.colors.RED, err=True) + # Aid recovery: suggest catalog tables that share the leading segment. + prefix = table.rsplit("_", 1)[0] + "_" if "_" in table else table + hints = sorted(n for n in physical.tables if n.startswith(prefix))[:12] + msg = f"ERRORE: tabella non nel catalogo: {table}" + if hints: + msg += f" (forse: {', '.join(hints)})" + typer.secho(msg, fg=typer.colors.RED, err=True) raise typer.Exit(code=1) - payload = { - "table": table, - "description": tbl.comment, - "columns": [ - {"name": name, "description": col.comment, "type": col.type, "pk": col.pk} - for name, col in tbl.columns.items() - ], - } + payload = _payload(table, tbl) if json_out: typer.echo(_json.dumps(payload, ensure_ascii=False)) return - typer.echo(f"{table}: {tbl.comment}") - for c in payload["columns"]: - typer.echo(f" {'*' if c['pk'] else ' '} {c['name']} ({c['type']}) — {c['description']}") + _emit_human(payload)