From 06e13395f871cbbeb9a41afe60d9730721dd234e Mon Sep 17 00:00:00 2001 From: mihailkudravcev Date: Fri, 17 Jul 2026 16:56:15 +0300 Subject: [PATCH] =?UTF-8?q?=D0=B5=D1=81=D0=BB=D0=B8=20=D0=BE=D0=B1=D1=8A?= =?UTF-8?q?=D0=B5=D0=BA=D1=82=20=D0=BD=D0=B0=D0=B9=D0=B4=D0=B5=D0=BD=20?= =?UTF-8?q?=D0=BF=D0=BE=20=D1=80=D0=B5=D0=BA=D0=B2=D0=B8=D0=B7=D0=B8=D1=82?= =?UTF-8?q?=D1=83=20(=D0=BA=D0=BE=D0=BB=D0=BE=D0=BD=D0=BA=D0=B0=20=20?= =?UTF-8?q?=D0=B2=20FTS),=20=D0=B2=20=D0=B2=D1=8B=D0=B4=D0=B0=D1=87=D0=B5?= =?UTF-8?q?=20=D0=BF=D0=BE=D0=BA=D0=B0=D0=B7=D1=8B=D0=B2=D0=B0=D0=B5=D1=82?= =?UTF-8?q?=D1=81=D1=8F=20=D1=82=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20=D1=81?= =?UTF-8?q?=D0=BE=D0=B2=D0=BF=D0=B0=D0=B2=D1=88=D0=B8=D0=B9=20=D1=80=D0=B5?= =?UTF-8?q?=D0=BA=D0=B2=D0=B8=D0=B7=D0=B8=D1=82=20=D1=81=20=D1=82=D0=B8?= =?UTF-8?q?=D0=BF=D0=BE=D0=BC=20=D0=B7=D0=BD=D0=B0=D1=87=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F=20=E2=80=94=20=D0=B2=20=D1=82=D0=BE=D0=BC=20=D0=B6=D0=B5?= =?UTF-8?q?=20=D1=84=D0=BE=D1=80=D0=BC=D0=B0=D1=82=D0=B5=20=D0=B4=D0=B5?= =?UTF-8?q?=D1=80=D0=B5=D0=B2=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 ++ README.md | 4 +- VERSION | 2 +- index1c/object_view.py | 229 ++++++++++++++++++++++++++++++++++++----- index1c/search.py | 10 +- query_1c.py | 41 ++++---- 6 files changed, 239 insertions(+), 53 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e9e0c5..80cbfbc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ Формат: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Версионирование: [Semantic Versioning](https://semver.org/lang/ru/). +## [0.3.3] - 2026-07-17 + +### Changed + +- **`query_1c.py search`**: если объект найден по реквизиту (колонка `fields_text` в FTS), в выдаче показывается только совпавший реквизит с типом значения — в том же формате дерева, что и `object --clear`. + ## [0.3.2] - 2026-07-17 ### Added diff --git a/README.md b/README.md index ddcd8e7..a2624fb 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Индекс метаданных и модулей конфигураций 1С -**Версия:** см. [`VERSION`](VERSION) (текущая: **0.3.2**) · [CHANGELOG](CHANGELOG.md) +**Версия:** см. [`VERSION`](VERSION) (текущая: **0.3.3**) · [CHANGELOG](CHANGELOG.md) **Автор:** Michael BAG · [mk@p7net.ru](mailto:mk@p7net.ru) **Лицензия:** [GNU GPL v3](LICENSE) · [русский перевод (справочно)](LICENSE.ru) @@ -127,7 +127,7 @@ python index_1c.py list | Команда | Аргумент | Результат | Полезные опции | |---------|----------|-----------|----------------| -| `search` | строка | объекты метаданных (FTS) | `-b`, `--type`, `--limit` | +| `search` | строка | объекты метаданных (FTS); при совпадении по реквизиту — дерево найденного поля | `-b`, `--type`, `--limit` | | `object` | имя/синоним объекта **или** реквизита | дерево структуры | `-b`, `--type`, `--clear`, `--limit` | | `show` | имя объекта | полная карточка JSON | `-b` | | `refs` | `Catalog.Customers` / `Contract` | обратные ссылки полей | `-b`, `--limit` (на каждую baseconf) | diff --git a/VERSION b/VERSION index d15723f..1c09c74 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.3.2 +0.3.3 diff --git a/index1c/object_view.py b/index1c/object_view.py index 2f3e271..ca6c86d 100644 --- a/index1c/object_view.py +++ b/index1c/object_view.py @@ -3,12 +3,15 @@ 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": "обяз.", @@ -43,6 +46,174 @@ def _field_matches(f: dict[str, Any], q: str, qn: str) -> bool: 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, @@ -239,12 +410,43 @@ def format_field_line( 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] = [] @@ -254,35 +456,14 @@ def format_object_tree( header = f"`{obj.get('full_name') or obj.get('object_type') + '.' + obj.get('name')}`{syn_s}" if cfg: header = f"[{cfg}] {header}" - lines.append(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: - 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) + selected, want, need_ts = _select_matched_field_nodes(fields, matched_fields) if not selected: lines.append(" (совпавшие реквизиты не найдены в структуре)") return "\n".join(lines) diff --git a/index1c/search.py b/index1c/search.py index 784df6a..3f65651 100644 --- a/index1c/search.py +++ b/index1c/search.py @@ -10,6 +10,7 @@ from typing import Any from . import db as dbmod from .config import cache_dir +from .object_view import annotate_search_hits def _fts_query(raw: str) -> str: @@ -266,7 +267,8 @@ def search_objects( limit: int = 30, ) -> list[dict[str, Any]]: exact_sql = """ - SELECT config, full_name, object_type, name, synonym, comment, path, '' AS snip, -100.0 AS score + SELECT config, full_name, object_type, name, synonym, comment, path, + id AS object_id, '' AS snip, -100.0 AS score FROM objects WHERE (name = ? OR full_name = ? OR full_name LIKE ?) """ @@ -286,6 +288,7 @@ def search_objects( fetch_n = max(limit * 5, 50) sql = """ SELECT o.config, o.full_name, o.object_type, o.name, o.synonym, o.comment, o.path, + o.id AS object_id, snippet(objects_fts, 0, '[', ']', '…', 12) AS snip, bm25(objects_fts, 10.0, 5.0, 2.0, 1.0) AS score FROM objects_fts @@ -307,7 +310,8 @@ def search_objects( except sqlite3.OperationalError: like = f"%{query}%" sql2 = """ - SELECT config, full_name, object_type, name, synonym, comment, path, '' AS snip, 0 AS score + SELECT config, full_name, object_type, name, synonym, comment, path, + id AS object_id, '' AS snip, 0 AS score FROM objects WHERE full_name LIKE ? OR synonym LIKE ? OR comment LIKE ? OR name LIKE ? """ @@ -343,7 +347,7 @@ def search_objects( merged.sort( key=lambda x: (x.get("_boost", 9), x.get("score") if x.get("score") is not None else 0) ) - return merged[:limit] + return annotate_search_hits(conn, merged[:limit], query) def search_modules( diff --git a/query_1c.py b/query_1c.py index 0b199cd..c888748 100755 --- a/query_1c.py +++ b/query_1c.py @@ -54,9 +54,11 @@ from index1c.search import ( # noqa: E402 search_objects, ) from index1c.object_view import ( # noqa: E402 + enrich_matched_fields, find_structure_matches, format_object_tree, load_object_structure, + load_object_structure_for_row, ) @@ -91,7 +93,21 @@ def cmd_search(args: argparse.Namespace) -> int: for r in rows: syn = f" — {r['synonym']}" if r.get("synonym") else "" print(f"- [{r['config']}] `{r['full_name']}`{syn}") - if r.get("snip"): + matched = list(r.get("matched_fields") or []) + if r.get("match_kind") == "field" and matched: + obj = load_object_structure_for_row(conn, r) + enrich_matched_fields(obj, matched) + block = format_object_tree( + obj, + clear=True, + matched_fields=matched, + match_kind="field", + omit_header=True, + ) + for line in block.splitlines(): + if line: + print(f" {line}") + elif r.get("snip"): print(f" {r['snip']}") return 0 finally: @@ -283,29 +299,8 @@ def cmd_object(args: argparse.Namespace) -> int: if i: print() obj = load_object_structure(m) - # подтянуть FillChecking/типы из свежего XML в matched_fields matched = list(m.get("matched_fields") or []) - if matched and obj.get("fields"): - 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 src: - 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") + enrich_matched_fields(obj, matched) # точное совпадение объекта (score 0) → clear без дерева; # иначе при найденных реквизитах — clear только по ним kind = m.get("match_kind") or "object"