Skip to content

Commit 9aee55b

Browse files
committed
Revert unrelated init.sh/SNP scope creep; simplify regex loading and umap_expression
- config/init.sh: drop the db_exists/import_if_missing wrapper that had been applied to every database (including ones this PR never touches, like fastpheno and gaia). Every SQL dump already starts with DROP TABLE IF EXISTS/CREATE TABLE, so the wrapper wasn't needed for correctness -- it introduced a bug (schema-exists check skipped real-data imports once the dynamic bootstrap created an empty table first) and needed a second fix to reorder around it. Restored plain unconditional imports for every database, keeping only DB_HOST (needed for docker-compose, where the api container talks to a separate BAR_mysqldb container). - config/databases/soybean_nssnp.sql, tomato_nssnp.sql: restore the FOREIGN KEY constraint dropped by the same commit that bloated init.sh; unrelated to the expression API. - api/utils/bar_utils.py: collapse the eFP-project alias assignments into a small alias dict + loop instead of six hardcoded lines. - Remove the dead api/models/bar_utils.py bridge file that only re-exported BARUtils from api/utils/bar_utils.py; point api/services/efp_data.py at the real module directly. - Move scripts/bootstrap_simple_efp_dbs.py's CLI into api/services/efp_bootstrap.py (run via `python3 -m api.services.efp_bootstrap`) and delete the standalone script; config/init.sh updated to match. - api/resources/umap_expression.py: stop leaking raw exception text to API clients (no other endpoint does this -- they all return a fixed BARUtils.error_exit message), and extract the query/retry/merge logic out of one monolithic method into module-level helpers (_fetch_expression_row, _fetch_coords, _merge_coords_and_expression), matching the rest of the codebase's structure.
1 parent e7c5f96 commit 9aee55b

9 files changed

Lines changed: 206 additions & 246 deletions

File tree

api/models/bar_utils.py

Lines changed: 0 additions & 4 deletions
This file was deleted.

api/resources/umap_expression.py

Lines changed: 57 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,53 @@
3131
"arabidopsis_stem_lee_umap": "arabidopsis",
3232
}
3333

34+
_EXPRESSION_SQL = text("SELECT expression FROM umap_expression WHERE gene_id = :gene_id")
35+
_COORDS_SQL = text("SELECT cell_id, umap_1, umap_2, cell_type FROM umap_coords ORDER BY cell_id")
36+
37+
38+
def _fetch_expression_row(engine, gene_id):
39+
"""Look up the expression JSON blob for gene_id, retrying uppercase
40+
(some datasets store IDs in uppercase).
41+
42+
:returns: (row, error_response) -- exactly one of the two is None.
43+
"""
44+
try:
45+
with Session(engine) as session:
46+
row = session.execute(_EXPRESSION_SQL, {"gene_id": gene_id}).first()
47+
if row is None:
48+
row = session.execute(_EXPRESSION_SQL, {"gene_id": gene_id.upper()}).first()
49+
except SQLAlchemyError:
50+
return None, (BARUtils.error_exit("Database query failed"), 500)
51+
52+
if row is None:
53+
return None, (BARUtils.error_exit("No data found for the given gene"), 404)
54+
return row, None
55+
56+
57+
def _fetch_coords(engine):
58+
"""Return every UMAP coordinate row (same for every gene), or an error response.
59+
60+
:returns: (rows, error_response) -- exactly one of the two is None.
61+
"""
62+
try:
63+
with Session(engine) as session:
64+
return session.execute(_COORDS_SQL).all(), None
65+
except SQLAlchemyError:
66+
return None, (BARUtils.error_exit("Database query failed"), 500)
67+
68+
69+
def _merge_coords_and_expression(coords, expr_map):
70+
"""Zip UMAP coordinates with each cell's expression value, keyed by cell_id."""
71+
return [
72+
{
73+
"umap_1": float(c.umap_1),
74+
"umap_2": float(c.umap_2),
75+
"expression": float(expr_map.get(str(c.cell_id), 0.0)),
76+
"cell_type": str(c.cell_type),
77+
}
78+
for c in coords
79+
]
80+
3481

