Search code examples
postgresqlsqlalchemyfastapisqlmodel

Query `MATERIALIZED VIEW` using declarative, class-based `SQLAlchemy` model definition


Given the following model schema definiton:

from datetime import date, datetime, time
from enum import Enum, unique
from typing import Optional, Tuple
from uuid import uuid4

from pydantic import UUID4, condecimal
from sqlalchemy import Column, DateTime
from sqlalchemy.dialects.postgresql import DATERANGE, ENUM, INT4RANGE, UUID
from sqlalchemy.schema import Index, PrimaryKeyConstraint
from sqlalchemy.sql import func
from sqlmodel import Field, SQLModel, create_engine, UniqueConstraint, select, Session, Relationship

from ..utils.db_view import create_materialized_view  # NOTE: Same/Similar to https://sqlalchemy-utils.readthedocs.io/en/latest/_modules/sqlalchemy_utils/view.html#create_materialized_view
from .user import User


@unique
class Season(str, Enum):
    """Season enum"""

    summer = "summer"
    winter = "winter"
    autumn = "autumn"
    spring = "spring"


@unique
class PeakType(str, Enum):
    """Peak usage? enum"""

    offpeak = "offpeak"
    partialpeak = "partialpeak"
    peak = "peak"


class PlanBase(SQLModel):
    """Base rate plan model"""

    id: Optional[int]
    uid: UUID4 = Field(
        default_factory=uuid4, nullable=False, sa_column=Column(UUID(as_uuid=True))
    )

    name: str
    is_deprecated: Optional[bool] = Field(default=False)


class Plan(PlanBase, table=True):
    """db plan model"""

    __table_args__ = (UniqueConstraint("uid"),)

    id: Optional[int] = Field(default=None, primary_key=True)

    added_on: datetime = Field(default_factory=datetime.utcnow)
    deprecated_on: Optional[datetime]

    rates: list["Rate"] = Relationship(back_populates="plan")
    users: list["User"] = Relationship(back_populates="plan")


class RateBase(SQLModel):
    """Plan rate model"""

    id: Optional[int]
    plan_id: Optional[int] = Field(foreign_key="plan.id")
    plan: Optional[Plan] = Relationship(back_populates="rates")

    rate: condecimal(max_digits=6, decimal_places=5) = Field(default=1.0)
    season: Season = Field(
        sa_column=Column(
            ENUM(Season, metadata=SQLModel.metadata),
            default=Season.summer,
        )
    )
    peak_type: PeakType = Field(
        sa_column=Column(
            ENUM(PeakType, metadata=SQLModel.metadata),
            default=PeakType.offpeak,
        )
    )

    date_range: Tuple[date, date] = Field(nullable=False, sa_column=Column(DATERANGE))
    time_range: Tuple[time, time] = Field(nullable=False, sa_column=Column(INT4RANGE))


class Rate(RateBase, table=True):
    """db rate model"""

    __table_args__ = (
        Index(
            "idx_plan_ts",
            "plan_id",
            "date_range",
            "time_range",
            postgresql_using="gist",
        ),
        UniqueConstraint("plan_id", "date_range", "time_range"),
    )

    id: Optional[int] = Field(default=None, primary_key=True)

    units: list["ConsumptionBase"] = Relationship(back_populates="rate")


class ConsumptionBase(SQLModel):
    """User consumption model"""

    user_id: int = Field(foreign_key="user.id")
    user: Optional["User"] = Relationship(back_populates="consumption")

    rate_id: Optional[int] = Field(foreign_key="rate.id")
    rate: Optional[Rate] = Relationship(back_populates="units")

    ts: datetime = Field(sa_column=Column(DateTime(timezone=True)))
    units: condecimal(max_digits=8, decimal_places=5) = Field(default=0.0)


class Consumption(ConsumptionBase, table=True):
    """db user consumption model"""

    __table_args__ = (
        PrimaryKeyConstraint("user_id", "ts"),
        UniqueConstraint("user_id", "ts"),
        {"timescaledb_hypertable": {"time_column_name": "ts"}},
    )


