From 5b3fd8fab5240d5c1b38ec4ab74c7e500eb61187 Mon Sep 17 00:00:00 2001 From: Victor Frank Date: Mon, 30 Jun 2025 17:18:46 -0500 Subject: [PATCH 1/2] #140 add support for intervals on ProperlyIncludesIn --- interpreter/operator_dispatcher.go | 32 +++ interpreter/operator_interval.go | 277 ++++++++++++++++++++ parser/operators.go | 8 + tests/enginetests/operator_interval_test.go | 66 +++++ tests/spectests/exclusions/exclusions.go | 4 +- 5 files changed, 386 insertions(+), 1 deletion(-) diff --git a/interpreter/operator_dispatcher.go b/interpreter/operator_dispatcher.go index af782da..aabccde 100644 --- a/interpreter/operator_dispatcher.go +++ b/interpreter/operator_dispatcher.go @@ -1246,6 +1246,38 @@ func (i *interpreter) binaryOverloads(m model.IBinaryExpression) ([]convert.Over Operands: []types.IType{&types.List{ElementType: types.Any}, &types.List{ElementType: types.Any}}, Result: evalProperlyIncludedInList, }, + { + Operands: []types.IType{&types.Interval{PointType: types.Date}, &types.Interval{PointType: types.Date}}, + Result: i.evalProperlyIncludedInInterval, + }, + { + Operands: []types.IType{&types.Interval{PointType: types.DateTime}, &types.Interval{PointType: types.DateTime}}, + Result: i.evalProperlyIncludedInInterval, + }, + { + Operands: []types.IType{&types.Interval{PointType: types.Integer}, &types.Interval{PointType: types.Integer}}, + Result: i.evalProperlyIncludedInInterval, + }, + { + Operands: []types.IType{&types.Interval{PointType: types.Long}, &types.Interval{PointType: types.Long}}, + Result: i.evalProperlyIncludedInInterval, + }, + { + Operands: []types.IType{&types.Interval{PointType: types.Decimal}, &types.Interval{PointType: types.Decimal}}, + Result: i.evalProperlyIncludedInInterval, + }, + { + Operands: []types.IType{&types.Interval{PointType: types.Quantity}, &types.Interval{PointType: types.Quantity}}, + Result: i.evalProperlyIncludedInInterval, + }, + { + Operands: []types.IType{&types.Interval{PointType: types.String}, &types.Interval{PointType: types.String}}, + Result: i.evalProperlyIncludedInInterval, + }, + { + Operands: []types.IType{&types.Interval{PointType: types.Time}, &types.Interval{PointType: types.Time}}, + Result: i.evalProperlyIncludedInInterval, + }, }, nil case *model.Skip: return []convert.Overload[evalBinarySignature]{ diff --git a/interpreter/operator_interval.go b/interpreter/operator_interval.go index e0461dc..b5eae21 100644 --- a/interpreter/operator_interval.go +++ b/interpreter/operator_interval.go @@ -641,3 +641,280 @@ func evalWidthInterval(m model.IUnaryExpression, intervalObj result.Value) (resu } return result.Value{}, fmt.Errorf("internal error - unsupported point type in evalWidthInterval: %v", start.RuntimeType()) } + + +// ProperlyIncludedIn(left Interval, right Interval) Boolean +// ProperlyIncludedIn(left Interval, right Interval) Boolean +// https://cql.hl7.org/09-b-cqlreference.html#properly-included-in-1 +func (i *interpreter) evalProperlyIncludedInInterval(m model.IBinaryExpression, lObj, rObj result.Value) (result.Value, error) { + if result.IsNull(lObj) || result.IsNull(rObj) { + return result.New(nil) + } + + // ProperlyIncludedIn(A, B) = IncludedIn(A, B) and A != B + // First check if left interval is included in right interval + // We can use the existing interval inclusion logic by checking if all points of left are in right + + // Get interval bounds + leftStart, leftEnd, err := startAndEnd(lObj, &i.evaluationTimestamp) + if err != nil { + return result.Value{}, err + } + rightStart, rightEnd, err := startAndEnd(rObj, &i.evaluationTimestamp) + if err != nil { + return result.Value{}, err + } + + // Check if left interval is included in right interval + // This means: rightStart <= leftStart AND leftEnd <= rightEnd + var includedIn bool + + // Handle null bounds + if result.IsNull(leftStart) || result.IsNull(leftEnd) || result.IsNull(rightStart) || result.IsNull(rightEnd) { + return result.New(nil) + } + + // Compare based on the point type + leftInterval, err := result.ToInterval(lObj) + if err != nil { + return result.Value{}, err + } + rightInterval, err := result.ToInterval(rObj) + if err != nil { + return result.Value{}, err + } + + // Check if left is included in right + if leftInterval.StaticType.PointType == types.Date || leftInterval.StaticType.PointType == types.DateTime { + // For temporal types, use DateTime comparison + leftStartDT, err := result.ToDateTime(leftStart) + if err != nil { + return result.Value{}, err + } + leftEndDT, err := result.ToDateTime(leftEnd) + if err != nil { + return result.Value{}, err + } + rightStartDT, err := result.ToDateTime(rightStart) + if err != nil { + return result.Value{}, err + } + rightEndDT, err := result.ToDateTime(rightEnd) + if err != nil { + return result.Value{}, err + } + + // Check: rightStart <= leftStart AND leftEnd <= rightEnd + startComp, err := compareDateTimeWithPrecision(rightStartDT, leftStartDT, "") + if err != nil { + return result.Value{}, err + } + endComp, err := compareDateTimeWithPrecision(leftEndDT, rightEndDT, "") + if err != nil { + return result.Value{}, err + } + + if startComp == insufficientPrecision || endComp == insufficientPrecision { + return result.New(nil) + } + + includedIn = (startComp == leftBeforeRight || startComp == leftEqualRight) && + (endComp == leftBeforeRight || endComp == leftEqualRight) + } else { + // For numeric types, use type-specific comparison + if leftInterval.StaticType.PointType == types.Integer { + leftStartInt, err := result.ToInt32(leftStart) + if err != nil { + return result.Value{}, err + } + leftEndInt, err := result.ToInt32(leftEnd) + if err != nil { + return result.Value{}, err + } + rightStartInt, err := result.ToInt32(rightStart) + if err != nil { + return result.Value{}, err + } + rightEndInt, err := result.ToInt32(rightEnd) + if err != nil { + return result.Value{}, err + } + + includedIn = rightStartInt <= leftStartInt && leftEndInt <= rightEndInt + } else if leftInterval.StaticType.PointType == types.Long { + leftStartLong, err := result.ToInt64(leftStart) + if err != nil { + return result.Value{}, err + } + leftEndLong, err := result.ToInt64(leftEnd) + if err != nil { + return result.Value{}, err + } + rightStartLong, err := result.ToInt64(rightStart) + if err != nil { + return result.Value{}, err + } + rightEndLong, err := result.ToInt64(rightEnd) + if err != nil { + return result.Value{}, err + } + + includedIn = rightStartLong <= leftStartLong && leftEndLong <= rightEndLong + } else if leftInterval.StaticType.PointType == types.Decimal { + leftStartFloat, err := result.ToFloat64(leftStart) + if err != nil { + return result.Value{}, err + } + leftEndFloat, err := result.ToFloat64(leftEnd) + if err != nil { + return result.Value{}, err + } + rightStartFloat, err := result.ToFloat64(rightStart) + if err != nil { + return result.Value{}, err + } + rightEndFloat, err := result.ToFloat64(rightEnd) + if err != nil { + return result.Value{}, err + } + + includedIn = rightStartFloat <= leftStartFloat && leftEndFloat <= rightEndFloat + } else if leftInterval.StaticType.PointType == types.Quantity { + leftStartQty, err := result.ToQuantity(leftStart) + if err != nil { + return result.Value{}, err + } + leftEndQty, err := result.ToQuantity(leftEnd) + if err != nil { + return result.Value{}, err + } + rightStartQty, err := result.ToQuantity(rightStart) + if err != nil { + return result.Value{}, err + } + rightEndQty, err := result.ToQuantity(rightEnd) + if err != nil { + return result.Value{}, err + } + + // Check units match + if leftStartQty.Unit != rightStartQty.Unit || leftEndQty.Unit != rightEndQty.Unit { + return result.Value{}, fmt.Errorf("ProperlyIncludedIn operator received Quantities with differing unit values") + } + + includedIn = rightStartQty.Value <= leftStartQty.Value && leftEndQty.Value <= rightEndQty.Value + } else if leftInterval.StaticType.PointType == types.Time { + // For Time types, we can't use float64 conversion, so we'll use a different approach + // Compare times by converting to a comparable format + leftStartTime, err := result.ToTime(leftStart) + if err != nil { + return result.Value{}, err + } + leftEndTime, err := result.ToTime(leftEnd) + if err != nil { + return result.Value{}, err + } + rightStartTime, err := result.ToTime(rightStart) + if err != nil { + return result.Value{}, err + } + rightEndTime, err := result.ToTime(rightEnd) + if err != nil { + return result.Value{}, err + } + + // Compare times using their internal representation + rightStartNanos := rightStartTime.Date.UnixNano() + leftStartNanos := leftStartTime.Date.UnixNano() + leftEndNanos := leftEndTime.Date.UnixNano() + rightEndNanos := rightEndTime.Date.UnixNano() + + includedIn = rightStartNanos <= leftStartNanos && leftEndNanos <= rightEndNanos + } else { + // For other types, try float conversion as fallback + leftStartFloat, err := result.ToFloat64(leftStart) + if err != nil { + return result.Value{}, err + } + leftEndFloat, err := result.ToFloat64(leftEnd) + if err != nil { + return result.Value{}, err + } + rightStartFloat, err := result.ToFloat64(rightStart) + if err != nil { + return result.Value{}, err + } + rightEndFloat, err := result.ToFloat64(rightEnd) + if err != nil { + return result.Value{}, err + } + + includedIn = rightStartFloat <= leftStartFloat && leftEndFloat <= rightEndFloat + } + } + + if !includedIn { + return result.New(false) + } + + // Now check if intervals are equal + // Two intervals are equal if they have the same bounds and inclusivity + leftStartEqual := false + leftEndEqual := false + rightStartEqual := false + rightEndEqual := false + + if leftInterval.StaticType.PointType == types.Date || leftInterval.StaticType.PointType == types.DateTime { + leftStartDT, _ := result.ToDateTime(leftStart) + leftEndDT, _ := result.ToDateTime(leftEnd) + rightStartDT, _ := result.ToDateTime(rightStart) + rightEndDT, _ := result.ToDateTime(rightEnd) + + startComp, err := compareDateTimeWithPrecision(leftStartDT, rightStartDT, "") + if err != nil { + return result.Value{}, err + } + endComp, err := compareDateTimeWithPrecision(leftEndDT, rightEndDT, "") + if err != nil { + return result.Value{}, err + } + + leftStartEqual = (startComp == leftEqualRight) + leftEndEqual = (endComp == leftEqualRight) + } else { + // For numeric types, handle different types properly + if leftInterval.StaticType.PointType == types.Integer { + leftStartInt, _ := result.ToInt32(leftStart) + leftEndInt, _ := result.ToInt32(leftEnd) + rightStartInt, _ := result.ToInt32(rightStart) + rightEndInt, _ := result.ToInt32(rightEnd) + + leftStartEqual = (leftStartInt == rightStartInt) + leftEndEqual = (leftEndInt == rightEndInt) + } else if leftInterval.StaticType.PointType == types.Long { + leftStartLong, _ := result.ToInt64(leftStart) + leftEndLong, _ := result.ToInt64(leftEnd) + rightStartLong, _ := result.ToInt64(rightStart) + rightEndLong, _ := result.ToInt64(rightEnd) + + leftStartEqual = (leftStartLong == rightStartLong) + leftEndEqual = (leftEndLong == rightEndLong) + } else { + // For Decimal, Quantity, and other types, try float conversion + leftStartFloat, _ := result.ToFloat64(leftStart) + leftEndFloat, _ := result.ToFloat64(leftEnd) + rightStartFloat, _ := result.ToFloat64(rightStart) + rightEndFloat, _ := result.ToFloat64(rightEnd) + + leftStartEqual = (leftStartFloat == rightStartFloat) + leftEndEqual = (leftEndFloat == rightEndFloat) + } + } + + rightStartEqual = (leftInterval.LowInclusive == rightInterval.LowInclusive) + rightEndEqual = (leftInterval.HighInclusive == rightInterval.HighInclusive) + + isEqual := leftStartEqual && leftEndEqual && rightStartEqual && rightEndEqual + + return result.New(includedIn && !isEqual) +} \ No newline at end of file diff --git a/parser/operators.go b/parser/operators.go index 1f616c2..00b93b1 100644 --- a/parser/operators.go +++ b/parser/operators.go @@ -1871,6 +1871,14 @@ func (p *Parser) loadSystemOperators() error { operands: [][]types.IType{ {convert.GenericType, convert.GenericList}, {convert.GenericList, convert.GenericList}, + {&types.Interval{PointType: types.Date}, &types.Interval{PointType: types.Date}}, + {&types.Interval{PointType: types.DateTime}, &types.Interval{PointType: types.DateTime}}, + {&types.Interval{PointType: types.Integer}, &types.Interval{PointType: types.Integer}}, + {&types.Interval{PointType: types.Long}, &types.Interval{PointType: types.Long}}, + {&types.Interval{PointType: types.Decimal}, &types.Interval{PointType: types.Decimal}}, + {&types.Interval{PointType: types.Quantity}, &types.Interval{PointType: types.Quantity}}, + {&types.Interval{PointType: types.String}, &types.Interval{PointType: types.String}}, + {&types.Interval{PointType: types.Time}, &types.Interval{PointType: types.Time}}, }, model: func() model.IExpression { return &model.ProperlyIncludedIn{ diff --git a/tests/enginetests/operator_interval_test.go b/tests/enginetests/operator_interval_test.go index 595a902..e00a08a 100644 --- a/tests/enginetests/operator_interval_test.go +++ b/tests/enginetests/operator_interval_test.go @@ -125,6 +125,72 @@ func TestEnd(t *testing.T) { } } +func TestProperlyIncludedInInterval(t *testing.T) { + tests := []struct { + name string + cql string + want result.Value + }{ + { + name: "Properly included Date intervals", + cql: `Interval[@2020-01-05, @2020-01-15] properly included in Interval[@2020-01-01, @2020-01-20]`, + want: newOrFatal(t, true), + }, + { + name: "Equal Date intervals not properly included", + cql: `Interval[@2020-01-01, @2020-01-15] properly included in Interval[@2020-01-01, @2020-01-15]`, + want: newOrFatal(t, false), + }, + { + name: "Not included Date intervals", + cql: `Interval[@2020-01-01, @2020-01-25] properly included in Interval[@2020-01-05, @2020-01-15]`, + want: newOrFatal(t, false), + }, + { + name: "Properly included Integer intervals", + cql: `Interval[5, 15] properly included in Interval[1, 20]`, + want: newOrFatal(t, true), + }, + { + name: "Equal Integer intervals not properly included", + cql: `Interval[1, 15] properly included in Interval[1, 15]`, + want: newOrFatal(t, false), + }, + { + name: "Not included Integer intervals", + cql: `Interval[1, 25] properly included in Interval[5, 15]`, + want: newOrFatal(t, false), + }, + { + name: "Null left interval", + cql: `(null as Interval) properly included in Interval[@2020-01-01, @2020-01-15]`, + want: newOrFatal(t, nil), + }, + { + name: "Null right interval", + cql: `Interval[@2020-01-01, @2020-01-15] properly included in (null as Interval)`, + want: newOrFatal(t, nil), + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + p := newFHIRParser(t) + parsedLibs, err := p.Libraries(context.Background(), wrapInLib(t, tc.cql), parser.Config{}) + if err != nil { + t.Fatalf("Parse returned unexpected error: %v", err) + } + results, err := interpreter.Eval(context.Background(), parsedLibs, defaultInterpreterConfig(t, p)) + if err != nil { + t.Fatalf("Eval returned unexpected error: %v", err) + } + if diff := cmp.Diff(tc.want, getTESTRESULT(t, results), protocmp.Transform()); diff != "" { + t.Errorf("Evaluate diff (-want +got):\n%s", diff) + } + }) + } +} + func TestStart(t *testing.T) { tests := []struct { name string diff --git a/tests/spectests/exclusions/exclusions.go b/tests/spectests/exclusions/exclusions.go index 50d255e..699df9f 100644 --- a/tests/spectests/exclusions/exclusions.go +++ b/tests/spectests/exclusions/exclusions.go @@ -235,11 +235,13 @@ func XMLTestFileExclusionDefinitions() map[string]XMLTestFileExclusions { "ProperContains", "ProperIn", "ProperlyIncludes", - "ProperlyIncludedIn", "Starts", "Union", }, NamesExcludes: []string{ + "IntegerIntervalProperlyIncludedInNullBoundaries", + "QuantityIntervalProperlyIncludedInTrue", + "TimeProperlyIncludedInTrue", // TODO: b/342061715 - unsupported operators. // Note: overlaps before and after are not supported. but these tests are missing the // before/after keyword for Date/Time test cases so they are not excluded. From 988b0ba451bb9252e1ea5384b154080331c1d3a4 Mon Sep 17 00:00:00 2001 From: Victor Frank Date: Sun, 11 Oct 2026 11:03:07 -0400 Subject: [PATCH 2/2] #140 address review comments: shared point comparison, unknown bounds, ucum ProperlyIncludedIn for intervals now compares the closed start and end of both intervals through one helper, compareIntervalPoints, built on a generic compareValues. Quantity points convert units with the ucum library instead of rejecting differing units. An unknown start is bounded by the interval's end (and an unknown end by its start), so Interval(null, 1] properly included in Interval[5, null) returns false instead of null. Comparing the normalised bounds also fixes two cases the old equality check got wrong: Interval[1, 5] and Interval[1, 6) are the same interval, and Quantity and Time bounds were compared as zero floats. The QuantityIntervalProperlyIncludedInTrue and TimeProperlyIncludedInTrue spec tests now pass. IntegerIntervalProperlyIncludedInNullBoundaries stays excluded under the existing ambiguous-match bug, because Interval[null, null] does not resolve in the parser. The Interval overload is removed: the Interval selector has no String overload, so it could not be reached. --- interpreter/operator_dispatcher.go | 4 - interpreter/operator_interval.go | 306 +++++--------------- parser/operators.go | 1 - tests/enginetests/operator_interval_test.go | 65 +++++ tests/spectests/exclusions/exclusions.go | 4 +- 5 files changed, 134 insertions(+), 246 deletions(-) diff --git a/interpreter/operator_dispatcher.go b/interpreter/operator_dispatcher.go index aabccde..a2f165d 100644 --- a/interpreter/operator_dispatcher.go +++ b/interpreter/operator_dispatcher.go @@ -1270,10 +1270,6 @@ func (i *interpreter) binaryOverloads(m model.IBinaryExpression) ([]convert.Over Operands: []types.IType{&types.Interval{PointType: types.Quantity}, &types.Interval{PointType: types.Quantity}}, Result: i.evalProperlyIncludedInInterval, }, - { - Operands: []types.IType{&types.Interval{PointType: types.String}, &types.Interval{PointType: types.String}}, - Result: i.evalProperlyIncludedInInterval, - }, { Operands: []types.IType{&types.Interval{PointType: types.Time}, &types.Interval{PointType: types.Time}}, Result: i.evalProperlyIncludedInInterval, diff --git a/interpreter/operator_interval.go b/interpreter/operator_interval.go index b5eae21..ef09b1b 100644 --- a/interpreter/operator_interval.go +++ b/interpreter/operator_interval.go @@ -15,6 +15,7 @@ package interpreter import ( + "cmp" "fmt" "time" @@ -642,20 +643,18 @@ func evalWidthInterval(m model.IUnaryExpression, intervalObj result.Value) (resu return result.Value{}, fmt.Errorf("internal error - unsupported point type in evalWidthInterval: %v", start.RuntimeType()) } - -// ProperlyIncludedIn(left Interval, right Interval) Boolean -// ProperlyIncludedIn(left Interval, right Interval) Boolean +// ProperlyIncludedIn(left Interval, right Interval) Boolean // https://cql.hl7.org/09-b-cqlreference.html#properly-included-in-1 -func (i *interpreter) evalProperlyIncludedInInterval(m model.IBinaryExpression, lObj, rObj result.Value) (result.Value, error) { +func (i *interpreter) evalProperlyIncludedInInterval(b model.IBinaryExpression, lObj, rObj result.Value) (result.Value, error) { if result.IsNull(lObj) || result.IsNull(rObj) { return result.New(nil) } + if m, ok := b.(*model.ProperlyIncludedIn); ok && m.Precision != model.UNSETDATETIMEPRECISION { + return result.Value{}, fmt.Errorf("internal error - properly included in does not yet support precision") + } - // ProperlyIncludedIn(A, B) = IncludedIn(A, B) and A != B - // First check if left interval is included in right interval - // We can use the existing interval inclusion logic by checking if all points of left are in right - - // Get interval bounds + // start and end handle null inclusivity and open boundaries, so every bound below is closed and + // a null bound is unknown. leftStart, leftEnd, err := startAndEnd(lObj, &i.evaluationTimestamp) if err != nil { return result.Value{}, err @@ -665,256 +664,87 @@ func (i *interpreter) evalProperlyIncludedInInterval(m model.IBinaryExpression, return result.Value{}, err } - // Check if left interval is included in right interval - // This means: rightStart <= leftStart AND leftEnd <= rightEnd - var includedIn bool - - // Handle null bounds - if result.IsNull(leftStart) || result.IsNull(leftEnd) || result.IsNull(rightStart) || result.IsNull(rightEnd) { - return result.New(nil) - } - - // Compare based on the point type - leftInterval, err := result.ToInterval(lObj) + startComp, err := compareIntervalPoints(leftStart, rightStart) if err != nil { return result.Value{}, err } - rightInterval, err := result.ToInterval(rObj) + endComp, err := compareIntervalPoints(leftEnd, rightEnd) if err != nil { return result.Value{}, err } + if startComp == leftBeforeRight || endComp == leftAfterRight { + return result.New(false) + } - // Check if left is included in right - if leftInterval.StaticType.PointType == types.Date || leftInterval.StaticType.PointType == types.DateTime { - // For temporal types, use DateTime comparison - leftStartDT, err := result.ToDateTime(leftStart) - if err != nil { - return result.Value{}, err - } - leftEndDT, err := result.ToDateTime(leftEnd) - if err != nil { - return result.Value{}, err - } - rightStartDT, err := result.ToDateTime(rightStart) + // An unknown start is at most the left end and an unknown end is at least the left start, so + // Interval(null, 1] properly included in Interval[5, null) is still false. + if result.IsNull(leftStart) { + c, err := compareIntervalPoints(leftEnd, rightStart) if err != nil { return result.Value{}, err } - rightEndDT, err := result.ToDateTime(rightEnd) - if err != nil { - return result.Value{}, err - } - - // Check: rightStart <= leftStart AND leftEnd <= rightEnd - startComp, err := compareDateTimeWithPrecision(rightStartDT, leftStartDT, "") - if err != nil { - return result.Value{}, err + if c == leftBeforeRight { + return result.New(false) } - endComp, err := compareDateTimeWithPrecision(leftEndDT, rightEndDT, "") + } + if result.IsNull(leftEnd) { + c, err := compareIntervalPoints(leftStart, rightEnd) if err != nil { return result.Value{}, err } - - if startComp == insufficientPrecision || endComp == insufficientPrecision { - return result.New(nil) - } - - includedIn = (startComp == leftBeforeRight || startComp == leftEqualRight) && - (endComp == leftBeforeRight || endComp == leftEqualRight) - } else { - // For numeric types, use type-specific comparison - if leftInterval.StaticType.PointType == types.Integer { - leftStartInt, err := result.ToInt32(leftStart) - if err != nil { - return result.Value{}, err - } - leftEndInt, err := result.ToInt32(leftEnd) - if err != nil { - return result.Value{}, err - } - rightStartInt, err := result.ToInt32(rightStart) - if err != nil { - return result.Value{}, err - } - rightEndInt, err := result.ToInt32(rightEnd) - if err != nil { - return result.Value{}, err - } - - includedIn = rightStartInt <= leftStartInt && leftEndInt <= rightEndInt - } else if leftInterval.StaticType.PointType == types.Long { - leftStartLong, err := result.ToInt64(leftStart) - if err != nil { - return result.Value{}, err - } - leftEndLong, err := result.ToInt64(leftEnd) - if err != nil { - return result.Value{}, err - } - rightStartLong, err := result.ToInt64(rightStart) - if err != nil { - return result.Value{}, err - } - rightEndLong, err := result.ToInt64(rightEnd) - if err != nil { - return result.Value{}, err - } - - includedIn = rightStartLong <= leftStartLong && leftEndLong <= rightEndLong - } else if leftInterval.StaticType.PointType == types.Decimal { - leftStartFloat, err := result.ToFloat64(leftStart) - if err != nil { - return result.Value{}, err - } - leftEndFloat, err := result.ToFloat64(leftEnd) - if err != nil { - return result.Value{}, err - } - rightStartFloat, err := result.ToFloat64(rightStart) - if err != nil { - return result.Value{}, err - } - rightEndFloat, err := result.ToFloat64(rightEnd) - if err != nil { - return result.Value{}, err - } - - includedIn = rightStartFloat <= leftStartFloat && leftEndFloat <= rightEndFloat - } else if leftInterval.StaticType.PointType == types.Quantity { - leftStartQty, err := result.ToQuantity(leftStart) - if err != nil { - return result.Value{}, err - } - leftEndQty, err := result.ToQuantity(leftEnd) - if err != nil { - return result.Value{}, err - } - rightStartQty, err := result.ToQuantity(rightStart) - if err != nil { - return result.Value{}, err - } - rightEndQty, err := result.ToQuantity(rightEnd) - if err != nil { - return result.Value{}, err - } - - // Check units match - if leftStartQty.Unit != rightStartQty.Unit || leftEndQty.Unit != rightEndQty.Unit { - return result.Value{}, fmt.Errorf("ProperlyIncludedIn operator received Quantities with differing unit values") - } - - includedIn = rightStartQty.Value <= leftStartQty.Value && leftEndQty.Value <= rightEndQty.Value - } else if leftInterval.StaticType.PointType == types.Time { - // For Time types, we can't use float64 conversion, so we'll use a different approach - // Compare times by converting to a comparable format - leftStartTime, err := result.ToTime(leftStart) - if err != nil { - return result.Value{}, err - } - leftEndTime, err := result.ToTime(leftEnd) - if err != nil { - return result.Value{}, err - } - rightStartTime, err := result.ToTime(rightStart) - if err != nil { - return result.Value{}, err - } - rightEndTime, err := result.ToTime(rightEnd) - if err != nil { - return result.Value{}, err - } - - // Compare times using their internal representation - rightStartNanos := rightStartTime.Date.UnixNano() - leftStartNanos := leftStartTime.Date.UnixNano() - leftEndNanos := leftEndTime.Date.UnixNano() - rightEndNanos := rightEndTime.Date.UnixNano() - - includedIn = rightStartNanos <= leftStartNanos && leftEndNanos <= rightEndNanos - } else { - // For other types, try float conversion as fallback - leftStartFloat, err := result.ToFloat64(leftStart) - if err != nil { - return result.Value{}, err - } - leftEndFloat, err := result.ToFloat64(leftEnd) - if err != nil { - return result.Value{}, err - } - rightStartFloat, err := result.ToFloat64(rightStart) - if err != nil { - return result.Value{}, err - } - rightEndFloat, err := result.ToFloat64(rightEnd) - if err != nil { - return result.Value{}, err - } - - includedIn = rightStartFloat <= leftStartFloat && leftEndFloat <= rightEndFloat + if c == leftAfterRight { + return result.New(false) } } - if !includedIn { - return result.New(false) + startKnown := startComp == leftEqualRight || startComp == leftAfterRight + endKnown := endComp == leftEqualRight || endComp == leftBeforeRight + if !startKnown || !endKnown { + return result.New(nil) } + return result.New(startComp == leftAfterRight || endComp == leftBeforeRight) +} - // Now check if intervals are equal - // Two intervals are equal if they have the same bounds and inclusivity - leftStartEqual := false - leftEndEqual := false - rightStartEqual := false - rightEndEqual := false - - if leftInterval.StaticType.PointType == types.Date || leftInterval.StaticType.PointType == types.DateTime { - leftStartDT, _ := result.ToDateTime(leftStart) - leftEndDT, _ := result.ToDateTime(leftEnd) - rightStartDT, _ := result.ToDateTime(rightStart) - rightEndDT, _ := result.ToDateTime(rightEnd) - - startComp, err := compareDateTimeWithPrecision(leftStartDT, rightStartDT, "") +// compareIntervalPoints compares two interval points of the same point type. It returns +// comparedToNull if either point is null or if Quantity units cannot be converted. +func compareIntervalPoints(l, r result.Value) (comparison, error) { + if result.IsNull(l) || result.IsNull(r) { + return comparedToNull, nil + } + switch l.RuntimeType() { + case types.Integer: + return compareValues(l, r, result.ToInt32) + case types.Long: + return compareValues(l, r, result.ToInt64) + case types.Decimal: + return compareValues(l, r, result.ToFloat64) + case types.Quantity: + lq, rq, err := applyToValues(l, r, result.ToQuantity) if err != nil { - return result.Value{}, err + return unsetComparison, err + } + if lq.Unit == rq.Unit { + return toComparison(cmp.Compare(lq.Value, rq.Value)), nil } - endComp, err := compareDateTimeWithPrecision(leftEndDT, rightEndDT, "") + converted, err := ucum.ConvertUnit(lq.Value, string(lq.Unit), string(rq.Unit)) if err != nil { - return result.Value{}, err + return comparedToNull, nil + } + return toComparison(cmp.Compare(converted, rq.Value)), nil + case types.Date, types.DateTime, types.Time: + ldt, rdt, err := applyToValues(l, r, result.ToDateTime) + if err != nil { + return unsetComparison, err } + return compareDateTime(ldt, rdt) + } + return unsetComparison, fmt.Errorf("internal error - unsupported point type in compareIntervalPoints: %v", l.RuntimeType()) +} - leftStartEqual = (startComp == leftEqualRight) - leftEndEqual = (endComp == leftEqualRight) - } else { - // For numeric types, handle different types properly - if leftInterval.StaticType.PointType == types.Integer { - leftStartInt, _ := result.ToInt32(leftStart) - leftEndInt, _ := result.ToInt32(leftEnd) - rightStartInt, _ := result.ToInt32(rightStart) - rightEndInt, _ := result.ToInt32(rightEnd) - - leftStartEqual = (leftStartInt == rightStartInt) - leftEndEqual = (leftEndInt == rightEndInt) - } else if leftInterval.StaticType.PointType == types.Long { - leftStartLong, _ := result.ToInt64(leftStart) - leftEndLong, _ := result.ToInt64(leftEnd) - rightStartLong, _ := result.ToInt64(rightStart) - rightEndLong, _ := result.ToInt64(rightEnd) - - leftStartEqual = (leftStartLong == rightStartLong) - leftEndEqual = (leftEndLong == rightEndLong) - } else { - // For Decimal, Quantity, and other types, try float conversion - leftStartFloat, _ := result.ToFloat64(leftStart) - leftEndFloat, _ := result.ToFloat64(leftEnd) - rightStartFloat, _ := result.ToFloat64(rightStart) - rightEndFloat, _ := result.ToFloat64(rightEnd) - - leftStartEqual = (leftStartFloat == rightStartFloat) - leftEndEqual = (leftEndFloat == rightEndFloat) - } - } - - rightStartEqual = (leftInterval.LowInclusive == rightInterval.LowInclusive) - rightEndEqual = (leftInterval.HighInclusive == rightInterval.HighInclusive) - - isEqual := leftStartEqual && leftEndEqual && rightStartEqual && rightEndEqual - - return result.New(includedIn && !isEqual) -} \ No newline at end of file +func compareValues[t cmp.Ordered](l, r result.Value, fn func(result.Value) (t, error)) (comparison, error) { + lv, rv, err := applyToValues(l, r, fn) + if err != nil { + return unsetComparison, err + } + return toComparison(cmp.Compare(lv, rv)), nil +} diff --git a/parser/operators.go b/parser/operators.go index 00b93b1..50efab7 100644 --- a/parser/operators.go +++ b/parser/operators.go @@ -1877,7 +1877,6 @@ func (p *Parser) loadSystemOperators() error { {&types.Interval{PointType: types.Long}, &types.Interval{PointType: types.Long}}, {&types.Interval{PointType: types.Decimal}, &types.Interval{PointType: types.Decimal}}, {&types.Interval{PointType: types.Quantity}, &types.Interval{PointType: types.Quantity}}, - {&types.Interval{PointType: types.String}, &types.Interval{PointType: types.String}}, {&types.Interval{PointType: types.Time}, &types.Interval{PointType: types.Time}}, }, model: func() model.IExpression { diff --git a/tests/enginetests/operator_interval_test.go b/tests/enginetests/operator_interval_test.go index e00a08a..dc0aca3 100644 --- a/tests/enginetests/operator_interval_test.go +++ b/tests/enginetests/operator_interval_test.go @@ -171,6 +171,71 @@ func TestProperlyIncludedInInterval(t *testing.T) { cql: `Interval[@2020-01-01, @2020-01-15] properly included in (null as Interval)`, want: newOrFatal(t, nil), }, + { + name: "Unknown left start ends before right start", + cql: `Interval(null, 1] properly included in Interval[5, null)`, + want: newOrFatal(t, false), + }, + { + name: "Unknown left end starts after right end", + cql: `Interval[5, null) properly included in Interval(null, 1]`, + want: newOrFatal(t, false), + }, + { + name: "Unknown left start inside right interval", + cql: `Interval(null, 5] properly included in Interval[1, 10]`, + want: newOrFatal(t, nil), + }, + { + name: "Unknown right start", + cql: `Interval[2, 5] properly included in Interval(null, 10]`, + want: newOrFatal(t, nil), + }, + { + name: "Closed null right bounds", + cql: `Interval[2, 5] properly included in Interval[null as Integer, null as Integer]`, + want: newOrFatal(t, true), + }, + { + name: "Same Integer interval with different boundary inclusivity", + cql: `Interval[1, 5] properly included in Interval[1, 6)`, + want: newOrFatal(t, false), + }, + { + name: "Properly included Long intervals", + cql: `Interval[2L, 4L] properly included in Interval[1L, 5L]`, + want: newOrFatal(t, true), + }, + { + name: "Properly included Decimal intervals with open bounds", + cql: `Interval(1.0, 2.0) properly included in Interval[1.0, 2.0]`, + want: newOrFatal(t, true), + }, + { + name: "Properly included Time intervals", + cql: `Interval[@T10:00, @T11:00] properly included in Interval[@T09:00, @T12:00]`, + want: newOrFatal(t, true), + }, + { + name: "Date intervals with insufficient precision", + cql: `Interval[@2020, @2021] properly included in Interval[@2020-01-01, @2022-01-01]`, + want: newOrFatal(t, nil), + }, + { + name: "Quantity intervals with convertible units", + cql: `Interval[500 'g', 900 'g'] properly included in Interval[0.1 'kg', 1 'kg']`, + want: newOrFatal(t, true), + }, + { + name: "Same Quantity interval in different units", + cql: `Interval[1 'kg', 2 'kg'] properly included in Interval[1000 'g', 2000 'g']`, + want: newOrFatal(t, false), + }, + { + name: "Quantity intervals with incompatible units", + cql: `Interval[1 'g', 2 'g'] properly included in Interval[1 'm', 5 'm']`, + want: newOrFatal(t, nil), + }, } for _, tc := range tests { diff --git a/tests/spectests/exclusions/exclusions.go b/tests/spectests/exclusions/exclusions.go index 699df9f..28204e8 100644 --- a/tests/spectests/exclusions/exclusions.go +++ b/tests/spectests/exclusions/exclusions.go @@ -239,9 +239,6 @@ func XMLTestFileExclusionDefinitions() map[string]XMLTestFileExclusions { "Union", }, NamesExcludes: []string{ - "IntegerIntervalProperlyIncludedInNullBoundaries", - "QuantityIntervalProperlyIncludedInTrue", - "TimeProperlyIncludedInTrue", // TODO: b/342061715 - unsupported operators. // Note: overlaps before and after are not supported. but these tests are missing the // before/after keyword for Date/Time test cases so they are not excluded. @@ -313,6 +310,7 @@ func XMLTestFileExclusionDefinitions() map[string]XMLTestFileExclusions { // TODO: b/342064453 - Ambiguous match. "TestEqualNull", "TestInNullBoundaries", + "IntegerIntervalProperlyIncludedInNullBoundaries", }, }, "CqlListOperatorsTest.xml": {