- m018_fts_from_normalized_tags: replace FTS triggers to read from asset_tags+tags instead of the legacy JSON asset_metadata.tags column - m019_drop_legacy_tag_columns: remove asset_metadata.tags and tags_text via create-copy-swap; includes invariant check (aborts if legacy tags not backfilled) - registry.py: MIGRATIONS = [M017, M018, M019] - schema_fts.py / schema_sql.py: FTS helpers and baseline triggers rewritten to source from normalized tables with asset_tags guard - TagsRepository: normalized read path (get_tags_for_asset, list_assets_with_tag, search_assets_by_tags) + sync_from_legacy_row repair helper - Readers v2: searcher, result_hydrator, metadata_helpers, updater, rating_tags_service, rename_service, stacks/service, duplicates/service migrated to asset_tags/tags JOIN - Stop-Write: writes no longer update asset_metadata.tags / tags_text - sqlite_facade: ahas_table helper; sqlite_recovery updated - deps.py: TagsRepository wired into service container - scripts/tag_drift_doctor.py: detects assets with legacy tags not backfilled - ADR 0009, ARCHITECTURE_MAP, RATINGS_TAGS_COLLECTIONS, adr/README updated - Tests: test_drop_legacy_tag_columns (2), test_fts_from_normalized (6), test_tags_dual_write, test_tags_normalized_readers + regression fixes across test_rating_tags_import, test_index_metadata_helpers, test_index_searcher, test_asset_updater, test_deps, test_schema_heal - Full suite: 1687 passed, 2 skipped, 2 pre-existing failures (test_health_routes)
149 lines
4.4 KiB
Python
149 lines
4.4 KiB
Python
import sqlite3
|
|
|
|
import pytest
|
|
|
|
|
|
def _create_partial_schema(db_path, current_schema_version) -> None:
|
|
conn = sqlite3.connect(str(db_path))
|
|
try:
|
|
conn.execute("CREATE TABLE metadata (key TEXT PRIMARY KEY, value TEXT NOT NULL)")
|
|
conn.execute(
|
|
"INSERT OR REPLACE INTO metadata (key, value) VALUES ('schema_version', ?)",
|
|
(str(current_schema_version),),
|
|
)
|
|
conn.execute(
|
|
"""
|
|
CREATE TABLE assets (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
filename TEXT NOT NULL,
|
|
filepath TEXT NOT NULL UNIQUE,
|
|
kind TEXT NOT NULL,
|
|
ext TEXT NOT NULL,
|
|
size INTEGER NOT NULL,
|
|
mtime INTEGER NOT NULL
|
|
)
|
|
"""
|
|
)
|
|
conn.execute(
|
|
"""
|
|
CREATE TABLE asset_metadata (
|
|
asset_id INTEGER PRIMARY KEY,
|
|
rating INTEGER DEFAULT 0
|
|
)
|
|
"""
|
|
)
|
|
conn.commit()
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
async def _assert_table_contains_columns(db, table: str, expected_columns: list[str]) -> None:
|
|
cols = await db.aquery(f"PRAGMA table_info('{table}')")
|
|
assert cols.ok, cols.error
|
|
col_names = {row["name"] for row in (cols.data or [])}
|
|
for name in expected_columns:
|
|
assert name in col_names
|
|
|
|
|
|
async def _assert_schema_healed_queries(db) -> None:
|
|
assert (
|
|
await db.aquery("SELECT source, root_id, enhanced_caption, content_hash, phash, hash_state FROM assets LIMIT 0")
|
|
).ok
|
|
assert (
|
|
await db.aquery(
|
|
"""
|
|
SELECT metadata_text, metadata_raw, has_workflow, has_generation_data, metadata_quality
|
|
FROM asset_metadata
|
|
LIMIT 0
|
|
"""
|
|
)
|
|
).ok
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_schema_self_heals_missing_columns(tmp_path):
|
|
from mjr_am_backend.adapters.db.schema import CURRENT_SCHEMA_VERSION, migrate_schema
|
|
from mjr_am_backend.adapters.db.sqlite import Sqlite
|
|
|
|
db_path = tmp_path / "partial_schema.db"
|
|
_create_partial_schema(db_path, CURRENT_SCHEMA_VERSION)
|
|
|
|
db = Sqlite(str(db_path), attach={"vec": str(tmp_path / "vectors.sqlite")})
|
|
try:
|
|
result = await migrate_schema(db)
|
|
assert result.ok, result.error
|
|
|
|
await _assert_table_contains_columns(
|
|
db,
|
|
"assets",
|
|
[
|
|
"source",
|
|
"root_id",
|
|
"subfolder",
|
|
"width",
|
|
"height",
|
|
"duration",
|
|
"enhanced_caption",
|
|
"indexed_at",
|
|
"content_hash",
|
|
"phash",
|
|
"hash_state",
|
|
],
|
|
)
|
|
await _assert_table_contains_columns(
|
|
db,
|
|
"asset_metadata",
|
|
[
|
|
"metadata_text",
|
|
"workflow_hash",
|
|
"has_workflow",
|
|
"has_generation_data",
|
|
"metadata_quality",
|
|
"metadata_raw",
|
|
],
|
|
)
|
|
await _assert_schema_healed_queries(db)
|
|
finally:
|
|
try:
|
|
await db.aclose()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_sqlite_query_missing_column_triggers_self_heal(tmp_path):
|
|
from mjr_am_backend.adapters.db.schema import ensure_tables_exist
|
|
from mjr_am_backend.adapters.db.sqlite import Sqlite
|
|
|
|
db_path = tmp_path / "missing_hash_columns.db"
|
|
conn = sqlite3.connect(str(db_path))
|
|
try:
|
|
conn.execute(
|
|
"""
|
|
CREATE TABLE assets (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
filename TEXT NOT NULL,
|
|
filepath TEXT NOT NULL UNIQUE,
|
|
kind TEXT NOT NULL,
|
|
ext TEXT NOT NULL,
|
|
size INTEGER NOT NULL,
|
|
mtime INTEGER NOT NULL
|
|
)
|
|
"""
|
|
)
|
|
conn.commit()
|
|
finally:
|
|
conn.close()
|
|
|
|
db = Sqlite(str(db_path), attach={"vec": str(tmp_path / "vectors.sqlite")})
|
|
try:
|
|
# Ensure required companion tables exist, but intentionally do not run migrate_schema().
|
|
assert (await ensure_tables_exist(db)).ok
|
|
res = await db.aquery("SELECT a.content_hash FROM assets a LIMIT 0")
|
|
assert res.ok, res.error
|
|
finally:
|
|
try:
|
|
await db.aclose()
|
|
except Exception:
|
|
pass
|