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
16 changes: 15 additions & 1 deletion .github/workflows/R-CMD-check.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ jobs:

- uses: r-lib/actions/setup-r-dependencies@v2
with:
extra-packages: any::rcmdcheck, any::urlchecker
extra-packages: any::rcmdcheck, any::urlchecker, any::pkgload
needs: check
cache-version: 2

Expand All @@ -57,6 +57,20 @@ jobs:
}
shell: Rscript {0}

# Source-tree lint. `R CMD check` runs the suite against an INSTALLED
# package, whose R/ contains omophub.rdb rather than any .R file, so this
# guard skips itself there. Running it here against the checkout keeps it
# gating every PR instead of only ever running on a developer's machine.
- name: Check roxygen @param order against signatures
if: matrix.r == 'release'
run: |
pkgload::load_all(quiet = TRUE)
testthat::test_file(
"tests/testthat/test-roxygen-param-order.R",
stop_on_failure = TRUE
)
shell: Rscript {0}

- uses: r-lib/actions/check-r-package@v2
with:
upload-snapshots: true
Expand Down
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 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 vocab_release Specific vocabulary release version (e.g., "2025.1"). Default `NULL`.
#' @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 page Page number. Default 1.
#' @param page_size Mappings per page. Default 100, maximum 200.
#'
#' @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 include_invalid Whether to return deprecated mappings. Same
#' semantics as `$get()`, including the include-by-default behaviour.
#' @param vocab_release Specific vocabulary release version (e.g., "2025.1"). Default `NULL`.
#' @param relationship_ids Relationship types to return. Same semantics as
#' `$get()` -- see there for the Value-as-Concept case.
#' @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`.
#'
#' @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