Files
index/index1c/search.py
T
mihailkudravcev 404659a545 Release 0.3.2: YAML config, default_baseconfs, autodiscover.
Конфиг утилиты переведён на YAML; default_baseconfs для reindex без -b;
автопоиск config.local.yml/.yaml/.json; обобщённая документация.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 11:09:51 +03:00

586 lines
20 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.
"""Поиск по единому index.sqlite проекта."""
from __future__ import annotations
import json
import re
import sqlite3
from pathlib import Path
from typing import Any
from . import db as dbmod
from .config import cache_dir
def _fts_query(raw: str) -> str:
tokens = re.findall(r"[\wА-Яа-яЁё]+", raw, flags=re.UNICODE)
if not tokens:
return raw
return " ".join(f"{t}*" for t in tokens)
def open_index(project_root: Path, *, readonly: bool = True) -> sqlite3.Connection:
path = dbmod.global_db_path(cache_dir(project_root))
if not path.is_file():
raise FileNotFoundError(
f"Индекс не найден: {path}. Запустите: python index_1c.py reindex --full"
)
# мягкая миграция схемы (например 2→3: таблица register_records)
probe = dbmod.connect(path, readonly=True)
need_migrate = False
try:
row = probe.execute(
"SELECT value FROM meta WHERE key='schema_version'"
).fetchone()
ver = int(row["value"]) if row else 0
if ver < dbmod.SCHEMA_VERSION:
need_migrate = True
elif ver == dbmod.SCHEMA_VERSION:
t = probe.execute(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='register_records'"
).fetchone()
if t is None:
need_migrate = True
finally:
probe.close()
if need_migrate:
wconn = dbmod.connect(path, readonly=False)
try:
dbmod.init_db(wconn)
finally:
wconn.close()
return dbmod.connect(path, readonly=readonly)
REGISTER_OBJECT_TYPES = frozenset(
{
"AccumulationRegister",
"InformationRegister",
"AccountingRegister",
"CalculationRegister",
}
)
def _resolve_movement_targets(
conn: sqlite3.Connection,
target: str,
*,
configs: list[str] | None = None,
object_type: str | None = None,
) -> list[dict[str, Any]]:
"""Найти объект(ы) для movements: документ или регистр."""
sql = """
SELECT config, full_name, object_type, name, synonym
FROM objects
WHERE (full_name = ? OR name = ?)
"""
params: list[Any] = [target, target]
if configs:
sql += f" AND config IN ({','.join('?' * len(configs))})"
params.extend(configs)
if object_type:
sql += " AND object_type = ?"
params.append(object_type)
sql += " ORDER BY config, full_name"
rows = [dict(r) for r in conn.execute(sql, params)]
if rows:
return rows
# Document.X / AccumulationRegister.X без точного совпадения типа
if "." in target:
kind, name = target.split(".", 1)
sql = """
SELECT config, full_name, object_type, name, synonym
FROM objects
WHERE object_type = ? AND name = ?
"""
params = [kind, name]
if configs:
sql += f" AND config IN ({','.join('?' * len(configs))})"
params.extend(configs)
sql += " ORDER BY config, full_name"
rows = [dict(r) for r in conn.execute(sql, params)]
if rows:
return rows
sql = """
SELECT config, full_name, object_type, name, synonym
FROM objects
WHERE full_name LIKE ?
"""
params = [f"%.{target}"]
if configs:
sql += f" AND config IN ({','.join('?' * len(configs))})"
params.extend(configs)
if object_type:
sql += " AND object_type = ?"
params.append(object_type)
sql += " ORDER BY config, full_name LIMIT 20"
return [dict(r) for r in conn.execute(sql, params)]
def find_movements(
conn: sqlite3.Connection,
target: str,
*,
configs: list[str] | None = None,
object_type: str | None = None,
limit: int = 200,
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
"""Связи регистратор ↔ регистры движений (RegisterRecords).
Возвращает ``(resolved_objects, rows)``, где каждая строка:
- direction: ``document_to_registers`` | ``register_to_documents``
- config, from_full_name, to_full_name
- from_synonym / to_synonym (если есть в индексе)
``limit`` — максимум связей **на каждый** найденный объект×конфигурацию.
"""
resolved = _resolve_movement_targets(
conn, target, configs=configs, object_type=object_type
)
# оставить документы и регистры
resolved = [
r
for r in resolved
if r["object_type"] == "Document" or r["object_type"] in REGISTER_OBJECT_TYPES
]
if not resolved:
return [], []
has_table = (
conn.execute(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='register_records'"
).fetchone()
is not None
)
# кэш синонимов
def _synonyms(full_names: set[str], cfg: str) -> dict[str, str]:
if not full_names:
return {}
names = sorted(full_names)
ph = ",".join("?" * len(names))
return {
r["full_name"]: r["synonym"] or ""
for r in conn.execute(
f"SELECT full_name, synonym FROM objects WHERE config=? AND full_name IN ({ph})",
[cfg, *names],
)
}
out: list[dict[str, Any]] = []
for obj in resolved:
cfg = obj["config"]
full = obj["full_name"]
if obj["object_type"] == "Document":
if has_table:
links = [
r["register_full_name"]
for r in conn.execute(
"""
SELECT register_full_name FROM register_records
WHERE config=? AND document_full_name=?
ORDER BY register_full_name
LIMIT ?
""",
(cfg, full, limit),
)
]
else:
row = conn.execute(
"SELECT json FROM objects WHERE config=? AND full_name=?",
(cfg, full),
).fetchone()
links = []
if row:
try:
links = list(json.loads(row["json"]).get("register_records") or [])
except json.JSONDecodeError:
links = []
links = sorted(links)[:limit]
syns = _synonyms(set(links), cfg)
for reg in links:
out.append(
{
"direction": "document_to_registers",
"config": cfg,
"from_full_name": full,
"from_synonym": obj.get("synonym") or "",
"to_full_name": reg,
"to_synonym": syns.get(reg, ""),
}
)
else:
# регистр → документы-регистраторы
if has_table:
docs = [
r["document_full_name"]
for r in conn.execute(
"""
SELECT document_full_name FROM register_records
WHERE config=? AND register_full_name=?
ORDER BY document_full_name
LIMIT ?
""",
(cfg, full, limit),
)
]
else:
docs = []
for r in conn.execute(
"SELECT full_name, json FROM objects WHERE config=? AND object_type='Document'",
(cfg,),
):
try:
regs = json.loads(r["json"]).get("register_records") or []
except json.JSONDecodeError:
continue
if full in regs:
docs.append(r["full_name"])
docs = sorted(docs)[:limit]
syns = _synonyms(set(docs), cfg)
for doc in docs:
out.append(
{
"direction": "register_to_documents",
"config": cfg,
"from_full_name": full,
"from_synonym": obj.get("synonym") or "",
"to_full_name": doc,
"to_synonym": syns.get(doc, ""),
}
)
return resolved, out
def search_objects(
conn: sqlite3.Connection,
query: str,
*,
configs: list[str] | None = None,
object_type: str | None = None,
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
FROM objects
WHERE (name = ? OR full_name = ? OR full_name LIKE ?)
"""
exact_params: list[Any] = [query, query, f"%.{query}"]
if configs:
exact_sql += f" AND config IN ({','.join('?' * len(configs))})"
exact_params.extend(configs)
if object_type:
exact_sql += " AND object_type = ?"
exact_params.append(object_type)
exact_sql += " LIMIT ?"
exact_params.append(limit)
exact = [dict(r) for r in conn.execute(exact_sql, exact_params)]
seen = {(r["config"], r["full_name"]) for r in exact}
fts = _fts_query(query)
fetch_n = max(limit * 5, 50)
sql = """
SELECT o.config, o.full_name, o.object_type, o.name, o.synonym, o.comment, o.path,
snippet(objects_fts, 0, '[', ']', '', 12) AS snip,
bm25(objects_fts, 10.0, 5.0, 2.0, 1.0) AS score
FROM objects_fts
JOIN objects o ON o.id = objects_fts.object_id
WHERE objects_fts MATCH ?
"""
params: list[Any] = [fts]
if configs:
sql += f" AND o.config IN ({','.join('?' * len(configs))})"
params.extend(configs)
if object_type:
sql += " AND o.object_type = ?"
params.append(object_type)
sql += " ORDER BY score LIMIT ?"
params.append(fetch_n)
try:
rows = [dict(r) for r in conn.execute(sql, params)]
except sqlite3.OperationalError:
like = f"%{query}%"
sql2 = """
SELECT config, full_name, object_type, name, synonym, comment, path, '' AS snip, 0 AS score
FROM objects
WHERE full_name LIKE ? OR synonym LIKE ? OR comment LIKE ? OR name LIKE ?
"""
params2: list[Any] = [like, like, like, like]
if configs:
sql2 += f" AND config IN ({','.join('?' * len(configs))})"
params2.extend(configs)
if object_type:
sql2 += " AND object_type = ?"
params2.append(object_type)
sql2 += " LIMIT ?"
params2.append(fetch_n)
rows = [dict(r) for r in conn.execute(sql2, params2)]
merged = list(exact)
for r in rows:
key = (r["config"], r["full_name"])
if key in seen:
continue
seen.add(key)
merged.append(r)
q_cf = query.casefold()
for r in merged:
name_cf = (r.get("name") or "").casefold()
full_cf = (r.get("full_name") or "").casefold()
if name_cf == q_cf or full_cf == q_cf or full_cf.endswith("." + q_cf):
r["_boost"] = 0
elif q_cf in name_cf or q_cf in full_cf:
r["_boost"] = 1
else:
r["_boost"] = 2
merged.sort(
key=lambda x: (x.get("_boost", 9), x.get("score") if x.get("score") is not None else 0)
)
return merged[:limit]
def search_modules(
conn: sqlite3.Connection,
query: str,
*,
configs: list[str] | None = None,
limit: int = 30,
names_only: bool = False,
) -> list[dict[str, Any]]:
fts = _fts_query(query)
snip_col = "'' AS snip" if names_only else "snippet(modules_fts, 4, '[', ']', '', 16) AS snip"
sql = f"""
SELECT m.config, m.rel_path, m.owner, m.module_kind, m.symbols, m.line_count,
o.synonym AS owner_synonym,
{snip_col},
bm25(modules_fts, 2.0, 5.0, 2.0, 8.0, 1.0) AS score
FROM modules_fts
JOIN modules m ON m.id = modules_fts.module_id
LEFT JOIN objects o ON o.config = m.config AND o.full_name = m.owner
WHERE modules_fts MATCH ?
"""
params: list[Any] = [fts]
if configs:
sql += f" AND m.config IN ({','.join('?' * len(configs))})"
params.extend(configs)
sql += " ORDER BY score LIMIT ?"
params.append(limit)
try:
return [dict(r) for r in conn.execute(sql, params)]
except sqlite3.OperationalError:
like = f"%{query}%"
sql2 = """
SELECT m.config, m.rel_path, m.owner, m.module_kind, m.symbols, m.line_count,
o.synonym AS owner_synonym, '' AS snip, 0 AS score
FROM modules m
LEFT JOIN objects o ON o.config = m.config AND o.full_name = m.owner
WHERE m.rel_path LIKE ? OR m.owner LIKE ? OR m.symbols LIKE ?
"""
params2: list[Any] = [like, like, like]
if configs:
sql2 += f" AND m.config IN ({','.join('?' * len(configs))})"
params2.extend(configs)
sql2 += " LIMIT ?"
params2.append(limit)
return [dict(r) for r in conn.execute(sql2, params2)]
def find_refs_to(
conn: sqlite3.Connection,
target: str,
*,
configs: list[str] | None = None,
limit: int = 100,
) -> tuple[list[dict[str, Any]], dict[str, int]]:
"""Обратные ссылки на объект.
Возвращает (строки, totals_by_config).
При поиске по нескольким конфигурациям ``limit`` — максимум **на каждую**
конфигурацию (чтобы одна baseconf не вытесняла другую из-за ORDER BY + LIMIT).
"""
kind = ""
name = target
if "." in target:
kind, name = target.split(".", 1)
kind_ref = kind
if kind and not kind.endswith("Ref"):
if kind in {
"Catalog",
"Document",
"Enum",
"ExchangePlan",
"BusinessProcess",
"Task",
"ChartOfCharacteristicTypes",
"ChartOfAccounts",
"ChartOfCalculationTypes",
}:
kind_ref = kind + "Ref"
where = "r.to_name = ?"
params: list[Any] = [name]
if kind_ref:
where += " AND (r.to_kind = ? OR r.to_kind = ?)"
params.extend([kind_ref, kind])
if configs:
where += f" AND r.config IN ({','.join('?' * len(configs))})"
params.extend(configs)
# Итоги по конфигурациям (без LIMIT)
totals: dict[str, int] = {
r["config"]: int(r["n"])
for r in conn.execute(
f"SELECT r.config, COUNT(*) AS n FROM refs r WHERE {where} GROUP BY r.config",
params,
)
}
# limit на каждую конфигурацию — иначе алфавитный ORDER BY отрезает остальные
sql = f"""
WITH matched AS (
SELECT
r.config,
r.from_full_name,
r.from_field,
r.to_kind,
r.to_name,
f.types AS field_types,
f.refs_json AS field_refs_json,
ROW_NUMBER() OVER (
PARTITION BY r.config
ORDER BY r.from_full_name, r.from_field
) AS rn
FROM refs r
LEFT JOIN objects o
ON o.config = r.config AND o.full_name = r.from_full_name
LEFT JOIN fields f
ON f.object_id = o.id
AND (
(COALESCE(f.tabular_section, '') = '' AND f.name = r.from_field)
OR (f.tabular_section || '.' || f.name = r.from_field)
)
WHERE {where}
)
SELECT *
FROM matched
WHERE rn <= ?
ORDER BY config, from_full_name, from_field
"""
rows = [dict(r) for r in conn.execute(sql, [*params, limit])]
for row in rows:
types_s = row.get("field_types") or ""
refs_raw = row.get("field_refs_json") or "[]"
try:
refs_list = json.loads(refs_raw) if refs_raw else []
except json.JSONDecodeError:
refs_list = []
type_parts = [t.strip() for t in types_s.split(",") if t.strip()]
# составной: несколько cfg:* или несколько ссылок в типе
composite = len(refs_list) > 1 or len(type_parts) > 1
row["is_composite"] = composite
others: list[str] = []
target_full = f"{row['to_kind']}.{row['to_name']}"
for ref in refs_list:
label = f"{ref.get('kind')}.{ref.get('name')}"
if label != target_full and label not in others:
others.append(label)
for t in type_parts:
# cfg:DocumentRef.X → DocumentRef.X
short = t[4:] if t.startswith("cfg:") else t
if short != target_full and short not in others and "Ref." in short:
others.append(short)
row["other_types"] = others
return rows, totals
def get_object(
conn: sqlite3.Connection,
name: str,
*,
configs: list[str] | None = None,
) -> dict[str, Any] | None:
sql = "SELECT config, json FROM objects WHERE full_name = ? OR name = ?"
params: list[Any] = [name, name]
if configs:
sql += f" AND config IN ({','.join('?' * len(configs))})"
params.extend(configs)
sql += " LIMIT 1"
row = conn.execute(sql, params).fetchone()
if not row:
sql = "SELECT config, json FROM objects WHERE full_name LIKE ?"
params = [f"%.{name}"]
if configs:
sql += f" AND config IN ({','.join('?' * len(configs))})"
params.extend(configs)
sql += " LIMIT 1"
row = conn.execute(sql, params).fetchone()
if not row:
return None
data = json.loads(row["json"])
data["_config"] = row["config"]
return data
def get_module(
conn: sqlite3.Connection,
path_or_owner: str,
*,
configs: list[str] | None = None,
) -> list[dict[str, Any]]:
sql = """
SELECT config, rel_path, owner, module_kind, symbols, line_count, size
FROM modules
WHERE rel_path = ? OR rel_path LIKE ? OR owner = ? OR owner LIKE ?
"""
like = f"%{path_or_owner}%"
params: list[Any] = [path_or_owner, like, path_or_owner, like]
if configs:
sql += f" AND config IN ({','.join('?' * len(configs))})"
params.extend(configs)
sql += " ORDER BY config, rel_path LIMIT 50"
return [dict(r) for r in conn.execute(sql, params)]
def list_indexed_configs(conn_or_cache) -> list[str]:
"""Совместимость: Connection или Path cache_root."""
if isinstance(conn_or_cache, Path):
db_path = dbmod.global_db_path(conn_or_cache)
if not db_path.is_file():
# legacy: подкаталоги с manifest
return [
p.name
for p in sorted(conn_or_cache.iterdir())
if p.is_dir() and (p / "manifest.json").is_file()
]
conn = dbmod.connect(db_path, readonly=True)
try:
return [c["name"] for c in dbmod.list_configs(conn)]
finally:
conn.close()
return [c["name"] for c in dbmod.list_configs(conn_or_cache)]
# --- legacy wrappers (per-config sqlite) ---
def search_sqlite(db_path: Path, query: str, *, limit: int = 30, object_type: str | None = None):
"""Устарело: ищет в per-config sqlite; для нового индекса используйте search_objects."""
if not db_path.is_file():
return []
# если передали index.sqlite — открыть как глобальный
if db_path.name == "index.sqlite":
conn = dbmod.connect(db_path, readonly=True)
try:
return search_objects(conn, query, object_type=object_type, limit=limit)
finally:
conn.close()
return []