~minus/stewdio-api

cc42146ad5dc278b0a5f819abdf1c8d9fb09c43c — minus 4 years ago e2881c8
Add variables for current artist/album
4 files changed, 71 insertions(+), 41 deletions(-)

M stewdio/search/ast.py
M stewdio/search/parse.py
M stewdio/search/parse_test.py
M stewdio/search/query.py
M stewdio/search/ast.py => stewdio/search/ast.py +33 -11
@@ 48,11 48,18 @@ QUICK = {
}


class Context:
    # not part of the AST
    def __init__(self, artist=None, album=None):
        self.artist = artist
        self.album = album


class String:
    def __init__(self, value):
        self.value = value

    def build(self):
    def build(self, context):
        return Literal(self.value)

    def __repr__(self):


@@ 67,8 74,8 @@ class Not:
    def __init__(self, query):
        self.query = query

    def build(self):
        return SQL('NOT ') + self.query.build()
    def build(self, context):
        return SQL('NOT ') + self.query.build(context)

    def __repr__(self):
        return f'{self.__class__.__name__}({self.query!r})'


@@ 83,8 90,8 @@ class And:
        self.left = left
        self.right = right

    def build(self):
        return SQL('(') + self.left.build() + SQL(' AND ') + self.right.build() + SQL(')')
    def build(self, context):
        return SQL('(') + self.left.build(context) + SQL(' AND ') + self.right.build(context) + SQL(')')

    def __repr__(self):
        return f'{self.__class__.__name__}({self.left!r}, {self.right!r})'


@@ 100,8 107,8 @@ class Or:
        self.left = left
        self.right = right

    def build(self):
        return SQL('(') + self.left.build() + SQL(' OR ') + self.right.build() + SQL(')')
    def build(self, context):
        return SQL('(') + self.left.build(context) + SQL(' OR ') + self.right.build(context) + SQL(')')

    def __repr__(self):
        return f'{self.__class__.__name__}({self.left!r}, {self.right!r})'


@@ 120,8 127,8 @@ class Qualified:
        self.oc = QUALIFIERS[self.qualifier]
        self.op = op if op else self.oc.default_op

    def build(self):
        return OP_MAP[self.op](self.oc.field, self.search_term.build())
    def build(self, context):
        return OP_MAP[self.op](self.oc.field, self.search_term.build(context))

    def __repr__(self):
        op = f', op=Ops.{self.op}' if self.op else ''


@@ 138,11 145,11 @@ class Unqualified:
    def __init__(self, search_term):
        self.search_term = search_term

    def build(self):
    def build(self, context):
        def build_one(qualifier):
            oc = QUALIFIERS[qualifier]
            op = oc.default_op
            return OP_MAP[op](oc.field, self.search_term.build())
            return OP_MAP[op](oc.field, self.search_term.build(context))
        return SQL('(') + SQL(' OR ').join(build_one(q) for q in UNQUALIFIERS) + SQL(')')

    def __repr__(self):


@@ 151,3 158,18 @@ class Unqualified:
    def __eq__(self, other):
        return (isinstance(other, self.__class__)
                and other.search_term == self.search_term)


class Variable:
    def __init__(self, name):
        self.name = name
        self.oc = QUALIFIERS[name]

    def build(self, context):
        return OP_MAP[Ops.EQUALS](self.oc.field, Literal(getattr(context, self.name)))

    def __repr__(self):
        return f'{self.__class__.__name__}({self.name!r})'

    def __eq__(self, other):
        return (isinstance(other, self.__class__) and other.name == self.name)

M stewdio/search/parse.py => stewdio/search/parse.py +11 -3
@@ 8,10 8,11 @@ EBNF:

query = ( combination | inverted_query | subquery | elemental_query ) ;

elemental_query = ( qualified | unqualified | quick ) ;
elemental_query = ( qualified | unqualified | quick | variable ) ;
qualified = WORD , OP , string ;
unqualified = string ;
quick = QUICK , string ;
variable = DOLLAR , string ;

combination = query , [ ( AND | OR ) ] , query ;



