"""Поиск по единому 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 []