#!/usr/bin/env python3 from __future__ import annotations import argparse import json from pathlib import Path from typing import Any SOURCE_BY_TARGET = {"ConfigSave": "Config", "ConfigCASSave": "ConfigCAS"} def sql_literal(value: str) -> str: return "N'" + value.replace("'", "''") + "'" def bracket_name(value: str) -> str: return "[" + value.replace("]", "]]") + "]" def read_json(path: Path) -> dict[str, Any]: data = json.loads(path.read_text(encoding="utf-8")) if not isinstance(data, dict): raise ValueError(f"{path} must contain a JSON object") return data def expected_rows_from_plan(plan: dict[str, Any]) -> tuple[list[dict[str, Any]], dict[str, Any]]: target = plan.get("target") if isinstance(plan.get("target"), dict) else {} source_family = plan.get("source_family") if isinstance(plan.get("source_family"), dict) else {} target_table = str(target.get("table") or "") source_table = str(source_family.get("expected_source_table") or SOURCE_BY_TARGET.get(target_table) or "") source_details = plan.get("source_row_details") if isinstance(plan.get("source_row_details"), dict) else {} table_details = source_details.get(source_table) if isinstance(source_details.get(source_table), dict) else {} rows: list[dict[str, Any]] = [] for row in table_details.get("rows") or []: if not isinstance(row, dict): continue file_name = str(row.get("FileName") or "") part_no = row.get("PartNo") sha1 = str(row.get("BinarySHA1") or "").upper() if file_name and isinstance(part_no, int) and sha1: rows.append({ "FileName": file_name, "PartNo": part_no, "BinarySHA1": sha1, }) return rows, { "base_id": plan.get("base_id"), "target_table": target_table, "source_table": source_table, } def validate_plan(plan: dict[str, Any], *, expected_base_id: str | None, expected_target_table: str | None) -> tuple[list[str], dict[str, Any], list[dict[str, Any]]]: failures: list[str] = [] target = plan.get("target") if isinstance(plan.get("target"), dict) else {} source_family = plan.get("source_family") if isinstance(plan.get("source_family"), dict) else {} target_table = str(target.get("table") or "") expected_source_table = SOURCE_BY_TARGET.get(target_table) rows, metadata = expected_rows_from_plan(plan) if plan.get("schema") != "onec_saved_state_copy_plan.v1": failures.append("schema must be onec_saved_state_copy_plan.v1") if plan.get("status") != "plan_ready" or plan.get("ready_to_copy") is not True: failures.append("copy plan must be plan_ready and ready_to_copy=true") if expected_base_id is not None and plan.get("base_id") != expected_base_id: failures.append(f"base_id must be {expected_base_id}") if target_table not in SOURCE_BY_TARGET: failures.append("target.table must be ConfigSave or ConfigCASSave") if expected_target_table is not None and target_table != expected_target_table: failures.append(f"target.table must be {expected_target_table}") if source_family.get("valid") is not True: failures.append("source_family.valid must be true") if expected_source_table and source_family.get("expected_source_table") != expected_source_table: failures.append(f"source_family.expected_source_table must be {expected_source_table}") if not rows: failures.append("copy plan source_row_details must include FileName, PartNo, and BinarySHA1 rows") return failures, metadata, rows def build_sql(metadata: dict[str, Any], rows: list[dict[str, Any]]) -> str: target_table = str(metadata["target_table"]) expected_rows = len(rows) values = ",\n ".join( f"({sql_literal(str(row['FileName']))}, {int(row['PartNo'])}, '{str(row['BinarySHA1']).upper()}')" for row in rows ) return "\n".join([ "-- Generated by scripts/prepare_1c_saved_state_cleanup_sql.py.", "-- Review before execution. This script removes only the reviewed saved-state working copy rows.", "SET XACT_ABORT ON;", "BEGIN TRANSACTION;", "", "DECLARE @planned TABLE (", " [FileName] nvarchar(260) NOT NULL,", " [PartNo] int NOT NULL,", " [BinarySHA1] varchar(40) NOT NULL,", " PRIMARY KEY ([FileName], [PartNo])", ");", "INSERT INTO @planned ([FileName], [PartNo], [BinarySHA1]) VALUES", f" {values};", "", f"IF (SELECT COUNT(1) FROM dbo.{bracket_name(target_table)} AS t INNER JOIN @planned AS p ON p.[FileName] = t.[FileName] AND p.[PartNo] = t.[PartNo]) <> {expected_rows}", "BEGIN", " ROLLBACK TRANSACTION;", " THROW 51101, 'Target save layer does not contain exactly the reviewed rows.', 1;", "END;", "", "IF EXISTS (", f" SELECT 1 FROM dbo.{bracket_name(target_table)} AS t", " INNER JOIN @planned AS p ON p.[FileName] = t.[FileName] AND p.[PartNo] = t.[PartNo]", " WHERE CONVERT(varchar(40), HASHBYTES('SHA1', t.[BinaryData]), 2) <> p.[BinarySHA1]", ")", "BEGIN", f" SELECT t.[FileName], t.[PartNo], CONVERT(varchar(40), HASHBYTES('SHA1', t.[BinaryData]), 2) AS ActualSHA1, p.[BinarySHA1] AS ExpectedSHA1 FROM dbo.{bracket_name(target_table)} AS t", " INNER JOIN @planned AS p ON p.[FileName] = t.[FileName] AND p.[PartNo] = t.[PartNo]", " WHERE CONVERT(varchar(40), HASHBYTES('SHA1', t.[BinaryData]), 2) <> p.[BinarySHA1]", " ORDER BY t.[FileName], t.[PartNo];", " ROLLBACK TRANSACTION;", " THROW 51102, 'Target save-layer rows differ from the reviewed copy plan; cleanup is blocked.', 1;", "END;", "", f"DELETE t FROM dbo.{bracket_name(target_table)} AS t", "INNER JOIN @planned AS p ON p.[FileName] = t.[FileName] AND p.[PartNo] = t.[PartNo];", "", f"IF @@ROWCOUNT <> {expected_rows}", "BEGIN", " ROLLBACK TRANSACTION;", " THROW 51103, 'Deleted row count did not match the reviewed cleanup plan.', 1;", "END;", "", "COMMIT TRANSACTION;", "", ]) def main() -> int: parser = argparse.ArgumentParser(description="Generate a guarded SQL cleanup script for a planned 1C saved-state copy. Does not execute SQL.") parser.add_argument("--plan", type=Path, default=Path("reports/1c-sql/upo_test/saved-state-copy-plan.json")) parser.add_argument("--sql-out", type=Path, required=True) parser.add_argument("--report", type=Path) parser.add_argument("--expected-base-id") parser.add_argument("--expected-target-table", choices=sorted(SOURCE_BY_TARGET)) parser.add_argument("--json", action="store_true") args = parser.parse_args() report: dict[str, Any] = { "schema": "onec_saved_state_cleanup_sql_plan.v1", "plan_path": str(args.plan), "sql_path": str(args.sql_out), "read_only": True, "sql_write_performed": False, "status": "error", "failures": [], } try: plan = read_json(args.plan) failures, metadata, rows = validate_plan(plan, expected_base_id=args.expected_base_id, expected_target_table=args.expected_target_table) report.update({ "base_id": metadata.get("base_id"), "target_table": metadata.get("target_table"), "source_table": metadata.get("source_table"), "expected_delete_rows": len(rows), "failures": failures, }) if failures: report["status"] = "blocked" else: sql_text = build_sql(metadata, rows) args.sql_out.parent.mkdir(parents=True, exist_ok=True) args.sql_out.write_text(sql_text, encoding="utf-8") report["status"] = "ready" report["sql_sha1_hint"] = "review_file_contents" except Exception as exc: report["failures"] = [str(exc)] if args.report: args.report.parent.mkdir(parents=True, exist_ok=True) args.report.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") if args.json or report["status"] != "ready": print(json.dumps(report, ensure_ascii=False, indent=2)) else: print(f"OK: saved-state cleanup SQL script written to {args.sql_out}") return 0 if report["status"] == "ready" else 1 if __name__ == "__main__": raise SystemExit(main())