Skip to content

Commit e6a3ed3

Browse files
AlexJSullyCopybara
andauthored
Implement '|' (union), 'contains' and 'in' operators (#29)
Copybara-ed version of @Quarz0 PR #25 with some addition changes in regards to some in-code comments. > Implement the collections operators: https://hl7.org/fhirpath/N1/#collections-2 GitOrigin-RevId: 185c3bcddf8abff6722afb6b5ad02c5a40a8c354 Co-authored-by: Copybara <copybara@example.com>
1 parent 4ed96d4 commit e6a3ed3

8 files changed

Lines changed: 356 additions & 6 deletions

File tree

fhirpath/fhirpath_test.go

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1130,6 +1130,16 @@ func TestFunctionInvocation_Evaluates(t *testing.T) {
11301130
wantCollection: system.Collection{system.String("Chu-Chu")},
11311131
compileOptions: []fhirpath.CompileOption{compopts.WithExperimentalFuncs()},
11321132
},
1133+
{
1134+
name: "returns the distinct union of two sets of names",
1135+
inputPath: "Patient.name.given | Patient.name.family",
1136+
inputCollection: []fhirpath.Resource{patientChu},
1137+
wantCollection: system.Collection{
1138+
fhir.String("Senpai"),
1139+
fhir.String("Kang"),
1140+
fhir.String("Chu"),
1141+
},
1142+
},
11331143
}
11341144

11351145
testEvaluate(t, testCases)
@@ -1220,6 +1230,61 @@ func TestTypeExpression_Evaluates(t *testing.T) {
12201230
testEvaluate(t, testCases)
12211231
}
12221232

