Initial SQL-only 1C adapter baseline
This commit is contained in:
@@ -0,0 +1,247 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
DEFAULT_BASE_URL = "http://docker-gpu.cin.su:8011"
|
||||
SOURCE_BY_TARGET = {"ConfigSave": "Config", "ConfigCASSave": "ConfigCAS"}
|
||||
|
||||
|
||||
def rpc(base_url: str, method: str, payload: dict[str, Any], timeout: float) -> dict[str, Any]:
|
||||
body = json.dumps({"method": method, "payload": payload}, ensure_ascii=False).encode("utf-8")
|
||||
request = urllib.request.Request(
|
||||
base_url.rstrip("/") + "/rpc",
|
||||
data=body,
|
||||
method="POST",
|
||||
headers={"Content-Type": "application/json; charset=utf-8"},
|
||||
)
|
||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||
data = json.loads(response.read().decode("utf-8"))
|
||||
if not isinstance(data, dict):
|
||||
raise RuntimeError(f"{method} returned non-object response: {data!r}")
|
||||
return data
|
||||
|
||||
|
||||
def sql_literal(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 {}
|
||||
expected: 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")
|
||||
if file_name and isinstance(part_no, int):
|
||||
expected.append({
|
||||
"FileName": file_name,
|
||||
"PartNo": part_no,
|
||||
"DataSize": row.get("DataSize"),
|
||||
"BinaryBytes": row.get("BinaryBytes"),
|
||||
"BinarySHA1": str(row.get("BinarySHA1") or "").upper(),
|
||||
})
|
||||
metadata = {
|
||||
"base_id": plan.get("base_id"),
|
||||
"target_table": target_table,
|
||||
"source_table": source_table,
|
||||
"source_family_valid": source_family.get("valid"),
|
||||
}
|
||||
return expected, metadata
|
||||
|
||||
|
||||
def validate_plan(plan: dict[str, Any], *, expected_base_id: str | None, expected_target_table: str | None) -> list[str]:
|
||||
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)
|
||||
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 expected_target_table is not None and target_table != expected_target_table:
|
||||
failures.append(f"target.table must be {expected_target_table}")
|
||||
if target_table not in SOURCE_BY_TARGET:
|
||||
failures.append("target.table must be ConfigSave or ConfigCASSave")
|
||||
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}")
|
||||
target_collisions = plan.get("target_collisions") if isinstance(plan.get("target_collisions"), dict) else {}
|
||||
if target_collisions.get("status") != "clear":
|
||||
failures.append("copy plan must have been generated before preparation with target_collisions.status=clear")
|
||||
return failures
|
||||
|
||||
|
||||
def fetch_target_rows(base_url: str, base_id: str, target_table: str, file_names: list[str], timeout: float) -> dict[str, Any]:
|
||||
query = (
|
||||
"SELECT FileName, PartNo, DataSize, DATALENGTH(BinaryData) AS BinaryBytes, "
|
||||
"CONVERT(varchar(40), HASHBYTES('SHA1', BinaryData), 2) AS BinarySHA1 "
|
||||
f"FROM {target_table} WHERE FileName IN ({', '.join(sql_literal(name) for name in file_names)}) "
|
||||
"ORDER BY FileName, PartNo"
|
||||
)
|
||||
return rpc(
|
||||
base_url,
|
||||
"query.run",
|
||||
{"base_id": base_id, "diagnostic": True, "query": query, "timeout_seconds": int(timeout)},
|
||||
timeout,
|
||||
)
|
||||
|
||||
|
||||
def row_key(row: dict[str, Any]) -> tuple[str, int]:
|
||||
return str(row.get("FileName") or ""), int(row.get("PartNo"))
|
||||
|
||||
|
||||
def compare_rows(expected: list[dict[str, Any]], actual: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], list[dict[str, Any]], list[dict[str, Any]]]:
|
||||
expected_by_key = {row_key(row): row for row in expected}
|
||||
actual_by_key = {row_key(row): row for row in actual if row.get("FileName") and isinstance(row.get("PartNo"), int)}
|
||||
missing = [row for key, row in sorted(expected_by_key.items()) if key not in actual_by_key]
|
||||
unexpected = [row for key, row in sorted(actual_by_key.items()) if key not in expected_by_key]
|
||||
mismatched: list[dict[str, Any]] = []
|
||||
for key, expected_row in sorted(expected_by_key.items()):
|
||||
actual_row = actual_by_key.get(key)
|
||||
if actual_row is None:
|
||||
continue
|
||||
diffs = {}
|
||||
for field in ("DataSize", "BinaryBytes", "BinarySHA1"):
|
||||
expected_value = expected_row.get(field)
|
||||
actual_value = actual_row.get(field)
|
||||
if field == "BinarySHA1":
|
||||
actual_value = str(actual_value or "").upper()
|
||||
if actual_value != expected_value:
|
||||
diffs[field] = {"expected": expected_value, "actual": actual_value}
|
||||
if diffs:
|
||||
mismatched.append({"FileName": key[0], "PartNo": key[1], "diffs": diffs})
|
||||
return missing, unexpected, mismatched
|
||||
|
||||
|
||||
def build_report(args: argparse.Namespace) -> dict[str, Any]:
|
||||
plan = read_json(args.plan)
|
||||
plan_failures = validate_plan(plan, expected_base_id=args.expected_base_id, expected_target_table=args.expected_target_table)
|
||||
expected, metadata = expected_rows_from_plan(plan)
|
||||
report: dict[str, Any] = {
|
||||
"schema": "onec_saved_state_copy_verify.v1",
|
||||
"base_url": args.base_url,
|
||||
"base_id": metadata.get("base_id"),
|
||||
"target_table": metadata.get("target_table"),
|
||||
"source_table": metadata.get("source_table"),
|
||||
"plan_path": str(args.plan),
|
||||
"read_only": True,
|
||||
"sql_write_performed": False,
|
||||
"ready": False,
|
||||
"status": "error",
|
||||
"failures": [],
|
||||
}
|
||||
if plan_failures:
|
||||
report["status"] = "blocked_invalid_plan"
|
||||
report["failures"] = plan_failures
|
||||
return report
|
||||
if not expected:
|
||||
report["status"] = "blocked_no_expected_rows"
|
||||
report["failures"] = ["copy plan source_row_details did not contain expected rows"]
|
||||
return report
|
||||
actual_result = fetch_target_rows(
|
||||
args.base_url,
|
||||
str(metadata["base_id"]),
|
||||
str(metadata["target_table"]),
|
||||
sorted({str(row["FileName"]) for row in expected}),
|
||||
args.timeout,
|
||||
)
|
||||
actual = [row for row in actual_result.get("rows") or [] if isinstance(row, dict)]
|
||||
missing, unexpected, mismatched = compare_rows(expected, actual)
|
||||
report["checks"] = {
|
||||
"target_rows": {
|
||||
"status": actual_result.get("status"),
|
||||
"validation": actual_result.get("validation"),
|
||||
"counts": actual_result.get("counts"),
|
||||
},
|
||||
"comparison": {
|
||||
"expected_rows": len(expected),
|
||||
"actual_rows": len(actual),
|
||||
"missing_rows": len(missing),
|
||||
"unexpected_rows": len(unexpected),
|
||||
"mismatched_rows": len(mismatched),
|
||||
},
|
||||
}
|
||||
report["details"] = {
|
||||
"missing": missing,
|
||||
"unexpected": unexpected,
|
||||
"mismatched": mismatched,
|
||||
}
|
||||
if missing and not actual:
|
||||
report["status"] = "blocked_missing_target_rows"
|
||||
report["failures"] = ["planned rows are not present in the selected save layer"]
|
||||
elif missing:
|
||||
report["status"] = "blocked_incomplete_target_rows"
|
||||
report["failures"] = ["some planned rows are missing from the selected save layer"]
|
||||
elif unexpected:
|
||||
report["status"] = "blocked_unexpected_target_rows"
|
||||
report["failures"] = ["selected save layer returned rows outside the reviewed plan"]
|
||||
elif mismatched:
|
||||
report["status"] = "blocked_target_row_mismatch"
|
||||
report["failures"] = ["one or more target rows differ from the reviewed active source rows"]
|
||||
else:
|
||||
report["status"] = "ready"
|
||||
report["ready"] = True
|
||||
return report
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Read-only verification that a planned 1C saved-state copy is present and byte-identical in the save layer.")
|
||||
parser.add_argument("--base-url", default=DEFAULT_BASE_URL)
|
||||
parser.add_argument("--plan", type=Path, default=Path("reports/1c-sql/upo_test/saved-state-copy-plan.json"))
|
||||
parser.add_argument("--expected-base-id")
|
||||
parser.add_argument("--expected-target-table", choices=sorted(SOURCE_BY_TARGET))
|
||||
parser.add_argument("--timeout", type=float, default=60.0)
|
||||
parser.add_argument("--report", type=Path)
|
||||
parser.add_argument("--require-ready", action="store_true")
|
||||
parser.add_argument("--json", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
report = build_report(args)
|
||||
except Exception as exc:
|
||||
report = {
|
||||
"schema": "onec_saved_state_copy_verify.v1",
|
||||
"base_url": args.base_url,
|
||||
"plan_path": str(args.plan),
|
||||
"read_only": True,
|
||||
"sql_write_performed": False,
|
||||
"ready": False,
|
||||
"status": "error",
|
||||
"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 args.require_ready or not report.get("ready"):
|
||||
print(json.dumps(report, ensure_ascii=False, indent=2), file=sys.stderr if args.require_ready and not report.get("ready") else sys.stdout)
|
||||
else:
|
||||
print(f"OK: saved-state copy verification passed for {report.get('base_id')} -> {report.get('target_table')}.")
|
||||
return 0 if report.get("ready") or not args.require_ready else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user