Source code for scgo.database.schema

"""Schema version helpers for SCGO."""

from __future__ import annotations

import sqlite3
from pathlib import Path

from ase_ga.data import DataConnection

from scgo.database.connection import _run_sqlite
from scgo.database.exceptions import DatabaseMigrationError
from scgo.utils.logging import get_logger

logger = get_logger(__name__)

CURRENT_SCHEMA_VERSION = 2

SCGO_METADATA_DDL = """
CREATE TABLE IF NOT EXISTS scgo_metadata (
    key TEXT PRIMARY KEY,
    value TEXT NOT NULL
)
"""


def _upsert_scgo_metadata_keys(
    conn: sqlite3.Connection, *, schema_version: int
) -> None:
    conn.execute(SCGO_METADATA_DDL)
    conn.execute(
        "INSERT OR REPLACE INTO scgo_metadata (key, value) VALUES ('created_by', 'scgo')"
    )
    conn.execute(
        "INSERT OR REPLACE INTO scgo_metadata (key, value) VALUES ('schema_version', ?)",
        (str(schema_version),),
    )


def get_schema_version(db: DataConnection) -> int:
    """Get current schema version from database.

    Args:
        db: Database connection

    Returns:
        int: Current schema version (0 if not set)
    """
    try:
        with db.c.managed_connection() as conn:
            cursor = conn.execute(
                "SELECT value FROM scgo_metadata WHERE key='schema_version'"
            )
            result = cursor.fetchone()
            return int(result[0]) if result else 0
    except sqlite3.OperationalError:
        # Table doesn't exist - schema version 0
        return 0


def set_schema_version(db: DataConnection, version: int) -> None:
    """Set schema version in database.

    Args:
        db: Database connection
        version: Schema version to set
    """
    with db.c.managed_connection() as conn:
        _upsert_scgo_metadata_keys(conn, schema_version=version)
        conn.commit()
        logger.debug(f"Set schema version to {version}")


def migrate_database(db: DataConnection, target_version: int | None = None) -> bool:
    """Set ``schema_version`` in ``scgo_metadata`` to *target_version* (no data migration).

    Returns True on success; raises DatabaseMigrationError for downgrades or failure.
    """
    if target_version is None:
        target_version = CURRENT_SCHEMA_VERSION

    current_version = get_schema_version(db)

    if current_version == target_version:
        logger.debug(f"Database already at version {target_version}")
        return True

    if current_version > target_version:
        raise DatabaseMigrationError(
            f"Cannot downgrade from version {current_version} to {target_version}"
        )

    try:
        set_schema_version(db, target_version)
        logger.info(
            f"Marked database schema version as {target_version} (no migrations applied)"
        )
        return True
    except (OSError, sqlite3.Error, TypeError, ValueError) as e:
        logger.error(f"Failed to set schema version to {target_version}: {e}")
        raise DatabaseMigrationError(f"Failed to set schema version: {e}") from e


def ensure_schema_version(db: DataConnection) -> None:
    """Bump recorded schema version to :data:`CURRENT_SCHEMA_VERSION` when behind."""
    current_version = get_schema_version(db)

    if current_version < CURRENT_SCHEMA_VERSION:
        logger.info(
            f"Database needs migration from v{current_version} to v{CURRENT_SCHEMA_VERSION}"
        )
        migrate_database(db, CURRENT_SCHEMA_VERSION)
    elif current_version > CURRENT_SCHEMA_VERSION:
        logger.warning(
            f"Database version {current_version} is newer than expected "
            f"{CURRENT_SCHEMA_VERSION}. Update SCGO to latest version."
        )


def get_scgo_metadata(db_path: str | Path) -> dict[str, str]:
    """Return key/value pairs from the `scgo_metadata` table for the DB, or {}.

    This performs a lightweight, read-only check and returns an empty dict for
    non-SCGO databases or on error.
    """
    try:
        db_file = str(db_path)
        # Open read-only to avoid creating files or locking where possible.
        with sqlite3.connect(f"file:{db_file}?mode=ro", uri=True, timeout=0.1) as conn:
            cur = conn.execute(
                "SELECT name FROM sqlite_master WHERE type='table' AND name='scgo_metadata'"
            )
            if cur.fetchone() is None:
                return {}
            rows = conn.execute("SELECT key, value FROM scgo_metadata").fetchall()
            return {r[0]: r[1] for r in rows}
    except (sqlite3.OperationalError, sqlite3.DatabaseError, FileNotFoundError) as exc:
        logger.debug("Could not read scgo_metadata from %s: %s", db_path, exc)
        return {}


_scgo_database_cache: dict[str, bool] = {}


def clear_scgo_database_cache() -> None:
    """Clear the :func:`is_scgo_database` memoization cache."""
    _scgo_database_cache.clear()


def is_scgo_database(db_path: str | Path) -> bool:
    """True if ``scgo_metadata.created_by`` is ``scgo``."""
    key = str(Path(db_path).resolve())
    cached = _scgo_database_cache.get(key)
    if cached is not None:
        return cached
    meta = get_scgo_metadata(db_path)
    result = bool(meta) and meta.get("created_by") == "scgo"
    _scgo_database_cache[key] = result
    return result


[docs] def stamp_scgo_database( db_path: str | Path, *, schema_version: int | None = None ) -> None: """Write ``scgo_metadata`` so :func:`is_scgo_database` accepts this file. Used by tests and tools that build SQLite files outside :func:`setup_database`. """ ver = schema_version if schema_version is not None else CURRENT_SCHEMA_VERSION path = str(db_path) def _stamp(conn: sqlite3.Connection) -> None: _upsert_scgo_metadata_keys(conn, schema_version=ver) _run_sqlite(path, _stamp) clear_scgo_database_cache()