Skip to content

Commit f045828

Browse files
skearnesclaude
andcommitted
Deduplicate stats queries and return proper HTTP errors
Backend consistency cleanup (Tier 2), no change to successful responses: - Collapse the near-identical `fetch_dataset_most_used_smiles_for_inputs` and `..._for_products` into one private helper, with the input/product table names passed as safely-composed `psycopg.sql.Identifier`s rather than duplicated query text. - Replace bare `ValueError`s in API handlers with `HTTPException`s so clients get meaningful status codes instead of 500s: - `/molfile` with an unparseable SMILES -> 400 - `/reaction_summary` for an unknown reaction_id -> 404 (and 500 only for the genuinely-impossible "multiple matches" case) - Add regression tests for both error paths. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 52ff89d commit f045828

5 files changed

Lines changed: 71 additions & 30 deletions

File tree

ord_interface/api/queries.py

Lines changed: 55 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@
5050
from ord_schema import message_helpers, validations
5151
from ord_schema.logging import get_logger
5252
from ord_schema.proto import reaction_pb2
53-
from psycopg import AsyncCursor
53+
from psycopg import AsyncCursor, sql
5454
from pydantic import BaseModel
5555
from rdkit import Chem
5656
from rdkit.Chem import rdChemReactions
@@ -435,47 +435,76 @@ class StatsResult(BaseModel):
435435
times_appearing: int
436436

437437

