From 9d7054d29aa0bdb1b7ea0701d252da3649018faf Mon Sep 17 00:00:00 2001 From: 283375 Date: Sat, 31 May 2025 14:34:45 +0800 Subject: [PATCH] wip(db): v5 models - Literally reverting f19ac4d8d5c453d7291c56919a0b17a8b538df48 --- .../database/models/__init__.py | 34 +-- .../database/models/{base.py => _base.py} | 31 +- .../database/models/_custom_types.py | 46 --- src/arcaea_offline/database/models/_types.py | 28 ++ src/arcaea_offline/database/models/arcaea.py | 285 ------------------ src/arcaea_offline/database/models/chart.py | 85 ++++++ .../database/models/chart_info.py | 33 ++ src/arcaea_offline/database/models/config.py | 6 +- .../database/models/difficulty.py | 92 ++++++ src/arcaea_offline/database/models/pack.py | 61 ++++ .../{play_results.py => play_result.py} | 99 +++--- src/arcaea_offline/database/models/song.py | 85 ++++++ 12 files changed, 463 insertions(+), 422 deletions(-) rename src/arcaea_offline/database/models/{base.py => _base.py} (68%) delete mode 100644 src/arcaea_offline/database/models/_custom_types.py create mode 100644 src/arcaea_offline/database/models/_types.py delete mode 100644 src/arcaea_offline/database/models/arcaea.py create mode 100644 src/arcaea_offline/database/models/chart.py create mode 100644 src/arcaea_offline/database/models/chart_info.py create mode 100644 src/arcaea_offline/database/models/difficulty.py create mode 100644 src/arcaea_offline/database/models/pack.py rename src/arcaea_offline/database/models/{play_results.py => play_result.py} (68%) create mode 100644 src/arcaea_offline/database/models/song.py diff --git a/src/arcaea_offline/database/models/__init__.py b/src/arcaea_offline/database/models/__init__.py index 2865160..5b234ac 100644 --- a/src/arcaea_offline/database/models/__init__.py +++ b/src/arcaea_offline/database/models/__init__.py @@ -1,38 +1,32 @@ -from .arcaea import ( - Chart, - ChartInfo, - Difficulty, - DifficultyLocalized, - Pack, - PackLocalized, - Song, - SongLocalized, - SongSearchWord, -) -from .base import ModelsV5Base, ModelsV5ViewBase +from ._base import ModelBase, ModelViewBase +from .chart_info import ChartInfo from .config import Property -from .play_results import ( +from .difficulty import Difficulty, DifficultyLocalization +from .pack import Pack, PackLocalization +from .song import Song, SongLocalization + +from .chart import Chart # isort: skip +from .play_result import ( CalculatedPotential, PlayResult, PlayResultBest, PlayResultCalculated, -) +) # isort: skip __all__ = [ "CalculatedPotential", "Chart", "ChartInfo", "Difficulty", - "DifficultyLocalized", - "ModelsV5Base", - "ModelsV5ViewBase", + "DifficultyLocalization", + "ModelBase", + "ModelViewBase", "Pack", - "PackLocalized", + "PackLocalization", "PlayResult", "PlayResultBest", "PlayResultCalculated", "Property", "Song", - "SongLocalized", - "SongSearchWord", + "SongLocalization", ] diff --git a/src/arcaea_offline/database/models/base.py b/src/arcaea_offline/database/models/_base.py similarity index 68% rename from src/arcaea_offline/database/models/base.py rename to src/arcaea_offline/database/models/_base.py index 464c324..2342dc3 100644 --- a/src/arcaea_offline/database/models/base.py +++ b/src/arcaea_offline/database/models/_base.py @@ -1,31 +1,30 @@ from datetime import datetime +from sqlalchemy import MetaData from sqlalchemy.orm import DeclarativeBase from sqlalchemy.orm.exc import DetachedInstanceError -from arcaea_offline.constants.enums import ( - ArcaeaPlayResultClearType, - ArcaeaPlayResultModifier, - ArcaeaRatingClass, - ArcaeaSongSide, -) - -from ._custom_types import DbIntEnum, TZDateTime +from ._types import ForceTimezoneDateTime TYPE_ANNOTATION_MAP = { - datetime: TZDateTime, - ArcaeaRatingClass: DbIntEnum(ArcaeaRatingClass), - ArcaeaSongSide: DbIntEnum(ArcaeaSongSide), - ArcaeaPlayResultClearType: DbIntEnum(ArcaeaPlayResultClearType), - ArcaeaPlayResultModifier: DbIntEnum(ArcaeaPlayResultModifier), + datetime: ForceTimezoneDateTime, } -class ModelsV5Base(DeclarativeBase): +class ModelBase(DeclarativeBase): type_annotation_map = TYPE_ANNOTATION_MAP + metadata = MetaData( + naming_convention={ + "ix": "ix_%(column_0_label)s", + "uq": "uq_%(table_name)s_%(column_0_name)s", + "ck": "ck_%(table_name)s_`%(constraint_name)s`", + "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s", + "pk": "pk_%(table_name)s", + } + ) -class ModelsV5ViewBase(DeclarativeBase): +class ModelViewBase(DeclarativeBase): type_annotation_map = TYPE_ANNOTATION_MAP @@ -34,7 +33,7 @@ class ReprHelper: def _repr(self, **kwargs) -> str: """ - Helper for __repr__ + SQLAlchemy model __repr__ helper https://stackoverflow.com/a/55749579/16484891 diff --git a/src/arcaea_offline/database/models/_custom_types.py b/src/arcaea_offline/database/models/_custom_types.py deleted file mode 100644 index e42beb5..0000000 --- a/src/arcaea_offline/database/models/_custom_types.py +++ /dev/null @@ -1,46 +0,0 @@ -from datetime import datetime, timezone -from enum import IntEnum -from typing import Optional, Type - -from sqlalchemy import DateTime, Integer -from sqlalchemy.types import TypeDecorator - - -class DbIntEnum(TypeDecorator): - """sqlalchemy `TypeDecorator` for `IntEnum`s""" - - impl = Integer - cache_ok = True - - def __init__(self, enum_class: Type[IntEnum]): - super().__init__() - self.enum_class = enum_class - - def process_bind_param(self, value: Optional[IntEnum], dialect) -> Optional[int]: - return None if value is None else value.value - - def process_result_value(self, value: Optional[int], dialect) -> Optional[IntEnum]: - return None if value is None else self.enum_class(value) - - -class TZDateTime(TypeDecorator): - """ - Store Timezone Aware Timestamps as Timezone Naive UTC - - https://docs.sqlalchemy.org/en/20/core/custom_types.html#store-timezone-aware-timestamps-as-timezone-naive-utc - """ - - impl = DateTime - cache_ok = True - - def process_bind_param(self, value: Optional[datetime], dialect): - if value is not None: - if not value.tzinfo or value.tzinfo.utcoffset(value) is None: - raise TypeError("tzinfo is required") - value = value.astimezone(timezone.utc).replace(tzinfo=None) - return value - - def process_result_value(self, value: Optional[datetime], dialect): - if value is not None: - value = value.replace(tzinfo=timezone.utc) - return value diff --git a/src/arcaea_offline/database/models/_types.py b/src/arcaea_offline/database/models/_types.py new file mode 100644 index 0000000..d6224ab --- /dev/null +++ b/src/arcaea_offline/database/models/_types.py @@ -0,0 +1,28 @@ +from datetime import datetime, timezone +from typing import Optional + +from sqlalchemy import DateTime +from sqlalchemy.types import TypeDecorator + + +class ForceTimezoneDateTime(TypeDecorator): + """ + Store timezone aware timestamps as timezone naive UTC + + https://docs.sqlalchemy.org/en/20/core/custom_types.html#store-timezone-aware-timestamps-as-timezone-naive-utc + """ + + impl = DateTime + cache_ok = True + + def process_bind_param(self, value: Optional[datetime], dialect): + if value is not None: + if not value.tzinfo or value.tzinfo.utcoffset(value) is None: + raise TypeError("datetime tzinfo is required") + value = value.astimezone(timezone.utc).replace(tzinfo=None) + return value + + def process_result_value(self, value: Optional[datetime], dialect): + if value is not None: + value = value.replace(tzinfo=timezone.utc) + return value diff --git a/src/arcaea_offline/database/models/arcaea.py b/src/arcaea_offline/database/models/arcaea.py deleted file mode 100644 index 29bb1cb..0000000 --- a/src/arcaea_offline/database/models/arcaea.py +++ /dev/null @@ -1,285 +0,0 @@ -from datetime import datetime -from typing import List, Optional - -from sqlalchemy import Enum, ForeignKey, and_, func, select -from sqlalchemy.orm import Mapped, mapped_column, relationship -from sqlalchemy_utils import create_view - -from arcaea_offline.constants.enums.arcaea import ( - ArcaeaLanguage, - ArcaeaRatingClass, - ArcaeaSongSide, -) - -from .base import ModelsV5Base, ModelsV5ViewBase, ReprHelper - -__all__ = [ - "Chart", - "ChartInfo", - "Difficulty", - "DifficultyLocalized", - "Pack", - "PackLocalized", - "Song", - "SongLocalized", -] - - -_ArcaeaLanguageEnumType = Enum( - ArcaeaLanguage, native_enum=False, values_callable=lambda e: [x.value for x in e] -) - - -class Pack(ModelsV5Base, ReprHelper): - __tablename__ = "packs" - - id: Mapped[str] = mapped_column(primary_key=True) - name: Mapped[str] - description: Mapped[Optional[str]] - - songs: Mapped[List["Song"]] = relationship(back_populates="pack", viewonly=True) - localized_objects: Mapped[List["PackLocalized"]] = relationship( - back_populates="parent", viewonly=True - ) - - -class PackLocalized(ModelsV5Base, ReprHelper): - __tablename__ = "packs_localized" - - pkid: Mapped[int] = mapped_column(primary_key=True) - id: Mapped[str] = mapped_column( - ForeignKey(Pack.id, onupdate="CASCADE", ondelete="NO ACTION") - ) - lang: Mapped[ArcaeaLanguage] = mapped_column(_ArcaeaLanguageEnumType) - name: Mapped[Optional[str]] - description: Mapped[Optional[str]] - - parent: Mapped[Pack] = relationship(viewonly=True) - - -class Song(ModelsV5Base, ReprHelper): - __tablename__ = "songs" - - idx: Mapped[int] - id: Mapped[str] = mapped_column(primary_key=True) - title: Mapped[str] - artist: Mapped[str] - pack_id: Mapped[str] = mapped_column( - ForeignKey(Pack.id, onupdate="CASCADE", ondelete="NO ACTION") - ) - bpm: Mapped[Optional[str]] - bpm_base: Mapped[Optional[float]] - audio_preview: Mapped[Optional[int]] - audio_preview_end: Mapped[Optional[int]] - side: Mapped[Optional[ArcaeaSongSide]] - version: Mapped[Optional[str]] - date: Mapped[Optional[datetime]] - bg: Mapped[Optional[str]] - bg_inverse: Mapped[Optional[str]] - bg_day: Mapped[Optional[str]] - bg_night: Mapped[Optional[str]] - source: Mapped[Optional[str]] - source_copyright: Mapped[Optional[str]] - - pack: Mapped[Pack] = relationship(viewonly=True) - difficulties: Mapped[List["Difficulty"]] = relationship( - back_populates="song", viewonly=True - ) - localized_objects: Mapped[List["SongLocalized"]] = relationship( - back_populates="parent", viewonly=True - ) - - @property - def charts_info(self): - return [d.chart_info for d in self.difficulties if d.chart_info is not None] - - -class SongLocalized(ModelsV5Base, ReprHelper): - __tablename__ = "songs_localized" - - pkid: Mapped[int] = mapped_column(primary_key=True) - id: Mapped[str] = mapped_column( - ForeignKey(Song.id, onupdate="CASCADE", ondelete="NO ACTION") - ) - lang: Mapped[ArcaeaLanguage] = mapped_column(_ArcaeaLanguageEnumType) - title: Mapped[Optional[str]] - source: Mapped[Optional[str]] - - parent: Mapped[Song] = relationship( - back_populates="localized_objects", viewonly=True - ) - - -class SongSearchWord(ModelsV5Base, ReprHelper): - __tablename__ = "songs_search_words" - - pkid: Mapped[int] = mapped_column(primary_key=True) - id: Mapped[str] = mapped_column( - ForeignKey(Song.id, onupdate="CASCADE", ondelete="NO ACTION") - ) - lang: Mapped[ArcaeaLanguage] = mapped_column(_ArcaeaLanguageEnumType) - type: Mapped[int] = mapped_column(comment="1: title, 2: artist") - value: Mapped[str] - - -class Difficulty(ModelsV5Base, ReprHelper): - __tablename__ = "difficulties" - - song_id: Mapped[str] = mapped_column( - ForeignKey(Song.id, onupdate="CASCADE", ondelete="NO ACTION"), - primary_key=True, - ) - rating_class: Mapped[ArcaeaRatingClass] = mapped_column(primary_key=True) - rating: Mapped[int] - rating_plus: Mapped[bool] - chart_designer: Mapped[Optional[str]] - jacket_desginer: Mapped[Optional[str]] - audio_override: Mapped[bool] = mapped_column(default=False) - jacket_override: Mapped[bool] = mapped_column(default=False) - jacket_night: Mapped[Optional[str]] - title: Mapped[Optional[str]] - artist: Mapped[Optional[str]] - bg: Mapped[Optional[str]] - bg_inverse: Mapped[Optional[str]] - bpm: Mapped[Optional[str]] - bpm_base: Mapped[Optional[float]] - version: Mapped[Optional[str]] - date: Mapped[Optional[datetime]] - - song: Mapped[Song] = relationship(back_populates="difficulties", viewonly=True) - chart_info: Mapped[Optional["ChartInfo"]] = relationship( - primaryjoin=( - "and_(Difficulty.song_id==ChartInfo.song_id, " - "Difficulty.rating_class==ChartInfo.rating_class)" - ), - viewonly=True, - ) - localized_objects: Mapped[List["DifficultyLocalized"]] = relationship( - primaryjoin=( - "and_(Difficulty.song_id==DifficultyLocalized.song_id, " - "Difficulty.rating_class==DifficultyLocalized.rating_class)" - ), - viewonly=True, - ) - - -class DifficultyLocalized(ModelsV5Base, ReprHelper): - __tablename__ = "difficulties_localized" - - pkid: Mapped[int] = mapped_column(primary_key=True) - song_id: Mapped[str] = mapped_column( - ForeignKey(Difficulty.song_id, onupdate="CASCADE", ondelete="NO ACTION") - ) - rating_class: Mapped[ArcaeaRatingClass] = mapped_column( - ForeignKey(Difficulty.rating_class, onupdate="CASCADE", ondelete="NO ACTION") - ) - lang: Mapped[ArcaeaLanguage] = mapped_column(_ArcaeaLanguageEnumType) - title: Mapped[Optional[str]] - artist: Mapped[Optional[str]] - - parent: Mapped[Difficulty] = relationship( - primaryjoin=and_( - Difficulty.song_id == song_id, Difficulty.rating_class == rating_class - ), - viewonly=True, - ) - - -class ChartInfo(ModelsV5Base, ReprHelper): - __tablename__ = "charts_info" - - song_id: Mapped[str] = mapped_column( - ForeignKey(Difficulty.song_id, onupdate="CASCADE", ondelete="NO ACTION"), - primary_key=True, - ) - rating_class: Mapped[ArcaeaRatingClass] = mapped_column( - ForeignKey(Difficulty.rating_class, onupdate="CASCADE", ondelete="NO ACTION"), - primary_key=True, - ) - constant: Mapped[int] = mapped_column( - comment="real_constant * 10. For example, Crimson Throne [FTR] is 10.4, then store 104." - ) - notes: Mapped[Optional[int]] - - difficulty: Mapped[Difficulty] = relationship( - primaryjoin=and_( - Difficulty.song_id == song_id, Difficulty.rating_class == rating_class - ), - viewonly=True, - ) - - -class Chart(ModelsV5ViewBase, ReprHelper): - __tablename__ = "charts" - - song_idx: Mapped[int] - song_id: Mapped[str] - rating_class: Mapped[ArcaeaRatingClass] - rating: Mapped[int] - rating_plus: Mapped[bool] - title: Mapped[str] - artist: Mapped[str] - pack_id: Mapped[str] - bpm: Mapped[Optional[str]] - bpm_base: Mapped[Optional[float]] - audio_preview: Mapped[Optional[int]] - audio_preview_end: Mapped[Optional[int]] - side: Mapped[Optional[int]] - version: Mapped[Optional[str]] - date: Mapped[Optional[datetime]] - bg: Mapped[Optional[str]] - bg_inverse: Mapped[Optional[str]] - bg_day: Mapped[Optional[str]] - bg_night: Mapped[Optional[str]] - source: Mapped[Optional[str]] - source_copyright: Mapped[Optional[str]] - chart_designer: Mapped[Optional[str]] - jacket_desginer: Mapped[Optional[str]] - audio_override: Mapped[bool] - jacket_override: Mapped[bool] - jacket_night: Mapped[Optional[str]] - constant: Mapped[int] - notes: Mapped[Optional[int]] - - __table__ = create_view( - name=__tablename__, - selectable=select( - Song.idx.label("song_idx"), - Difficulty.song_id, - Difficulty.rating_class, - Difficulty.rating, - Difficulty.rating_plus, - func.coalesce(Difficulty.title, Song.title).label("title"), - func.coalesce(Difficulty.artist, Song.artist).label("artist"), - Song.pack_id, - func.coalesce(Difficulty.bpm, Song.bpm).label("bpm"), - func.coalesce(Difficulty.bpm_base, Song.bpm_base).label("bpm_base"), - Song.audio_preview, - Song.audio_preview_end, - Song.side, - func.coalesce(Difficulty.version, Song.version).label("version"), - func.coalesce(Difficulty.date, Song.date).label("date"), - func.coalesce(Difficulty.bg, Song.bg).label("bg"), - func.coalesce(Difficulty.bg_inverse, Song.bg_inverse).label("bg_inverse"), - Song.bg_day, - Song.bg_night, - Song.source, - Song.source_copyright, - Difficulty.chart_designer, - Difficulty.jacket_desginer, - Difficulty.audio_override, - Difficulty.jacket_override, - Difficulty.jacket_night, - ChartInfo.constant, - ChartInfo.notes, - ) - .select_from(Difficulty) - .join( - ChartInfo, - (Difficulty.song_id == ChartInfo.song_id) - & (Difficulty.rating_class == ChartInfo.rating_class), - ) - .join(Song, Difficulty.song_id == Song.id), - metadata=ModelsV5ViewBase.metadata, - cascade_on_drop=False, - ) diff --git a/src/arcaea_offline/database/models/chart.py b/src/arcaea_offline/database/models/chart.py new file mode 100644 index 0000000..5f34b81 --- /dev/null +++ b/src/arcaea_offline/database/models/chart.py @@ -0,0 +1,85 @@ +from datetime import datetime +from typing import Optional + +from sqlalchemy import func, select +from sqlalchemy.orm import Mapped +from sqlalchemy_utils import create_view + +from ._base import ModelBase, ModelViewBase, ReprHelper +from .chart_info import ChartInfo +from .difficulty import Difficulty +from .song import Song + + +class Chart(ModelBase, ReprHelper): + __tablename__ = "charts" + + song_idx: Mapped[int] + song_id: Mapped[str] + rating_class: Mapped[int] + rating: Mapped[int] + is_rating_plus: Mapped[bool] + title: Mapped[str] + artist: Mapped[str] + pack_id: Mapped[str] + bpm: Mapped[Optional[str]] + bpm_base: Mapped[Optional[float]] + audio_preview: Mapped[Optional[int]] + audio_preview_end: Mapped[Optional[int]] + side: Mapped[Optional[int]] + version: Mapped[Optional[str]] + added_at: Mapped[Optional[datetime]] + bg: Mapped[Optional[str]] + bg_inverse: Mapped[Optional[str]] + bg_day: Mapped[Optional[str]] + bg_night: Mapped[Optional[str]] + source: Mapped[Optional[str]] + source_copyright: Mapped[Optional[str]] + chart_designer: Mapped[Optional[str]] + jacket_desginer: Mapped[Optional[str]] + has_overriding_audio: Mapped[bool] + has_overriding_jacket: Mapped[bool] + jacket_night: Mapped[Optional[str]] + constant: Mapped[int] + notes: Mapped[Optional[int]] + + __table__ = create_view( + name=__tablename__, + selectable=select( + Song.idx.label("song_idx"), + Difficulty.song_id, + Difficulty.rating_class, + Difficulty.rating, + Difficulty.is_rating_plus, + func.coalesce(Difficulty.title, Song.title).label("title"), + func.coalesce(Difficulty.artist, Song.artist).label("artist"), + Song.pack_id, + func.coalesce(Difficulty.bpm, Song.bpm).label("bpm"), + func.coalesce(Difficulty.bpm_base, Song.bpm_base).label("bpm_base"), + Song.side, + func.coalesce(Difficulty.version, Song.version).label("version"), + func.coalesce(Difficulty.added_at, Song.added_at).label("added_at"), + func.coalesce(Difficulty.bg, Song.bg).label("bg"), + func.coalesce(Difficulty.bg_inverse, Song.bg_inverse).label("bg_inverse"), + Song.bg_day, + Song.bg_night, + Song.source, + Song.source_copyright, + Difficulty.chart_designer, + Difficulty.jacket_designer, + Difficulty.has_overriding_audio, + Difficulty.has_overriding_jacket, + Difficulty.jacket_night, + ChartInfo.constant, + ChartInfo.notes, + ) + .select_from(Difficulty) + .join( + ChartInfo, + (Difficulty.song_id == ChartInfo.song_id) + & (Difficulty.rating_class == ChartInfo.rating_class), + ) + .join(Song, Difficulty.song_id == Song.id), + metadata=ModelViewBase.metadata, + cascade_on_drop=False, + ) diff --git a/src/arcaea_offline/database/models/chart_info.py b/src/arcaea_offline/database/models/chart_info.py new file mode 100644 index 0000000..5d2d366 --- /dev/null +++ b/src/arcaea_offline/database/models/chart_info.py @@ -0,0 +1,33 @@ +from datetime import datetime +from decimal import Decimal +from typing import TYPE_CHECKING, Optional + +from sqlalchemy import ForeignKeyConstraint, Integer, Numeric, String +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from ._base import ModelBase, ReprHelper +from ._types import ForceTimezoneDateTime + +if TYPE_CHECKING: + from .difficulty import Difficulty + + +class ChartInfo(ModelBase, ReprHelper): + __tablename__ = "chart_info" + __table_args__ = ( + ForeignKeyConstraint( + ["song_id", "rating_class"], + ["difficulty.song_id", "difficulty.rating_class"], + onupdate="CASCADE", + ondelete="CASCADE", + ), + ) + + difficulty: Mapped["Difficulty"] = relationship(back_populates="chart_info_list") + + song_id: Mapped[str] = mapped_column(String, primary_key=True) + rating_class: Mapped[int] = mapped_column(Integer, primary_key=True) + constant: Mapped[Decimal] = mapped_column(Numeric, nullable=False) + notes: Mapped[int] = mapped_column(Integer) + added_at: Mapped[datetime] = mapped_column(ForceTimezoneDateTime, primary_key=True) + version: Mapped[Optional[str]] = mapped_column(String) diff --git a/src/arcaea_offline/database/models/config.py b/src/arcaea_offline/database/models/config.py index ca5a339..0ce3b98 100644 --- a/src/arcaea_offline/database/models/config.py +++ b/src/arcaea_offline/database/models/config.py @@ -1,12 +1,12 @@ from sqlalchemy.orm import Mapped, mapped_column -from .base import ModelsV5Base, ReprHelper +from ._base import ModelBase, ReprHelper __all__ = ["Property"] -class Property(ModelsV5Base, ReprHelper): - __tablename__ = "properties" +class Property(ModelBase, ReprHelper): + __tablename__ = "property" key: Mapped[str] = mapped_column(primary_key=True) value: Mapped[str] = mapped_column() diff --git a/src/arcaea_offline/database/models/difficulty.py b/src/arcaea_offline/database/models/difficulty.py new file mode 100644 index 0000000..314f87e --- /dev/null +++ b/src/arcaea_offline/database/models/difficulty.py @@ -0,0 +1,92 @@ +from datetime import datetime +from decimal import Decimal +from typing import TYPE_CHECKING, Optional + +from sqlalchemy import ( + Boolean, + ForeignKey, + ForeignKeyConstraint, + Integer, + Numeric, + String, + text, +) +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from ._base import ModelBase, ReprHelper +from ._types import ForceTimezoneDateTime + +if TYPE_CHECKING: + from .chart_info import ChartInfo + from .song import Song + + +class Difficulty(ModelBase, ReprHelper): + __tablename__ = "difficulty" + + song_id: Mapped[str] = mapped_column( + ForeignKey("song.id", onupdate="CASCADE", ondelete="CASCADE"), + primary_key=True, + ) + song: Mapped["Song"] = relationship(back_populates="difficulties") + localization_entries: Mapped[list["DifficultyLocalization"]] = relationship( + back_populates="difficulty", + cascade="all, delete", + passive_deletes=True, + ) + chart_info_list: Mapped[list["ChartInfo"]] = relationship( + back_populates="difficulty", + cascade="all, delete", + passive_deletes=True, + ) + + rating_class: Mapped[int] = mapped_column(Integer, primary_key=True) + + rating: Mapped[int] = mapped_column(Integer, nullable=False) + is_rating_plus: Mapped[bool] = mapped_column( + Boolean, nullable=False, insert_default=False, server_default=text("0") + ) + + chart_designer: Mapped[Optional[str]] = mapped_column(String) + jacket_designer: Mapped[Optional[str]] = mapped_column(String) + + has_overriding_audio: Mapped[bool] = mapped_column( + Boolean, nullable=False, insert_default=False, server_default=text("0") + ) + has_overriding_jacket: Mapped[bool] = mapped_column( + Boolean, nullable=False, insert_default=False, server_default=text("0") + ) + jacket_night: Mapped[Optional[str]] = mapped_column(String) + + title: Mapped[Optional[str]] = mapped_column(String) + artist: Mapped[Optional[str]] = mapped_column(String) + bg: Mapped[Optional[str]] = mapped_column(String) + bg_inverse: Mapped[Optional[str]] = mapped_column(String) + bpm: Mapped[Optional[str]] = mapped_column(String) + bpm_base: Mapped[Optional[Decimal]] = mapped_column(Numeric(asdecimal=True)) + added_at: Mapped[Optional[datetime]] = mapped_column(ForceTimezoneDateTime) + version: Mapped[Optional[str]] = mapped_column(String) + is_legacy11: Mapped[bool] = mapped_column( + Boolean, nullable=False, insert_default=False, server_default=text("0") + ) + + +class DifficultyLocalization(ModelBase, ReprHelper): + __tablename__ = "difficulty_localization" + __table_args__ = ( + ForeignKeyConstraint( + ["song_id", "rating_class"], + ["difficulty.song_id", "difficulty.rating_class"], + onupdate="CASCADE", + ondelete="CASCADE", + ), + ) + + difficulty: Mapped["Difficulty"] = relationship( + back_populates="localization_entries" + ) + song_id: Mapped[str] = mapped_column(String, primary_key=True) + rating_class: Mapped[int] = mapped_column(Integer, primary_key=True) + + lang: Mapped[str] = mapped_column(String, primary_key=True) + title: Mapped[Optional[str]] = mapped_column(String) diff --git a/src/arcaea_offline/database/models/pack.py b/src/arcaea_offline/database/models/pack.py new file mode 100644 index 0000000..13a0109 --- /dev/null +++ b/src/arcaea_offline/database/models/pack.py @@ -0,0 +1,61 @@ +from typing import TYPE_CHECKING, Optional + +from sqlalchemy import Boolean, ForeignKey, Integer, String, Text, text +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from ._base import ModelBase, ReprHelper + +if TYPE_CHECKING: + from .song import Song + + +class Pack(ModelBase, ReprHelper): + __tablename__ = "pack" + + songs: Mapped[list["Song"]] = relationship( + back_populates="pack", + cascade="all, delete", + passive_deletes=True, + ) + localized_entries: Mapped[list["PackLocalization"]] = relationship( + back_populates="pack", + cascade="all, delete", + passive_deletes=True, + ) + + id: Mapped[str] = mapped_column(String, primary_key=True) + name: Mapped[Optional[str]] = mapped_column(String, index=True) + description: Mapped[Optional[str]] = mapped_column(Text) + section: Mapped[Optional[str]] = mapped_column(String) + is_world_extend: Mapped[bool] = mapped_column( + Boolean, nullable=False, insert_default=False, server_default=text("0") + ) + + plus_character: Mapped[Optional[int]] = mapped_column(Integer) + + append_parent_id: Mapped[Optional[str]] = mapped_column( + ForeignKey("pack.id", onupdate="CASCADE", ondelete="CASCADE") + ) + + parent: Mapped["Pack"] = relationship( + "Pack", + back_populates="appendages", + cascade="all, delete", + passive_deletes=True, + remote_side=[id], + ) + appendages: Mapped[list["Pack"]] = relationship("Pack", back_populates="parent") + + +class PackLocalization(ModelBase, ReprHelper): + __tablename__ = "pack_localization" + + pack: Mapped["Pack"] = relationship(back_populates="localized_entries") + id: Mapped[str] = mapped_column( + ForeignKey("pack.id", onupdate="CASCADE", ondelete="CASCADE"), + primary_key=True, + ) + + lang: Mapped[str] = mapped_column(String, primary_key=True) + name: Mapped[Optional[str]] = mapped_column(String) + description: Mapped[Optional[str]] = mapped_column(Text) diff --git a/src/arcaea_offline/database/models/play_results.py b/src/arcaea_offline/database/models/play_result.py similarity index 68% rename from src/arcaea_offline/database/models/play_results.py rename to src/arcaea_offline/database/models/play_result.py index bda8497..a7393fa 100644 --- a/src/arcaea_offline/database/models/play_results.py +++ b/src/arcaea_offline/database/models/play_result.py @@ -1,18 +1,14 @@ from datetime import datetime, timezone from typing import Optional +from uuid import UUID, uuid4 -from sqlalchemy import ForeignKey, and_, case, func, inspect, select, text -from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy import Integer, String, Text, Uuid, case, func, inspect, select, text +from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy_utils import create_view -from arcaea_offline.constants.enums import ( - ArcaeaPlayResultClearType, - ArcaeaPlayResultModifier, - ArcaeaRatingClass, -) - -from .arcaea import ChartInfo, Difficulty -from .base import ModelsV5Base, ModelsV5ViewBase, ReprHelper +from ._base import ModelBase, ModelViewBase, ReprHelper +from .chart_info import ChartInfo +from .difficulty import Difficulty __all__ = [ "CalculatedPotential", @@ -22,59 +18,53 @@ __all__ = [ ] -class PlayResult(ModelsV5Base, ReprHelper): - __tablename__ = "play_results" +class PlayResult(ModelBase, ReprHelper): + __tablename__ = "play_result" id: Mapped[int] = mapped_column(autoincrement=True, primary_key=True) - song_id: Mapped[str] = mapped_column( - ForeignKey(Difficulty.song_id, onupdate="CASCADE", ondelete="NO ACTION"), - index=True, + uuid: Mapped[UUID] = mapped_column( + Uuid, nullable=False, unique=True, default=lambda: uuid4() ) - rating_class: Mapped[ArcaeaRatingClass] = mapped_column( - ForeignKey(Difficulty.rating_class, onupdate="CASCADE", ondelete="NO ACTION"), - index=True, + song_id: Mapped[str] = mapped_column(String) + rating_class: Mapped[int] = mapped_column(Integer) + played_at: Mapped[Optional[datetime]] = mapped_column( + default=lambda: datetime.now(timezone.utc) ) score: Mapped[int] pure: Mapped[Optional[int]] + pure_early: Mapped[Optional[int]] + pure_late: Mapped[Optional[int]] far: Mapped[Optional[int]] + far_early: Mapped[Optional[int]] + far_late: Mapped[Optional[int]] lost: Mapped[Optional[int]] - date: Mapped[Optional[datetime]] = mapped_column( - default=lambda: datetime.now(timezone.utc) - ) + max_recall: Mapped[Optional[int]] - modifier: Mapped[Optional[ArcaeaPlayResultModifier]] - clear_type: Mapped[Optional[ArcaeaPlayResultClearType]] - comment: Mapped[Optional[str]] - - difficulty: Mapped[Difficulty] = relationship( - primaryjoin=and_( - song_id == Difficulty.song_id, - rating_class == Difficulty.rating_class, - ), - viewonly=True, - ) + clear_type: Mapped[Optional[int]] + modifier: Mapped[Optional[int]] + comment: Mapped[Optional[str]] = mapped_column(Text) -# How to create an SQL View with SQLAlchemy? -# https://stackoverflow.com/a/53253105/16484891 -# CC BY-SA 4.0 - - -class PlayResultCalculated(ModelsV5ViewBase, ReprHelper): +class PlayResultCalculated(ModelViewBase, ReprHelper): __tablename__ = "play_results_calculated" id: Mapped[int] + uuid: Mapped[UUID] song_id: Mapped[str] - rating_class: Mapped[ArcaeaRatingClass] + rating_class: Mapped[int] score: Mapped[int] pure: Mapped[Optional[int]] + pure_early: Mapped[Optional[int]] + pure_late: Mapped[Optional[int]] shiny_pure: Mapped[Optional[int]] far: Mapped[Optional[int]] + far_early: Mapped[Optional[int]] + far_late: Mapped[Optional[int]] lost: Mapped[Optional[int]] - date: Mapped[Optional[datetime]] + played_at: Mapped[Optional[datetime]] max_recall: Mapped[Optional[int]] - modifier: Mapped[Optional[ArcaeaPlayResultModifier]] - clear_type: Mapped[Optional[ArcaeaPlayResultClearType]] + modifier: Mapped[Optional[int]] + clear_type: Mapped[Optional[int]] potential: Mapped[float] comment: Mapped[Optional[str]] @@ -106,7 +96,7 @@ class PlayResultCalculated(ModelsV5ViewBase, ReprHelper): ).label("shiny_pure"), PlayResult.far, PlayResult.lost, - PlayResult.date, + PlayResult.played_at, PlayResult.max_recall, PlayResult.modifier, PlayResult.clear_type, @@ -137,26 +127,31 @@ class PlayResultCalculated(ModelsV5ViewBase, ReprHelper): (Difficulty.song_id == PlayResult.song_id) & (Difficulty.rating_class == PlayResult.rating_class), ), - metadata=ModelsV5ViewBase.metadata, + metadata=ModelViewBase.metadata, cascade_on_drop=False, ) -class PlayResultBest(ModelsV5ViewBase, ReprHelper): +class PlayResultBest(ModelViewBase, ReprHelper): __tablename__ = "play_results_best" id: Mapped[int] + uuid: Mapped[UUID] song_id: Mapped[str] - rating_class: Mapped[ArcaeaRatingClass] + rating_class: Mapped[int] score: Mapped[int] pure: Mapped[Optional[int]] + pure_early: Mapped[Optional[int]] + pure_late: Mapped[Optional[int]] shiny_pure: Mapped[Optional[int]] far: Mapped[Optional[int]] + far_early: Mapped[Optional[int]] + far_late: Mapped[Optional[int]] lost: Mapped[Optional[int]] - date: Mapped[Optional[datetime]] + played_at: Mapped[Optional[datetime]] max_recall: Mapped[Optional[int]] - modifier: Mapped[Optional[ArcaeaPlayResultModifier]] - clear_type: Mapped[Optional[ArcaeaPlayResultClearType]] + modifier: Mapped[Optional[int]] + clear_type: Mapped[Optional[int]] potential: Mapped[float] comment: Mapped[Optional[str]] @@ -173,12 +168,12 @@ class PlayResultBest(ModelsV5ViewBase, ReprHelper): .select_from(PlayResultCalculated) .group_by(PlayResultCalculated.song_id, PlayResultCalculated.rating_class) .order_by(PlayResultCalculated.potential.desc()), - metadata=ModelsV5ViewBase.metadata, + metadata=ModelViewBase.metadata, cascade_on_drop=False, ) -class CalculatedPotential(ModelsV5ViewBase, ReprHelper): +class CalculatedPotential(ModelViewBase, ReprHelper): __tablename__ = "calculated_potential" b30: Mapped[float] @@ -192,6 +187,6 @@ class CalculatedPotential(ModelsV5ViewBase, ReprHelper): __table__ = create_view( name=__tablename__, selectable=select(func.avg(_select_bests_subquery.c.b30_sum).label("b30")), - metadata=ModelsV5ViewBase.metadata, + metadata=ModelViewBase.metadata, cascade_on_drop=False, ) diff --git a/src/arcaea_offline/database/models/song.py b/src/arcaea_offline/database/models/song.py new file mode 100644 index 0000000..ddb89c5 --- /dev/null +++ b/src/arcaea_offline/database/models/song.py @@ -0,0 +1,85 @@ +from datetime import datetime +from decimal import Decimal +from typing import TYPE_CHECKING, Optional + +from sqlalchemy import Boolean, ForeignKey, Integer, Numeric, String, text +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from ._base import ModelBase, ReprHelper +from ._types import ForceTimezoneDateTime + +if TYPE_CHECKING: + from .difficulty import Difficulty + from .pack import Pack + + +class Song(ModelBase, ReprHelper): + __tablename__ = "song" + + pack_id: Mapped[str] = mapped_column( + ForeignKey("pack.id", onupdate="CASCADE", ondelete="CASCADE") + ) + pack: Mapped["Pack"] = relationship(back_populates="songs") + difficulties: Mapped[list["Difficulty"]] = relationship( + back_populates="song", + cascade="all, delete", + passive_deletes=True, + ) + localized_entries: Mapped[list["SongLocalization"]] = relationship( + back_populates="song", + cascade="all, delete", + passive_deletes=True, + ) + + id: Mapped[str] = mapped_column(String, primary_key=True) + idx: Mapped[Optional[int]] = mapped_column(Integer) + title: Mapped[Optional[str]] = mapped_column(String, index=True) + artist: Mapped[Optional[str]] = mapped_column(String, index=True) + is_deleted: Mapped[bool] = mapped_column( + Boolean, nullable=False, insert_default=False, server_default=text("0") + ) + + added_at: Mapped[datetime] = mapped_column( + ForceTimezoneDateTime, nullable=False, index=True + ) + version: Mapped[Optional[str]] = mapped_column(String) + + bpm: Mapped[Optional[str]] = mapped_column(String) + bpm_base: Mapped[Optional[Decimal]] = mapped_column(Numeric(asdecimal=True)) + is_remote: Mapped[bool] = mapped_column( + Boolean, nullable=False, insert_default=False, server_default=text("0") + ) + is_unlockable_in_world: Mapped[bool] = mapped_column( + Boolean, nullable=False, insert_default=False, server_default=text("0") + ) + is_beyond_unlock_state_local: Mapped[bool] = mapped_column( + Boolean, nullable=False, insert_default=False, server_default=text("0") + ) + purchase: Mapped[Optional[str]] = mapped_column(String) + category: Mapped[Optional[str]] = mapped_column(String) + + side: Mapped[Optional[int]] = mapped_column(Integer) + bg: Mapped[Optional[str]] = mapped_column(String) + bg_inverse: Mapped[Optional[str]] = mapped_column(String) + bg_day: Mapped[Optional[str]] = mapped_column(String) + bg_night: Mapped[Optional[str]] = mapped_column(String) + + source: Mapped[Optional[str]] = mapped_column(String) + source_copyright: Mapped[Optional[str]] = mapped_column(String) + + +class SongLocalization(ModelBase, ReprHelper): + __tablename__ = "song_localization" + + song: Mapped["Song"] = relationship(back_populates="localized_entries") + id: Mapped[str] = mapped_column( + ForeignKey("song.id", onupdate="CASCADE", ondelete="CASCADE"), + primary_key=True, + ) + + lang: Mapped[str] = mapped_column(String, primary_key=True) + title: Mapped[Optional[str]] = mapped_column(String) + source: Mapped[Optional[str]] = mapped_column(String) + has_jacket: Mapped[bool] = mapped_column( + Boolean, nullable=False, insert_default=False, server_default=text("0") + )