1233+
func TestMembershipExpression_Evaluates(t *testing.T) {
1234+
testCases := []evaluateTestCase{
1235+
{
1236+
name: "returns true for 'in' operator with present value",
1237+
inputPath: "'a' in ('a' | 'b' | 'c')",
1238+
inputCollection: []fhirpath.Resource{},
1239+
wantCollection: system.Collection{system.Boolean(true)},
1240+
},
1241+
{
1242+
name: "returns false for 'in' operator with absent value",
1243+
inputPath: "'d' in ('a' | 'b' | 'c')",
1244+
inputCollection: []fhirpath.Resource{},
1245+
wantCollection: system.Collection{system.Boolean(false)},
1246+
},
1247+
{
1248+
name: "returns empty for 'in' operator with empty left side",
1249+
inputPath: "{} in ('a' | 'b' | 'c')",
1250+
inputCollection: []fhirpath.Resource{},
1251+
wantCollection: system.Collection{},
1252+
},
1253+
{
1254+
name: "returns false for 'in' operator with empty right side",
1255+
inputPath: "'a' in {}",
1256+
inputCollection: []fhirpath.Resource{},
1257+
wantCollection: system.Collection{system.Boolean(false)},
1258+
},
1259+
{
1260+
name: "returns true for 'contains' operator with present value",
1261+
inputPath: "('a' | 'b' | 'c') contains 'b'",
1262+
inputCollection: []fhirpath.Resource{},
1263+
wantCollection: system.Collection{system.Boolean(true)},
1264+
},
1265+
{
1266+
name: "returns false for 'contains' operator with absent value",
1267+
inputPath: "('a' | 'b' | 'c') contains 'd'",
1268+
inputCollection: []fhirpath.Resource{},
1269+
wantCollection: system.Collection{system.Boolean(false)},
1270+
},
1271+
{
1272+
name: "returns false for 'contains' operator with empty left side",
1273+
inputPath: "{} contains 'a'",
1274+
inputCollection: []fhirpath.Resource{},
1275+
wantCollection: system.Collection{system.Boolean(false)},
1276+
},
1277+
{
1278+
name: "returns empty for 'contains' operator with empty right side",
1279+
inputPath: "('a' | 'b' | 'c') contains {}",
1280+
inputCollection: []fhirpath.Resource{},
1281+
wantCollection: system.Collection{},
1282+
},
1283+
}
1284+
1285+
testEvaluate(t, testCases)
1286+
}
1287+
12231288
func TestBooleanExpression_Evaluates(t *testing.T) {
12241289
testCases := []evaluateTestCase{
12251290
{

fhirpath/internal/expr/expressions.go

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -808,3 +808,76 @@ func (e *NegationExpression) Evaluate(ctx *Context, input system.Collection) (sy
808808
}
809809

810810
var _ Expression = (*NegationExpression)(nil)
811+
812+
// UnionExpression combines two collections into a single collection, retaining only distinct elements.
813+
type UnionExpression struct {
814+
Left Expression
815+
Right Expression
816+
}
817+
818+
// MembershipExpression enables evaluation of the "in" and "contains" operators.
819+
type MembershipExpression struct {
820+
Left Expression
821+
Right Expression
822+
Operator Operator
823+
}
824+
825+
// Evaluate evaluates the left and right subexpressions and performs a membership check
826+
// according to the FHIRPath specification for 'in' and 'contains'.
827+
func (e *MembershipExpression) Evaluate(ctx *Context, input system.Collection) (system.Collection, error) {
828+
leftResult, err := e.Left.Evaluate(ctx.Clone(), input)
829+
if err != nil {
830+
return nil, err
831+
}
832+
rightResult, err := e.Right.Evaluate(ctx.Clone(), input)
833+
if err != nil {
834+
return nil, err
835+
}
836+
837+
switch e.Operator {
838+
case In:
839+
if leftResult.IsEmpty() {
840+
return system.Collection{}, nil
841+
}
842+
if !leftResult.IsSingleton() {
843+
return nil, fmt.Errorf("%w: 'in' operator requires the left operand to be a singleton, but got %d items", ErrNotSingleton, len(leftResult))
844+
}
845+
return system.Collection{system.Boolean(rightResult.Contains(leftResult[0]))}, nil
846+
case Contains:
847+
if rightResult.IsEmpty() {
848+
return system.Collection{}, nil
849+
}
850+
if !rightResult.IsSingleton() {
851+
return nil, fmt.Errorf("%w: 'contains' operator requires the right operand to be a singleton, but got %d items", ErrNotSingleton, len(rightResult))
852+
}
853+
return system.Collection{system.Boolean(leftResult.Contains(rightResult[0]))}, nil
854+
default:
855+
return nil, fmt.Errorf("%w: %s", ErrInvalidOperator, e.Operator)
856+
}
857+
}
858+
859+
var _ Expression = (*MembershipExpression)(nil)
860+
861+
// Evaluate executes the union expression by evaluating both the left and right expressions,
862+
// and then combining their results into a single collection with duplicates removed.
863+
func (e *UnionExpression) Evaluate(ctx *Context, input system.Collection) (system.Collection, error) {
864+
leftResult, err := e.Left.Evaluate(ctx.Clone(), input)
865+
if err != nil {
866+
return nil, err
867+
}
868+
rightResult, err := e.Right.Evaluate(ctx.Clone(), input)
869+
if err != nil {
870+
return nil, err
871+
}
872+
873+
// makes sure result is not nil if both inputs are empty or nil
874+
result := append(system.Collection{}, leftResult...)
875+
for _, item := range rightResult {
876+
if !result.Contains(item) {
877+
result = append(result, item)
878+
}
879+
}
880+
return result, nil
881+
}
882+
883+
var _ Expression = (*UnionExpression)(nil)

fhirpath/internal/expr/expressions_test.go

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1463,3 +1463,187 @@ func TestNegationExpression(t *testing.T) {
14631463
})
14641464
}
14651465
}
1466+
1467+
func TestUnionExpression(t *testing.T) {
1468+
testCases := []struct {
1469+
name string
1470+
expr *expr.UnionExpression
1471+
want system.Collection
1472+
wantErr error
1473+
}{
1474+
{
1475+
name: "unions two distinct collections",
1476+
expr: &expr.UnionExpression{
1477+
Left: exprtest.Return(system.Integer(1), system.Integer(2)),
1478+
Right: exprtest.Return(system.Integer(3), system.Integer(4)),
1479+
},
1480+
want: system.Collection{system.Integer(1), system.Integer(2), system.Integer(3), system.Integer(4)},
1481+
},
1482+
{
1483+
name: "unions two collections with overlap",
1484+
expr: &expr.UnionExpression{
1485+
Left: exprtest.Return(system.Integer(1), system.Integer(2)),
1486+
Right: exprtest.Return(system.Integer(2), system.Integer(3)),
1487+
},
1488+
want: system.Collection{system.Integer(1), system.Integer(2), system.Integer(3)},
1489+
},
1490+
{
1491+
name: "unions with an empty collection",
1492+
expr: &expr.UnionExpression{
1493+
Left: exprtest.Return(system.Integer(1), system.Integer(2)),
1494+
Right: exprtest.Return(),
1495+
},
1496+
want: system.Collection{system.Integer(1), system.Integer(2)},
1497+
},
1498+
{
1499+
name: "unions two empty collections",
1500+
expr: &expr.UnionExpression{
1501+
Left: exprtest.Return(),
1502+
Right: exprtest.Return(),
1503+
},
1504+
want: system.Collection{},
1505+
},
1506+
{
1507+
name: "propagates error from left expression",
1508+
expr: &expr.UnionExpression{
1509+
Left: exprtest.Error(errMock),
1510+
Right: exprtest.Return(system.Integer(1)),
1511+
},
1512+
wantErr: errMock,
1513+
},
1514+
{
1515+
name: "propagates error from right expression",
1516+
expr: &expr.UnionExpression{
1517+
Left: exprtest.Return(system.Integer(1)),
1518+
Right: exprtest.Error(errMock),
1519+
},
1520+
wantErr: errMock,
1521+
},
1522+
}
1523+
1524+
for _, tc := range testCases {
1525+
t.Run(tc.name, func(t *testing.T) {
1526+
got, err := tc.expr.Evaluate(&expr.Context{}, system.Collection{})
1527+
1528+
if !errors.Is(err, tc.wantErr) {
1529+
t.Fatalf("UnionExpression.Evaluate returned unexpected error: got %v, want %v", err, tc.wantErr)
1530+
}
1531+
if diff := cmp.Diff(tc.want, got, protocmp.Transform()); diff != "" {
1532+
t.Errorf("UnionExpression.Evaluate returned unexpected diff: (-want, +got)\n%s", diff)
1533+
}
1534+
})
1535+
}
1536+
}
1537+
1538+
func TestMembershipExpression(t *testing.T) {
1539+
testCases := []struct {
1540+
name string
1541+
expr *expr.MembershipExpression
1542+
want system.Collection
1543+
wantErr error
1544+
}{
1545+
{
1546+
name: "item is in collection",
1547+
expr: &expr.MembershipExpression{
1548+
Left: exprtest.Return(system.Integer(1)),
1549+
Right: exprtest.Return(system.Integer(1), system.Integer(2)),
1550+
Operator: expr.In,
1551+
},
1552+
want: system.Collection{system.Boolean(true)},
1553+
},
1554+
{
1555+
name: "item is not in collection",
1556+
expr: &expr.MembershipExpression{
1557+
Left: exprtest.Return(system.Integer(3)),
1558+
Right: exprtest.Return(system.Integer(1), system.Integer(2)),
1559+
Operator: expr.In,
1560+
},
1561+
want: system.Collection{system.Boolean(false)},
1562+
},
1563+
{
1564+
name: "left operand is empty",
1565+
expr: &expr.MembershipExpression{
1566+
Left: exprtest.Return(),
1567+
Right: exprtest.Return(system.Integer(1), system.Integer(2)),
1568+
Operator: expr.In,
1569+
},
1570+
want: system.Collection{},
1571+
},
1572+
{
1573+
name: "left operand is not a singleton",
1574+
expr: &expr.MembershipExpression{
1575+
Left: exprtest.Return(system.Integer(1), system.Integer(2)),
1576+
Right: exprtest.Return(system.Integer(1), system.Integer(2)),
1577+
Operator: expr.In,
1578+
},
1579+
wantErr: expr.ErrNotSingleton,
1580+
},
1581+
{
1582+
name: "collection contains item",
1583+
expr: &expr.MembershipExpression{
1584+
Left: exprtest.Return(system.Integer(1), system.Integer(2)),
1585+
Right: exprtest.Return(system.Integer(1)),
1586+
Operator: expr.Contains,
1587+
},
1588+
want: system.Collection{system.Boolean(true)},
1589+
},
1590+
{
1591+
name: "collection does not contain item",
1592+
expr: &expr.MembershipExpression{
1593+
Left: exprtest.Return(system.Integer(1), system.Integer(2)),
1594+
Right: exprtest.Return(system.Integer(3)),
1595+
Operator: expr.Contains,
1596+
},
1597+
want: system.Collection{system.Boolean(false)},
1598+
},
1599+
{
1600+
name: "right operand is empty",
1601+
expr: &expr.MembershipExpression{
1602+
Left: exprtest.Return(system.Integer(1), system.Integer(2)),
1603+
Right: exprtest.Return(),
1604+
Operator: expr.Contains,
1605+
},
1606+
want: system.Collection{},
1607+
},
1608+
{
1609+
name: "right operand is not a singleton",
1610+
expr: &expr.MembershipExpression{
1611+
Left: exprtest.Return(system.Integer(1), system.Integer(2)),
1612+
Right: exprtest.Return(system.Integer(1), system.Integer(2)),
1613+
Operator: expr.Contains,
1614+
},
1615+
wantErr: expr.ErrNotSingleton,
1616+
},
1617+
{
1618+
name: "propagates error from left expression",
1619+
expr: &expr.MembershipExpression{
1620+
Left: exprtest.Error(errMock),
1621+
Right: exprtest.Return(system.Integer(1)),
1622+
Operator: expr.In,
1623+
},
1624+
wantErr: errMock,
1625+
},
1626+
{
1627+
name: "propagates error from right expression",
1628+
expr: &expr.MembershipExpression{
1629+
Left: exprtest.Return(system.Integer(1)),
1630+
Right: exprtest.Error(errMock),
1631+
Operator: expr.In,
1632+
},
1633+
wantErr: errMock,
1634+
},
1635+
}
1636+
1637+
for _, tc := range testCases {
1638+
t.Run(tc.name, func(t *testing.T) {
1639+
got, err := tc.expr.Evaluate(&expr.Context{}, system.Collection{})
1640+
1641+
if !errors.Is(err, tc.wantErr) {
1642+
t.Fatalf("MembershipExpression.Evaluate returned unexpected error: got %v, want %v", err, tc.wantErr)
1643+
}
1644+
if diff := cmp.Diff(tc.want, got); diff != "" {
1645+
t.Errorf("MembershipExpression.Evaluate returned unexpected diff: (-want, +got)\n%s", diff)
1646+
}
1647+
})
1648+
}
1649+
}