438-
async def fetch_dataset_most_used_smiles_for_inputs(
439-
cursor: DictCursor, dataset_id: str, limit: int = 30
438+
async def _fetch_dataset_most_used_smiles(
439+
cursor: DictCursor,
440+
dataset_id: str,
441+
*,
442+
compound_table: str,
443+
join_table: str,
444+
foreign_key: str,
445+
limit: int,
440446
) -> list[StatsResult]:
441-
"""Fetches the top K most used SMILES molecules in terms of reaction inputs for a given dataset."""
442-
query = """
443-
SELECT smiles, COUNT(*) as times_appearing
444-
FROM ord.compound
445-
JOIN ord.reaction_input ON ord.compound.reaction_input_id = ord.reaction_input.id
446-
JOIN ord.reaction ON ord.reaction_input.reaction_id = ord.reaction.id
447+
"""Fetches the top K most used SMILES for a dataset, joined through the given tables.
448+
449+
Args:
450+
cursor: Database cursor.
451+
dataset_id: Dataset to aggregate over.
452+
compound_table: Table holding SMILES, e.g. "compound" or "product_compound".
453+
join_table: Table linking compounds to reactions, e.g. "reaction_input".
454+
foreign_key: Column on ``compound_table`` referencing ``join_table``.
455+
limit: Maximum number of rows to return.
456+
457+
Returns:
458+
Most frequently appearing SMILES, in descending order of frequency.
459+
"""
460+
# Compose schema-qualified identifiers safely rather than interpolating raw
461+
# strings into the SQL text.
462+
compound = sql.Identifier("ord", compound_table)
463+
join = sql.Identifier("ord", join_table)
464+
query = sql.SQL(
465+
"""
466+
SELECT smiles, COUNT(*) AS times_appearing
467+
FROM {compound}
468+
JOIN {join} ON {compound}.{foreign_key} = {join}.id
469+
JOIN ord.reaction ON {join}.reaction_id = ord.reaction.id
447470
JOIN ord.dataset ON ord.reaction.dataset_id = ord.dataset.id
448471
WHERE ord.dataset.dataset_id = %s
449472
AND smiles IS NOT NULL
450473
GROUP BY smiles
451474
ORDER BY times_appearing DESC
452475
LIMIT %s
453476
"""
477+
).format(compound=compound, join=join, foreign_key=sql.Identifier(foreign_key))
454478
await cursor.execute(query, (dataset_id, limit))
455479
results = []
456480
async for row in cursor:
457481
results.append(StatsResult(**row))
458482
return results
459483

460484

485+
async def fetch_dataset_most_used_smiles_for_inputs(
486+
cursor: DictCursor, dataset_id: str, limit: int = 30
487+
) -> list[StatsResult]:
488+
"""Fetches the top K most used SMILES molecules in terms of reaction inputs for a given dataset."""
489+
return await _fetch_dataset_most_used_smiles(
490+
cursor,
491+
dataset_id,
492+
compound_table="compound",
493+
join_table="reaction_input",
494+
foreign_key="reaction_input_id",
495+
limit=limit,
496+
)
497+
498+
461499
async def fetch_dataset_most_used_smiles_for_products(
462500
cursor: DictCursor, dataset_id: str, limit: int = 30
463501
) -> list[StatsResult]:
464502
"""Fetches the top K most used SMILES molecules in terms of reaction products for a given dataset."""
465-
query = """
466-
SELECT smiles, COUNT(*) as times_appearing
467-
FROM ord.product_compound
468-
JOIN ord.reaction_outcome ON ord.product_compound.reaction_outcome_id = ord.reaction_outcome.id
469-
JOIN ord.reaction ON ord.reaction_outcome.reaction_id = ord.reaction.id
470-
JOIN ord.dataset ON ord.reaction.dataset_id = ord.dataset.id
471-
WHERE ord.dataset.dataset_id = %s
472-
AND smiles IS NOT NULL
473-
GROUP BY smiles
474-
ORDER BY times_appearing DESC
475-
LIMIT %s
476-
"""
477-
await cursor.execute(query, (dataset_id, limit))
478-
results = []
479-
async for row in cursor:
480-
results.append(StatsResult(**row))
481-
return results
503+
return await _fetch_dataset_most_used_smiles(
504+
cursor,
505+
dataset_id,
506+
compound_table="product_compound",
507+
join_table="reaction_outcome",
508+
foreign_key="reaction_outcome_id",
509+
limit=limit,
510+
)

ord_interface/api/search.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -224,7 +224,7 @@ async def get_molfile(smiles: str) -> str:
224224
"""Returns a molblock for the given SMILES."""
225225
mol = Chem.MolFromSmiles(smiles)
226226
if mol is None:
227-
raise ValueError(smiles)
227+
raise HTTPException(status_code=400, detail=f"invalid SMILES: {smiles}")
228228
return Chem.MolToMolBlock(mol)
229229

230230

ord_interface/api/search_test.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,11 @@ def test_get_molfile(test_client):
9898
assert Chem.MolToSmiles(Chem.MolFromMolBlock(response.json())) == "NC=O"
9999

100100

101+
def test_get_molfile_invalid_smiles(test_client):
102+
response = test_client.get("/api/molfile", params={"smiles": "not-a-smiles"})
103+
assert response.status_code == 400
104+
105+
101106
def test_get_search_results(test_client):
102107
response = test_client.post(
103108
"/api/download_search_results", json={"reaction_ids": ["ord-3f67aa5592fd434d97a577988d3fd241"]}

ord_interface/api/view.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414

1515
"""View API."""
1616

17-
from fastapi import APIRouter, Request
17+
from fastapi import APIRouter, HTTPException, Request
1818
from fastapi.responses import HTMLResponse
1919
from ord_schema.proto import reaction_pb2
2020

@@ -39,8 +39,10 @@ async def get_compound(request: Request) -> str:
3939
async def get_reaction_summary(reaction_id: str, compact: bool = True) -> str:
4040
"""Renders a reaction as an HTML table with images and text."""
4141
results = await get_reactions(ReactionIdList(reaction_ids=[reaction_id]))
42-
if len(results) == 0 or len(results) > 1:
43-
raise ValueError(reaction_id)
42+
if len(results) == 0:
43+
raise HTTPException(status_code=404, detail=f"reaction not found: {reaction_id}")
44+
if len(results) > 1:
45+
raise HTTPException(status_code=500, detail=f"multiple reactions found: {reaction_id}")
4446
try:
4547
return generate_text.generate_html(reaction=results[0].reaction, compact=compact)
4648
except (ValueError, KeyError):

ord_interface/api/view_test.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,3 +27,8 @@ def test_get_compound_svg(test_client):
2727
def test_get_reaction_summary(test_client):
2828
response = test_client.get("/api/reaction_summary", params={"reaction_id": "ord-3f67aa5592fd434d97a577988d3fd241"})
2929
response.raise_for_status()
30+
31+
32+
def test_get_reaction_summary_not_found(test_client):
33+
response = test_client.get("/api/reaction_summary", params={"reaction_id": "ord-does-not-exist"})
34+
assert response.status_code == 404

0 commit comments

Comments
 (0)