diff --git a/alembic/env.py b/alembic/env.py index e37432c..ab12c86 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -58,7 +58,9 @@ def run_migrations_offline() -> None: target_metadata=target_metadata, literal_binds=True, dialect_opts={"paramstyle": "named"}, - render_as_batch=True, # SQLite batch mode for ALTER TABLE + # Batch mode is a SQLite-only workaround for its limited ALTER TABLE; + # Postgres performs ALTERs directly. + render_as_batch=url.startswith("sqlite"), ) with context.begin_transaction(): diff --git a/src/meshcore_hub/common/database.py b/src/meshcore_hub/common/database.py index 4b0427b..90c1b31 100644 --- a/src/meshcore_hub/common/database.py +++ b/src/meshcore_hub/common/database.py @@ -11,6 +11,22 @@ from sqlalchemy.orm import Session, sessionmaker from meshcore_hub.common.models.base import Base +def _to_async_url(database_url: str) -> str: + """Map a sync database URL to its async-driver equivalent. + + Leaves an already driver-qualified URL (``dialect+driver://``) untouched so an + explicit driver choice is respected. + """ + scheme = database_url.split("://", 1)[0] + if "+" in scheme: + return database_url + if scheme == "sqlite": + return database_url.replace("sqlite://", "sqlite+aiosqlite://", 1) + if scheme in ("postgresql", "postgres"): + return database_url.replace(f"{scheme}://", "postgresql+asyncpg://", 1) + return database_url + + def create_database_engine( database_url: str, echo: bool = False, @@ -142,7 +158,7 @@ class DatabaseManager: from sqlalchemy.ext.asyncio import async_sessionmaker - async_url = self.database_url.replace("sqlite://", "sqlite+aiosqlite://") + async_url = _to_async_url(self.database_url) self._async_engine = create_async_engine(async_url, echo=self._echo) # Apply the same SQLite pragmas as the sync engine (see diff --git a/src/meshcore_hub/common/models/event_log.py b/src/meshcore_hub/common/models/event_log.py index 13e0a11..ccab483 100644 --- a/src/meshcore_hub/common/models/event_log.py +++ b/src/meshcore_hub/common/models/event_log.py @@ -3,8 +3,7 @@ from datetime import datetime from typing import Any, Optional -from sqlalchemy import DateTime, ForeignKey, Index, String -from sqlalchemy.dialects.sqlite import JSON +from sqlalchemy import DateTime, ForeignKey, Index, JSON, String from sqlalchemy.orm import Mapped, mapped_column from meshcore_hub.common.models.base import Base, TimestampMixin, UUIDMixin, utc_now diff --git a/src/meshcore_hub/common/models/event_observer.py b/src/meshcore_hub/common/models/event_observer.py index a3478cd..40166d3 100644 --- a/src/meshcore_hub/common/models/event_observer.py +++ b/src/meshcore_hub/common/models/event_observer.py @@ -8,13 +8,13 @@ from sqlalchemy import ( DateTime, Float, ForeignKey, + Insert, Integer, Index, String, UniqueConstraint, update, ) -from sqlalchemy.dialects.sqlite import insert as sqlite_insert from sqlalchemy.orm import Mapped, Session, mapped_column, relationship from meshcore_hub.common.models.base import Base, TimestampMixin, UUIDMixin, utc_now @@ -122,21 +122,40 @@ def add_event_observer( now = observed_at or datetime.now(timezone.utc) - stmt = ( - sqlite_insert(EventObserver) - .values( - id=str(uuid4()), - event_type=event_type, - event_hash=event_hash, - observer_node_id=observer_node_id, - snr=snr, - path_len=path_len, - observed_at=now, - created_at=now, - updated_at=now, + # Both SQLite and Postgres expose on_conflict_do_nothing() with the same signature, + # but the INSERT construct must come from the matching dialect or it emits SQL for + # the wrong backend. Build the statement in each branch (rather than aliasing the + # insert() function) so the two dialect-specific Insert types stay distinct. + values = { + "id": str(uuid4()), + "event_type": event_type, + "event_hash": event_hash, + "observer_node_id": observer_node_id, + "snr": snr, + "path_len": path_len, + "observed_at": now, + "created_at": now, + "updated_at": now, + } + conflict_cols = ["event_hash", "observer_node_id"] + stmt: Insert + + if session.get_bind().dialect.name == "postgresql": + from sqlalchemy.dialects.postgresql import insert as pg_insert + + stmt = ( + pg_insert(EventObserver) + .values(**values) + .on_conflict_do_nothing(index_elements=conflict_cols) + ) + else: + from sqlalchemy.dialects.sqlite import insert as sqlite_insert + + stmt = ( + sqlite_insert(EventObserver) + .values(**values) + .on_conflict_do_nothing(index_elements=conflict_cols) ) - .on_conflict_do_nothing(index_elements=["event_hash", "observer_node_id"]) - ) result = session.execute(stmt) rowcount = getattr(result, "rowcount", 0) diff --git a/src/meshcore_hub/common/models/raw_packet.py b/src/meshcore_hub/common/models/raw_packet.py index 66576ab..835c9f7 100644 --- a/src/meshcore_hub/common/models/raw_packet.py +++ b/src/meshcore_hub/common/models/raw_packet.py @@ -3,8 +3,7 @@ from datetime import datetime from typing import Any, Optional -from sqlalchemy import DateTime, Float, ForeignKey, Index, Integer, String, Text -from sqlalchemy.dialects.sqlite import JSON +from sqlalchemy import DateTime, Float, ForeignKey, Index, Integer, JSON, String, Text from sqlalchemy.orm import Mapped, mapped_column from meshcore_hub.common.models.base import Base, TimestampMixin, UUIDMixin, utc_now diff --git a/src/meshcore_hub/common/models/telemetry.py b/src/meshcore_hub/common/models/telemetry.py index abca8ac..db95332 100644 --- a/src/meshcore_hub/common/models/telemetry.py +++ b/src/meshcore_hub/common/models/telemetry.py @@ -3,8 +3,7 @@ from datetime import datetime from typing import Any, Optional -from sqlalchemy import DateTime, ForeignKey, Index, LargeBinary, String -from sqlalchemy.dialects.sqlite import JSON +from sqlalchemy import DateTime, ForeignKey, Index, JSON, LargeBinary, String from sqlalchemy.orm import Mapped, mapped_column from meshcore_hub.common.models.base import Base, TimestampMixin, UUIDMixin, utc_now diff --git a/src/meshcore_hub/common/models/trace_path.py b/src/meshcore_hub/common/models/trace_path.py index df0d48b..31a65da 100644 --- a/src/meshcore_hub/common/models/trace_path.py +++ b/src/meshcore_hub/common/models/trace_path.py @@ -3,8 +3,7 @@ from datetime import datetime from typing import Optional -from sqlalchemy import BigInteger, DateTime, ForeignKey, Index, Integer, String -from sqlalchemy.dialects.sqlite import JSON +from sqlalchemy import BigInteger, DateTime, ForeignKey, Index, Integer, JSON, String from sqlalchemy.orm import Mapped, mapped_column from meshcore_hub.common.models.base import Base, TimestampMixin, UUIDMixin, utc_now