Skip to content

Commit 7fda72c

Browse files
author
alex-omophub
committed
Add bulk search functionality to SearchResource
- Introduced `bulk_basic()` method for executing multiple lexical searches in a single request, supporting up to 50 queries. - Added `bulk_semantic()` method for performing multiple semantic searches, allowing up to 25 queries. - Updated README with examples for both bulk search methods. - Enhanced tests to validate the new bulk search functionalities, including input validation and endpoint correctness.
1 parent 240f723 commit 7fda72c

4 files changed

Lines changed: 277 additions & 2 deletions

File tree

R/search.R

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,66 @@ SearchResource <- R6::R6Class(
327327
paginate_all(fetch_fn, page_size = page_size, max_pages = max_pages, progress = progress)
328328
},
329329

330+
#' @description
331+
#' Execute multiple lexical searches in a single request (max 50).
332+
#'
333+
#' @param searches List of search inputs. Each element is a named list with:
334+
#' - `search_id` (required): Unique ID to match results.
335+
#' - `query` (required): Search query string.
336+
#' - `vocabulary_ids`, `domain_ids`, `concept_class_ids`: Optional filters.
337+
#' - `standard_concept`, `include_invalid`, `page_size`: Optional params.
338+
#' @param defaults Named list of default filters applied to all searches.
339+
#' Individual search-level values override defaults.
340+
#'
341+
#' @returns List with `results` (per-search), `total_searches`,
342+
#' `completed_searches`, `failed_searches`.
343+
bulk_basic = function(searches, defaults = NULL) {
344+
checkmate::assert_list(searches, min.len = 1, max.len = 50)
345+
for (s in searches) {
346+
checkmate::assert_list(s)
347+
checkmate::assert_string(s$search_id, min.chars = 1)
348+
checkmate::assert_string(s$query, min.chars = 1)
349+
}
350+
351+
body <- list(searches = searches)
352+
if (!is.null(defaults)) {
353+
checkmate::assert_list(defaults)
354+
body$defaults <- defaults
355+
}
356+
357+
perform_post(private$.base_req, "search/bulk", body = body)
358+
},
359+
360+
#' @description
361+
#' Execute multiple semantic searches in a single request (max 25).
362+
#'
363+
#' @param searches List of search inputs. Each element is a named list with:
364+
#' - `search_id` (required): Unique ID to match results.
365+
#' - `query` (required): Natural language query (1-500 chars).
366+
#' - `threshold`: Per-search similarity threshold (0-1).
367+
#' - `page_size`: Per-search result limit (1-50).
368+
#' - `vocabulary_ids`, `domain_ids`, `standard_concept`: Optional filters.
369+
#' @param defaults Named list of default filters applied to all searches.
370+
#'
371+
#' @returns List with `results` (per-search), `total_searches`,
372+
#' `completed_count`, `failed_count`, `total_duration`.
373+
bulk_semantic = function(searches, defaults = NULL) {
374+
checkmate::assert_list(searches, min.len = 1, max.len = 25)
375+
for (s in searches) {
376+
checkmate::assert_list(s)
377+
checkmate::assert_string(s$search_id, min.chars = 1)
378+
checkmate::assert_string(s$query, min.chars = 1, max.chars = 500)
379+
}
380+
381+
body <- list(searches = searches)
382+
if (!is.null(defaults)) {
383+
checkmate::assert_list(defaults)
384+
body$defaults <- defaults
385+
}
386+
387+
perform_post(private$.base_req, "search/semantic-bulk", body = body)
388+
},
389+
330390
#' @description
331391
#' Find concepts similar to a reference concept or query.
332392
#'
@@ -419,7 +479,7 @@ SearchResource <- R6::R6Class(
419479
#' Print resource information.
420480
print = function() {
421481
cat("<OMOPHub SearchResource>\n")
422-
cat(" Methods: basic, basic_all, advanced, autocomplete, semantic, semantic_all, similar\n")
482+
cat(" Methods: basic, basic_all, advanced, autocomplete, semantic, semantic_all, bulk_basic, bulk_semantic, similar\n")
423483
invisible(self)
424484
}
425485
),

