Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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
2 changes: 1 addition & 1 deletion DESCRIPTION
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
Package: omophub
Title: R Client for the 'OMOPHub' Medical Vocabulary API
Version: 1.8.1
Version: 1.9.0
Authors@R: c(
person("Alex", "Chen", email = "alex@omophub.com", role = c("aut", "cre", "cph")),
person("Observational Health Data Science and Informatics", role = c("cph"))
Expand Down
46 changes: 41 additions & 5 deletions NEWS.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,44 @@
# omophub 1.9.0

## New Features

* **Mapping pagination** - `client$mappings$get()` accepts `page` and
`page_size`. `GET /v1/concepts/{id}/mappings` became paginated;
`page_size` defaults to 100,
matching the old cap, so an existing call returns exactly the page it
returned before. Pagination metadata is attached to the result as the
`pagination` attribute - read it with `attr(result, "pagination")`.

* **`client$mappings$get_all()`** - walks every page and returns one tibble of
all mappings for a concept. Prefer it over `get()` when assembling a code
list; a partial code list is wrong in a way nothing in the result reveals.
Takes `max_pages` and `progress` like the other `*_all()` methods.

* **`relationship_ids`** on `client$mappings$get()` and `get_all()` - a
character vector of relationship types. The server defaults to `"Maps to"`,
so a composite concept returns only half its decomposition unless
`"Maps to value"` is asked for too: "Allergy to penicillin G" maps to
"Allergy to drug" via `Maps to` and to "penicillin G" via `Maps to value`.

## Bug Fixes

* **`client$mappings$get()` returned a different shape once the API added
pagination.** `perform_get()` switches its return shape based on whether the
response carries `meta.pagination`.

* **`include_invalid = FALSE` never reached the server** on
`client$mappings$get()` and `get_all()`. The parameter was only sent when
`TRUE`, and this endpoint defaults to *including* deprecated mappings, so
asking to exclude them did nothing. The default is now `NULL` (take the
server default); pass `FALSE` to exclude. Omitting it behaves exactly as
before, so only callers who explicitly passed `FALSE` - and were being
ignored - see a change.

# omophub 1.8.1

## Changed

* **Canonical endpoint path** `client$search$semantic()` and
* **Canonical endpoint path** - `client$search$semantic()` and
`client$search$semantic_all()` now call `GET /v1/search/semantic` instead of
`GET /v1/concepts/semantic-search`. The legacy path remains a permanent
server-side alias (emits `Deprecation: true` + `Link: …rel="successor-version"`
Expand All @@ -13,21 +49,21 @@

## New Features

* **FHIR Value-as-Concept** the `resolve_batch(as_tibble = TRUE)` tibble now
* **FHIR Value-as-Concept** - the `resolve_batch(as_tibble = TRUE)` tibble now
includes `value_as_concept_id` and `value_as_concept_name` columns, populated
when the resolver decomposes a composite concept via the `Maps to value`
relationship (HL7 FHIR-to-OMOP IG Value-as-Concept pattern e.g. "Allergy to
relationship (HL7 FHIR-to-OMOP IG Value-as-Concept pattern - e.g. "Allergy to
penicillin" yields a standard "Allergy to drug" plus a value "Penicillin G").

* **`on_unmapped` for FHIR resolution** `resolve()`, `resolve_batch()`, and
* **`on_unmapped` for FHIR resolution** - `resolve()`, `resolve_batch()`, and
`resolve_codeable_concept()` gained an `on_unmapped` argument (`"error"`
default / `"sentinel"`). With `"sentinel"` the resolver returns a
`concept_id` 0 record instead of a 404 when nothing resolves, so ETL
pipelines always get a row (matches the Python SDK).

## Behavior Changes

* **Unmapped rows in the batch tibble** a coding that resolves to a source
* **Unmapped rows in the batch tibble** - a coding that resolves to a source
concept but has no standard `Maps to` target now reports `status = "unmapped"`
(with `standard_concept_id = 0`) instead of `"resolved"`, matching the OMOP /
FHIR-to-OMOP IG convention that an unmapped concept is `concept_id 0`. Code
Expand Down
137 changes: 125 additions & 12 deletions R/mappings.R
Original file line number Diff line number Diff line change
Expand Up @@ -15,38 +15,131 @@ MappingsResource <- R6::R6Class(
},
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.

#' @description
#' Get mappings for a concept.
#' Get one page of mappings for a concept.
#'
#' The endpoint is paginated and a concept can easily have more mappings
#' than one page holds, so a full page means "there is probably more",
#' not "this is everything". Read the `pagination` attribute on the
#' result, or use `get_all()` to walk every page.
#'
#' @param concept_id The concept ID.
#' @param target_vocabulary Filter to a specific target vocabulary (e.g., "ICD10CM").
#' @param include_invalid Include invalid/deprecated mappings. Default `FALSE`.
#' @param relationship_ids Character vector of relationship types to return.
#' Defaults server-side to `"Maps to"`. Pass `c("Maps to", "Maps to value")`
#' to also get the Value-as-Concept decomposition of composite concepts -
#' "Allergy to penicillin G" maps to "Allergy to drug" via `Maps to` and to
#' "penicillin G" via `Maps to value`, and the default returns only the
#' first of those.
#' @param include_invalid Whether to return mappings whose relationship or
#' target concept is deprecated. Default `NULL` takes the server default,
#' which for this endpoint is to *include* them; pass `FALSE` to exclude
#' them. The source concept is never filtered, so a deprecated concept
#' still returns what it maps to.
#' @param page Page number. Default 1.
#' @param page_size Mappings per page. Default 100, maximum 200.
#' @param vocab_release Specific vocabulary release version (e.g., "2025.1"). Default `NULL`.
#'
#' @returns Mappings for the concept.
#' @returns Mappings for the concept, with pagination metadata attached as
#' the `pagination` attribute.
get = function(concept_id,
target_vocabulary = NULL,
include_invalid = FALSE,
vocab_release = NULL) {
include_invalid = NULL,
vocab_release = NULL,
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
relationship_ids = NULL,
page = 1,
page_size = 100) {
concept_id <- validate_concept_id(concept_id)
pag <- validate_pagination(page, page_size, max_page_size = 200)

params <- list()
params <- list(
page = pag$page,
page_size = pag$page_size
)

if (!is.null(target_vocabulary)) {
checkmate::assert_string(target_vocabulary, min.chars = 1)
params$target_vocabulary <- target_vocabulary
}
if (isTRUE(include_invalid)) {
params$include_invalid <- "true"
if (!is.null(relationship_ids)) {
checkmate::assert_character(
relationship_ids,
min.len = 1,
any.missing = FALSE
)
params$relationship_ids <- paste(relationship_ids, collapse = ",")
}
# Tri-state, not a flag. This endpoint defaults to *including* deprecated
# mappings, so omitting the parameter and sending "false" are different
# requests -- dropping a FALSE would silently return the rows the caller
# asked to exclude.
if (!is.null(include_invalid)) {
checkmate::assert_flag(include_invalid)
params$include_invalid <- if (include_invalid) "true" else "false"
}
if (!is.null(vocab_release)) {
checkmate::assert_string(vocab_release, min.chars = 1)
params$vocab_release <- vocab_release
}

perform_get(
result <- perform_get(
private$.base_req,
paste0("concepts/", concept_id, "/mappings"),
query = if (length(params) > 0) params else NULL
query = params
)

private$.with_pagination(result)
},

#' @description
#' Get every mapping for a concept, walking all pages.
#'
#' Prefer this over `get()` when assembling a code list — `get()` returns
#' a single page, and a partial code list is wrong in a way nothing in
#' the result reveals.
#'
#' @param concept_id The concept ID.
#' @param target_vocabulary Filter to a specific target vocabulary (e.g., "ICD10CM").
#' @param relationship_ids Relationship types to return. Same semantics as
#' `$get()` -- see there for the Value-as-Concept case.
#' @param include_invalid Whether to return deprecated mappings. Same
#' semantics as `$get()`, including the include-by-default behaviour.
#' @param page_size Mappings fetched per request. Default 100, maximum 200.
#' @param max_pages Maximum pages to fetch. Default `Inf`.
#' @param progress Show progress bar. Default `TRUE`.
#' @param vocab_release Specific vocabulary release version (e.g., "2025.1"). Default `NULL`.
#'
#' @returns A tibble of all mappings for the concept.
get_all = function(concept_id,
target_vocabulary = NULL,
include_invalid = NULL,
vocab_release = NULL,
relationship_ids = NULL,
page_size = 100,
max_pages = Inf,
progress = TRUE) {
concept_id <- validate_concept_id(concept_id)

fetch_fn <- function(page, size) {
result <- self$get(
concept_id,
target_vocabulary = target_vocabulary,
relationship_ids = relationship_ids,
include_invalid = include_invalid,
page = page,
page_size = size,
vocab_release = vocab_release
)
list(
data = result$mappings %||% list(),
meta = attr(result, "pagination") %||% list()
)
}

paginate_all(
fetch_fn,
page_size = page_size,
max_pages = max_pages,
progress = progress
)
},

Expand Down Expand Up @@ -120,11 +213,31 @@ MappingsResource <- R6::R6Class(
#' Print resource information.
print = function() {
cat("<OMOPHub MappingsResource>\n")
cat(" Methods: get, map\n")
cat(" Methods: get, get_all, map\n")
invisible(self)
}
),
private = list(
.base_req = NULL
.base_req = NULL,

# Keep get()'s return shape stable across the API gaining pagination.
#
# perform_get() switches shape based on the response: without
# meta.pagination it unwraps to body$data (so `result$mappings` works),
# with it, it returns list(data = <body$data>, meta = <pagination>). When
# GET /concepts/{id}/mappings became paginated on 2026-08-04 that flipped
# this method's result out from under existing callers — `result$mappings`
# started returning NULL against an unchanged SDK, with no error.
#
# So unwrap back to the documented shape and carry the pagination as an
# attribute, which adds the new information without moving the old.
.with_pagination = function(result) {
if (is.list(result) && !is.null(result$data) && !is.null(result$meta)) {
out <- result$data
attr(out, "pagination") <- result$meta
return(out)
}
result
}
)
)
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,10 @@ results$data
# Get concept by vocabulary code
snomed_concept <- client$concepts$get_by_code("SNOMED", "44054006")

# Map to another vocabulary
mappings <- client$mappings$get(201826, target_vocabulary = "ICD10CM")
# Map to another vocabulary. `Maps to` points at *standard* concepts, so
# SNOMED -> ICD10CM returns nothing; look the code up and map it instead.
icd <- client$concepts$get_by_code("ICD10CM", "E11.9")
mappings <- client$mappings$get(icd$concept_id, target_vocabulary = "SNOMED")

# Navigate hierarchy
ancestors <- client$hierarchy$ancestors(201826, max_levels = 3)
Expand Down Expand Up @@ -391,7 +393,7 @@ concepts_df %>%
| `concepts` | Concept lookup and batch operations | `get()`, `get_by_code()`, `batch()`, `suggest()` |
| `search` | Full-text and semantic search | `basic()`, `advanced()`, `semantic()`, `similar()`, `bulk_basic()`, `bulk_semantic()` |
| `hierarchy` | Navigate concept relationships | `ancestors()`, `descendants()` |
| `mappings` | Cross-vocabulary mappings | `get()`, `map()` |
| `mappings` | Cross-vocabulary mappings | `get()`, `get_all()`, `map()` |
| `vocabularies` | Vocabulary metadata | `list()`, `get()`, `stats()` |
| `domains` | Domain information | `list()`, `get()`, `concepts()` |
| `fhir` | FHIR-to-OMOP resolution | `resolve()`, `resolve_batch()`, `resolve_codeable_concept()` |
Expand Down
Loading