Skip to content

Commit

Permalink
Use SQL ILIKE for search
Browse files Browse the repository at this point in the history
  • Loading branch information
bfbachmann committed Aug 28, 2018
1 parent 4870cf2 commit 39fac6e
Show file tree
Hide file tree
Showing 10 changed files with 67 additions and 71 deletions.
4 changes: 2 additions & 2 deletions bounce/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
import logging

import click

from sanic.log import logger

from .server import Server
Expand Down Expand Up @@ -77,7 +76,8 @@ def start(port, secret, pg_host, pg_port, pg_user, pg_password, pg_database,
pg_database, allowed_origin)
# Register your new endpoints here
endpoints = [
UsersEndpoint, UserEndpoint, SearchClubsEndpoint, ClubsEndpoint, ClubEndpoint, LoginEndpoint
UsersEndpoint, UserEndpoint, SearchClubsEndpoint, ClubsEndpoint,
ClubEndpoint, LoginEndpoint
]
serv = Server(conf, endpoints)
serv.start()
3 changes: 3 additions & 0 deletions bounce/db/__init__.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
"""Utilities for interacting with the DB."""

import sqlalchemy
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker

BASE = declarative_base()


def create_engine(driver, user, password, host, port, db_name):
"""Create an Engine for interacting with the DB.
Expand Down
21 changes: 9 additions & 12 deletions bounce/db/club.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,15 @@
"""

from sqlalchemy import Column, Integer, String, func
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.types import TIMESTAMP
from sqlalchemy_searchable import make_searchable
from sqlalchemy_searchable import search as sql_search
from sqlalchemy_utils.types import TSVectorType

Base = declarative_base() # pylint: disable=invalid-name
make_searchable(Base.metadata)
from . import BASE

# The maximum number of results to return from one search query
MAX_SEARCH_RESULTS = 20

class Club(Base):

class Club(BASE):
"""
Specifies a mapping between a Club as a Python object and the Clubs table
in our DB.
Expand All @@ -30,8 +28,6 @@ class Club(Base):
twitter_url = Column('twitter_url', String, nullable=True)
created_at = Column(
'created_at', TIMESTAMP, nullable=False, server_default=func.now())
search_vector = Column('search_vector', TSVectorType(
'name', 'description'))

def to_dict(self):
"""Returns a dict representation of a club."""
Expand All @@ -56,10 +52,11 @@ def select(session, name):
return None if club is None else club.to_dict()


def search(session, query):
def search(session, query, max_results=MAX_SEARCH_RESULTS):
"""Returns a list of clubs that contain content from the user's query"""
clubs = session.query(Club)
return sql_search(clubs, query, sort=True)
clubs = session.query(Club).filter(
Club.name.ilike(f'%{query}%')).limit(max_results)
return [result.to_dict() for result in clubs]


def insert(session, name, description, website_url, facebook_url,
Expand Down
5 changes: 2 additions & 3 deletions bounce/db/membership.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
"""Defines the schema for the Memberships table in our DB."""

from sqlalchemy import Column, ForeignKey, Integer, func
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.types import TIMESTAMP

Base = declarative_base() # pylint: disable=invalid-name
from . import BASE


class Membership(Base):
class Membership(BASE):
"""
Specifies a mapping between a Membership as a Python object and the
Memberships table in our DB. A memership is simply a mapping from a user
Expand Down
5 changes: 2 additions & 3 deletions bounce/db/user.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
"""Defines the schema for the Users table in our DB."""

from sqlalchemy import Column, Integer, String, func
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.types import TIMESTAMP

Base = declarative_base() # pylint: disable=invalid-name
from . import BASE


class User(Base):
class User(BASE):
"""
Specifies a mapping between a User as a Python object and the Users table
in our DB.
Expand Down
15 changes: 7 additions & 8 deletions bounce/server/api/clubs.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,21 +68,20 @@ async def post(self, request):
raise APIError('Club already exists', status=409)
return response.text('', status=201)


class SearchClubsEndpoint(Endpoint):
"""Handles requests to /clubs/search."""

__uri__ = '/clubs/search'

@validate(SearchClubsRequest, SearchClubsResponse)
async def get(self, request):
"""Handles a GET /club/search request by returning clubs that contain content from the query."""
queried_clubs = club.search(self.server.db_session, request.args['query'][0])
if not queried_clubs:
"""
Handles a GET /clubs/search request by returning clubs that contain
content that matches the query.
"""
results = club.search(self.server.db_session, request.args['query'][0])
if not results:
# Failed to find clubs that match the query
raise APIError('No clubs match your query', status=404)
#import pdb
#pdb.set_trace()
results = []
for result in queried_clubs.all():
results.append(result.to_dict())
return response.json(results, status=200)
13 changes: 3 additions & 10 deletions bounce/server/resource/club.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ class GetClubResponse(metaclass=ResourceMeta):
'object',
'required': [
'name', 'description', 'website_url', 'facebook_url',
'instagram_url', 'twitter_url', 'id', 'created_at', 'tsvector'
'instagram_url', 'twitter_url', 'id', 'created_at'
],
'additionalProperties':
False,
Expand Down Expand Up @@ -98,15 +98,12 @@ class GetClubResponse(metaclass=ResourceMeta):
'created_at': {
'type': 'integer',
},
'search_vector': {
'type': 'tsvector'
},
}
}


