diff --git a/netx_api/schema_patches.py b/netx_api/schema_patches.py index d073e8e..120956c 100644 --- a/netx_api/schema_patches.py +++ b/netx_api/schema_patches.py @@ -12,6 +12,7 @@ columns and indexes that ``create_all`` will not evolve on existing DBs. from __future__ import annotations import logging +import re from sqlalchemy import text from sqlalchemy.engine import Connection, Engine @@ -23,6 +24,24 @@ def _dialect_name(conn: Connection) -> str: return str(getattr(conn.dialect, "name", "") or "").lower() +def _normalize_ddl_for_dialect(conn: Connection, sql: str) -> str: + """SQLite lacks ``ADD COLUMN IF NOT EXISTS`` (PG 9.5+/11+). Strip for quiet retry.""" + stmt = str(sql or "").strip() + if not stmt: + return stmt + if _dialect_name(conn) == "sqlite": + # Keep CREATE/INDEX IF NOT EXISTS; only ALTER TABLE … ADD COLUMN needs help. + upper = stmt.upper() + if "ADD COLUMN IF NOT EXISTS" in upper: + stmt = re.sub( + r"(?i)\bADD\s+COLUMN\s+IF\s+NOT\s+EXISTS\b", + "ADD COLUMN", + stmt, + count=1, + ) + return stmt + + def _run_sql(conn: Connection, sql: str, *, quiet: bool = True) -> None: """Run one DDL/DML statement. @@ -30,7 +49,7 @@ def _run_sql(conn: Connection, sql: str, *, quiet: bool = True) -> None: patches must use a SAVEPOINT (``begin_nested``) so Alembic can still stamp ``alembic_version`` after a best-effort ALTER fails. """ - stmt = str(sql or "").strip() + stmt = _normalize_ddl_for_dialect(conn, sql) if not stmt: return @@ -143,6 +162,16 @@ def apply_topology_schema_safety_net(conn: Connection) -> None: conn, "CREATE INDEX IF NOT EXISTS ix_topo_fabric_node_world_y ON topo_fabric_node (world_y)", ) + # UME link ifnames — required by topology sync/apply; missing when DB predates the columns + # or Alembic was stamped head without running domain patches. + _run_sql( + conn, + "ALTER TABLE ume_topo_link ADD COLUMN IF NOT EXISTS a_ifname VARCHAR(128) DEFAULT ''", + ) + _run_sql( + conn, + "ALTER TABLE ume_topo_link ADD COLUMN IF NOT EXISTS z_ifname VARCHAR(128) DEFAULT ''", + ) def apply_key_alert_schema_patches( @@ -408,6 +437,8 @@ def apply_domain_schema_patches(conn: Connection) -> None: z_ume_ne_id VARCHAR(128) DEFAULT '', a_ptp VARCHAR(256) DEFAULT '', z_ptp VARCHAR(256) DEFAULT '', + a_ifname VARCHAR(128) DEFAULT '', + z_ifname VARCHAR(128) DEFAULT '', first_seen_at TIMESTAMP, last_seen_at TIMESTAMP, raw_json TEXT DEFAULT '{}' diff --git a/tests/test_schema_patches.py b/tests/test_schema_patches.py index 2f0b3cf..dbd5c91 100644 --- a/tests/test_schema_patches.py +++ b/tests/test_schema_patches.py @@ -10,7 +10,11 @@ from sqlalchemy.orm import sessionmaker from netx_api.db import Base import netx_api.models # noqa: F401 -from netx_api.schema_patches import apply_auth_schema_patches, apply_domain_schema_patches +from netx_api.schema_patches import ( + apply_auth_schema_patches, + apply_domain_schema_patches, + apply_topology_schema_safety_net, +) class SchemaPatchesTests(unittest.TestCase): @@ -34,6 +38,28 @@ class SchemaPatchesTests(unittest.TestCase): self.assertIn("expires_at", token_cols) self.assertIn("auth_session", insp.get_table_names()) + def test_topology_safety_net_adds_ume_link_ifnames(self) -> None: + """Brownfield: table without ifnames; safety-net must add them (sync depends on it).""" + with self.engine.begin() as conn: + conn.execute(text("DROP TABLE IF EXISTS ume_topo_link")) + conn.execute( + text( + """ + CREATE TABLE ume_topo_link ( + link_id VARCHAR(128) PRIMARY KEY, + name VARCHAR(1024) DEFAULT '', + a_ptp VARCHAR(256) DEFAULT '', + z_ptp VARCHAR(256) DEFAULT '' + ) + """ + ) + ) + apply_topology_schema_safety_net(conn) + apply_topology_schema_safety_net(conn) + cols = {c["name"] for c in inspect(self.engine).get_columns("ume_topo_link")} + self.assertIn("a_ifname", cols) + self.assertIn("z_ifname", cols) + def test_domain_patches_do_not_raise(self) -> None: with self.engine.begin() as conn: apply_domain_schema_patches(conn)