@@ 28,13 29,14 @@ lg = LexerGenerator()
lg.add('AND', r'AND')
lg.add('OR', r'OR')
lg.add('NOT', r'NOT')
lg.add('WORD', r'[^:"\'()\s=<>\-#@/][^:)\s=<>]*')
lg.add('WORD', r'[^:"\'()\s=<>\-#@/$][^:)\s=<>]*')
lg.add('STRING', r'"[^"]*"|\'[^\']*\'')
lg.add('MINUS', r'-')
lg.add('LPAREN', r'\(')
lg.add('RPAREN', r'\)')
lg.add('OP', r'[:=<>]')
lg.add('QUICK', r'[#@/]')
lg.add('DOLLAR', r'\$')

lg.ignore(r'\s+')



@@ 60,6 62,7 @@ def main(p):
@pg.production('elemental_query : qualified')
@pg.production('elemental_query : unqualified')
@pg.production('elemental_query : quick')
@pg.production('elemental_query : variable')
@pg.production('not : NOT')
@pg.production('not : MINUS')
def alias(p):


@@ 95,6 98,11 @@ def quick(p):
    return Qualified(qualifier_name, p[1])


@pg.production('variable : DOLLAR string')
def variable(p):
    return Variable(p[1].value)


@pg.production('inverted_query : not query', precedence='NOT')
def inverted_query(p):
    return Not(p[1])


@@ 153,4 161,4 @@ if __name__ == '__main__':
    print(ast)
    import psycopg2
    conn = psycopg2.connect('')
    print(ast.build().as_string(conn))
    print(ast.build(None).as_string(conn))

M stewdio/search/parse_test.py => stewdio/search/parse_test.py +23 -23
@@ 6,35 6,35 @@ import pytest


