Research-Stack/4-Infrastructure/NoDupeLabs/nodupe/tools/databases/wrapper.py

435 lines
16 KiB
Python

# SPDX-License-Identifier: Apache-2.0
# Copyright (c) 2025 Allaun
"""Database Wrapper Module.
This module provides the main Database wrapper class for comprehensive database
operations in NoDupeLabs. It serves as the primary interface for all database
interactions, including connection management, query execution, transactions,
and component orchestration.
This is a clean refactored version that fixes architectural issues from the
original implementation, including:
- Fixed dual-purpose attribute conflict (transaction object vs context manager)
- Added missing component attributes (logging, cache, locking, session, etc.)
- Fixed component dependencies (pass Connection, not Database)
Classes:
Database: High-level database wrapper for SQLite operations.
DatabaseError: Exception raised for database operation errors.
Example:
>>> from nodupe.core.database import Database
>>> db = Database("/path/to/database.db")
>>> db.connect()
>>> results = db.read("SELECT * FROM files WHERE id = ?", (1,))
>>> db.close()
"""
from __future__ import annotations
import sqlite3
from typing import Any, Dict, List, Optional, Tuple
from contextlib import contextmanager
from .connection import DatabaseConnection
from .schema import DatabaseSchema
from .indexing import DatabaseIndexing
from .transactions import DatabaseTransaction
from .query import (
DatabaseQuery,
DatabaseBatch,
DatabasePerformance,
DatabaseIntegrity,
DatabaseBackup,
DatabaseMigration,
DatabaseRecovery,
)
from .security import DatabaseSecurity
from .logging_ import DatabaseLogging
from .cache import DatabaseCache
from .locking import DatabaseLocking
from .session import DatabaseSession
from .compression import DatabaseCompression
from .serialization import DatabaseSerialization
from .cleanup import DatabaseCleanup
class DatabaseError(Exception):
"""Exception raised for database operation errors.
This exception is raised when any database operation fails, including
connection errors, query execution errors, or transaction errors.
Attributes:
message: The error message describing the failure.
Example:
>>> try:
... db.create_table("invalid name", "col TEXT")
... except DatabaseError as e:
... print(f"Database error: {e}")
>>> # doctest: +SKIP
"""
def __init__(self, message: str) -> None:
"""Initialize DatabaseError with message.
Args:
message: The error message describing the failure.
"""
self.message = message
super().__init__(self.message)
class Database:
"""High-level database wrapper class for comprehensive database operations.
This class provides a unified interface for all database operations in
NoDupeLabs. It manages connections, executes queries, handles transactions,
and provides access to various database components.
Attributes:
path: Path to the SQLite database file.
timeout: Database connection timeout in seconds.
connection: DatabaseConnection instance for low-level operations.
schema: DatabaseSchema instance for schema management.
indexing: DatabaseIndexing instance for index management.
query: DatabaseQuery instance for query execution.
batch: DatabaseBatch instance for batch operations.
transaction_manager: DatabaseTransaction instance for transaction handling.
performance: DatabasePerformance instance for performance monitoring.
integrity: DatabaseIntegrity instance for integrity checks.
backup: DatabaseBackup instance for backup operations.
migration: DatabaseMigration instance for schema migrations.
recovery: DatabaseRecovery instance for error recovery.
security: DatabaseSecurity instance for security validation.
logging: DatabaseLogging instance for logging operations.
cache: DatabaseCache instance for caching.
locking: DatabaseLocking instance for locking mechanisms.
session: DatabaseSession instance for session management.
compression: DatabaseCompression instance for data compression.
serialization: DatabaseSerialization instance for serialization.
cleanup: DatabaseCleanup instance for cleanup operations.
Example:
>>> db = Database("/path/to/database.db", timeout=30.0)
>>> with db.transaction():
... db.create("files", {"path": "/test.txt", "size": 100})
>>> db.close()
"""
def __init__(self, db_path: str, timeout: float = 30.0) -> None:
"""Initialize the Database wrapper.
Args:
db_path: Path to SQLite database file.
timeout: Database connection timeout in seconds (default: 30.0).
Raises:
DatabaseError: If database initialization fails.
Example:
>>> db = Database("/path/to/database.db")
>>> db = Database("/path/to/database.db", timeout=60.0)
"""
self.path: str = db_path
self.timeout: float = timeout
self._connection: Optional[sqlite3.Connection] = None
# Initialize connection first (components depend on it)
# Note: DatabaseConnection only takes db_path (timeout is handled internally)
self.connection: DatabaseConnection = DatabaseConnection(db_path)
# Initialize components with connection (not Database instance)
# Some components require a raw sqlite3.Connection; obtain it once
# and pass it to those components. Others accept the higher-level
# DatabaseConnection and will continue to receive `self.connection`.
conn = self.connection.get_connection()
# Components that require a sqlite3.Connection
self.schema: DatabaseSchema = DatabaseSchema(conn)
self.indexing: DatabaseIndexing = DatabaseIndexing(conn)
# Components that accept DatabaseConnection (keeps existing behavior)
self.query: DatabaseQuery = DatabaseQuery(self.connection)
self.batch: DatabaseBatch = DatabaseBatch(self.connection)
# FIXED: Renamed from 'transaction' to 'transaction_manager'
# to avoid conflict with the context manager method below
self.transaction_manager: DatabaseTransaction = DatabaseTransaction(conn)
self.performance: DatabasePerformance = DatabasePerformance(self.connection)
self.integrity: DatabaseIntegrity = DatabaseIntegrity(self.connection)
self.backup: DatabaseBackup = DatabaseBackup(self.connection)
self.migration: DatabaseMigration = DatabaseMigration(self.connection)
self.recovery: DatabaseRecovery = DatabaseRecovery(self.connection)
self.security: DatabaseSecurity = DatabaseSecurity(self.connection)
# ADDED: Missing components that were referenced but didn't exist
self.logging: DatabaseLogging = DatabaseLogging(self.connection)
self.cache: DatabaseCache = DatabaseCache(self.connection)
self.locking: DatabaseLocking = DatabaseLocking(self.connection)
self.session: DatabaseSession = DatabaseSession(self.connection)
self.compression: DatabaseCompression = DatabaseCompression(self.connection)
self.serialization: DatabaseSerialization = DatabaseSerialization(self.connection)
self.cleanup: DatabaseCleanup = DatabaseCleanup(self.connection)
# BACKWARD COMPATIBILITY ALIASES - Map old attribute names to new
# These aliases ensure compatibility with existing code/tests
self.monitoring = self.performance # Alias for performance monitoring
self.validation = self.integrity # Alias for integrity checking
self.schema_migration = self.migration # Alias for migration
self.optimization = self.performance # Alias for optimization
def connect(self) -> sqlite3.Connection:
"""Get a database connection.
Returns the SQLite connection object for direct database operations.
Returns:
sqlite3.Connection: The active database connection.
Raises:
DatabaseError: If connection fails.
Example:
>>> conn = db.connect()
>>> cursor = conn.execute("SELECT * FROM files")
"""
return self.connection.get_connection()
def close(self) -> None:
"""Close the database connection.
Closes the underlying database connection and releases all resources.
After calling this method, the database should not be used until
a new connection is established.
Example:
>>> db.close()
"""
self.connection.close()
def create_table(self, table_name: str, schema: str) -> None:
"""Create a table with the given schema.
Validates the table name and schema using the security module,
then creates the table in the database.
Args:
table_name: Name of the table to create.
schema: SQL schema definition for the table.
Raises:
DatabaseError: If table creation fails.
ValueError: If table name or schema fails validation.
Example:
>>> db.create_table("files", "id INTEGER PRIMARY KEY, path TEXT")
"""
# Validate table name and schema using security module
self.security.validate_identifier(table_name)
self.security.validate_schema(schema)
conn = self.connect()
try:
# Use parameterized query for table creation
conn.execute(f"CREATE TABLE {table_name} ({schema})")
except sqlite3.Error as e:
raise DatabaseError(f"Failed to create table {table_name}: {self.security.sanitize_error_message(str(e))}")
def create(self, table_name: str, data: Dict[str, Any]) -> Optional[int]:
"""Create a record and return the inserted ID.
Inserts a new record into the specified table with the given data.
Args:
table_name: Name of the table to insert into.
data: Dictionary of column names to values.
Returns:
Optional[int]: The ID of the inserted row, or None if no ID was generated.
Raises:
DatabaseError: If insert operation fails.
Example:
>>> db.create("files", {"path": "/test.txt", "size": 100})
1
"""
columns = ", ".join(data.keys())
placeholders = ", ".join(["?"] * len(data))
query = f"INSERT INTO {table_name} ({columns}) VALUES ({placeholders})"
conn = self.connect()
cursor = conn.cursor()
cursor.execute(query, tuple(data.values()))
conn.commit()
return cursor.lastrowid
def read(self, query: str, params: Optional[Tuple] = None) -> List[Dict[str, Any]]:
"""Read records from the database.
Executes a SELECT query and returns the results as a list of dictionaries.
Args:
query: SQL SELECT query to execute.
params: Optional tuple of parameters for the query.
Returns:
List[Dict[str, Any]]: List of row dictionaries.
Raises:
DatabaseError: If query execution fails.
Example:
>>> results = db.read("SELECT * FROM files WHERE size > ?", (100,))
[{'id': 1, 'path': '/test.txt', 'size': 200}, ...]
"""
return self.query.execute(query, params or ())
def update(self, query: str, params: Optional[Tuple] = None) -> int:
"""Update records in the database.
Executes an UPDATE query and returns the number of affected rows.
Args:
query: SQL UPDATE query to execute.
params: Optional tuple of parameters for the query.
Returns:
int: Number of rows affected.
Raises:
DatabaseError: If update operation fails.
Example:
>>> count = db.update("UPDATE files SET size = ? WHERE id = ?", (200, 1))
1
"""
conn = self.connect()
cursor = conn.cursor()
cursor.execute(query, params or ())
conn.commit()
return cursor.rowcount
def delete(self, query: str, params: Optional[Tuple] = None) -> int:
"""Delete records from the database.
Executes a DELETE query and returns the number of affected rows.
Args:
query: SQL DELETE query to execute.
params: Optional tuple of parameters for the query.
Returns:
int: Number of rows deleted.
Raises:
DatabaseError: If delete operation fails.
Example:
>>> count = db.delete("DELETE FROM files WHERE id = ?", (1,))
1
"""
conn = self.connect()
cursor = conn.cursor()
cursor.execute(query, params or ())
conn.commit()
return cursor.rowcount
def execute_batch(self, operations: List[Tuple[str, Tuple]]) -> None:
"""Execute multiple operations as a batch.
Executes multiple SQL operations in sequence without transaction wrapping.
Args:
operations: List of (query, params) tuples.
Raises:
DatabaseError: If any operation fails.
Example:
>>> ops = [
... ("INSERT INTO files (path, size) VALUES (?, ?)", ("/a.txt", 100)),
... ("INSERT INTO files (path, size) VALUES (?, ?)", ("/b.txt", 200))
... ]
>>> db.execute_batch(ops)
"""
self.batch.execute_batch(operations)
def execute_transaction_batch(self, operations: List[Tuple[str, Tuple]]) -> None:
"""Execute multiple operations within a transaction.
Executes multiple SQL operations atomically within a single transaction.
If any operation fails, all changes are rolled back.
Args:
operations: List of (query, params) tuples.
Raises:
DatabaseError: If any operation fails.
Example:
>>> ops = [
... ("INSERT INTO files (path, size) VALUES (?, ?)", ("/a.txt", 100)),
... ("UPDATE files SET size = ? WHERE path = ?", (200, "/a.txt"))
... ]
>>> db.execute_transaction_batch(ops)
"""
self.batch.execute_transaction_batch(operations)
@contextmanager
def transaction(self):
"""Context manager for database transactions.
Provides transactional semantics for database operations. All operations
within the context are executed atomically - if any operation fails,
all changes are rolled back.
Yields:
None: No value is yielded, the context manages the transaction.
Raises:
DatabaseError: If transaction fails.
Example:
>>> with db.transaction():
... db.create("files", {"path": "/test.txt"})
... db.update("UPDATE files SET size = 100 WHERE path = ?", ("/test.txt",))
"""
# FIXED: Now references transaction_manager (not transaction itself)
# to avoid the dual-purpose attribute conflict
# Using the transaction context manager from DatabaseTransaction
with self.transaction_manager.transaction():
yield
def __enter__(self) -> "Database":
"""Enter the context manager protocol.
Returns:
Database: Self reference for context management.
Example:
>>> with Database("/path/to/db.db") as db:
... db.read("SELECT * FROM files")
"""
return self
def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
"""Exit the context manager protocol.
Closes the database connection when exiting the context.
Args:
exc_type: Exception type if an exception was raised.
exc_val: Exception value if an exception was raised.
exc_tb: Exception traceback if an exception was raised.
Example:
>>> with Database("/path/to/db.db") as db:
... pass
>>> # db is now closed
"""
self.close()