3582
@umap_expression.route("/<string:database>/<string:gene_id>")
3683
@umap_expression.doc(description="Retrieve per-cell UMAP coordinates and expression values for a gene.")
@@ -52,76 +99,35 @@ def get(self, database, gene_id):
5299
database = str(escape(database))
53100
gene_id = str(escape(gene_id))
54101

55-
# 1. Resolve database species
56102
species = UMAP_DATABASE_SPECIES.get(database)
57103
if species is None:
58104
return BARUtils.error_exit(
59105
f"Unknown UMAP database '{database}'. "
60106
f"Available: {', '.join(sorted(UMAP_DATABASE_SPECIES.keys()))}"
61107
), 400
62108

63-
# 2. Validate gene ID format against the expected input species regex
64109
if BARUtils.is_injection_attempt(gene_id):
65110
return BARUtils.error_exit(f"Invalid {species} gene ID: '{gene_id}'"), 400
66111
if not GeneIdUtils.validate_gene_id(gene_id, species):
67112
return BARUtils.error_exit(f"Invalid {species} gene ID: '{gene_id}'"), 400
68-
69-
# 3. Normalise (e.g. strip maize transcript suffix _T##)
70113
gene_id = GeneIdUtils.normalize_gene_id(gene_id, species)
71114

72-
# 4. Get SQLAlchemy bind engine for this database
73115
engine = db.engines.get(database)
74116
if engine is None:
75117
return BARUtils.error_exit("Database not available"), 503
76118

77-
# 5. Query expression JSON array for this gene (single PK lookup)
78-
expr_sql = text(
79-
"SELECT expression FROM umap_expression WHERE gene_id = :gene_id"
80-
)
81-
82-
try:
83-
with Session(engine) as session:
84-
row = session.execute(expr_sql, {"gene_id": gene_id}).first()
85-
except SQLAlchemyError as exc:
86-
return BARUtils.error_exit(f"Database query failed: {str(exc)}"), 500
87-
88-
# Retry with uppercase (some datasets store IDs in uppercase)
89-
if row is None:
90-
try:
91-
with Session(engine) as session:
92-
row = session.execute(expr_sql, {"gene_id": gene_id.upper()}).first()
93-
except SQLAlchemyError as exc:
94-
return BARUtils.error_exit(f"Database query failed: {str(exc)}"), 500
95-
96-
if row is None:
97-
return BARUtils.error_exit("No data found for the given gene"), 404
98-
99-
# Parse expression JSON array: {cell_id: value, ...}
100-
expr_raw = row.expression
119+
expr_row, error = _fetch_expression_row(engine, gene_id)
120+
if error:
121+
return error
122+
123+
expr_raw = expr_row.expression
101124
expr_map = json.loads(expr_raw) if isinstance(expr_raw, str) else expr_raw
102125

103-
# 6. Query all coords ordered by cell_id (same for every gene)
104-
coords_sql = text(
105-
"SELECT cell_id, umap_1, umap_2, cell_type "
106-
"FROM umap_coords ORDER BY cell_id"
107-
)
108-
109-
try:
110-
with Session(engine) as session:
111-
coords = session.execute(coords_sql).all()
112-
except SQLAlchemyError as exc:
113-
return BARUtils.error_exit(f"Database query failed: {str(exc)}"), 500
114-
115-
# 7. Merge coords + expression by cell_id
116-
data = [
117-
{
118-
"umap_1": float(c.umap_1),
119-
"umap_2": float(c.umap_2),
120-
"expression": float(expr_map.get(str(c.cell_id), 0.0)),
121-
"cell_type": str(c.cell_type),
122-
}
123-
for c in coords
124-
]
126+
coords, error = _fetch_coords(engine)
127+
if error:
128+
return error
129+
130+
data = _merge_coords_and_expression(coords, expr_map)
125131

