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") +})