466 lines
18 KiB
Python
466 lines
18 KiB
Python
#!/usr/bin/env python3
|
|
"""Build a read-projection metadata card from resolved XML object evidence."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import sys
|
|
import xml.etree.ElementTree as ET
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
from resolve_1c_object import load_json, physical_name, resolve_object # noqa: E402
|
|
|
|
|
|
REFERENCE_MARKERS = ("Ref.",)
|
|
|
|
TYPE_PRESENTATION_RU = {
|
|
"xs:string": "Строка",
|
|
"xs:decimal": "Число",
|
|
"xs:boolean": "Булево",
|
|
"xs:dateTime": "Дата",
|
|
"v8:UUID": "УникальныйИдентификатор",
|
|
"cfg:AnyRef": "ЛюбаяСсылка",
|
|
"cfg:AnyIBRef": "ЛюбаяСсылка",
|
|
}
|
|
|
|
REFERENCE_PRESENTATION_RU = {
|
|
"CatalogRef": "СправочникСсылка",
|
|
"DocumentRef": "ДокументСсылка",
|
|
"EnumRef": "ПеречислениеСсылка",
|
|
"ChartOfAccountsRef": "ПланСчетовСсылка",
|
|
"ChartOfCalculationTypesRef": "ПланВидовРасчетаСсылка",
|
|
"ChartOfCharacteristicTypesRef": "ПланВидовХарактеристикСсылка",
|
|
"BusinessProcessRef": "БизнесПроцессСсылка",
|
|
"TaskRef": "ЗадачаСсылка",
|
|
}
|
|
|
|
|
|
def local_name(tag: str) -> str:
|
|
return tag.rsplit("}", 1)[-1] if "}" in tag else tag
|
|
|
|
|
|
def direct_child(node: ET.Element, name: str) -> ET.Element | None:
|
|
return next((child for child in list(node) if local_name(child.tag) == name), None)
|
|
|
|
|
|
def text_child(node: ET.Element | None, name: str) -> str | None:
|
|
if node is None:
|
|
return None
|
|
child = direct_child(node, name)
|
|
if child is None or child.text is None:
|
|
return None
|
|
return child.text.strip()
|
|
|
|
|
|
def synonym(properties: ET.Element | None) -> str | None:
|
|
if properties is None:
|
|
return None
|
|
syn = direct_child(properties, "Synonym")
|
|
if syn is None:
|
|
return None
|
|
for node in syn.iter():
|
|
if local_name(node.tag) == "content" and node.text:
|
|
return node.text.strip()
|
|
return None
|
|
|
|
|
|
def value_types(properties: ET.Element | None) -> list[str]:
|
|
if properties is None:
|
|
return []
|
|
type_node = direct_child(properties, "Type")
|
|
if type_node is None:
|
|
return []
|
|
result = []
|
|
for node in type_node.iter():
|
|
if local_name(node.tag) == "Type" and node.text and ":" in node.text:
|
|
result.append(node.text.strip())
|
|
elif local_name(node.tag) == "TypeSet" and node.text and ":" in node.text:
|
|
result.append(node.text.strip())
|
|
return result
|
|
|
|
|
|
def type_presentation_ru(value: str) -> str:
|
|
if value in TYPE_PRESENTATION_RU:
|
|
return TYPE_PRESENTATION_RU[value]
|
|
if value.startswith("cfg:"):
|
|
payload = value[4:]
|
|
if "." in payload:
|
|
family, name = payload.split(".", 1)
|
|
prefix = REFERENCE_PRESENTATION_RU.get(family)
|
|
if prefix:
|
|
return f"{prefix}.{name}"
|
|
return f"{family}.{name}"
|
|
return value
|
|
|
|
|
|
def route_kind(role: str) -> str:
|
|
if role == "Fld":
|
|
return "field"
|
|
if role in {"VT", "LineNo"}:
|
|
return "structural"
|
|
return "table"
|
|
|
|
|
|
def storage_routes(index: dict[str, Any], guid: str | None) -> list[dict[str, Any]]:
|
|
if not guid:
|
|
return []
|
|
item = (index.get("objects") or {}).get(guid.lower()) or {}
|
|
result = []
|
|
for route in item.get("dbnames") or []:
|
|
copy = {
|
|
"guid": guid,
|
|
"storage_role": route.get("storage_role"),
|
|
"sql_number": route.get("sql_number"),
|
|
"source": route.get("source_file") or "DBNames",
|
|
"route_kind": route_kind(str(route.get("storage_role") or "")),
|
|
"physical_name_candidate": physical_name(route),
|
|
}
|
|
result.append(copy)
|
|
return result
|
|
|
|
|
|
def value_type_payload(types: list[str]) -> dict[str, Any] | None:
|
|
if not types:
|
|
return None
|
|
return {
|
|
"types": types,
|
|
"presentation": {
|
|
"default_language": "ru",
|
|
"ru": [type_presentation_ru(item) for item in types],
|
|
},
|
|
"qualifiers": {},
|
|
"is_composite": len(types) > 1,
|
|
}
|
|
|
|
|
|
def field_columns(routes: list[dict[str, Any]], types: list[str]) -> list[dict[str, Any]]:
|
|
fld = next((route for route in routes if route.get("storage_role") == "Fld" and route.get("sql_number") is not None), None)
|
|
if not fld:
|
|
return [{"status": "no_storage_route"}]
|
|
base = f"_Fld{fld['sql_number']}"
|
|
if len(types) > 1:
|
|
return [
|
|
{"column": f"{base}_TYPE", "reason": "composite value discriminator", "value_types": types},
|
|
{"column": f"{base}_S", "reason": "composite string value", "value_types": types},
|
|
{"column": f"{base}_N", "reason": "composite numeric value", "value_types": types},
|
|
{"column": f"{base}_L", "reason": "composite boolean value", "value_types": types},
|
|
{"column": f"{base}_T", "reason": "composite datetime value", "value_types": types},
|
|
{"column": f"{base}_RTRef", "reason": "composite reference type id", "value_types": types},
|
|
{"column": f"{base}_RRRef", "reason": "composite reference value", "value_types": types},
|
|
]
|
|
value_type = types[0] if types else None
|
|
if value_type in {"cfg:AnyRef", "cfg:AnyIBRef"}:
|
|
return [
|
|
{"column": f"{base}_TYPE", "reason": "any reference discriminator", "value_types": types},
|
|
{"column": f"{base}_RTRef", "reason": "any reference type id", "value_types": types},
|
|
{"column": f"{base}_RRRef", "reason": "any reference value", "value_types": types},
|
|
]
|
|
if value_type and any(marker in value_type for marker in REFERENCE_MARKERS):
|
|
return [{"column": f"{base}RRef", "reason": "single 1C reference type", "value_type": value_type}]
|
|
return [{"column": base, "reason": "single primitive value", "value_type": value_type}]
|
|
|
|
|
|
def extension_name_from_path(path: str | None) -> str | None:
|
|
parts = str(path or "").replace("/", "\\").split("\\")
|
|
lowered = [part.casefold() for part in parts]
|
|
if "расширения" in lowered:
|
|
index = lowered.index("расширения")
|
|
if index + 1 < len(parts):
|
|
return parts[index + 1]
|
|
if "extensions" in lowered:
|
|
index = lowered.index("extensions")
|
|
if index + 1 < len(parts):
|
|
return parts[index + 1]
|
|
return None
|
|
|
|
|
|
def metadata_item(
|
|
node: ET.Element,
|
|
*,
|
|
index: dict[str, Any],
|
|
category: str,
|
|
parent_category: str,
|
|
parent_name: str,
|
|
parent_uuid: str,
|
|
tabular_section_name: str | None = None,
|
|
tabular_section_uuid: str | None = None,
|
|
record_index: int,
|
|
) -> dict[str, Any]:
|
|
properties = direct_child(node, "Properties")
|
|
guid = (node.get("uuid") or "").lower()
|
|
name = text_child(properties, "Name")
|
|
types = value_types(properties)
|
|
routes = storage_routes(index, guid)
|
|
item = {
|
|
"category": category,
|
|
"name": name,
|
|
"synonym": synonym(properties),
|
|
"uuid": guid,
|
|
"value_type": value_type_payload(types),
|
|
"parent_category": parent_category,
|
|
"parent_name": parent_name,
|
|
"parent_uuid": parent_uuid,
|
|
"record_index": record_index,
|
|
"evidence": {"name": bool(name), "synonym": bool(synonym(properties)), "uuid": bool(guid)},
|
|
"storage_routes": routes,
|
|
"storage_route_count": len(routes),
|
|
"physical_columns": field_columns(routes, types) if category == "Attribute" else [{"status": "no_value_type"}],
|
|
}
|
|
object_belonging = text_child(properties, "ObjectBelonging")
|
|
extended_object = text_child(properties, "ExtendedConfigurationObject")
|
|
if object_belonging:
|
|
item["object_belonging"] = object_belonging
|
|
if extended_object:
|
|
item["extended_configuration_object"] = extended_object.lower()
|
|
if tabular_section_name:
|
|
item["tabular_section_name"] = tabular_section_name
|
|
item["tabular_section_uuid"] = tabular_section_uuid
|
|
return item
|
|
|
|
|
|
def object_node(root: ET.Element, kind: str) -> ET.Element:
|
|
for node in root.iter():
|
|
if local_name(node.tag) == kind:
|
|
return node
|
|
raise SystemExit(f"XML object node not found: {kind}")
|
|
|
|
|
|
def merge_attribute_overlay(base: list[dict[str, Any]], overlay: dict[str, Any]) -> None:
|
|
extended_uuid = overlay.get("extended_configuration_object")
|
|
target = None
|
|
if extended_uuid:
|
|
target = next((item for item in base if item.get("uuid") == extended_uuid), None)
|
|
if target is None and overlay.get("name"):
|
|
target = next((item for item in base if item.get("name") == overlay.get("name")), None)
|
|
if target is None:
|
|
base.append(overlay)
|
|
return
|
|
|
|
record = {
|
|
"source": overlay.get("source"),
|
|
"extension_name": overlay.get("extension_name"),
|
|
"path": overlay.get("source_path"),
|
|
"uuid": overlay.get("uuid"),
|
|
"object_belonging": overlay.get("object_belonging"),
|
|
"extended_configuration_object": overlay.get("extended_configuration_object"),
|
|
"value_type": overlay.get("value_type"),
|
|
"synonym": overlay.get("synonym"),
|
|
}
|
|
target.setdefault("extension_overrides", []).append(record)
|
|
target["effective_source"] = "base+extension"
|
|
if overlay.get("value_type"):
|
|
target["base_value_type"] = target.get("base_value_type") or target.get("value_type")
|
|
target["value_type"] = overlay["value_type"]
|
|
target["physical_columns"] = field_columns(target.get("storage_routes") or [], overlay["value_type"].get("types") or [])
|
|
if overlay.get("synonym"):
|
|
target["synonym"] = overlay["synonym"]
|
|
|
|
|
|
def apply_object_overlay(
|
|
*,
|
|
index: dict[str, Any],
|
|
overlay: dict[str, Any],
|
|
kind: str,
|
|
attributes: list[dict[str, Any]],
|
|
tabular_sections: list[dict[str, Any]],
|
|
tabular_section_attributes: list[dict[str, Any]],
|
|
) -> dict[str, int]:
|
|
path = Path(str(overlay.get("path") or ""))
|
|
if not path.is_file():
|
|
return {"missing": 1, "attributes_added": 0, "attributes_changed": 0}
|
|
root = ET.parse(path).getroot()
|
|
node = object_node(root, kind)
|
|
children = direct_child(node, "ChildObjects")
|
|
if children is None:
|
|
return {"missing": 0, "attributes_added": 0, "attributes_changed": 0}
|
|
|
|
added = 0
|
|
changed = 0
|
|
extension_name = extension_name_from_path(str(path))
|
|
for child in list(children):
|
|
if local_name(child.tag) != "Attribute":
|
|
continue
|
|
before = len(attributes)
|
|
item = metadata_item(
|
|
child,
|
|
index=index,
|
|
category="Attribute",
|
|
parent_category=kind,
|
|
parent_name=str(overlay.get("name") or ""),
|
|
parent_uuid=str(overlay.get("guid") or ""),
|
|
record_index=len(attributes),
|
|
)
|
|
item["source"] = "extension"
|
|
item["extension_name"] = extension_name
|
|
item["source_path"] = str(path)
|
|
merge_attribute_overlay(attributes, item)
|
|
if len(attributes) > before:
|
|
added += 1
|
|
else:
|
|
changed += 1
|
|
return {"missing": 0, "attributes_added": added, "attributes_changed": changed}
|
|
|
|
|
|
def build_metadata(index: dict[str, Any], *, kind: str, name: str) -> dict[str, Any]:
|
|
resolution = resolve_object(index, kind=kind, name=name, limit=50)
|
|
canonical = resolution.get("canonical")
|
|
if not canonical:
|
|
raise SystemExit(f"Object not found: {kind}.{name}")
|
|
xml_path = Path(str(canonical.get("path") or ""))
|
|
if not xml_path.is_file():
|
|
raise SystemExit(f"Object XML file not found: {xml_path}")
|
|
|
|
root = ET.parse(xml_path).getroot()
|
|
node = object_node(root, str(canonical["kind"]))
|
|
properties = direct_child(node, "Properties")
|
|
object_name = text_child(properties, "Name") or str(canonical["name"])
|
|
object_uuid = str(canonical["guid"]).lower()
|
|
main_routes = storage_routes(index, object_uuid)
|
|
main_table = next((physical_name(route) for route in main_routes if route.get("storage_role") == canonical["kind"]), None)
|
|
if not main_table:
|
|
main_table = next((route.get("physical_name_candidate") for route in main_routes if route.get("route_kind") == "table"), None)
|
|
|
|
attributes = []
|
|
tabular_sections = []
|
|
tabular_section_attributes = []
|
|
children = direct_child(node, "ChildObjects")
|
|
if children is not None:
|
|
attr_index = 0
|
|
ts_index = 0
|
|
for child in list(children):
|
|
child_kind = local_name(child.tag)
|
|
if child_kind == "Attribute":
|
|
attributes.append(
|
|
{
|
|
**metadata_item(
|
|
child,
|
|
index=index,
|
|
category="Attribute",
|
|
parent_category=str(canonical["kind"]),
|
|
parent_name=object_name,
|
|
parent_uuid=object_uuid,
|
|
record_index=attr_index,
|
|
),
|
|
"source": "base",
|
|
}
|
|
)
|
|
attr_index += 1
|
|
elif child_kind == "TabularSection":
|
|
section = metadata_item(
|
|
child,
|
|
index=index,
|
|
category="TabularSection",
|
|
parent_category=str(canonical["kind"]),
|
|
parent_name=object_name,
|
|
parent_uuid=object_uuid,
|
|
record_index=ts_index,
|
|
)
|
|
vt_route = next((route for route in section["storage_routes"] if route.get("storage_role") == "VT"), None)
|
|
line_numbers = [route.get("sql_number") for route in section["storage_routes"] if route.get("storage_role") == "LineNo"]
|
|
if main_table and vt_route and vt_route.get("sql_number") is not None:
|
|
section["physical_tables"] = [
|
|
{
|
|
"table": f"{main_table}_VT{vt_route['sql_number']}",
|
|
"reason": "tabular section VT route under object table",
|
|
"vt_sql_number": vt_route["sql_number"],
|
|
"line_no_sql_numbers": line_numbers,
|
|
}
|
|
]
|
|
tabular_sections.append(section)
|
|
section_children = direct_child(child, "ChildObjects")
|
|
if section_children is not None:
|
|
for record_index, section_child in enumerate([item for item in list(section_children) if local_name(item.tag) == "Attribute"]):
|
|
attr = metadata_item(
|
|
section_child,
|
|
index=index,
|
|
category="Attribute",
|
|
parent_category="TabularSection",
|
|
parent_name=section["name"],
|
|
parent_uuid=section["uuid"],
|
|
tabular_section_name=section["name"],
|
|
tabular_section_uuid=section["uuid"],
|
|
record_index=record_index,
|
|
)
|
|
attr["parent_physical_tables"] = section.get("physical_tables") or []
|
|
attr["source"] = "base"
|
|
tabular_section_attributes.append(attr)
|
|
ts_index += 1
|
|
|
|
overlay_stats = []
|
|
for overlay in resolution.get("extension_overlays") or []:
|
|
stats = apply_object_overlay(
|
|
index=index,
|
|
overlay=overlay,
|
|
kind=str(canonical["kind"]),
|
|
attributes=attributes,
|
|
tabular_sections=tabular_sections,
|
|
tabular_section_attributes=tabular_section_attributes,
|
|
)
|
|
overlay_stats.append(
|
|
{
|
|
"extension_name": extension_name_from_path(overlay.get("path")),
|
|
"path": overlay.get("path"),
|
|
**stats,
|
|
}
|
|
)
|
|
|
|
return {
|
|
"schema": "onec_structured_metadata_from_resolved_xml.v1",
|
|
"kind": canonical["kind"],
|
|
"xml_file": str(xml_path),
|
|
"identity": {
|
|
"guid": object_uuid,
|
|
"name": object_name,
|
|
"synonyms": {"ru": synonym(properties)} if synonym(properties) else {},
|
|
},
|
|
"resolution": {"schema": resolution.get("schema"), "canonical": canonical, "summary": resolution.get("summary")},
|
|
"effective_metadata": {
|
|
"base_path": str(xml_path),
|
|
"extension_overlays_applied": overlay_stats,
|
|
},
|
|
"attributes": attributes,
|
|
"tabular_sections": tabular_sections,
|
|
"tabular_section_attributes": tabular_section_attributes,
|
|
"dimensions": [],
|
|
"resources": [],
|
|
"forms": [],
|
|
"templates": [],
|
|
"commands": [],
|
|
"addressing_attributes": [],
|
|
"accounting_flags": [],
|
|
"columns": [],
|
|
"enum_values": [],
|
|
"object_storage_routes": main_routes,
|
|
"storage_route_summary": {
|
|
"metadata_items_with_routes": sum(1 for item in attributes + tabular_sections + tabular_section_attributes if item.get("storage_routes")),
|
|
"object_routes": len(main_routes),
|
|
},
|
|
"counts": {
|
|
"attributes": len(attributes),
|
|
"tabular_sections": len(tabular_sections),
|
|
"tabular_section_attributes": len(tabular_section_attributes),
|
|
},
|
|
}
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description="Build metadata card from resolved 1C object XML.")
|
|
parser.add_argument("--index", type=Path, required=True)
|
|
parser.add_argument("--kind", required=True)
|
|
parser.add_argument("--name", required=True)
|
|
parser.add_argument("--output", type=Path, required=True)
|
|
args = parser.parse_args()
|
|
|
|
result = build_metadata(load_json(args.index), kind=args.kind, name=args.name)
|
|
args.output.parent.mkdir(parents=True, exist_ok=True)
|
|
args.output.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
print(json.dumps({"output": str(args.output), "counts": result["counts"]}, ensure_ascii=False))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|