From ba3dce3cdf2e047da591bef3e863887f114512e7 Mon Sep 17 00:00:00 2001 From: Cvicnaire Date: Thu, 15 Oct 2026 08:26:11 -0400 Subject: [PATCH] Add mapMetabolites(): metabolite id conversion through the MetaboAnalyst API One exported function over MetaboAnalyst's public mapcompounds endpoint, built on the searchExternal HTTP plumbing so tests mock externalPostJson(). Inputs are cleaned and sent in chunks of 200; the reply's "NA" strings and JSON nulls both become NA. The URL is overridable through options(sigrepo.metaboanalyst_url). When tested live on 2026-10-15 only from = "name" returned matches; the documented id input types are passed through as documented but came back unmatched, which the roxygen and NEWS say. Closes #281. Serves #171. Co-Authored-By: Claude Fable 5.1 --- NAMESPACE | 1 + NEWS.Rmd | 17 ++ NEWS.md | 18 +++ R/externalCommon.R | 3 +- R/mapMetabolites.R | 147 ++++++++++++++++++ _pkgdown.yml | 3 +- man/mapMetabolites.Rd | 53 +++++++ .../external/metaboanalyst_mapcompounds.json | 11 ++ tests/testthat/test_mapMetabolites.R | 133 ++++++++++++++++ 9 files changed, 384 insertions(+), 2 deletions(-) create mode 100644 R/mapMetabolites.R create mode 100644 man/mapMetabolites.Rd create mode 100644 tests/testthat/test_data/external/metaboanalyst_mapcompounds.json create mode 100644 tests/testthat/test_mapMetabolites.R diff --git a/NAMESPACE b/NAMESPACE index aef0c67..fa2ddbc 100755 --- a/NAMESPACE +++ b/NAMESPACE @@ -66,6 +66,7 @@ export(inActivateUser) export(init_handle) export(insert_table_sql) export(lookup_table_sql) +export(mapMetabolites) export(mergeDifexp) export(newConnHandler) export(plotHypeRDots) diff --git a/NEWS.Rmd b/NEWS.Rmd index 35e155e..caf2434 100644 --- a/NEWS.Rmd +++ b/NEWS.Rmd @@ -3,6 +3,23 @@ output: rmarkdown::github_document --- +# SigRepo (development version) + +## New + +- `mapMetabolites()` converts metabolite names or identifiers between HMDB, + PubChem, ChEBI, KEGG, METLIN and SMILES through MetaboAnalyst's public + compound-mapping service, with no login and no new dependency. Give it a + character vector and the namespace it is in (`from = "name"` by default), + and it returns one row per distinct input, in input order, with a `matched` + flag and the canonical compound name. It is the step to run before + `addMetabolomicsSignatureSet()` when a signature arrives with compound + names or KEGG, PubChem or ChEBI ids, since SigRepo's metabolite reference + is RefMet-centred with HMDB, SMILES and InChIKey cross-references. Long + lists go in chunks of 200. When tested on 2026-10-15 only name queries + returned matches; the identifier input types MetaboAnalyst documents are + passed through as documented but came back unmatched (#281, serves #171). + # SigRepo 1.1.0 ## Breaking changes diff --git a/NEWS.md b/NEWS.md index e6b6602..fde2465 100755 --- a/NEWS.md +++ b/NEWS.md @@ -1,3 +1,21 @@ +# SigRepo (development version) + +## New + +- `mapMetabolites()` converts metabolite names or identifiers between + HMDB, PubChem, ChEBI, KEGG, METLIN and SMILES through MetaboAnalyst’s + public compound-mapping service, with no login and no new dependency. + Give it a character vector and the namespace it is in (`from = "name"` + by default), and it returns one row per distinct input, in input + order, with a `matched` flag and the canonical compound name. It is + the step to run before `addMetabolomicsSignatureSet()` when a + signature arrives with compound names or KEGG, PubChem or ChEBI ids, + since SigRepo’s metabolite reference is RefMet-centred with HMDB, + SMILES and InChIKey cross-references. Long lists go in chunks of 200. + When tested on 2026-10-15 only name queries returned matches; the + identifier input types MetaboAnalyst documents are passed through as + documented but came back unmatched (#281, serves \#171). + # SigRepo 1.1.0 ## Breaking changes diff --git a/R/externalCommon.R b/R/externalCommon.R index f9ae6ab..a77538a 100644 --- a/R/externalCommon.R +++ b/R/externalCommon.R @@ -18,7 +18,7 @@ EXTERNAL_COLUMNS <- c( #' @title externalUrl #' @description Base URL of an external search engine, overridable through #' options so tests and a future dev instance can redirect it. -#' @param source One of "rummagene", "rummageo", "coresh". +#' @param source One of "rummagene", "rummageo", "coresh", "metaboanalyst". #' @return A single URL string. #' @keywords internal #' @noRd @@ -28,6 +28,7 @@ externalUrl <- function(source) { rummagene = base::getOption("sigrepo.rummagene_url", "https://rummagene.com/graphql"), rummageo = base::getOption("sigrepo.rummageo_url", "https://rummageo.com/graphql"), coresh = base::getOption("sigrepo.coresh_url", "https://alserglab.wustl.edu/coresh-back"), + metaboanalyst = base::getOption("sigrepo.metaboanalyst_url", "https://rest.xialab.ca/api/mapcompounds"), base::stop(base::sprintf("Unknown external source '%s'.", source)) ) } diff --git a/R/mapMetabolites.R b/R/mapMetabolites.R new file mode 100644 index 0000000..c26c63e --- /dev/null +++ b/R/mapMetabolites.R @@ -0,0 +1,147 @@ +############################################################ +# MetaboAnalyst compound mapping: converts metabolite names +# or ids between HMDB, PubChem, ChEBI, KEGG and METLIN through +# the public endpoint documented at +# https://www.metaboanalyst.ca/docs/APIs.xhtml. No auth. HTTP +# goes through externalPostJson() so tests run offline. +############################################################ + +METABOANALYST_COLUMNS <- c( + "query", "matched", "match", "hmdb", "pubchem", "chebi", "kegg", "metlin", "smiles" +) + +METABOANALYST_INPUT_TYPES <- c("name", "hmdb", "pubchem", "chebi", "kegg", "metlin") + +#' @title mapMetabolites +#' @description Convert metabolite names or identifiers between namespaces +#' through MetaboAnalyst's public compound-mapping service. +#' @param ids Character vector of compound names or identifiers, all in the +#' namespace named by \code{from}. Values are trimmed, empty values and +#' duplicates are dropped before the request, and a value containing a +#' semicolon (the service's list separator) is an error. +#' @param from Namespace of \code{ids}. One of \code{"name"}, \code{"hmdb"}, +#' \code{"pubchem"}, \code{"chebi"}, \code{"kegg"} or \code{"metlin"}, as +#' documented by MetaboAnalyst. Defaults to \code{"name"}. +#' @param timeout Seconds to wait for each request. The service takes about +#' 20 to 30 seconds per request plus a fraction of a second per compound, so +#' long lists are sent in chunks of 200 and the timeout applies to each +#' chunk. Defaults to 120. +#' @return A data frame with one row per distinct input, in input order: +#' \code{query} (the value sent), \code{matched} (logical, whether the +#' service recognised it), \code{match} (MetaboAnalyst's canonical compound +#' name), and the \code{hmdb}, \code{pubchem}, \code{chebi}, \code{kegg}, +#' \code{metlin} and \code{smiles} identifiers. Unmatched rows are \code{NA} +#' in every column but \code{query} and \code{matched}. +#' @details +#' SigRepo's metabolite reference is RefMet-centred with HMDB, SMILES and +#' InChIKey cross-references, so this is the step to run when a signature +#' arrives with compound names or KEGG, PubChem or ChEBI ids: map them, +#' then deposit the HMDB ids with \code{metabolomics_nomenclature = "hmdb"}. +#' +#' The endpoint is \code{POST https://rest.xialab.ca/api/mapcompounds}, +#' overridable through \code{options(sigrepo.metaboanalyst_url = ...)}. +#' When tested on 2026-10-15 only \code{from = "name"} returned matches; +#' the identifier input types are documented by MetaboAnalyst and are passed +#' through as documented, but every id query came back unmatched. Check the +#' \code{matched} column before relying on an id-based lookup. +#' @examples +#' \dontrun{ +#' mapMetabolites(c("D-Glucose", "Citric acid", "1,3-Diaminopropane")) +#' } +#' @export +mapMetabolites <- function(ids, from = "name", timeout = 120) { + from <- base::match.arg(from, METABOANALYST_INPUT_TYPES) + ids <- cleanMetaboliteIds(ids) + if (base::length(ids) == 0) { + return(emptyMetaboanalystFrame()) + } + chunk_size <- base::getOption("sigrepo.metaboanalyst_chunk_size", 200) + chunks <- base::split(ids, base::ceiling(base::seq_along(ids) / chunk_size)) + frames <- base::lapply(chunks, function(chunk) { + payload <- metaboanalystMapCompounds(chunk, from, timeout = timeout) + parseMetaboanalystMapping(payload, chunk) + }) + out <- base::do.call(base::rbind, base::unname(frames)) + base::rownames(out) <- NULL + out +} + +#' @title cleanMetaboliteIds +#' @description Trim, drop empty and NA-like values, de-duplicate, and refuse +#' values that would be split by the service's semicolon separator. +#' @keywords internal +#' @noRd +cleanMetaboliteIds <- function(ids) { + ids <- base::trimws(base::as.character(ids %||% base::character())) + ids <- ids[!base::is.na(ids) & !ids %in% c("", "NA", "NULL")] + ids <- base::unique(ids) + bad <- ids[base::grepl(";", ids, fixed = TRUE)] + if (base::length(bad) > 0) { + base::stop(base::sprintf( + "MetaboAnalyst separates compounds with a semicolon, so a value may not contain one: %s", + base::paste(utils::head(bad, 3), collapse = ", ") + )) + } + ids +} + +#' @title metaboanalystMapCompounds +#' @description One POST to the mapcompounds endpoint; the fetch half. +#' @keywords internal +#' @noRd +metaboanalystMapCompounds <- function(ids, from, timeout = 120) { + externalPostJson( + externalUrl("metaboanalyst"), + base::list(queryList = base::paste0(base::paste(ids, collapse = ";"), ";"), inputType = from), + timeout = timeout + ) +} + +#' @title emptyMetaboanalystFrame +#' @description A zero-row frame with the mapMetabolites columns. +#' @keywords internal +#' @noRd +emptyMetaboanalystFrame <- function() { + base::data.frame( + query = base::character(), matched = base::logical(), match = base::character(), + hmdb = base::character(), pubchem = base::character(), chebi = base::character(), + kegg = base::character(), metlin = base::character(), smiles = base::character(), + stringsAsFactors = FALSE + ) +} + +#' @title parseMetaboanalystMapping +#' @description Column-oriented reply -> one row per query, in the order of +#' `queries`; queries absent from the reply become unmatched rows. Missing +#' values arrive as the string "NA" or as JSON null. +#' @keywords internal +#' @noRd +parseMetaboanalystMapping <- function(payload, queries) { + column <- function(name) { + values <- payload[[name]] %||% base::list() + out <- base::vapply(values, function(v) { + if (base::is.null(v) || base::length(v) == 0) NA_character_ else base::as.character(v[[1]]) + }, base::character(1)) + out[!base::is.na(out) & out %in% c("NA", "")] <- NA_character_ + out + } + reply <- base::data.frame( + query = column("Query"), match = column("Match"), hmdb = column("HMDB"), + pubchem = column("PubChem"), chebi = column("ChEBI"), kegg = column("KEGG"), + metlin = column("METLIN"), smiles = column("SMILES"), comment = column("Comment"), + stringsAsFactors = FALSE + ) + reply <- reply[!base::is.na(reply$query) & !base::duplicated(reply$query), , drop = FALSE] + idx <- base::match(queries, reply$query) + out <- base::data.frame( + query = base::as.character(queries), + matched = reply$comment[idx] %in% "1", + match = reply$match[idx], hmdb = reply$hmdb[idx], pubchem = reply$pubchem[idx], + chebi = reply$chebi[idx], kegg = reply$kegg[idx], metlin = reply$metlin[idx], + smiles = reply$smiles[idx], + stringsAsFactors = FALSE + ) + out <- out[, METABOANALYST_COLUMNS, drop = FALSE] + base::rownames(out) <- NULL + out +} diff --git a/_pkgdown.yml b/_pkgdown.yml index 43ba06b..ad1adaf 100755 --- a/_pkgdown.yml +++ b/_pkgdown.yml @@ -130,10 +130,11 @@ reference: - '`hypergemExport`' - title: Search Beyond the Repository - desc: Ask Rummagene, RummaGEO and CORESH what resembles a signature, or write a script that searches LINCS for perturbations that mimic or reverse it + desc: Ask Rummagene, RummaGEO and CORESH what resembles a signature, write a script that searches LINCS for perturbations that mimic or reverse it, or convert metabolite identifiers through MetaboAnalyst contents: - '`searchExternal`' - '`writeSignatureSearchScript`' + - '`mapMetabolites`' - title: Build Signature Objects desc: Construct and validate OmicSignature and OmicSignatureCollection objects before depositing them diff --git a/man/mapMetabolites.Rd b/man/mapMetabolites.Rd new file mode 100644 index 0000000..eb150ea --- /dev/null +++ b/man/mapMetabolites.Rd @@ -0,0 +1,53 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/mapMetabolites.R +\name{mapMetabolites} +\alias{mapMetabolites} +\title{mapMetabolites} +\usage{ +mapMetabolites(ids, from = "name", timeout = 120) +} +\arguments{ +\item{ids}{Character vector of compound names or identifiers, all in the +namespace named by \code{from}. Values are trimmed, empty values and +duplicates are dropped before the request, and a value containing a +semicolon (the service's list separator) is an error.} + +\item{from}{Namespace of \code{ids}. One of \code{"name"}, \code{"hmdb"}, +\code{"pubchem"}, \code{"chebi"}, \code{"kegg"} or \code{"metlin"}, as +documented by MetaboAnalyst. Defaults to \code{"name"}.} + +\item{timeout}{Seconds to wait for each request. The service takes about +20 to 30 seconds per request plus a fraction of a second per compound, so +long lists are sent in chunks of 200 and the timeout applies to each +chunk. Defaults to 120.} +} +\value{ +A data frame with one row per distinct input, in input order: +\code{query} (the value sent), \code{matched} (logical, whether the +service recognised it), \code{match} (MetaboAnalyst's canonical compound +name), and the \code{hmdb}, \code{pubchem}, \code{chebi}, \code{kegg}, +\code{metlin} and \code{smiles} identifiers. Unmatched rows are \code{NA} +in every column but \code{query} and \code{matched}. +} +\description{ +Convert metabolite names or identifiers between namespaces +through MetaboAnalyst's public compound-mapping service. +} +\details{ +SigRepo's metabolite reference is RefMet-centred with HMDB, SMILES and +InChIKey cross-references, so this is the step to run when a signature +arrives with compound names or KEGG, PubChem or ChEBI ids: map them, +then deposit the HMDB ids with \code{metabolomics_nomenclature = "hmdb"}. + +The endpoint is \code{POST https://rest.xialab.ca/api/mapcompounds}, +overridable through \code{options(sigrepo.metaboanalyst_url = ...)}. +When tested on 2026-10-15 only \code{from = "name"} returned matches; +the identifier input types are documented by MetaboAnalyst and are passed +through as documented, but every id query came back unmatched. Check the +\code{matched} column before relying on an id-based lookup. +} +\examples{ +\dontrun{ +mapMetabolites(c("D-Glucose", "Citric acid", "1,3-Diaminopropane")) +} +} diff --git a/tests/testthat/test_data/external/metaboanalyst_mapcompounds.json b/tests/testthat/test_data/external/metaboanalyst_mapcompounds.json new file mode 100644 index 0000000..05174e2 --- /dev/null +++ b/tests/testthat/test_data/external/metaboanalyst_mapcompounds.json @@ -0,0 +1,11 @@ +{ + "Query": [ "1,3-Diaminopropane", "2-Ketobutyric acid", "Citric acid", "notacompoundxyz", "D-Glucose" ], +"Match": [ "1,3-Diaminopropane", "2-Ketobutyric acid", "Citric acid", "NA", "D-Glucose" ], +"HMDB": [ "HMDB0000002", "HMDB0000005", "HMDB0000094", "NA", "HMDB0000122" ], +"PubChem": [ "428", "58", "311", "NA", "5793" ], +"ChEBI": [ "15725", "30831", "30769", "NA", "15903" ], +"KEGG": [ "C00986", "C00109", "C00158", "NA", "C00031" ], +"METLIN": [ "5081", null, "124", "NA", "133" ], +"SMILES": [ "NCCCN", "CCC(=O)C(O)=O", "OC(=O)CC(O)(CC(O)=O)C(O)=O", "NA", "OC[C@H]1O[C@@H](O)[C@H](O)[C@@H](O)[C@@H]1O" ], +"Comment": [ "1", "1", "1", "0", "1" ] +} \ No newline at end of file diff --git a/tests/testthat/test_mapMetabolites.R b/tests/testthat/test_mapMetabolites.R new file mode 100644 index 0000000..c79d8b8 --- /dev/null +++ b/tests/testthat/test_mapMetabolites.R @@ -0,0 +1,133 @@ +# mapMetabolites(): metabolite identifier conversion through MetaboAnalyst's +# public mapcompounds endpoint. The fixture is a real reply captured on +# 2026-10-15 for five compound names, one of them bogus; METLIN came back as +# JSON null for one row and the string "NA" for the unmatched one, so both +# spellings of "missing" are covered. HTTP is mocked so the tests run offline. + +read_fixture <- function(name) { + jsonlite::fromJSON(testthat::test_path("test_data", "external", name), simplifyVector = FALSE) +} +fixture_queries <- c("1,3-Diaminopropane", "2-Ketobutyric acid", "Citric acid", "notacompoundxyz", "D-Glucose") + +test_that("parseMetaboanalystMapping turns the column-oriented reply into one row per query", { + out <- parseMetaboanalystMapping(read_fixture("metaboanalyst_mapcompounds.json"), fixture_queries) + expect_s3_class(out, "data.frame") + expect_equal(colnames(out), METABOANALYST_COLUMNS) + expect_equal(nrow(out), 5) + expect_equal(out$query, fixture_queries) + expect_equal(out$match[1], "1,3-Diaminopropane") + expect_equal(out$hmdb[5], "HMDB0000122") + expect_equal(out$pubchem[3], "311") + expect_equal(out$chebi[3], "30769") + expect_equal(out$kegg[5], "C00031") + expect_equal(out$metlin[1], "5081") + expect_equal(out$smiles[1], "NCCCN") + expect_type(out$matched, "logical") + expect_equal(out$matched, c(TRUE, TRUE, TRUE, FALSE, TRUE)) +}) + +test_that("parseMetaboanalystMapping reads both the string NA and JSON null as missing", { + out <- parseMetaboanalystMapping(read_fixture("metaboanalyst_mapcompounds.json"), fixture_queries) + expect_true(all(is.na(out[4, c("match", "hmdb", "pubchem", "chebi", "kegg", "metlin", "smiles")]))) + expect_true(is.na(out$metlin[2])) +}) + +test_that("parseMetaboanalystMapping keeps the caller's order and fills queries the service dropped", { + payload <- read_fixture("metaboanalyst_mapcompounds.json") + out <- parseMetaboanalystMapping(payload, c("D-Glucose", "never sent", "Citric acid")) + expect_equal(out$query, c("D-Glucose", "never sent", "Citric acid")) + expect_equal(out$hmdb, c("HMDB0000122", NA, "HMDB0000094")) + expect_equal(out$matched, c(TRUE, FALSE, TRUE)) +}) + +test_that("mapMetabolites posts the semicolon-joined list and the input type to the mapcompounds URL", { + seen <- NULL + testthat::local_mocked_bindings( + externalPostJson = function(url, body, timeout = 60) { + seen <<- list(url = url, body = body, timeout = timeout) + read_fixture("metaboanalyst_mapcompounds.json") + } + ) + out <- mapMetabolites(fixture_queries, from = "name", timeout = 90) + expect_equal(seen$url, "https://rest.xialab.ca/api/mapcompounds") + expect_equal(seen$body$queryList, paste0(paste(fixture_queries, collapse = ";"), ";")) + expect_equal(seen$body$inputType, "name") + expect_equal(seen$timeout, 90) + expect_equal(nrow(out), 5) + expect_equal(out$kegg[5], "C00031") +}) + +test_that("mapMetabolites passes the other documented input types through unchanged", { + seen <- character() + testthat::local_mocked_bindings( + externalPostJson = function(url, body, timeout = 60) { + seen <<- c(seen, body$inputType) + list(Query = list("x"), Match = list("NA"), HMDB = list("NA"), PubChem = list("NA"), + ChEBI = list("NA"), KEGG = list("NA"), METLIN = list("NA"), SMILES = list("NA"), Comment = list("0")) + } + ) + for (type in c("hmdb", "pubchem", "chebi", "kegg", "metlin")) mapMetabolites("x", from = type) + expect_equal(seen, c("hmdb", "pubchem", "chebi", "kegg", "metlin")) +}) + +test_that("mapMetabolites trims, drops empty values and de-duplicates before sending", { + seen <- NULL + testthat::local_mocked_bindings( + externalPostJson = function(url, body, timeout = 60) { + seen <<- body$queryList + list(Query = list("Citric acid", "D-Glucose"), Match = list("Citric acid", "D-Glucose"), + HMDB = list("HMDB0000094", "HMDB0000122"), PubChem = list("311", "5793"), + ChEBI = list("30769", "15903"), KEGG = list("C00158", "C00031"), + METLIN = list("124", "133"), SMILES = list("s1", "s2"), Comment = list("1", "1")) + } + ) + out <- mapMetabolites(c(" Citric acid ", "", NA, "D-Glucose", "Citric acid", "NA")) + expect_equal(seen, "Citric acid;D-Glucose;") + expect_equal(out$query, c("Citric acid", "D-Glucose")) +}) + +test_that("mapMetabolites returns an empty frame without calling the service when nothing is left to send", { + testthat::local_mocked_bindings( + externalPostJson = function(url, body, timeout = 60) stop("must not be called") + ) + out <- mapMetabolites(c("", NA, " ")) + expect_equal(nrow(out), 0) + expect_equal(colnames(out), METABOANALYST_COLUMNS) + expect_type(out$matched, "logical") +}) + +test_that("mapMetabolites refuses a value containing the list separator", { + testthat::local_mocked_bindings( + externalPostJson = function(url, body, timeout = 60) stop("must not be called") + ) + expect_error(mapMetabolites(c("Citric acid", "a;b")), "semicolon") +}) + +test_that("mapMetabolites rejects an unknown input type", { + expect_error(mapMetabolites("glucose", from = "inchikey"), "should be one of") +}) + +test_that("mapMetabolites sends long lists in chunks and keeps the input order", { + sizes <- integer() + testthat::local_mocked_bindings( + externalPostJson = function(url, body, timeout = 60) { + q <- strsplit(sub(";$", "", body$queryList), ";", fixed = TRUE)[[1]] + sizes <<- c(sizes, length(q)) + list(Query = as.list(q), Match = as.list(toupper(q)), HMDB = as.list(rep("NA", length(q))), + PubChem = as.list(rep("NA", length(q))), ChEBI = as.list(rep("NA", length(q))), + KEGG = as.list(rep("NA", length(q))), METLIN = as.list(rep("NA", length(q))), + SMILES = as.list(rep("NA", length(q))), Comment = as.list(rep("1", length(q)))) + } + ) + ids <- sprintf("compound%03d", 1:250) + out <- mapMetabolites(ids) + expect_equal(sizes, c(200, 50)) + expect_equal(out$query, ids) + expect_equal(out$match, toupper(ids)) +}) + +test_that("externalUrl knows the MetaboAnalyst endpoint and honours its option", { + expect_equal(externalUrl("metaboanalyst"), "https://rest.xialab.ca/api/mapcompounds") + withr::local_options(list(sigrepo.metaboanalyst_url = "http://localhost:1/map")) + expect_equal(externalUrl("metaboanalyst"), "http://localhost:1/map") +})