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
1 change: 1 addition & 0 deletions rust/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions rust/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ utoipa-swagger-ui = { version = "8", features = ["axum"] }
# Serialization / JSONB payloads
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# Query-string (de)serialization — used directly by crate::extract::Query to
# dedupe repeated keys before deserializing (axum already pulls it transitively).
serde_urlencoded = "0.7"

# Common types
uuid = { version = "1", features = ["v4", "serde"] }
Expand Down
102 changes: 61 additions & 41 deletions rust/crates/du-db/src/biosample.rs
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,10 @@ pub enum HaplogroupCallOrigin {
Reconciled,
/// `fed.biosample.y/mt_haplogroup` (a single Navigator call, not reconciled).
FedConsensus,
/// `tree.haplogroup_sample` — the node this sample sits under in the decoding-us
/// de-novo tree. The sample was used as a tree building block, so its tip position
/// is an authoritative assignment (preferred over the raw publication call).
TreePlacement,
/// `core.biosample.original_haplogroups` (per-publication original call).
Original,
}
Expand Down Expand Up @@ -427,53 +431,69 @@ pub async fn report_by_guid(pool: &PgPool, guid: SampleGuid) -> Result<Option<Sa
}
}

// ── Q2c: de-novo tree placement, resolved at the DONOR level. When any biosample
// of this individual was used as a tree building block, the loader recorded the
// terminal node in tree.haplogroup_sample (PLACED for a topology tip, CURATED for
// a curator pin). Because the placement lives on the tip biosample — a *sibling*
// of this catalog accession under the same specimen_donor — we resolve it across
// all of the donor's biosamples, preferring this sample's own placement, then a
// curator pin. This is what surfaces the assignment on every accession of the donor.
let mut placed_y: Option<String> = None;
let mut placed_mt: Option<String> = None;
{
#[derive(sqlx::FromRow)]
struct PlaceRow {
dna_type: Option<String>,
name: Option<String>,
}
let rows: Vec<PlaceRow> = sqlx::query_as(
"SELECT DISTINCT ON (hs.dna_type) hs.dna_type::text AS dna_type, h.name \
FROM tree.haplogroup_sample hs \
JOIN core.biosample b2 ON b2.sample_guid = hs.sample_guid \
JOIN tree.haplogroup h ON h.id = hs.haplogroup_id AND h.valid_until IS NULL \
WHERE hs.status IN ('PLACED', 'CURATED') \
AND (b2.sample_guid = $1 \
OR b2.donor_id = (SELECT donor_id FROM core.biosample WHERE sample_guid = $1 AND donor_id IS NOT NULL)) \
ORDER BY hs.dna_type, (b2.sample_guid = $1) DESC, (hs.status = 'CURATED') DESC, b2.sample_guid",
)
.bind(guid.0)
.fetch_all(pool)
.await?;
for r in rows {
match r.dna_type.as_deref() {
Some("Y_DNA") => placed_y = r.name,
Some("MT_DNA") => placed_mt = r.name,
_ => {}
}
}
}

// A plain (no-reliability) call from a name + origin — for the non-reconciled sources.
let plain = |name: String, dna_type: DnaType, origin: HaplogroupCallOrigin| HaplogroupCall {
name,
dna_type,
origin,
confidence: None,
run_count: None,
snp_concordance: None,
compatibility_level: None,
};

// Call precedence: cross-technology consensus, else the single federated call,
// else the newest original publication call.
// else the de-novo tree placement, else the newest original publication call.
let y = reconciled_call(recon_y, DnaType::YDna)
.or_else(|| fed_y.map(|n| plain(n, DnaType::YDna, HaplogroupCallOrigin::FedConsensus)))
.or_else(|| placed_y.map(|n| plain(n, DnaType::YDna, HaplogroupCallOrigin::TreePlacement)))
.or_else(|| {
fed_y.map(|name| HaplogroupCall {
name,
dna_type: DnaType::YDna,
origin: HaplogroupCallOrigin::FedConsensus,
confidence: None,
run_count: None,
snp_concordance: None,
compatibility_level: None,
})
})
.or_else(|| {
pick_original_call(&idr.original_haplogroups, "y", "y_result").map(|name| HaplogroupCall {
name,
dna_type: DnaType::YDna,
origin: HaplogroupCallOrigin::Original,
confidence: None,
run_count: None,
snp_concordance: None,
compatibility_level: None,
})
pick_original_call(&idr.original_haplogroups, "y", "y_result")
.map(|n| plain(n, DnaType::YDna, HaplogroupCallOrigin::Original))
});
let mt = reconciled_call(recon_mt, DnaType::MtDna)
.or_else(|| fed_mt.map(|n| plain(n, DnaType::MtDna, HaplogroupCallOrigin::FedConsensus)))
.or_else(|| placed_mt.map(|n| plain(n, DnaType::MtDna, HaplogroupCallOrigin::TreePlacement)))
.or_else(|| {
fed_mt.map(|name| HaplogroupCall {
name,
dna_type: DnaType::MtDna,
origin: HaplogroupCallOrigin::FedConsensus,
confidence: None,
run_count: None,
snp_concordance: None,
compatibility_level: None,
})
})
.or_else(|| {
pick_original_call(&idr.original_haplogroups, "mt", "mt_result").map(|name| HaplogroupCall {
name,
dna_type: DnaType::MtDna,
origin: HaplogroupCallOrigin::Original,
confidence: None,
run_count: None,
snp_concordance: None,
compatibility_level: None,
})
pick_original_call(&idr.original_haplogroups, "mt", "mt_result")
.map(|n| plain(n, DnaType::MtDna, HaplogroupCallOrigin::Original))
});

