Skip to content

Commit

Permalink
[Issue #1455] Implement logic for query box in the search API (#1514)
Browse files Browse the repository at this point in the history
## Summary
Fixes #1455

### Time to review: __10 mins__

## Changes proposed
Implements logic for the query field in the search API

Updated the seed script to allow you to generate more opportunities
locally

## Context for reviewers
This logic is deliberately very, very simple at the moment. It basically
just does a text contains check across several columns. This is the most
naive approach, and it is expected that if you tried to search two
separate words at the same time, you probably won't get any results.

A few options we may consider later (depending on the level of effort we
want to put into a non-search-index approach):
- Postgres text search / tsvectors:
https://www.postgresql.org/docs/current/textsearch.html
- Postgres text similarity:
https://www.postgresql.org/docs/current/pgtrgm.html

## Additional information
The `ids` function I added for the tests was because there are a lot of
tests and when test `16` fails, its really hard to figure out which one
that is. Now it outputs the request+expected results:
![Screenshot 2024-03-21 at 10 47 34
AM](https://github.com/HHS/simpler-grants-gov/assets/46358556/a7732375-00a8-4e4f-8e16-c00114a80342)

---------

Co-authored-by: nava-platform-bot <platform-admins@navapbc.com>
  • Loading branch information
chouinar and nava-platform-bot authored Mar 22, 2024
1 parent 7f050b7 commit 2b205af
Show file tree
Hide file tree
Showing 7 changed files with 392 additions and 27 deletions.
2 changes: 1 addition & 1 deletion api/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ db-migrate-heads: ## Show migrations marked as a head
$(alembic_cmd) heads $(args)

db-seed-local:
$(PY_RUN_CMD) db-seed-local
$(PY_RUN_CMD) db-seed-local $(args)

db-check-migrations: ## Verify the DB schema matches the DB migrations generated
$(alembic_cmd) check || (echo -e "\n$(RED)Migrations are not up-to-date, make sure you generate migrations by running 'make db-migrate-create <msg>'$(NO_COLOR)"; exit 1)
Expand Down
2 changes: 2 additions & 0 deletions api/openapi.generated.yml
Original file line number Diff line number Diff line change
Expand Up @@ -932,6 +932,8 @@ components:
properties:
query:
type: string
minLength: 1
maxLength: 100
description: Query string which searches against several text fields
example: research
filters:
Expand Down
5 changes: 3 additions & 2 deletions api/src/api/opportunities_v0_1/opportunity_schemas.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from src.api.schemas.extension import Schema, fields
from src.api.schemas.extension import Schema, fields, validators
from src.api.schemas.search_schema import StrSearchSchemaBuilder
from src.constants.lookup_constants import (
ApplicantType,
Expand Down Expand Up @@ -276,7 +276,8 @@ class OpportunitySearchRequestSchema(Schema):
metadata={
"description": "Query string which searches against several text fields",
"example": "research",
}
},
validate=[validators.Length(min=1, max=100)],
)

filters = fields.Nested(OpportunitySearchFilterSchema())
Expand Down
40 changes: 39 additions & 1 deletion api/src/services/opportunities_v0_1/search_opportunities.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
LinkOpportunitySummaryFundingCategory,
LinkOpportunitySummaryFundingInstrument,
Opportunity,
OpportunityAssistanceListing,
OpportunitySummary,
)
from src.pagination.pagination_models import PaginationInfo, PaginationParams
Expand Down Expand Up @@ -51,7 +52,44 @@ def _add_query_filters(stmt: Select[tuple[Any]], query: str | None) -> Select[tu
if query is None or len(query) == 0:
return stmt

# TODO - will implement this in https://github.com/HHS/simpler-grants-gov/issues/1455
ilike_query = f"%{query}%"

# Add a left join to the assistance listing table to filter by any of its values
stmt = stmt.outerjoin(
OpportunityAssistanceListing,
Opportunity.opportunity_id == OpportunityAssistanceListing.opportunity_id,
)

"""
This adds the following to the inner query (assuming the query value is "example")
WHERE
(opportunity.opportunity_title ILIKE '%example%'
OR opportunity.opportunity_number ILIKE '%example%'
OR opportunity.agency ILIKE '%example%'
OR opportunity_summary.summary_description ILIKE '%example%'
OR opportunity_assistance_listing.assistance_listing_number = 'example'
OR opportunity_assistance_listing.program_title ILIKE '%example%'))
Note that SQLAlchemy escapes everything and queries are actually written like:
opportunity.opportunity_number ILIKE % (opportunity_number_1)
"""
stmt = stmt.where(
or_(
# Title partial match
Opportunity.opportunity_title.ilike(ilike_query),
# Number partial match
Opportunity.opportunity_number.ilike(ilike_query),
# Agency (code) partial match
Opportunity.agency.ilike(ilike_query),
# Summary description partial match
OpportunitySummary.summary_description.ilike(ilike_query),
# assistance listing number matches exactly or program title partial match
OpportunityAssistanceListing.assistance_listing_number == query,
OpportunityAssistanceListing.program_title.ilike(ilike_query),
)
)

return stmt

Expand Down
49 changes: 30 additions & 19 deletions api/tests/lib/seed_local_db.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import logging

import click
from sqlalchemy import func

import src.adapters.db as db
Expand All @@ -13,7 +14,7 @@
logger = logging.getLogger(__name__)


def _build_opportunities(db_session: db.Session) -> None:
def _build_opportunities(db_session: db.Session, iterations: int) -> None:
# Just create a variety of opportunities for local testing
# we can eventually look into creating more specific scenarios

Expand All @@ -26,23 +27,27 @@ def _build_opportunities(db_session: db.Session) -> None:
logger.info(f"Creating opportunities starting with opportunity_id {max_opportunity_id + 1}")
factories.OpportunityFactory.reset_sequence(value=max_opportunity_id + 1)

# Create a few opportunities in various scenarios
factories.OpportunityFactory.create_batch(size=5, is_forecasted_summary=True)
factories.OpportunityFactory.create_batch(size=5, is_posted_summary=True)
factories.OpportunityFactory.create_batch(size=5, is_closed_summary=True)
factories.OpportunityFactory.create_batch(size=5, is_archived_non_forecast_summary=True)
factories.OpportunityFactory.create_batch(size=5, is_archived_forecast_summary=True)
factories.OpportunityFactory.create_batch(size=5, no_current_summary=True)

# generate a few opportunities with mostly null values
all_null_opportunities = factories.OpportunityFactory.create_batch(size=5, all_fields_null=True)
for all_null_opportunity in all_null_opportunities:
summary = factories.OpportunitySummaryFactory.create(
all_fields_null=True, opportunity=all_null_opportunity
)
factories.CurrentOpportunitySummaryFactory.create(
opportunity=all_null_opportunity, opportunity_summary=summary
for i in range(iterations):
logger.info(f"Creating opportunity batch number {i}")
# Create a few opportunities in various scenarios
factories.OpportunityFactory.create_batch(size=5, is_forecasted_summary=True)
factories.OpportunityFactory.create_batch(size=5, is_posted_summary=True)
factories.OpportunityFactory.create_batch(size=5, is_closed_summary=True)
factories.OpportunityFactory.create_batch(size=5, is_archived_non_forecast_summary=True)
factories.OpportunityFactory.create_batch(size=5, is_archived_forecast_summary=True)
factories.OpportunityFactory.create_batch(size=5, no_current_summary=True)

# generate a few opportunities with mostly null values
all_null_opportunities = factories.OpportunityFactory.create_batch(
size=5, all_fields_null=True
)
for all_null_opportunity in all_null_opportunities:
summary = factories.OpportunitySummaryFactory.create(
all_fields_null=True, opportunity=all_null_opportunity
)
factories.CurrentOpportunitySummaryFactory.create(
opportunity=all_null_opportunity, opportunity_summary=summary
)

logger.info("Finished creating opportunities")

Expand All @@ -57,7 +62,13 @@ def _build_opportunities(db_session: db.Session) -> None:
logger.info("Finished creating records in the transfer_topportunity table")


def seed_local_db() -> None:
@click.command()
@click.option(
"--iterations",
default=1,
help="Number of sets of opportunities to create, note that several are created per iteration",
)
def seed_local_db(iterations: int) -> None:
with src.logging.init("seed_local_db"):
logger.info("Running seed script for local DB")
error_if_not_local()
Expand All @@ -67,7 +78,7 @@ def seed_local_db() -> None:
with db_client.get_session() as db_session:
factories._db_session = db_session

_build_opportunities(db_session)
_build_opportunities(db_session, iterations)
# Need to commit to force any updates made
# after factories created objects
db_session.commit()
Loading

0 comments on commit 2b205af

Please sign in to comment.