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