""" SQLite implementation of space repositories. This module provides SQLite-backed implementations of the repository interfaces for persistent storage of Information Spaces. """ import sqlite3 import json from pathlib import Path from typing import List, Optional from datetime import datetime from .interfaces import ( ISpaceRepository, IDocumentAssociationRepository, IVariableRepository, IReferenceRepository, ) from ..models import ( InformationSpace, SpaceDocument, SpaceVariable, TransclusionReference, SpaceStatus, SpaceMetadata, SpaceConfig, ) # SQL Schema for space tables SPACE_TABLES_SQL = """ -- Information Spaces table CREATE TABLE IF NOT EXISTS spaces ( id TEXT PRIMARY KEY, name TEXT UNIQUE NOT NULL, description TEXT, metadata JSON, config JSON, parent_space_id TEXT REFERENCES spaces(id), status TEXT DEFAULT 'draft', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); -- Space documents association table CREATE TABLE IF NOT EXISTS space_documents ( id TEXT PRIMARY KEY, space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE, document_id TEXT NOT NULL, space_path TEXT NOT NULL, order_index INTEGER DEFAULT 0, metadata JSON, content_hash TEXT, added_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, UNIQUE(space_id, space_path) ); -- Space variables for transclusion context CREATE TABLE IF NOT EXISTS space_variables ( space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE, name TEXT NOT NULL, value JSON, scope TEXT DEFAULT 'space', PRIMARY KEY(space_id, name) ); -- Transclusion reference tracking for cache invalidation CREATE TABLE IF NOT EXISTS transclusion_references ( source_doc_id TEXT NOT NULL, target_doc_id TEXT NOT NULL, space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY(source_doc_id, target_doc_id, space_id) ); -- Indexes for performance CREATE INDEX IF NOT EXISTS idx_spaces_name ON spaces(name); CREATE INDEX IF NOT EXISTS idx_spaces_parent ON spaces(parent_space_id); CREATE INDEX IF NOT EXISTS idx_spaces_status ON spaces(status); CREATE INDEX IF NOT EXISTS idx_space_documents_space ON space_documents(space_id); CREATE INDEX IF NOT EXISTS idx_space_documents_path ON space_documents(space_id, space_path); CREATE INDEX IF NOT EXISTS idx_transclusion_refs_source ON transclusion_references(source_doc_id, space_id); CREATE INDEX IF NOT EXISTS idx_transclusion_refs_target ON transclusion_references(target_doc_id, space_id); """ def initialize_space_tables(db_path: str) -> None: """ Initialize the space-related database tables. Args: db_path: Path to the SQLite database file """ # Ensure directory exists db_dir = Path(db_path).parent if db_dir and not db_dir.exists(): db_dir.mkdir(parents=True, exist_ok=True) conn = sqlite3.connect(db_path) try: cursor = conn.cursor() cursor.executescript(SPACE_TABLES_SQL) conn.commit() finally: conn.close() class SqliteSpaceRepository(ISpaceRepository): """ SQLite implementation of the space repository. Provides persistent storage for InformationSpace entities using SQLite as the backend. """ def __init__(self, db_path: str): """ Initialize the repository. Args: db_path: Path to the SQLite database file """ self.db_path = db_path initialize_space_tables(db_path) def _get_connection(self) -> sqlite3.Connection: """Get a database connection with foreign keys enabled.""" conn = sqlite3.connect(self.db_path) conn.row_factory = sqlite3.Row conn.execute("PRAGMA foreign_keys = ON") return conn def _row_to_space(self, row: sqlite3.Row) -> InformationSpace: """Convert a database row to an InformationSpace.""" metadata_dict = json.loads(row["metadata"]) if row["metadata"] else {} config_dict = json.loads(row["config"]) if row["config"] else {} return InformationSpace( id=row["id"], name=row["name"], description=row["description"], metadata=SpaceMetadata.from_dict(metadata_dict), config=SpaceConfig.from_dict(config_dict), parent_space_id=row["parent_space_id"], status=SpaceStatus(row["status"]), created_at=datetime.fromisoformat(row["created_at"]) if row["created_at"] else datetime.now(), updated_at=datetime.fromisoformat(row["updated_at"]) if row["updated_at"] else datetime.now(), ) def create(self, space: InformationSpace) -> InformationSpace: """Create a new space.""" conn = self._get_connection() try: cursor = conn.cursor() # Check if name already exists cursor.execute("SELECT id FROM spaces WHERE name = ?", (space.name,)) if cursor.fetchone(): raise ValueError(f"Space with name '{space.name}' already exists") cursor.execute( """ INSERT INTO spaces (id, name, description, metadata, config, parent_space_id, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( space.id, space.name, space.description, json.dumps(space.metadata.to_dict() if isinstance(space.metadata, SpaceMetadata) else space.metadata), json.dumps(space.config.to_dict() if isinstance(space.config, SpaceConfig) else space.config), space.parent_space_id, space.status.value if isinstance(space.status, SpaceStatus) else space.status, space.created_at.isoformat(), space.updated_at.isoformat(), ), ) conn.commit() return space finally: conn.close() def get_by_id(self, space_id: str) -> Optional[InformationSpace]: """Get a space by ID.""" conn = self._get_connection() try: cursor = conn.cursor() cursor.execute("SELECT * FROM spaces WHERE id = ?", (space_id,)) row = cursor.fetchone() return self._row_to_space(row) if row else None finally: conn.close() def get_by_name(self, name: str) -> Optional[InformationSpace]: """Get a space by name.""" conn = self._get_connection() try: cursor = conn.cursor() cursor.execute("SELECT * FROM spaces WHERE name = ?", (name,)) row = cursor.fetchone() return self._row_to_space(row) if row else None finally: conn.close() def list_all(self, include_archived: bool = False) -> List[InformationSpace]: """List all spaces.""" conn = self._get_connection() try: cursor = conn.cursor() if include_archived: cursor.execute("SELECT * FROM spaces WHERE status != 'deleted' ORDER BY name") else: cursor.execute("SELECT * FROM spaces WHERE status NOT IN ('archived', 'deleted') ORDER BY name") return [self._row_to_space(row) for row in cursor.fetchall()] finally: conn.close() def update(self, space: InformationSpace) -> InformationSpace: """Update a space.""" conn = self._get_connection() try: cursor = conn.cursor() # Check if space exists cursor.execute("SELECT id FROM spaces WHERE id = ?", (space.id,)) if not cursor.fetchone(): raise ValueError(f"Space with id '{space.id}' does not exist") space.touch() # Update timestamp cursor.execute( """ UPDATE spaces SET name = ?, description = ?, metadata = ?, config = ?, parent_space_id = ?, status = ?, updated_at = ? WHERE id = ? """, ( space.name, space.description, json.dumps(space.metadata.to_dict() if isinstance(space.metadata, SpaceMetadata) else space.metadata), json.dumps(space.config.to_dict() if isinstance(space.config, SpaceConfig) else space.config), space.parent_space_id, space.status.value if isinstance(space.status, SpaceStatus) else space.status, space.updated_at.isoformat(), space.id, ), ) conn.commit() return space finally: conn.close() def delete(self, space_id: str) -> bool: """Delete a space.""" conn = self._get_connection() try: cursor = conn.cursor() cursor.execute("DELETE FROM spaces WHERE id = ?", (space_id,)) conn.commit() return cursor.rowcount > 0 finally: conn.close() def exists(self, space_id: str) -> bool: """Check if a space exists.""" conn = self._get_connection() try: cursor = conn.cursor() cursor.execute("SELECT 1 FROM spaces WHERE id = ?", (space_id,)) return cursor.fetchone() is not None finally: conn.close() def get_children(self, parent_space_id: str) -> List[InformationSpace]: """Get child spaces.""" conn = self._get_connection() try: cursor = conn.cursor() cursor.execute( "SELECT * FROM spaces WHERE parent_space_id = ? ORDER BY name", (parent_space_id,), ) return [self._row_to_space(row) for row in cursor.fetchall()] finally: conn.close() class SqliteDocumentRepository(IDocumentAssociationRepository): """ SQLite implementation of the document association repository. """ def __init__(self, db_path: str): """Initialize the repository.""" self.db_path = db_path initialize_space_tables(db_path) def _get_connection(self) -> sqlite3.Connection: """Get a database connection with foreign keys enabled.""" conn = sqlite3.connect(self.db_path) conn.row_factory = sqlite3.Row conn.execute("PRAGMA foreign_keys = ON") return conn def _row_to_document(self, row: sqlite3.Row) -> SpaceDocument: """Convert a database row to a SpaceDocument.""" metadata_dict = json.loads(row["metadata"]) if row["metadata"] else {} return SpaceDocument( id=row["id"], space_id=row["space_id"], document_id=row["document_id"], space_path=row["space_path"], order_index=row["order_index"], metadata=metadata_dict, content_hash=row["content_hash"], added_at=datetime.fromisoformat(row["added_at"]) if row["added_at"] else datetime.now(), ) def add_document(self, document: SpaceDocument) -> SpaceDocument: """Add a document to a space.""" conn = self._get_connection() try: cursor = conn.cursor() # Check if path already exists in space cursor.execute( "SELECT id FROM space_documents WHERE space_id = ? AND space_path = ?", (document.space_id, document.space_path), ) if cursor.fetchone(): raise ValueError(f"Document path '{document.space_path}' already exists in space") cursor.execute( """ INSERT INTO space_documents (id, space_id, document_id, space_path, order_index, metadata, content_hash, added_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?) """, ( document.id, document.space_id, document.document_id, document.space_path, document.order_index, json.dumps(document.metadata), document.content_hash, document.added_at.isoformat(), ), ) conn.commit() return document finally: conn.close() def get_document(self, document_id: str) -> Optional[SpaceDocument]: """Get a document by ID.""" conn = self._get_connection() try: cursor = conn.cursor() cursor.execute("SELECT * FROM space_documents WHERE id = ?", (document_id,)) row = cursor.fetchone() return self._row_to_document(row) if row else None finally: conn.close() def get_by_space_path(self, space_id: str, space_path: str) -> Optional[SpaceDocument]: """Get a document by its path within a space.""" conn = self._get_connection() try: cursor = conn.cursor() cursor.execute( "SELECT * FROM space_documents WHERE space_id = ? AND space_path = ?", (space_id, space_path), ) row = cursor.fetchone() return self._row_to_document(row) if row else None finally: conn.close() def list_by_space(self, space_id: str) -> List[SpaceDocument]: """List all documents in a space.""" conn = self._get_connection() try: cursor = conn.cursor() cursor.execute( "SELECT * FROM space_documents WHERE space_id = ? ORDER BY order_index, space_path", (space_id,), ) return [self._row_to_document(row) for row in cursor.fetchall()] finally: conn.close() def update_document(self, document: SpaceDocument) -> SpaceDocument: """Update a document.""" conn = self._get_connection() try: cursor = conn.cursor() cursor.execute("SELECT id FROM space_documents WHERE id = ?", (document.id,)) if not cursor.fetchone(): raise ValueError(f"Document with id '{document.id}' does not exist") cursor.execute( """ UPDATE space_documents SET document_id = ?, space_path = ?, order_index = ?, metadata = ?, content_hash = ? WHERE id = ? """, ( document.document_id, document.space_path, document.order_index, json.dumps(document.metadata), document.content_hash, document.id, ), ) conn.commit() return document finally: conn.close() def remove_document(self, document_id: str) -> bool: """Remove a document from a space.""" conn = self._get_connection() try: cursor = conn.cursor() cursor.execute("DELETE FROM space_documents WHERE id = ?", (document_id,)) conn.commit() return cursor.rowcount > 0 finally: conn.close() def move_document(self, document_id: str, new_space_path: str) -> SpaceDocument: """Move a document to a new path.""" conn = self._get_connection() try: cursor = conn.cursor() # Get current document cursor.execute("SELECT * FROM space_documents WHERE id = ?", (document_id,)) row = cursor.fetchone() if not row: raise ValueError(f"Document with id '{document_id}' does not exist") # Check if new path already exists cursor.execute( "SELECT id FROM space_documents WHERE space_id = ? AND space_path = ? AND id != ?", (row["space_id"], new_space_path, document_id), ) if cursor.fetchone(): raise ValueError(f"Document path '{new_space_path}' already exists") cursor.execute( "UPDATE space_documents SET space_path = ? WHERE id = ?", (new_space_path, document_id), ) conn.commit() document = self._row_to_document(row) document.space_path = new_space_path return document finally: conn.close() def reorder_documents(self, space_id: str, document_ids: List[str]) -> None: """Reorder documents within a space.""" conn = self._get_connection() try: cursor = conn.cursor() for index, doc_id in enumerate(document_ids): cursor.execute( "UPDATE space_documents SET order_index = ? WHERE id = ? AND space_id = ?", (index, doc_id, space_id), ) conn.commit() finally: conn.close() def update_content_hash(self, document_id: str, content_hash: str) -> None: """Update the content hash.""" conn = self._get_connection() try: cursor = conn.cursor() cursor.execute( "UPDATE space_documents SET content_hash = ? WHERE id = ?", (content_hash, document_id), ) conn.commit() finally: conn.close() class SqliteVariableRepository(IVariableRepository): """ SQLite implementation of the variable repository. """ def __init__(self, db_path: str): """Initialize the repository.""" self.db_path = db_path initialize_space_tables(db_path) def _get_connection(self) -> sqlite3.Connection: """Get a database connection with foreign keys enabled.""" conn = sqlite3.connect(self.db_path) conn.row_factory = sqlite3.Row conn.execute("PRAGMA foreign_keys = ON") return conn def set_variable(self, variable: SpaceVariable) -> SpaceVariable: """Set a variable value.""" conn = self._get_connection() try: cursor = conn.cursor() cursor.execute( """ INSERT OR REPLACE INTO space_variables (space_id, name, value, scope) VALUES (?, ?, ?, ?) """, ( variable.space_id, variable.name, json.dumps(variable.value), variable.scope, ), ) conn.commit() return variable finally: conn.close() def get_variable(self, space_id: str, name: str) -> Optional[SpaceVariable]: """Get a variable by name.""" conn = self._get_connection() try: cursor = conn.cursor() cursor.execute( "SELECT * FROM space_variables WHERE space_id = ? AND name = ?", (space_id, name), ) row = cursor.fetchone() if not row: return None return SpaceVariable( space_id=row["space_id"], name=row["name"], value=json.loads(row["value"]) if row["value"] else None, scope=row["scope"], ) finally: conn.close() def list_variables(self, space_id: str, scope: Optional[str] = None) -> List[SpaceVariable]: """List variables in a space.""" conn = self._get_connection() try: cursor = conn.cursor() if scope: cursor.execute( "SELECT * FROM space_variables WHERE space_id = ? AND scope = ?", (space_id, scope), ) else: cursor.execute( "SELECT * FROM space_variables WHERE space_id = ?", (space_id,), ) return [ SpaceVariable( space_id=row["space_id"], name=row["name"], value=json.loads(row["value"]) if row["value"] else None, scope=row["scope"], ) for row in cursor.fetchall() ] finally: conn.close() def delete_variable(self, space_id: str, name: str) -> bool: """Delete a variable.""" conn = self._get_connection() try: cursor = conn.cursor() cursor.execute( "DELETE FROM space_variables WHERE space_id = ? AND name = ?", (space_id, name), ) conn.commit() return cursor.rowcount > 0 finally: conn.close() class SqliteReferenceRepository(IReferenceRepository): """ SQLite implementation of the reference repository. """ def __init__(self, db_path: str): """Initialize the repository.""" self.db_path = db_path initialize_space_tables(db_path) def _get_connection(self) -> sqlite3.Connection: """Get a database connection with foreign keys enabled.""" conn = sqlite3.connect(self.db_path) conn.row_factory = sqlite3.Row conn.execute("PRAGMA foreign_keys = ON") return conn def add_reference(self, reference: TransclusionReference) -> TransclusionReference: """Add a transclusion reference.""" conn = self._get_connection() try: cursor = conn.cursor() cursor.execute( """ INSERT OR REPLACE INTO transclusion_references (source_doc_id, target_doc_id, space_id, created_at) VALUES (?, ?, ?, ?) """, ( reference.source_doc_id, reference.target_doc_id, reference.space_id, reference.created_at.isoformat(), ), ) conn.commit() return reference finally: conn.close() def get_references_from(self, source_doc_id: str, space_id: str) -> List[TransclusionReference]: """Get references from a source document.""" conn = self._get_connection() try: cursor = conn.cursor() cursor.execute( "SELECT * FROM transclusion_references WHERE source_doc_id = ? AND space_id = ?", (source_doc_id, space_id), ) return [ TransclusionReference( source_doc_id=row["source_doc_id"], target_doc_id=row["target_doc_id"], space_id=row["space_id"], created_at=datetime.fromisoformat(row["created_at"]) if row["created_at"] else datetime.now(), ) for row in cursor.fetchall() ] finally: conn.close() def get_references_to(self, target_doc_id: str, space_id: str) -> List[TransclusionReference]: """Get references to a target document.""" conn = self._get_connection() try: cursor = conn.cursor() cursor.execute( "SELECT * FROM transclusion_references WHERE target_doc_id = ? AND space_id = ?", (target_doc_id, space_id), ) return [ TransclusionReference( source_doc_id=row["source_doc_id"], target_doc_id=row["target_doc_id"], space_id=row["space_id"], created_at=datetime.fromisoformat(row["created_at"]) if row["created_at"] else datetime.now(), ) for row in cursor.fetchall() ] finally: conn.close() def clear_references_from(self, source_doc_id: str, space_id: str) -> int: """Clear references from a source document.""" conn = self._get_connection() try: cursor = conn.cursor() cursor.execute( "DELETE FROM transclusion_references WHERE source_doc_id = ? AND space_id = ?", (source_doc_id, space_id), ) conn.commit() return cursor.rowcount finally: conn.close() def get_dependents(self, document_id: str, space_id: str) -> List[str]: """Get documents that depend on this document.""" conn = self._get_connection() try: cursor = conn.cursor() cursor.execute( "SELECT DISTINCT source_doc_id FROM transclusion_references WHERE target_doc_id = ? AND space_id = ?", (document_id, space_id), ) return [row["source_doc_id"] for row in cursor.fetchall()] finally: conn.close()