если объект найден по реквизиту (колонка в FTS), в выдаче показывается только совпавший реквизит с типом значения — в том же формате дерева

This commit is contained in:
mihailkudravcev
2026-07-17 16:56:15 +03:00
parent 404659a545
commit 06e13395f8
6 changed files with 239 additions and 53 deletions
+6
View File
@@ -5,6 +5,12 @@
Формат: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Формат: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
Версионирование: [Semantic Versioning](https://semver.org/lang/ru/). Версионирование: [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 ## [0.3.2] - 2026-07-17
### Added ### Added
+2 -2
View File
@@ -1,6 +1,6 @@
# Индекс метаданных и модулей конфигураций 1С # Индекс метаданных и модулей конфигураций 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) **Автор:** Michael BAG · [mk@p7net.ru](mailto:mk@p7net.ru)
**Лицензия:** [GNU GPL v3](LICENSE) · [русский перевод (справочно)](LICENSE.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` | | `object` | имя/синоним объекта **или** реквизита | дерево структуры | `-b`, `--type`, `--clear`, `--limit` |
| `show` | имя объекта | полная карточка JSON | `-b` | | `show` | имя объекта | полная карточка JSON | `-b` |
| `refs` | `Catalog.Customers` / `Contract` | обратные ссылки полей | `-b`, `--limit` (на каждую baseconf) | | `refs` | `Catalog.Customers` / `Contract` | обратные ссылки полей | `-b`, `--limit` (на каждую baseconf) |
+1 -1
View File
@@ -1 +1 @@
0.3.2 0.3.3
+202 -21
View File
@@ -3,12 +3,15 @@
from __future__ import annotations from __future__ import annotations
import json import json
import re
import sqlite3 import sqlite3
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from .parse_meta import FieldRec, ObjectRec, parse_metadata_xml from .parse_meta import FieldRec, ObjectRec, parse_metadata_xml
_QUERY_TOKEN_RE = re.compile(r"[\wА-Яа-яЁё]+", flags=re.UNICODE)
FILL_LABEL = { FILL_LABEL = {
"ShowError": "обяз.", "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 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( def find_structure_matches(
conn: sqlite3.Connection, conn: sqlite3.Connection,
query: str, query: str,
@@ -239,27 +410,10 @@ def format_field_line(
return f"{indent}{branch}`{kind}.{name}`{syn_s} [{fill}] : {types_s}" return f"{indent}{branch}`{kind}.{name}`{syn_s} [{fill}] : {types_s}"
def format_object_tree( def _select_matched_field_nodes(
obj: dict[str, Any], fields: list[dict[str, Any]],
*, matched_fields: list[dict[str, Any]],
clear: bool = False, ) -> tuple[list[dict[str, Any]], set[tuple[str, str, str]], set[str]]:
matched_fields: list[dict[str, Any]] | None = None,
match_kind: str = "object",
) -> 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}"
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() want: set[tuple[str, str, str]] = set()
need_ts: set[str] = set() need_ts: set[str] = set()
for mf in matched_fields: for mf in matched_fields:
@@ -283,6 +437,33 @@ def format_object_tree(
selected.append(f) selected.append(f)
elif f.get("kind") == "TabularSection" and f.get("name") in need_ts: elif f.get("kind") == "TabularSection" and f.get("name") in need_ts:
selected.append(f) 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: if not selected:
lines.append(" (совпавшие реквизиты не найдены в структуре)") lines.append(" (совпавшие реквизиты не найдены в структуре)")
return "\n".join(lines) return "\n".join(lines)
+7 -3
View File
@@ -10,6 +10,7 @@ from typing import Any
from . import db as dbmod from . import db as dbmod
from .config import cache_dir from .config import cache_dir
from .object_view import annotate_search_hits
def _fts_query(raw: str) -> str: def _fts_query(raw: str) -> str:
@@ -266,7 +267,8 @@ def search_objects(
limit: int = 30, limit: int = 30,
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
exact_sql = """ 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 FROM objects
WHERE (name = ? OR full_name = ? OR full_name LIKE ?) WHERE (name = ? OR full_name = ? OR full_name LIKE ?)
""" """
@@ -286,6 +288,7 @@ def search_objects(
fetch_n = max(limit * 5, 50) fetch_n = max(limit * 5, 50)
sql = """ sql = """
SELECT o.config, o.full_name, o.object_type, o.name, o.synonym, o.comment, o.path, 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, snippet(objects_fts, 0, '[', ']', '', 12) AS snip,
bm25(objects_fts, 10.0, 5.0, 2.0, 1.0) AS score bm25(objects_fts, 10.0, 5.0, 2.0, 1.0) AS score
FROM objects_fts FROM objects_fts
@@ -307,7 +310,8 @@ def search_objects(
except sqlite3.OperationalError: except sqlite3.OperationalError:
like = f"%{query}%" like = f"%{query}%"
sql2 = """ 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 FROM objects
WHERE full_name LIKE ? OR synonym LIKE ? OR comment LIKE ? OR name LIKE ? WHERE full_name LIKE ? OR synonym LIKE ? OR comment LIKE ? OR name LIKE ?
""" """
@@ -343,7 +347,7 @@ def search_objects(
merged.sort( merged.sort(
key=lambda x: (x.get("_boost", 9), x.get("score") if x.get("score") is not None else 0) 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( def search_modules(
+18 -23
View File
@@ -54,9 +54,11 @@ from index1c.search import ( # noqa: E402
search_objects, search_objects,
) )
from index1c.object_view import ( # noqa: E402 from index1c.object_view import ( # noqa: E402
enrich_matched_fields,
find_structure_matches, find_structure_matches,
format_object_tree, format_object_tree,
load_object_structure, load_object_structure,
load_object_structure_for_row,
) )
@@ -91,7 +93,21 @@ def cmd_search(args: argparse.Namespace) -> int:
for r in rows: for r in rows:
syn = f"{r['synonym']}" if r.get("synonym") else "" syn = f"{r['synonym']}" if r.get("synonym") else ""
print(f"- [{r['config']}] `{r['full_name']}`{syn}") 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']}") print(f" {r['snip']}")
return 0 return 0
finally: finally:
@@ -283,29 +299,8 @@ def cmd_object(args: argparse.Namespace) -> int:
if i: if i:
print() print()
obj = load_object_structure(m) obj = load_object_structure(m)
# подтянуть FillChecking/типы из свежего XML в matched_fields
matched = list(m.get("matched_fields") or []) matched = list(m.get("matched_fields") or [])
if matched and obj.get("fields"): enrich_matched_fields(obj, matched)
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")
# точное совпадение объекта (score 0) → clear без дерева; # точное совпадение объекта (score 0) → clear без дерева;
# иначе при найденных реквизитах — clear только по ним # иначе при найденных реквизитах — clear только по ним
kind = m.get("match_kind") or "object" kind = m.get("match_kind") or "object"