Files
index/index1c/object_view.py
T

543 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Поиск объекта/реквизита и вывод дерева структуры метаданных."""
from __future__ import annotations
import json
import re
import sqlite3
from pathlib import Path
from typing import Any
from .parse_meta import FieldRec, ObjectRec, parse_metadata_xml
_QUERY_TOKEN_RE = re.compile(r"[\wА-Яа-яЁё]+", flags=re.UNICODE)
FILL_LABEL = {
"ShowError": "обяз.",
"DontCheck": "необяз.",
}
def _norm(s: str) -> str:
return " ".join((s or "").casefold().split())
def _type_label(raw: str) -> str:
if raw.startswith("cfg:"):
return raw[4:]
if raw.startswith("xs:"):
return raw[3:]
return raw
def _fill_label(fill: str) -> str:
if not fill:
return "?"
return FILL_LABEL.get(fill, fill)
def _field_matches(f: dict[str, Any], q: str, qn: str) -> bool:
"""Только точное совпадение имени или синонима реквизита."""
name_cf = _norm(f.get("name") or "")
syn_cf = _norm(f.get("synonym") or "")
# синоним может быть «Клиент | en:Client» — сравниваем по частям
syn_parts = [_norm(p) for p in (f.get("synonym") or "").split("|")]
return name_cf == qn or syn_cf == qn or qn in syn_parts
def _query_tokens(query: str) -> list[str]:
return _QUERY_TOKEN_RE.findall(query or "")
def _words_cf(text: str) -> list[str]:
return [w.casefold() for w in _QUERY_TOKEN_RE.findall(text or "")]
def _tokens_match_text(tokens: list[str], text: str) -> bool:
"""Как FTS search: каждый токен — префикс какого-либо слова в тексте."""
if not tokens:
return False
words = _words_cf(text)
if not words:
return False
for tok in tokens:
t = tok.casefold()
if not any(w.startswith(t) for w in words):
return False
return True
def object_header_matches_search(row: dict[str, Any], query: str) -> bool:
"""Совпадение по имени/синониму/комментарию объекта (не по реквизитам)."""
tokens = _query_tokens(query)
if not tokens:
return False
for part in (
row.get("name") or "",
row.get("full_name") or "",
row.get("synonym") or "",
row.get("comment") or "",
):
if _tokens_match_text(tokens, part):
return True
return False
def field_matches_search_query(f: dict[str, Any], query: str) -> bool:
"""Совпадение реквизита с поисковым запросом (префикс по словам, как FTS)."""
tokens = _query_tokens(query)
if not tokens:
return False
text = " ".join(
filter(
None,
[
f.get("kind") or "",
f.get("name") or "",
f.get("synonym") or "",
f.get("comment") or "",
f.get("tabular_section") or "",
" ".join(f.get("types") or []),
],
)
)
return _tokens_match_text(tokens, text)
def _field_dict_from_db_row(row: sqlite3.Row) -> dict[str, Any]:
return {
"kind": row["kind"],
"name": row["name"],
"synonym": row["synonym"] or "",
"comment": row["comment"] or "" if "comment" in row.keys() else "",
"types": [t.strip() for t in (row["types"] or "").split(",") if t.strip()],
"tabular_section": row["tabular_section"] or "",
"refs": json.loads(row["refs_json"] or "[]"),
"fill_checking": "",
}
def find_fields_for_search_query(
conn: sqlite3.Connection,
object_id: int,
query: str,
) -> list[dict[str, Any]]:
"""Реквизиты объекта, совпавшие с FTS-запросом search."""
rows = conn.execute(
"""
SELECT kind, name, synonym, comment, types, tabular_section, refs_json
FROM fields
WHERE object_id = ?
ORDER BY tabular_section, kind, name
""",
(object_id,),
)
matched: list[dict[str, Any]] = []
seen: set[tuple[str, str, str]] = set()
for row in rows:
fdict = _field_dict_from_db_row(row)
if not field_matches_search_query(fdict, query):
continue
sig = (fdict["tabular_section"], fdict["name"], fdict["kind"])
if sig in seen:
continue
seen.add(sig)
matched.append(fdict)
return matched
def enrich_matched_fields(obj: dict[str, Any], matched: list[dict[str, Any]]) -> None:
"""Подтянуть FillChecking и типы из актуальной структуры объекта."""
if not matched or not obj.get("fields"):
return
by_f = {
(f.get("tabular_section") or "", f.get("name") or "", f.get("kind") or ""): f
for f in obj["fields"]
}
for mf in matched:
key = (mf.get("tabular_section") or "", mf.get("name") or "", mf.get("kind") or "")
src = by_f.get(key)
if not src:
continue
mf["fill_checking"] = src.get("fill_checking") or ""
mf["types"] = src.get("types") or mf.get("types")
mf["synonym"] = src.get("synonym") or mf.get("synonym")
mf["refs"] = src.get("refs") or mf.get("refs")
def load_object_structure_for_row(
conn: sqlite3.Connection,
row: dict[str, Any],
) -> dict[str, Any]:
match = {
"config": row["config"],
"full_name": row["full_name"],
"path": row.get("path"),
"object_type": row.get("object_type"),
}
if row.get("json"):
match["json"] = row["json"]
elif row.get("object_id"):
jrow = conn.execute(
"SELECT json FROM objects WHERE id=?", (row["object_id"],)
).fetchone()
match["json"] = jrow["json"] if jrow else "{}"
else:
jrow = conn.execute(
"SELECT json FROM objects WHERE config=? AND full_name=?",
(row["config"], row["full_name"]),
).fetchone()
match["json"] = jrow["json"] if jrow else "{}"
return load_object_structure(match)
def annotate_search_hits(
conn: sqlite3.Connection,
rows: list[dict[str, Any]],
query: str,
) -> list[dict[str, Any]]:
"""Уточнить search: object — по шапке; field — по реквизитам в fields_text."""
for row in rows:
if object_header_matches_search(row, query):
row["match_kind"] = "object"
row["matched_fields"] = []
continue
oid = row.get("object_id")
matched = find_fields_for_search_query(conn, int(oid), query) if oid else []
if matched:
row["match_kind"] = "field"
row["matched_fields"] = matched
else:
row["match_kind"] = "object"
row["matched_fields"] = []
return rows
def find_structure_matches(
conn: sqlite3.Connection,
query: str,
*,
configs: list[str] | None = None,
object_type: str | None = None,
limit: int = 20,
) -> list[dict[str, Any]]:
"""Найти объекты по имени/синониму объекта или реквизита.
Каждый элемент:
config, full_name, path, json, match_kind: object|field,
matched_fields: list[field dict keys],
score: int (меньше — лучше)
"""
q = query.strip()
qn = _norm(q)
if not qn:
return []
sql_obj = """
SELECT id, config, full_name, name, synonym, path, json, object_type
FROM objects
WHERE (
name = ? OR full_name = ? OR full_name LIKE ?
OR synonym LIKE ? OR name LIKE ?
)
"""
params: list[Any] = [q, q, f"%.{q}", f"%{q}%", f"%{q}%"]
if configs:
sql_obj += f" AND config IN ({','.join('?' * len(configs))})"
params.extend(configs)
if object_type:
sql_obj += " AND object_type = ?"
params.append(object_type)
sql_obj += " ORDER BY config, full_name LIMIT ?"
params.append(max(limit * 5, 50))
by_key: dict[tuple[str, str], dict[str, Any]] = {}
def _ensure(row) -> dict[str, Any]:
key = (row["config"], row["full_name"])
if key not in by_key:
oid = row["object_id"] if "object_id" in row.keys() else row["id"]
by_key[key] = {
"config": row["config"],
"full_name": row["full_name"],
"path": row["path"],
"json": row["json"],
"object_type": row["object_type"],
"match_kind": "none",
"matched_fields": [],
"object_id": oid,
"score": 100,
}
return by_key[key]
for row in conn.execute(sql_obj, params):
entry = _ensure(row)
name_cf = _norm(row["name"])
full_cf = _norm(row["full_name"])
syn_cf = _norm(row["synonym"] or "")
if name_cf == qn or full_cf == qn or full_cf.endswith("." + qn):
entry["match_kind"] = "object"
entry["score"] = min(entry["score"], 0)
elif syn_cf == qn:
entry["match_kind"] = "object"
entry["score"] = min(entry["score"], 2)
elif qn in name_cf or qn in full_cf:
if entry["match_kind"] == "none":
entry["match_kind"] = "object"
entry["score"] = min(entry["score"], 20)
elif qn in syn_cf:
if entry["match_kind"] == "none":
entry["match_kind"] = "object"
entry["score"] = min(entry["score"], 25)
sql_f = """
SELECT o.id AS object_id, o.config, o.full_name, o.path, o.json, o.object_type,
o.name AS object_name, o.synonym AS object_synonym,
f.kind, f.name, f.synonym, f.types, f.tabular_section, f.refs_json
FROM fields f
JOIN objects o ON o.id = f.object_id
WHERE (
f.name = ? OR f.synonym LIKE ? OR f.name LIKE ?
)
"""
params_f: list[Any] = [q, f"%{q}%", f"%{q}%"]
if configs:
sql_f += f" AND o.config IN ({','.join('?' * len(configs))})"
params_f.extend(configs)
if object_type:
sql_f += " AND o.object_type = ?"
params_f.append(object_type)
sql_f += " ORDER BY o.config, o.full_name LIMIT ?"
params_f.append(max(limit * 15, 100))
for row in conn.execute(sql_f, params_f):
fdict = {
"kind": row["kind"],
"name": row["name"],
"synonym": row["synonym"] or "",
"types": [t.strip() for t in (row["types"] or "").split(",") if t.strip()],
"tabular_section": row["tabular_section"] or "",
"refs": json.loads(row["refs_json"] or "[]"),
"fill_checking": "",
}
if not _field_matches(fdict, q, qn):
continue
entry = _ensure(row)
fname_cf = _norm(fdict["name"])
fsyn_cf = _norm(fdict["synonym"])
if fname_cf == qn:
field_score = 5
elif fsyn_cf == qn:
field_score = 6
else:
field_score = 15
# если объект уже найден точно — оставляем object, но добавляем поля
if entry["score"] > field_score or entry["match_kind"] in {"none", "field"}:
if entry["match_kind"] != "object" or entry["score"] > 0:
entry["match_kind"] = "field"
entry["score"] = min(entry["score"], field_score)
elif entry["match_kind"] == "object" and entry["score"] == 0:
# точный объект + ещё реквизит с тем же именем (редко) — остаётся object
pass
else:
entry["score"] = min(entry["score"], field_score)
sig = (fdict["tabular_section"], fdict["name"], fdict["kind"])
existing = {
(x.get("tabular_section"), x.get("name"), x.get("kind"))
for x in entry["matched_fields"]
}
if sig not in existing:
entry["matched_fields"].append(fdict)
# убрать слабые «просто LIKE по имени объекта», если есть точные хиты
items = [v for v in by_key.values() if v["match_kind"] != "none"]
has_exact = any(v["score"] <= 6 for v in items)
if has_exact:
items = [v for v in items if v["score"] <= 15]
items.sort(key=lambda x: (x["score"], x["config"], x["full_name"]))
return items[:limit]
def load_object_structure(match: dict[str, Any]) -> dict[str, Any]:
"""Загрузить структуру: предпочесть свежий разбор XML (FillChecking)."""
path_s = match.get("path") or ""
path = Path(path_s) if path_s else None
config = match["config"]
otype = match.get("object_type") or match["full_name"].split(".", 1)[0]
if path and path.is_file():
rec = parse_metadata_xml(path, config=config, object_type=otype)
if rec:
return rec.to_dict()
data = json.loads(match["json"])
# старые индексы без fill_checking
for f in data.get("fields") or []:
f.setdefault("fill_checking", "")
return data
def format_types(types: list[str], refs: list[dict[str, Any]] | None = None) -> str:
if not types:
return ""
labels = [_type_label(t) for t in types]
if len(labels) == 1:
return labels[0]
return " | ".join(labels)
def format_field_line(
f: dict[str, Any],
*,
indent: str = "",
last: bool = True,
under_ts: bool = False,
) -> str:
branch = "└── " if last else "├── "
name = f.get("name") or ""
kind = f.get("kind") or "Attribute"
syn = f.get("synonym") or ""
syn_s = f"{syn}" if syn else ""
if kind == "TabularSection":
return f"{indent}{branch}`{kind}.{name}`{syn_s}"
fill = _fill_label(f.get("fill_checking") or "")
types_s = format_types(f.get("types") or [], f.get("refs"))
if kind == "Column" and not under_ts and f.get("tabular_section"):
name = f"{f['tabular_section']}.{name}"
return f"{indent}{branch}`{kind}.{name}`{syn_s} [{fill}] : {types_s}"
def _select_matched_field_nodes(
fields: list[dict[str, Any]],
matched_fields: list[dict[str, Any]],
) -> tuple[list[dict[str, Any]], set[tuple[str, str, str]], set[str]]:
want: set[tuple[str, str, str]] = set()
need_ts: set[str] = set()
for mf in matched_fields:
want.add(
(
mf.get("kind") or "",
mf.get("tabular_section") or "",
mf.get("name") or "",
)
)
if mf.get("tabular_section") and mf.get("kind") == "Column":
need_ts.add(mf["tabular_section"])
selected: list[dict[str, Any]] = []
for f in fields:
key = (
f.get("kind") or "",
f.get("tabular_section") or "",
f.get("name") or "",
)
if key in want:
selected.append(f)
elif f.get("kind") == "TabularSection" and f.get("name") in need_ts:
selected.append(f)
return selected, want, need_ts
def format_object_tree(
obj: dict[str, Any],
*,
clear: bool = False,
matched_fields: list[dict[str, Any]] | None = None,
match_kind: str = "object",
omit_header: bool = False,
) -> str:
"""Дерево реквизитов объекта. clear — только совпадения."""
lines: list[str] = []
syn = obj.get("synonym") or ""
syn_s = f"{syn}" if syn else ""
cfg = obj.get("config") or obj.get("_config") or ""
header = f"`{obj.get('full_name') or obj.get('object_type') + '.' + obj.get('name')}`{syn_s}"
if cfg:
header = f"[{cfg}] {header}"
if not omit_header:
lines.append(header)
fields: list[dict[str, Any]] = list(obj.get("fields") or [])
if clear:
if match_kind == "field" and matched_fields:
selected, want, need_ts = _select_matched_field_nodes(fields, matched_fields)
if not selected:
lines.append(" (совпавшие реквизиты не найдены в структуре)")
return "\n".join(lines)
lines.extend(_render_field_tree(selected, only_keys=want, need_ts=need_ts))
return "\n".join(lines)
return "\n".join(lines)
if not fields:
lines.append(" (нет реквизитов в индексе)")
return "\n".join(lines)
lines.extend(_render_field_tree(fields))
return "\n".join(lines)
def _render_field_tree(
fields: list[dict[str, Any]],
*,
only_keys: set[tuple[str, str, str]] | None = None,
need_ts: set[str] | None = None,
) -> list[str]:
"""Рендер: сначала шапка объекта уже напечатана; здесь реквизиты и ТЧ."""
lines: list[str] = []
# группировка: header fields, then TS with columns
headers = [
f
for f in fields
if f.get("kind") not in {"TabularSection", "Column"}
and not f.get("tabular_section")
]
tab_sections = [f for f in fields if f.get("kind") == "TabularSection"]
columns_by_ts: dict[str, list[dict[str, Any]]] = {}
for f in fields:
if f.get("kind") == "Column" and f.get("tabular_section"):
columns_by_ts.setdefault(f["tabular_section"], []).append(f)
if only_keys is not None:
headers = [
f
for f in headers
if (f.get("kind") or "", f.get("tabular_section") or "", f.get("name") or "")
in only_keys
]
tab_sections = [
f
for f in tab_sections
if f.get("name") in (need_ts or set())
or (f.get("kind") or "", "", f.get("name") or "") in only_keys
]
# плоский список узлов верхнего уровня для корректных └──/├──
top: list[tuple[str, Any]] = [("h", f) for f in headers] + [("ts", f) for f in tab_sections]
for i, (kind, node) in enumerate(top):
last = i == len(top) - 1
if kind == "h":
lines.append(format_field_line(node, indent="", last=last))
continue
# tabular section
lines.append(format_field_line(node, indent="", last=last))
ts_name = node.get("name") or ""
cols = columns_by_ts.get(ts_name, [])
if only_keys is not None:
cols = [
c
for c in cols
if (c.get("kind") or "", c.get("tabular_section") or "", c.get("name") or "")
in only_keys
]
child_indent = " " if last else ""
for j, col in enumerate(cols):
lines.append(
format_field_line(
col, indent=child_indent, last=(j == len(cols) - 1), under_ts=True
)
)
return lines