Verified Commit 28a60b0d authored by Jakob Moser's avatar Jakob Moser
Browse files

Add basic model types

parent 3500143c
Loading
Loading
Loading
Loading
Loading

portal/model/Base.py

0 → 100644
+38 −0
Changes for portal/model/Base.py: 38 added lines, 0 removed lines.
Original line number Diff line number Diff line
from typing import Optional, Self
from uuid import uuid4, UUID

from portal import db
from .Retrievable import Retrievable
from .InvalidUuidError import InvalidUuidError
from .Uuid import Uuid


class Base(Retrievable):
    """
    A type with a UUID, stored in the database.

    It also has a method to return a dictionary representation for easy serialization.
    """

    # See https://flask-sqlalchemy.palletsprojects.com/en/3.0.x/customizing/#abstract-models-and-mixins
    __abstract__ = True

    uuid = db.Column(Uuid(), primary_key=True, default=uuid4)

    @classmethod
    def get_only(cls, uuid: str | UUID) -> Optional[Self]:
        if isinstance(uuid, str):
            try:
                # Manually convert the uuid to a UUID object (even though sqlalchemy-uuid would do that
                # automatically for us), so we can catch errors.
                uuid_obj = UUID(uuid)
            except ValueError:
                raise InvalidUuidError()
        else:
            uuid_obj = uuid

        return super().get_only(uuid_obj)

    @property
    def primary_key(self) -> UUID:
        return self.uuid
+6 −0
Changes for portal/model/InvalidUuidError.py: 6 added lines, 0 removed lines.
Original line number Diff line number Diff line
class InvalidUuidError(ValueError):
    """
    A given string should have been a valid UUID, but was not.
    """

    pass
+76 −0
Changes for portal/model/Retrievable.py: 76 added lines, 0 removed lines.
Original line number Diff line number Diff line
from abc import abstractmethod
from typing import List, Optional, Any, Self

from flask import g
from portal import db


class Retrievable(db.Model):
    """
    A type whose instances can be retrieved from the database.

    It has methods to get all instances and a specific instance by its primary key.
    """

    # See https://flask-sqlalchemy.palletsprojects.com/en/3.0.x/customizing/#abstract-models-and-mixins
    __abstract__ = True

    @classmethod
    def get_canonical_order_column(cls) -> Optional[db.Column]:
        """
        Return the colum by which instances of this type should be canonically ordered
        when retrieving them all from the database.

        By default, the column is None, i.e. no order is enforced.
        """
        return None

    @classmethod
    def get_all(cls) -> List[Self]:
        """
        Return all instances of this type.

        Cached per request: After the first call of get_all(), all instances are stored in the request globals
        object, i.e. not retrieved again _for that particular request_.
        """
        if "instances" not in g or not g.instances.get(cls):
            g.setdefault("instances", {})[cls] = {
                instance.primary_key: instance
                for instance in db.session.execute(
                    db.select(cls).order_by(cls.get_canonical_order_column())
                ).scalars()
            }

        return list(g.instances[cls].values())

    @classmethod
    def get_only(cls, primary_key: Any) -> Optional[Self]:
        """
        Return the instance of this type with the given primary key or None if it doesn't exist.

        If the cache has been populated using get_all(), the instance is retrieved from cache.
        """
        if "instances" in g and g.instances.get(cls):
            return g.instances[cls].get(primary_key)
        else:
            return db.session.get(cls, primary_key)

    @property
    def primary_key(self) -> Any:
        """
        Return the primary key that can be used to identify an instance of this class.
        """
        raise NotImplementedError(
            "The primary key property needs to be implemented by subclasses."
        )

    @abstractmethod
    def to_dict(self) -> dict[str, Any]:
        """
        Return a dictionary representation of this instance.

        Note that this representation might not be complete (and instead only contain fields that are currently
        relevant for at least one purpose), so do not rely on it if you want a complete serialization of this
        object.
        """
        pass

portal/model/Uuid.py

0 → 100644
+39 −0
Changes for portal/model/Uuid.py: 39 added lines, 0 removed lines.
Original line number Diff line number Diff line
from typing import Optional, Any
from uuid import UUID

from sqlalchemy.engine import Dialect
import sqlalchemy.types as types


UUID_LENGTH = 36


class Uuid(types.TypeDecorator):
    """
    A UUID, stored as a 36-character string containing dashes.

    There is a built-in SQLAlchemy type representing a UUID (which uses built-in database types for UUIDs, if they
    exist), but it falls back to 32-character strings (dashes are removed) if there is no such type (like with
    MySQL, MariaDB or SQLite).

    This makes working directly in the database (which often is a requirement) complicated, so we've decided to
    implement our own type for that.
    """

    impl = types.TypeEngine
    cache_ok = True

    def load_dialect_impl(self, dialect: Dialect) -> types.TypeEngine[Any]:
        return types.String(UUID_LENGTH)

    def process_bind_param(self, value: str | UUID, dialect: Dialect) -> Optional[str]:
        if isinstance(value, str):
            # Manually create UUID from string, to raise an error if the string is malformed
            UUID(value)

        return str(value) if value else None

    def process_result_value(
        self, value: Optional[str], dialect: Dialect
    ) -> Optional[UUID]:
        return UUID(value) if value else None
+4 −0
Changes for portal/model/__init__.py: 4 added lines, 0 removed lines.
Original line number Diff line number Diff line
from .Base import Base
from .FlaskConfigEntry import FlaskConfigEntry
from .InvalidUuidError import InvalidUuidError
from .Retrievable import Retrievable
from .Uuid import Uuid