diff --git a/src/arcaea_offline/database/models/v5/__init__.py b/src/arcaea_offline/database/models/v5/__init__.py new file mode 100644 index 0000000..2865160 --- /dev/null +++ b/src/arcaea_offline/database/models/v5/__init__.py @@ -0,0 +1,38 @@ +from .arcaea import ( + Chart, + ChartInfo, + Difficulty, + DifficultyLocalized, + Pack, + PackLocalized, + Song, + SongLocalized, + SongSearchWord, +) +from .base import ModelsV5Base, ModelsV5ViewBase +from .config import Property +from .play_results import ( + CalculatedPotential, + PlayResult, + PlayResultBest, + PlayResultCalculated, +) + +__all__ = [ + "CalculatedPotential", + "Chart", + "ChartInfo", + "Difficulty", + "DifficultyLocalized", + "ModelsV5Base", + "ModelsV5ViewBase", + "Pack", + "PackLocalized", + "PlayResult", + "PlayResultBest", + "PlayResultCalculated", + "Property", + "Song", + "SongLocalized", + "SongSearchWord", +] diff --git a/src/arcaea_offline/database/models/v5/arcaea.py b/src/arcaea_offline/database/models/v5/arcaea.py new file mode 100644 index 0000000..67835b5 --- /dev/null +++ b/src/arcaea_offline/database/models/v5/arcaea.py @@ -0,0 +1,275 @@ +from typing import List, Optional + +from sqlalchemy import ForeignKey, and_, func, select +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy_utils import create_view + +from arcaea_offline.constants.enums import ArcaeaRatingClass, ArcaeaSongSide + +from .base import ModelsV5Base, ModelsV5ViewBase, ReprHelper + +__all__ = [ + "Chart", + "ChartInfo", + "Difficulty", + "DifficultyLocalized", + "Pack", + "PackLocalized", + "Song", + "SongLocalized", +] + + +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[str] + 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[int]] + 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[str] + 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[str] + 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[int]] + + 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[str] + 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[int]] + 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/v5/base.py b/src/arcaea_offline/database/models/v5/base.py new file mode 100644 index 0000000..516d0b3 --- /dev/null +++ b/src/arcaea_offline/database/models/v5/base.py @@ -0,0 +1,62 @@ +from datetime import datetime + +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 + +TYPE_ANNOTATION_MAP = { + datetime: TZDateTime, + ArcaeaRatingClass: DbIntEnum(ArcaeaRatingClass), + ArcaeaSongSide: DbIntEnum(ArcaeaSongSide), + ArcaeaPlayResultClearType: DbIntEnum(ArcaeaPlayResultClearType), + ArcaeaPlayResultModifier: DbIntEnum(ArcaeaPlayResultModifier), +} + + +class ModelsV5Base(DeclarativeBase): + type_annotation_map = TYPE_ANNOTATION_MAP + + +class ModelsV5ViewBase(DeclarativeBase): + type_annotation_map = TYPE_ANNOTATION_MAP + + +class ReprHelper: + # pylint: disable=no-member + + def _repr(self, **kwargs) -> str: + """ + Helper for __repr__ + + https://stackoverflow.com/a/55749579/16484891 + + CC BY-SA 4.0 + """ + field_strings = [] + at_least_one_attached_attribute = False + for key, field in kwargs.items(): + try: + field_strings.append(f"{key}={field!r}") + except DetachedInstanceError: + field_strings.append(f"{key}=DetachedInstanceError") + else: + at_least_one_attached_attribute = True + + if at_least_one_attached_attribute: + return f"<{self.__class__.__name__}({', '.join(field_strings)})>" + return f"<{self.__class__.__name__} {id(self)}>" + + def __repr__(self): + if isinstance(self, DeclarativeBase): + return self._repr( + **{c.key: getattr(self, c.key) for c in self.__table__.columns} + ) + return super().__repr__() diff --git a/src/arcaea_offline/database/models/v5/config.py b/src/arcaea_offline/database/models/v5/config.py new file mode 100644 index 0000000..ca5a339 --- /dev/null +++ b/src/arcaea_offline/database/models/v5/config.py @@ -0,0 +1,12 @@ +from sqlalchemy.orm import Mapped, mapped_column + +from .base import ModelsV5Base, ReprHelper + +__all__ = ["Property"] + + +class Property(ModelsV5Base, ReprHelper): + __tablename__ = "properties" + + key: Mapped[str] = mapped_column(primary_key=True) + value: Mapped[str] = mapped_column() diff --git a/src/arcaea_offline/database/models/v5/play_results.py b/src/arcaea_offline/database/models/v5/play_results.py new file mode 100644 index 0000000..51d610f --- /dev/null +++ b/src/arcaea_offline/database/models/v5/play_results.py @@ -0,0 +1,197 @@ +from datetime import datetime, timezone +from typing import Optional + +from sqlalchemy import ForeignKey, and_, case, func, inspect, select, text +from sqlalchemy.orm import Mapped, mapped_column, relationship +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 + +__all__ = [ + "CalculatedPotential", + "PlayResult", + "PlayResultBest", + "PlayResultCalculated", +] + + +class PlayResult(ModelsV5Base, ReprHelper): + __tablename__ = "play_results" + + 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, + ) + rating_class: Mapped[ArcaeaRatingClass] = mapped_column( + ForeignKey(Difficulty.rating_class, onupdate="CASCADE", ondelete="NO ACTION"), + index=True, + ) + score: Mapped[int] + pure: Mapped[Optional[int]] + far: 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, + ) + + +# How to create an SQL View with SQLAlchemy? +# https://stackoverflow.com/a/53253105/16484891 +# CC BY-SA 4.0 + + +class PlayResultCalculated(ModelsV5ViewBase, ReprHelper): + __tablename__ = "play_results_calculated" + + id: Mapped[int] + song_id: Mapped[str] + rating_class: Mapped[ArcaeaRatingClass] + score: Mapped[int] + pure: Mapped[Optional[int]] + shiny_pure: Mapped[Optional[int]] + far: Mapped[Optional[int]] + lost: Mapped[Optional[int]] + date: Mapped[Optional[datetime]] + max_recall: Mapped[Optional[int]] + modifier: Mapped[Optional[ArcaeaPlayResultModifier]] + clear_type: Mapped[Optional[ArcaeaPlayResultClearType]] + potential: Mapped[float] + comment: Mapped[Optional[str]] + + __table__ = create_view( + name=__tablename__, + selectable=select( + PlayResult.id, + Difficulty.song_id, + Difficulty.rating_class, + PlayResult.score, + PlayResult.pure, + ( + case( + ( + ( + ChartInfo.notes.is_not(None) + & PlayResult.pure.is_not(None) + & PlayResult.far.is_not(None) + & (ChartInfo.notes != 0) + ), + PlayResult.score + - func.floor( + (PlayResult.pure * 10000000.0 / ChartInfo.notes) + + (PlayResult.far * 0.5 * 10000000.0 / ChartInfo.notes) + ), + ), + else_=text("NULL"), + ) + ).label("shiny_pure"), + PlayResult.far, + PlayResult.lost, + PlayResult.date, + PlayResult.max_recall, + PlayResult.modifier, + PlayResult.clear_type, + case( + (PlayResult.score >= 10000000, ChartInfo.constant / 10.0 + 2), # noqa: PLR2004 + ( + PlayResult.score >= 9800000, # noqa: PLR2004 + ChartInfo.constant / 10.0 + + 1 + + (PlayResult.score - 9800000) / 200000.0, + ), + else_=func.max( + (ChartInfo.constant / 10.0) + + (PlayResult.score - 9500000) / 300000.0, + 0, + ), + ).label("potential"), + PlayResult.comment, + ) + .select_from(Difficulty) + .join( + ChartInfo, + (Difficulty.song_id == ChartInfo.song_id) + & (Difficulty.rating_class == ChartInfo.rating_class), + ) + .join( + PlayResult, + (Difficulty.song_id == PlayResult.song_id) + & (Difficulty.rating_class == PlayResult.rating_class), + ), + metadata=ModelsV5ViewBase.metadata, + cascade_on_drop=False, + ) + + +class PlayResultBest(ModelsV5ViewBase, ReprHelper): + __tablename__ = "play_results_best" + + id: Mapped[int] + song_id: Mapped[str] + rating_class: Mapped[int] + score: Mapped[int] + pure: Mapped[Optional[int]] + shiny_pure: Mapped[Optional[int]] + far: Mapped[Optional[int]] + lost: Mapped[Optional[int]] + date: Mapped[Optional[datetime]] + max_recall: Mapped[Optional[int]] + modifier: Mapped[Optional[ArcaeaPlayResultModifier]] + clear_type: Mapped[Optional[ArcaeaPlayResultClearType]] + potential: Mapped[float] + comment: Mapped[Optional[str]] + + __table__ = create_view( + name=__tablename__, + selectable=select( + *[ + col + for col in inspect(PlayResultCalculated).columns + if col.name != "potential" + ], + func.max(PlayResultCalculated.potential).label("potential"), + ) + .select_from(PlayResultCalculated) + .group_by(PlayResultCalculated.song_id, PlayResultCalculated.rating_class) + .order_by(PlayResultCalculated.potential.desc()), + metadata=ModelsV5ViewBase.metadata, + cascade_on_drop=False, + ) + + +class CalculatedPotential(ModelsV5ViewBase, ReprHelper): + __tablename__ = "calculated_potential" + + b30: Mapped[float] + + _select_bests_subquery = ( + select(PlayResultBest.potential.label("b30_sum")) + .order_by(PlayResultBest.potential.desc()) + .limit(30) + .subquery() + ) + __table__ = create_view( + name=__tablename__, + selectable=select(func.avg(_select_bests_subquery.c.b30_sum).label("b30")), + metadata=ModelsV5ViewBase.metadata, + cascade_on_drop=False, + ) diff --git a/tests/db/models/v5/__init__.py b/tests/db/models/v5/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/db/models/v5/relationships/__init__.py b/tests/db/models/v5/relationships/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/db/models/v5/relationships/test_common.py b/tests/db/models/v5/relationships/test_common.py new file mode 100644 index 0000000..604240d --- /dev/null +++ b/tests/db/models/v5/relationships/test_common.py @@ -0,0 +1,143 @@ +""" +Database model v5 common relationships + +┌──────┐ ┌──────┐ ┌────────────┐ ┌────────────┐ +│ Pack ◄───► Song ◄───► Difficulty ◄───┤ PlayResult │ +└──────┘ └──┬───┘ └─────▲──────┘ └────────────┘ + │ │ + │ ┌─────▼─────┐ + └───────► ChartInfo │ + └───────────┘ +""" + +from arcaea_offline.constants.enums import ArcaeaRatingClass +from arcaea_offline.database.models.v5 import ( + ChartInfo, + Difficulty, + ModelsV5Base, + Pack, + PlayResult, + Song, +) + + +class TestSongRelationships: + @staticmethod + def init_db(session): + ModelsV5Base.metadata.create_all(session.bind) + + def test_relationships(self, db_session): + self.init_db(db_session) + + song_id = "test_song" + title_en = "Test Lorem Ipsum" + artist_en = "Test Artist" + + pack = Pack( + id="test_pack", + name="Test Pack", + description="This is a test pack.", + ) + + song = Song( + idx=1, + id=song_id, + title=title_en, + artist=artist_en, + pack_id=pack.id, + ) + + difficulty_pst = Difficulty( + song_id=song.id, + rating_class=ArcaeaRatingClass.PAST, + rating=2, + rating_plus=False, + ) + chart_info_pst = ChartInfo( + song_id=song.id, + rating_class=ArcaeaRatingClass.PAST, + constant=20, + notes=200, + ) + + difficulty_prs = Difficulty( + song_id=song.id, + rating_class=ArcaeaRatingClass.PRESENT, + rating=7, + rating_plus=True, + ) + chart_info_prs = ChartInfo( + song_id=song.id, + rating_class=ArcaeaRatingClass.PRESENT, + constant=78, + notes=780, + ) + + difficulty_ftr = Difficulty( + song_id=song.id, + rating_class=ArcaeaRatingClass.FUTURE, + rating=10, + rating_plus=True, + ) + chart_info_ftr = ChartInfo( + song_id=song.id, + rating_class=ArcaeaRatingClass.FUTURE, + constant=109, + notes=1090, + ) + + difficulty_etr = Difficulty( + song_id=song.id, + rating_class=ArcaeaRatingClass.ETERNAL, + rating=9, + rating_plus=True, + ) + + play_result_ftr = PlayResult( + song_id=song.id, + rating_class=ArcaeaRatingClass.FUTURE, + score=123456, + ) + + db_session.add_all( + [ + pack, + song, + difficulty_pst, + chart_info_pst, + difficulty_prs, + chart_info_prs, + difficulty_ftr, + chart_info_ftr, + difficulty_etr, + play_result_ftr, + ] + ) + db_session.commit() + + assert pack.songs == [song] + + assert song.pack == pack + assert song.difficulties == [ + difficulty_pst, + difficulty_prs, + difficulty_ftr, + difficulty_etr, + ] + assert song.charts_info == [chart_info_pst, chart_info_prs, chart_info_ftr] + + assert difficulty_pst.song == song + assert difficulty_prs.song == song + assert difficulty_ftr.song == song + assert difficulty_etr.song == song + + assert difficulty_pst.chart_info == chart_info_pst + assert difficulty_prs.chart_info == chart_info_prs + assert difficulty_ftr.chart_info == chart_info_ftr + assert difficulty_etr.chart_info is None + + assert chart_info_pst.difficulty == difficulty_pst + assert chart_info_prs.difficulty == difficulty_prs + assert chart_info_ftr.difficulty == difficulty_ftr + + assert play_result_ftr.difficulty == difficulty_ftr diff --git a/tests/db/models/v5/relationships/test_pack.py b/tests/db/models/v5/relationships/test_pack.py new file mode 100644 index 0000000..d6912e6 --- /dev/null +++ b/tests/db/models/v5/relationships/test_pack.py @@ -0,0 +1,69 @@ +""" +Database model v5 relationships + +Pack <> PackLocalized +""" + +from arcaea_offline.constants.enums import ArcaeaLanguage +from arcaea_offline.database.models.v5 import ModelsV5Base, Pack, PackLocalized + + +class TestPackRelationships: + @staticmethod + def init_db(session): + ModelsV5Base.metadata.create_all(session.bind) + + def test_localized_objects(self, db_session): + self.init_db(db_session) + + pack_id = "test_pack" + name_en = "Test Pack" + description_en = "Travel through common database models\nfrom the unpopular framework 'Arcaea Offline'\ntogether with an ordinary partner '∅'." + + pack = Pack( + id=pack_id, + name=name_en, + description=description_en, + ) + + pkid_ja = 1 + description_ja = "普通のパートナー「∅」と一緒に、\n不人気フレームワーク「Arcaea Offline」より、\n一般的なデータベース・モデルを通過する。" + pack_localized_ja = PackLocalized( + pkid=pkid_ja, + id=pack_id, + lang=ArcaeaLanguage.JA.value, + name=None, + description=description_ja, + ) + + pkid_zh_hans = 2 + description_zh_hans = "与平凡的「∅」一起,\n在没人用的「Arcaea Offline」框架里,\n一同探索随处可见的数据库模型。" + pack_localized_zh_hans = PackLocalized( + pkid=pkid_zh_hans, + id=pack_id, + lang=ArcaeaLanguage.ZH_HANS.value, + name=None, + description=description_zh_hans, + ) + + db_session.add_all([pack, pack_localized_ja]) + db_session.commit() + + assert len(pack.localized_objects) == len([pack_localized_ja]) + + assert pack_localized_ja.parent.description == pack.description + + # relationships should be viewonly + new_pack = Pack( + id=f"{pack_id}_new", + name="NEW", + description="new new pack", + ) + db_session.add(new_pack) + + pack_localized_ja.parent = new_pack + pack.localized_objects.append(pack_localized_zh_hans) + db_session.commit() + + assert pack_localized_ja.parent == pack + assert len(pack.localized_objects) == 1 diff --git a/tests/db/models/v5/test_chart.py b/tests/db/models/v5/test_chart.py new file mode 100644 index 0000000..ce62fd6 --- /dev/null +++ b/tests/db/models/v5/test_chart.py @@ -0,0 +1,110 @@ +""" +Database models v5 + +Chart functionalities +- basic data handling +- Difficulty song info overriding +""" + +from arcaea_offline.constants.enums.arcaea import ArcaeaRatingClass +from arcaea_offline.database.models.v5 import ( + Chart, + ChartInfo, + Difficulty, + ModelsV5Base, + ModelsV5ViewBase, + Pack, + Song, +) + + +class TestChart: + def init_db(self, session): + ModelsV5Base.metadata.create_all(session.bind) + ModelsV5ViewBase.metadata.create_all(session.bind) + + def test_basic(self, db_session): + self.init_db(db_session) + + pack_id = "test_pack" + song_id = "test_song" + rating_class = ArcaeaRatingClass.FUTURE + + pack = Pack(id=pack_id, name="Test Pack") + song = Song( + idx=2, + id=song_id, + title="~TEST~", + artist="~test~", + pack_id=pack_id, + ) + difficulty = Difficulty( + song_id=song_id, + rating_class=rating_class, + rating=9, + rating_plus=True, + ) + chart_info = ChartInfo( + song_id=song_id, + rating_class=rating_class, + constant=98, + notes=980, + ) + db_session.add_all([pack, song, difficulty, chart_info]) + + chart: Chart = ( + db_session.query(Chart) + .where((Chart.song_id == song_id) & (Chart.rating_class == rating_class)) + .one() + ) + + # `song_id` and `rating_class` are guarded by the WHERE clause above + assert chart.song_idx == song.idx + assert chart.title == song.title + assert chart.artist == song.artist + assert chart.pack_id == song.pack_id + assert chart.rating == difficulty.rating + assert chart.rating_plus == difficulty.rating_plus + assert chart.constant == chart_info.constant + assert chart.notes == chart_info.notes + + def test_difficulty_override(self, db_session): + self.init_db(db_session) + + pack_id = "test_pack" + song_id = "test_song" + rating_class = ArcaeaRatingClass.FUTURE + + pack = Pack(id=pack_id, name="Test Pack") + song = Song( + idx=2, + id=song_id, + title="~TEST~", + artist="~test~", + pack_id=pack_id, + ) + difficulty = Difficulty( + song_id=song_id, + rating_class=rating_class, + rating=9, + rating_plus=True, + title="~TEST DIFF~", + artist="~diff~", + ) + chart_info = ChartInfo( + song_id=song_id, + rating_class=rating_class, + constant=98, + notes=980, + ) + db_session.add_all([pack, song, difficulty, chart_info]) + + chart: Chart = ( + db_session.query(Chart) + .where((Chart.song_id == song_id) & (Chart.rating_class == rating_class)) + .one() + ) + + assert chart.song_idx == song.idx + assert chart.title == difficulty.title + assert chart.artist == difficulty.artist