diff --git a/backend/eq2db/_catalogue.py b/backend/eq2db/_catalogue.py index 84787c73..5a66db67 100644 --- a/backend/eq2db/_catalogue.py +++ b/backend/eq2db/_catalogue.py @@ -44,11 +44,21 @@ from pathlib import Path from typing import ClassVar +from backend.db_helpers import like_escape from backend.eq2db import _meta as _meta_db _log = logging.getLogger(__name__) +def _is_unbuilt_schema(exc: sqlite3.OperationalError) -> bool: + """True for the "DB file exists but the schema hasn't been created yet" + class of errors the read helpers degrade gracefully on (fresh volume, + pre-seeded stub file). Anything else — locked database, disk I/O, SQL + syntax — is a real fault and must propagate.""" + msg = str(exc) + return "no such table" in msg or "no such column" in msg + + class BaseCatalogue: """Read (and build) access to one eq2db SQLite file.""" @@ -138,34 +148,62 @@ def _fetchall(self, sql: str, params: Sequence | Mapping = ()) -> list[sqlite3.R """Run one read query with Row factory; the connection is opened and closed per call. Returns [] when the DB file is missing or the table isn't built yet — eq2db read paths degrade gracefully on an - unprovisioned DB rather than 500.""" + unprovisioned DB rather than 500. Other OperationalErrors (locked DB, + disk I/O) propagate: a transient failure must surface as an error, + not be served — and possibly cached — as an empty result.""" if not self.path.exists(): return [] conn = sqlite3.connect(self.path) conn.row_factory = sqlite3.Row try: return conn.execute(sql, params).fetchall() - except sqlite3.OperationalError: - _log.exception("[eq2db] read failed on %r (unbuilt db?)", self) + except sqlite3.OperationalError as exc: + if not _is_unbuilt_schema(exc): + raise + _log.warning("[eq2db] read on unbuilt db %r: %s", self, exc) return [] finally: conn.close() def _fetchone(self, sql: str, params: Sequence | Mapping = ()) -> sqlite3.Row | None: """Single-row variant of :meth:`_fetchall`. None on missing DB, - unbuilt table, or no match.""" + unbuilt table, or no match; other OperationalErrors propagate.""" if not self.path.exists(): return None conn = sqlite3.connect(self.path) conn.row_factory = sqlite3.Row try: return conn.execute(sql, params).fetchone() - except sqlite3.OperationalError: - _log.exception("[eq2db] read failed on %r (unbuilt db?)", self) + except sqlite3.OperationalError as exc: + if not _is_unbuilt_schema(exc): + raise + _log.warning("[eq2db] read on unbuilt db %r: %s", self, exc) return None finally: conn.close() + def _find_exact_then_like(self, exact_sql: str, like_sql: str, name: str) -> list[sqlite3.Row]: + """The shared name-search protocol: exact lowercased match first, then + a LIKE fallback with user wildcards escaped (BE-006) — both queries on + ONE connection. ``exact_sql`` takes the lowercased name; ``like_sql`` + takes the escaped %-wrapped pattern with ``ESCAPE '\\'`` semantics.""" + if not self.path.exists(): + return [] + conn = sqlite3.connect(self.path) + conn.row_factory = sqlite3.Row + try: + rows = conn.execute(exact_sql, (name.lower(),)).fetchall() + if not rows: + rows = conn.execute(like_sql, (f"%{like_escape(name.lower())}%",)).fetchall() + return rows + except sqlite3.OperationalError as exc: + if not _is_unbuilt_schema(exc): + raise + _log.warning("[eq2db] read on unbuilt db %r: %s", self, exc) + return [] + finally: + conn.close() + # ── DB lifecycle ───────────────────────────────────────────────────────── def init_db(self) -> sqlite3.Connection: @@ -173,16 +211,16 @@ def init_db(self) -> sqlite3.Connection: Template method: the connection preamble and commit live here; the module-specific schema comes from ``_create_schema`` and any - post-commit backfills from ``_post_init``. ``:memory:`` is - supported for tests (skips mkdir + WAL). + post-commit backfills from ``_post_init``. + + ``:memory:`` is deliberately NOT supported: every read helper opens + its own connection against ``self.path``, so a memory DB would be a + fresh empty database per read — tests use a tmp_path file instead. """ _log.debug("[eq2db] init_db %r", self) - if str(self.path) == ":memory:": - conn = sqlite3.connect(":memory:") - else: - self.path.parent.mkdir(parents=True, exist_ok=True) - conn = sqlite3.connect(self.path) - conn.execute("PRAGMA journal_mode = WAL;") + self.path.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(self.path) + conn.execute("PRAGMA journal_mode = WAL;") conn.execute("PRAGMA synchronous = NORMAL;") if self.FOREIGN_KEYS: conn.execute("PRAGMA foreign_keys = ON;") diff --git a/backend/eq2db/items.py b/backend/eq2db/items.py index 7c260650..06005b0d 100644 --- a/backend/eq2db/items.py +++ b/backend/eq2db/items.py @@ -22,6 +22,8 @@ import aiosqlite +from backend.census._coerce import coerce_int as _coerce_int +from backend.census._coerce import coerce_str_or_none as _coerce_str from backend.census.item_level import compute_ilvl from backend.db_helpers import like_escape, resolve_db_path from backend.eq2db._catalogue import BaseCatalogue @@ -135,30 +137,18 @@ def _flag(flags: dict, key: str) -> int: def _str_field(item: dict, key: str) -> str | None: - v = item.get(key) - if v is None or isinstance(v, dict): - return None - s = str(v).strip() - return s if s else None + """Stripped string or None — shared Census coercion semantics + (backend.census._coerce), applied to a dict field.""" + return _coerce_str(item.get(key)) def _int_field(v: Any) -> int | None: - if v is None: - return None - try: - return int(v) or None # treat 0 as NULL for quest IDs etc. - except (ValueError, TypeError): - return None + """coerce_int with 0 treated as NULL (quest IDs etc.).""" + return _coerce_int(v) or None -def _int_field_zero(v: Any) -> int | None: - """Like _int_field but keeps 0.""" - if v is None: - return None - try: - return int(v) - except (ValueError, TypeError): - return None +# Keeps 0 — exactly the shared coercer. +_int_field_zero = _coerce_int class GearRow(NamedTuple): @@ -505,7 +495,7 @@ def _backfill_effect_stats(conn: sqlite3.Connection) -> None: try: raw = json.loads(raw_json_str) except Exception as exc: - _log.warning("[db] Failed to parse effect_stats JSON for item_id=%s: %s", item_id, exc) + _log.warning("[items-db] Failed to parse effect_stats JSON for item_id=%s: %s", item_id, exc) continue for stat_name, value in ItemCatalogue.extract_effect_stats(raw).items(): stat_rows.append((item_id, stat_name, value)) diff --git a/backend/eq2db/recipes.py b/backend/eq2db/recipes.py index a067e39a..5a9f5624 100644 --- a/backend/eq2db/recipes.py +++ b/backend/eq2db/recipes.py @@ -36,7 +36,7 @@ from typing import TypedDict, cast from backend.census._coerce import coerce_int as _int -from backend.db_helpers import like_escape, resolve_db_path +from backend.db_helpers import resolve_db_path from backend.eq2db._catalogue import BaseCatalogue from backend.sql_loader import load_sql @@ -133,7 +133,7 @@ def _row_to_dict(row: sqlite3.Row) -> RecipeRow: try: d["secondary_comps"] = json.loads(d.get("secondary_comps") or "[]") except Exception as exc: - _log.warning("[recipes_db] Failed to parse secondary_comps for recipe id=%s: %s", d.get("id"), exc) + _log.warning("[recipes-db] Failed to parse secondary_comps for recipe id=%s: %s", d.get("id"), exc) d["secondary_comps"] = [] return cast(RecipeRow, d) @@ -293,13 +293,11 @@ def find_by_id(self, recipe_id: int) -> RecipeRow | None: def find_by_name(self, name: str) -> list[RecipeRow]: """Return recipes whose name matches (exact then LIKE), ordered by name.""" - rows = self._fetchall(_SQL["find_by_name_exact"].format(cols=_SELECT_COLS), (name.lower(),)) - if not rows: - # LIKE fallback — escape user wildcards (BE-006). - rows = self._fetchall( - _SQL["find_by_name_like"].format(cols=_SELECT_COLS), - (f"%{like_escape(name.lower())}%",), - ) + rows = self._find_exact_then_like( + _SQL["find_by_name_exact"].format(cols=_SELECT_COLS), + _SQL["find_by_name_like"].format(cols=_SELECT_COLS), + name, + ) return [_row_to_dict(r) for r in rows] def find_by_spell(self, spell_name: str, tier: str) -> list[RecipeRow]: diff --git a/backend/eq2db/spells.py b/backend/eq2db/spells.py index aa76c00d..3f70ca9b 100644 --- a/backend/eq2db/spells.py +++ b/backend/eq2db/spells.py @@ -34,7 +34,7 @@ from backend.census._coerce import coerce_float as _float from backend.census._coerce import coerce_int as _int from backend.census._coerce import coerce_str_or_none as _str -from backend.db_helpers import like_escape, resolve_db_path +from backend.db_helpers import resolve_db_path from backend.eq2db._catalogue import BaseCatalogue from backend.sql_loader import load_sql @@ -162,6 +162,11 @@ class SpellCatalogue(BaseCatalogue): changed; stale CRC lookups would lie). """ + # Cache bound — parity with the pre-catalogue @lru_cache(maxsize=4096). + # The route feeding this (GET /aa/spell/{crc}?tier=N) takes arbitrary + # client ints, so an unbounded dict would grow until process restart. + _CRC_CACHE_MAX = 4096 + def __init__(self, path: Path = DB_PATH) -> None: super().__init__(path) self._crc_cache: dict[tuple[int, int | None], SpellRow | None] = {} @@ -252,7 +257,7 @@ def load_blocklist(path: Path = _BLOCKLIST_PATH) -> Blocklist: return Blocklist(frozenset(exact), patterns) except Exception as exc: - _log.warning("[spells_db] Failed to load blocklist: %s", exc) + _log.warning("[spells-db] Failed to load blocklist: %s", exc) return Blocklist(frozenset(), []) @staticmethod @@ -282,7 +287,7 @@ def _parse_effects(spell: dict) -> str: return "[]" if not isinstance(raw, list): _log.warning( - "[spells_db] effect_list for spell %s has unexpected shape %s — returning empty", + "[spells-db] effect_list for spell %s has unexpected shape %s — returning empty", spell.get("id"), type(raw).__name__, ) @@ -414,18 +419,20 @@ def find_by_crc(self, crc: int, tier: int | None = None) -> SpellRow | None: # Fallback: highest available tier row = self._fetchone(_SQL["find_by_crc_highest_tier"].format(cols=_SELECT_COLS), (crc,)) result = _row_to_dict(row) if row else None + if len(self._crc_cache) >= self._CRC_CACHE_MAX: + # FIFO eviction (dicts preserve insertion order) — cheap and + # good enough for a cache that upsert_spells fully clears anyway. + self._crc_cache.pop(next(iter(self._crc_cache))) self._crc_cache[key] = result return result def find_by_name(self, name: str) -> list[SpellRow]: """Return all spell rows whose name matches (exact, then LIKE). Ordered by level.""" - rows = self._fetchall(_SQL["find_by_name_exact"].format(cols=_SELECT_COLS), (name.lower(),)) - if not rows: - # LIKE fallback — escape user wildcards (BE-006). - rows = self._fetchall( - _SQL["find_by_name_like"].format(cols=_SELECT_COLS), - (f"%{like_escape(name.lower())}%",), - ) + rows = self._find_exact_then_like( + _SQL["find_by_name_exact"].format(cols=_SELECT_COLS), + _SQL["find_by_name_like"].format(cols=_SELECT_COLS), + name, + ) return [_row_to_dict(r) for r in rows] def character_upgradeable_spells(self, spell_ids: list[int]) -> list[SpellRow]: diff --git a/backend/eq2db/zones.py b/backend/eq2db/zones.py index ea163883..e22190b7 100644 --- a/backend/eq2db/zones.py +++ b/backend/eq2db/zones.py @@ -180,7 +180,7 @@ def to_dict(self) -> dict: # ----------------------------------------------------------------- @classmethod - def find_by_name(cls, name: str, path: Path = DB_PATH) -> Zone | None: + def find_by_name(cls, name: str, *, path: Path) -> Zone | None: """Resolve a zone by name, falling back to the alias table. Lookup order: @@ -215,7 +215,7 @@ def list_by_expansion( expansion_short: str, *, type_filter: str | None = None, - path: Path = DB_PATH, + path: Path, ) -> list[Zone]: """All zones in an expansion, ordered by name. Optionally filter to a single type token (e.g. 'raid_x4', 'group', 'tradeskill').""" @@ -236,7 +236,7 @@ def list_by_expansion( return [cls._from_row(conn, r) for r in rows] @classmethod - def list_by_event(cls, event_name: str, path: Path = DB_PATH) -> list[Zone]: + def list_by_event(cls, event_name: str, *, path: Path) -> list[Zone]: """All zones for a recurring in-game event (Tinkerfest, Frostfell).""" if not path.exists(): return [] @@ -249,7 +249,7 @@ def list_by_event(cls, event_name: str, path: Path = DB_PATH) -> list[Zone]: return [cls._from_row(conn, r) for r in rows] @classmethod - def list_by_type(cls, type_token: str, path: Path = DB_PATH) -> list[Zone]: + def list_by_type(cls, type_token: str, *, path: Path) -> list[Zone]: """All zones tagged with a given type token across all expansions.""" if not path.exists(): return [] @@ -262,7 +262,7 @@ def list_by_type(cls, type_token: str, path: Path = DB_PATH) -> list[Zone]: return [cls._from_row(conn, r) for r in rows] @classmethod - def find_by_boss(cls, mob_name: str, path: Path = DB_PATH) -> list[Zone]: + def find_by_boss(cls, mob_name: str, *, path: Path) -> list[Zone]: """Reverse lookup: which zone(s) host a given raid boss? Joins through zone_encounter_mobs so individual mob names inside @@ -285,7 +285,7 @@ def find_by_boss(cls, mob_name: str, path: Path = DB_PATH) -> list[Zone]: # zone_types mutations # ----------------------------------------------------------------- - def add_type(self, type_token: str, path: Path = DB_PATH) -> Zone: + def add_type(self, type_token: str, *, path: Path) -> Zone: """Add a type tag (e.g. 'dungeon') to this zone. Idempotent — adding the same tag twice is a no-op (INSERT OR IGNORE against the PK). Returns a freshly-loaded Zone reflecting the new tag @@ -304,7 +304,7 @@ def add_type(self, type_token: str, path: Path = DB_PATH) -> Zone: # we just held it in memory; nothing deletes it concurrently. return Zone._from_row(conn, row) - def remove_type(self, type_token: str, path: Path = DB_PATH) -> Zone: + def remove_type(self, type_token: str, *, path: Path) -> Zone: """Remove a type tag from this zone. Idempotent — a no-op when the tag isn't present. Returns a freshly-loaded Zone reflecting the updated tag list.""" @@ -406,7 +406,7 @@ def add_to_encounter( *, mob_name: str, make_primary: bool = False, - path: Path = DB_PATH, + path: Path, ) -> ZoneEncounterMob: """Add a mob to an encounter. By default appends as a sibling at the next available position. With ``make_primary=True``, shifts every @@ -446,7 +446,7 @@ def add_to_encounter( return cls._from_row(row, encounter_id=encounter_id) @classmethod - def find_by_id(cls, mob_id: int, path: Path = DB_PATH) -> ZoneEncounterMob | None: + def find_by_id(cls, mob_id: int, *, path: Path) -> ZoneEncounterMob | None: """Single-mob fetch by id. Returns None if not found.""" with sqlite3.connect(path) as conn: conn.row_factory = sqlite3.Row @@ -458,7 +458,7 @@ def find_by_id(cls, mob_id: int, path: Path = DB_PATH) -> ZoneEncounterMob | Non ) @classmethod - def list_for_encounter(cls, encounter_id: int, path: Path = DB_PATH) -> list[ZoneEncounterMob]: + def list_for_encounter(cls, encounter_id: int, *, path: Path) -> list[ZoneEncounterMob]: """All mobs for an encounter, ordered by position.""" with sqlite3.connect(path) as conn: conn.row_factory = sqlite3.Row @@ -467,7 +467,7 @@ def list_for_encounter(cls, encounter_id: int, path: Path = DB_PATH) -> list[Zon for r in conn.execute(_SQL["list_mobs_for_encounter_asc"], (encounter_id,)) ] - def rename(self, new_mob_name: str, path: Path = DB_PATH) -> ZoneEncounterMob: + def rename(self, new_mob_name: str, *, path: Path) -> ZoneEncounterMob: """Rename. If this mob is at position 0 (the primary), also updates the parent encounter_name so the two stay in sync, and mirrors the rename onto raids_db.raid_encounters (if a row exists there). @@ -487,7 +487,7 @@ def rename(self, new_mob_name: str, path: Path = DB_PATH) -> ZoneEncounterMob: id=self.id, encounter_id=self.encounter_id, mob_name=new_mob_name, position=self.position ) - def promote_to_primary(self, path: Path = DB_PATH) -> ZoneEncounterMob: + def promote_to_primary(self, *, path: Path) -> ZoneEncounterMob: """Swap this mob (a sibling) with the current primary (position 0). No-op if already primary. Updates the parent encounter_name and mirrors the rename onto raids_db. Returns the promoted instance.""" @@ -517,7 +517,7 @@ def promote_to_primary(self, path: Path = DB_PATH) -> ZoneEncounterMob: ) return ZoneEncounterMob(id=self.id, encounter_id=self.encounter_id, mob_name=self.mob_name, position=0) - def delete(self, path: Path = DB_PATH) -> bool: + def delete(self, *, path: Path) -> bool: """Delete this mob. Refuses with ValueError when it's the only mob in the encounter (an encounter needs ≥ 1 mob) or when it's the primary while siblings exist (caller must promote a sibling first). @@ -612,7 +612,7 @@ def to_dict(self, *, with_mob_ids: bool = False) -> dict: } @classmethod - def find_by_id(cls, encounter_id: int, path: Path = DB_PATH) -> ZoneEncounter | None: + def find_by_id(cls, encounter_id: int, *, path: Path) -> ZoneEncounter | None: """Single-encounter fetch by id, with mobs. None if not found.""" with sqlite3.connect(path) as conn: conn.row_factory = sqlite3.Row @@ -629,7 +629,7 @@ def _list_for_zone_id(cls, conn: sqlite3.Connection, zone_id: int) -> list[ZoneE return [cls._from_row(conn, r) for r in conn.execute(_SQL["list_encounters_for_zone"], (zone_id,)).fetchall()] @classmethod - def list_for_zone_name(cls, zone_name: str, path: Path = DB_PATH) -> list[ZoneEncounter]: + def list_for_zone_name(cls, zone_name: str, *, path: Path) -> list[ZoneEncounter]: """All raid encounters in a zone, resolving the zone by canonical name OR alias. Empty list if zone unknown or has no encounters.""" if not path.exists() or not zone_name: @@ -713,7 +713,7 @@ def add_to_zone( position: int | None = None, stage: str | None = None, wiki_url: str | None = None, - path: Path = DB_PATH, + path: Path, ) -> ZoneEncounter: """Append a new encounter to a zone with a single primary mob at position 0. If ``position`` is None, appends after the current max; @@ -738,7 +738,7 @@ def update( primary_mob: str | None = None, stage: str | None = _UNSET, # type: ignore[assignment] wiki_url: str | None = _UNSET, # type: ignore[assignment] - path: Path = DB_PATH, + path: Path, ) -> ZoneEncounter: """Edit encounter metadata. When ``primary_mob`` is given, also renames the position-0 mob in zone_encounter_mobs and mirrors the @@ -763,7 +763,7 @@ def update( _mirror_primary_rename_in_raids_db(self.zone_id, self.encounter_name, primary_mob, path) return result - def delete(self, path: Path = DB_PATH) -> bool: + def delete(self, *, path: Path) -> bool: """Delete this encounter. Cascades zone_encounter_mobs via FK and the matching raids_db row (which itself cascades triggers / timers / strategies). Returns True if a row was deleted.""" @@ -783,7 +783,7 @@ def delete(self, path: Path = DB_PATH) -> bool: return True @staticmethod - def reorder_in_zone(zone_id: int, ordered_ids: list[int], path: Path = DB_PATH) -> None: + def reorder_in_zone(zone_id: int, ordered_ids: list[int], *, path: Path) -> None: """Atomically renumber the zone's encounters to 1..N matching the given order. ``ordered_ids`` MUST be a complete permutation of the zone's current encounter ids — raises ValueError otherwise. The @@ -875,7 +875,7 @@ def to_dict(self) -> dict: return {"short": self.expansion_short, "name": self.name, "year": self.year} @classmethod - def list_active(cls, path: Path = DB_PATH) -> list[FeaturedRaidExpansion]: + def list_active(cls, *, path: Path) -> list[FeaturedRaidExpansion]: """Featured expansions (explicit + implicit-via-zones), newest first.""" if not path.exists(): return [] @@ -885,7 +885,7 @@ def list_active(cls, path: Path = DB_PATH) -> list[FeaturedRaidExpansion]: return _dedup_expansion_rows(rows) @classmethod - def list_available(cls, path: Path = DB_PATH) -> list[FeaturedRaidExpansion]: + def list_available(cls, *, path: Path) -> list[FeaturedRaidExpansion]: """Expansions in zones.db NOT yet featured (neither explicit row nor any zone of theirs featured). The admin 'Add expansion' picker.""" if not path.exists(): @@ -896,7 +896,7 @@ def list_available(cls, path: Path = DB_PATH) -> list[FeaturedRaidExpansion]: return _dedup_expansion_rows(rows) @classmethod - def create(cls, expansion_short: str, path: Path = DB_PATH) -> FeaturedRaidExpansion | None: + def create(cls, expansion_short: str, *, path: Path) -> FeaturedRaidExpansion | None: """Mark an expansion as featured. Validates that the expansion is known to zones.db (returns None otherwise — route layer maps to 404). Idempotent for already-featured expansions: returns the @@ -912,7 +912,7 @@ def create(cls, expansion_short: str, path: Path = DB_PATH) -> FeaturedRaidExpan conn.commit() return cls(expansion_short=expansion_short, name=meta["name"], year=meta["year"]) - def remove(self, path: Path = DB_PATH) -> bool: + def remove(self, *, path: Path) -> bool: """Remove from featured AND cascade-remove this expansion's featured raid zones. Preserves the underlying zone_encounters data — just hides everything from /raids until re-added. @@ -954,7 +954,7 @@ class FeaturedRaidZone: category: str | None @classmethod - def list_for_expansion(cls, expansion_short: str, path: Path = DB_PATH) -> list[FeaturedRaidZone]: + def list_for_expansion(cls, expansion_short: str, *, path: Path) -> list[FeaturedRaidZone]: """All featured raid zones for an expansion, sorted by (category, position). NULL categories sort first (SQLite default), which lands the implicit Uncategorised lane at the top.""" @@ -978,7 +978,7 @@ def list_for_expansion(cls, expansion_short: str, path: Path = DB_PATH) -> list[ ] @classmethod - def add(cls, zone_name: str, path: Path = DB_PATH) -> FeaturedRaidZone | None: + def add(cls, zone_name: str, *, path: Path) -> FeaturedRaidZone | None: """Mark a raid zone as featured. Validates that the zone exists AND is tagged raid_x4 or raid_x2 — we don't want random zones surfacing on /raids just because admin typed a name. Lands in @@ -1017,7 +1017,7 @@ def add(cls, zone_name: str, path: Path = DB_PATH) -> FeaturedRaidZone | None: category=None, ) - def remove(self, path: Path = DB_PATH) -> bool: + def remove(self, *, path: Path) -> bool: """Remove from featured. Preserves zone_encounters boss data so re-adding restores the lane. Returns True if a row was removed.""" if not path.exists(): @@ -1031,7 +1031,7 @@ def remove(self, path: Path = DB_PATH) -> bool: return cur.rowcount > 0 @staticmethod - def reorder_in_expansion(expansion_short: str, ordering: list[dict], path: Path = DB_PATH) -> bool: + def reorder_in_expansion(expansion_short: str, ordering: list[dict], *, path: Path) -> bool: """Atomically rewrite category + position for every zone in ``ordering``. Each entry: ``{"name": str, "category": str | None, "position": int}``. @@ -1108,7 +1108,7 @@ def to_dict(self) -> dict: return {"name": self.name, "position": self.position} @classmethod - def list_for_expansion(cls, expansion_short: str, path: Path = DB_PATH) -> list[FeaturedRaidCategory]: + def list_for_expansion(cls, expansion_short: str, *, path: Path) -> list[FeaturedRaidCategory]: """Admin-defined categories in saved order.""" if not path.exists(): return [] @@ -1121,7 +1121,7 @@ def list_for_expansion(cls, expansion_short: str, path: Path = DB_PATH) -> list[ return [cls(expansion_short=expansion_short, name=r["name"], position=r["position"]) for r in rows] @classmethod - def create(cls, expansion_short: str, name: str, path: Path = DB_PATH) -> FeaturedRaidCategory | None: + def create(cls, expansion_short: str, name: str, *, path: Path) -> FeaturedRaidCategory | None: """Create an empty category lane at MAX+1 position. Returns None if a category by this name already exists for the expansion.""" if not path.exists(): @@ -1145,7 +1145,7 @@ def create(cls, expansion_short: str, name: str, path: Path = DB_PATH) -> Featur conn.commit() return cls(expansion_short=expansion_short, name=name, position=new_pos) - def delete(self, path: Path = DB_PATH) -> bool: + def delete(self, *, path: Path) -> bool: """Delete this category. Zones currently in it have their category set to NULL (move to Uncategorised). Returns True if a row was deleted.""" @@ -1164,7 +1164,7 @@ def delete(self, path: Path = DB_PATH) -> bool: return cur.rowcount > 0 @staticmethod - def reorder_in_expansion(expansion_short: str, ordering: list[dict], path: Path = DB_PATH) -> bool: + def reorder_in_expansion(expansion_short: str, ordering: list[dict], *, path: Path) -> bool: """Atomic two-phase position rewrite for category lanes. Each entry: ``{"name": str, "position": int}``. Returns False if diff --git a/scripts/backfill_spell_effects.py b/scripts/backfill_spell_effects.py index cd679bae..4606711d 100644 --- a/scripts/backfill_spell_effects.py +++ b/scripts/backfill_spell_effects.py @@ -77,7 +77,7 @@ async def main(dry_run: bool) -> None: if service_id == "example": print("WARNING: using 'example' service ID — rate limits will be low.") - conn = catalogue.init_db(DB_PATH) + conn = catalogue.init_db() null_rows = conn.execute("SELECT id FROM spells WHERE effects IS NULL ORDER BY id").fetchall() ids = [r[0] for r in null_rows] diff --git a/scripts/dev/_smoke_test_zones_db.py b/scripts/dev/_smoke_test_zones_db.py index 0e364810..9c566e93 100644 --- a/scripts/dev/_smoke_test_zones_db.py +++ b/scripts/dev/_smoke_test_zones_db.py @@ -80,18 +80,18 @@ def check(label: str, cond: bool, detail: str = "") -> None: # ── find_by_name: canonical match ───────────────────────────────────── print("\n--- find_by_name ---") -z = zones_db.find_by_name("Sebilis") +z = zones_db.catalogue.find_by_name("Sebilis") check( "find_by_name('Sebilis') returns RoK openworld", z and z["expansion_short"] == "RoK", f"got {z['expansion_short'] if z else None}, types={z['types'] if z else None}", ) -z = zones_db.find_by_name("SEBILIS") +z = zones_db.catalogue.find_by_name("SEBILIS") check("find_by_name case-insensitive", z is not None and z["name"] == "Sebilis", f"got {z['name'] if z else None}") # ── find_by_name: alias resolution ──────────────────────────────────── -z = zones_db.find_by_name("The Fabled Deathtoll") +z = zones_db.catalogue.find_by_name("The Fabled Deathtoll") check( "alias 'The Fabled Deathtoll' resolves to canonical 'Fabled Deathtoll'", z is not None and z["name"] == "Fabled Deathtoll", @@ -100,7 +100,7 @@ def check(label: str, cond: bool, detail: str = "") -> None: if z: check("resolved canonical has alias listed", "The Fabled Deathtoll" in z["aliases"], f"aliases={z['aliases']}") -z = zones_db.find_by_name("not a real zone") +z = zones_db.catalogue.find_by_name("not a real zone") check("find_by_name returns None for unknown", z is None, "") # ── list_by_expansion ──────────────────────────────────────────────── @@ -248,7 +248,7 @@ def check(label: str, cond: bool, detail: str = "") -> None: ) # find_by_name hydrates the bosses array on every zone fetch - vp_zone = zones_db.find_by_name("Veeshan's Peak") + vp_zone = zones_db.catalogue.find_by_name("Veeshan's Peak") check( "find_by_name('Veeshan's Peak') hydrates 13 bosses", vp_zone is not None and len(vp_zone["bosses"]) == 13, diff --git a/scripts/download_spells.py b/scripts/download_spells.py index 2c85c574..6c827579 100644 --- a/scripts/download_spells.py +++ b/scripts/download_spells.py @@ -114,7 +114,7 @@ async def main(restart: bool, spell_limit: int | None) -> None: if service_id == "example": print("WARNING: using 'example' service ID — rate limits will be low.") - conn = catalogue.init_db(DB_PATH) + conn = catalogue.init_db() existing = catalogue.spell_count(conn) print(f"DB: {DB_PATH}") print(f"Existing rows: {existing:,}") @@ -196,7 +196,7 @@ async def main(restart: bool, spell_limit: int | None) -> None: print("\nReached end of Census data — offset reset for next run.") conn.close() - final = catalogue.spell_count(catalogue.init_db(DB_PATH)) + final = catalogue.spell_count(catalogue.init_db()) print(f"\nDone. Written this run: {written:,} | Total in DB: {final:,}") diff --git a/tests/eq2db/test_catalogue_base.py b/tests/eq2db/test_catalogue_base.py index 1ca269e1..088d7b02 100644 --- a/tests/eq2db/test_catalogue_base.py +++ b/tests/eq2db/test_catalogue_base.py @@ -148,6 +148,30 @@ def test_close_happens_on_exception(self, tmp_path: Path): conn.execute("SELECT 1") +# --------------------------------------------------------------------------- +# _fetchall / _fetchone error handling +# --------------------------------------------------------------------------- + + +class TestReadHelperErrors: + def test_unbuilt_schema_degrades_to_empty(self, tmp_path: Path): + """File exists but tables don't (fresh volume / stub) -> [] not raise.""" + db = tmp_path / "stub.db" + sqlite3.connect(db).close() # zero-byte real file, no schema + cat = RaidCatalogue(db) + assert cat._fetchall("SELECT * FROM raid_zones") == [] + assert cat._fetchone("SELECT * FROM raid_zones") is None + + def test_other_operational_errors_propagate(self, tmp_path: Path): + """Non-schema faults (here: SQL syntax) must NOT be swallowed as empty.""" + cat = RaidCatalogue(tmp_path / "raids.db") + cat.init_db().close() + with pytest.raises(sqlite3.OperationalError): + cat._fetchall("SELEKT broken") + with pytest.raises(sqlite3.OperationalError): + cat._fetchone("SELEKT broken") + + # --------------------------------------------------------------------------- # __init_subclass__ # --------------------------------------------------------------------------- diff --git a/tests/eq2db/test_spells.py b/tests/eq2db/test_spells.py index 9a09e1af..2e9e7d71 100644 --- a/tests/eq2db/test_spells.py +++ b/tests/eq2db/test_spells.py @@ -571,3 +571,14 @@ def test_lru_cache_returns_same_result(self, db): result2 = db.find_by_crc(crc=888, tier=1) assert result1 == result2 assert result1 is not None + + def test_crc_cache_is_bounded(self, db): + """The cache evicts past _CRC_CACHE_MAX — arbitrary client (crc, tier) + pairs (the /aa/spell route) must not grow memory without bound.""" + db._CRC_CACHE_MAX = 4 # shadow the class bound on this instance + for crc in range(10): + db.find_by_crc(crc=crc, tier=1) # misses — None results still cached + assert len(db._crc_cache) <= 4 + # Newest keys survive, oldest evicted (FIFO) + assert (9, 1) in db._crc_cache + assert (0, 1) not in db._crc_cache