diff --git a/DESCRIPTION b/DESCRIPTION
index 91855ba..46f5186 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.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"))
@@ -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..1598dce 100644
--- a/NEWS.md
+++ b/NEWS.md
@@ -1,3 +1,31 @@
+# omophub 1.9.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/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..3b8c2bb 100644
--- a/R/search.R
+++ b/R/search.R
@@ -199,27 +199,51 @@ 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.
+ #' @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,
+ 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,23 +419,38 @@ 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_size Results per page (max 1000). Default 20.
#' @param vocabulary_ids Filter by vocabulary IDs.
#' @param domain_ids Filter by domain 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 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).
#'
- #' @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_size = 20,
vocabulary_ids = NULL,
@@ -419,7 +458,10 @@ SearchResource <- R6::R6Class(
standard_concept = NULL,
include_invalid = NULL,
include_scores = NULL,
- include_explanations = 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))
if (provided != 1) {
@@ -430,6 +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, 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)
@@ -449,6 +492,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 +504,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 +520,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..a5d7034 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))
@@ -327,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 c8f7ede..21144f3 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,13 +25,18 @@ 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, 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.
}
\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 +217,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 +231,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,7 +390,7 @@ 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_size = 20,
vocabulary_ids = NULL,
@@ -383,7 +398,10 @@ Must provide exactly one of: concept_id, concept_name, or query.
standard_concept = NULL,
include_invalid = NULL,
include_scores = NULL,
- include_explanations = NULL
+ include_explanations = NULL,
+ page = 1,
+ concept_class_ids = NULL,
+ exclude_self = NULL
)}\if{html}{\out{}}
}
@@ -396,23 +414,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.}
+\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_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{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_scores}}{Include \code{similarity_score} on each concept
+(default TRUE). When FALSE the field is absent.}
-\item{\code{include_invalid}}{Include invalid/deprecated concepts.}
+\item{\code{include_explanations}}{Include an \code{explanation} on each concept.}
-\item{\code{include_scores}}{Include detailed similarity scores.}
+\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{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/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-request-pagination.R b/tests/testthat/test-request-pagination.R
new file mode 100644
index 0000000..80a382c
--- /dev/null
+++ b/tests/testthat/test-request-pagination.R
@@ -0,0 +1,86 @@
+# 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(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)
+
+ 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..23a5098 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())
}
@@ -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", max_suggestions = 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$max_suggestions, 5L)
+ 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", {
@@ -374,11 +392,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 +668,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 +678,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 +691,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 +708,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 +725,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 +742,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 +759,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 +777,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 +794,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())
}
@@ -758,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,
+ c(
+ "concept_id", "concept_name", "query", "algorithm",
+ "similarity_threshold", "page_size", "vocabulary_ids", "domain_ids",
+ "standard_concept", "include_invalid", "include_scores",
+ "include_explanations", "page", "concept_class_ids", "exclude_self"
+ )
+ )
+})
+
test_that("search$similar validates standard_concept choices", {
base_req <- httr2::request("https://api.omophub.com/v1")
resource <- SearchResource$new(base_req)
@@ -771,7 +867,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 +884,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 +926,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 +1030,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 +1053,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 +1087,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 +1108,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)
}