#!/usr/bin/env python3 """Compare decoded SQL form semantics with exported 1C Form.xml semantics.""" from __future__ import annotations import argparse import json import sys from collections import Counter from pathlib import Path from typing import Any ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT / "plugins" / "1c")) from parser.form_payload import enrich_form_common_semantic, form_common_semantic # noqa: E402 from parser.form_xml import decode_form_xml # noqa: E402 PROPERTY_NAME_ALIASES = { "Action": "Действие", "MainAttribute": "ОсновнойРеквизит", "AutoEditMode": "АвтоРежимРедактирования", "HeightInTableRows": "ВысотаВСтрокахТаблицы", "RowSelectionMode": "РежимВыделенияСтроки", "HorizontalLinesBWA": "ГоризонтальныеЛинии", "VerticalLinesBWA": "ВертикальныеЛинии", "UseAlternationRowColorBWA": "ЧередованиеЦветовСтрок", "AutoInsertNewRow": "АвтоВставкаНовойСтроки", "EnableStartDrag": "РазрешитьНачалоПеретаскивания", "EnableDrag": "РазрешитьПеретаскивание", "FileDragMode": "РежимПеретаскиванияФайлов", "CommandBarLocation": "ПоложениеКоманднойПанели", "DefaultItem": "АктивизироватьПоУмолчанию", "Autofill": "Автозаполнение", "AutoMaxWidth": "АвтоМаксимальнаяШирина", "MaxWidth": "МаксимальнаяШирина", "MultiLine": "МногострочныйРежим", "AutoCommandBar": "АвтоКоманднаяПанель", "SearchStringAddition": "ДополнениеСтрокиПоиска", "ViewStatusAddition": "ДополнениеСостоянияПросмотра", "SearchControlAddition": "ДополнениеУправленияПоиском", "Width": "Ширина", "Height": "Высота", "HorizontalStretch": "РастягиватьПоГоризонтали", "VerticalStretch": "РастягиватьПоВертикали", "TextColor": "ЦветТекста", "BackColor": "ЦветФона", "HorizontalAlign": "ГоризонтальноеПоложениеВГруппе", "AutoMaxHeight": "АвтоМаксимальнаяВысота", "MaxHeight": "МаксимальнаяВысота", "AutoMarkIncomplete": "АвтоОтметкаНезаполненного", "ToolTipRepresentation": "ОтображениеПодсказки", "SpinButton": "КнопкаРегулирования", "Representation": "Отображение", "DefaultButton": "КнопкаПоУмолчанию", "OpenButton": "КнопкаОткрытия", "CreateButton": "КнопкаСоздания", "ChoiceHistoryOnInput": "ИсторияВыбораПриВводе", "BorderColor": "ЦветРамки", "ChangeRowSet": "ИзменятьСоставСтрок", "ShowInHeader": "ОтображатьВШапке", "AutoCellHeight": "АвтоВысотаЯчейки", "SearchStringLocation": "ПоложениеСтрокиПоиска", "ViewStatusLocation": "ПоложениеСостоянияПросмотра", "SearchControlLocation": "ПоложениеУправленияПоиском", "GroupHorizontalAlign": "ГоризонтальноеПоложениеВГруппе", "GroupVerticalAlign": "ВертикальноеПоложениеВГруппе", "ShapeRepresentation": "ОтображениеФигуры", } def read_json(path: Path) -> dict[str, Any]: data = json.loads(path.read_text(encoding="utf-8-sig")) if not isinstance(data, dict): raise ValueError(f"{path} must contain a JSON object") return data def write_json(path: Path, data: dict[str, Any]) -> None: path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") def scalar_text(value: Any) -> str: if value is True: return "true" if value is False: return "false" if value is None: return "" return str(value).strip() def comparable(value: Any) -> str: text = scalar_text(value) lowered = text.casefold() if lowered in {"истина", "true", "1"}: return "true" if lowered in {"ложь", "false", "0"}: return "false" if lowered in {"таблица формы", "таблица", "динамический список"}: return "table" if lowered in {"кнопка", "кнопка командной панели", "commandbarbutton"}: return "button" if lowered in {"декорация надписи", "labeldecoration"}: return "label_decoration" if lowered in {"декорация картинки", "picturedecoration"}: return "picture_decoration" if lowered in {"поле переключателя", "radiobuttonfield"}: return "radio_button_field" if lowered in {"в дополнительном подменю", "inadditionalsubmenu"}: return "in_additional_submenu" if lowered in {"в командной панели", "incommandbar"}: return "in_command_bar" if lowered in {"поле", "checkboxfield", "поле флажка"}: return "field" if lowered in {"колонка динамического списка", "column", "колонка реквизита"}: return "column" if lowered in {"attribute", "реквизит формы"}: return "attribute" if lowered in {"event", "событие"}: return "event" if lowered in {"command", "команда формы"}: return "command" if lowered in {"searchstringaddition", "дополнение строки поиска"}: return "search_string_addition" if lowered in {"viewstatusaddition", "дополнение состояния просмотра"}: return "view_status_addition" if lowered in {"searchcontroladdition", "дополнение управления поиском"}: return "search_control_addition" return lowered def canonical_property_name(value: Any) -> str: text = str(value or "").strip() return PROPERTY_NAME_ALIASES.get(text, text) def semantic_properties(row: dict[str, Any]) -> dict[str, dict[str, Any]]: result: dict[str, dict[str, Any]] = {} for direct_name, key in ( ("Имя", "name"), ("Идентификатор", "id"), ("Заголовок", "title"), ("ПутьКДанным", "path_to_data"), ("Вид", "type_name"), ("Обработчик", "handler"), ): if key in row and row.get(key) not in {None, ""}: result[direct_name] = {"name": direct_name, "value": row.get(key), "source": "decoded_direct"} semantic = row.get("semantic") if isinstance(row.get("semantic"), dict) else {} for group, props in (semantic.get("groups") or {}).items(): for prop in props or []: if not isinstance(prop, dict): continue name = canonical_property_name(prop.get("name")) if name and name not in result: result[name] = {**prop, "group": group} return result def iter_sql_rows(form: dict[str, Any]) -> list[dict[str, Any]]: profile = form.get("profile") if isinstance(form.get("profile"), dict) else {} rows = [] for section in ("items", "attributes", "parameters", "commands", "tables", "command_bars", "events"): for row in profile.get(section) or []: if isinstance(row, dict): rows.append({**row, "_profile_section": section}) for item in profile.get("items") or []: if not isinstance(item, dict): continue for event in item.get("events") or []: if not isinstance(event, dict): continue event_name = event.get("event_name") or event.get("name") rows.append( { **event, "name": event_name, "type_name": "Event", "_profile_section": "events", "_event_owner": item.get("name"), } ) return rows def row_key(row: dict[str, Any]) -> tuple[str, str]: if row.get("id") not in {None, ""}: return ("id", str(row.get("id"))) return ("name", str(row.get("name") or "")) def xml_match_keys(xml_item: dict[str, Any]) -> list[tuple[str, str]]: name = str(xml_item.get("name") or "") item_id = str(xml_item.get("id") or "") kind = str(xml_item.get("kind") or "") keys: list[tuple[str, str]] = [] if kind in {"ExtendedTooltip", "Event", "Column"}: if name: keys.append(("name", name.casefold())) return keys if kind == "Button": if name: keys.append(("name", name.casefold())) if item_id: keys.append(("id", item_id)) return keys if item_id: keys.append(("id", item_id)) if name: keys.append(("name", name.casefold())) return keys def index_rows(rows: list[dict[str, Any]], *, sections: set[str] | None = None) -> dict[tuple[str, str], dict[str, Any]]: by_key: dict[tuple[str, str], dict[str, Any]] = {} by_name: dict[str, dict[str, Any]] = {} for row in rows: if sections is not None and str(row.get("_profile_section") or "") not in sections: continue key = row_key(row) if key[1]: by_key.setdefault(key, row) name = str(row.get("name") or "") if name: by_name.setdefault(name.casefold(), row) for name, row in by_name.items(): by_key.setdefault(("name", name), row) return by_key def xml_sql_sections(xml_item: dict[str, Any]) -> set[str]: kind = str(xml_item.get("kind") or "") if kind == "Attribute": return {"attributes"} if kind == "Parameter": return {"parameters"} if kind == "Column": return {"items", "tables"} if kind == "Command": return {"commands"} if kind == "Button": return {"items", "commands"} if kind in {"CommandBar", "AutoCommandBar"}: return {"items", "command_bars"} if kind == "Event": return {"events"} return {"items", "commands", "command_bars"} def xml_profile_from_context_form(form: dict[str, Any], *, max_items: int) -> dict[str, Any] | None: path_text = str(form.get("form_xml_path") or "") if not path_text: structure = form.get("structure") if isinstance(form.get("structure"), dict) else {} path_text = str(structure.get("form_xml_path") or "") if not path_text: return None path = Path(path_text) if not path.is_file(): return {"status": "missing_xml_file", "source": {"path": path_text}, "items": []} profile = decode_form_xml(path, max_items=max_items) profile["form"]["name"] = form.get("name") profile["form"]["guid"] = form.get("uuid") return profile def matching_sql_form(xml_form: dict[str, Any], sql_forms: list[dict[str, Any]]) -> dict[str, Any] | None: guid = str((xml_form.get("form") or {}).get("guid") or "").casefold() name = str((xml_form.get("form") or {}).get("name") or "").casefold() for form in sql_forms: if guid and str(form.get("guid") or "").casefold() == guid: return form for form in sql_forms: if name and str(form.get("name") or "").casefold() == name: return form return None def compare_form(sql_form: dict[str, Any] | None, xml_profile: dict[str, Any], *, sample_limit: int) -> dict[str, Any]: xml_items = [item for item in xml_profile.get("items") or [] if isinstance(item, dict)] sql_rows = iter_sql_rows(sql_form or {}) sql_any_index = index_rows(sql_rows) sql_indexes_by_sections: dict[tuple[str, ...], dict[tuple[str, str], dict[str, Any]]] = {} counts = Counter() property_route_counts: Counter[tuple[str, str, str, str, str, str]] = Counter() samples: dict[str, list[dict[str, Any]]] = { "matched": [], "missing_sql_item": [], "xml_only_property": [], "mismatch": [], "command_name_match": [], "command_name_mismatch": [], "matched_form_property": [], "xml_only_form_property": [], "form_mismatch": [], } sql_profile = (sql_form or {}).get("profile") if isinstance((sql_form or {}).get("profile"), dict) else {} sql_form_semantic = sql_profile.get("form_semantic") if isinstance(sql_profile.get("form_semantic"), dict) else None if sql_form_semantic is None: sql_form_semantic = form_common_semantic( [item for item in sql_profile.get("form_parameters") or [] if isinstance(item, dict)], include_diagnostics=True, ) enrich_form_common_semantic( sql_form_semantic, [item for item in sql_profile.get("items") or [] if isinstance(item, dict)], ) xml_form = xml_profile.get("form") if isinstance(xml_profile.get("form"), dict) else {} xml_form_semantic = xml_form.get("semantic") if isinstance(xml_form.get("semantic"), dict) else {} sql_root_props = semantic_properties({"type_name": "Форма", "semantic": sql_form_semantic}) xml_root_props = semantic_properties({"type_name": "Форма", "semantic": xml_form_semantic}) form_property_routes = [] for name, xml_prop in xml_root_props.items(): sql_prop = sql_root_props.get(name) if sql_prop is None: counts["xml_only_form_properties"] += 1 if len(samples["xml_only_form_property"]) < sample_limit: samples["xml_only_form_property"].append( {"property": name, "xml_name": xml_prop.get("xml_name"), "xml_value": xml_prop.get("value")} ) continue if comparable(sql_prop.get("value")) != comparable(xml_prop.get("value")): counts["form_mismatches"] += 1 if len(samples["form_mismatch"]) < sample_limit: samples["form_mismatch"].append( { "property": name, "xml_name": xml_prop.get("xml_name"), "sql_value": sql_prop.get("value"), "xml_value": xml_prop.get("value"), "sql_source": sql_prop.get("source"), } ) continue counts["matched_form_properties"] += 1 if len(samples["matched_form_property"]) < sample_limit: samples["matched_form_property"].append( {"property": name, "xml_name": xml_prop.get("xml_name"), "value": xml_prop.get("value")} ) parameter_indices = sql_prop.get("parameter_indices") if isinstance(parameter_indices, list) and parameter_indices: form_property_routes.append( { "xml_name": xml_prop.get("xml_name") or name, "property": name, "parameter_indices": parameter_indices, "sql_source": sql_prop.get("source"), "write_shape": sql_prop.get("write_shape"), } ) for xml_item in xml_items: wanted_sections = xml_sql_sections(xml_item) section_key = tuple(sorted(wanted_sections)) sql_item_index = sql_indexes_by_sections.setdefault(section_key, index_rows(sql_rows, sections=wanted_sections)) keys = xml_match_keys(xml_item) sql_row = None if xml_item.get("kind") == "Column" and xml_item.get("additional_columns_table"): logical_path = f"{xml_item.get('additional_columns_table')}.{xml_item.get('name')}" sql_row = next((row for row in sql_rows if comparable(row.get("path_to_data")) == comparable(logical_path)), None) if sql_row is not None: counts["logical_column_sql_match"] += 1 if len(samples.setdefault("logical_column_sql_match", [])) < sample_limit: samples["logical_column_sql_match"].append( { "name": xml_item.get("name"), "id": xml_item.get("id"), "logical_path": logical_path, "sql_element": sql_row.get("name"), "sql_path": sql_row.get("path"), } ) if xml_item.get("kind") == "Event" and xml_item.get("owner"): sql_row = next( ( row for row in sql_rows if str(row.get("_profile_section") or "") == "events" and str(row.get("_event_owner") or "").casefold() == str(xml_item.get("owner") or "").casefold() and str(row.get("name") or "").casefold() == str(xml_item.get("name") or "").casefold() ), None, ) if sql_row is None and not (xml_item.get("kind") == "Event" and xml_item.get("owner")): sql_row = next((sql_item_index[key] for key in keys if key in sql_item_index), None) if sql_row is not None and xml_item.get("kind") == "Column" and xml_item.get("additional_columns_table"): counts["matched_items"] += 1 if len(samples["matched"]) < sample_limit: samples["matched"].append( { "name": xml_item.get("name"), "id": xml_item.get("id"), "sql_path": sql_row.get("path"), "sql_type": sql_row.get("type_name"), "xml_kind": xml_item.get("kind_ru"), "match_by": "additional_columns_data_path", } ) continue if sql_row is None: fallback = next((sql_any_index[key] for key in keys if key in sql_any_index), None) if fallback is not None: counts["non_item_sql_match"] += 1 if len(samples.setdefault("non_item_sql_match", [])) < sample_limit: samples["non_item_sql_match"].append({"name": xml_item.get("name"), "id": xml_item.get("id"), "xml_kind": xml_item.get("kind"), "sql_section": fallback.get("_profile_section"), "sql_type": fallback.get("type_name")}) if sql_row is None: counts["missing_sql_item"] += 1 if len(samples["missing_sql_item"]) < sample_limit: samples["missing_sql_item"].append({"name": xml_item.get("name"), "id": xml_item.get("id"), "kind": xml_item.get("kind")}) continue counts["matched_items"] += 1 if len(samples["matched"]) < sample_limit: samples["matched"].append({"name": xml_item.get("name"), "id": xml_item.get("id"), "sql_path": sql_row.get("path"), "sql_type": sql_row.get("type_name"), "xml_kind": xml_item.get("kind_ru")}) sql_props = semantic_properties(sql_row) xml_props = semantic_properties(xml_item) sql_command = sql_props.get("ИмяКоманды") xml_command = xml_props.get("ИмяКоманды") if sql_command is not None and xml_command is not None: sql_command_value = sql_command.get("value") xml_command_value = xml_command.get("value") command_row = { "item": xml_item.get("name"), "id": xml_item.get("id"), "sql_value": sql_command_value, "xml_value": xml_command_value, "sql_path": sql_row.get("path"), "sql_source": sql_command.get("source"), } if comparable(sql_command_value) == comparable(xml_command_value): counts["command_name_matches"] += 1 if len(samples["command_name_match"]) < sample_limit: samples["command_name_match"].append(command_row) else: counts["command_name_mismatches"] += 1 if len(samples["command_name_mismatch"]) < sample_limit: samples["command_name_mismatch"].append(command_row) for name, xml_prop in xml_props.items(): if name == "Идентификатор" and xml_item.get("kind") == "Button" and sql_row.get("_profile_section") == "commands": continue xml_value = xml_prop.get("value") sql_prop = sql_props.get(name) if sql_prop is None: counts["xml_only_properties"] += 1 if len(samples["xml_only_property"]) < sample_limit: samples["xml_only_property"].append({"item": xml_item.get("name"), "id": xml_item.get("id"), "property": name, "xml_value": xml_value, "xml_name": xml_prop.get("xml_name")}) continue sql_value = sql_prop.get("value") if comparable(sql_value) != comparable(xml_value): counts["mismatches"] += 1 if len(samples["mismatch"]) < sample_limit: samples["mismatch"].append({"item": xml_item.get("name"), "id": xml_item.get("id"), "property": name, "sql_value": sql_value, "xml_value": xml_value, "sql_source": sql_prop.get("source"), "xml_name": xml_prop.get("xml_name")}) else: counts["matched_properties"] += 1 parameter_index = sql_prop.get("parameter_index") if parameter_index is not None: property_route_counts[ ( str(xml_item.get("kind") or ""), str(xml_prop.get("xml_name") or name), name, str(sql_row.get("marker") or ""), str(parameter_index), str(sql_prop.get("source") or ""), ) ] += 1 property_routes = [ { "xml_kind": key[0], "xml_name": key[1], "property": key[2], "sql_marker": key[3] or None, "parameter_index": int(key[4]) if key[4].lstrip("-").isdigit() else key[4], "sql_source": key[5] or None, "matches": match_count, } for key, match_count in sorted(property_route_counts.items(), key=lambda item: (-item[1], item[0])) ] return { "sql_form": {key: (sql_form or {}).get(key) for key in ("name", "guid", "source") if (sql_form or {}).get(key) is not None}, "xml_form": xml_profile.get("form"), "xml_source": xml_profile.get("source"), "counts": dict(counts), "form_property_routes": form_property_routes, "property_routes": property_routes, "samples": samples, } def build_report(sql_details: dict[str, Any], xml_context: dict[str, Any], *, sample_limit: int, max_items: int) -> dict[str, Any]: sql_forms = [form for form in sql_details.get("forms") or [] if isinstance(form, dict)] comparisons = [] for form in xml_context.get("forms") or []: if not isinstance(form, dict): continue profiles = [] seen_xml_paths: set[str] = set() xml_profile = xml_profile_from_context_form(form, max_items=max_items) if xml_profile is not None: source_path = str((xml_profile.get("source") or {}).get("path") or "").casefold() if source_path: seen_xml_paths.add(source_path) profiles.append(xml_profile) for overlay in form.get("extension_overlays") or []: if isinstance(overlay, dict): overlay_profile = xml_profile_from_context_form(overlay, max_items=max_items) if overlay_profile is not None: source_path = str((overlay_profile.get("source") or {}).get("path") or "").casefold() if source_path and source_path in seen_xml_paths: continue if source_path: seen_xml_paths.add(source_path) profiles.append(overlay_profile) for profile in profiles: comparisons.append(compare_form(matching_sql_form(profile, sql_forms), profile, sample_limit=sample_limit)) totals = Counter() route_counts: Counter[tuple[str, str, str, str, str, str]] = Counter() for comparison in comparisons: totals.update(comparison.get("counts") or {}) for route in comparison.get("property_routes") or []: route_counts[ ( str(route.get("xml_kind") or ""), str(route.get("xml_name") or ""), str(route.get("property") or ""), str(route.get("sql_marker") or ""), str(route.get("parameter_index") if route.get("parameter_index") is not None else ""), str(route.get("sql_source") or ""), ) ] += int(route.get("matches") or 0) grouped_routes: dict[tuple[str, str, str], list[tuple[tuple[str, str, str, str, str, str], int]]] = {} for key, match_count in route_counts.items(): grouped_routes.setdefault(key[:3], []).append((key, match_count)) stable_property_routes = [] ambiguous_property_routes = [] for identity, variants in sorted(grouped_routes.items()): rows = [ { "xml_kind": key[0], "xml_name": key[1], "property": key[2], "sql_marker": key[3] or None, "parameter_index": int(key[4]) if key[4].lstrip("-").isdigit() else key[4], "sql_source": key[5] or None, "matches": match_count, } for key, match_count in sorted(variants, key=lambda item: (-item[1], item[0])) ] if len(rows) == 1 and rows[0]["matches"] >= 2: stable_property_routes.append(rows[0]) elif len(rows) > 1: ambiguous_property_routes.append( {"xml_kind": identity[0], "xml_name": identity[1], "property": identity[2], "routes": rows} ) totals["property_routes"] = len(route_counts) totals["stable_property_routes"] = len(stable_property_routes) totals["ambiguous_property_routes"] = len(ambiguous_property_routes) return { "schema": "onec_form_sql_xml_comparison.v1", "status": "ok", "object": sql_details.get("object") or xml_context.get("object"), "counts": {"forms_compared": len(comparisons), **dict(totals)}, "stable_property_routes": stable_property_routes, "ambiguous_property_routes": ambiguous_property_routes, "comparisons": comparisons, } def markdown_table_row(values: list[Any]) -> str: return "| " + " | ".join(str(value).replace("\n", " ") for value in values) + " |" def render_markdown(report: dict[str, Any]) -> str: lines = ["# 1C Form SQL/XML Comparison", ""] obj = report.get("object") or {} lines.append(f"- Object: `{obj.get('kind')}.{obj.get('name')}`") counts = report.get("counts") or {} lines.append(f"- Forms compared: `{counts.get('forms_compared')}`") lines.append(f"- Matched items: `{counts.get('matched_items', 0)}`") lines.append(f"- Matched properties: `{counts.get('matched_properties', 0)}`") lines.append(f"- XML-only properties: `{counts.get('xml_only_properties', 0)}`") lines.append(f"- Mismatches: `{counts.get('mismatches', 0)}`") lines.append(f"- CommandName matches: `{counts.get('command_name_matches', 0)}`") lines.append(f"- CommandName mismatches: `{counts.get('command_name_mismatches', 0)}`") lines.append("") for comparison in report.get("comparisons") or []: xml_form = comparison.get("xml_form") or {} sql_form = comparison.get("sql_form") or {} lines.append(f"## {xml_form.get('name') or sql_form.get('name')}") lines.append("") lines.append(markdown_table_row(["Metric", "Count"])) lines.append(markdown_table_row(["---", "---:"])) for key, value in sorted((comparison.get("counts") or {}).items()): lines.append(markdown_table_row([key, value])) for title, key in (("CommandName Matches", "command_name_match"), ("CommandName Mismatches", "command_name_mismatch"), ("XML-Only Properties", "xml_only_property"), ("Mismatches", "mismatch"), ("Missing SQL Items", "missing_sql_item")): sample = (comparison.get("samples") or {}).get(key) or [] if not sample: continue lines.append("") lines.append(f"### {title}") lines.append("") lines.append("```json") lines.append(json.dumps(sample[:10], ensure_ascii=False, indent=2)) lines.append("```") lines.append("") return "\n".join(lines).rstrip() + "\n" def main() -> int: parser = argparse.ArgumentParser(description="Compare decoded SQL form semantics with Form.xml semantics.") parser.add_argument("--sql-details", type=Path, required=True) parser.add_argument("--xml-context", type=Path, required=True) parser.add_argument("--output-json", type=Path, required=True) parser.add_argument("--output-markdown", type=Path) parser.add_argument("--sample-limit", type=int, default=20) parser.add_argument("--max-items", type=int, default=5000) args = parser.parse_args() report = build_report(read_json(args.sql_details), read_json(args.xml_context), sample_limit=args.sample_limit, max_items=args.max_items) write_json(args.output_json, report) if args.output_markdown: args.output_markdown.parent.mkdir(parents=True, exist_ok=True) args.output_markdown.write_text(render_markdown(report), encoding="utf-8") print(json.dumps({"schema": "onec_form_sql_xml_comparison_cli_summary.v1", "status": report["status"], "counts": report["counts"], "output_json": str(args.output_json), "output_markdown": str(args.output_markdown) if args.output_markdown else None}, ensure_ascii=False, indent=2)) return 0 if __name__ == "__main__": raise SystemExit(main())