From 9dc0a92b417940b40b560c6a1fde785b420909a0 Mon Sep 17 00:00:00 2001 From: alex-omophub Date: Mon, 25 May 2026 12:14:28 +0100 Subject: [PATCH 1/3] Release version 1.8.0 with new FHIR resolution features - Updated the package version to 1.8.0. - Introduced new features in FHIR resolution, including `value_as_concept_id` and `value_as_concept_name` columns in the `resolve_batch()` output for composite concepts. - Added `on_unmapped` argument to `resolve()`, `resolve_batch()`, and `resolve_codeable_concept()` methods, allowing users to specify behavior for unmapped concepts. - Updated documentation and examples to reflect these enhancements, ensuring clarity on new functionalities and usage patterns. --- DESCRIPTION | 2 +- NEWS.md | 25 +++++++++ R/fhir.R | 41 ++++++++++++-- README.md | 22 +++++++- inst/examples/fhir_resolver.R | 75 ++++++++++++++++++++++++- man/FhirResource.Rd | 16 +++++- tests/testthat/test-fhir.R | 103 +++++++++++++++++++++++++++++++++- vignettes/getting-started.Rmd | 22 +++++++- 8 files changed, 291 insertions(+), 15 deletions(-) diff --git a/DESCRIPTION b/DESCRIPTION index 2d5e369..1043121 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,6 +1,6 @@ Package: omophub Title: R Client for the 'OMOPHub' Medical Vocabulary API -Version: 1.7.0 +Version: 1.8.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")) diff --git a/NEWS.md b/NEWS.md index b7af4a4..114601f 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,3 +1,28 @@ +# omophub 1.8.0 + +## New Features + +* **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 + penicillin" yields a standard "Allergy to drug" plus a value "Penicillin G"). + +* **`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 + 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 + that filters `status == "resolved"` will no longer treat these sentinels as + successful mappings. + # omophub 1.7.0 ## New Features diff --git a/R/fhir.R b/R/fhir.R index 08cfb91..4c3e2cc 100644 --- a/R/fhir.R +++ b/R/fhir.R @@ -43,6 +43,8 @@ FhirResource <- R6::R6Class( #' @param include_recommendations Logical. Include Phoebe recommendations. Default `FALSE`. #' @param recommendations_limit Integer. Max recommendations (1-20). Default `5L`. #' @param include_quality Logical. Include mapping quality signal. Default `FALSE`. + #' @param on_unmapped Optional. `"error"` (default) returns a 404 when no + #' concept resolves; `"sentinel"` returns a `concept_id` 0 record instead. #' #' @returns A list with `input` and `resolution` containing source/standard #' concepts, target CDM table, and optional enrichments. @@ -53,13 +55,15 @@ FhirResource <- R6::R6Class( resource_type = NULL, include_recommendations = FALSE, recommendations_limit = 5L, - include_quality = FALSE) { + include_quality = FALSE, + on_unmapped = NULL) { body <- compact_list( system = system, code = code, display = display, vocabulary_id = vocabulary_id, - resource_type = resource_type + resource_type = resource_type, + on_unmapped = on_unmapped ) if (isTRUE(include_recommendations)) { body$include_recommendations <- TRUE @@ -83,6 +87,8 @@ FhirResource <- R6::R6Class( #' @param include_recommendations Logical. Default `FALSE`. #' @param recommendations_limit Integer. Default `5L`. #' @param include_quality Logical. Default `FALSE`. + #' @param on_unmapped Optional `"error"` (default) / `"sentinel"`; see + #' `resolve()`. #' @param as_tibble Logical. When `TRUE`, returns a [tibble::tibble] #' with one row per input coding and flat columns for the source #' concept, standard concept, target CDM table, mapping type, and @@ -99,6 +105,7 @@ FhirResource <- R6::R6Class( include_recommendations = FALSE, recommendations_limit = 5L, include_quality = FALSE, + on_unmapped = NULL, as_tibble = FALSE) { stopifnot(is.list(codings), length(codings) >= 1, length(codings) <= 100) if (!all(vapply(codings, is.list, logical(1)))) { @@ -116,6 +123,7 @@ FhirResource <- R6::R6Class( body$recommendations_limit <- as.integer(recommendations_limit) } if (isTRUE(include_quality)) body$include_quality <- TRUE + if (!is.null(on_unmapped)) body$on_unmapped <- on_unmapped result <- perform_post(private$.base_req, "fhir/resolve/batch", body = body) @@ -139,6 +147,8 @@ FhirResource <- R6::R6Class( #' @param include_recommendations Logical. Default `FALSE`. #' @param recommendations_limit Integer. Default `5L`. #' @param include_quality Logical. Default `FALSE`. + #' @param on_unmapped Optional `"error"` (default) / `"sentinel"`; see + #' `resolve()`. #' #' @returns A list with `best_match`, `alternatives`, and `unresolved`. resolve_codeable_concept = function(coding, @@ -146,7 +156,8 @@ FhirResource <- R6::R6Class( resource_type = NULL, include_recommendations = FALSE, recommendations_limit = 5L, - include_quality = FALSE) { + include_quality = FALSE, + on_unmapped = NULL) { stopifnot(is.list(coding), length(coding) >= 1, length(coding) <= 20) if (!all(vapply(coding, is.list, logical(1)))) { cli::cli_abort(c( @@ -164,6 +175,7 @@ FhirResource <- R6::R6Class( body$recommendations_limit <- as.integer(recommendations_limit) } if (isTRUE(include_quality)) body$include_quality <- TRUE + if (!is.null(on_unmapped)) body$on_unmapped <- on_unmapped perform_post(private$.base_req, "fhir/resolve/codeable-concept", body = body) } @@ -238,6 +250,8 @@ fhir_batch_to_tibble <- function(result, codings) { standard_concept_name = NA_character_, standard_vocabulary_id = NA_character_, domain_id = NA_character_, + value_as_concept_id = NA_integer_, + value_as_concept_name = NA_character_, target_table = NA_character_, mapping_type = NA_character_, similarity_score = NA_real_, @@ -248,21 +262,36 @@ fhir_batch_to_tibble <- function(result, codings) { src <- resolution$source_concept %||% list() std <- resolution$standard_concept %||% list() + # Value concept from `Maps to value` decomposition (FHIR-to-OMOP IG + # Value-as-Concept pattern); NA when the source is not composite. + val <- resolution$value_as_concept %||% list() + + # A resolved-but-unmapped row carries concept_id 0 (OMOP "no matching + # concept"). Surface it as status "unmapped" so callers filtering + # `status == "resolved"` don't mistake the sentinel for a real mapping. + std_id <- std$concept_id %||% NA_integer_ + unmapped <- is.na(std_id) || std_id == 0L tibble::tibble( source_system = input_coding$system %||% NA_character_, source_code = input_coding$code %||% NA_character_, source_concept_id = src$concept_id %||% NA_integer_, source_concept_name = src$concept_name %||% NA_character_, - standard_concept_id = std$concept_id %||% NA_integer_, + standard_concept_id = std_id, standard_concept_name = std$concept_name %||% NA_character_, standard_vocabulary_id = std$vocabulary_id %||% NA_character_, domain_id = std$domain_id %||% NA_character_, + value_as_concept_id = val$concept_id %||% NA_integer_, + value_as_concept_name = val$concept_name %||% NA_character_, target_table = resolution$target_table %||% NA_character_, mapping_type = resolution$mapping_type %||% NA_character_, similarity_score = resolution$similarity_score %||% NA_real_, - status = "resolved", - status_detail = NA_character_ + status = if (unmapped) "unmapped" else "resolved", + status_detail = if (unmapped) { + "no standard concept (concept_id 0)" + } else { + NA_character_ + } ) } diff --git a/README.md b/README.md index cbce8af..b9fcc1d 100644 --- a/README.md +++ b/README.md @@ -182,6 +182,24 @@ result$best_match$resolution$source_concept$vocabulary_id # [1] "SNOMED" ``` +The resolver also follows the [HL7 FHIR-to-OMOP IG](https://hl7.org/fhir/uv/omop/INFORMATIVE1/en/): it resolves FHIR administrative codes via the IG ConceptMaps, decomposes composite concepts (`Maps to value`), honors `Coding.userSelected`, and can return a `concept_id` 0 sentinel instead of an error. + +```r +# Administrative gender -> person (via IG ConceptMap) +client$fhir$resolve(system = "http://hl7.org/fhir/administrative-gender", code = "male") + +# A user-selected coding wins over vocabulary preference +client$fhir$resolve_codeable_concept(coding = list( + list(system = "http://snomed.info/sct", code = "44054006"), + list(system = "http://hl7.org/fhir/sid/icd-10-cm", code = "E11.9", user_selected = TRUE) +)) + +# on_unmapped = "sentinel" -> a concept_id 0 record instead of an error (one row per input) +client$fhir$resolve(system = "http://snomed.info/sct", code = "00000000", on_unmapped = "sentinel") +``` + +Composite concepts (e.g. "Allergy to penicillin") additionally surface `resolution$value_as_concept` (the IG Value-as-Concept pattern). `on_unmapped` is accepted by `resolve()`, `resolve_batch()`, and `resolve_codeable_concept()`. + ### Tibble Output for Batch Resolution Pass `as_tibble = TRUE` to get a flat [`tibble`](https://tibble.tidyverse.org/) with one row per input coding - ready to pipe into `dplyr` / `tidyr`: @@ -209,7 +227,7 @@ tbl |> #> 3 J45.909 Asthma condition_occurrence ``` -The tibble columns are `source_system`, `source_code`, `source_concept_id`, `source_concept_name`, `standard_concept_id`, `standard_concept_name`, `standard_vocabulary_id`, `domain_id`, `target_table`, `mapping_type`, `similarity_score`, `status`, and `status_detail`. Failed rows stay in-place with `status = "failed"` and the API error text in `status_detail`. The batch summary (`total` / `resolved` / `failed`) is attached as `attr(tbl, "summary")`. +The tibble columns are `source_system`, `source_code`, `source_concept_id`, `source_concept_name`, `standard_concept_id`, `standard_concept_name`, `standard_vocabulary_id`, `domain_id`, `value_as_concept_id`, `value_as_concept_name`, `target_table`, `mapping_type`, `similarity_score`, `status`, and `status_detail`. Failed rows stay in-place with `status = "failed"` and the API error text in `status_detail`; a coding that resolves but has no standard target gets `status = "unmapped"` (`standard_concept_id = 0`). The batch summary (`total` / `resolved` / `failed`) is attached as `attr(tbl, "summary")`. Default `as_tibble = FALSE` still returns the legacy `list(results, summary)` shape. @@ -453,7 +471,7 @@ The package includes comprehensive examples in `inst/examples/`: | `navigate_hierarchy.R` | Hierarchy navigation - ancestors, descendants | | `map_between_vocabularies.R` | Cross-vocabulary mapping | | `error_handling.R` | Error handling patterns | -| `fhir_resolver.R` | FHIR Concept Resolver - single / batch / CodeableConcept, quality, recommendations | +| `fhir_resolver.R` | FHIR Concept Resolver - single / batch / CodeableConcept, quality, recommendations, administrative codes, `user_selected`, `on_unmapped` | | `fhir_interop.R` | 1.7.0 interop - tibble batch output, standalone wrappers, `omophub_fhir_url()` | Run an example: diff --git a/inst/examples/fhir_resolver.R b/inst/examples/fhir_resolver.R index 5067de1..3e44ec6 100644 --- a/inst/examples/fhir_resolver.R +++ b/inst/examples/fhir_resolver.R @@ -14,6 +14,9 @@ #' - Mapping quality signal #' - Batch resolution #' - CodeableConcept resolution with OHDSI vocabulary preference +#' - Administrative codes via the IG ConceptMap layer (gender, ...) +#' - user_selected coding precedence +#' - on_unmapped = "sentinel" (concept_id 0 instead of a 404) #' - Error handling #' #' For tibble-shaped batch output, standalone wrapper functions, and the @@ -284,10 +287,78 @@ if (!is.null(best)) { cat(" Failed codings:", length(result$unresolved), "\n\n") # ============================================================================ -# 12. Error handling +# 12. Administrative code via the IG ConceptMap layer (gender, encounter, ...) # ============================================================================ -cat("12. Error handling\n") +cat("12. Administrative gender (IG ConceptMap)\n") +cat("-----------------------------------------\n") + +result <- client$fhir$resolve( + system = "http://hl7.org/fhir/administrative-gender", + code = "male", + resource_type = "Patient" +) + +res <- result$resolution +cat(sprintf(" male -> %s (%s)\n", + res$standard_concept$concept_name, + res$standard_concept$concept_id)) +cat(" Target table: ", res$target_table, "\n") +cat(" Via IG ConceptMap:", res$concept_map_id %||% "N/A", "\n") +# Composite source concepts also surface value_as_concept (IG 'Maps to value'). +if (!is.null(res$value_as_concept)) { + cat(" Value concept: ", res$value_as_concept$concept_name, "\n") +} +cat("\n") + +# ============================================================================ +# 13. CodeableConcept with user_selected (overrides vocabulary preference) +# ============================================================================ + +cat("13. CodeableConcept with user_selected\n") +cat("--------------------------------------\n") + +result <- client$fhir$resolve_codeable_concept( + coding = list( + # SNOMED would normally win; the user-selected ICD-10-CM coding wins. + list(system = "http://snomed.info/sct", code = "44054006"), + list( + system = "http://hl7.org/fhir/sid/icd-10-cm", + code = "E11.9", + user_selected = TRUE + ) + ), + resource_type = "Condition" +) + +best <- result$best_match +if (!is.null(best)) { + cat(" best_match source:", best$resolution$source_concept$vocabulary_id, "\n") +} +cat("\n") + +# ============================================================================ +# 14. on_unmapped = "sentinel" (concept_id 0 record instead of a 404) +# ============================================================================ + +cat("14. on_unmapped = 'sentinel'\n") +cat("----------------------------\n") + +result <- client$fhir$resolve( + system = "http://snomed.info/sct", + code = "00000000", + on_unmapped = "sentinel" +) + +res <- result$resolution +cat(" Mapping type: ", res$mapping_type, "\n") +cat(" Standard concept_id:", res$standard_concept$concept_id, "\n\n") + +# ============================================================================ +# 15. Error handling +# ============================================================================ + +cat("15. Error handling\n") cat("------------------\n") # Restricted vocabulary (CPT4) -> 403 diff --git a/man/FhirResource.Rd b/man/FhirResource.Rd index feb9fc0..895eba8 100644 --- a/man/FhirResource.Rd +++ b/man/FhirResource.Rd @@ -77,7 +77,8 @@ or \code{display}. resource_type = NULL, include_recommendations = FALSE, recommendations_limit = 5L, - include_quality = FALSE + include_quality = FALSE, + on_unmapped = NULL )}\if{html}{\out{}} } @@ -99,6 +100,9 @@ or \code{display}. \item{\code{recommendations_limit}}{Integer. Max recommendations (1-20). Default \code{5L}.} \item{\code{include_quality}}{Logical. Include mapping quality signal. Default \code{FALSE}.} + +\item{\code{on_unmapped}}{Optional. \code{"error"} (default) returns a 404 when no +concept resolves; \code{"sentinel"} returns a \code{concept_id} 0 record instead.} } \if{html}{\out{}} } @@ -117,6 +121,7 @@ Failed items are reported inline without failing the batch. include_recommendations = FALSE, recommendations_limit = 5L, include_quality = FALSE, + on_unmapped = NULL, as_tibble = FALSE )}\if{html}{\out{}} } @@ -135,6 +140,9 @@ Failed items are reported inline without failing the batch. \item{\code{include_quality}}{Logical. Default \code{FALSE}.} +\item{\code{on_unmapped}}{Optional \code{"error"} (default) / \code{"sentinel"}; see +\code{resolve()}.} + \item{\code{as_tibble}}{Logical. When \code{TRUE}, returns a \link[tibble:tibble]{tibble::tibble} with one row per input coding and flat columns for the source concept, standard concept, target CDM table, mapping type, and @@ -161,7 +169,8 @@ via semantic search if no coding resolves. resource_type = NULL, include_recommendations = FALSE, recommendations_limit = 5L, - include_quality = FALSE + include_quality = FALSE, + on_unmapped = NULL )}\if{html}{\out{}} } @@ -180,6 +189,9 @@ and optional \code{display}.} \item{\code{recommendations_limit}}{Integer. Default \code{5L}.} \item{\code{include_quality}}{Logical. Default \code{FALSE}.} + +\item{\code{on_unmapped}}{Optional \code{"error"} (default) / \code{"sentinel"}; see +\code{resolve()}.} } \if{html}{\out{}} } diff --git a/tests/testthat/test-fhir.R b/tests/testthat/test-fhir.R index fdb6766..49f13e3 100644 --- a/tests/testthat/test-fhir.R +++ b/tests/testthat/test-fhir.R @@ -99,6 +99,39 @@ test_that("fhir$resolve calls correct endpoint with body", { # Optional flags should NOT be in body when FALSE expect_null(called_with$body$include_recommendations) expect_null(called_with$body$include_quality) + # on_unmapped omitted by default (API defaults to "error") + expect_null(called_with$body$on_unmapped) +}) + +test_that("fhir resolve methods forward on_unmapped when set", { + base_req <- httr2::request("https://api.omophub.com/v1") + resource <- FhirResource$new(base_req) + + called_with <- NULL + local_mocked_bindings( + perform_post = function(req, path, body = NULL, query = NULL) { + called_with <<- list(body = body) + mock_fhir_resolution() + } + ) + + resource$resolve( + system = "http://snomed.info/sct", code = "44054006", + on_unmapped = "sentinel" + ) + expect_equal(called_with$body$on_unmapped, "sentinel") + + resource$resolve_batch( + list(list(system = "http://snomed.info/sct", code = "44054006")), + on_unmapped = "sentinel" + ) + expect_equal(called_with$body$on_unmapped, "sentinel") + + resource$resolve_codeable_concept( + list(list(system = "http://snomed.info/sct", code = "44054006")), + on_unmapped = "sentinel" + ) + expect_equal(called_with$body$on_unmapped, "sentinel") }) test_that("fhir$resolve includes recommendation flags when requested", { @@ -340,7 +373,8 @@ test_that("resolve_batch returns tibble when as_tibble = TRUE", { "source_system", "source_code", "source_concept_id", "source_concept_name", "standard_concept_id", "standard_concept_name", - "standard_vocabulary_id", "domain_id", "target_table", + "standard_vocabulary_id", "domain_id", + "value_as_concept_id", "value_as_concept_name", "target_table", "mapping_type", "similarity_score", "status", "status_detail" ) %in% names(tbl))) @@ -359,6 +393,73 @@ test_that("resolve_batch returns tibble when as_tibble = TRUE", { expect_equal(summary$failed, 1L) }) +test_that("resolve_batch tibble surfaces value_as_concept (Maps to value)", { + base_req <- httr2::request("https://api.omophub.com/v1") + resource <- FhirResource$new(base_req) + + local_mocked_bindings( + perform_post = function(req, path, body = NULL, query = NULL) { + list( + results = list(list(resolution = list( + source_concept = list(concept_id = 4222295L, concept_name = "Allergy to penicillin"), + standard_concept = list( + concept_id = 439224L, concept_name = "Allergy to drug", + vocabulary_id = "SNOMED", domain_id = "Observation" + ), + value_as_concept = list(concept_id = 1728416L, concept_name = "Penicillin G"), + value_target_field = "value_as_concept_id", + mapping_type = "mapped", + target_table = "observation" + ))), + summary = list(total = 1L, resolved = 1L, failed = 0L) + ) + } + ) + + tbl <- resource$resolve_batch( + list(list(system = "http://snomed.info/sct", code = "294499007")), + as_tibble = TRUE + ) + + expect_equal(tbl$standard_concept_id[1], 439224L) + expect_equal(tbl$value_as_concept_id[1], 1728416L) + expect_equal(tbl$value_as_concept_name[1], "Penicillin G") +}) + +test_that("resolve_batch tibble flags concept_id 0 as status 'unmapped'", { + base_req <- httr2::request("https://api.omophub.com/v1") + resource <- FhirResource$new(base_req) + + local_mocked_bindings( + perform_post = function(req, path, body = NULL, query = NULL) { + list( + results = list(list(resolution = list( + source_concept = list( + concept_id = 45576876L, + concept_name = "Some non-standard code", + vocabulary_id = "ICD10CM" + ), + standard_concept = list(concept_id = 0L, concept_name = "No matching concept"), + mapping_type = "unmapped", + target_table = NULL + ))), + summary = list(total = 1L, resolved = 0L, failed = 0L) + ) + } + ) + + tbl <- resource$resolve_batch( + list(list(system = "http://hl7.org/fhir/sid/icd-10-cm", code = "E11.9")), + as_tibble = TRUE + ) + + # A concept_id 0 sentinel must not be reported as a successful resolution. + expect_equal(tbl$standard_concept_id[1], 0L) + expect_equal(tbl$status[1], "unmapped") + expect_false(is.na(tbl$status_detail[1])) + expect_equal(tbl$source_concept_id[1], 45576876L) +}) + test_that("resolve_batch default return is unchanged (list shape)", { base_req <- httr2::request("https://api.omophub.com/v1") resource <- FhirResource$new(base_req) diff --git a/vignettes/getting-started.Rmd b/vignettes/getting-started.Rmd index f4c8511..3ab3fa0 100644 --- a/vignettes/getting-started.Rmd +++ b/vignettes/getting-started.Rmd @@ -395,12 +395,32 @@ result <- client$fhir$resolve_codeable_concept( cat(result$best_match$resolution$source_concept$vocabulary_id) # "SNOMED" ``` +To override the vocabulary preference, mark a coding with `user_selected = TRUE` +(mirroring FHIR `Coding.userSelected`); that coding then wins `best_match` +regardless of its vocabulary: + +```r +client$fhir$resolve_codeable_concept( + coding = list( + list( + system = "http://hl7.org/fhir/sid/icd-10-cm", code = "E11.9", + user_selected = TRUE + ), + list(system = "http://snomed.info/sct", code = "44054006") + ), + resource_type = "Condition" +) +``` + ### Tibble Output for Batch Resolution For `dplyr` / `tidyr` workflows, pass `as_tibble = TRUE` to get a flat tibble with one row per input coding and columns for the source and standard concepts, target CDM table, mapping type, and resolution status. -This is the most ergonomic shape for ETL pipelines processing many codes: +Composite concepts decomposed via `Maps to value` (the HL7 FHIR-to-OMOP IG +Value-as-Concept pattern) also populate `value_as_concept_id` / +`value_as_concept_name`. This is the most ergonomic shape for ETL pipelines +processing many codes: ```{r fhir-batch-tibble} library(dplyr) From a0ab3d3ae86e2ae8eef72927df40b940d27d8350 Mon Sep 17 00:00:00 2001 From: alex-omophub Date: Mon, 25 May 2026 12:31:53 +0100 Subject: [PATCH 2/3] Refine unmapped concept handling in fhir_batch_to_tibble and add corresponding tests - Updated the logic in fhir_batch_to_tibble to correctly identify unmapped concepts, ensuring that only explicit concept_id 0 is labeled as "unmapped" while NA ids are treated as resolved. - Added a new test case to verify that a missing standard id does not mislabel the response as "unmapped", maintaining clarity in the output status. - Enhanced overall test coverage for the resolve_batch function to ensure accurate handling of malformed responses. --- R/fhir.R | 6 +++++- tests/testthat/test-fhir.R | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/R/fhir.R b/R/fhir.R index 4c3e2cc..ea90285 100644 --- a/R/fhir.R +++ b/R/fhir.R @@ -270,7 +270,11 @@ fhir_batch_to_tibble <- function(result, codings) { # concept"). Surface it as status "unmapped" so callers filtering # `status == "resolved"` don't mistake the sentinel for a real mapping. std_id <- std$concept_id %||% NA_integer_ - unmapped <- is.na(std_id) || std_id == 0L + # Only an explicit concept_id 0 is the OMOP "no matching concept" sentinel. + # A missing/NA id signals a malformed or partial response (not an unmapped + # code), so it stays "resolved" with an NA id rather than being mislabeled + # "unmapped" with a misleading "concept_id 0" detail. + unmapped <- !is.na(std_id) && std_id == 0L tibble::tibble( source_system = input_coding$system %||% NA_character_, diff --git a/tests/testthat/test-fhir.R b/tests/testthat/test-fhir.R index 49f13e3..7edbe0a 100644 --- a/tests/testthat/test-fhir.R +++ b/tests/testthat/test-fhir.R @@ -460,6 +460,41 @@ test_that("resolve_batch tibble flags concept_id 0 as status 'unmapped'", { expect_equal(tbl$source_concept_id[1], 45576876L) }) +test_that("resolve_batch tibble does not mislabel a missing standard id as 'unmapped'", { + base_req <- httr2::request("https://api.omophub.com/v1") + resource <- FhirResource$new(base_req) + + local_mocked_bindings( + perform_post = function(req, path, body = NULL, query = NULL) { + list( + # Malformed/partial resolution: standard_concept present but no + # concept_id. This must not be folded into the concept_id 0 sentinel. + results = list(list(resolution = list( + source_concept = list( + concept_id = 45576876L, + concept_name = "Some non-standard code", + vocabulary_id = "ICD10CM" + ), + standard_concept = list(concept_name = "Partial response"), + target_table = NULL + ))), + summary = list(total = 1L, resolved = 1L, failed = 0L) + ) + } + ) + + tbl <- resource$resolve_batch( + list(list(system = "http://hl7.org/fhir/sid/icd-10-cm", code = "E11.9")), + as_tibble = TRUE + ) + + # NA id is a malformed response, not the explicit 0 sentinel: it stays + # "resolved" (with an NA id) and never claims "concept_id 0". + expect_true(is.na(tbl$standard_concept_id[1])) + expect_equal(tbl$status[1], "resolved") + expect_true(is.na(tbl$status_detail[1])) +}) + test_that("resolve_batch default return is unchanged (list shape)", { base_req <- httr2::request("https://api.omophub.com/v1") resource <- FhirResource$new(base_req) From 1fc5a7e0d8c57f3360137441846435c892497749 Mon Sep 17 00:00:00 2001 From: alex-omophub Date: Mon, 25 May 2026 13:20:51 +0100 Subject: [PATCH 3/3] Update GitHub Actions workflows to use latest action versions - Upgraded actions/checkout from v4 to v6 across all workflows for improved performance and features. - Updated actions/upload-artifact from v4 to v7 in integration and test coverage workflows to leverage new capabilities. - Enhanced deployment action in pkgdown workflow by upgrading from JamesIves/github-pages-deploy-action@v4.5.0 to v4.8.0 for better compatibility and functionality. --- .github/workflows/R-CMD-check.yaml | 4 ++-- .github/workflows/integration-tests.yaml | 4 ++-- .github/workflows/pkgdown.yaml | 4 ++-- .github/workflows/test-coverage.yaml | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/R-CMD-check.yaml b/.github/workflows/R-CMD-check.yaml index d394966..d9e66b1 100644 --- a/.github/workflows/R-CMD-check.yaml +++ b/.github/workflows/R-CMD-check.yaml @@ -26,7 +26,7 @@ jobs: OMOPHUB_API_KEY: ${{ secrets.OMOPHUB_API_KEY }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: r-lib/actions/setup-pandoc@v2 @@ -89,7 +89,7 @@ jobs: OMOPHUB_API_KEY: ${{ secrets.OMOPHUB_API_KEY }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: r-lib/actions/setup-pandoc@v2 diff --git a/.github/workflows/integration-tests.yaml b/.github/workflows/integration-tests.yaml index cc823dc..c5da557 100644 --- a/.github/workflows/integration-tests.yaml +++ b/.github/workflows/integration-tests.yaml @@ -19,7 +19,7 @@ jobs: TEST_API_KEY: ${{ secrets.TEST_API_KEY }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: r-lib/actions/setup-r@v2 with: @@ -61,7 +61,7 @@ jobs: - name: Upload test results if: failure() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: integration-test-failures path: tests/testthat/ diff --git a/.github/workflows/pkgdown.yaml b/.github/workflows/pkgdown.yaml index 7dfebb1..a0dc9ea 100644 --- a/.github/workflows/pkgdown.yaml +++ b/.github/workflows/pkgdown.yaml @@ -20,7 +20,7 @@ jobs: contents: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: r-lib/actions/setup-pandoc@v2 @@ -40,7 +40,7 @@ jobs: - name: Deploy to GitHub pages if: github.event_name != 'pull_request' - uses: JamesIves/github-pages-deploy-action@v4.5.0 + uses: JamesIves/github-pages-deploy-action@v4.8.0 with: clean: false branch: gh-pages diff --git a/.github/workflows/test-coverage.yaml b/.github/workflows/test-coverage.yaml index d190c12..0d27d69 100644 --- a/.github/workflows/test-coverage.yaml +++ b/.github/workflows/test-coverage.yaml @@ -17,7 +17,7 @@ jobs: OMOPHUB_API_KEY: ${{ secrets.OMOPHUB_API_KEY }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: r-lib/actions/setup-r@v2 with: @@ -48,7 +48,7 @@ jobs: - name: Upload test results if: failure() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: coverage-test-failures path: ${{ runner.temp }}/package