"""Единый SQLite-индекс проекта (все конфигурации 1С).""" from __future__ import annotations import json import sqlite3 from datetime import datetime, timezone from pathlib import Path from typing import Any, Iterable SCHEMA_VERSION = 3 DDL = """ PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; CREATE TABLE IF NOT EXISTS meta ( key TEXT PRIMARY KEY, value TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS configs ( name TEXT PRIMARY KEY, src TEXT NOT NULL, objects INTEGER NOT NULL DEFAULT 0, fields INTEGER NOT NULL DEFAULT 0, refs INTEGER NOT NULL DEFAULT 0, modules INTEGER NOT NULL DEFAULT 0, updated_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS files ( id INTEGER PRIMARY KEY, config TEXT NOT NULL, rel_path TEXT NOT NULL, kind TEXT NOT NULL, -- meta_xml | module_bsl mtime_ns INTEGER NOT NULL, size INTEGER NOT NULL, sha1 TEXT, object_type TEXT, UNIQUE(config, rel_path) ); CREATE TABLE IF NOT EXISTS objects ( id INTEGER PRIMARY KEY, config TEXT NOT NULL, object_type TEXT NOT NULL, name TEXT NOT NULL, full_name TEXT NOT NULL, synonym TEXT, comment TEXT, tooltip TEXT, explanation TEXT, uuid TEXT, path TEXT, file_id INTEGER, json TEXT NOT NULL, UNIQUE(config, full_name), FOREIGN KEY(file_id) REFERENCES files(id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS fields ( id INTEGER PRIMARY KEY, object_id INTEGER NOT NULL, config TEXT NOT NULL, kind TEXT, name TEXT, synonym TEXT, comment TEXT, types TEXT, tabular_section TEXT, refs_json TEXT, FOREIGN KEY(object_id) REFERENCES objects(id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS refs ( id INTEGER PRIMARY KEY, config TEXT NOT NULL, from_full_name TEXT NOT NULL, from_field TEXT NOT NULL, to_kind TEXT NOT NULL, to_name TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS register_records ( id INTEGER PRIMARY KEY, config TEXT NOT NULL, document_full_name TEXT NOT NULL, register_full_name TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS modules ( id INTEGER PRIMARY KEY, config TEXT NOT NULL, rel_path TEXT NOT NULL, owner TEXT, module_kind TEXT, symbols TEXT, line_count INTEGER, size INTEGER, file_id INTEGER, UNIQUE(config, rel_path), FOREIGN KEY(file_id) REFERENCES files(id) ON DELETE CASCADE ); CREATE VIRTUAL TABLE IF NOT EXISTS objects_fts USING fts5( full_name, synonym, comment, fields_text, object_id UNINDEXED, config UNINDEXED, tokenize='unicode61' ); CREATE VIRTUAL TABLE IF NOT EXISTS modules_fts USING fts5( rel_path, owner, module_kind, symbols, body, module_id UNINDEXED, config UNINDEXED, tokenize='unicode61' ); CREATE INDEX IF NOT EXISTS idx_objects_config_type ON objects(config, object_type); CREATE INDEX IF NOT EXISTS idx_objects_name ON objects(name); CREATE INDEX IF NOT EXISTS idx_fields_object ON fields(object_id); CREATE INDEX IF NOT EXISTS idx_refs_to ON refs(to_kind, to_name); CREATE INDEX IF NOT EXISTS idx_refs_from ON refs(from_full_name); CREATE INDEX IF NOT EXISTS idx_refs_config ON refs(config); CREATE INDEX IF NOT EXISTS idx_regrec_register ON register_records(register_full_name); CREATE INDEX IF NOT EXISTS idx_regrec_document ON register_records(document_full_name); CREATE INDEX IF NOT EXISTS idx_regrec_config ON register_records(config); CREATE INDEX IF NOT EXISTS idx_modules_config ON modules(config); CREATE INDEX IF NOT EXISTS idx_modules_owner ON modules(owner); CREATE INDEX IF NOT EXISTS idx_files_config ON files(config); """ def _table_exists(conn: sqlite3.Connection, name: str) -> bool: row = conn.execute( "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (name,), ).fetchone() return row is not None def backfill_register_records(conn: sqlite3.Connection) -> int: """Заполнить register_records из JSON объектов (документы → RegisterRecords).""" conn.execute("DELETE FROM register_records") n = 0 rows = conn.execute( "SELECT config, full_name, json FROM objects WHERE object_type='Document'" ).fetchall() batch: list[tuple[str, str, str]] = [] for r in rows: try: data = json.loads(r["json"]) except json.JSONDecodeError: continue for reg in data.get("register_records") or []: if not reg or not isinstance(reg, str): continue batch.append((r["config"], r["full_name"], reg)) n += 1 if len(batch) >= 500: conn.executemany( """ INSERT INTO register_records(config, document_full_name, register_full_name) VALUES(?,?,?) """, batch, ) batch.clear() if batch: conn.executemany( """ INSERT INTO register_records(config, document_full_name, register_full_name) VALUES(?,?,?) """, batch, ) return n def migrate_schema(conn: sqlite3.Connection, from_version: int) -> None: """Мягкая миграция без wipe БД.""" if from_version < 3: conn.executescript( """ CREATE TABLE IF NOT EXISTS register_records ( id INTEGER PRIMARY KEY, config TEXT NOT NULL, document_full_name TEXT NOT NULL, register_full_name TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_regrec_register ON register_records(register_full_name); CREATE INDEX IF NOT EXISTS idx_regrec_document ON register_records(document_full_name); CREATE INDEX IF NOT EXISTS idx_regrec_config ON register_records(config); """ ) backfill_register_records(conn) conn.execute( "INSERT OR REPLACE INTO meta(key, value) VALUES('schema_version', ?)", (str(SCHEMA_VERSION),), ) def utcnow() -> str: return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") def global_db_path(cache_root: Path) -> Path: return cache_root / "index.sqlite" def connect(db_path: Path, *, readonly: bool = False) -> sqlite3.Connection: db_path.parent.mkdir(parents=True, exist_ok=True) if readonly and db_path.is_file(): uri = f"file:{db_path.resolve()}?mode=ro" conn = sqlite3.connect(uri, uri=True) else: conn = sqlite3.connect(db_path) conn.row_factory = sqlite3.Row conn.execute("PRAGMA foreign_keys=ON") return conn def init_db(conn: sqlite3.Connection) -> None: conn.executescript(DDL) row = conn.execute("SELECT value FROM meta WHERE key='schema_version'").fetchone() if row is None: conn.execute( "INSERT INTO meta(key, value) VALUES('schema_version', ?)", (str(SCHEMA_VERSION),), ) conn.execute( "INSERT OR REPLACE INTO meta(key, value) VALUES('created_at', ?)", (utcnow(),), ) else: ver = int(row["value"]) if ver > SCHEMA_VERSION: raise RuntimeError( f"Индекс новее утилиты (схема {ver}, утилита {SCHEMA_VERSION}). " "Обновите утилиту index до более новой версии." ) if ver < SCHEMA_VERSION: migrate_schema(conn, ver) elif not _table_exists(conn, "register_records"): # schema_version=3, но таблица потеряна — восстановить migrate_schema(conn, 2) conn.commit() def reset_db(db_path: Path) -> sqlite3.Connection: if db_path.exists(): db_path.unlink() for suffix in ("-wal", "-shm"): p = Path(str(db_path) + suffix) if p.exists(): p.unlink() conn = connect(db_path) init_db(conn) return conn def ensure_db(db_path: Path) -> sqlite3.Connection: conn = connect(db_path) try: init_db(conn) except RuntimeError: conn.close() raise return conn def get_file_index(conn: sqlite3.Connection, config: str | None = None) -> dict[str, dict[str, Any]]: """rel_path → {mtime_ns, size, id, kind} для инкремента.""" if config: rows = conn.execute( "SELECT id, rel_path, kind, mtime_ns, size FROM files WHERE config=?", (config,), ).fetchall() else: rows = conn.execute( "SELECT id, config, rel_path, kind, mtime_ns, size FROM files" ).fetchall() out: dict[str, dict[str, Any]] = {} for r in rows: key = r["rel_path"] if config else f"{r['config']}:{r['rel_path']}" out[key] = { "id": r["id"], "kind": r["kind"], "mtime_ns": r["mtime_ns"], "size": r["size"], } return out def delete_file_cascade(conn: sqlite3.Connection, file_id: int) -> None: """Удалить файл и связанные объекты/модули/FTS.""" obj = conn.execute( "SELECT id FROM objects WHERE file_id=?", (file_id,) ).fetchone() if obj: oid = obj["id"] conn.execute("DELETE FROM objects_fts WHERE object_id=?", (oid,)) conn.execute("DELETE FROM fields WHERE object_id=?", (oid,)) row = conn.execute( "SELECT config, full_name FROM objects WHERE id=?", (oid,) ).fetchone() if row: conn.execute( "DELETE FROM refs WHERE config=? AND from_full_name=?", (row["config"], row["full_name"]), ) if _table_exists(conn, "register_records"): conn.execute( "DELETE FROM register_records WHERE config=? AND document_full_name=?", (row["config"], row["full_name"]), ) conn.execute("DELETE FROM objects WHERE id=?", (oid,)) mod = conn.execute( "SELECT id FROM modules WHERE file_id=?", (file_id,) ).fetchone() if mod: mid = mod["id"] conn.execute("DELETE FROM modules_fts WHERE module_id=?", (mid,)) conn.execute("DELETE FROM modules WHERE id=?", (mid,)) conn.execute("DELETE FROM files WHERE id=?", (file_id,)) def delete_config(conn: sqlite3.Connection, config: str) -> None: oids = [ r["id"] for r in conn.execute("SELECT id FROM objects WHERE config=?", (config,)) ] for oid in oids: conn.execute("DELETE FROM objects_fts WHERE object_id=?", (oid,)) mids = [ r["id"] for r in conn.execute("SELECT id FROM modules WHERE config=?", (config,)) ] for mid in mids: conn.execute("DELETE FROM modules_fts WHERE module_id=?", (mid,)) conn.execute("DELETE FROM fields WHERE config=?", (config,)) conn.execute("DELETE FROM refs WHERE config=?", (config,)) if _table_exists(conn, "register_records"): conn.execute("DELETE FROM register_records WHERE config=?", (config,)) conn.execute("DELETE FROM objects WHERE config=?", (config,)) conn.execute("DELETE FROM modules WHERE config=?", (config,)) conn.execute("DELETE FROM files WHERE config=?", (config,)) conn.execute("DELETE FROM configs WHERE name=?", (config,)) def upsert_file( conn: sqlite3.Connection, *, config: str, rel_path: str, kind: str, mtime_ns: int, size: int, object_type: str | None = None, sha1: str | None = None, ) -> int: conn.execute( """ INSERT INTO files(config, rel_path, kind, mtime_ns, size, sha1, object_type) VALUES(?,?,?,?,?,?,?) ON CONFLICT(config, rel_path) DO UPDATE SET kind=excluded.kind, mtime_ns=excluded.mtime_ns, size=excluded.size, sha1=excluded.sha1, object_type=excluded.object_type """, (config, rel_path, kind, mtime_ns, size, sha1, object_type), ) row = conn.execute( "SELECT id FROM files WHERE config=? AND rel_path=?", (config, rel_path), ).fetchone() assert row is not None return int(row["id"]) def upsert_object( conn: sqlite3.Connection, obj: dict[str, Any], *, file_id: int, fields_text: str, ) -> int: config = obj["config"] full_name = obj["full_name"] old = conn.execute( "SELECT id FROM objects WHERE config=? AND full_name=?", (config, full_name), ).fetchone() if old: oid = int(old["id"]) conn.execute("DELETE FROM objects_fts WHERE object_id=?", (oid,)) conn.execute("DELETE FROM fields WHERE object_id=?", (oid,)) conn.execute( "DELETE FROM refs WHERE config=? AND from_full_name=?", (config, full_name), ) if _table_exists(conn, "register_records"): conn.execute( "DELETE FROM register_records WHERE config=? AND document_full_name=?", (config, full_name), ) conn.execute( """ UPDATE objects SET object_type=?, name=?, synonym=?, comment=?, tooltip=?, explanation=?, uuid=?, path=?, file_id=?, json=? WHERE id=? """, ( obj["object_type"], obj["name"], obj.get("synonym"), obj.get("comment"), obj.get("tooltip"), obj.get("explanation"), obj.get("uuid"), obj.get("path"), file_id, json.dumps(obj, ensure_ascii=False), oid, ), ) else: cur = conn.execute( """ INSERT INTO objects(config, object_type, name, full_name, synonym, comment, tooltip, explanation, uuid, path, file_id, json) VALUES(?,?,?,?,?,?,?,?,?,?,?,?) """, ( config, obj["object_type"], obj["name"], full_name, obj.get("synonym"), obj.get("comment"), obj.get("tooltip"), obj.get("explanation"), obj.get("uuid"), obj.get("path"), file_id, json.dumps(obj, ensure_ascii=False), ), ) oid = int(cur.lastrowid) conn.execute( """ INSERT INTO objects_fts(full_name, synonym, comment, fields_text, object_id, config) VALUES(?,?,?,?,?,?) """, ( full_name, obj.get("synonym") or "", obj.get("comment") or "", fields_text, oid, config, ), ) for f in obj.get("fields") or []: conn.execute( """ INSERT INTO fields(object_id, config, kind, name, synonym, comment, types, tabular_section, refs_json) VALUES(?,?,?,?,?,?,?,?,?) """, ( oid, config, f.get("kind"), f.get("name"), f.get("synonym"), f.get("comment"), ", ".join(f.get("types") or []), f.get("tabular_section") or "", json.dumps(f.get("refs") or [], ensure_ascii=False), ), ) for r in f.get("refs") or []: loc = f"{f['tabular_section']}." if f.get("tabular_section") else "" conn.execute( """ INSERT INTO refs(config, from_full_name, from_field, to_kind, to_name) VALUES(?,?,?,?,?) """, ( config, full_name, f"{loc}{f.get('name')}", r.get("kind"), r.get("name"), ), ) if _table_exists(conn, "register_records"): for reg in obj.get("register_records") or []: if not reg or not isinstance(reg, str): continue conn.execute( """ INSERT INTO register_records(config, document_full_name, register_full_name) VALUES(?,?,?) """, (config, full_name, reg), ) return oid def upsert_module( conn: sqlite3.Connection, *, config: str, rel_path: str, owner: str, module_kind: str, symbols: list[str], body: str, line_count: int, size: int, file_id: int, ) -> int: sym_text = "\n".join(symbols) old = conn.execute( "SELECT id FROM modules WHERE config=? AND rel_path=?", (config, rel_path), ).fetchone() if old: mid = int(old["id"]) conn.execute("DELETE FROM modules_fts WHERE module_id=?", (mid,)) conn.execute( """ UPDATE modules SET owner=?, module_kind=?, symbols=?, line_count=?, size=?, file_id=? WHERE id=? """, (owner, module_kind, sym_text, line_count, size, file_id, mid), ) else: cur = conn.execute( """ INSERT INTO modules(config, rel_path, owner, module_kind, symbols, line_count, size, file_id) VALUES(?,?,?,?,?,?,?,?) """, (config, rel_path, owner, module_kind, sym_text, line_count, size, file_id), ) mid = int(cur.lastrowid) conn.execute( """ INSERT INTO modules_fts(rel_path, owner, module_kind, symbols, body, module_id, config) VALUES(?,?,?,?,?,?,?) """, (rel_path, owner, module_kind, sym_text, body, mid, config), ) return mid def refresh_config_stats(conn: sqlite3.Connection, config: str, src: str) -> dict[str, Any]: objects = conn.execute( "SELECT COUNT(*) AS n FROM objects WHERE config=?", (config,) ).fetchone()["n"] fields = conn.execute( "SELECT COUNT(*) AS n FROM fields WHERE config=?", (config,) ).fetchone()["n"] refs = conn.execute( "SELECT COUNT(*) AS n FROM refs WHERE config=?", (config,) ).fetchone()["n"] modules = conn.execute( "SELECT COUNT(*) AS n FROM modules WHERE config=?", (config,) ).fetchone()["n"] stats = { "name": config, "src": src, "objects": objects, "fields": fields, "refs": refs, "modules": modules, "updated_at": utcnow(), } conn.execute( """ INSERT INTO configs(name, src, objects, fields, refs, modules, updated_at) VALUES(?,?,?,?,?,?,?) ON CONFLICT(name) DO UPDATE SET src=excluded.src, objects=excluded.objects, fields=excluded.fields, refs=excluded.refs, modules=excluded.modules, updated_at=excluded.updated_at """, ( stats["name"], stats["src"], stats["objects"], stats["fields"], stats["refs"], stats["modules"], stats["updated_at"], ), ) return stats def list_configs(conn: sqlite3.Connection) -> list[dict[str, Any]]: return [dict(r) for r in conn.execute("SELECT * FROM configs ORDER BY name")] def set_meta(conn: sqlite3.Connection, key: str, value: str) -> None: conn.execute( "INSERT OR REPLACE INTO meta(key, value) VALUES(?, ?)", (key, value), )