Coverage for src/qdrant_loader/core/state/utils.py: 63%
51 statements
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-20 10:15 +0000
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-20 10:15 +0000
1"""
2Utilities for StateManager: database URL construction and common query builders.
3"""
5from __future__ import annotations
7import os
8from pathlib import Path
9from typing import TYPE_CHECKING
11if TYPE_CHECKING:
12 from qdrant_loader.config.state import StateManagementConfig
14from sqlalchemy import select
16from qdrant_loader.core.state.exceptions import DatabaseError
17from qdrant_loader.core.state.models import DocumentStateRecord, IngestionHistory
20def ensure_parent_directory(db_path: Path) -> None:
21 """Ensure the parent directory of the database file exists and is writable."""
22 parent_dir = db_path.parent
23 if not parent_dir.exists():
24 try:
25 parent_dir.mkdir(parents=True, exist_ok=True)
26 except Exception as e: # pragma: no cover - safety net
27 raise DatabaseError(
28 f"Cannot create database directory {parent_dir}: {e}"
29 ) from e
30 if not os.access(parent_dir, os.W_OK):
31 raise DatabaseError(f"No write permission for database directory: {parent_dir}")
34def generate_sqlite_aiosqlite_url(database_path: str) -> str:
35 """Generate an aiosqlite URL from a configured database path string.
37 Supports special values like ":memory:" and already-prefixed sqlite URLs.
38 Ensures parent directory exists for file-backed databases.
39 """
40 if database_path in (":memory:", "sqlite:///:memory:", "sqlite://:memory:"):
41 return "sqlite+aiosqlite:///:memory:"
43 if database_path.startswith("sqlite://"):
44 # Convert to aiosqlite dialect
45 return database_path.replace("sqlite://", "sqlite+aiosqlite://")
47 # Treat as filesystem path
48 db_path = Path(database_path)
49 if not db_path.is_absolute():
50 db_path = db_path.resolve()
52 ensure_parent_directory(db_path)
54 # Normalize to POSIX path for SQLAlchemy URL
55 db_url_path = db_path.as_posix()
56 # Absolute and relative are handled similarly here (three slashes)
57 return f"sqlite+aiosqlite:///{db_url_path}"
60def _normalize_async_url(url: str) -> str:
61 """Ensure a database URL uses an async driver SQLAlchemy can use.
63 - postgres:// / postgresql:// -> postgresql+asyncpg:// (so a standard RDS/psql
64 URL works unchanged)
65 - sqlite:// -> sqlite+aiosqlite://
66 - anything already carrying a driver (e.g. postgresql+asyncpg://) passes through.
67 """
68 if url.startswith(("postgresql://", "postgres://")):
69 return "postgresql+asyncpg://" + url.split("://", 1)[1]
70 if url.startswith("sqlite://") and "+aiosqlite" not in url:
71 return url.replace("sqlite://", "sqlite+aiosqlite://", 1)
72 return url
75def generate_database_url(config: StateManagementConfig) -> str:
76 """Build the async SQLAlchemy URL for the configured state backend.
78 Precedence: ``config.database_url`` (Postgres or any full SQLAlchemy URL) wins
79 and selects the backend by dialect; otherwise fall back to the SQLite
80 ``database_path`` (community default).
81 """
82 database_url = getattr(config, "database_url", None)
83 if database_url:
84 return _normalize_async_url(database_url)
85 return generate_sqlite_aiosqlite_url(config.database_path)
88def build_ingestion_history_select(
89 source_type: str,
90 source: str,
91 project_id: str | None = None,
92 order_by_last_successful_desc: bool = False,
93):
94 """Create a select() for IngestionHistory with optional project filter and ordering."""
95 query = select(IngestionHistory).filter(
96 IngestionHistory.source_type == source_type, IngestionHistory.source == source
97 )
98 if project_id is not None:
99 query = query.filter(IngestionHistory.project_id == project_id)
100 if order_by_last_successful_desc:
101 query = query.order_by(IngestionHistory.last_successful_ingestion.desc())
102 return query
105def build_document_state_select(
106 source_type: str,
107 source: str,
108 document_id: str | None = None,
109 project_id: str | None = None,
110):
111 """Create a select() for DocumentStateRecord with optional project/doc filters."""
112 conditions = [
113 DocumentStateRecord.source_type == source_type,
114 DocumentStateRecord.source == source,
115 ]
116 if document_id is not None:
117 conditions.append(DocumentStateRecord.document_id == document_id)
118 query = select(DocumentStateRecord).filter(*conditions)
119 if project_id is not None:
120 query = query.filter(DocumentStateRecord.project_id == project_id)
121 return query