diff --git a/console/core/app.py b/console/core/app.py index b43d5d8..784adae 100644 --- a/console/core/app.py +++ b/console/core/app.py @@ -193,3 +193,4 @@ class App: pass finally: self.renderer.cleanup() + self.db.close() diff --git a/console/db.py b/console/db.py index cee7676..d691a4c 100644 --- a/console/db.py +++ b/console/db.py @@ -17,40 +17,55 @@ class Database: def __init__(self, db_path: Optional[str] = None): self.db_path = db_path or DB_PATH + self._conn: Optional[sqlite3.Connection] = None + self._cache: dict = {} # ------------------------------------------------------------------ # Private helpers # ------------------------------------------------------------------ def _connect(self) -> sqlite3.Connection: - """Open a connection with row_factory set to sqlite3.Row.""" - conn = sqlite3.connect(self.db_path) - conn.row_factory = sqlite3.Row - return conn + """Return persistent connection (created once, reused).""" + if self._conn is None: + self._conn = sqlite3.connect(self.db_path) + self._conn.row_factory = sqlite3.Row + self._conn.execute("PRAGMA journal_mode=WAL") + self._conn.execute("PRAGMA cache_size=-8000") # 8MB cache + self._conn.execute("PRAGMA mmap_size=67108864") # 64MB mmap + return self._conn + + def close(self): + """Close the persistent connection.""" + if self._conn is not None: + self._conn.close() + self._conn = None def _query(self, sql: str, params: tuple = (), one: bool = False): """Execute a SELECT and return list[dict] (or a single dict if *one*).""" conn = self._connect() - try: - cursor = conn.cursor() - cursor.execute(sql, params) - if one: - row = cursor.fetchone() - return dict(row) if row else None - return [dict(r) for r in cursor.fetchall()] - finally: - conn.close() + cursor = conn.cursor() + cursor.execute(sql, params) + if one: + row = cursor.fetchone() + return dict(row) if row else None + return [dict(r) for r in cursor.fetchall()] + + def _query_cached(self, cache_key: str, sql: str, params: tuple = ()): + """Execute a SELECT with in-memory caching for repeated queries.""" + if cache_key in self._cache: + return self._cache[cache_key] + result = self._query(sql, params) + self._cache[cache_key] = result + return result def _execute(self, sql: str, params: tuple = ()) -> int: """Execute an INSERT/UPDATE/DELETE and return lastrowid.""" conn = self._connect() - try: - cursor = conn.cursor() - cursor.execute(sql, params) - conn.commit() - return cursor.lastrowid - finally: - conn.close() + cursor = conn.cursor() + cursor.execute(sql, params) + conn.commit() + self._cache.clear() # invalidate cache on writes + return cursor.lastrowid # ================================================================== # Vehicle navigation @@ -58,14 +73,17 @@ class Database: def get_brands(self) -> list[dict]: """Return all brands ordered by name: [{id, name, country}].""" - return self._query( - "SELECT id, name, country FROM brands ORDER BY name" + return self._query_cached( + "brands", + "SELECT id, name, country FROM brands ORDER BY name", ) def get_models(self, brand: Optional[str] = None) -> list[dict]: """Return models, optionally filtered by brand name (case-insensitive).""" if brand: - return self._query( + key = f"models:{brand.upper()}" + return self._query_cached( + key, """ SELECT MIN(m.id) AS id, m.name FROM models m @@ -76,8 +94,9 @@ class Database: """, (brand,), ) - return self._query( - "SELECT MIN(id) AS id, name FROM models GROUP BY UPPER(name) ORDER BY name" + return self._query_cached( + "models:all", + "SELECT MIN(id) AS id, name FROM models GROUP BY UPPER(name) ORDER BY name", ) def get_years( @@ -178,12 +197,13 @@ class Database: def get_categories(self) -> list[dict]: """Return all part categories ordered by display_order.""" - return self._query( + return self._query_cached( + "categories", """ SELECT id, name, name_es, slug, icon_name, display_order FROM part_categories ORDER BY display_order, name - """ + """, ) def get_groups(self, category_id: int) -> list[dict]: @@ -344,77 +364,74 @@ class Database: offset = (page - 1) * per_page conn = self._connect() - try: - cursor = conn.cursor() + cursor = conn.cursor() + + # Check if FTS5 table exists + cursor.execute( + "SELECT name FROM sqlite_master " + "WHERE type='table' AND name='parts_fts'" + ) + fts_exists = cursor.fetchone() is not None + + if fts_exists: + # Escape FTS5 special chars by quoting each term + terms = query.split() + quoted = ['"' + t.replace('"', '""') + '"' for t in terms] + fts_query = " ".join(quoted) - # Check if FTS5 table exists cursor.execute( - "SELECT name FROM sqlite_master " - "WHERE type='table' AND name='parts_fts'" + """ + SELECT + p.id, + p.oem_part_number, + p.name, + p.name_es, + p.description, + pg.name AS group_name, + pc.name AS category_name, + bm25(parts_fts) AS rank + FROM parts_fts + JOIN parts p ON parts_fts.rowid = p.id + JOIN part_groups pg ON p.group_id = pg.id + JOIN part_categories pc ON pg.category_id = pc.id + WHERE parts_fts MATCH ? + ORDER BY rank + LIMIT ? OFFSET ? + """, + (fts_query, per_page, offset), + ) + else: + search_term = f"%{query}%" + cursor.execute( + """ + SELECT + p.id, + p.oem_part_number, + p.name, + p.name_es, + p.description, + pg.name AS group_name, + pc.name AS category_name, + 0 AS rank + FROM parts p + JOIN part_groups pg ON p.group_id = pg.id + JOIN part_categories pc ON pg.category_id = pc.id + WHERE p.name LIKE ? OR p.name_es LIKE ? + OR p.oem_part_number LIKE ? OR p.description LIKE ? + ORDER BY p.name + LIMIT ? OFFSET ? + """, + ( + search_term, + search_term, + search_term, + search_term, + per_page, + offset, + ), ) - fts_exists = cursor.fetchone() is not None - if fts_exists: - # Escape FTS5 special chars by quoting each term - terms = query.split() - quoted = ['"' + t.replace('"', '""') + '"' for t in terms] - fts_query = " ".join(quoted) - - cursor.execute( - """ - SELECT - p.id, - p.oem_part_number, - p.name, - p.name_es, - p.description, - pg.name AS group_name, - pc.name AS category_name, - bm25(parts_fts) AS rank - FROM parts_fts - JOIN parts p ON parts_fts.rowid = p.id - JOIN part_groups pg ON p.group_id = pg.id - JOIN part_categories pc ON pg.category_id = pc.id - WHERE parts_fts MATCH ? - ORDER BY rank - LIMIT ? OFFSET ? - """, - (fts_query, per_page, offset), - ) - else: - search_term = f"%{query}%" - cursor.execute( - """ - SELECT - p.id, - p.oem_part_number, - p.name, - p.name_es, - p.description, - pg.name AS group_name, - pc.name AS category_name, - 0 AS rank - FROM parts p - JOIN part_groups pg ON p.group_id = pg.id - JOIN part_categories pc ON pg.category_id = pc.id - WHERE p.name LIKE ? OR p.name_es LIKE ? - OR p.oem_part_number LIKE ? OR p.description LIKE ? - ORDER BY p.name - LIMIT ? OFFSET ? - """, - ( - search_term, - search_term, - search_term, - search_term, - per_page, - offset, - ), - ) - - return [dict(r) for r in cursor.fetchall()] - finally: - conn.close() + return [dict(r) for r in cursor.fetchall()] def search_part_number(self, number: str) -> list[dict]: """Search OEM, aftermarket, and cross-reference part numbers.""" @@ -422,75 +439,72 @@ class Database: results: list[dict] = [] conn = self._connect() - try: - cursor = conn.cursor() + cursor = conn.cursor() - # OEM parts - cursor.execute( - """ - SELECT id, oem_part_number, name, name_es - FROM parts - WHERE oem_part_number LIKE ? - """, - (search_term,), + # OEM parts + cursor.execute( + """ + SELECT id, oem_part_number, name, name_es + FROM parts + WHERE oem_part_number LIKE ? + """, + (search_term,), + ) + for row in cursor.fetchall(): + results.append( + { + **dict(row), + "match_type": "oem", + "matched_number": row["oem_part_number"], + } ) - for row in cursor.fetchall(): - results.append( - { - **dict(row), - "match_type": "oem", - "matched_number": row["oem_part_number"], - } - ) - # Aftermarket parts - cursor.execute( - """ - SELECT p.id, p.oem_part_number, p.name, p.name_es, ap.part_number - FROM aftermarket_parts ap - JOIN parts p ON ap.oem_part_id = p.id - WHERE ap.part_number LIKE ? - """, - (search_term,), + # Aftermarket parts + cursor.execute( + """ + SELECT p.id, p.oem_part_number, p.name, p.name_es, ap.part_number + FROM aftermarket_parts ap + JOIN parts p ON ap.oem_part_id = p.id + WHERE ap.part_number LIKE ? + """, + (search_term,), + ) + for row in cursor.fetchall(): + results.append( + { + "id": row["id"], + "oem_part_number": row["oem_part_number"], + "name": row["name"], + "name_es": row["name_es"], + "match_type": "aftermarket", + "matched_number": row["part_number"], + } ) - for row in cursor.fetchall(): - results.append( - { - "id": row["id"], - "oem_part_number": row["oem_part_number"], - "name": row["name"], - "name_es": row["name_es"], - "match_type": "aftermarket", - "matched_number": row["part_number"], - } - ) - # Cross-references - cursor.execute( - """ - SELECT p.id, p.oem_part_number, p.name, p.name_es, - pcr.cross_reference_number - FROM part_cross_references pcr - JOIN parts p ON pcr.part_id = p.id - WHERE pcr.cross_reference_number LIKE ? - """, - (search_term,), + # Cross-references + cursor.execute( + """ + SELECT p.id, p.oem_part_number, p.name, p.name_es, + pcr.cross_reference_number + FROM part_cross_references pcr + JOIN parts p ON pcr.part_id = p.id + WHERE pcr.cross_reference_number LIKE ? + """, + (search_term,), + ) + for row in cursor.fetchall(): + results.append( + { + "id": row["id"], + "oem_part_number": row["oem_part_number"], + "name": row["name"], + "name_es": row["name_es"], + "match_type": "cross_reference", + "matched_number": row["cross_reference_number"], + } ) - for row in cursor.fetchall(): - results.append( - { - "id": row["id"], - "oem_part_number": row["oem_part_number"], - "name": row["name"], - "name_es": row["name_es"], - "match_type": "cross_reference", - "matched_number": row["cross_reference_number"], - } - ) - return results - finally: - conn.close() + return results # ================================================================== # VIN cache @@ -525,31 +539,29 @@ class Database: """Insert or replace a VIN cache entry (30-day expiry).""" expires = datetime.utcnow() + timedelta(days=30) conn = self._connect() - try: - cursor = conn.cursor() - cursor.execute( - """ - INSERT OR REPLACE INTO vin_cache - (vin, decoded_data, make, model, year, - engine_info, body_class, drive_type, expires_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - """, - ( - vin.upper().strip(), - data, - make, - model, - year, - engine_info, - body_class, - drive_type, - expires.isoformat(), - ), - ) - conn.commit() - return cursor.lastrowid - finally: - conn.close() + cursor = conn.cursor() + cursor.execute( + """ + INSERT OR REPLACE INTO vin_cache + (vin, decoded_data, make, model, year, + engine_info, body_class, drive_type, expires_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + vin.upper().strip(), + data, + make, + model, + year, + engine_info, + body_class, + drive_type, + expires.isoformat(), + ), + ) + conn.commit() + self._cache.clear() + return cursor.lastrowid # ================================================================== # Stats @@ -558,46 +570,43 @@ class Database: def get_stats(self) -> dict: """Return counts for all major tables plus top brands by fitment.""" conn = self._connect() - try: - cursor = conn.cursor() - stats: dict = {} + cursor = conn.cursor() + stats: dict = {} - for table in [ - "brands", - "models", - "years", - "engines", - "part_categories", - "part_groups", - "parts", - "aftermarket_parts", - "manufacturers", - "vehicle_parts", - "part_cross_references", - ]: - cursor.execute(f"SELECT COUNT(*) FROM {table}") - stats[table] = cursor.fetchone()[0] + for table in [ + "brands", + "models", + "years", + "engines", + "part_categories", + "part_groups", + "parts", + "aftermarket_parts", + "manufacturers", + "vehicle_parts", + "part_cross_references", + ]: + cursor.execute(f"SELECT COUNT(*) FROM {table}") + stats[table] = cursor.fetchone()[0] - # Top brands by number of fitments - cursor.execute( - """ - SELECT b.name, COUNT(DISTINCT vp.id) AS cnt - FROM brands b - JOIN models m ON m.brand_id = b.id - JOIN model_year_engine mye ON mye.model_id = m.id - JOIN vehicle_parts vp ON vp.model_year_engine_id = mye.id - GROUP BY b.name - ORDER BY cnt DESC - LIMIT 10 - """ - ) - stats["top_brands"] = [ - {"name": r["name"], "count": r["cnt"]} for r in cursor.fetchall() - ] + # Top brands by number of fitments + cursor.execute( + """ + SELECT b.name, COUNT(DISTINCT vp.id) AS cnt + FROM brands b + JOIN models m ON m.brand_id = b.id + JOIN model_year_engine mye ON mye.model_id = m.id + JOIN vehicle_parts vp ON vp.model_year_engine_id = mye.id + GROUP BY b.name + ORDER BY cnt DESC + LIMIT 10 + """ + ) + stats["top_brands"] = [ + {"name": r["name"], "count": r["cnt"]} for r in cursor.fetchall() + ] - return stats - finally: - conn.close() + return stats # ================================================================== # Admin — Manufacturers diff --git a/console/renderers/curses_renderer.py b/console/renderers/curses_renderer.py index bf8bc5d..798a0b7 100644 --- a/console/renderers/curses_renderer.py +++ b/console/renderers/curses_renderer.py @@ -40,6 +40,7 @@ class CursesRenderer(BaseRenderer): def __init__(self): self._screen = None self._color_pairs: dict[str, int] = {} + self._size_cache: tuple = (24, 80) # ── Lifecycle ──────────────────────────────────────────────────── @@ -68,12 +69,13 @@ class CursesRenderer(BaseRenderer): # ── Screen queries ─────────────────────────────────────────────── def get_size(self) -> tuple: - """Return ``(height, width)``.""" - return self._screen.getmaxyx() + """Return ``(height, width)`` with cached value per render cycle.""" + return self._size_cache # ── Primitive operations ───────────────────────────────────────── def clear(self): + self._size_cache = self._screen.getmaxyx() self._screen.erase() def refresh(self):