"""Portable SQLAlchemy schema shared by the runtime store and Alembic.""" from __future__ import annotations from pathlib import Path from sqlalchemy import ( JSON, Boolean, Column, DateTime, ForeignKey, Index, Integer, MetaData, String, Table, Text, create_engine, event, ) from sqlalchemy.engine import Engine NAMING_CONVENTION = { "ix": "ix_%(table_name)s_%(column_0_name)s", "uq": "uq_%(table_name)s_%(column_0_name)s", "ck": "ck_%(table_name)s_%(constraint_name)s", "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s", "pk": "pk_%(table_name)s", } metadata = MetaData(naming_convention=NAMING_CONVENTION) repositories = Table( "repositories", metadata, Column("id", String(36), primary_key=True), Column("slug", String(200), nullable=False, unique=True), Column("checkout_path", Text, nullable=True), Column("active", Boolean, nullable=False), Column("last_attempt_at", DateTime(timezone=True), nullable=True), Column("last_success_at", DateTime(timezone=True), nullable=True), Column("last_status", String(50), nullable=True), Column("last_source", String(200), nullable=True), Column("created_at", DateTime(timezone=True), nullable=False), Column("updated_at", DateTime(timezone=True), nullable=False), ) snapshots = Table( "snapshots", metadata, Column("id", String(36), primary_key=True), Column( "repo_id", String(36), ForeignKey("repositories.id", ondelete="RESTRICT"), nullable=False, ), Column("snapshot_at", DateTime(timezone=True), nullable=False), Column("source", String(200), nullable=False), Column("status", String(50), nullable=False), Column("entry_count", Integer, nullable=False), Column("source_revision", String(200), nullable=True), Column("sources_json", JSON, nullable=False), Column("errors_json", JSON, nullable=False), Column("legacy_id", String(100), nullable=True, unique=True), Column("created_at", DateTime(timezone=True), nullable=False), ) entries = Table( "entries", metadata, Column("id", String(36), primary_key=True), Column( "repo_id", String(36), ForeignKey("repositories.id", ondelete="RESTRICT"), nullable=False, ), Column( "snapshot_id", String(36), ForeignKey("snapshots.id", ondelete="RESTRICT"), nullable=False, ), Column("package_name", String(300), nullable=False), Column("package_version", String(100), nullable=True), Column("ecosystem", String(50), nullable=False), Column("license_spdx", String(100), nullable=True), Column("is_direct", Boolean, nullable=False), Column("is_dev", Boolean, nullable=False), Column("source_path", Text, nullable=True), Column("created_at", DateTime(timezone=True), nullable=False), ) Index("ix_snapshots_repo_time", snapshots.c.repo_id, snapshots.c.snapshot_at) Index("ix_entries_snapshot", entries.c.snapshot_id) Index("ix_entries_repo", entries.c.repo_id) Index("ix_entries_license", entries.c.license_spdx) def database_url(value: str | Path) -> str: """Normalize a filesystem path or supported URL into a SQLAlchemy URL.""" text = str(value) if "://" not in text: return f"sqlite:///{Path(text).resolve()}" if text.startswith("postgres://"): return text.replace("postgres://", "postgresql+psycopg://", 1) if text.startswith("postgresql://"): return text.replace("postgresql://", "postgresql+psycopg://", 1) return text def create_database_engine(value: str | Path) -> Engine: url = database_url(value) options: dict[str, object] = {"pool_pre_ping": True} if url.startswith("sqlite:"): options["connect_args"] = {"check_same_thread": False} engine = create_engine(url, **options) if engine.dialect.name == "sqlite": @event.listens_for(engine, "connect") def _enable_sqlite_foreign_keys(dbapi_connection, _connection_record) -> None: cursor = dbapi_connection.cursor() cursor.execute("PRAGMA foreign_keys = ON") cursor.close() return engine