diff options
Diffstat (limited to 'src/hircine/api/inputs.py')
-rw-r--r-- | src/hircine/api/inputs.py | 578 |
1 files changed, 578 insertions, 0 deletions
diff --git a/src/hircine/api/inputs.py b/src/hircine/api/inputs.py new file mode 100644 index 0000000..c88bcce --- /dev/null +++ b/src/hircine/api/inputs.py @@ -0,0 +1,578 @@ +import datetime +from abc import ABC, abstractmethod +from typing import List, Optional, Type + +import strawberry +from sqlalchemy.orm.util import identity_key +from strawberry import UNSET + +import hircine.db +import hircine.db.ops as ops +from hircine.api import APIException, MutationContext +from hircine.api.responses import ( + IDNotFoundError, + InvalidParameterError, + PageClaimedError, + PageRemoteError, +) +from hircine.db.models import Archive, Base, Comic, ComicTag, Namespace, Tag +from hircine.enums import ( + Category, + Censorship, + Direction, + Language, + Layout, + OnMissing, + Rating, + UpdateMode, +) + + +def add_input_cls(modelcls): + return globals().get(f"Add{modelcls.__name__}Input") + + +def update_input_cls(modelcls): + return globals().get(f"Update{modelcls.__name__}Input") + + +def upsert_input_cls(modelcls): + return globals().get(f"Upsert{modelcls.__name__}Input") + + +class Fetchable(ABC): + """ + When mutating a model's associations, the API requires the user to pass + referential IDs. These may be referencing new items to add to a list of + associations, or items that should be removed. + + For example, the updateTags mutation requires as its input for the + "namespaces" field a list of numerical IDs: + + mutation updateTags { + updateTags(ids: 1, input: {namespaces: {ids: [1, 2]}}) { [...] } + } + + Mutations make heavy use of SQLAlchemy's ORM features to reconcile changes + between related objects (like Tags and Namespaces). In the example above, + to reconcile the changes made to a Tag's valid Namespaces, SQLAlchemy needs + to know about three objects: the Tag that is being modified and the two + Namespaces being added to it. + + This way SQLAlchemy can figure out whether it needs to add those Namespaces + to the Tag (or whether they're already there and can be skipped) and will, + upon commit, update the relevant tables automatically without us having to + emit custom SQL. + + SQLAlchemy cannot know about an object's relationships by ID alone, so it + needs to be fetched from the database first. The Fetchable class + facilitates this. It provides an abstract "fetch" method that, given a + MutationContext, will return any relevant objects from the database. + + Additionally, fetched items can be "constrained" to enforce API rules. + """ + + _model: Type[Base] + + @abstractmethod + async def fetch(self, ctx: MutationContext): + pass + + def update_mode(self): + try: + return self.options.mode + except AttributeError: + return UpdateMode.REPLACE + + @classmethod + async def constrain_item(cls, item, ctx: MutationContext): + pass + + +class FetchableID(Fetchable): + """ + A Fetchable for numerical IDs. Database queries are batched to avoid an + excess amount of SQL queries. + """ + + @classmethod + async def get_from_id(cls, id, ctx: MutationContext): + item, *_ = await cls.get_from_ids([id], ctx) + + return item + + @classmethod + async def get_from_ids(cls, ids, ctx: MutationContext): + items, missing = await ops.get_all( + ctx.session, cls._model, ids, use_identity_map=True + ) + + if missing: + raise APIException(IDNotFoundError(cls._model, missing.pop())) + + for item in items: + await cls.constrain_item(item, ctx) + + return items + + +class FetchableName(Fetchable): + """ + A Fetchable for textual IDs (used only for Tags). As with FetchableID, + queries are batched. + """ + + @classmethod + async def get_from_names(cls, names, ctx: MutationContext, on_missing: OnMissing): + for name in names: + if not name: + raise APIException( + InvalidParameterError( + parameter=f"{cls._model.__name__}.name", text="cannot be empty" + ) + ) + + items, missing = await ops.get_all_names(ctx.session, cls._model, names) + + if on_missing == OnMissing.CREATE: + for m in missing: + items.append(cls._model(name=m)) + + return items + + +@strawberry.input +class Input(FetchableID): + id: int + + async def fetch(self, ctx: MutationContext): + return await self.get_from_id(self.id, ctx) + + +@strawberry.input +class InputList(FetchableID): + ids: List[int] + + async def fetch(self, ctx: MutationContext): + if not self.ids: + return [] + + return await self.get_from_ids(self.ids, ctx) + + +@strawberry.input +class UpdateOptions: + mode: UpdateMode = UpdateMode.REPLACE + + +@strawberry.input +class UpdateInputList(InputList): + options: Optional[UpdateOptions] = UNSET + + +@strawberry.input +class Pagination: + page: int = 1 + items: int = 40 + + +@hircine.db.model("Archive") +@strawberry.input +class ArchiveInput(Input): + pass + + +@hircine.db.model("Page") +@strawberry.input +class UniquePagesInput(InputList): + @classmethod + async def constrain_item(cls, page, ctx): + if page.comic_id: + raise APIException(PageClaimedError(id=page.id, comic_id=page.comic_id)) + + if page.archive_id != ctx.input.archive.id: + raise APIException(PageRemoteError(id=page.id, archive_id=page.archive_id)) + + +@hircine.db.model("Page") +@strawberry.input +class UniquePagesUpdateInput(UpdateInputList): + @classmethod + async def constrain_item(cls, page, ctx): + if page.comic_id and page.comic_id != ctx.root.id: + raise APIException(PageClaimedError(id=page.id, comic_id=page.comic_id)) + + if page.archive_id != ctx.root.archive_id: + raise APIException(PageRemoteError(id=page.id, archive_id=page.archive_id)) + + +@hircine.db.model("Namespace") +@strawberry.input +class NamespacesInput(InputList): + pass + + +@hircine.db.model("Namespace") +@strawberry.input +class NamespacesUpdateInput(UpdateInputList): + pass + + +@hircine.db.model("Page") +@strawberry.input +class CoverInput(Input): + async def fetch(self, ctx: MutationContext): + page = await self.get_from_id(self.id, ctx) + return page.image + + @classmethod + async def constrain_item(cls, page, ctx): + if page.archive_id != ctx.input.archive.id: + raise APIException(PageRemoteError(id=page.id, archive_id=page.archive_id)) + + +@hircine.db.model("Page") +@strawberry.input +class CoverUpdateInput(CoverInput): + @classmethod + async def constrain_item(cls, page, ctx): + if ctx.model == Comic: + id = ctx.root.archive_id + elif ctx.model == Archive: + id = ctx.root.id + + if page.archive_id != id: + raise APIException(PageRemoteError(id=page.id, archive_id=page.archive_id)) + + +@hircine.db.model("Character") +@strawberry.input +class CharactersUpdateInput(UpdateInputList): + pass + + +@hircine.db.model("Artist") +@strawberry.input +class ArtistsUpdateInput(UpdateInputList): + pass + + +@hircine.db.model("Circle") +@strawberry.input +class CirclesUpdateInput(UpdateInputList): + pass + + +@hircine.db.model("World") +@strawberry.input +class WorldsUpdateInput(UpdateInputList): + pass + + +@strawberry.input +class ComicTagsUpdateInput(UpdateInputList): + ids: List[str] = strawberry.field(default_factory=lambda: []) + + @classmethod + def parse_input(cls, id): + try: + return [int(i) for i in id.split(":")] + except ValueError: + raise APIException( + InvalidParameterError( + parameter="id", + text="ComicTag ID must be specified as <namespace_id>:<tag_id>", + ) + ) + + @classmethod + async def get_from_ids(cls, ids, ctx: MutationContext): + comic = ctx.root + + ctags = [] + remaining = set() + + for id in ids: + nid, tid = cls.parse_input(id) + + key = identity_key(ComicTag, (comic.id, nid, tid)) + item = ctx.session.identity_map.get(key, None) + + if item is not None: + ctags.append(item) + else: + remaining.add((nid, tid)) + + if not remaining: + return ctags + + nids, tids = zip(*remaining) + + namespaces, missing = await ops.get_all( + ctx.session, Namespace, nids, use_identity_map=True + ) + if missing: + raise APIException(IDNotFoundError(Namespace, missing.pop())) + + tags, missing = await ops.get_all(ctx.session, Tag, tids, use_identity_map=True) + if missing: + raise APIException(IDNotFoundError(Tag, missing.pop())) + + for nid, tid in remaining: + namespace = ctx.session.identity_map.get(identity_key(Namespace, nid)) + tag = ctx.session.identity_map.get(identity_key(Tag, tid)) + + ctags.append(ComicTag(namespace=namespace, tag=tag)) + + return ctags + + +@strawberry.input +class UpsertOptions: + on_missing: OnMissing = OnMissing.IGNORE + + +@strawberry.input +class UpsertInputList(FetchableName): + names: List[str] = strawberry.field(default_factory=lambda: []) + options: Optional[UpsertOptions] = UNSET + + async def fetch(self, ctx: MutationContext): + if not self.names: + return [] + + options = self.options or UpsertOptions() + return await self.get_from_names(self.names, ctx, on_missing=options.on_missing) + + def update_mode(self): + return UpdateMode.ADD + + +@hircine.db.model("Character") +@strawberry.input +class CharactersUpsertInput(UpsertInputList): + pass + + +@hircine.db.model("Artist") +@strawberry.input +class ArtistsUpsertInput(UpsertInputList): + pass + + +@hircine.db.model("Circle") +@strawberry.input +class CirclesUpsertInput(UpsertInputList): + pass + + +@hircine.db.model("World") +@strawberry.input +class WorldsUpsertInput(UpsertInputList): + pass + + +@strawberry.input +class ComicTagsUpsertInput(UpsertInputList): + @classmethod + def parse_input(cls, name): + try: + namespace, tag = name.split(":") + + if not namespace or not tag: + raise ValueError() + + return namespace, tag + except ValueError: + raise APIException( + InvalidParameterError( + parameter="name", + text="ComicTag name must be specified as <namespace>:<tag>", + ) + ) + + @classmethod + async def get_from_names(cls, input, ctx: MutationContext, on_missing: OnMissing): + comic = ctx.root + + names = set() + for name in input: + names.add(cls.parse_input(name)) + + ctags, missing = await ops.get_ctag_names(ctx.session, comic.id, names) + + if not missing: + return ctags + + async def lookup(names, model): + have, missing = await ops.get_all_names( + ctx.session, model, names, options=model.load_full() + ) + dict = {} + + for item in have: + dict[item.name] = (item, True) + for item in missing: + dict[item] = (model(name=item), False) + + return dict + + remaining_ns, remaining_tags = zip(*missing) + + namespaces = await lookup(remaining_ns, Namespace) + tags = await lookup(remaining_tags, Tag) + + if on_missing == OnMissing.CREATE: + for ns, tag in missing: + namespace, _ = namespaces[ns] + tag, _ = tags[tag] + + tag.namespaces.append(namespace) + + ctags.append(ComicTag(namespace=namespace, tag=tag)) + + elif on_missing == OnMissing.IGNORE: + resident = [] + + for ns, tag in missing: + namespace, namespace_resident = namespaces[ns] + tag, tag_resident = tags[tag] + + if namespace_resident and tag_resident: + resident.append((namespace, tag)) + + restrictions = await ops.tag_restrictions( + ctx.session, [(ns.id, tag.id) for ns, tag in resident] + ) + + for namespace, tag in resident: + if namespace.id in restrictions[tag.id]: + ctags.append(ComicTag(namespace=namespace, tag=tag)) + + return ctags + + +@strawberry.input +class UpdateArchiveInput: + cover: Optional[CoverUpdateInput] = UNSET + organized: Optional[bool] = UNSET + + +@strawberry.input +class AddComicInput: + title: str + archive: ArchiveInput + pages: UniquePagesInput + cover: CoverInput + + +@strawberry.input +class UpdateComicInput: + title: Optional[str] = UNSET + original_title: Optional[str] = UNSET + cover: Optional[CoverUpdateInput] = UNSET + pages: Optional[UniquePagesUpdateInput] = UNSET + url: Optional[str] = UNSET + language: Optional[Language] = UNSET + date: Optional[datetime.date] = UNSET + direction: Optional[Direction] = UNSET + layout: Optional[Layout] = UNSET + rating: Optional[Rating] = UNSET + category: Optional[Category] = UNSET + censorship: Optional[Censorship] = UNSET + tags: Optional[ComicTagsUpdateInput] = UNSET + artists: Optional[ArtistsUpdateInput] = UNSET + characters: Optional[CharactersUpdateInput] = UNSET + circles: Optional[CirclesUpdateInput] = UNSET + worlds: Optional[WorldsUpdateInput] = UNSET + favourite: Optional[bool] = UNSET + organized: Optional[bool] = UNSET + bookmarked: Optional[bool] = UNSET + + +@strawberry.input +class UpsertComicInput: + title: Optional[str] = UNSET + original_title: Optional[str] = UNSET + url: Optional[str] = UNSET + language: Optional[Language] = UNSET + date: Optional[datetime.date] = UNSET + direction: Optional[Direction] = UNSET + layout: Optional[Layout] = UNSET + rating: Optional[Rating] = UNSET + category: Optional[Category] = UNSET + censorship: Optional[Censorship] = UNSET + tags: Optional[ComicTagsUpsertInput] = UNSET + artists: Optional[ArtistsUpsertInput] = UNSET + characters: Optional[CharactersUpsertInput] = UNSET + circles: Optional[CirclesUpsertInput] = UNSET + worlds: Optional[WorldsUpsertInput] = UNSET + favourite: Optional[bool] = UNSET + organized: Optional[bool] = UNSET + bookmarked: Optional[bool] = UNSET + + +@strawberry.input +class AddNamespaceInput: + name: str + sort_name: Optional[str] = UNSET + + +@strawberry.input +class UpdateNamespaceInput: + name: Optional[str] = UNSET + sort_name: Optional[str] = UNSET + + +@strawberry.input +class AddTagInput: + name: str + description: Optional[str] = None + namespaces: Optional[NamespacesInput] = UNSET + + +@strawberry.input +class UpdateTagInput: + name: Optional[str] = UNSET + description: Optional[str] = UNSET + namespaces: Optional[NamespacesUpdateInput] = UNSET + + +@strawberry.input +class AddArtistInput: + name: str + + +@strawberry.input +class UpdateArtistInput: + name: Optional[str] = UNSET + + +@strawberry.input +class AddCharacterInput: + name: str + + +@strawberry.input +class UpdateCharacterInput: + name: Optional[str] = UNSET + + +@strawberry.input +class AddCircleInput: + name: str + + +@strawberry.input +class UpdateCircleInput: + name: Optional[str] = UNSET + + +@strawberry.input +class AddWorldInput: + name: str + + +@strawberry.input +class UpdateWorldInput: + name: Optional[str] = UNSET |