cases = (
    ('''(artist:mizuki OR artist:水樹) AND NOT fav:minus''', And(Or(Qualified('artist', String('mizuki')), Qualified('artist', String('水樹'))), Not(Qualified('fav', String('minus')))), None),
    ('''(artist:詩月カオリ OR (artist:utatsuki AND artist:kaori)) AND artist:kotoko''', And(Or(Qualified('artist', String('詩月カオリ')), And(Qualified('artist', String('utatsuki')), Qualified('artist', String('kaori')))), Qualified('artist', String('kotoko'))), None),
    ('''album:styx AND NOT title:instr''', And(Qualified('album', String('styx')), Not(Qualified('title', String('instr')))), None),
    ('''title:"always in this place" OR title:いつもこの場所で''', Or(Qualified('title', String('always in this place')), Qualified('title', String('いつもこの場所で'))), None),
    ('''album:barbarossa''', Qualified('album', String('barbarossa')), None),
    ('''tag:vocaloid''', Qualified('tag', String('vocaloid')), None),
    ('''test''', Unqualified(String('test')), None),
    ('''fav:SirCmpwn album:"gurren lagann"''', And(Qualified('fav', String('SirCmpwn')), Qualified('album', String('gurren lagann'))), None),
    ('''album:H.O.T.D NOT fav:minus''', And(Qualified('album', String('H.O.T.D')), Not(Qualified('fav', String('minus')))), None),
    ('''one in a billion''', And(And(And(Unqualified(String('one')), Unqualified(String('in'))), Unqualified(String('a'))), Unqualified(String('billion'))), None),
    ('''one in a billion may'n''', And(And(And(And(Unqualified(String('one')), Unqualified(String('in'))), Unqualified(String('a'))), Unqualified(String('billion'))), Unqualified(String("may'n"))), None),
    ('''(artist:mizuki OR artist:水樹) AND NOT fav:minus AND album:'supernal liberty' OR million''', Or(And(And(Or(Qualified('artist', String('mizuki')), Qualified('artist', String('水樹'))), Not(Qualified('fav', String('minus')))), Qualified('album', String('supernal liberty'))), Unqualified(String('million'))), None),
    ('''world.execute(me)''', (ValueError, "Ran into a Token('RPAREN', ')') where it wasn't expected"), None),
    ('''path:"comet lucifer" -inst''', And(Qualified('path', String('comet lucifer')), Not(Unqualified(String('inst')))), None),
    ('''(fav:minus OR fav:nyc OR fav:jdiez) NOT fav:sircmpwn''', And(Or(Or(Qualified('fav', String('minus')), Qualified('fav', String('nyc'))), Qualified('fav', String('jdiez'))), Not(Qualified('fav', String('sircmpwn')))), Composed([SQL('('), SQL('('), SQL('('), SQL('EXISTS('), SQL('SELECT 1 FROM users JOIN favorites ON (favorites.user_id = users.id) WHERE favorites.song = songs.id AND users.name = '), Composed([SQL('lower('), Literal('minus'), SQL(')')]), SQL(')'), SQL(' OR '), SQL('EXISTS('), SQL('SELECT 1 FROM users JOIN favorites ON (favorites.user_id = users.id) WHERE favorites.song = songs.id AND users.name = '), Composed([SQL('lower('), Literal('nyc'), SQL(')')]), SQL(')'), SQL(')'), SQL(' OR '), SQL('EXISTS('), SQL('SELECT 1 FROM users JOIN favorites ON (favorites.user_id = users.id) WHERE favorites.song = songs.id AND users.name = '), Composed([SQL('lower('), Literal('jdiez'), SQL(')')]), SQL(')'), SQL(')'), SQL(' AND '), SQL('NOT '), SQL('EXISTS('), SQL('SELECT 1 FROM users JOIN favorites ON (favorites.user_id = users.id) WHERE favorites.song = songs.id AND users.name = '), Composed([SQL('lower('), Literal('sircmpwn'), SQL(')')]), SQL(')'), SQL(')')])),
    ('''title="why?"''', Qualified('title', String('why?'), op=Ops.EQUALS), None),
    ('''#op @minus''', And(Qualified('tag', String('op')), Qualified('fav', String('minus'))), None),
    ('''@minus''', Qualified('fav', String('minus')), Composed([SQL('EXISTS('), SQL('SELECT 1 FROM users JOIN favorites ON (favorites.user_id = users.id) WHERE favorites.song = songs.id AND users.name = '), Composed([SQL('lower('), Literal('minus'), SQL(')')]), SQL(')')])),
    ('''duration>10 AND duration<500''', And(Qualified('duration', String('10'), op=Ops.GREATER_THAN), Qualified('duration', String('500'), op=Ops.LESS_THAN)), None),
    ('''(artist:mizuki OR artist:水樹) AND NOT fav:minus''', And(Or(Qualified('artist', String('mizuki')), Qualified('artist', String('水樹'))), Not(Qualified('fav', String('minus')))), None, None),
    ('''(artist:詩月カオリ OR (artist:utatsuki AND artist:kaori)) AND artist:kotoko''', And(Or(Qualified('artist', String('詩月カオリ')), And(Qualified('artist', String('utatsuki')), Qualified('artist', String('kaori')))), Qualified('artist', String('kotoko'))), None, None),
    ('''album:styx AND NOT title:instr''', And(Qualified('album', String('styx')), Not(Qualified('title', String('instr')))), None, None),
    ('''title:"always in this place" OR title:いつもこの場所で''', Or(Qualified('title', String('always in this place')), Qualified('title', String('いつもこの場所で'))), None, None),
    ('''album:barbarossa''', Qualified('album', String('barbarossa')), None, None),
    ('''tag:vocaloid''', Qualified('tag', String('vocaloid')), None, None),
    ('''test''', Unqualified(String('test')), None, None),
    ('''fav:SirCmpwn album:"gurren lagann"''', And(Qualified('fav', String('SirCmpwn')), Qualified('album', String('gurren lagann'))), None, None),
    ('''album:H.O.T.D NOT fav:minus''', And(Qualified('album', String('H.O.T.D')), Not(Qualified('fav', String('minus')))), None, None),
    ('''one in a billion''', And(And(And(Unqualified(String('one')), Unqualified(String('in'))), Unqualified(String('a'))), Unqualified(String('billion'))), None, None),
    ('''one in a billion may'n''', And(And(And(And(Unqualified(String('one')), Unqualified(String('in'))), Unqualified(String('a'))), Unqualified(String('billion'))), Unqualified(String("may'n"))), None, None),
    ('''(artist:mizuki OR artist:水樹) AND NOT fav:minus AND album:'supernal liberty' OR million''', Or(And(And(Or(Qualified('artist', String('mizuki')), Qualified('artist', String('水樹'))), Not(Qualified('fav', String('minus')))), Qualified('album', String('supernal liberty'))), Unqualified(String('million'))), None, None),
    ('''world.execute(me)''', (ValueError, "Ran into a Token('RPAREN', ')') where it wasn't expected"), None, None),
    ('''path:"comet lucifer" -inst''', And(Qualified('path', String('comet lucifer')), Not(Unqualified(String('inst')))), None, None),
    ('''(fav:minus OR fav:nyc OR fav:jdiez) NOT fav:sircmpwn''', And(Or(Or(Qualified('fav', String('minus')), Qualified('fav', String('nyc'))), Qualified('fav', String('jdiez'))), Not(Qualified('fav', String('sircmpwn')))), Composed([SQL('('), SQL('('), SQL('('), SQL('EXISTS('), SQL('SELECT 1 FROM users JOIN favorites ON (favorites.user_id = users.id) WHERE favorites.song = songs.id AND users.name = '), Composed([SQL('lower('), Literal('minus'), SQL(')')]), SQL(')'), SQL(' OR '), SQL('EXISTS('), SQL('SELECT 1 FROM users JOIN favorites ON (favorites.user_id = users.id) WHERE favorites.song = songs.id AND users.name = '), Composed([SQL('lower('), Literal('nyc'), SQL(')')]), SQL(')'), SQL(')'), SQL(' OR '), SQL('EXISTS('), SQL('SELECT 1 FROM users JOIN favorites ON (favorites.user_id = users.id) WHERE favorites.song = songs.id AND users.name = '), Composed([SQL('lower('), Literal('jdiez'), SQL(')')]), SQL(')'), SQL(')'), SQL(' AND '), SQL('NOT '), SQL('EXISTS('), SQL('SELECT 1 FROM users JOIN favorites ON (favorites.user_id = users.id) WHERE favorites.song = songs.id AND users.name = '), Composed([SQL('lower('), Literal('sircmpwn'), SQL(')')]), SQL(')'), SQL(')')]), None),
    ('''title="why?"''', Qualified('title', String('why?'), op=Ops.EQUALS), None, None),
    ('''#op @minus''', And(Qualified('tag', String('op')), Qualified('fav', String('minus'))), None, None),
    ('''@minus''', Qualified('fav', String('minus')), Composed([SQL('EXISTS('), SQL('SELECT 1 FROM users JOIN favorites ON (favorites.user_id = users.id) WHERE favorites.song = songs.id AND users.name = '), Composed([SQL('lower('), Literal('minus'), SQL(')')]), SQL(')')]), None),
    ('''duration>10 AND duration<500''', And(Qualified('duration', String('10'), op=Ops.GREATER_THAN), Qualified('duration', String('500'), op=Ops.LESS_THAN)), None, None),
    ('''$album''', Variable('album'), Composed([SQL('albums.name'), SQL(' ILIKE '), Literal('test album')]), Context(album='test album'))
)

