Skip to content

Commit f7bb604

Browse files
committed
feat: Add pathling_evaluate_fhirpath() to the R library
Add R binding for evaluating FHIRPath expressions against a single FHIR resource JSON string, mirroring the Python evaluate_fhirpath() method. Includes roxygen2 documentation, 7 tests, and a new "Single resource evaluation" section in the FHIRPath documentation with examples in all four languages. Replace Collections.emptyList() with new ArrayList<>() in SingleInstanceEvaluator to fix reflection access errors with sparklyr's invoke() in Java 17+.
1 parent 4f6ee84 commit f7bb604

File tree

10 files changed

+588
-4
lines changed

10 files changed

+588
-4
lines changed

fhirpath/src/main/java/au/csiro/pathling/fhirpath/evaluation/SingleInstanceEvaluator.java

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,6 @@
3232
import jakarta.annotation.Nullable;
3333
import java.math.BigDecimal;
3434
import java.util.ArrayList;
35-
import java.util.Collections;
3635
import java.util.HashMap;
3736
import java.util.List;
3837
import java.util.Map;
@@ -184,7 +183,7 @@ private static SingleInstanceEvaluationResult evaluateWithContext(
184183
final List<Row> contextRows = contextDf.collectAsList();
185184

186185
if (contextRows.isEmpty() || contextRows.getFirst().isNullAt(0)) {
187-
return new SingleInstanceEvaluationResult(Collections.emptyList(), expectedReturnType);
186+
return new SingleInstanceEvaluationResult(new ArrayList<>(), expectedReturnType);
188187
}
189188

190189
// The context value is an array; evaluate the main expression against the input context
@@ -233,12 +232,12 @@ private static List<TypedValue> collectResults(
233232
final List<Row> rows = resultDf.collectAsList();
234233

235234
if (rows.isEmpty()) {
236-
return Collections.emptyList();
235+
return new ArrayList<>();
237236
}
238237

239238
final Row row = rows.getFirst();
240239
if (row.isNullAt(0)) {
241-
return Collections.emptyList();
240+
return new ArrayList<>();
242241
}
243242

244243
final Object rawValue = row.get(0);

lib/R/R/context.R

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -440,3 +440,86 @@ pathling_with_column <- function(df, pc, resource_type, expression, column) {
440440
j_invoke("withColumn", as.character(column), col) %>%
441441
sparklyr::sdf_register()
442442
}
443+
444+
#' Evaluate a FHIRPath expression against a single FHIR resource
445+
#'
446+
#' Evaluates a FHIRPath expression against a single FHIR resource provided as a JSON string and
447+
#' returns materialised typed results. The resource is encoded into a one-row Spark Dataset
448+
#' internally, and the existing FHIRPath engine is used to evaluate the expression.
449+
#'
450+
#' @param pc The PathlingContext object.
451+
#' @param resource_type A string containing the FHIR resource type code (e.g., "Patient",
452+
#' "Observation").
453+
#' @param resource_json A string containing the FHIR resource as JSON.
454+
#' @param fhirpath_expression A FHIRPath expression to evaluate (e.g., "name.family",
455+
#' "gender = 'male'").
456+
#' @param context_expression An optional context expression string. If provided, the main
457+
#' expression is evaluated once for each result of the context expression. Defaults to NULL.
458+
#' @param variables An optional named list of variables available via \code{\%variable} syntax.
459+
#' Defaults to NULL.
460+
#'
461+
#' @return A list with two elements:
462+
#' \describe{
463+
#' \item{\code{results}}{A list of lists, each containing \code{type} (character) and
464+
#' \code{value} (the materialised R value or NULL).}
465+
#' \item{\code{expectedReturnType}}{A character string indicating the inferred return type.}
466+
#' }
467+
#'
468+
#' @importFrom sparklyr invoke j_invoke j_invoke_new spark_connection
469+
#'
470+
#' @family context functions
471+
#'
472+
#' @export
473+
#'
474+
#' @examples \dontrun{
475+
#' pc <- pathling_connect()
476+
#' patient_json <- '{"resourceType": "Patient", "id": "example", "gender": "male"}'
477+
#' result <- pathling_evaluate_fhirpath(pc, "Patient", patient_json, "gender")
478+
#' for (entry in result$results) {
479+
#' cat(entry$type, ": ", entry$value, "\n")
480+
#' }
481+
#' pathling_disconnect(pc)
482+
#' }
483+
pathling_evaluate_fhirpath <- function(pc, resource_type, resource_json,
484+
fhirpath_expression,
485+
context_expression = NULL,
486+
variables = NULL) {
487+
# Convert R named list to a Java HashMap if variables are provided.
488+
java_variables <- NULL
489+
if (!is.null(variables)) {
490+
sc <- spark_connection(pc)
491+
java_variables <- j_invoke_new(sc, "java.util.HashMap")
492+
for (name in names(variables)) {
493+
invoke(java_variables, "put", name, variables[[name]])
494+
}
495+
}
496+
497+
jresult <- invoke(
498+
pc, "evaluateFhirPath",
499+
as.character(resource_type),
500+
as.character(resource_json),
501+
as.character(fhirpath_expression),
502+
context_expression,
503+
java_variables
504+
)
505+
506+
# Convert Java SingleInstanceEvaluationResult to an R list.
507+
jtyped_values <- invoke(jresult, "getResults")
508+
size <- jtyped_values %>% invoke("size")
509+
results <- if (size > 0L) {
510+
lapply(seq_len(size), function(i) {
511+
jtv <- jtyped_values %>% invoke("get", as.integer(i - 1))
512+
list(
513+
type = jtv %>% invoke("getType"),
514+
value = jtv %>% invoke("getValue")
515+
)
516+
})
517+
} else {
518+
list()
519+
}
520+
521+
list(
522+
results = results,
523+
expectedReturnType = invoke(jresult, "getExpectedReturnType")
524+
)
525+
}

lib/R/tests/testthat/test-context.R

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,3 +211,108 @@ test_that("pathling_filter and pathling_with_column work together in a pipe", {
211211
# Should have only male patients.
212212
expect_true(result %>% sparklyr::sdf_nrow() > 0)
213213
})
214+
215+
# ========== pathling_evaluate_fhirpath tests ==========
216+
217+
# Sample Patient JSON used across evaluate_fhirpath tests.
218+
PATIENT_JSON <- '{
219+
"resourceType": "Patient",
220+
"id": "example",
221+
"active": true,
222+
"gender": "male",
223+
"birthDate": "1990-01-01",
224+
"name": [
225+
{
226+
"use": "official",
227+
"family": "Smith",
228+
"given": ["John", "James"]
229+
},
230+
{
231+
"use": "nickname",
232+
"family": "Smith",
233+
"given": ["Johnny"]
234+
}
235+
]
236+
}'
237+
238+
# Helper to create a PathlingContext for evaluate_fhirpath tests.
239+
evaluate_test_setup <- function() {
240+
spark <- def_spark()
241+
pc <- pathling_connect(spark)
242+
list(pc = pc)
243+
}
244+
245+
test_that("evaluate_fhirpath returns a string result for name.family", {
246+
setup <- evaluate_test_setup()
247+
# Evaluating name.family should return family names as strings.
248+
result <- pathling_evaluate_fhirpath(setup$pc, "Patient", PATIENT_JSON, "name.family")
249+
expect_type(result, "list")
250+
expect_true("results" %in% names(result))
251+
expect_true("expectedReturnType" %in% names(result))
252+
expect_equal(result$expectedReturnType, "string")
253+
expect_equal(length(result$results), 2)
254+
expect_equal(result$results[[1]]$type, "string")
255+
expect_equal(result$results[[1]]$value, "Smith")
256+
})
257+
258+
test_that("evaluate_fhirpath returns multiple values for name.given", {
259+
setup <- evaluate_test_setup()
260+
# Evaluating name.given should return all given names.
261+
result <- pathling_evaluate_fhirpath(setup$pc, "Patient", PATIENT_JSON, "name.given")
262+
expect_equal(length(result$results), 3)
263+
values <- sapply(result$results, function(x) x$value)
264+
expect_true("John" %in% values)
265+
expect_true("James" %in% values)
266+
expect_true("Johnny" %in% values)
267+
})
268+
269+
test_that("evaluate_fhirpath returns empty results for missing element", {
270+
setup <- evaluate_test_setup()
271+
# Evaluating a path that matches nothing should return an empty list.
272+
result <- pathling_evaluate_fhirpath(setup$pc, "Patient", PATIENT_JSON, "multipleBirthBoolean")
273+
expect_equal(length(result$results), 0)
274+
})
275+
276+
test_that("evaluate_fhirpath returns boolean result for active", {
277+
setup <- evaluate_test_setup()
278+
# Evaluating active should return a boolean.
279+
result <- pathling_evaluate_fhirpath(setup$pc, "Patient", PATIENT_JSON, "active")
280+
expect_equal(length(result$results), 1)
281+
expect_equal(result$results[[1]]$type, "boolean")
282+
expect_equal(result$results[[1]]$value, TRUE)
283+
expect_equal(result$expectedReturnType, "boolean")
284+
})
285+
286+
test_that("evaluate_fhirpath raises error for invalid expression", {
287+
setup <- evaluate_test_setup()
288+
# An invalid FHIRPath expression should raise an error.
289+
expect_error(
290+
pathling_evaluate_fhirpath(setup$pc, "Patient", PATIENT_JSON, "!!invalid!!")
291+
)
292+
})
293+
294+
test_that("evaluate_fhirpath with context expression", {
295+
setup <- evaluate_test_setup()
296+
# Using a context expression to compose the evaluation.
297+
result <- pathling_evaluate_fhirpath(
298+
setup$pc, "Patient", PATIENT_JSON, "given",
299+
context_expression = "name"
300+
)
301+
expect_equal(length(result$results), 3)
302+
values <- sapply(result$results, function(x) x$value)
303+
expect_true("John" %in% values)
304+
expect_true("James" %in% values)
305+
expect_true("Johnny" %in% values)
306+
})
307+
308+
test_that("evaluate_fhirpath with variable substitution", {
309+
setup <- evaluate_test_setup()
310+
# A string variable should be resolvable via %variable syntax.
311+
result <- pathling_evaluate_fhirpath(
312+
setup$pc, "Patient", PATIENT_JSON, "%myVar",
313+
variables = list(myVar = "test")
314+
)
315+
expect_equal(length(result$results), 1)
316+
expect_equal(result$results[[1]]$type, "string")
317+
expect_equal(result$results[[1]]$value, "test")
318+
})
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
schema: spec-driven
2+
created: 2026-02-17
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
## Context
2+
3+
The Python library already wraps `PathlingContext.evaluateFhirPath()` via Py4J,
4+
converting the Java `SingleInstanceEvaluationResult` into a Python dictionary.
5+
The R library uses sparklyr's `j_invoke` for all JVM interop and currently has
6+
no equivalent function. The Java method accepts `(String, String, String,
7+
String, Map)` and returns a `SingleInstanceEvaluationResult` containing a
8+
`List<TypedValue>` and a `String`.
9+
10+
## Goals / Non-Goals
11+
12+
**Goals:**
13+
14+
- Provide `pathling_evaluate_fhirpath()` in R with the same capabilities as the
15+
Python `evaluate_fhirpath()`.
16+
- Document single-resource FHIRPath evaluation in `fhirpath.md` across all four
17+
languages.
18+
19+
**Non-Goals:**
20+
21+
- Changing the Java API or `SingleInstanceEvaluationResult` class.
22+
- Adding AST/parse tree information to the R result (the Java API does not
23+
expose this through the `evaluateFhirPath` method).
24+
- Supporting streaming or batch evaluation of multiple resources.
25+
26+
## Decisions
27+
28+
### Call the 5-argument Java overload directly
29+
30+
The Java `evaluateFhirPath` has two overloads: a 3-argument (expression only)
31+
and a 5-argument (with optional context expression and variables). The R
32+
function will always call the 5-argument overload, passing `NULL` for the
33+
optional parameters when they are not provided. This mirrors the Python
34+
implementation and avoids method-resolution complexity in sparklyr.
35+
36+
### Convert Java result to an R list
37+
38+
The function will iterate over `jresult.getResults()` using `j_invoke`, extract
39+
each `TypedValue`'s type and value, and build an R list with the structure:
40+
41+
```r
42+
list(
43+
results = list(
44+
list(type = "string", value = "Smith"),
45+
list(type = "string", value = "Jones")
46+
),
47+
expectedReturnType = "string"
48+
)
49+
```
50+
51+
This mirrors the Python dictionary structure and is idiomatic R.
52+
53+
### Convert Java variable map via HashMap
54+
55+
When the user passes R named variables, the function will create a
56+
`java.util.HashMap` via `j_invoke_static` / `j_invoke` and populate it with the
57+
named entries. This matches how the Python library passes its `dict` to Py4J.
58+
59+
### Documentation section placement
60+
61+
The new "Single resource evaluation" section will be placed at the end of the
62+
existing `fhirpath.md` page, after "Combining with other operations". This is a
63+
distinct use case from DataFrame-based operations and logically comes last.
64+
65+
## Risks / Trade-offs
66+
67+
- **Java type coercion**: sparklyr's `j_invoke` handles basic Java-to-R type
68+
conversions (String, Integer, Boolean, Double). Complex FHIR types are
69+
returned as JSON strings by the Java API, so no special handling is needed.
70+
Risk is low.
71+
- **NULL handling**: Java `null` values from `TypedValue.getValue()` will map to
72+
R `NULL` via sparklyr. This is consistent with R conventions.
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
## Why
2+
3+
The Python library provides `evaluate_fhirpath()` for evaluating a FHIRPath
4+
expression against a single FHIR resource (as a JSON string) and returning
5+
materialised typed results. The R library has no equivalent, limiting R users to
6+
DataFrame-based operations only. Additionally, the library documentation lacks a
7+
section showing how to evaluate FHIRPath against a single resource.
8+
9+
## What Changes
10+
11+
- Add a `pathling_evaluate_fhirpath()` function to the R library that calls
12+
`PathlingContext.evaluateFhirPath()` via sparklyr's `j_invoke`, converts the
13+
Java result to an R list, and returns it.
14+
- Add a "Single resource evaluation" section to
15+
`site/docs/libraries/fhirpath.md` with examples in all four languages (Python,
16+
R, Scala, Java).
17+
18+
## Capabilities
19+
20+
### New Capabilities
21+
22+
- `r-evaluate-fhirpath`: R binding for evaluating a FHIRPath expression against
23+
a single FHIR resource and returning typed results.
24+
25+
### Modified Capabilities
26+
27+
_(none)_
28+
29+
## Impact
30+
31+
- `lib/R/R/context.R` - new exported function and roxygen2 documentation.
32+
- `lib/R/NAMESPACE` - updated by roxygen2 to export the new function.
33+
- `lib/R/tests/testthat/test-context.R` - new tests for the function.
34+
- `site/docs/libraries/fhirpath.md` - new documentation section.

0 commit comments

Comments
 (0)