diff --git a/fhirpath/src/main/java/au/csiro/pathling/fhirpath/function/provider/StringFunctions.java b/fhirpath/src/main/java/au/csiro/pathling/fhirpath/function/provider/StringFunctions.java index e007ff709c..d29c71108b 100644 --- a/fhirpath/src/main/java/au/csiro/pathling/fhirpath/function/provider/StringFunctions.java +++ b/fhirpath/src/main/java/au/csiro/pathling/fhirpath/function/provider/StringFunctions.java @@ -21,11 +21,14 @@ import au.csiro.pathling.fhirpath.annotations.SqlOnFhirConformance; import au.csiro.pathling.fhirpath.annotations.SqlOnFhirConformance.Profile; +import au.csiro.pathling.fhirpath.collection.BooleanCollection; import au.csiro.pathling.fhirpath.collection.StringCollection; +import au.csiro.pathling.fhirpath.column.ColumnRepresentation; import au.csiro.pathling.fhirpath.column.DefaultRepresentation; import au.csiro.pathling.fhirpath.function.FhirPathFunction; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; +import org.apache.spark.sql.Column; /** * Contains functions for manipulating strings. @@ -67,4 +70,78 @@ public static StringCollection join( ? separator.asSingular().getColumn() : DefaultRepresentation.literal(JOIN_DEFAULT_SEPARATOR))); } + + /** + * The startsWith function returns true when the input string starts with the given prefix. + * + *

If prefix is the empty string ({@code ''}), the result is {@code true}. + * + *

If the input collection or the prefix argument is an empty collection ({@code {}}), the + * result is empty. + * + * @param input The input string + * @param prefix The prefix to check for + * @return A {@link BooleanCollection} containing the result + * @see FHIRPath + * Specification - startsWith + */ + @FhirPathFunction + @SqlOnFhirConformance(Profile.EXPERIMENTAL) + @Nonnull + public static BooleanCollection startsWith( + @Nonnull final StringCollection input, @Nonnull final StringCollection prefix) { + return BooleanCollection.build( + ColumnRepresentation.binaryOperator( + input.asSingular().getColumn(), prefix.asSingular().getColumn(), Column::startsWith)); + } + + /** + * The endsWith function returns true when the input string ends with the given suffix. + * + *

If suffix is the empty string ({@code ''}), the result is {@code true}. + * + *

If the input collection or the suffix argument is an empty collection ({@code {}}), the + * result is empty. + * + * @param input The input string + * @param suffix The suffix to check for + * @return A {@link BooleanCollection} containing the result + * @see FHIRPath + * Specification - endsWith + */ + @FhirPathFunction + @SqlOnFhirConformance(Profile.EXPERIMENTAL) + @Nonnull + public static BooleanCollection endsWith( + @Nonnull final StringCollection input, @Nonnull final StringCollection suffix) { + return BooleanCollection.build( + ColumnRepresentation.binaryOperator( + input.asSingular().getColumn(), suffix.asSingular().getColumn(), Column::endsWith)); + } + + /** + * The contains function returns true when the input string contains the given substring. + * + *

If substring is the empty string ({@code ''}), the result is {@code true}. + * + *

If the input collection or substring is empty, the result is empty. + * + * @param input The input string + * @param substring The substring to check for + * @return A {@link BooleanCollection} containing the result + * @see FHIRPath + * Specification - contains + */ + @FhirPathFunction + @SqlOnFhirConformance(Profile.EXPERIMENTAL) + @Nonnull + public static BooleanCollection contains( + @Nonnull final StringCollection input, @Nonnull final StringCollection substring) { + return BooleanCollection.build( + ColumnRepresentation.binaryOperator( + input.asSingular().getColumn(), substring.asSingular().getColumn(), Column::contains)); + } } diff --git a/fhirpath/src/test/java/au/csiro/pathling/fhirpath/dsl/StringFunctionsDslTest.java b/fhirpath/src/test/java/au/csiro/pathling/fhirpath/dsl/StringFunctionsDslTest.java index 0ee81532b9..cbba4fc218 100644 --- a/fhirpath/src/test/java/au/csiro/pathling/fhirpath/dsl/StringFunctionsDslTest.java +++ b/fhirpath/src/test/java/au/csiro/pathling/fhirpath/dsl/StringFunctionsDslTest.java @@ -24,7 +24,8 @@ /** * Tests for FHIRPath string functions as defined in supported.md: - join([separator: String]) : - * String + * String - startsWith(prefix: String) : Boolean - endsWith(suffix: String) : Boolean - + * contains(substring: String) : Boolean */ public class StringFunctionsDslTest extends FhirPathDslTestBase { @@ -78,4 +79,108 @@ public Stream testJoin() { .testError("person.join(',')", "join() errors on non-collection types") .build(); } + + @FhirPathTest + public Stream testStartsWith() { + return builder() + .withSubject( + sb -> + sb + // Empty values + .stringEmpty("emptyString") + // Single values + .string("singleString", "Hello, world!") + // Arrays of strings + .stringArray("stringArray", "one", "two", "three")) + .group("startsWith() function with single values") + .testTrue( + "singleString.startsWith('Hello')", "startsWith() returns true when prefix matches") + .testFalse( + "singleString.startsWith('world')", + "startsWith() returns false when prefix does not match") + .testTrue("singleString.startsWith('')", "startsWith() returns true for an empty prefix") + .testTrue( + "singleString.startsWith('Hello, world!')", + "startsWith() returns true when prefix equals the whole string") + .group("startsWith() function with empty values") + .testEmpty("emptyString.startsWith('Hello')", "startsWith() on empty input returns empty") + .testEmpty( + "singleString.startsWith({})", "startsWith() with empty prefix argument returns empty") + .group("startsWith() function error cases") + .testError( + "stringArray.startsWith('o')", + "startsWith() errors when input collection is not singular") + .testError( + "singleString.startsWith(stringArray)", + "startsWith() errors when the argument is not singular") + .build(); + } + + @FhirPathTest + public Stream testEndsWith() { + return builder() + .withSubject( + sb -> + sb + // Empty values + .stringEmpty("emptyString") + // Single values + .string("singleString", "Hello, world!") + // Arrays of strings + .stringArray("stringArray", "one", "two", "three")) + .group("endsWith() function with single values") + .testTrue("singleString.endsWith('world!')", "endsWith() returns true when suffix matches") + .testFalse( + "singleString.endsWith('Hello')", "endsWith() returns false when suffix does not match") + .testTrue("singleString.endsWith('')", "endsWith() returns true for an empty suffix") + .testTrue( + "singleString.endsWith('Hello, world!')", + "endsWith() returns true when suffix equals the whole string") + .group("endsWith() function with empty values") + .testEmpty("emptyString.endsWith('world!')", "endsWith() on empty input returns empty") + .testEmpty( + "singleString.endsWith({})", "endsWith() with empty suffix argument returns empty") + .group("endsWith() function error cases") + .testError( + "stringArray.endsWith('o')", "endsWith() errors when input collection is not singular") + .testError( + "singleString.endsWith(stringArray)", + "endsWith() errors when the argument is not singular") + .build(); + } + + @FhirPathTest + public Stream testContains() { + return builder() + .withSubject( + sb -> + sb + // Empty values + .stringEmpty("emptyString") + // Single values + .string("singleString", "Hello, world!") + // Arrays of strings + .stringArray("stringArray", "one", "two", "three")) + .group("contains() function with single values") + .testTrue( + "singleString.contains('lo, wo')", "contains() returns true when substring matches") + .testFalse( + "singleString.contains('goodbye')", + "contains() returns false when substring does not match") + .testTrue("singleString.contains('')", "contains() returns true for an empty substring") + .testTrue( + "singleString.contains('Hello, world!')", + "contains() returns true when substring equals the whole string") + .group("contains() function with empty values") + .testEmpty("emptyString.contains('lo')", "contains() on empty input returns empty") + .testEmpty( + "singleString.contains({})", "contains() with empty substring argument returns empty") + .group("contains() function error cases") + .testError( + "stringArray.contains('o')", "contains() errors when input collection is not singular") + .testError( + "singleString.contains(stringArray)", + "contains() errors when the argument is not singular") + .build(); + } } diff --git a/lib/python/tests/test_view.py b/lib/python/tests/test_view.py index 008fd9707e..e7014a88da 100644 --- a/lib/python/tests/test_view.py +++ b/lib/python/tests/test_view.py @@ -131,3 +131,36 @@ def test_foreach_coding_with_sibling_text_topandas(ndjson_test_data_dir, pathlin "Anemia (disorder)", ), ] + + +StringFunctionsRow = Row("starts_with_sey", "ends_with_r882", "contains_k43") + + +def test_view_with_string_functions(ndjson_test_data_dir, pathling_ctx): + data_source = pathling_ctx.read.ndjson(ndjson_test_data_dir) + result = data_source.view( + resource="Patient", + select=[ + { + "column": [ + { + "path": "name.given.first().startsWith('Sey')", + "name": "starts_with_sey", + }, + { + "path": "name.given.first().endsWith('r882')", + "name": "ends_with_r882", + }, + { + "path": "name.family.first().contains('k43')", + "name": "contains_k43", + }, + ] + } + ], + ) + assert result.columns == list(StringFunctionsRow) + assert result.limit(2).collect() == [ + StringFunctionsRow(True, True, True), + StringFunctionsRow(False, False, False), + ] diff --git a/site/docs/fhirpath/index.md b/site/docs/fhirpath/index.md index 087d3f6081..d0b3794426 100644 --- a/site/docs/fhirpath/index.md +++ b/site/docs/fhirpath/index.md @@ -157,9 +157,12 @@ current `name` element, matching `name.select(use | given)`. #### String functions -| Function | Description | -| ------------------ | ------------------------------------ | -| `join(separator?)` | Join strings with optional separator | +| Function | Description | +| --------------------- | ---------------------------------------------------- | +| `join(separator?)` | Join strings with optional separator | +| `startsWith(prefix)` | Test whether the string starts with the given prefix | +| `endsWith(suffix)` | Test whether the string ends with the given suffix | +| `contains(substring)` | Test whether the string contains the given substring | #### Type functions