README.md

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,29 @@ for (s in similar$similar_concepts) {
111111
}
112112
```
113113

114+
### Bulk Search
115+
116+
Search for multiple terms in a single API call:
117+
118+
```r
119+
# Bulk lexical search (up to 50 queries)
120+
results <- client$search$bulk_basic(list(
121+
list(search_id = "q1", query = "diabetes mellitus"),
122+
list(search_id = "q2", query = "hypertension"),
123+
list(search_id = "q3", query = "aspirin")
124+
), defaults = list(vocabulary_ids = list("SNOMED"), page_size = 5))
125+
126+
for (item in results$results) {
127+
cat(sprintf("%s: %d results\n", item$search_id, length(item$results)))
128+
}
129+
130+
# Bulk semantic search (up to 25 queries)
131+
results <- client$search$bulk_semantic(list(
132+
list(search_id = "s1", query = "heart failure treatment options"),
133+
list(search_id = "s2", query = "type 2 diabetes medication")
134+
), defaults = list(threshold = 0.5, page_size = 10))
135+
```
136+
114137
## Use Cases
115138

116139
### ETL & Data Pipelines
@@ -198,7 +221,7 @@ concepts_df %>%
198221
| Resource | Description | Key Methods |
199222
|----------|-------------|-------------|
200223
| `concepts` | Concept lookup and batch operations | `get()`, `get_by_code()`, `batch()`, `suggest()` |
201-
| `search` | Full-text and semantic search | `basic()`, `advanced()`, `semantic()`, `semantic_all()`, `similar()`, `basic_all()` |
224+
| `search` | Full-text and semantic search | `basic()`, `advanced()`, `semantic()`, `similar()`, `bulk_basic()`, `bulk_semantic()` |
202225
| `hierarchy` | Navigate concept relationships | `ancestors()`, `descendants()` |
203226
| `mappings` | Cross-vocabulary mappings | `get()`, `map()` |
204227
| `vocabularies` | Vocabulary metadata | `list()`, `get()`, `stats()` |

tests/testthat/test-search-integration.R

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,3 +315,76 @@ test_that("similar with vocabulary filter works", {
315315
}
316316
}
317317
})
318+
319+
# ==============================================================================
320+
# Bulk lexical search integration tests
321+
# ==============================================================================
322+
323+
test_that("bulk_basic search works with multiple queries", {
324+
skip_if_no_integration_key()
325+
client <- integration_client()
326+
327+
result <- client$search$bulk_basic(list(
328+
list(search_id = "q1", query = "diabetes mellitus"),
329+
list(search_id = "q2", query = "hypertension"),
330+
list(search_id = "q3", query = "aspirin")
331+
), defaults = list(page_size = 5))
332+
333+
results <- extract_data(result, "results")
334+
expect_length(results, 3)
335+
336+
for (item in results) {
337+
expect_true(item$search_id %in% c("q1", "q2", "q3"))
338+
expect_equal(item$status, "completed")
339+
expect_gt(length(item$results), 0)
340+
}
341+
})
342+
343+
test_that("bulk_basic search works with vocabulary filter", {
344+
skip_if_no_integration_key()
345+
client <- integration_client()
346+
347+
result <- client$search$bulk_basic(
348+
list(list(search_id = "snomed1", query = "diabetes")),
349+
defaults = list(vocabulary_ids = list("SNOMED"), page_size = 3)
350+
)
351+
352+
results <- extract_data(result, "results")
353+
expect_length(results, 1)
354+
expect_equal(results[[1]]$status, "completed")
355+
})
356+
357+
# ==============================================================================
358+
# Bulk semantic search integration tests
359+
# ==============================================================================
360+
361+
test_that("bulk_semantic search works with multiple queries", {
362+
skip_if_no_integration_key()
363+
client <- integration_client()
364+
365+
result <- client$search$bulk_semantic(list(
366+
list(search_id = "s1", query = "heart failure treatment options"),
367+
list(search_id = "s2", query = "type 2 diabetes medication")
368+
), defaults = list(threshold = 0.5, page_size = 5))
369+
370+
results <- extract_data(result, "results")
371+
expect_length(results, 2)
372+
373+
for (item in results) {
374+
expect_true(item$search_id %in% c("s1", "s2"))
375+
expect_equal(item$status, "completed")
376+
}
377+
})
378+
379+
test_that("bulk_semantic search works with single query", {
380+
skip_if_no_integration_key()
381+
client <- integration_client()
382+
383+
result <- client$search$bulk_semantic(
384+
list(list(search_id = "one", query = "elevated blood pressure", threshold = 0.5))
385+
)
386+
387+
results <- extract_data(result, "results")
388+
expect_length(results, 1)
389+
expect_equal(results[[1]]$search_id, "one")
390+
})

tests/testthat/test-search.R

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -915,3 +915,122 @@ test_that("SearchResource print method includes new methods", {
915915
expect_output(print(resource), "similar")
916916
})
917917

918+
# ==============================================================================
919+
# bulk_basic() method
920+
# ==============================================================================
921+
922+
test_that("search$bulk_basic validates searches", {
923+
base_req <- httr2::request("https://api.omophub.com/v1")
924+
resource <- SearchResource$new(base_req)
925+
926+
expect_error(resource$bulk_basic(list())) # Empty list
927+
expect_error(resource$bulk_basic(list(list(search_id = "", query = "test")))) # Empty search_id
928+
expect_error(resource$bulk_basic(list(list(search_id = "q1", query = "")))) # Empty query
929+
})
930+
931+
test_that("search$bulk_basic calls correct endpoint", {
932+
base_req <- httr2::request("https://api.omophub.com/v1")
933+
resource <- SearchResource$new(base_req)
934+
935+
called_with <- NULL
936+
local_mocked_bindings(
937+
perform_post = function(req, path, body = NULL) {
938+
called_with <<- list(path = path, body = body)
939+
list(results = list(), total_searches = 0, completed_searches = 0, failed_searches = 0)
940+
}
941+
)
942+
943+
resource$bulk_basic(list(
944+
list(search_id = "q1", query = "diabetes"),
945+
list(search_id = "q2", query = "hypertension")
946+
))
947+
948+
expect_equal(called_with$path, "search/bulk")
949+
expect_length(called_with$body$searches, 2)
950+
expect_equal(called_with$body$searches[[1]]$search_id, "q1")
951+
expect_equal(called_with$body$searches[[2]]$query, "hypertension")
952+
})
953+
954+
test_that("search$bulk_basic passes defaults", {
955+
base_req <- httr2::request("https://api.omophub.com/v1")
956+
resource <- SearchResource$new(base_req)
957+
958+
called_with <- NULL
959+
local_mocked_bindings(
960+
perform_post = function(req, path, body = NULL) {
961+
called_with <<- list(path = path, body = body)
962+
list(results = list(), total_searches = 1, completed_searches = 1, failed_searches = 0)
963+
}
964+
)
965+
966+
resource$bulk_basic(
967+
list(list(search_id = "q1", query = "diabetes")),
968+
defaults = list(vocabulary_ids = list("SNOMED"), page_size = 5)
969+
)
970+
971+
expect_equal(called_with$body$defaults$vocabulary_ids, list("SNOMED"))
972+
expect_equal(called_with$body$defaults$page_size, 5)
973+
})
974+
975+
# ==============================================================================
976+
# bulk_semantic() method
977+
# ==============================================================================
978+
979+
test_that("search$bulk_semantic validates searches", {
980+
base_req <- httr2::request("https://api.omophub.com/v1")
981+
resource <- SearchResource$new(base_req)
982+
983+
expect_error(resource$bulk_semantic(list())) # Empty list
984+
expect_error(resource$bulk_semantic(list(list(search_id = "", query = "test")))) # Empty search_id
985+
expect_error(resource$bulk_semantic(list(list(search_id = "s1", query = "")))) # Empty query
986+
})
987+
988+
test_that("search$bulk_semantic calls correct endpoint", {
989+
base_req <- httr2::request("https://api.omophub.com/v1")
990+
resource <- SearchResource$new(base_req)
991+
992+
called_with <- NULL
993+
local_mocked_bindings(
994+
perform_post = function(req, path, body = NULL) {
995+
called_with <<- list(path = path, body = body)
996+
list(results = list(), total_searches = 0, completed_count = 0, failed_count = 0)
997+
}
998+
)
999+
1000+
resource$bulk_semantic(list(
1001+
list(search_id = "s1", query = "heart failure treatment")
1002+
))
1003+
1004+
expect_equal(called_with$path, "search/semantic-bulk")
1005+
expect_length(called_with$body$searches, 1)
1006+
expect_equal(called_with$body$searches[[1]]$search_id, "s1")
1007+
})
1008+
1009+
test_that("search$bulk_semantic passes defaults", {
1010+
base_req <- httr2::request("https://api.omophub.com/v1")
1011+
resource <- SearchResource$new(base_req)
1012+
1013+
called_with <- NULL
1014+
local_mocked_bindings(
1015+
perform_post = function(req, path, body = NULL) {
1016+
called_with <<- list(path = path, body = body)
1017+
list(results = list(), total_searches = 1, completed_count = 1, failed_count = 0)
1018+
}
1019+
)
1020+
1021+
resource$bulk_semantic(
1022+
list(list(search_id = "s1", query = "diabetes medications")),
1023+
defaults = list(threshold = 0.8, page_size = 10)
1024+
)
1025+
1026+
expect_equal(called_with$body$defaults$threshold, 0.8)
1027+
expect_equal(called_with$body$defaults$page_size, 10)
1028+
})
1029+
1030+
test_that("SearchResource print includes bulk methods", {
1031+
base_req <- httr2::request("https://api.omophub.com/v1")
1032+
resource <- SearchResource$new(base_req)
1033+
1034+
expect_output(print(resource), "bulk_basic")
1035+
expect_output(print(resource), "bulk_semantic")
1036+
})

0 commit comments

Comments
 (0)