wip(db): v5 models

- Literally reverting f19ac4d8d5
This commit is contained in:
2025-05-31 14:34:45 +08:00
parent 4ea49ebeda
commit 9d7054d29a
12 changed files with 463 additions and 422 deletions

View File

@ -1,38 +1,32 @@
from .arcaea import ( from ._base import ModelBase, ModelViewBase
Chart, from .chart_info import ChartInfo
ChartInfo,
Difficulty,
DifficultyLocalized,
Pack,
PackLocalized,
Song,
SongLocalized,
SongSearchWord,
)
from .base import ModelsV5Base, ModelsV5ViewBase
from .config import Property 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, CalculatedPotential,
PlayResult, PlayResult,
PlayResultBest, PlayResultBest,
PlayResultCalculated, PlayResultCalculated,
) ) # isort: skip
__all__ = [ __all__ = [
"CalculatedPotential", "CalculatedPotential",
"Chart", "Chart",
"ChartInfo", "ChartInfo",
"Difficulty", "Difficulty",
"DifficultyLocalized", "DifficultyLocalization",
"ModelsV5Base", "ModelBase",
"ModelsV5ViewBase", "ModelViewBase",
"Pack", "Pack",
"PackLocalized", "PackLocalization",
"PlayResult", "PlayResult",
"PlayResultBest", "PlayResultBest",
"PlayResultCalculated", "PlayResultCalculated",
"Property", "Property",
"Song", "Song",
"SongLocalized", "SongLocalization",
"SongSearchWord",
] ]

View File

@ -1,31 +1,30 @@
from datetime import datetime from datetime import datetime
from sqlalchemy import MetaData
from sqlalchemy.orm import DeclarativeBase from sqlalchemy.orm import DeclarativeBase
from sqlalchemy.orm.exc import DetachedInstanceError from sqlalchemy.orm.exc import DetachedInstanceError
from arcaea_offline.constants.enums import ( from ._types import ForceTimezoneDateTime
ArcaeaPlayResultClearType,
ArcaeaPlayResultModifier,
ArcaeaRatingClass,
ArcaeaSongSide,
)
from ._custom_types import DbIntEnum, TZDateTime
TYPE_ANNOTATION_MAP = { TYPE_ANNOTATION_MAP = {
datetime: TZDateTime, datetime: ForceTimezoneDateTime,
ArcaeaRatingClass: DbIntEnum(ArcaeaRatingClass),
ArcaeaSongSide: DbIntEnum(ArcaeaSongSide),
ArcaeaPlayResultClearType: DbIntEnum(ArcaeaPlayResultClearType),
ArcaeaPlayResultModifier: DbIntEnum(ArcaeaPlayResultModifier),
} }
class ModelsV5Base(DeclarativeBase): class ModelBase(DeclarativeBase):
type_annotation_map = TYPE_ANNOTATION_MAP 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 type_annotation_map = TYPE_ANNOTATION_MAP
@ -34,7 +33,7 @@ class ReprHelper:
def _repr(self, **kwargs) -> str: def _repr(self, **kwargs) -> str:
""" """
Helper for __repr__ SQLAlchemy model __repr__ helper
https://stackoverflow.com/a/55749579/16484891 https://stackoverflow.com/a/55749579/16484891

View File

@ -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

View File

@ -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

View File

@ -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,
)

View File

@ -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,
)

View File

@ -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)

View File

@ -1,12 +1,12 @@
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from .base import ModelsV5Base, ReprHelper from ._base import ModelBase, ReprHelper
__all__ = ["Property"] __all__ = ["Property"]
class Property(ModelsV5Base, ReprHelper): class Property(ModelBase, ReprHelper):
__tablename__ = "properties" __tablename__ = "property"
key: Mapped[str] = mapped_column(primary_key=True) key: Mapped[str] = mapped_column(primary_key=True)
value: Mapped[str] = mapped_column() value: Mapped[str] = mapped_column()

View File

@ -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)

View File

@ -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)

View File

