Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions src/test/test_flybase_combo_pubs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"]
72 changes: 72 additions & 0 deletions src/test/test_flybase_stocks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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):
Expand All @@ -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):
Expand Down
86 changes: 81 additions & 5 deletions src/vfbquery/flybase_stocks.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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})
Expand Down Expand Up @@ -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:
Expand All @@ -428,14 +502,16 @@ 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"):
df = _find_stock_details(conn, feature_id)
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:
Expand Down
17 changes: 16 additions & 1 deletion src/vfbquery/vfb_queries.py
Original file line number Diff line number Diff line change
Expand Up @@ -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})
Expand Down Expand Up @@ -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', ''),
Expand All @@ -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},
Expand Down Expand Up @@ -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', ''),
Expand All @@ -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},
Expand Down
Loading