class SearchClubsRequest(metaclass=ResourceMeta):
"""Defines the schema for a GET /clubs/<search> request."""
"""Defines the schema for a GET /clubs/search request."""
__params__ = {
'query': {
'type': 'string',
Expand All @@ -124,8 +121,7 @@ class SearchClubsResponse(metaclass=ResourceMeta):
'object',
'required': [
'name', 'description', 'website_url', 'facebook_url',
'instagram_url', 'twitter_url', 'id', 'created_at',
'tsvector'
'instagram_url', 'twitter_url', 'id', 'created_at'
],
'additionalProperties':
False,
Expand Down Expand Up @@ -155,9 +151,6 @@ class SearchClubsResponse(metaclass=ResourceMeta):
'created_at': {
'type': 'integer',
},
'search_vector': {
'type': 'tsvector'
},
}
}
}
Expand Down
11 changes: 5 additions & 6 deletions schema/schema.sql
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
DROP TABLE IF EXISTS clubs;
DROP TABLE IF EXISTS clubs CASCADE;
CREATE TABLE clubs (
id INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
Expand All @@ -7,11 +7,10 @@ CREATE TABLE clubs (
facebook_url TEXT,
instagram_url TEXT,
twitter_url TEXT,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT (now() at time zone 'utc'),
search_vector TSVECTOR
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT (now() at time zone 'utc')
);

DROP TABLE IF EXISTS users;
DROP TABLE IF EXISTS users CASCADE;
CREATE TABLE users (
id INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
full_name TEXT NOT NULL,
Expand All @@ -24,7 +23,7 @@ CREATE TABLE users (
DROP TABLE IF EXISTS memberships;
CREATE TABLE memberships (
id INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id INT REFERENCES users(id),
club_id INT REFERENCES clubs(id),
user_id INT REFERENCES users(id) ON DELETE CASCADE,
club_id INT REFERENCES clubs(id) ON DELETE CASCADE,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT (now() at time zone 'utc')
);
51 changes: 27 additions & 24 deletions tests/api/test_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,7 @@

import json

from bounce.server.api import util, Endpoint
from bounce.server.api.clubs import ClubEndpoint
from bounce.server.api import util


def test_root_handler(server):
Expand Down Expand Up @@ -139,30 +138,34 @@ def test_post_clubs__failure(server):
assert 'error' in response.json


def test_search_clubs(server):
def test_search_clubs__success(server):
# add dummy data to search for in database
server.app.test_client.post(
'/clubs',
data=json.dumps({
'name': 'ubclaunchpad',
'description': 'software engineering team',
}))
server.app.test_client.post(
'/clubs',
data=json.dumps({
'name': 'envision',
'description': 'chemical engineering team',
}))
server.app.test_client.post(
'/clubs',
data=json.dumps({
'name': 'ubcbiomod',
'description': 'chemical engineering team',
}))
server.app.test_client.get('/clubs/search?query=chemical')
assert queried_clubs.count() == 2
club_info = [
['UBC Launch Pad', 'software engineering team'],
['envision', 'something'],
['UBC biomed', 'something else']]
for name, desc in club_info:
server.app.test_client.post(
'/clubs',
data=json.dumps({
'name': name,
'description': desc,
'website_url': '',
'twitter_url': '',
'facebook_url': '',
'instagram_url': '',
}))

_, response = server.app.test_client.get('/clubs/search?query=UBC')
assert response.status == 200
body = response.json
assert len(body) == 2
assert body[0]['name'] == 'UBC Launch Pad'
assert body[0]['description'] == 'software engineering team'
assert body[1]['name'] == 'UBC biomed'
assert body[1]['description'] == 'something else'



def test_put_club__success(server):
_, response = server.app.test_client.put(
'/clubs/test',
Expand Down
10 changes: 7 additions & 3 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
"""Defines fixtures for use in our tests."""

import pytest

from bounce.server import Server
from bounce.server.api.auth import LoginEndpoint
from bounce.server.api.clubs import (ClubEndpoint, ClubsEndpoint,
SearchClubsEndpoint)
from bounce.server.api.users import UserEndpoint, UsersEndpoint
from bounce.server.api.clubs import ClubEndpoint, ClubsEndpoint, SearchClubsEndpoint
from bounce.server.config import ServerConfig


Expand All @@ -18,7 +20,9 @@ def config():
@pytest.fixture
def server(config):
"""Returns a test server."""
serv = Server(config, [UserEndpoint, UsersEndpoint, SearchClubsEndpoint,
ClubEndpoint, ClubsEndpoint, LoginEndpoint])
serv = Server(config, [
UserEndpoint, UsersEndpoint, SearchClubsEndpoint, ClubEndpoint,
ClubsEndpoint, LoginEndpoint
])
serv.start(test=True)
return serv

0 comments on commit 39fac6e

Please sign in to comment.