From 9b3a85894ed08017897aa6779002cb50f511b83c Mon Sep 17 00:00:00 2001 From: alex-omophub Date: Mon, 7 Sep 2026 15:41:01 +0100 Subject: [PATCH 1/4] Enhance search functionality and documentation - Updated `autocomplete` and `similar` methods in `SearchResource` to use `page_size` instead of deprecated `max_suggestions` and `domains` parameters, improving clarity and consistency. - Added `preserve_pagination` option to `perform_post` to retain pagination information in responses. - Enhanced documentation for parameters and updated examples to reflect changes in method signatures. - Improved tests to validate the new parameter mappings and ensure backward compatibility with deprecated aliases. --- R/request.R | 20 ++++- R/search.R | 86 ++++++++++++++++---- README.md | 1 + man/SearchResource.Rd | 55 +++++++++---- tests/testthat/test-request-pagination.R | 80 +++++++++++++++++++ tests/testthat/test-search-integration.R | 6 +- tests/testthat/test-search.R | 99 ++++++++++++++++++------ 7 files changed, 288 insertions(+), 59 deletions(-) create mode 100644 tests/testthat/test-request-pagination.R diff --git a/R/request.R b/R/request.R index 2d86d4f..3859149 100644 --- a/R/request.R +++ b/R/request.R @@ -92,10 +92,18 @@ perform_get <- function(base_req, endpoint, query = NULL) { #' @param endpoint API endpoint path. #' @param body Named list for JSON body. #' @param query Named list of query parameters. +#' @param preserve_pagination If `TRUE`, copy `meta$pagination` from the +#' response envelope onto the returned list as a `pagination` element. +#' Paginated POST endpoints carry their pagination in `meta` while the results +#' sit in `data`, so unwrapping to `data` alone leaves the caller with a +#' `page` argument and no way to know whether another page exists. Added as an +#' element rather than changing the return shape, so existing accessors keep +#' working. #' #' @returns Parsed JSON response (unwrapped from `data` field if present). #' @keywords internal -perform_post <- function(base_req, endpoint, body = NULL, query = NULL) { +perform_post <- function(base_req, endpoint, body = NULL, query = NULL, + preserve_pagination = FALSE) { req <- base_req |> httr2::req_url_path_append(endpoint) |> httr2::req_method("POST") @@ -119,6 +127,16 @@ perform_post <- function(base_req, endpoint, body = NULL, query = NULL) { # Unwrap data field if present (matching Python SDK behavior) if (is.list(resp_body) && "data" %in% names(resp_body)) { + if (isTRUE(preserve_pagination)) { + pagination <- resp_body$meta$pagination + if (!is.null(pagination)) { + data <- resp_body$data + if (is.list(data)) { + data$pagination <- pagination + return(data) + } + } + } return(resp_body$data) } resp_body diff --git a/R/search.R b/R/search.R index 0c4a0a1..986f375 100644 --- a/R/search.R +++ b/R/search.R @@ -199,27 +199,48 @@ SearchResource <- R6::R6Class( #' #' @param query Partial query string. #' @param vocabulary_ids Filter by vocabulary IDs. - #' @param domains Filter by domains. - #' @param max_suggestions Maximum suggestions. Default 10. + #' @param domain_ids Filter by domain IDs. + #' @param page_size Maximum suggestions (1-20). Default 10. + #' @param domains Deprecated alias for `domain_ids`. + #' @param max_suggestions Deprecated alias for `page_size`. Ignored, with a + #' warning, when `page_size` is also supplied. #' #' @returns Autocomplete suggestions. autocomplete = function(query, vocabulary_ids = NULL, + domain_ids = NULL, + page_size = 10, domains = NULL, - max_suggestions = 10) { + max_suggestions = NULL) { checkmate::assert_string(query, min.chars = 1) - checkmate::assert_integerish(max_suggestions, lower = 1, len = 1, any.missing = FALSE) + # The canonical argument wins, matching how `domain_ids` beats `domains` + # below and how the Python SDK resolves the same pair. This used to + # overwrite page_size unconditionally, so a caller passing both got the + # deprecated value and no indication that the one they named was ignored. + if (!is.null(max_suggestions)) { + if (missing(page_size)) { + page_size <- max_suggestions + } else { + warning( + "Both `page_size` and the deprecated `max_suggestions` were given; ", + "using `page_size`.", + call. = FALSE + ) + } + } + checkmate::assert_integerish(page_size, lower = 1, upper = 20, len = 1, any.missing = FALSE) params <- list( query = query, - max_suggestions = as.integer(max_suggestions) + page_size = as.integer(page_size) ) if (!is.null(vocabulary_ids)) { params$vocabulary_ids <- join_params(vocabulary_ids) } - if (!is.null(domains)) { - params$domains <- join_params(domains) + selected_domains <- domain_ids %||% domains + if (!is.null(selected_domains)) { + params$domain_ids <- join_params(selected_domains) } perform_get(private$.base_req, "search/suggest", query = params) @@ -395,31 +416,49 @@ SearchResource <- R6::R6Class( #' @param concept_id Concept ID to find similar concepts for. #' @param concept_name Concept name to find similar concepts for. #' @param query Natural language query for semantic similarity. - #' @param algorithm One of 'semantic', 'lexical', or 'hybrid' (default). + #' @param algorithm One of 'semantic' (default), 'lexical', or 'hybrid'. #' @param similarity_threshold Minimum similarity (0.0-1.0). Default 0.7. - #' @param page_size Max results (max 1000). Default 20. + #' `0` is a valid value and is honoured. + #' @param page Page of the ranked candidate pool (1-based). Default 1. + #' @param page_size Results per page (max 1000). Default 20. #' @param vocabulary_ids Filter by vocabulary IDs. #' @param domain_ids Filter by domain IDs. + #' @param concept_class_ids Filter by concept class IDs. #' @param standard_concept Filter by standard concept flag ('S', 'C', or 'N'). - #' @param include_invalid Include invalid/deprecated concepts. - #' @param include_scores Include detailed similarity scores. - #' @param include_explanations Include similarity explanations. + #' 'N' selects non-standard concepts, which OMOP stores as a null column. + #' @param include_invalid Include invalid/deprecated concepts. Defaults to + #' FALSE, and supported only with algorithm='lexical' - the embedding + #' index holds valid concepts only, so the API returns 400 for the other + #' two rather than ignoring the filter. + #' @param include_scores Include `similarity_score` on each concept + #' (default TRUE). When FALSE the field is absent. + #' @param include_explanations Include an `explanation` on each concept. + #' @param exclude_self Exclude the reference concept from its own results + #' (default TRUE). #' - #' @returns List with similar_concepts and search_metadata. + #' @returns List with similar_concepts, search_metadata, a `pagination` + #' element carrying the response envelope's pagination, and, when the + #' search started from a concept_id, source_concept. #' - #' @note When algorithm='semantic', only single vocabulary/domain filter supported. + #' @note Every algorithm ranks a bounded candidate pool, so the totals can + #' be lower bounds - `search_metadata$totals_are_lower_bound` says when. + #' Page while `has_next` is TRUE rather than comparing page to + #' total_pages. similar = function(concept_id = NULL, concept_name = NULL, query = NULL, - algorithm = "hybrid", + algorithm = "semantic", similarity_threshold = 0.7, + page = 1, page_size = 20, vocabulary_ids = NULL, domain_ids = NULL, + concept_class_ids = NULL, standard_concept = NULL, include_invalid = NULL, include_scores = NULL, - include_explanations = NULL) { + include_explanations = NULL, + exclude_self = NULL) { # Validate exactly one of concept_id, concept_name, or query provided provided <- sum(!is.null(concept_id), !is.null(concept_name), !is.null(query)) if (provided != 1) { @@ -430,6 +469,7 @@ SearchResource <- R6::R6Class( checkmate::assert_choice(algorithm, c("semantic", "lexical", "hybrid")) checkmate::assert_number(similarity_threshold, lower = 0, upper = 1) + checkmate::assert_integerish(page, lower = 1) checkmate::assert_integerish(page_size, lower = 1, upper = 1000) if (!is.null(concept_id)) { checkmate::assert_integerish(concept_id, len = 1, any.missing = FALSE) @@ -449,6 +489,9 @@ SearchResource <- R6::R6Class( if (!is.null(query)) { body$query <- query } + if (page != 1) { + body$page <- as.integer(page) + } if (page_size != 20) { body$page_size <- as.integer(page_size) } @@ -458,6 +501,9 @@ SearchResource <- R6::R6Class( if (!is.null(domain_ids)) { body$domain_ids <- as.list(domain_ids) } + if (!is.null(concept_class_ids)) { + body$concept_class_ids <- as.list(concept_class_ids) + } if (!is.null(standard_concept)) { checkmate::assert_choice(standard_concept, c("S", "C", "N")) body$standard_concept <- standard_concept @@ -471,8 +517,14 @@ SearchResource <- R6::R6Class( if (!is.null(include_explanations)) { body$include_explanations <- include_explanations } + if (!is.null(exclude_self)) { + body$exclude_self <- exclude_self + } - perform_post(private$.base_req, "search/similar", body = body) + perform_post( + private$.base_req, "search/similar", + body = body, preserve_pagination = TRUE + ) }, #' @description diff --git a/README.md b/README.md index 853b507..bcb2a05 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,7 @@ results <- client$search$semantic( all_results <- client$search$semantic_all("chronic kidney disease", page_size = 50) # Find concepts similar to a reference concept +# `algorithm` defaults to "semantic"; "lexical" and "hybrid" are also available. similar <- client$search$similar(concept_id = 201826, algorithm = "hybrid") for (s in similar$similar_concepts) { cat(sprintf("%s (score: %.2f)\n", s$concept_name, s$similarity_score)) diff --git a/man/SearchResource.Rd b/man/SearchResource.Rd index c8f7ede..07f112e 100644 --- a/man/SearchResource.Rd +++ b/man/SearchResource.Rd @@ -22,13 +22,17 @@ List with \code{results} (per-search), \code{total_searches}, List with \code{results} (per-search), \code{total_searches}, \code{completed_count}, \code{failed_count}, \code{total_duration}. -List with similar_concepts and search_metadata. +List with similar_concepts, search_metadata and, when the search +started from a concept_id, source_concept. } \description{ R6 class providing access to search operations. } \note{ -When algorithm='semantic', only single vocabulary/domain filter supported. +Every algorithm ranks a bounded candidate pool, so the totals can +be lower bounds - \code{search_metadata$totals_are_lower_bound} says when. +Page while \code{has_next} is TRUE rather than comparing page to +total_pages. } \keyword{internal} \section{Methods}{ @@ -209,8 +213,10 @@ Get autocomplete suggestions. \if{html}{\out{
}}\preformatted{SearchResource$autocomplete( query, vocabulary_ids = NULL, + domain_ids = NULL, + page_size = 10, domains = NULL, - max_suggestions = 10 + max_suggestions = NULL )}\if{html}{\out{
}} } @@ -221,9 +227,14 @@ Get autocomplete suggestions. \item{\code{vocabulary_ids}}{Filter by vocabulary IDs.} -\item{\code{domains}}{Filter by domains.} +\item{\code{domain_ids}}{Filter by domain IDs.} + +\item{\code{page_size}}{Maximum suggestions (1-20). Default 10.} -\item{\code{max_suggestions}}{Maximum suggestions. Default 10.} +\item{\code{domains}}{Deprecated alias for \code{domain_ids}.} + +\item{\code{max_suggestions}}{Deprecated alias for \code{page_size}. Ignored, with a +warning, when \code{page_size} is also supplied.} } \if{html}{\out{}} } @@ -375,15 +386,18 @@ Must provide exactly one of: concept_id, concept_name, or query. concept_id = NULL, concept_name = NULL, query = NULL, - algorithm = "hybrid", + algorithm = "semantic", similarity_threshold = 0.7, + page = 1, page_size = 20, vocabulary_ids = NULL, domain_ids = NULL, + concept_class_ids = NULL, standard_concept = NULL, include_invalid = NULL, include_scores = NULL, - include_explanations = NULL + include_explanations = NULL, + exclude_self = NULL )}\if{html}{\out{}} } @@ -396,23 +410,36 @@ Must provide exactly one of: concept_id, concept_name, or query. \item{\code{query}}{Natural language query for semantic similarity.} -\item{\code{algorithm}}{One of 'semantic', 'lexical', or 'hybrid' (default).} +\item{\code{algorithm}}{One of 'semantic' (default), 'lexical', or 'hybrid'.} + +\item{\code{similarity_threshold}}{Minimum similarity (0.0-1.0). Default 0.7. +\code{0} is a valid value and is honoured.} -\item{\code{similarity_threshold}}{Minimum similarity (0.0-1.0). Default 0.7.} +\item{\code{page}}{Page of the ranked candidate pool (1-based). Default 1.} -\item{\code{page_size}}{Max results (max 1000). Default 20.} +\item{\code{page_size}}{Results per page (max 1000). Default 20.} \item{\code{vocabulary_ids}}{Filter by vocabulary IDs.} \item{\code{domain_ids}}{Filter by domain IDs.} -\item{\code{standard_concept}}{Filter by standard concept flag ('S', 'C', or 'N').} +\item{\code{concept_class_ids}}{Filter by concept class IDs.} + +\item{\code{standard_concept}}{Filter by standard concept flag ('S', 'C', or 'N'). +'N' selects non-standard concepts, which OMOP stores as a null column.} + +\item{\code{include_invalid}}{Include invalid/deprecated concepts. Defaults to +FALSE, and supported only with algorithm='lexical' - the embedding +index holds valid concepts only, so the API returns 400 for the other +two rather than ignoring the filter.} -\item{\code{include_invalid}}{Include invalid/deprecated concepts.} +\item{\code{include_scores}}{Include \code{similarity_score} on each concept +(default TRUE). When FALSE the field is absent.} -\item{\code{include_scores}}{Include detailed similarity scores.} +\item{\code{include_explanations}}{Include an \code{explanation} on each concept.} -\item{\code{include_explanations}}{Include similarity explanations.} +\item{\code{exclude_self}}{Exclude the reference concept from its own results +(default TRUE).} } \if{html}{\out{}} } diff --git a/tests/testthat/test-request-pagination.R b/tests/testthat/test-request-pagination.R new file mode 100644 index 0000000..d964766 --- /dev/null +++ b/tests/testthat/test-request-pagination.R @@ -0,0 +1,80 @@ +# Tests for pagination preservation in perform_post (R/request.R) +# +# `/v1/search/similar` is paginated, but its pagination sits in the response +# envelope's `meta` while the results sit in `data`. `perform_post` unwrapped to +# `data` unconditionally, so a caller who passed `page` had no way to learn +# whether another page existed. `perform_get` already preserved it; this brings +# the POST path into line, opt-in so no other resource's return shape changes. + +json_response <- function(body) { + httr2::response( + status_code = 200, + headers = list(`content-type` = "application/json"), + body = charToRaw(jsonlite::toJSON(body, auto_unbox = TRUE)) + ) +} + +test_that("perform_post preserves pagination when asked", { + httr2::local_mocked_responses(list(json_response(list( + success = TRUE, + data = list(similar_concepts = list(), search_metadata = list()), + meta = list(pagination = list( + page = 2, page_size = 20, total_items = 55, + total_pages = 3, has_next = TRUE, has_previous = TRUE + )) + )))) + + result <- perform_post( + httr2::request("https://api.omophub.com/v1"), "search/similar", + body = list(concept_id = 201826), preserve_pagination = TRUE + ) + + expect_true(result$pagination$has_next) + expect_equal(result$pagination$page, 2) + # The existing shape is unchanged. + expect_true("similar_concepts" %in% names(result)) +}) + +test_that("perform_post unwraps to data by default", { + httr2::local_mocked_responses(list(json_response(list( + success = TRUE, + data = list(similar_concepts = list(), search_metadata = list()), + meta = list(pagination = list(page = 1, has_next = FALSE)) + )))) + + result <- perform_post( + httr2::request("https://api.omophub.com/v1"), "search/similar", + body = list(concept_id = 201826) + ) + + # Every other POST resource keeps the shape it had. + expect_null(result$pagination) +}) + +test_that("perform_post adds no pagination element when meta carries none", { + httr2::local_mocked_responses(list(json_response(list( + success = TRUE, + data = list(similar_concepts = list(), search_metadata = list()) + )))) + + result <- perform_post( + httr2::request("https://api.omophub.com/v1"), "search/similar", + body = list(concept_id = 201826), preserve_pagination = TRUE + ) + + expect_null(result$pagination) +}) + +test_that("search$similar exposes pagination end to end", { + httr2::local_mocked_responses(list(json_response(list( + success = TRUE, + data = list(similar_concepts = list(), search_metadata = list()), + meta = list(pagination = list(page = 3, has_next = FALSE)) + )))) + + resource <- SearchResource$new(httr2::request("https://api.omophub.com/v1")) + result <- resource$similar(concept_id = 201826, page = 3) + + expect_equal(result$pagination$page, 3) + expect_false(result$pagination$has_next) +}) diff --git a/tests/testthat/test-search-integration.R b/tests/testthat/test-search-integration.R index 1572289..30350bb 100644 --- a/tests/testthat/test-search-integration.R +++ b/tests/testthat/test-search-integration.R @@ -98,7 +98,7 @@ test_that("autocomplete works", { result <- client$search$autocomplete( "diab", - max_suggestions = 10 + page_size = 10 ) suggestions <- extract_data(result, "suggestions") @@ -126,8 +126,8 @@ test_that("autocomplete with filters works", { result <- client$search$autocomplete( "hyper", vocabulary_ids = "SNOMED", - domains = "Condition", - max_suggestions = 5 + domain_ids = "Condition", + page_size = 5 ) suggestions <- extract_data(result, "suggestions") diff --git a/tests/testthat/test-search.R b/tests/testthat/test-search.R index 3c44ad3..de338b1 100644 --- a/tests/testthat/test-search.R +++ b/tests/testthat/test-search.R @@ -254,7 +254,7 @@ test_that("search$advanced calls correct endpoint with body", { called_with <- NULL local_mocked_bindings( - perform_post = function(req, path, body = NULL) { + perform_post = function(req, path, body = NULL, ...) { called_with <<- list(path = path, body = body) list(results = list(), facets = list()) } @@ -272,7 +272,7 @@ test_that("search$advanced includes filters", { called_with <- NULL local_mocked_bindings( - perform_post = function(req, path, body = NULL) { + perform_post = function(req, path, body = NULL, ...) { called_with <<- list(body = body) list(results = list()) } @@ -300,7 +300,7 @@ test_that("search$advanced includes pagination params", { called_with <- NULL local_mocked_bindings( - perform_post = function(req, path, body = NULL) { + perform_post = function(req, path, body = NULL, ...) { called_with <<- list(body = body) list(results = list()) } @@ -318,7 +318,7 @@ test_that("search$advanced omits default page_size", { called_with <- NULL local_mocked_bindings( - perform_post = function(req, path, body = NULL) { + perform_post = function(req, path, body = NULL, ...) { called_with <<- list(body = body) list(results = list()) } @@ -352,11 +352,11 @@ test_that("search$autocomplete calls correct endpoint", { } ) - resource$autocomplete("diab", max_suggestions = 5) + resource$autocomplete("diab", page_size = 5) expect_equal(called_with$path, "search/suggest") expect_equal(called_with$query$query, "diab") - expect_equal(called_with$query$max_suggestions, 5L) + expect_equal(called_with$query$page_size, 5L) }) test_that("search$autocomplete includes filters", { @@ -374,11 +374,59 @@ test_that("search$autocomplete includes filters", { resource$autocomplete( "diab", vocabulary_ids = c("SNOMED"), - domains = c("Condition", "Drug") + domain_ids = c("Condition", "Drug") ) expect_equal(called_with$query$vocabulary_ids, "SNOMED") - expect_equal(called_with$query$domains, "Condition,Drug") + expect_equal(called_with$query$domain_ids, "Condition,Drug") + expect_null(called_with$query$domains) +}) + +test_that("search$autocomplete maps deprecated aliases to canonical parameters", { + base_req <- httr2::request("https://api.omophub.com/v1") + resource <- SearchResource$new(base_req) + + called_with <- NULL + local_mocked_bindings( + perform_get = function(req, path, query = NULL) { + called_with <<- list(query = query) + list(suggestions = list()) + } + ) + + resource$autocomplete( + "diab", + domains = c("Condition"), + max_suggestions = 5 + ) + + expect_equal(called_with$query$domain_ids, "Condition") + expect_equal(called_with$query$page_size, 5L) + expect_null(called_with$query$domains) + expect_null(called_with$query$max_suggestions) +}) + +test_that("search$autocomplete prefers page_size over max_suggestions", { + # The deprecated alias used to overwrite page_size unconditionally, so a + # caller who passed both got the value they were migrating away from and no + # indication that the argument they named had been ignored. + base_req <- httr2::request("https://api.omophub.com/v1") + resource <- SearchResource$new(base_req) + + called_with <- NULL + local_mocked_bindings( + perform_get = function(req, path, query = NULL) { + called_with <<- list(query = query) + list(suggestions = list()) + } + ) + + expect_warning( + resource$autocomplete("diab", page_size = 3, max_suggestions = 5), + "using `page_size`" + ) + + expect_equal(called_with$query$page_size, 3L) }) # ============================================================================== @@ -602,7 +650,7 @@ test_that("search$similar by concept_id calls correct endpoint", { called_with <- NULL local_mocked_bindings( - perform_post = function(req, path, body = NULL) { + perform_post = function(req, path, body = NULL, ...) { called_with <<- list(path = path, body = body) list(similar_concepts = list(), search_metadata = list()) } @@ -612,7 +660,10 @@ test_that("search$similar by concept_id calls correct endpoint", { expect_equal(called_with$path, "search/similar") expect_equal(called_with$body$concept_id, 4329847L) - expect_equal(called_with$body$algorithm, "hybrid") + # The API's documented default. The SDK used to send "hybrid", so a caller + # who omitted `algorithm` got a different algorithm depending on which + # client they used. + expect_equal(called_with$body$algorithm, "semantic") expect_equal(called_with$body$similarity_threshold, 0.7) }) @@ -622,7 +673,7 @@ test_that("search$similar by concept_name works", { called_with <- NULL local_mocked_bindings( - perform_post = function(req, path, body = NULL) { + perform_post = function(req, path, body = NULL, ...) { called_with <<- list(body = body) list(similar_concepts = list(), search_metadata = list()) } @@ -639,7 +690,7 @@ test_that("search$similar by query works", { called_with <- NULL local_mocked_bindings( - perform_post = function(req, path, body = NULL) { + perform_post = function(req, path, body = NULL, ...) { called_with <<- list(body = body) list(similar_concepts = list(), search_metadata = list()) } @@ -656,7 +707,7 @@ test_that("search$similar includes algorithm option", { called_with <- NULL local_mocked_bindings( - perform_post = function(req, path, body = NULL) { + perform_post = function(req, path, body = NULL, ...) { called_with <<- list(body = body) list(similar_concepts = list(), search_metadata = list()) } @@ -673,7 +724,7 @@ test_that("search$similar includes similarity_threshold", { called_with <- NULL local_mocked_bindings( - perform_post = function(req, path, body = NULL) { + perform_post = function(req, path, body = NULL, ...) { called_with <<- list(body = body) list(similar_concepts = list(), search_metadata = list()) } @@ -690,7 +741,7 @@ test_that("search$similar includes vocabulary_ids as list", { called_with <- NULL local_mocked_bindings( - perform_post = function(req, path, body = NULL) { + perform_post = function(req, path, body = NULL, ...) { called_with <<- list(body = body) list(similar_concepts = list(), search_metadata = list()) } @@ -708,7 +759,7 @@ test_that("search$similar includes domain_ids as list", { called_with <- NULL local_mocked_bindings( - perform_post = function(req, path, body = NULL) { + perform_post = function(req, path, body = NULL, ...) { called_with <<- list(body = body) list(similar_concepts = list(), search_metadata = list()) } @@ -725,7 +776,7 @@ test_that("search$similar includes boolean options", { called_with <- NULL local_mocked_bindings( - perform_post = function(req, path, body = NULL) { + perform_post = function(req, path, body = NULL, ...) { called_with <<- list(body = body) list(similar_concepts = list(), search_metadata = list()) } @@ -771,7 +822,7 @@ test_that("search$similar omits default page_size", { called_with <- NULL local_mocked_bindings( - perform_post = function(req, path, body = NULL) { + perform_post = function(req, path, body = NULL, ...) { called_with <<- list(body = body) list(similar_concepts = list(), search_metadata = list()) } @@ -788,7 +839,7 @@ test_that("search$similar includes non-default page_size", { called_with <- NULL local_mocked_bindings( - perform_post = function(req, path, body = NULL) { + perform_post = function(req, path, body = NULL, ...) { called_with <<- list(body = body) list(similar_concepts = list(), search_metadata = list()) } @@ -830,7 +881,7 @@ test_that("search$similar accepts standard_concept N", { called_with <- NULL local_mocked_bindings( - perform_post = function(req, path, body = NULL) { + perform_post = function(req, path, body = NULL, ...) { called_with <<- list(body = body) list(similar_concepts = list(), search_metadata = list()) } @@ -934,7 +985,7 @@ test_that("search$bulk_basic calls correct endpoint", { called_with <- NULL local_mocked_bindings( - perform_post = function(req, path, body = NULL) { + perform_post = function(req, path, body = NULL, ...) { called_with <<- list(path = path, body = body) list(results = list(), total_searches = 0, completed_searches = 0, failed_searches = 0) } @@ -957,7 +1008,7 @@ test_that("search$bulk_basic passes defaults", { called_with <- NULL local_mocked_bindings( - perform_post = function(req, path, body = NULL) { + perform_post = function(req, path, body = NULL, ...) { called_with <<- list(path = path, body = body) list(results = list(), total_searches = 1, completed_searches = 1, failed_searches = 0) } @@ -991,7 +1042,7 @@ test_that("search$bulk_semantic calls correct endpoint", { called_with <- NULL local_mocked_bindings( - perform_post = function(req, path, body = NULL) { + perform_post = function(req, path, body = NULL, ...) { called_with <<- list(path = path, body = body) list(results = list(), total_searches = 0, completed_count = 0, failed_count = 0) } @@ -1012,7 +1063,7 @@ test_that("search$bulk_semantic passes defaults", { called_with <- NULL local_mocked_bindings( - perform_post = function(req, path, body = NULL) { + perform_post = function(req, path, body = NULL, ...) { called_with <<- list(path = path, body = body) list(results = list(), total_searches = 1, completed_count = 1, failed_count = 0) } From 42969dd0a88153196ede650c784faa976a64b956 Mon Sep 17 00:00:00 2001 From: alex-omophub Date: Mon, 7 Sep 2026 16:41:00 +0100 Subject: [PATCH 2/4] Update package to version 1.19.1 with new features and bug fixes - Introduced pagination support for similarity search and enhanced response metadata. - Updated autocomplete functionality to utilize `domain_ids` and `page_size`, while maintaining compatibility with previous aliases. - Fixed bugs related to the `include_invalid` parameter in mapping requests and improved documentation for autocomplete responses. - Added `jsonlite` as a test-only dependency and regenerated package manuals to reflect current source. --- DESCRIPTION | 3 ++- NEWS.md | 28 ++++++++++++++++++++++++++++ R/mappings.R | 11 ++++++----- R/search.R | 5 ++++- README.md | 14 ++++++++++++++ man/MappingsResource.Rd | 6 ++++-- man/SearchResource.Rd | 10 +++++++--- man/perform_post.Rd | 16 +++++++++++++++- tests/testthat/test-mappings.R | 1 + tests/testthat/test-search.R | 22 ++++++++++++++++++++-- 10 files changed, 101 insertions(+), 15 deletions(-) diff --git a/DESCRIPTION b/DESCRIPTION index 91855ba..8900305 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,6 +1,6 @@ Package: omophub Title: R Client for the 'OMOPHub' Medical Vocabulary API -Version: 1.9.0 +Version: 1.19.1 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")) @@ -32,6 +32,7 @@ Suggests: webmockr, knitr, rmarkdown, + jsonlite, keyring, withr Config/testthat/edition: 3 diff --git a/NEWS.md b/NEWS.md index 83e61eb..c945c88 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,3 +1,31 @@ +# omophub 1.19.1 + +## New Features + +* Similarity search now supports pagination and the API's complete filter and + response metadata, including `source_concept` and lower-bound totals. + +## Changed + +* Autocomplete now uses `domain_ids` and `page_size`, while retaining warned + compatibility aliases for `domains` and `max_suggestions`. Similarity search + now uses the API's `semantic` default algorithm. + +## Bug Fixes + +* **`include_invalid = FALSE` never reached the server** on + `client$mappings$map()`. The method now sends its documented default + explicitly. Mapping documentation also describes `unmapped_sources`, which + reports every input that was not mapped and why. + +* **Autocomplete documentation and tests used an incomplete response shape.** + They now document and exercise all seven fields returned for each suggestion. + +## Maintenance + +* Declared the test-only `jsonlite` dependency and regenerated package manuals + so their signatures match the current source. + # omophub 1.9.0 ## New Features diff --git a/R/mappings.R b/R/mappings.R index ff02170..ad5c150 100644 --- a/R/mappings.R +++ b/R/mappings.R @@ -151,10 +151,12 @@ MappingsResource <- R6::R6Class( #' @param source_codes List of vocabulary/code pairs to map. Each element should be a list #' with `vocabulary_id` and `concept_code`. Use this OR source_concepts, not both. #' @param mapping_type Mapping type filter (direct, equivalent, broader, narrower). - #' @param include_invalid Include invalid mappings. Default `FALSE`. + #' @param include_invalid Include invalid mappings. Default `FALSE`; the + #' value is always sent explicitly. #' @param vocab_release Specific vocabulary release version (e.g., "2025.1"). Default `NULL`. #' - #' @returns Mapping results with summary. + #' @returns Mapping results with `mappings`, per-input `unmapped_sources`, + #' and a `summary` of requested, mapped, and unmapped sources. map = function(target_vocabulary, source_concepts = NULL, source_codes = NULL, @@ -197,9 +199,8 @@ MappingsResource <- R6::R6Class( if (!is.null(mapping_type)) { body$mapping_type <- mapping_type } - if (isTRUE(include_invalid)) { - body$include_invalid <- TRUE - } + checkmate::assert_flag(include_invalid) + body$include_invalid <- include_invalid query <- list() if (!is.null(vocab_release)) { diff --git a/R/search.R b/R/search.R index 986f375..3fcdb67 100644 --- a/R/search.R +++ b/R/search.R @@ -205,7 +205,10 @@ SearchResource <- R6::R6Class( #' @param max_suggestions Deprecated alias for `page_size`. Ignored, with a #' warning, when `page_size` is also supplied. #' - #' @returns Autocomplete suggestions. + #' @returns A list containing `query` and `suggestions`. Each suggestion is + #' a flat list with `suggestion`, `concept_id`, `concept_code`, + #' `vocabulary_id`, `domain_id`, `concept_class_id`, and + #' `standard_concept`. autocomplete = function(query, vocabulary_ids = NULL, domain_ids = NULL, diff --git a/README.md b/README.md index bcb2a05..a5d7034 100644 --- a/README.md +++ b/README.md @@ -328,6 +328,20 @@ validate_and_map <- function(source_vocab, source_code) { standard_id <- validate_and_map("ICD10CM", "E11.9") ``` +Map several native codes in one request with `client$mappings$map()`. Inputs +that do not produce a mapping are preserved in `unmapped_sources` with a +`source_not_found` or `no_mapping_found` reason. + +```r +result <- client$mappings$map( + target_vocabulary = "SNOMED", + source_codes = list(list(vocabulary_id = "ICD10CM", concept_code = "E11.9")) +) + +result$summary +result$unmapped_sources +``` + ### Data Quality Checks Verify codes exist and are valid: diff --git a/man/MappingsResource.Rd b/man/MappingsResource.Rd index dbc786b..2faa5d9 100644 --- a/man/MappingsResource.Rd +++ b/man/MappingsResource.Rd @@ -9,7 +9,8 @@ the \code{pagination} attribute. A tibble of all mappings for the concept. -Mapping results with summary. +Mapping results with \code{mappings}, per-input \code{unmapped_sources}, +and a \code{summary} of requested, mapped, and unmapped sources. } \description{ R6 class providing access to mapping operations. @@ -168,7 +169,8 @@ with \code{vocabulary_id} and \code{concept_code}. Use this OR source_concepts, \item{\code{mapping_type}}{Mapping type filter (direct, equivalent, broader, narrower).} -\item{\code{include_invalid}}{Include invalid mappings. Default \code{FALSE}.} +\item{\code{include_invalid}}{Include invalid mappings. Default \code{FALSE}; the +value is always sent explicitly.} \item{\code{vocab_release}}{Specific vocabulary release version (e.g., "2025.1"). Default \code{NULL}.} } diff --git a/man/SearchResource.Rd b/man/SearchResource.Rd index 07f112e..0df989c 100644 --- a/man/SearchResource.Rd +++ b/man/SearchResource.Rd @@ -10,7 +10,10 @@ A tibble of all matching concepts. Search results with facets and metadata. -Autocomplete suggestions. +A list containing \code{query} and \code{suggestions}. Each suggestion is +a flat list with \code{suggestion}, \code{concept_id}, \code{concept_code}, +\code{vocabulary_id}, \code{domain_id}, \code{concept_class_id}, and +\code{standard_concept}. List with results and pagination metadata. @@ -22,8 +25,9 @@ List with \code{results} (per-search), \code{total_searches}, List with \code{results} (per-search), \code{total_searches}, \code{completed_count}, \code{failed_count}, \code{total_duration}. -List with similar_concepts, search_metadata and, when the search -started from a concept_id, source_concept. +List with similar_concepts, search_metadata, a \code{pagination} +element carrying the response envelope's pagination, and, when the +search started from a concept_id, source_concept. } \description{ R6 class providing access to search operations. diff --git a/man/perform_post.Rd b/man/perform_post.Rd index e30f9dc..559b906 100644 --- a/man/perform_post.Rd +++ b/man/perform_post.Rd @@ -4,7 +4,13 @@ \alias{perform_post} \title{Perform POST Request} \usage{ -perform_post(base_req, endpoint, body = NULL, query = NULL) +perform_post( + base_req, + endpoint, + body = NULL, + query = NULL, + preserve_pagination = FALSE +) } \arguments{ \item{base_req}{Base request object.} @@ -14,6 +20,14 @@ perform_post(base_req, endpoint, body = NULL, query = NULL) \item{body}{Named list for JSON body.} \item{query}{Named list of query parameters.} + +\item{preserve_pagination}{If \code{TRUE}, copy \code{meta$pagination} from the +response envelope onto the returned list as a \code{pagination} element. +Paginated POST endpoints carry their pagination in \code{meta} while the results +sit in \code{data}, so unwrapping to \code{data} alone leaves the caller with a +\code{page} argument and no way to know whether another page exists. Added as an +element rather than changing the return shape, so existing accessors keep +working.} } \value{ Parsed JSON response (unwrapped from \code{data} field if present). diff --git a/tests/testthat/test-mappings.R b/tests/testthat/test-mappings.R index f74cd1a..d302a15 100644 --- a/tests/testthat/test-mappings.R +++ b/tests/testthat/test-mappings.R @@ -385,6 +385,7 @@ test_that("mappings$map calls correct endpoint with body", { expect_equal(called_with$path, "concepts/map") expect_equal(called_with$body$source_concepts, c(201826L, 12345L)) expect_equal(called_with$body$target_vocabulary, "ICD10CM") + expect_false(called_with$body$include_invalid) }) test_that("mappings$map includes optional parameters", { diff --git a/tests/testthat/test-search.R b/tests/testthat/test-search.R index de338b1..48026d2 100644 --- a/tests/testthat/test-search.R +++ b/tests/testthat/test-search.R @@ -345,18 +345,36 @@ test_that("search$autocomplete calls correct endpoint", { resource <- SearchResource$new(base_req) called_with <- NULL + fixture <- list( + suggestion = "Type 2 diabetes mellitus", + concept_id = 201826L, + concept_code = "44054006", + vocabulary_id = "SNOMED", + domain_id = "Condition", + concept_class_id = "Clinical Finding", + standard_concept = "S" + ) local_mocked_bindings( perform_get = function(req, path, query = NULL) { called_with <<- list(path = path, query = query) - list(suggestions = list()) + list(query = "diab", suggestions = list(fixture)) } ) - resource$autocomplete("diab", page_size = 5) + result <- resource$autocomplete("diab", page_size = 5) expect_equal(called_with$path, "search/suggest") expect_equal(called_with$query$query, "diab") expect_equal(called_with$query$page_size, 5L) + expect_equal(result$query, "diab") + expect_equal( + names(result$suggestions[[1]]), + c( + "suggestion", "concept_id", "concept_code", "vocabulary_id", + "domain_id", "concept_class_id", "standard_concept" + ) + ) + expect_equal(result$suggestions[[1]], fixture) }) test_that("search$autocomplete includes filters", { From e0d5a80a0fa75640bd6503932efed2ba31a1fac5 Mon Sep 17 00:00:00 2001 From: alex-omophub Date: Mon, 7 Sep 2026 21:36:28 +0100 Subject: [PATCH 3/4] Update SearchResource documentation and tests for pagination parameters - Reintroduced `page` and `concept_class_ids` parameters in the documentation for clarity. - Enhanced validation in the `similar` method to ensure a single non-missing page value is provided. - Updated tests to confirm the correct handling of pagination and maintain consistency with method signatures. --- R/search.R | 10 ++++----- man/SearchResource.Rd | 12 +++++------ tests/testthat/test-request-pagination.R | 16 +++++++++----- tests/testthat/test-search.R | 27 ++++++++++++++++++++++++ 4 files changed, 49 insertions(+), 16 deletions(-) diff --git a/R/search.R b/R/search.R index 3fcdb67..3b8c2bb 100644 --- a/R/search.R +++ b/R/search.R @@ -422,11 +422,9 @@ SearchResource <- R6::R6Class( #' @param algorithm One of 'semantic' (default), 'lexical', or 'hybrid'. #' @param similarity_threshold Minimum similarity (0.0-1.0). Default 0.7. #' `0` is a valid value and is honoured. - #' @param page Page of the ranked candidate pool (1-based). Default 1. #' @param page_size Results per page (max 1000). Default 20. #' @param vocabulary_ids Filter by vocabulary IDs. #' @param domain_ids Filter by domain IDs. - #' @param concept_class_ids Filter by concept class IDs. #' @param standard_concept Filter by standard concept flag ('S', 'C', or 'N'). #' 'N' selects non-standard concepts, which OMOP stores as a null column. #' @param include_invalid Include invalid/deprecated concepts. Defaults to @@ -436,6 +434,8 @@ SearchResource <- R6::R6Class( #' @param include_scores Include `similarity_score` on each concept #' (default TRUE). When FALSE the field is absent. #' @param include_explanations Include an `explanation` on each concept. + #' @param page Page of the ranked candidate pool (1-based). Default 1. + #' @param concept_class_ids Filter by concept class IDs. #' @param exclude_self Exclude the reference concept from its own results #' (default TRUE). #' @@ -452,15 +452,15 @@ SearchResource <- R6::R6Class( query = NULL, algorithm = "semantic", similarity_threshold = 0.7, - page = 1, page_size = 20, vocabulary_ids = NULL, domain_ids = NULL, - concept_class_ids = NULL, standard_concept = NULL, include_invalid = NULL, include_scores = NULL, include_explanations = NULL, + page = 1, + concept_class_ids = NULL, exclude_self = NULL) { # Validate exactly one of concept_id, concept_name, or query provided provided <- sum(!is.null(concept_id), !is.null(concept_name), !is.null(query)) @@ -472,7 +472,7 @@ SearchResource <- R6::R6Class( checkmate::assert_choice(algorithm, c("semantic", "lexical", "hybrid")) checkmate::assert_number(similarity_threshold, lower = 0, upper = 1) - checkmate::assert_integerish(page, lower = 1) + checkmate::assert_integerish(page, lower = 1, len = 1, any.missing = FALSE) checkmate::assert_integerish(page_size, lower = 1, upper = 1000) if (!is.null(concept_id)) { checkmate::assert_integerish(concept_id, len = 1, any.missing = FALSE) diff --git a/man/SearchResource.Rd b/man/SearchResource.Rd index 0df989c..21144f3 100644 --- a/man/SearchResource.Rd +++ b/man/SearchResource.Rd @@ -392,15 +392,15 @@ Must provide exactly one of: concept_id, concept_name, or query. query = NULL, algorithm = "semantic", similarity_threshold = 0.7, - page = 1, page_size = 20, vocabulary_ids = NULL, domain_ids = NULL, - concept_class_ids = NULL, standard_concept = NULL, include_invalid = NULL, include_scores = NULL, include_explanations = NULL, + page = 1, + concept_class_ids = NULL, exclude_self = NULL )}\if{html}{\out{}} } @@ -419,16 +419,12 @@ Must provide exactly one of: concept_id, concept_name, or query. \item{\code{similarity_threshold}}{Minimum similarity (0.0-1.0). Default 0.7. \code{0} is a valid value and is honoured.} -\item{\code{page}}{Page of the ranked candidate pool (1-based). Default 1.} - \item{\code{page_size}}{Results per page (max 1000). Default 20.} \item{\code{vocabulary_ids}}{Filter by vocabulary IDs.} \item{\code{domain_ids}}{Filter by domain IDs.} -\item{\code{concept_class_ids}}{Filter by concept class IDs.} - \item{\code{standard_concept}}{Filter by standard concept flag ('S', 'C', or 'N'). 'N' selects non-standard concepts, which OMOP stores as a null column.} @@ -442,6 +438,10 @@ two rather than ignoring the filter.} \item{\code{include_explanations}}{Include an \code{explanation} on each concept.} +\item{\code{page}}{Page of the ranked candidate pool (1-based). Default 1.} + +\item{\code{concept_class_ids}}{Filter by concept class IDs.} + \item{\code{exclude_self}}{Exclude the reference concept from its own results (default TRUE).} } diff --git a/tests/testthat/test-request-pagination.R b/tests/testthat/test-request-pagination.R index d964766..80a382c 100644 --- a/tests/testthat/test-request-pagination.R +++ b/tests/testthat/test-request-pagination.R @@ -66,11 +66,17 @@ test_that("perform_post adds no pagination element when meta carries none", { }) test_that("search$similar exposes pagination end to end", { - httr2::local_mocked_responses(list(json_response(list( - success = TRUE, - data = list(similar_concepts = list(), search_metadata = list()), - meta = list(pagination = list(page = 3, has_next = FALSE)) - )))) + httr2::local_mocked_responses(function(req) { + expect_equal(req$body$data$page, 3) + json_response(list( + success = TRUE, + data = list(similar_concepts = list(), search_metadata = list()), + meta = list(pagination = list( + page = req$body$data$page, + has_next = FALSE + )) + )) + }) resource <- SearchResource$new(httr2::request("https://api.omophub.com/v1")) result <- resource$similar(concept_id = 201826, page = 3) diff --git a/tests/testthat/test-search.R b/tests/testthat/test-search.R index 48026d2..c4ef30f 100644 --- a/tests/testthat/test-search.R +++ b/tests/testthat/test-search.R @@ -827,6 +827,33 @@ test_that("search$similar validates similarity_threshold range", { expect_error(resource$similar(concept_id = 123, similarity_threshold = -0.1)) }) +test_that("search$similar requires one non-missing page value", { + base_req <- httr2::request("https://api.omophub.com/v1") + resource <- SearchResource$new(base_req) + + expect_error( + resource$similar(concept_id = 123, page = c(1, 2)), + "length 1" + ) + expect_error( + resource$similar(concept_id = 123, page = NA_integer_), + "missing" + ) +}) + +test_that("search$similar keeps the 1.9.0 positional contract", { + formal_names <- names(formals(SearchResource$public_methods$similar)) + expect_equal( + formal_names[1:12], + c( + "concept_id", "concept_name", "query", "algorithm", + "similarity_threshold", "page_size", "vocabulary_ids", "domain_ids", + "standard_concept", "include_invalid", "include_scores", + "include_explanations" + ) + ) +}) + test_that("search$similar validates standard_concept choices", { base_req <- httr2::request("https://api.omophub.com/v1") resource <- SearchResource$new(base_req) From 4520bff316e578510e236ab361febc4ca0b7e5bb Mon Sep 17 00:00:00 2001 From: alex-omophub Date: Mon, 7 Sep 2026 21:53:36 +0100 Subject: [PATCH 4/4] Update package version to 1.9.1 and adjust related documentation - Changed package version from 1.19.1 to 1.9.1 in DESCRIPTION and NEWS.md files. - Updated tests to reflect the new parameter order in the `similar` method, ensuring consistency with the latest changes. --- DESCRIPTION | 2 +- NEWS.md | 2 +- tests/testthat/test-search.R | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/DESCRIPTION b/DESCRIPTION index 8900305..46f5186 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,6 +1,6 @@ Package: omophub Title: R Client for the 'OMOPHub' Medical Vocabulary API -Version: 1.19.1 +Version: 1.9.1 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 c945c88..1598dce 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,4 +1,4 @@ -# omophub 1.19.1 +# omophub 1.9.1 ## New Features diff --git a/tests/testthat/test-search.R b/tests/testthat/test-search.R index c4ef30f..23a5098 100644 --- a/tests/testthat/test-search.R +++ b/tests/testthat/test-search.R @@ -844,12 +844,12 @@ test_that("search$similar requires one non-missing page value", { test_that("search$similar keeps the 1.9.0 positional contract", { formal_names <- names(formals(SearchResource$public_methods$similar)) expect_equal( - formal_names[1:12], + formal_names, c( "concept_id", "concept_name", "query", "algorithm", "similarity_threshold", "page_size", "vocabulary_ids", "domain_ids", "standard_concept", "include_invalid", "include_scores", - "include_explanations" + "include_explanations", "page", "concept_class_ids", "exclude_self" ) ) })