feat(db): sqlite library foundation + migration runner
This commit is contained in:
+16
@@ -12,6 +12,8 @@ import importlib.util
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).parent
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
@@ -23,4 +25,18 @@ if importlib.util.find_spec("comfy_api") is None:
|
||||
sys.modules.setdefault("comfy_api.latest", _stub)
|
||||
sys.modules.setdefault("comfy_api.latest._io", _stub.io._io)
|
||||
|
||||
from engine.db.connection import get_connection # noqa: E402
|
||||
from engine.db.migrations import migrate # noqa: E402
|
||||
|
||||
collect_ignore: list[str] = []
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def wp_db(tmp_path):
|
||||
"""Fresh, migrated SQLite DB. Yields a sqlite3.Connection."""
|
||||
conn = get_connection(tmp_path / "wp.db")
|
||||
migrate(conn)
|
||||
try:
|
||||
yield conn
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
"""SQLite-backed library DB."""
|
||||
from engine.db.connection import get_connection, resolve_db_path
|
||||
|
||||
__all__ = ["get_connection", "resolve_db_path"]
|
||||
@@ -0,0 +1,33 @@
|
||||
"""SQLite connection factory.
|
||||
|
||||
DB path resolution order:
|
||||
1. ``WP_DB_PATH`` env var (explicit override)
|
||||
2. ``$COMFYUI_USER_DIR/wildcard-pipeline.db``
|
||||
3. Fallback: ``~/.comfyui/wildcard-pipeline.db``
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def resolve_db_path() -> Path:
|
||||
override = os.environ.get("WP_DB_PATH")
|
||||
if override:
|
||||
return Path(override)
|
||||
user_dir = os.environ.get("COMFYUI_USER_DIR")
|
||||
if user_dir:
|
||||
return Path(user_dir) / "wildcard-pipeline.db"
|
||||
return Path.home() / ".comfyui" / "wildcard-pipeline.db"
|
||||
|
||||
|
||||
def get_connection(path: Path | None = None) -> sqlite3.Connection:
|
||||
"""Open a SQLite connection with WAL mode and FK enforcement."""
|
||||
db_path = path or resolve_db_path()
|
||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(str(db_path), check_same_thread=False)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL;")
|
||||
conn.execute("PRAGMA foreign_keys=ON;")
|
||||
return conn
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Versioned, forward-only migration runner.
|
||||
|
||||
SQL files live in ``engine/db/migrations_sql/<NNN>_<name>.sql``. Filenames
|
||||
must start with a 3-digit zero-padded version number; the runner applies
|
||||
them in numeric order.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as _dt
|
||||
import re
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
_SQL_DIR = Path(__file__).parent / "migrations_sql"
|
||||
_VERSION_RE = re.compile(r"^(\d{3})_.+\.sql$")
|
||||
|
||||
|
||||
def _migrations_table_exists(conn: sqlite3.Connection) -> bool:
|
||||
row = conn.execute(
|
||||
"SELECT name FROM sqlite_master "
|
||||
"WHERE type='table' AND name='migrations';"
|
||||
).fetchone()
|
||||
return row is not None
|
||||
|
||||
|
||||
def current_version(conn: sqlite3.Connection) -> int:
|
||||
if not _migrations_table_exists(conn):
|
||||
return 0
|
||||
row = conn.execute("SELECT MAX(version) FROM migrations;").fetchone()
|
||||
return int(row[0]) if row and row[0] is not None else 0
|
||||
|
||||
|
||||
def _discover() -> list[tuple[int, Path]]:
|
||||
pairs: list[tuple[int, Path]] = []
|
||||
for entry in _SQL_DIR.iterdir():
|
||||
if not entry.is_file():
|
||||
continue
|
||||
m = _VERSION_RE.match(entry.name)
|
||||
if not m:
|
||||
continue
|
||||
pairs.append((int(m.group(1)), entry))
|
||||
pairs.sort(key=lambda p: p[0])
|
||||
return pairs
|
||||
|
||||
|
||||
def migrate(conn: sqlite3.Connection) -> None:
|
||||
"""Apply all pending migrations. Safe to call repeatedly."""
|
||||
applied = current_version(conn)
|
||||
for version, sql_file in _discover():
|
||||
if version <= applied:
|
||||
continue
|
||||
sql = sql_file.read_text(encoding="utf-8")
|
||||
with conn:
|
||||
conn.executescript(sql)
|
||||
conn.execute(
|
||||
"INSERT INTO migrations(version, applied_at) VALUES(?, ?);",
|
||||
(version, _dt.datetime.utcnow().isoformat(timespec="seconds") + "Z"),
|
||||
)
|
||||
@@ -0,0 +1,30 @@
|
||||
CREATE TABLE IF NOT EXISTS migrations (
|
||||
version INTEGER PRIMARY KEY,
|
||||
applied_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS module_categories (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||||
color TEXT,
|
||||
icon TEXT,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS modules (
|
||||
id TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
category_id TEXT REFERENCES module_categories(id) ON DELETE SET NULL,
|
||||
tags TEXT NOT NULL DEFAULT '[]',
|
||||
is_favorite INTEGER NOT NULL DEFAULT 0,
|
||||
payload TEXT NOT NULL,
|
||||
version INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_modules_type ON modules(type);
|
||||
CREATE INDEX IF NOT EXISTS idx_modules_category ON modules(category_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_modules_updated_at ON modules(updated_at DESC);
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Tests for SQLite connection factory."""
|
||||
import sqlite3
|
||||
|
||||
from engine.db.connection import get_connection, resolve_db_path
|
||||
|
||||
|
||||
def test_resolve_db_path_uses_override(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("WP_DB_PATH", str(tmp_path / "custom.db"))
|
||||
assert resolve_db_path() == tmp_path / "custom.db"
|
||||
|
||||
|
||||
def test_resolve_db_path_default_under_user_dir(tmp_path, monkeypatch):
|
||||
monkeypatch.delenv("WP_DB_PATH", raising=False)
|
||||
monkeypatch.setenv("COMFYUI_USER_DIR", str(tmp_path))
|
||||
assert resolve_db_path() == tmp_path / "wildcard-pipeline.db"
|
||||
|
||||
|
||||
def test_get_connection_returns_sqlite3_connection(tmp_path):
|
||||
conn = get_connection(tmp_path / "test.db")
|
||||
assert isinstance(conn, sqlite3.Connection)
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_get_connection_enables_wal_mode(tmp_path):
|
||||
conn = get_connection(tmp_path / "wal.db")
|
||||
mode = conn.execute("PRAGMA journal_mode;").fetchone()[0]
|
||||
assert mode == "wal"
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_get_connection_enforces_foreign_keys(tmp_path):
|
||||
conn = get_connection(tmp_path / "fk.db")
|
||||
fk = conn.execute("PRAGMA foreign_keys;").fetchone()[0]
|
||||
assert fk == 1
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_get_connection_creates_parent_dir(tmp_path):
|
||||
target = tmp_path / "nested" / "deeper" / "wp.db"
|
||||
conn = get_connection(target)
|
||||
assert target.parent.exists()
|
||||
conn.close()
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Tests for the versioned migration runner."""
|
||||
from engine.db.connection import get_connection
|
||||
from engine.db.migrations import current_version, migrate
|
||||
|
||||
|
||||
def test_initial_version_is_zero(tmp_path):
|
||||
conn = get_connection(tmp_path / "v.db")
|
||||
assert current_version(conn) == 0
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_migrate_creates_modules_and_categories_and_migrations(tmp_path):
|
||||
conn = get_connection(tmp_path / "m.db")
|
||||
migrate(conn)
|
||||
tables = {
|
||||
row[0]
|
||||
for row in conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table';"
|
||||
).fetchall()
|
||||
}
|
||||
assert "modules" in tables
|
||||
assert "module_categories" in tables
|
||||
assert "migrations" in tables
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_migrate_records_version(tmp_path):
|
||||
conn = get_connection(tmp_path / "v2.db")
|
||||
migrate(conn)
|
||||
assert current_version(conn) == 1
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_migrate_is_idempotent(tmp_path):
|
||||
conn = get_connection(tmp_path / "i.db")
|
||||
migrate(conn)
|
||||
migrate(conn)
|
||||
assert current_version(conn) == 1
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_modules_table_has_expected_columns(tmp_path):
|
||||
conn = get_connection(tmp_path / "c.db")
|
||||
migrate(conn)
|
||||
cols = {row[1] for row in conn.execute("PRAGMA table_info(modules);")}
|
||||
assert cols == {
|
||||
"id", "type", "name", "description", "category_id",
|
||||
"tags", "is_favorite", "payload", "version",
|
||||
"created_at", "updated_at",
|
||||
}
|
||||
conn.close()
|
||||
Reference in New Issue
Block a user