diff --git a/src/test/test_flybase_combo_pubs.py b/src/test/test_flybase_combo_pubs.py index a85f842..f3c1c6d 100644 --- a/src/test/test_flybase_combo_pubs.py +++ b/src/test/test_flybase_combo_pubs.py @@ -2,6 +2,17 @@ import pytest from vfbquery.flybase_combo_pubs import resolve_combination, find_combo_publications +from vfbquery.vfb_queries import get_flybase_combo_pubs + + +def assert_single_selection_id(result): + """A result table must declare exactly one `selection_id` identity column, + hidden at order -1, so the website doesn't consume a visible data column as + the row identity (regression: FBrf column went missing on the site).""" + headers = result["headers"] + sel = [c for c, m in headers.items() if m.get("type") == "selection_id"] + assert len(sel) == 1, f"expected exactly one selection_id column, got {sel}" + assert headers[sel[0]]["order"] == -1, "selection_id column must be hidden (order -1)" KNOWN_COMBO_ID = "FBco0000052" KNOWN_COMBO_SYNONYM = "MB002B" @@ -89,3 +100,20 @@ def test_nonexistent_combo(self): def test_invalid_id_prefix(self): with pytest.raises(ValueError, match="Expected FBco"): find_combo_publications("FBgn0000490") + + +class TestComboPubsTableSchema: + @pytest.mark.integration + def test_single_selection_id_column(self): + result = get_flybase_combo_pubs(KNOWN_COMBO_ID, return_dataframe=False, limit=3) + assert_single_selection_id(result) + + @pytest.mark.integration + def test_fbrf_is_a_visible_column(self): + # fbrf must be a normal displayed column, not the (hidden) identity. + result = get_flybase_combo_pubs(KNOWN_COMBO_ID, return_dataframe=False, limit=3) + fbrf = result["headers"]["fbrf"] + assert fbrf["type"] == "text" + assert fbrf["order"] >= 0 + if result["rows"]: + assert result["rows"][0]["id"] == result["rows"][0]["fbrf"] diff --git a/src/test/test_flybase_stocks.py b/src/test/test_flybase_stocks.py index a3a79ad..0bf0be9 100644 --- a/src/test/test_flybase_stocks.py +++ b/src/test/test_flybase_stocks.py @@ -2,6 +2,18 @@ import pytest from vfbquery.flybase_stocks import resolve_entity, find_stocks +from vfbquery.vfb_queries import get_flybase_stocks + + +def assert_single_selection_id(result): + """A result table must declare exactly one `selection_id` identity column, + hidden at order -1. Without it the website consumes the first visible data + column as the row identity and drops it from the table (regression: the + Stock ID / FBrf columns went missing on the site).""" + headers = result["headers"] + sel = [c for c, m in headers.items() if m.get("type") == "selection_id"] + assert len(sel) == 1, f"expected exactly one selection_id column, got {sel}" + assert headers[sel[0]]["order"] == -1, "selection_id column must be hidden (order -1)" # Known stable test entities KNOWN_GENE_SYMBOL = "dpp" @@ -154,6 +166,47 @@ def test_stock_includes_collection(self): assert any("Bloomington" in str(s.get("collection", "")) for s in stocks) +class TestFindStocksConstruct: + # FBtp0000352 = P{GawB} — a widely-used construct carried by many insertions. + KNOWN_CONSTRUCT_ID = "FBtp0000352" + + @pytest.mark.integration + def test_known_construct_returns_stocks(self): + # A construct is not held in stocks directly; stocks are propagated from + # the FBti insertions producedby it. + stocks = find_stocks(self.KNOWN_CONSTRUCT_ID) + assert len(stocks) > 0 + assert all("stock_id" in s for s in stocks) + + @pytest.mark.integration + def test_construct_stocks_have_fbst(self): + stocks = find_stocks(self.KNOWN_CONSTRUCT_ID) + assert any(s["stock_id"].startswith("FBst") for s in stocks) + + @pytest.mark.integration + def test_construct_collection_filter(self): + all_stocks = find_stocks(self.KNOWN_CONSTRUCT_ID) + filtered = find_stocks(self.KNOWN_CONSTRUCT_ID, collection_filter="Bloomington") + assert 0 < len(filtered) <= len(all_stocks) + for s in filtered: + if s.get("collection"): + assert "Bloomington" in s["collection"] + + @pytest.mark.integration + def test_construct_stocks_via_allele_path(self): + # FBtp0000162 = P{CaSpeR-3}: has stocks ONLY through alleles made from it + # (zero via the producedby-insertion route). Regression guard for the + # multi-path UNION — a single insertion-only query returns 0 here. + stocks = find_stocks("FBtp0000162") + assert len(stocks) > 0 + assert all(s["stock_id"].startswith("FBst") for s in stocks) + + @pytest.mark.integration + def test_nonexistent_construct(self): + stocks = find_stocks("FBtp9999999999") + assert stocks == [] + + class TestFindStocksCombination: @pytest.mark.integration def test_known_combination(self): @@ -172,6 +225,25 @@ def test_nonexistent_combination(self): assert stocks == [] +class TestFindStocksTableSchema: + CONSTRUCT_WITH_STOCKS = "FBtp0000352" # P{GawB} + + @pytest.mark.integration + def test_single_selection_id_column(self): + result = get_flybase_stocks(self.CONSTRUCT_WITH_STOCKS, return_dataframe=False, limit=3) + assert_single_selection_id(result) + + @pytest.mark.integration + def test_stock_id_is_a_visible_column(self): + # stock_id must be a normal displayed column, not the (hidden) identity. + result = get_flybase_stocks(self.CONSTRUCT_WITH_STOCKS, return_dataframe=False, limit=3) + stock_id = result["headers"]["stock_id"] + assert stock_id["type"] == "text" + assert stock_id["order"] >= 0 + # and the hidden identity carries the same FBst value + assert result["rows"][0]["id"] == result["rows"][0]["stock_id"] + + class TestFindStocksEdgeCases: @pytest.mark.integration def test_nonexistent_gene_id(self): diff --git a/src/vfbquery/flybase_stocks.py b/src/vfbquery/flybase_stocks.py index 3774cdf..b266f97 100644 --- a/src/vfbquery/flybase_stocks.py +++ b/src/vfbquery/flybase_stocks.py @@ -1,4 +1,4 @@ -"""Find FlyBase stocks for genes, alleles, insertions, or split system combinations.""" +"""Find FlyBase stocks for genes, alleles, insertions, transgenic constructs, or split system combinations.""" import re import pandas as pd @@ -30,7 +30,7 @@ def resolve_entity(name_or_id): 2. Synonym match via feature_synonym (case-insensitive ILIKE) 3. Broad match via feature.name ILIKE '%query%' - :param name_or_id: Gene name, allele symbol, or FlyBase ID (FBgn/FBal/FBti/FBst/FBco) + :param name_or_id: Gene name, allele symbol, or FlyBase ID (FBgn/FBal/FBti/FBtp/FBst/FBco) :return: dict with 'match_type' and 'results' list """ conn = get_connection(statement_timeout_ms=30000) @@ -308,6 +308,63 @@ def _resolve_entity_impl(conn, name_or_id): AND s.is_obsolete = false """ +_CONSTRUCT_STOCKS_SQL = """ +WITH all_stocks AS ( + -- A construct (FBtp) is not itself held in stocks; it reaches them three ways. + -- Path 1 alone misses ~1,400 construct/stock pairs and leaves 127 constructs + -- (e.g. FBtp0000162 P{CaSpeR-3}) with zero stocks despite carrying some. + + -- Path 1: construct <- producedby - FBti insertion -> genotype -> stock + SELECT DISTINCT s.uniquename AS stock_id, s.name AS stock_number, + g.uniquename AS genotype, sc.uniquename AS collection + FROM feature tp + JOIN feature_relationship fr2 ON tp.feature_id = fr2.object_id + JOIN cvterm c2 ON fr2.type_id = c2.cvterm_id AND c2.name = 'producedby' + JOIN feature ti ON fr2.subject_id = ti.feature_id AND ti.is_obsolete = false + JOIN feature_genotype fg ON ti.feature_id = fg.feature_id + JOIN genotype g ON fg.genotype_id = g.genotype_id + JOIN stock_genotype sg ON g.genotype_id = sg.genotype_id + JOIN stock s ON sg.stock_id = s.stock_id AND s.is_obsolete = false + LEFT JOIN stockcollection_stock scs ON s.stock_id = scs.stock_id + LEFT JOIN stockcollection sc ON scs.stockcollection_id = sc.stockcollection_id + WHERE tp.uniquename = %(feature_id)s AND tp.is_obsolete = false + + UNION + + -- Path 2: construct -> genotype -> stock (construct sits directly in the genotype) + SELECT DISTINCT s.uniquename, s.name, g.uniquename, sc.uniquename + FROM feature tp + JOIN feature_genotype fg ON tp.feature_id = fg.feature_id + JOIN genotype g ON fg.genotype_id = g.genotype_id + JOIN stock_genotype sg ON g.genotype_id = sg.genotype_id + JOIN stock s ON sg.stock_id = s.stock_id AND s.is_obsolete = false + LEFT JOIN stockcollection_stock scs ON s.stock_id = scs.stock_id + LEFT JOIN stockcollection sc ON scs.stockcollection_id = sc.stockcollection_id + WHERE tp.uniquename = %(feature_id)s AND tp.is_obsolete = false + + UNION + + -- Path 3: construct <- (associated_with|derived_tp_assoc_alleles) - allele + -- -> genotype -> stock (allele made from this construct, held directly) + SELECT DISTINCT s.uniquename, s.name, g.uniquename, sc.uniquename + FROM feature tp + JOIN feature_relationship fr1 ON tp.feature_id = fr1.object_id + JOIN cvterm cr ON fr1.type_id = cr.cvterm_id + AND cr.name IN ('associated_with', 'derived_tp_assoc_alleles') + JOIN feature al ON fr1.subject_id = al.feature_id AND al.is_obsolete = false + JOIN cvterm cal ON al.type_id = cal.cvterm_id AND cal.name = 'allele' + JOIN feature_genotype fg ON al.feature_id = fg.feature_id + JOIN genotype g ON fg.genotype_id = g.genotype_id + JOIN stock_genotype sg ON g.genotype_id = sg.genotype_id + JOIN stock s ON sg.stock_id = s.stock_id AND s.is_obsolete = false + LEFT JOIN stockcollection_stock scs ON s.stock_id = scs.stock_id + LEFT JOIN stockcollection sc ON scs.stockcollection_id = sc.stockcollection_id + WHERE tp.uniquename = %(feature_id)s AND tp.is_obsolete = false +) +SELECT stock_id, stock_number, genotype, collection +FROM all_stocks +""" + _STOCK_DETAILS_SQL = """ SELECT s.uniquename AS stock_id, @@ -374,6 +431,21 @@ def _find_stocks_insertion(conn, feature_id, collection_filter=None): return _run_query(conn, sql, params) +def _find_stocks_construct(conn, construct_id, collection_filter=None): + """Find stocks for a transgenic construct (FBtp) via three UNION paths. + + A construct is not itself held in stocks; it reaches them through + (1) the FBti insertions producedby it, (2) direct genotype membership, and + (3) alleles made from it (associated_with / derived_tp_assoc_alleles) that + are held directly. Path 1 alone leaves 127 constructs with zero stocks. + """ + sql = _CONSTRUCT_STOCKS_SQL + params = {"feature_id": construct_id} + sql = _add_collection_filter(sql, params, collection_filter, use_where=True) + sql += "ORDER BY collection, stock_number;" + return _run_query(conn, sql, params) + + def _find_stocks_combination(conn, combo_id, collection_filter=None): """Find stocks for a split system combination via its component alleles.""" components = _run_query(conn, _COMBO_COMPONENTS_SQL, {"combo_id": combo_id}) @@ -411,14 +483,16 @@ def find_stocks(feature_id, collection_filter=None): - FBgn: gene (4-path UNION, 120s timeout) - FBal: allele (3-path UNION, 60s timeout) - FBti: insertion (direct, 60s timeout) + - FBtp: transgenic construct (via its insertions, 120s timeout) - FBco: combination (component alleles, 60s timeout) - FBst: stock details (direct lookup, 60s timeout) - :param feature_id: FlyBase ID (FBgn/FBal/FBti/FBco/FBst) + :param feature_id: FlyBase ID (FBgn/FBal/FBti/FBtp/FBco/FBst) :param collection_filter: Optional stock collection name filter (e.g. "Bloomington") :return: list of stock dicts """ - timeout = 120000 if feature_id.startswith("FBgn") else 60000 + # FBgn (gene) and FBtp (construct) fan out to many insertions, so allow longer. + timeout = 120000 if feature_id.startswith(("FBgn", "FBtp")) else 60000 conn = get_connection(statement_timeout_ms=timeout) try: @@ -428,6 +502,8 @@ def find_stocks(feature_id, collection_filter=None): df = _find_stocks_allele(conn, feature_id, collection_filter) elif feature_id.startswith("FBti"): df = _find_stocks_insertion(conn, feature_id, collection_filter) + elif feature_id.startswith("FBtp"): + df = _find_stocks_construct(conn, feature_id, collection_filter) elif feature_id.startswith("FBco"): df = _find_stocks_combination(conn, feature_id, collection_filter) elif feature_id.startswith("FBst"): @@ -435,7 +511,7 @@ def find_stocks(feature_id, collection_filter=None): else: raise ValueError( f"Unrecognised ID prefix: {feature_id}. " - "Expected FBgn, FBal, FBti, FBst, or FBco." + "Expected FBgn, FBal, FBti, FBtp, FBst, or FBco." ) if df.empty: diff --git a/src/vfbquery/vfb_queries.py b/src/vfbquery/vfb_queries.py index db00572..b10fdd9 100644 --- a/src/vfbquery/vfb_queries.py +++ b/src/vfbquery/vfb_queries.py @@ -1383,7 +1383,11 @@ def term_info_parse_object(results, short_form): }, }) - # FlyBase stock finder — for Feature terms (FBgn/FBal/FBti/FBtp/FBco/FBst) + # FlyBase stock finder. Prefix-gated (not by a single SuperType): genes + # and combinations aren't `Feature` nodes but still have stocks. Every + # prefix here has a matching router branch in flybase_stocks.find_stocks + # — FBtp (construct) resolves via its insertions, direct genotype + # membership, and the alleles made from it. sf = vfbTerm.term.core.short_form if sf.startswith(("FBgn", "FBal", "FBti", "FBtp", "FBco", "FBst")): q = FindStocks_to_schema(termInfo["Name"], {"short_form": sf}) @@ -4770,6 +4774,11 @@ def get_flybase_stocks(short_form: str, return_dataframe=True, limit: int = -1): rows = [] for s in stocks: rows.append({ + # Hidden identity column (the FBst the row is about). Without a + # `selection_id`-typed column the website consumes the first data + # column as the row identity and hides it — which dropped Stock ID + # from the table. See the `id` header below. + 'id': s.get('stock_id', ''), 'stock_id': s.get('stock_id', ''), 'stock_number': s.get('stock_number', ''), 'genotype': s.get('genotype', ''), @@ -4784,6 +4793,7 @@ def get_flybase_stocks(short_form: str, return_dataframe=True, limit: int = -1): return pd.DataFrame(rows) headers = { + 'id': {'title': 'ID', 'type': 'selection_id', 'order': -1}, 'stock_id': {'title': 'Stock ID', 'type': 'text', 'order': 0}, 'stock_number': {'title': 'Stock Number', 'type': 'text', 'order': 1}, 'genotype': {'title': 'Genotype', 'type': 'text', 'order': 2}, @@ -4813,6 +4823,10 @@ def get_flybase_combo_pubs(short_form: str, return_dataframe=True, limit: int = rows = [] for p in pubs: rows.append({ + # Hidden identity column (the FBrf the row is about). Mirrors the + # stocks fix: without a `selection_id` column the website consumes + # the first data column (FBrf) as the row identity and hides it. + 'id': p.get('fbrf', ''), 'fbrf': p.get('fbrf', ''), 'title': p.get('title', ''), 'year': p.get('year', ''), @@ -4831,6 +4845,7 @@ def get_flybase_combo_pubs(short_form: str, return_dataframe=True, limit: int = return pd.DataFrame(rows) headers = { + 'id': {'title': 'ID', 'type': 'selection_id', 'order': -1}, 'fbrf': {'title': 'FBrf', 'type': 'text', 'order': 0}, 'title': {'title': 'Title', 'type': 'text', 'order': 1}, 'year': {'title': 'Year', 'type': 'text', 'order': 2},