@ -1,18 +1,14 @@
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Optional from typing import Optional
from uuid import UUID, uuid4
from sqlalchemy import ForeignKey, and_, case, func, inspect, select, text from sqlalchemy import Integer, String, Text, Uuid, case, func, inspect, select, text
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy_utils import create_view from sqlalchemy_utils import create_view
from arcaea_offline.constants.enums import ( from ._base import ModelBase, ModelViewBase, ReprHelper
ArcaeaPlayResultClearType, from .chart_info import ChartInfo
ArcaeaPlayResultModifier, from .difficulty import Difficulty
ArcaeaRatingClass,
)
from .arcaea import ChartInfo, Difficulty
from .base import ModelsV5Base, ModelsV5ViewBase, ReprHelper
__all__ = [ __all__ = [
"CalculatedPotential", "CalculatedPotential",
@ -22,59 +18,53 @@ __all__ = [
] ]
class PlayResult(ModelsV5Base, ReprHelper): class PlayResult(ModelBase, ReprHelper):
__tablename__ = "play_results" __tablename__ = "play_result"
id: Mapped[int] = mapped_column(autoincrement=True, primary_key=True) id: Mapped[int] = mapped_column(autoincrement=True, primary_key=True)
song_id: Mapped[str] = mapped_column( uuid: Mapped[UUID] = mapped_column(
ForeignKey(Difficulty.song_id, onupdate="CASCADE", ondelete="NO ACTION"), Uuid, nullable=False, unique=True, default=lambda: uuid4()
index=True,
) )
rating_class: Mapped[ArcaeaRatingClass] = mapped_column( song_id: Mapped[str] = mapped_column(String)
ForeignKey(Difficulty.rating_class, onupdate="CASCADE", ondelete="NO ACTION"), rating_class: Mapped[int] = mapped_column(Integer)
index=True, played_at: Mapped[Optional[datetime]] = mapped_column(
default=lambda: datetime.now(timezone.utc)
) )
score: Mapped[int] score: Mapped[int]
pure: Mapped[Optional[int]] pure: Mapped[Optional[int]]
pure_early: Mapped[Optional[int]]
pure_late: Mapped[Optional[int]]
far: Mapped[Optional[int]] far: Mapped[Optional[int]]
far_early: Mapped[Optional[int]]
far_late: Mapped[Optional[int]]
lost: Mapped[Optional[int]] lost: Mapped[Optional[int]]
date: Mapped[Optional[datetime]] = mapped_column(
default=lambda: datetime.now(timezone.utc)
)
max_recall: Mapped[Optional[int]] max_recall: Mapped[Optional[int]]
modifier: Mapped[Optional[ArcaeaPlayResultModifier]] clear_type: Mapped[Optional[int]]
clear_type: Mapped[Optional[ArcaeaPlayResultClearType]] modifier: Mapped[Optional[int]]
comment: Mapped[Optional[str]] comment: Mapped[Optional[str]] = mapped_column(Text)
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? class PlayResultCalculated(ModelViewBase, ReprHelper):
# https://stackoverflow.com/a/53253105/16484891
# CC BY-SA 4.0
class PlayResultCalculated(ModelsV5ViewBase, ReprHelper):
__tablename__ = "play_results_calculated" __tablename__ = "play_results_calculated"
id: Mapped[int] id: Mapped[int]
uuid: Mapped[UUID]
song_id: Mapped[str] song_id: Mapped[str]
rating_class: Mapped[ArcaeaRatingClass] rating_class: Mapped[int]
score: Mapped[int] score: Mapped[int]
pure: Mapped[Optional[int]] pure: Mapped[Optional[int]]
pure_early: Mapped[Optional[int]]
pure_late: Mapped[Optional[int]]
shiny_pure: Mapped[Optional[int]] shiny_pure: Mapped[Optional[int]]
far: Mapped[Optional[int]] far: Mapped[Optional[int]]
far_early: Mapped[Optional[int]]
far_late: Mapped[Optional[int]]
lost: Mapped[Optional[int]] lost: Mapped[Optional[int]]
date: Mapped[Optional[datetime]] played_at: Mapped[Optional[datetime]]
max_recall: Mapped[Optional[int]] max_recall: Mapped[Optional[int]]
modifier: Mapped[Optional[ArcaeaPlayResultModifier]] modifier: Mapped[Optional[int]]
clear_type: Mapped[Optional[ArcaeaPlayResultClearType]] clear_type: Mapped[Optional[int]]
potential: Mapped[float] potential: Mapped[float]
comment: Mapped[Optional[str]] comment: Mapped[Optional[str]]
@ -106,7 +96,7 @@ class PlayResultCalculated(ModelsV5ViewBase, ReprHelper):
).label("shiny_pure"), ).label("shiny_pure"),
PlayResult.far, PlayResult.far,
PlayResult.lost, PlayResult.lost,
PlayResult.date, PlayResult.played_at,
PlayResult.max_recall, PlayResult.max_recall,
PlayResult.modifier, PlayResult.modifier,
PlayResult.clear_type, PlayResult.clear_type,
@ -137,26 +127,31 @@ class PlayResultCalculated(ModelsV5ViewBase, ReprHelper):
(Difficulty.song_id == PlayResult.song_id) (Difficulty.song_id == PlayResult.song_id)
& (Difficulty.rating_class == PlayResult.rating_class), & (Difficulty.rating_class == PlayResult.rating_class),
), ),
metadata=ModelsV5ViewBase.metadata, metadata=ModelViewBase.metadata,
cascade_on_drop=False, cascade_on_drop=False,
) )
class PlayResultBest(ModelsV5ViewBase, ReprHelper): class PlayResultBest(ModelViewBase, ReprHelper):
__tablename__ = "play_results_best" __tablename__ = "play_results_best"
id: Mapped[int] id: Mapped[int]
uuid: Mapped[UUID]
song_id: Mapped[str] song_id: Mapped[str]
rating_class: Mapped[ArcaeaRatingClass] rating_class: Mapped[int]
score: Mapped[int] score: Mapped[int]
pure: Mapped[Optional[int]] pure: Mapped[Optional[int]]
pure_early: Mapped[Optional[int]]
pure_late: Mapped[Optional[int]]
shiny_pure: Mapped[Optional[int]] shiny_pure: Mapped[Optional[int]]
far: Mapped[Optional[int]] far: Mapped[Optional[int]]
far_early: Mapped[Optional[int]]
far_late: Mapped[Optional[int]]
lost: Mapped[Optional[int]] lost: Mapped[Optional[int]]
date: Mapped[Optional[datetime]] played_at: Mapped[Optional[datetime]]
max_recall: Mapped[Optional[int]] max_recall: Mapped[Optional[int]]
modifier: Mapped[Optional[ArcaeaPlayResultModifier]] modifier: Mapped[Optional[int]]
clear_type: Mapped[Optional[ArcaeaPlayResultClearType]] clear_type: Mapped[Optional[int]]
potential: Mapped[float] potential: Mapped[float]
comment: Mapped[Optional[str]] comment: Mapped[Optional[str]]
@ -173,12 +168,12 @@ class PlayResultBest(ModelsV5ViewBase, ReprHelper):
.select_from(PlayResultCalculated) .select_from(PlayResultCalculated)
.group_by(PlayResultCalculated.song_id, PlayResultCalculated.rating_class) .group_by(PlayResultCalculated.song_id, PlayResultCalculated.rating_class)
.order_by(PlayResultCalculated.potential.desc()), .order_by(PlayResultCalculated.potential.desc()),
metadata=ModelsV5ViewBase.metadata, metadata=ModelViewBase.metadata,
cascade_on_drop=False, cascade_on_drop=False,
) )
class CalculatedPotential(ModelsV5ViewBase, ReprHelper): class CalculatedPotential(ModelViewBase, ReprHelper):
__tablename__ = "calculated_potential" __tablename__ = "calculated_potential"
b30: Mapped[float] b30: Mapped[float]
@ -192,6 +187,6 @@ class CalculatedPotential(ModelsV5ViewBase, ReprHelper):
__table__ = create_view( __table__ = create_view(
name=__tablename__, name=__tablename__,
selectable=select(func.avg(_select_bests_subquery.c.b30_sum).label("b30")), selectable=select(func.avg(_select_bests_subquery.c.b30_sum).label("b30")),
metadata=ModelsV5ViewBase.metadata, metadata=ModelViewBase.metadata,
cascade_on_drop=False, cascade_on_drop=False,
) )

View File

@ -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")
)