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 Sep 5, 2018
1 parent 5547611 commit 867d2eb
Show file tree
Hide file tree
Showing 13 changed files with 58 additions and 82 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,7 @@ Notice that we're using the `@validate` decorator to validate the request parame

**Step 3: Add the endpoint to the server**

Now we can add the endpoint to the servers by updating `endpoints` in the `start` function in `cli.py` and server function in `conftest.py`:
Now we can add the endpoint to the servers by updating `endpoints` in the `start` function in `cli.py` and `server` function in `conftest.py`:

In `cli.py`:
```python
Expand Down
14 changes: 2 additions & 12 deletions bounce/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,12 @@
import logging

import click

from sanic.log import logger

from .server import Server
from .server.api.auth import LoginEndpoint
<<<<<<< HEAD
from .server.api.clubs import ClubEndpoint, ClubsEndpoint
from .server.api.users import UserEndpoint, UserImagesEndpoint, UsersEndpoint
=======
from .server.api.clubs import ClubEndpoint, ClubsEndpoint, SearchClubsEndpoint
from .server.api.users import UserEndpoint, UsersEndpoint
>>>>>>> create new SearchClubsEndpoint
from .server.api.users import UserEndpoint, UserImagesEndpoint, UsersEndpoint
from .server.config import ServerConfig


Expand Down Expand Up @@ -87,12 +81,8 @@ def start(port, secret, pg_host, pg_port, pg_user, pg_password, pg_database,
pg_database, allowed_origin, image_dir)
# Register your new endpoints here
endpoints = [
<<<<<<< HEAD
UsersEndpoint, UserEndpoint, UserImagesEndpoint, ClubsEndpoint,
ClubEndpoint, LoginEndpoint
=======
UsersEndpoint, UserEndpoint, SearchClubsEndpoint, ClubsEndpoint, ClubEndpoint, LoginEndpoint
>>>>>>> create new SearchClubsEndpoint
ClubEndpoint, SearchClubsEndpoint, 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 @@ -69,21 +69,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
2 changes: 1 addition & 1 deletion container/dev/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ services:
command: sh -c "pip install -e . && bounce start"
volumes:
- ../..:/opt/bounce
- images:/var/bounce/images
- ./images:/var/bounce/images
ports:
- 8080:8080
depends_on:
Expand Down
1 change: 0 additions & 1 deletion requirements.in
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,3 @@ psycopg2==2.7.4
jsonschema==2.6.0
bcrypt==3.1.4
python-jose==3.0.0
sqlalchemy-searchable==1.0.3
5 changes: 1 addition & 4 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ aiofiles==0.4.0 # via sanic
bcrypt==3.1.4
cffi==1.11.5 # via bcrypt
click==6.7
decorator==4.3.0 # via validators
ecdsa==0.13 # via python-jose
future==0.16.0 # via python-jose
httptools==0.0.11 # via sanic
Expand All @@ -19,9 +18,7 @@ pycparser==2.18 # via cffi
python-jose==3.0.0
rsa==3.4.2 # via python-jose
sanic==0.7.0
six==1.11.0 # via bcrypt, python-jose, sqlalchemy-utils, validators
sqlalchemy-searchable==1.0.3
sqlalchemy-utils==0.33.3 # via sqlalchemy-searchable
six==1.11.0 # via bcrypt, python-jose
sqlalchemy==1.2.8
ujson==1.35 # via sanic
uvloop==0.11.2 # via sanic
Expand Down
7 changes: 3 additions & 4 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 Down
47 changes: 24 additions & 23 deletions tests/api/test_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,7 @@

from aiohttp import FormData

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


def test_root_handler(server):
Expand Down Expand Up @@ -147,28 +146,30 @@ 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):
Expand Down

0 comments on commit 867d2eb

Please sign in to comment.