// ── Q3/Q4/Q5: federated sequencing, coverage, ancestry (only when linked) ──
Expand Down
15 changes: 10 additions & 5 deletions rust/crates/du-db/src/dedup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,9 @@ struct Repoint {
const REPOINTS: &[Repoint] = &[
Repoint { table: "fed.pds_submission", cols: &["biosample_guid"], action: Action::Simple },
Repoint { table: "genomics.biosample_callable_loci", cols: &["sample_guid"], action: Action::Simple },
// STR profile is 1-per-sample (mig 0053), keyed by sample_guid — keep the
// survivor's if it already has one, else adopt the merged sample's.
Repoint { table: "genomics.biosample_str_profile", cols: &["sample_guid"], action: Action::KeepSurvivor(&[]) },
Repoint { table: "genomics.genotype_data", cols: &["sample_guid"], action: Action::Simple },
Repoint { table: "genomics.reported_variant_pangenome", cols: &["sample_guid"], action: Action::Simple },
Repoint { table: "genomics.sequence_library", cols: &["sample_guid"], action: Action::Simple },
Expand Down Expand Up @@ -527,14 +530,16 @@ pub async fn merge_biosamples(
}
Action::KeepSurvivor(others) => {
let col = r.cols[0];
let on = others
// Extra AND-clauses that make a merged row a *duplicate* of a survivor
// row. Empty ⇒ the table is unique on `col` alone (1-per-sample), so the
// mere existence of a survivor row makes the merged one a duplicate.
let extra = others
.iter()
.map(|oc| format!("s.{oc} = m.{oc}"))
.collect::<Vec<_>>()
.join(" AND ");
.map(|oc| format!(" AND s.{oc} = m.{oc}"))
.collect::<String>();
let dropped = sqlx::query(&format!(
"DELETE FROM {t} m WHERE m.{col} = $2 \
AND EXISTS (SELECT 1 FROM {t} s WHERE s.{col} = $1 AND {on})",
AND EXISTS (SELECT 1 FROM {t} s WHERE s.{col} = $1{extra})",
t = r.table
))
.bind(survivor)
Expand Down
40 changes: 34 additions & 6 deletions rust/crates/du-db/src/denovo.rs
Original file line number Diff line number Diff line change
Expand Up @@ -436,16 +436,39 @@ pub async fn load(pool: &PgPool, doc: &DenovoTree, keep_private: bool) -> Result
_ => ("STANDARD", false),
};
let attrs = json!({ "cohort": tip.cohort, "sex": tip.sex, "denovo": true });
// Resolve the donor (the entity that links one individual's biosample rows):
// if a catalog accession already carries this panel id as its alias/accession,
// reuse that donor so the tip joins the same person instead of forming a
// donor-less orphan; else create a donor keyed by the panel id. This keeps the
// tip donor-linked across reloads (consolidate-donors still merges the case
// where the individual has more than one catalog donor).
let donor_id: i64 = sqlx::query_scalar(
"WITH existing AS ( \
SELECT donor_id FROM core.biosample \
WHERE donor_id IS NOT NULL AND deleted = false \
AND (lower(alias) = lower($1) OR lower(accession) = lower($1)) \
ORDER BY (source_attrs->>'denovo' = 'true') ASC, donor_id LIMIT 1), \
ins AS ( \
INSERT INTO core.specimen_donor (donor_identifier, donor_type) \
SELECT $1, $2::core.biosample_source WHERE NOT EXISTS (SELECT 1 FROM existing) \
RETURNING id) \
SELECT donor_id FROM existing UNION ALL SELECT id FROM ins LIMIT 1",
)
.bind(&tip.sample)
.bind(src)
.fetch_one(&mut *tx)
.await?;
let guid: Uuid = match sqlx::query_scalar(
"INSERT INTO core.biosample (source, accession, center_name, source_attrs, is_public) \
VALUES ($1::core.biosample_source, $2, $3, $4, $5) \
"INSERT INTO core.biosample (source, accession, center_name, source_attrs, is_public, donor_id) \
VALUES ($1::core.biosample_source, $2, $3, $4, $5, $6) \
ON CONFLICT (accession) WHERE accession IS NOT NULL DO NOTHING RETURNING sample_guid",
)
.bind(src)
.bind(&tip.sample)
.bind(tip.cohort.as_deref())
.bind(&attrs)
.bind(is_public)
.bind(donor_id)
.fetch_optional(&mut *tx)
.await?
{
Expand All @@ -454,10 +477,15 @@ pub async fn load(pool: &PgPool, doc: &DenovoTree, keep_private: bool) -> Result
g
}
None => {
sqlx::query_scalar("SELECT sample_guid FROM core.biosample WHERE accession = $1")
.bind(&tip.sample)
.fetch_one(&mut *tx)
.await?
// Already present (reload): backfill a missing donor link.
sqlx::query_scalar(
"UPDATE core.biosample SET donor_id = COALESCE(donor_id, $2), updated_at = now() \
WHERE accession = $1 RETURNING sample_guid",
)
.bind(&tip.sample)
.bind(donor_id)
.fetch_one(&mut *tx)
.await?
}
};
let call = tip.terminal_label.clone().unwrap_or_else(|| tip.sample.clone());
Expand Down
134 changes: 134 additions & 0 deletions rust/crates/du-db/src/donor.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
//! Specimen-donor consolidation — the entity *above* biosamples that links the
//! multiple biosample rows belonging to one individual.
//!
//! One person is routinely represented by several `core.biosample` rows: the same
//! 1000-Genomes donor appears under an NCBI BioSample accession (`SAMN…`) **and** an
//! EBI accession (`SAMEA…`) from different publications, and — when the sample was
//! used as a de-novo tree building block — again as a tree tip keyed by the raw
//! panel id (`HG00126`). The ETL gave each accession its own `specimen_donor` and
//! left the tip donor-less, so the donor entity never actually linked them, and the
//! sample's haplogroup (recorded on the tip's tree placement) couldn't surface on
//! the catalog accessions.
//!
//! [`consolidate_denovo_donors`] repairs this: it groups biosamples by their shared
//! panel id (a tip's `accession` == a reference's `alias`), points every member at a
//! single surviving donor (the richest one — most complete metadata), fills that
//! donor's gaps + canonical identifier, and prunes the emptied donor rows. The
//! sample report then resolves the haplogroup at the donor level, so every accession
//! of the individual shows the de-novo tree placement.

use crate::DbError;
use sqlx::PgPool;

/// Outcome of [`consolidate_denovo_donors`].
#[derive(Debug, Clone, Default)]
pub struct DonorConsolidationReport {
/// Distinct same-individual groups (shared panel id with a de-novo tip + ≥1 ref).
pub groups: i64,
/// Biosamples repointed to a surviving donor.
pub biosamples_repointed: u64,
/// Redundant donor rows deleted (emptied by the repoint).
pub donors_pruned: u64,
}

/// The member set: every biosample whose identity key (a de-novo tip's `accession`,
/// else a reference's `alias`) is a panel id shared between a de-novo tip and at
/// least one non-de-novo reference. `key` is lower-cased for grouping; `orig_id`
/// keeps the original-case id for the donor identifier.
const MEMBERS_CTE: &str = "\
members AS ( \
SELECT b.sample_guid, b.donor_id, \
(CASE WHEN b.source_attrs->>'denovo' = 'true' THEN lower(b.accession) ELSE lower(b.alias) END) AS key, \
(CASE WHEN b.source_attrs->>'denovo' = 'true' THEN b.accession ELSE b.alias END) AS orig_id \
FROM core.biosample b \
WHERE b.deleted = false \
AND (CASE WHEN b.source_attrs->>'denovo' = 'true' THEN lower(b.accession) ELSE lower(b.alias) END) IN ( \
SELECT lower(d.accession) FROM core.biosample d \
WHERE d.source_attrs->>'denovo' = 'true' AND d.deleted = false AND d.accession IS NOT NULL \
AND EXISTS (SELECT 1 FROM core.biosample r \
WHERE r.deleted = false AND COALESCE(r.source_attrs->>'denovo','') <> 'true' \
AND lower(r.alias) = lower(d.accession)) \
) \
)";

/// Consolidate the donor rows of same-individual biosamples (see module docs).
/// Idempotent — once a group shares one donor there is nothing left to repoint or
/// prune. `apply = false` counts the work without mutating.
pub async fn consolidate_denovo_donors(pool: &PgPool, apply: bool) -> Result<DonorConsolidationReport, DbError> {
let groups: i64 = sqlx::query_scalar(&format!("WITH {MEMBERS_CTE} SELECT count(DISTINCT key) FROM members"))
.fetch_one(pool)
.await?;
let mut rep = DonorConsolidationReport { groups, ..Default::default() };
if !apply {
return Ok(rep);
}

let mut tx = pool.begin().await?;

// 1) Materialize each member with its group's surviving donor: the donor with the
// most complete metadata (geocoord + biobank + sex), ties broken by lowest id.
sqlx::query(&format!(
"CREATE TEMP TABLE _donor_grp ON COMMIT DROP AS \
WITH {MEMBERS_CTE}, \
survivor AS ( \
SELECT DISTINCT ON (m.key) m.key, sd.id AS survivor_donor \
FROM (SELECT DISTINCT key, donor_id FROM members WHERE donor_id IS NOT NULL) m \
JOIN core.specimen_donor sd ON sd.id = m.donor_id \
ORDER BY m.key, \
((sd.geocoord IS NOT NULL)::int + (sd.origin_biobank IS NOT NULL)::int + (sd.sex IS NOT NULL)::int) DESC, \
sd.id \
) \
SELECT m.sample_guid, m.donor_id, m.key, m.orig_id, s.survivor_donor \
FROM members m JOIN survivor s ON s.key = m.key"
))
.execute(&mut *tx)
.await?;

// 2) Fill the survivor donor's gaps from its group siblings + set the canonical
// identifier (original-case panel id) — do this BEFORE the repoint, while the
// other donors still hold their metadata.
sqlx::query(
"UPDATE core.specimen_donor s SET \
sex = COALESCE(s.sex, agg.sex), \
geocoord = COALESCE(s.geocoord, agg.geocoord), \
origin_biobank = COALESCE(s.origin_biobank, agg.biobank), \
donor_identifier = agg.ident, \
updated_at = now() \
FROM ( \
SELECT g.survivor_donor, \
(array_agg(sd.sex) FILTER (WHERE sd.sex IS NOT NULL))[1] AS sex, \
(array_agg(sd.geocoord) FILTER (WHERE sd.geocoord IS NOT NULL))[1] AS geocoord, \
(array_agg(sd.origin_biobank) FILTER (WHERE sd.origin_biobank IS NOT NULL))[1] AS biobank, \
max(g.orig_id) AS ident \
FROM _donor_grp g LEFT JOIN core.specimen_donor sd ON sd.id = g.donor_id \
GROUP BY g.survivor_donor \
) agg \
WHERE s.id = agg.survivor_donor",
)
.execute(&mut *tx)
.await?;

// 3) Repoint every group member to its surviving donor.
let repointed = sqlx::query(
"UPDATE core.biosample b SET donor_id = g.survivor_donor, updated_at = now() \
FROM _donor_grp g WHERE b.sample_guid = g.sample_guid AND b.donor_id IS DISTINCT FROM g.survivor_donor",
)
.execute(&mut *tx)
.await?
.rows_affected();

// 4) Prune donors that the repoint emptied (were in a group, now unreferenced).
let pruned = sqlx::query(
"DELETE FROM core.specimen_donor sd \
WHERE sd.id IN (SELECT DISTINCT donor_id FROM _donor_grp WHERE donor_id IS NOT NULL) \
AND NOT EXISTS (SELECT 1 FROM core.biosample b WHERE b.donor_id = sd.id)",
)
.execute(&mut *tx)
.await?
.rows_affected();

tx.commit().await?;
rep.biosamples_repointed = repointed;
rep.donors_pruned = pruned;
Ok(rep)
}
1 change: 1 addition & 0 deletions rust/crates/du-db/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ pub mod coverage;
pub mod dedup;
pub mod denovo;
pub mod discovery;
pub mod donor;
pub mod exchange;
pub mod fed;
pub mod genome_region;
Expand Down
Loading
Loading