634 lines
30 KiB
Python
634 lines
30 KiB
Python
#!/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())
|