126132
return BARUtils.success_exit({
127133
"gene_id": gene_id,

api/services/efp_bootstrap.py

Lines changed: 75 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,26 @@
22
Bootstrap utilities for creating eFP MySQL databases from the shared schema registry.
33
44
Used by:
5-
- scripts/bootstrap_simple_efp_dbs.py (CLI)
6-
- config/init.sh (Docker / CI)
5+
- config/init.sh (Docker / CI), via `python3 -m api.services.efp_bootstrap`
6+
7+
Usage:
8+
python3 -m api.services.efp_bootstrap
9+
python3 -m api.services.efp_bootstrap --databases embryo klepikova
10+
python3 -m api.services.efp_bootstrap --host localhost --port 3306
711
"""
812

913
from __future__ import annotations
1014

15+
import argparse
16+
import os
1117
import re
1218
import hashlib
1319
from typing import Dict, Iterable, List
1420

1521
from sqlalchemy import Column, Index, MetaData, Table, create_engine, text
1622
from sqlalchemy.dialects.mysql import FLOAT, INTEGER, TEXT, VARCHAR
1723
from sqlalchemy.engine import URL
24+
from sqlalchemy.exc import SQLAlchemyError
1825

1926
from api.models.efp_schemas import SIMPLE_EFP_DATABASE_SCHEMAS
2027

@@ -221,10 +228,8 @@ def bootstrap_simple_efp_databases(
221228
2. Creates the sample_data table with schema from SIMPLE_EFP_DATABASE_SCHEMAS
222229
3. Inserts seed rows if the table is empty and seed_rows are defined
223230
224-
Used by:
225-
- scripts/bootstrap_simple_efp_dbs.py (CLI tool)
226-
- config/init.sh (Docker/CI initialization)
227-
- api/resources/efp_proxy.py (HTTP bootstrap endpoint)
231+
Used by config/init.sh (Docker/CI initialization) via the CLI entry point
232+
at the bottom of this module (`python3 -m api.services.efp_bootstrap`).
228233
229234
:param host: MySQL server hostname (e.g., 'localhost', 'BAR_mysqldb' for Docker)
230235
:type host: str
@@ -280,3 +285,67 @@ def bootstrap_simple_efp_databases(
280285

281286

282287
__all__ = ["bootstrap_simple_efp_databases"]
288+
289+
290+
def _default_host() -> str:
291+
"""Resolve the default MySQL hostname from environment variables.
292+
293+
Checks DB_HOST, then MYSQL_HOST, then falls back to 'localhost'.
294+
Docker deployments should set DB_HOST=BAR_mysqldb explicitly.
295+
296+
:returns: MySQL hostname string.
297+
:rtype: str
298+
"""
299+
if os.environ.get("DB_HOST"):
300+
return os.environ["DB_HOST"]
301+
if os.environ.get("MYSQL_HOST"):
302+
return os.environ["MYSQL_HOST"]
303+
return "localhost"
304+
305+
306+
def _parse_args() -> argparse.Namespace:
307+
"""Parse command-line arguments for the bootstrap CLI.
308+
309+
:returns: Parsed arguments with host, port, user, password, and optional database list.
310+
:rtype: argparse.Namespace
311+
"""
312+
parser = argparse.ArgumentParser(description="Create simple eFP MySQL databases from in-memory schemas.")
313+
parser.add_argument("--host", default=_default_host(), help="MySQL hostname (default: %(default)s)")
314+
parser.add_argument("--port", type=int, default=int(os.environ.get("DB_PORT", 3306)), help="MySQL port")
315+
parser.add_argument("--user", default=os.environ.get("DB_USER", "root"), help="MySQL user")
316+
parser.add_argument("--password", default=os.environ.get("DB_PASS", "root"), help="MySQL password")
317+
parser.add_argument(
318+
"--databases",
319+
nargs="*",
320+
help="Optional list of databases to bootstrap (defaults to every simple schema).",
321+
)
322+
return parser.parse_args()
323+
324+
325+
def _main():
326+
"""Run the bootstrap CLI — creates all eFP databases and prints a result per entry.
327+
328+
Output format: [ok] ensured database_name.table_name (seeded N rows)
329+
330+
:raises SQLAlchemyError: If database creation or connection fails.
331+
"""
332+
args = _parse_args()
333+
results = bootstrap_simple_efp_databases(
334+
host=args.host,
335+
port=args.port,
336+
user=args.user,
337+
password=args.password,
338+
databases=args.databases,
339+
)
340+
for entry in results:
341+
seeded = entry["seeded_rows"]
342+
seed_msg = f"seeded {seeded} rows" if seeded else "no seed rows inserted"
343+
print(f"[ok] ensured {entry['database']}.{entry['table']} ({seed_msg})")
344+
345+
346+
if __name__ == "__main__":
347+
try:
348+
_main()
349+
except SQLAlchemyError as exc:
350+
print(f"failed to initialize simple efp databases: {exc}")
351+
raise

api/services/efp_data.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,8 @@
2525

2626
from api import db
2727
from api.models.annotations_lookup import AtAgiLookup
28-
from api.models.bar_utils import BARUtils
2928
from api.models.efp_schemas import SIMPLE_EFP_DATABASE_SCHEMAS
29+
from api.utils.bar_utils import BARUtils
3030

3131
_RANDOM_ROWS_DIR = Path(__file__).resolve().parents[1] / "random_rows_json"
3232

api/utils/bar_utils.py

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,16 @@ def load_combined_master() -> dict:
2828

2929
# Aliases for alternate eFP project key spellings used by the BAR (not present
3030
# in Vincent's registry, which is keyed by canonical eFP project name only).
31-
EFP_PROJECT_REGEXES["efpbarley"] = EFP_PROJECT_REGEXES["efp_barley"]
32-
EFP_PROJECT_REGEXES["efprice"] = EFP_PROJECT_REGEXES["efp_rice"]
33-
EFP_PROJECT_REGEXES["efpmedicago"] = EFP_PROJECT_REGEXES["efp_medicago"]
34-
EFP_PROJECT_REGEXES["efppop"] = EFP_PROJECT_REGEXES["efp_poplar"]
35-
EFP_PROJECT_REGEXES["efpsoybean"] = EFP_PROJECT_REGEXES["efp_soybean"]
36-
EFP_PROJECT_REGEXES["maizeefp"] = EFP_PROJECT_REGEXES["efp_maize"]
31+
_PROJECT_ALIASES = {
32+
"efpbarley": "efp_barley",
33+
"efprice": "efp_rice",
34+
"efpmedicago": "efp_medicago",
35+
"efppop": "efp_poplar",
36+
"efpsoybean": "efp_soybean",
37+
"maizeefp": "efp_maize",
38+
}
39+
for _alias, _canonical in _PROJECT_ALIASES.items():
40+
EFP_PROJECT_REGEXES[_alias] = EFP_PROJECT_REGEXES[_canonical]
3741

3842
# General injection guard, run before any per-project/probeset format check.
3943
# A handful of eFP projects accept loose freeform text (metabolite/enzyme/trait

config/databases/soybean_nssnp.sql

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,8 @@ CREATE TABLE `sample_lookup` (
6060
`sample_id` varchar(45) NOT NULL,
6161
`dataset` varchar(45) DEFAULT NULL,
6262
`dataset_sample` varchar(45) DEFAULT NULL,
63-
PRIMARY KEY (`sample_id`)
63+
PRIMARY KEY (`sample_id`),
64+
CONSTRAINT `sample_id` FOREIGN KEY (`sample_id`) REFERENCES `snps_reference` (`sample_id`)
6465
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
6566
/*!40101 SET character_set_client = @saved_cs_client */;
6667

config/databases/tomato_nssnp.sql

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,8 @@ CREATE TABLE `lines_lookup` (
3434
`lines_id` varchar(45) NOT NULL,
3535
`species` varchar(35) DEFAULT NULL,
3636
`alias` varchar(35) DEFAULT NULL,
37-
PRIMARY KEY (`lines_id`)
37+
PRIMARY KEY (`lines_id`),
38+
CONSTRAINT `lines_id` FOREIGN KEY (`lines_id`) REFERENCES `snps_reference` (`sample_id`)
3839
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
3940
/*!40101 SET character_set_client = @saved_cs_client */;
4041

0 commit comments

Comments
 (0)