Initial SQL-only 1C adapter baseline
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user