From 5d725ec18cd911d6618d8602835545717b54aa7e Mon Sep 17 00:00:00 2001 From: Correl Date: Wed, 14 Jul 2021 17:12:03 -0400 Subject: [PATCH] Add advanced search Use a parsing library to allow search using a subset of Scryfall's search syntax. --- pyproject.toml | 1 + tutor/database.py | 87 ++++++++++++++++++++++++++ tutor/search.py | 154 ++++++++++++++++++++++++++++++++++++++++++++++ tutor/server.py | 9 +-- 4 files changed, 247 insertions(+), 4 deletions(-) create mode 100644 tutor/search.py diff --git a/pyproject.toml b/pyproject.toml index 4ba5780..5164b0e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,6 +11,7 @@ aiosqlite = "^0.17.0" click = "^8.0.1" humanize = "^3.10.0" httpx = "^0.18.2" +parsy = "^1.3.0" [tool.poetry.dev-dependencies] pytest = "^5.2" diff --git a/tutor/database.py b/tutor/database.py index bbcaf29..f2199d2 100644 --- a/tutor/database.py +++ b/tutor/database.py @@ -1,11 +1,16 @@ import datetime import decimal +import logging import typing import uuid import aiosqlite import tutor.models +import tutor.search + + +logger = logging.getLogger(__name__) async def search( @@ -84,6 +89,88 @@ async def search( ] +async def advanced_search( + db: aiosqlite.Connection, + search: tutor.search.Search, + limit: int = 10, + in_collection: typing.Optional[bool] = None, +) -> typing.List[tutor.models.Card]: + db.row_factory = aiosqlite.Row + joins = [] + constraints = [] + params = {} + sets = [] + + logging.debug("Performing search for: %s", search) + for i, criterion in enumerate(search.criteria): + param = f"param_{i}" + if isinstance(criterion, tutor.search.Name): + constraints.append(f"cards.name LIKE :{param}") + params[param] = f"%{criterion.name}%" + if isinstance(criterion, tutor.search.Type): + constraints.append(f"cards.type_line LIKE :{param}") + params[param] = f"%{criterion.name}%" + if isinstance(criterion, tutor.search.IsExpansion): + constraints.append(f"cards.set_code LIKE :{param}") + params[param] = criterion.set_code + if isinstance(criterion, tutor.search.InExpansion): + sets.append(criterion.set_code) + if isinstance(criterion, tutor.search.IsColor): + constraints.append(f"cards.color_identity LIKE :{param}") + params[param] = tutor.models.Color.to_string(criterion.colors) + if isinstance(criterion, tutor.search.IsRarity): + constraints.append(f"cards.rarity LIKE :{param}") + params[param] = str(criterion.rarity) + if isinstance(criterion, tutor.search.Oracle): + constraints.append(f"cards.oracle_text LIKE :{param}") + params[param] = f"%{criterion.text}%" + + if sets: + set_params = {f"set_{i}": set_code for i, set_code in enumerate(sets)} + constraints.append( + "cards.set_code IN ({})".format( + ", ".join([f":{key}" for key in set_params.keys()]) + ) + ) + params.update(set_params) + if in_collection is not None: + if in_collection: + joins.append("JOIN copies ON (cards.scryfall_id = copies.scryfall_id)") + else: + joins.append("LEFT JOIN copies ON (cards.scryfall_id = copies.scryfall_id)") + constraints.append("copies.id IS NULL") + joins.append("JOIN sets ON (cards.set_code = sets.set_code)") + query = " ".join( + [ + "SELECT cards.* FROM cards", + " ".join(joins), + "WHERE" if constraints else "", + " AND ".join(constraints), + f"LIMIT {limit}", + ] + ) + cursor = await db.execute(query, params) + rows = await cursor.fetchall() + return [ + tutor.models.Card( + scryfall_id=uuid.UUID(row["scryfall_id"]), + name=row["name"], + set_code=row["set_code"], + collector_number=row["collector_number"], + rarity=tutor.models.Rarity.from_string(row["rarity"]), + color_identity=tutor.models.Color.from_string(row["color_identity"]), + cmc=decimal.Decimal(row["cmc"]), + type_line=row["type_line"], + release_date=datetime.date.fromisoformat(row["release_date"]), + games=set(), + legalities={}, + edhrec_rank=row["edhrec_rank"], + oracle_text=row["oracle_text"], + ) + for row in rows + ] + + async def store_card(db: aiosqlite.Connection, card: tutor.models.Card) -> None: await db.execute( "INSERT INTO cards " diff --git a/tutor/search.py b/tutor/search.py new file mode 100644 index 0000000..f86084d --- /dev/null +++ b/tutor/search.py @@ -0,0 +1,154 @@ +import dataclasses +import functools +import typing + +import parsy + +import tutor.models + + +class Criterion: + ... + + +@dataclasses.dataclass +class IsExpansion(Criterion): + set_code: parsy.string + + +@dataclasses.dataclass +class InExpansion(Criterion): + set_code: parsy.string + + +@dataclasses.dataclass +class IsColor(Criterion): + colors: typing.Set[tutor.models.Color] + + +@dataclasses.dataclass +class IsRarity(Criterion): + rarity: tutor.models.Rarity + + +@dataclasses.dataclass +class Name(Criterion): + name: parsy.string + + +@dataclasses.dataclass +class Type(Criterion): + name: parsy.string + + +@dataclasses.dataclass +class Oracle(Criterion): + text: parsy.string + + +@dataclasses.dataclass +class Search: + criteria: typing.List[Criterion] + + +lstring = functools.partial(parsy.string, transform=lambda s: s.lower()) +ustring = functools.partial(parsy.string, transform=lambda s: s.upper()) +lstring_from = functools.partial(parsy.string_from, transform=lambda s: s.lower()) +ustring_from = functools.partial(parsy.string_from, transform=lambda s: s.upper()) + +W, U, B, G, R = ( + tutor.models.Color.White, + tutor.models.Color.Blue, + tutor.models.Color.Black, + tutor.models.Color.Green, + tutor.models.Color.Red, +) +matches = parsy.string(":") + +color = ( + ustring("w").result(W) + | ustring("u").result(U) + | ustring("b").result(B) + | ustring("g").result(G) + | ustring("r").result(R) +) + +multicolor = color.many() + +single_color = ( + lstring("white").result({W}) + | lstring("blue").result({U}) + | lstring("black").result({B}) + | lstring("green").result({G}) + | lstring("red").result({R}) +) + +guild = ( + lstring("boros").result({W, R}) + | lstring("golgari").result({G, B}) + | lstring("selesnya").result({G, W}) + | lstring("dimir").result({U, B}) + | lstring("orzhov").result({W, B}) + | lstring("izzet").result({U, R}) + | lstring("gruul").result({R, G}) + | lstring("azorius").result({W, U}) + | lstring("rakdos").result({B, R}) + | lstring("simic").result({G, U}) +) + +shard = ( + lstring("bant").result({W, G, U}) + | lstring("esper").result({U, W, B}) + | lstring("grixis").result({B, U, R}) + | lstring("jund").result({R, B, G}) + | lstring("naya").result({G, R, W}) +) + +wedge = ( + lstring("abzan").result({W, B, G}) + | lstring("jeskai").result({W, U, R}) + | lstring("sultai").result({U, B, G}) + | lstring("mardu").result({W, B, R}) + | lstring("temur").result({U, R, G}) +) + +colors = single_color | guild | shard | wedge | multicolor + +expansion_string = ( + parsy.regex(r"[a-zA-Z0-9]+").map(lambda s: s.upper()).desc("expansion set code") +) + +is_expansion = lstring_from("e", "s") >> matches >> expansion_string.map(IsExpansion) + +in_expansion = lstring("in") >> matches >> expansion_string.map(InExpansion) + +rarity = ( + lstring_from("c", "common").result(tutor.models.Rarity.Common) + | lstring_from("u", "uncommon").result(tutor.models.Rarity.Uncommon) + | lstring_from("r", "rare").result(tutor.models.Rarity.Rare) + | lstring_from("m", "mythic").result(tutor.models.Rarity.Mythic) +) + +is_rarity = lstring_from("r", "rarity") >> matches >> rarity.map(IsRarity) + +is_color = lstring_from("c", "color", "colors") >> matches >> colors.map(IsColor) + +string_literal = parsy.regex(r'"[^"]*"').map(lambda s: s[1:-1]) | parsy.regex(r"[^\s]+") + +has_type = lstring_from("t", "type") >> matches >> string_literal.map(Type) + +has_oracle = lstring_from("o", "oracle") >> matches >> string_literal.map(Oracle) + +name = string_literal.map(Name) + +criterion = ( + is_expansion | in_expansion | is_rarity | is_color | has_type | has_oracle | name +) + +padding = parsy.regex(r"\s*") +search = padding >> (criterion << padding).many().map(Search) + +if __name__ == "__main__": + import sys + + print(search.parse(" ".join(sys.argv[1:]))) diff --git a/tutor/server.py b/tutor/server.py index 6e1f908..197fbe9 100644 --- a/tutor/server.py +++ b/tutor/server.py @@ -5,7 +5,7 @@ import tornado.web import tutor.database import tutor.models - +import tutor.search class SearchHandler(tornado.web.RequestHandler): async def get(self) -> None: @@ -13,11 +13,12 @@ class SearchHandler(tornado.web.RequestHandler): name = self.get_argument("name", None) in_collection = self.get_argument("in_collection", None) limit = int(self.get_argument("limit", 10)) - cards = await tutor.database.search( + search = tutor.search.search.parse(name) + cards = await tutor.database.advanced_search( db, - name=f"%{name}%" if name else None, - in_collection=in_collection.lower() == "yes" if in_collection else None, + search, limit=limit, + in_collection=in_collection, ) self.set_header("Content-Type", "application/json") self.set_header("Access-Control-Allow-Origin", "*")