Add advanced search
Use a parsing library to allow search using a subset of Scryfall's search syntax.
This commit is contained in:
parent
f781e36b9c
commit
5d725ec18c
4 changed files with 247 additions and 4 deletions
|
@ -11,6 +11,7 @@ aiosqlite = "^0.17.0"
|
||||||
click = "^8.0.1"
|
click = "^8.0.1"
|
||||||
humanize = "^3.10.0"
|
humanize = "^3.10.0"
|
||||||
httpx = "^0.18.2"
|
httpx = "^0.18.2"
|
||||||
|
parsy = "^1.3.0"
|
||||||
|
|
||||||
[tool.poetry.dev-dependencies]
|
[tool.poetry.dev-dependencies]
|
||||||
pytest = "^5.2"
|
pytest = "^5.2"
|
||||||
|
|
|
@ -1,11 +1,16 @@
|
||||||
import datetime
|
import datetime
|
||||||
import decimal
|
import decimal
|
||||||
|
import logging
|
||||||
import typing
|
import typing
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
import aiosqlite
|
import aiosqlite
|
||||||
|
|
||||||
import tutor.models
|
import tutor.models
|
||||||
|
import tutor.search
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
async def search(
|
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:
|
async def store_card(db: aiosqlite.Connection, card: tutor.models.Card) -> None:
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"INSERT INTO cards "
|
"INSERT INTO cards "
|
||||||
|
|
154
tutor/search.py
Normal file
154
tutor/search.py
Normal file
|
@ -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:])))
|
|
@ -5,7 +5,7 @@ import tornado.web
|
||||||
|
|
||||||
import tutor.database
|
import tutor.database
|
||||||
import tutor.models
|
import tutor.models
|
||||||
|
import tutor.search
|
||||||
|
|
||||||
class SearchHandler(tornado.web.RequestHandler):
|
class SearchHandler(tornado.web.RequestHandler):
|
||||||
async def get(self) -> None:
|
async def get(self) -> None:
|
||||||
|
@ -13,11 +13,12 @@ class SearchHandler(tornado.web.RequestHandler):
|
||||||
name = self.get_argument("name", None)
|
name = self.get_argument("name", None)
|
||||||
in_collection = self.get_argument("in_collection", None)
|
in_collection = self.get_argument("in_collection", None)
|
||||||
limit = int(self.get_argument("limit", 10))
|
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,
|
db,
|
||||||
name=f"%{name}%" if name else None,
|
search,
|
||||||
in_collection=in_collection.lower() == "yes" if in_collection else None,
|
|
||||||
limit=limit,
|
limit=limit,
|
||||||
|
in_collection=in_collection,
|
||||||
)
|
)
|
||||||
self.set_header("Content-Type", "application/json")
|
self.set_header("Content-Type", "application/json")
|
||||||
self.set_header("Access-Control-Allow-Origin", "*")
|
self.set_header("Access-Control-Allow-Origin", "*")
|
||||||
|
|
Loading…
Reference in a new issue