fhirpath/internal/expr/operators.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ const (
2323
Div = "/"
2424
FloorDiv = "div"
2525
Mod = "mod"
26+
In = "in"
27+
Contains = "contains"
2628
)
2729

2830
// Operator represents a valid expression operator.

fhirpath/internal/funcs/impl/terminology.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ var (
1515
ErrNotSupported = errors.New("Not Supported, memberOf must be called on a single Coding or a CodeableConcept")
1616
)
1717

18-
// MemberOf takes a Coding or a Codeable concept and a ValueSet internal id
18+
// MemberOf takes a Coding or a CodeableConcept and a ValueSet internal ID
1919
// and determines if the coding of any of the coding is inside the ValueSet
2020
func MemberOf(ctx *expr.Context, input system.Collection, args ...expr.Expression) (system.Collection, error) {
2121
if length := len(args); length != 1 {
@@ -56,7 +56,7 @@ func MemberOf(ctx *expr.Context, input system.Collection, args ...expr.Expressio
5656
validateResult = result
5757

5858
case *dtpb.CodeableConcept:
59-
// If it's a Codeable Concept, we will checking the coding inside one by one
59+
// If it's a CodeableConcept, check the coding inside one by one
6060
for _, coding := range res.GetCoding() {
6161
result, err := validateCoding(ctx, coding.GetCode().GetValue(), coding.GetSystem().GetValue(), valueSetId)
6262
if err != nil {

fhirpath/internal/funcs/table.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,12 @@ var baseTable = FunctionTable{
6262
1,
6363
false,
6464
},
65-
"supersetOf": notImplemented,
65+
"supersetOf": Function{
66+
impl.SupersetOf,
67+
1,
68+
1,
69+
false,
70+
},
6671
"count": Function{
6772
impl.Count,
6873
0,

0 commit comments

Comments
 (0)