From f102e072567f79d1be6ecdf9516e25c457604e0d Mon Sep 17 00:00:00 2001 From: ducklet Date: Sat, 18 May 2024 23:32:10 +0200 Subject: [PATCH] feat: add a table to store award information --- ...716050110-62882ef5e3ff_add_awards_table.py | 44 ++++++++++ unwind/models.py | 88 ++++++++++++++++--- 2 files changed, 120 insertions(+), 12 deletions(-) create mode 100644 alembic/versions/1716050110-62882ef5e3ff_add_awards_table.py diff --git a/alembic/versions/1716050110-62882ef5e3ff_add_awards_table.py b/alembic/versions/1716050110-62882ef5e3ff_add_awards_table.py new file mode 100644 index 0000000..b66cee0 --- /dev/null +++ b/alembic/versions/1716050110-62882ef5e3ff_add_awards_table.py @@ -0,0 +1,44 @@ +"""add awards table + +Revision ID: 62882ef5e3ff +Revises: c08ae04dc482 +Create Date: 2024-05-18 16:35:10.145964+00:00 + +""" + +from typing import Sequence + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "62882ef5e3ff" +down_revision: str | None = "c08ae04dc482" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "awards", + sa.Column("id", sa.String(), nullable=False), + sa.Column("movie_id", sa.String(), nullable=False), + sa.Column("category", sa.String(), nullable=False), + sa.Column("details", sa.String(), nullable=False), + sa.Column("created", sa.String(), nullable=False), + sa.Column("updated", sa.String(), nullable=False), + sa.ForeignKeyConstraint( + ["movie_id"], + ["movies.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("awards") + # ### end Alembic commands ### diff --git a/unwind/models.py b/unwind/models.py index 23b0794..594635c 100644 --- a/unwind/models.py +++ b/unwind/models.py @@ -13,6 +13,8 @@ from typing import ( Mapping, Protocol, Type, + TypeAlias, + TypeAliasType, TypedDict, TypeVar, Union, @@ -25,8 +27,9 @@ from sqlalchemy.orm import registry from .types import ULID -JSON = int | float | str | None | list["JSON"] | dict[str, "JSON"] -JSONObject = dict[str, JSON] +JSONScalar: TypeAlias = int | float | str | None +type JSON = JSONScalar | list["JSON"] | dict[str, "JSON"] +type JSONObject = dict[str, JSON] T = TypeVar("T") @@ -126,6 +129,10 @@ def asplain( continue target: Any = f.type + if isinstance(target, TypeAliasType): + # Support type aliases. + target = target.__value__ + # XXX this doesn't properly support any kind of nested types if (otype := optional_type(f.type)) is not None: target = otype @@ -150,10 +157,13 @@ def asplain( elif target in {bool, str, int, float}: assert isinstance( v, target - ), f"Type mismatch: {f.name} ({target} != {type(v)})" + ), f"Type mismatch: {f.name!a} ({target!a} != {type(v)!a})" + d[f.name] = v + elif target in {Literal}: + assert isinstance(v, JSONScalar) d[f.name] = v else: - raise ValueError(f"Unsupported value type: {f.name}: {type(v)}") + raise ValueError(f"Unsupported value type: {f.name!a}: {type(v)!a}") return d @@ -196,18 +206,24 @@ def fromplain(cls: Type[T], d: Mapping, *, serialized: bool = False) -> T: def validate(o: object) -> None: for f in fields(o): - vtype = type(getattr(o, f.name)) - if vtype is f.type: + ftype = f.type + if isinstance(ftype, TypeAliasType): + # Support type aliases. + ftype = ftype.__value__ + + v = getattr(o, f.name) + vtype = type(v) + if vtype is ftype: continue - origin = get_origin(f.type) + origin = get_origin(ftype) if origin is vtype: continue - is_union = isinstance(f.type, UnionType) or origin is Union + is_union = isinstance(ftype, UnionType) or origin is Union if is_union: # Support unioned types. - utypes = get_args(f.type) + utypes = get_args(ftype) if vtype in utypes: continue @@ -216,7 +232,14 @@ def validate(o: object) -> None: if any(vtype is gtype for gtype in gtypes): continue - raise ValueError(f"Invalid value type: {f.name}: {vtype}") + if origin is Literal: + # Support literal types. + vals = get_args(ftype) + if v in vals: + continue + raise ValueError(f"Invalid value: {f.name!a}: {v!a}") + + raise ValueError(f"Invalid value type: {f.name!a}: {vtype!a}") def utcnow() -> datetime: @@ -365,10 +388,10 @@ dataclass containing the ID of the linked data. The contents of the Relation are ignored or discarded when using `asplain`, `fromplain`, and `validate`. """ -Relation = Annotated[T | None, _RelationSentinel] +Relation: TypeAlias = Annotated[T | None, _RelationSentinel] -Access = Literal[ +type Access = Literal[ "r", # read "i", # index "w", # write @@ -413,6 +436,9 @@ class User: self.groups.append({"id": group_id, "access": access}) +users = User.__table__ + + @mapper_registry.mapped @dataclass class Rating: @@ -477,3 +503,41 @@ class Group: id: ULID = field(default_factory=ULID) name: str = None users: list[GroupUser] = field(default_factory=list) + + +type AwardCategory = Literal[ + "imdb-top-250", "imdb-bottom-100", "imdb-pop-100", "oscars" +] + + +@mapper_registry.mapped +@dataclass +class Award: + __table__: ClassVar[Table] = Table( + "awards", + metadata, + Column("id", String, primary_key=True), # ULID + Column("movie_id", ForeignKey("movies.id"), nullable=False), # ULID + Column( + "category", String, nullable=False + ), # Enum: "imdb-top-250", "imdb-bottom-100", "imdb-pop-100", "oscars", ... + Column( + "details", String, nullable=False + ), # e.g. "23" (position in list), "2024, nominee, best director", "1977, winner, best picture", ... + Column("created", String, nullable=False), # datetime + Column("updated", String, nullable=False), # datetime + ) + + id: ULID = field(default_factory=ULID) + + movie_id: ULID = None + movie: Relation[Movie] = None + + category: AwardCategory = None + details: str = None + + created: datetime = field(default_factory=utcnow) + updated: datetime = field(default_factory=utcnow) + + +awards = Award.__table__