@pytest.mark.parametrize('input,expected_ast,expected_sql', cases)
def test_parse(input, expected_ast, expected_sql):
@pytest.mark.parametrize('input,expected_ast,expected_sql,context', cases)
def test_parse(input, expected_ast, expected_sql, context):
    try:
        parsed = parse(input)
    except Exception as e:
        parsed = e.__class__, *e.args
    assert expected_ast == parsed
    if expected_sql is not None:
        generated_sql = parsed.build()
        print(generated_sql)
        generated_sql = parsed.build(context)
        assert generated_sql == expected_sql

M stewdio/search/query.py => stewdio/search/query.py +4 -4
@@ 22,8 22,8 @@ JOIN albums ON songs.album = albums.id
''')


def search(cursor, query, limit=None):
    where = SQL("WHERE songs.status = 'active' AND ") + parse(query).build()
def search(cursor, context, query, limit=None):
    where = SQL("WHERE songs.status = 'active' AND ") + parse(query).build(context)
    q = BASE_QUERY.format(where=where)
    if limit:
        q += SQL(' LIMIT ') + Literal(limit)


@@ 32,7 32,7 @@ def search(cursor, query, limit=None):


def search_favorites(cursor, user):
    q = BASE_QUERY.format(where=SQL(' WHERE ') + Qualified('fav', String(user)).build())
    q = BASE_QUERY.format(where=SQL(' WHERE ') + Qualified('fav', String(user)).build(None))
    cursor.execute(q, (user,))
    return [dict(r) for r in cursor]



@@ 63,7 63,7 @@ if __name__ == '__main__':
    print("original query from user input:")
    print(q)

    where = parse(q).build()
    where = parse(q).build(None)
    q = BASE_QUERY.format(where=SQL(''))
    q += SQL(' WHERE ') + where
    print("generated SQL condition:")