class Usage(SQLModel):
    """Materialized View combining user, rate and consumption data

    NOTE: Only for creation of view, **DO NOT** use this model for querying
    """

    # NOTE: Same/Similar to https://sqlalchemy-utils.readthedocs.io/en/latest/_modules/sqlalchemy_utils/view.html#create_materialized_view
    # from sqlalchemy_utils import create_materialized_view

    __table__ = create_materialized_view(
        name="usage",
        selectable=select(
            [
                Consumption.user_id,
                Consumption.ts,
                Rate.plan_id,
                Consumption.units,
                func.round(Rate.rate, 4).label("rate"),
                func.round(Consumption.units * Rate.rate, 2).label("cost"),
                Rate.season,
                Rate.peak_type,
            ]
        )
        .join(Rate, Rate.id == Consumption.rate_id)
        .order_by(Consumption.ts.desc()),
        metadata=SQLModel.metadata,
        indexes=[
            Index(
                "idx_usage_user_ts",
                "user_id",
                "ts",
                postgresql_using="gist",
                # postgresql_ops={ "ts": "DESC NULLS LAST"},  # NOTE: `gist` doesn't support `DESC NULLS LAST`
            ),
            Index(
                "idx_usage_user_plan_ts",
                "user_id",
                "plan_id",
                "ts",
                unique=True,
            ),
        ],
    )

engine = create_engine("timescaledb+postgresql://test:pass@localhost:5432/test")
SQLModel.metadata.create_all(engine)


with Session(engine) as session:
    query = select(Usage)
    usage = session.execute(query).scalars().all()  # TypeError: 'SQLModelMetaclass' object is not iterable

The materialized view and other data gets created sucessfully. Looking through the psql console, all indices, columns and subsequent constraints exist. Furthermore, I am able to sucessfully insert data into tables and have said dependent view show the updates. However, I want to be able to query the Usage data table, though because the fields/columns are not explicitly defined on the Usage model, I am unable to query them, specifically, facing this issue:

File "/usr/local/lib/python3.11/site-packages/sqlalchemy/sql/selectable.py", line 5166, in create_legacy_select
     self._raw_columns = [
                         ^
TypeError: 'SQLModelMetaclass' object is not iterable

Inspite of trying different approache(s), viz creating a new model UsageRead with the same tablename and query it instead:

class UsageRead(SQLModel, table=True):
    __tablename__ = "usage"

    user_id: int
    ts: datetime
    plan_id: int
    units: condecimal(max_digits=8, decimal_places=5)
    rate: condecimal(max_digits=6, decimal_places=5)
    cost: condecimal(max_digits=8, decimal_places=2)
    season: Season
    peak_type: PeakType

I am still facing (yet another) error:

sqlalchemy.exc.ArgumentError: Mapper mapped class UsageRead->usage could not assemble any primary key columns for mapped table 'usage'

Trying any combination of table=True, PrimaryKeyConstraint in __table_args__ either returns an empty list or returns the results, but on subsequent restart of the backend service leads to recreation of the Usage view as a regular table.

(PS - I have posted this same question in the sqlmodel discussion forum in case someone wants more information)


Solution

  • Finally solved it using this other SO answer. Basically, we need to create a dummy clone class with all the fields defined and map it to the View class.

    from datetime import datetime
    
    from pydantic import condecimal
    from sqlalchemy.orm import registry
    from sqlalchemy.schema import Index
    from sqlalchemy.sql import func
    from sqlmodel import SQLModel, select
    
    from ..utils.db_view import create_materialized_view
    
    mapper_registry = registry()
    
    
    class Usage(SQLModel):
        """View combining user, rate and consumption data
    
        NOTE: any changes to the `_Usage` schema must be reflected here
        """
    
        __tablename__ = "usage"
    
        user_id: int
        ts: datetime
        plan_id: int
        units: condecimal(max_digits=8, decimal_places=5)
        rate: condecimal(max_digits=6, decimal_places=5)
        cost: condecimal(max_digits=10, decimal_places=2)
        season: Season
        peak_type: PeakType
    
    
    class _Usage(SQLModel):
        """View combining user, rate and consumption data
    
        NOTE: This is an internal model used **ONLY** for creating the materialized view
        """
    
        __table__ = create_materialized_view(
            name="usage",
            selectable=select(
                [
                    Consumption.user_id,
                    Consumption.ts,
                    Rate.plan_id,
                    Consumption.units,
                    func.round(Rate.rate, 4).label("rate"),
                    func.round(Consumption.units * Rate.rate, 2).label("cost"),
                    Rate.season,
                    Rate.peak_type,
                ]
            )
            .join(Rate, Rate.id == Consumption.rate_id)
            .order_by(Consumption.ts.desc()),
            metadata=SQLModel.metadata,
            indexes=[
                Index(
                    "idx_usage_user_ts",
                    "user_id",
                    "ts",
                    postgresql_using="gist",
                    # postgresql_ops={ "ts": "DESC NULLS LAST"},  # NOTE: `gist` doesn't support `DESC NULLS LAST`
                ),
                Index(
                    "idx_usage_user_plan_ts",
                    "user_id",
                    "ts",
                    "plan_id",
                    unique=True,
                ),
            ],
        )
    
    
    mapper_registry.map_imperatively(Usage, _Usage.__table__)
    

    Now I can use Usage anywhere to query/filter/etc!