From b357e3a9f47a9099a2dc075b59bc5c75e35df8ae Mon Sep 17 00:00:00 2001 From: Victor Frank Date: Mon, 30 Jun 2025 21:14:41 -0500 Subject: [PATCH 1/4] #144 add interval in Intersect --- interpreter/operator_dispatcher.go | 28 + interpreter/operator_interval.go | 634 ++++++++++++++++++++ parser/operators.go | 34 +- tests/enginetests/operator_interval_test.go | 260 ++++++++ tests/spectests/exclusions/exclusions.go | 6 +- 5 files changed, 953 insertions(+), 9 deletions(-) diff --git a/interpreter/operator_dispatcher.go b/interpreter/operator_dispatcher.go index af782da..5d88cba 100644 --- a/interpreter/operator_dispatcher.go +++ b/interpreter/operator_dispatcher.go @@ -1224,6 +1224,34 @@ func (i *interpreter) binaryOverloads(m model.IBinaryExpression) ([]convert.Over Operands: []types.IType{&types.List{ElementType: types.Any}, &types.List{ElementType: types.Any}}, Result: evalIntersect, }, + { + Operands: []types.IType{&types.Interval{PointType: types.Integer}, &types.Interval{PointType: types.Integer}}, + Result: i.evalIntersectIntervalInteger, + }, + { + Operands: []types.IType{&types.Interval{PointType: types.Long}, &types.Interval{PointType: types.Long}}, + Result: i.evalIntersectIntervalLong, + }, + { + Operands: []types.IType{&types.Interval{PointType: types.Decimal}, &types.Interval{PointType: types.Decimal}}, + Result: i.evalIntersectIntervalDecimal, + }, + { + Operands: []types.IType{&types.Interval{PointType: types.Quantity}, &types.Interval{PointType: types.Quantity}}, + Result: i.evalIntersectIntervalQuantity, + }, + { + Operands: []types.IType{&types.Interval{PointType: types.Date}, &types.Interval{PointType: types.Date}}, + Result: i.evalIntersectInterval, + }, + { + Operands: []types.IType{&types.Interval{PointType: types.DateTime}, &types.Interval{PointType: types.DateTime}}, + Result: i.evalIntersectInterval, + }, + { + Operands: []types.IType{&types.Interval{PointType: types.Time}, &types.Interval{PointType: types.Time}}, + Result: i.evalIntersectInterval, + }, }, nil case *model.ProperlyIncludes: return []convert.Overload[evalBinarySignature]{ diff --git a/interpreter/operator_interval.go b/interpreter/operator_interval.go index e0461dc..d4a7cc6 100644 --- a/interpreter/operator_interval.go +++ b/interpreter/operator_interval.go @@ -641,3 +641,637 @@ func evalWidthInterval(m model.IUnaryExpression, intervalObj result.Value) (resu } return result.Value{}, fmt.Errorf("internal error - unsupported point type in evalWidthInterval: %v", start.RuntimeType()) } + +// intersect(left Interval, right Interval) Interval +// https://cql.hl7.org/09-b-cqlreference.html#intersect +// This function is used only for Date, DateTime, and Time intervals +func (i *interpreter) evalIntersectInterval(m model.IBinaryExpression, lObj, rObj result.Value) (result.Value, error) { + // Handle null inputs + if result.IsNull(lObj) || result.IsNull(rObj) { + return result.New(nil) + } + + // Get start and end bounds for both intervals + lStart, lEnd, err := startAndEnd(lObj, &i.evaluationTimestamp) + if err != nil { + return result.Value{}, err + } + rStart, rEnd, err := startAndEnd(rObj, &i.evaluationTimestamp) + if err != nil { + return result.Value{}, err + } + + // If any bound is null, return null + if result.IsNull(lStart) || result.IsNull(lEnd) || result.IsNull(rStart) || result.IsNull(rEnd) { + return result.New(nil) + } + + // This function only handles Date, DateTime, and Time intervals + return i.evalIntersectIntervalDateTime(m, lObj, rObj) +} + +// calculateNumeralIntersectionInt32 calculates the intersection of two int32 intervals +func (i *interpreter) calculateNumeralIntersectionInt32(lStart, lEnd, rStart, rEnd int32, lInterval, rInterval result.Interval) (result.Value, error) { + // Calculate intersection bounds + intersectionStart := maxInt32(lStart, rStart) + intersectionEnd := minInt32(lEnd, rEnd) + + // Check if there's no overlap + if compareNumeral(intersectionStart, intersectionEnd) == leftAfterRight { + return result.New(nil) + } + + // For intersection, the result bounds are always inclusive + // This is because we're using the effective start/end values from startAndEnd() + // which already account for the original inclusivity + startInclusive := true + endInclusive := true + + // Create result values + startVal, err := result.New(intersectionStart) + if err != nil { + return result.Value{}, err + } + endVal, err := result.New(intersectionEnd) + if err != nil { + return result.Value{}, err + } + + // Create the intersection interval + intersectionInterval := result.Interval{ + Low: startVal, + High: endVal, + LowInclusive: startInclusive, + HighInclusive: endInclusive, + StaticType: lInterval.StaticType, + } + + return result.New(intersectionInterval) +} + +// calculateNumeralIntersectionInt64 calculates the intersection of two int64 intervals +func (i *interpreter) calculateNumeralIntersectionInt64(lStart, lEnd, rStart, rEnd int64, lInterval, rInterval result.Interval) (result.Value, error) { + // Calculate intersection bounds + intersectionStart := maxInt64(lStart, rStart) + intersectionEnd := minInt64(lEnd, rEnd) + + // Check if there's no overlap + if compareNumeral(intersectionStart, intersectionEnd) == leftAfterRight { + return result.New(nil) + } + + // Calculate inclusivity for intersection bounds + // Start is inclusive if it matches an inclusive bound from either interval + startInclusive := true + if compareNumeral(intersectionStart, lStart) == leftEqualRight { + startInclusive = lInterval.LowInclusive + } + if compareNumeral(intersectionStart, rStart) == leftEqualRight { + startInclusive = startInclusive && rInterval.LowInclusive + } + + // End is inclusive if it matches an inclusive bound from either interval + endInclusive := true + if compareNumeral(intersectionEnd, lEnd) == leftEqualRight { + endInclusive = lInterval.HighInclusive + } + if compareNumeral(intersectionEnd, rEnd) == leftEqualRight { + endInclusive = endInclusive && rInterval.HighInclusive + } + + // Create result values + startVal, err := result.New(intersectionStart) + if err != nil { + return result.Value{}, err + } + endVal, err := result.New(intersectionEnd) + if err != nil { + return result.Value{}, err + } + + // Create the intersection interval + intersectionInterval := result.Interval{ + Low: startVal, + High: endVal, + LowInclusive: startInclusive, + HighInclusive: endInclusive, + StaticType: lInterval.StaticType, + } + + return result.New(intersectionInterval) +} + +// calculateNumeralIntersectionFloat64 calculates the intersection of two float64 intervals +func (i *interpreter) calculateNumeralIntersectionFloat64(lStart, lEnd, rStart, rEnd float64, lInterval, rInterval result.Interval) (result.Value, error) { + // Calculate intersection bounds + intersectionStart := maxFloat64(lStart, rStart) + intersectionEnd := minFloat64(lEnd, rEnd) + + // Check if there's no overlap + if compareNumeral(intersectionStart, intersectionEnd) == leftAfterRight { + return result.New(nil) + } + + // Create intersection interval with appropriate inclusivity + startInclusive := (compareNumeral(intersectionStart, lStart) == leftEqualRight && lInterval.LowInclusive) || + (compareNumeral(intersectionStart, rStart) == leftEqualRight && rInterval.LowInclusive) || + (compareNumeral(intersectionStart, lStart) == leftAfterRight && compareNumeral(intersectionStart, lEnd) == leftBeforeRight) || + (compareNumeral(intersectionStart, rStart) == leftAfterRight && compareNumeral(intersectionStart, rEnd) == leftBeforeRight) + + endInclusive := (compareNumeral(intersectionEnd, lEnd) == leftEqualRight && lInterval.HighInclusive) || + (compareNumeral(intersectionEnd, rEnd) == leftEqualRight && rInterval.HighInclusive) || + (compareNumeral(intersectionEnd, lStart) == leftAfterRight && compareNumeral(intersectionEnd, lEnd) == leftBeforeRight) || + (compareNumeral(intersectionEnd, rStart) == leftAfterRight && compareNumeral(intersectionEnd, rEnd) == leftBeforeRight) + + // Create result values + startVal, err := result.New(intersectionStart) + if err != nil { + return result.Value{}, err + } + endVal, err := result.New(intersectionEnd) + if err != nil { + return result.Value{}, err + } + + // Create the intersection interval + intersectionInterval := result.Interval{ + Low: startVal, + High: endVal, + LowInclusive: startInclusive, + HighInclusive: endInclusive, + StaticType: lInterval.StaticType, + } + + return result.New(intersectionInterval) +} + +// calculateNumeralIntersectionQuantity calculates the intersection of two Quantity intervals +func (i *interpreter) calculateNumeralIntersectionQuantity(lStart, lEnd, rStart, rEnd result.Quantity, lInterval, rInterval result.Interval) (result.Value, error) { + // Calculate intersection bounds + intersectionStart := maxFloat64(lStart.Value, rStart.Value) + intersectionEnd := minFloat64(lEnd.Value, rEnd.Value) + + // Check if there's no overlap + if compareNumeral(intersectionStart, intersectionEnd) == leftAfterRight { + return result.New(nil) + } + + // Calculate inclusivity for intersection bounds + // Start is inclusive if it matches an inclusive bound from either interval + startInclusive := true + if compareNumeral(intersectionStart, lStart.Value) == leftEqualRight { + startInclusive = lInterval.LowInclusive + } + if compareNumeral(intersectionStart, rStart.Value) == leftEqualRight { + startInclusive = startInclusive && rInterval.LowInclusive + } + + // End is inclusive if it matches an inclusive bound from either interval + endInclusive := true + if compareNumeral(intersectionEnd, lEnd.Value) == leftEqualRight { + endInclusive = lInterval.HighInclusive + } + if compareNumeral(intersectionEnd, rEnd.Value) == leftEqualRight { + endInclusive = endInclusive && rInterval.HighInclusive + } + + // Create result values with Quantity type + startVal, err := result.New(result.Quantity{Value: intersectionStart, Unit: lStart.Unit}) + if err != nil { + return result.Value{}, err + } + endVal, err := result.New(result.Quantity{Value: intersectionEnd, Unit: lStart.Unit}) + if err != nil { + return result.Value{}, err + } + + // Create the intersection interval + intersectionInterval := result.Interval{ + Low: startVal, + High: endVal, + LowInclusive: startInclusive, + HighInclusive: endInclusive, + StaticType: lInterval.StaticType, + } + + return result.New(intersectionInterval) +} + +// evalIntersectIntervalDateTime handles intersection for date/time interval types +func (i *interpreter) evalIntersectIntervalDateTime(m model.IBinaryExpression, lObj, rObj result.Value) (result.Value, error) { + // Get start and end bounds for both intervals + lStart, lEnd, err := startAndEnd(lObj, &i.evaluationTimestamp) + if err != nil { + return result.Value{}, err + } + rStart, rEnd, err := startAndEnd(rObj, &i.evaluationTimestamp) + if err != nil { + return result.Value{}, err + } + + // If any bound is null, return null + if result.IsNull(lStart) || result.IsNull(lEnd) || result.IsNull(rStart) || result.IsNull(rEnd) { + return result.New(nil) + } + + // Get interval metadata for result construction + lInterval, _ := result.ToInterval(lObj) + + // Handle different date/time types + switch lInterval.StaticType.PointType { + case types.Date: + return i.evalIntersectIntervalDate(lStart, lEnd, rStart, rEnd, lInterval) + case types.DateTime: + return i.evalIntersectIntervalDateTimeType(lStart, lEnd, rStart, rEnd, lInterval) + case types.Time: + return i.evalIntersectIntervalTime(lStart, lEnd, rStart, rEnd, lInterval) + default: + return result.Value{}, fmt.Errorf("internal error - unsupported date/time type in evalIntersectIntervalDateTime: %v", lInterval.StaticType.PointType) + } +} + +// evalIntersectIntervalDate handles intersection for Date intervals +func (i *interpreter) evalIntersectIntervalDate(lStart, lEnd, rStart, rEnd result.Value, lInterval result.Interval) (result.Value, error) { + // Convert to DateTime for comparison but preserve Date type in result + lStartDT, lEndDT, err := applyToValues(lStart, lEnd, result.ToDateTime) + if err != nil { + return result.Value{}, err + } + rStartDT, rEndDT, err := applyToValues(rStart, rEnd, result.ToDateTime) + if err != nil { + return result.Value{}, err + } + + // Calculate intersection bounds using time.Time comparison + var intersectionStartDT, intersectionEndDT result.DateTime + var intersectionStart, intersectionEnd result.Value + + if lStartDT.Date.After(rStartDT.Date) { + intersectionStartDT = lStartDT + intersectionStart = lStart + } else { + intersectionStartDT = rStartDT + intersectionStart = rStart + } + + if lEndDT.Date.Before(rEndDT.Date) { + intersectionEndDT = lEndDT + intersectionEnd = lEnd + } else { + intersectionEndDT = rEndDT + intersectionEnd = rEnd + } + + // Check if there's no overlap + if intersectionStartDT.Date.After(intersectionEndDT.Date) { + return result.New(nil) + } + + // Determine inclusivity for intersection bounds + startInclusive := (intersectionStartDT.Date.Equal(lStartDT.Date) && lInterval.LowInclusive) || + (intersectionStartDT.Date.Equal(rStartDT.Date) && lInterval.LowInclusive) || + (intersectionStartDT.Date.After(lStartDT.Date) && intersectionStartDT.Date.Before(lEndDT.Date)) || + (intersectionStartDT.Date.After(rStartDT.Date) && intersectionStartDT.Date.Before(rEndDT.Date)) + + endInclusive := (intersectionEndDT.Date.Equal(lEndDT.Date) && lInterval.HighInclusive) || + (intersectionEndDT.Date.Equal(rEndDT.Date) && lInterval.HighInclusive) || + (intersectionEndDT.Date.After(lStartDT.Date) && intersectionEndDT.Date.Before(lEndDT.Date)) || + (intersectionEndDT.Date.After(rStartDT.Date) && intersectionEndDT.Date.Before(rEndDT.Date)) + + // Create the intersection interval with Date values + intersectionInterval := result.Interval{ + Low: intersectionStart, + High: intersectionEnd, + LowInclusive: startInclusive, + HighInclusive: endInclusive, + StaticType: lInterval.StaticType, + } + + return result.New(intersectionInterval) +} + +// evalIntersectIntervalDateTimeType handles intersection for DateTime intervals +func (i *interpreter) evalIntersectIntervalDateTimeType(lStart, lEnd, rStart, rEnd result.Value, lInterval result.Interval) (result.Value, error) { + // Convert to DateTime for comparison + lStartDT, lEndDT, err := applyToValues(lStart, lEnd, result.ToDateTime) + if err != nil { + return result.Value{}, err + } + rStartDT, rEndDT, err := applyToValues(rStart, rEnd, result.ToDateTime) + if err != nil { + return result.Value{}, err + } + + // Calculate intersection bounds using time.Time comparison + var intersectionStart, intersectionEnd result.DateTime + if lStartDT.Date.After(rStartDT.Date) { + intersectionStart = lStartDT + } else { + intersectionStart = rStartDT + } + + if lEndDT.Date.Before(rEndDT.Date) { + intersectionEnd = lEndDT + } else { + intersectionEnd = rEndDT + } + + // Check if there's no overlap + if intersectionStart.Date.After(intersectionEnd.Date) { + return result.New(nil) + } + + // Determine inclusivity for intersection bounds + startInclusive := (intersectionStart.Date.Equal(lStartDT.Date) && lInterval.LowInclusive) || + (intersectionStart.Date.Equal(rStartDT.Date) && lInterval.LowInclusive) || + (intersectionStart.Date.After(lStartDT.Date) && intersectionStart.Date.Before(lEndDT.Date)) || + (intersectionStart.Date.After(rStartDT.Date) && intersectionStart.Date.Before(rEndDT.Date)) + + endInclusive := (intersectionEnd.Date.Equal(lEndDT.Date) && lInterval.HighInclusive) || + (intersectionEnd.Date.Equal(rEndDT.Date) && lInterval.HighInclusive) || + (intersectionEnd.Date.After(lStartDT.Date) && intersectionEnd.Date.Before(lEndDT.Date)) || + (intersectionEnd.Date.After(rStartDT.Date) && intersectionEnd.Date.Before(rEndDT.Date)) + + // Create result values + startVal, err := result.New(intersectionStart) + if err != nil { + return result.Value{}, err + } + endVal, err := result.New(intersectionEnd) + if err != nil { + return result.Value{}, err + } + + // Create the intersection interval + intersectionInterval := result.Interval{ + Low: startVal, + High: endVal, + LowInclusive: startInclusive, + HighInclusive: endInclusive, + StaticType: lInterval.StaticType, + } + + return result.New(intersectionInterval) +} + +// evalIntersectIntervalTime handles intersection for Time intervals +func (i *interpreter) evalIntersectIntervalTime(lStart, lEnd, rStart, rEnd result.Value, lInterval result.Interval) (result.Value, error) { + // Convert to DateTime for comparison but preserve Time type in result + lStartDT, lEndDT, err := applyToValues(lStart, lEnd, result.ToDateTime) + if err != nil { + return result.Value{}, err + } + rStartDT, rEndDT, err := applyToValues(rStart, rEnd, result.ToDateTime) + if err != nil { + return result.Value{}, err + } + + // Calculate intersection bounds using time.Time comparison + var intersectionStartDT, intersectionEndDT result.DateTime + var intersectionStart, intersectionEnd result.Value + + if lStartDT.Date.After(rStartDT.Date) { + intersectionStartDT = lStartDT + intersectionStart = lStart + } else { + intersectionStartDT = rStartDT + intersectionStart = rStart + } + + if lEndDT.Date.Before(rEndDT.Date) { + intersectionEndDT = lEndDT + intersectionEnd = lEnd + } else { + intersectionEndDT = rEndDT + intersectionEnd = rEnd + } + + // Check if there's no overlap + if intersectionStartDT.Date.After(intersectionEndDT.Date) { + return result.New(nil) + } + + // Determine inclusivity for intersection bounds + startInclusive := (intersectionStartDT.Date.Equal(lStartDT.Date) && lInterval.LowInclusive) || + (intersectionStartDT.Date.Equal(rStartDT.Date) && lInterval.LowInclusive) || + (intersectionStartDT.Date.After(lStartDT.Date) && intersectionStartDT.Date.Before(lEndDT.Date)) || + (intersectionStartDT.Date.After(rStartDT.Date) && intersectionStartDT.Date.Before(rEndDT.Date)) + + endInclusive := (intersectionEndDT.Date.Equal(lEndDT.Date) && lInterval.HighInclusive) || + (intersectionEndDT.Date.Equal(rEndDT.Date) && lInterval.HighInclusive) || + (intersectionEndDT.Date.After(lStartDT.Date) && intersectionEndDT.Date.Before(lEndDT.Date)) || + (intersectionEndDT.Date.After(rStartDT.Date) && intersectionEndDT.Date.Before(rEndDT.Date)) + + // Create the intersection interval with Time values + intersectionInterval := result.Interval{ + Low: intersectionStart, + High: intersectionEnd, + LowInclusive: startInclusive, + HighInclusive: endInclusive, + StaticType: lInterval.StaticType, + } + + return result.New(intersectionInterval) +} + + +// Helper functions for min/max calculations +func maxInt32(a, b int32) int32 { + if a > b { + return a + } + return b +} + +func minInt32(a, b int32) int32 { + if a < b { + return a + } + return b +} + +func maxInt64(a, b int64) int64 { + if a > b { + return a + } + return b +} + +func minInt64(a, b int64) int64 { + if a < b { + return a + } + return b +} + +func maxFloat64(a, b float64) float64 { + if a > b { + return a + } + return b +} + +func minFloat64(a, b float64) float64 { + if a < b { + return a + } + return b +} + +// Type-specific intersect functions for dispatcher +func (i *interpreter) evalIntersectIntervalInteger(m model.IBinaryExpression, lObj, rObj result.Value) (result.Value, error) { + // Handle null inputs + if result.IsNull(lObj) || result.IsNull(rObj) { + return result.New(nil) + } + + // Get start and end bounds for both intervals + lStart, lEnd, err := startAndEnd(lObj, &i.evaluationTimestamp) + if err != nil { + return result.Value{}, err + } + rStart, rEnd, err := startAndEnd(rObj, &i.evaluationTimestamp) + if err != nil { + return result.Value{}, err + } + + // If any bound is null, return null + if result.IsNull(lStart) || result.IsNull(lEnd) || result.IsNull(rStart) || result.IsNull(rEnd) { + return result.New(nil) + } + + // Get interval metadata for result construction + lInterval, _ := result.ToInterval(lObj) + rInterval, _ := result.ToInterval(rObj) + + // Convert to int32 values + lStartVal, lEndVal, err := applyToValues(lStart, lEnd, result.ToInt32) + if err != nil { + return result.Value{}, err + } + rStartVal, rEndVal, err := applyToValues(rStart, rEnd, result.ToInt32) + if err != nil { + return result.Value{}, err + } + + return i.calculateNumeralIntersectionInt32(lStartVal, lEndVal, rStartVal, rEndVal, lInterval, rInterval) +} + +func (i *interpreter) evalIntersectIntervalLong(m model.IBinaryExpression, lObj, rObj result.Value) (result.Value, error) { + // Handle null inputs + if result.IsNull(lObj) || result.IsNull(rObj) { + return result.New(nil) + } + + // Get start and end bounds for both intervals + lStart, lEnd, err := startAndEnd(lObj, &i.evaluationTimestamp) + if err != nil { + return result.Value{}, err + } + rStart, rEnd, err := startAndEnd(rObj, &i.evaluationTimestamp) + if err != nil { + return result.Value{}, err + } + + // If any bound is null, return null + if result.IsNull(lStart) || result.IsNull(lEnd) || result.IsNull(rStart) || result.IsNull(rEnd) { + return result.New(nil) + } + + // Get interval metadata for result construction + lInterval, _ := result.ToInterval(lObj) + rInterval, _ := result.ToInterval(rObj) + + // Convert to int64 values + lStartVal, lEndVal, err := applyToValues(lStart, lEnd, result.ToInt64) + if err != nil { + return result.Value{}, err + } + rStartVal, rEndVal, err := applyToValues(rStart, rEnd, result.ToInt64) + if err != nil { + return result.Value{}, err + } + + return i.calculateNumeralIntersectionInt64(lStartVal, lEndVal, rStartVal, rEndVal, lInterval, rInterval) +} + +func (i *interpreter) evalIntersectIntervalDecimal(m model.IBinaryExpression, lObj, rObj result.Value) (result.Value, error) { + // Handle null inputs + if result.IsNull(lObj) || result.IsNull(rObj) { + return result.New(nil) + } + + // Get start and end bounds for both intervals + lStart, lEnd, err := startAndEnd(lObj, &i.evaluationTimestamp) + if err != nil { + return result.Value{}, err + } + rStart, rEnd, err := startAndEnd(rObj, &i.evaluationTimestamp) + if err != nil { + return result.Value{}, err + } + + // If any bound is null, return null + if result.IsNull(lStart) || result.IsNull(lEnd) || result.IsNull(rStart) || result.IsNull(rEnd) { + return result.New(nil) + } + + // Get interval metadata for result construction + lInterval, _ := result.ToInterval(lObj) + rInterval, _ := result.ToInterval(rObj) + + // Convert to float64 values + lStartVal, lEndVal, err := applyToValues(lStart, lEnd, result.ToFloat64) + if err != nil { + return result.Value{}, err + } + rStartVal, rEndVal, err := applyToValues(rStart, rEnd, result.ToFloat64) + if err != nil { + return result.Value{}, err + } + + return i.calculateNumeralIntersectionFloat64(lStartVal, lEndVal, rStartVal, rEndVal, lInterval, rInterval) +} + +func (i *interpreter) evalIntersectIntervalQuantity(m model.IBinaryExpression, lObj, rObj result.Value) (result.Value, error) { + // Handle null inputs + if result.IsNull(lObj) || result.IsNull(rObj) { + return result.New(nil) + } + + // Get start and end bounds for both intervals + lStart, lEnd, err := startAndEnd(lObj, &i.evaluationTimestamp) + if err != nil { + return result.Value{}, err + } + rStart, rEnd, err := startAndEnd(rObj, &i.evaluationTimestamp) + if err != nil { + return result.Value{}, err + } + + // If any bound is null, return null + if result.IsNull(lStart) || result.IsNull(lEnd) || result.IsNull(rStart) || result.IsNull(rEnd) { + return result.New(nil) + } + + // Get interval metadata for result construction + lInterval, _ := result.ToInterval(lObj) + rInterval, _ := result.ToInterval(rObj) + + // Convert to Quantity values + lStartVal, lEndVal, err := applyToValues(lStart, lEnd, result.ToQuantity) + if err != nil { + return result.Value{}, err + } + rStartVal, rEndVal, err := applyToValues(rStart, rEnd, result.ToQuantity) + if err != nil { + return result.Value{}, err + } + + // Check unit compatibility + if lStartVal.Unit != rStartVal.Unit { + return result.Value{}, fmt.Errorf("intersect operator received Quantities with differing unit values, unit conversion is not currently supported, got: %v, %v", lStartVal.Unit, rStartVal.Unit) + } + + return i.calculateNumeralIntersectionQuantity(lStartVal, lEndVal, rStartVal, rEndVal, lInterval, rInterval) +} diff --git a/parser/operators.go b/parser/operators.go index 1f616c2..b72fe18 100644 --- a/parser/operators.go +++ b/parser/operators.go @@ -81,13 +81,22 @@ func (v *visitor) resolveFunction(libraryName, funcName string, operands []model // For Except the left side is the result type. t.Expression = model.ResultType(resolved.WrappedOperands[0].GetResultType()) case *model.Intersect: - listTypeLeft := resolved.WrappedOperands[0].GetResultType().(*types.List) - listTypeRight := resolved.WrappedOperands[1].GetResultType().(*types.List) - listElemType, err := convert.Intersect(listTypeLeft.ElementType, listTypeRight.ElementType) - if err != nil { - return nil, err + leftType := resolved.WrappedOperands[0].GetResultType() + rightType := resolved.WrappedOperands[1].GetResultType() + + // Check if this is a list intersect or interval intersect + if listTypeLeft, ok := leftType.(*types.List); ok { + // List intersect + listTypeRight := rightType.(*types.List) + listElemType, err := convert.Intersect(listTypeLeft.ElementType, listTypeRight.ElementType) + if err != nil { + return nil, err + } + t.Expression = model.ResultType(&types.List{ElementType: listElemType}) + } else if intervalTypeLeft, ok := leftType.(*types.Interval); ok { + // Interval intersect - result type is the same interval type + t.Expression = model.ResultType(intervalTypeLeft) } - t.Expression = model.ResultType(&types.List{ElementType: listElemType}) case *model.Avg: listType := resolved.WrappedOperands[0].GetResultType().(*types.List) t.Expression = model.ResultType(listType.ElementType) @@ -1831,8 +1840,17 @@ func (p *Parser) loadSystemOperators() error { }, }, { - name: "Intersect", - operands: [][]types.IType{{&types.List{ElementType: types.Any}, &types.List{ElementType: types.Any}}}, + name: "Intersect", + operands: [][]types.IType{ + {&types.List{ElementType: types.Any}, &types.List{ElementType: types.Any}}, + {&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.Date}, &types.Interval{PointType: types.Date}}, + {&types.Interval{PointType: types.DateTime}, &types.Interval{PointType: types.DateTime}}, + {&types.Interval{PointType: types.Time}, &types.Interval{PointType: types.Time}}, + }, model: func() model.IExpression { return &model.Intersect{ BinaryExpression: &model.BinaryExpression{}, diff --git a/tests/enginetests/operator_interval_test.go b/tests/enginetests/operator_interval_test.go index 595a902..f80b305 100644 --- a/tests/enginetests/operator_interval_test.go +++ b/tests/enginetests/operator_interval_test.go @@ -125,6 +125,266 @@ func TestEnd(t *testing.T) { } } +func TestIntersectInterval(t *testing.T) { + tests := []struct { + name string + cql string + wantModel model.IExpression + wantResult result.Value + }{ + // Basic integer interval tests + { + name: "Integer intervals with overlap", + cql: "Interval[1, 5] intersect Interval[3, 7]", + wantModel: &model.Intersect{ + BinaryExpression: &model.BinaryExpression{ + Expression: model.ResultType(&types.Interval{PointType: types.Integer}), + Operands: []model.IExpression{ + &model.Interval{ + Low: model.NewLiteral("1", types.Integer), + High: model.NewLiteral("5", types.Integer), + LowInclusive: true, + HighInclusive: true, + Expression: model.ResultType(&types.Interval{PointType: types.Integer}), + }, + &model.Interval{ + Low: model.NewLiteral("3", types.Integer), + High: model.NewLiteral("7", types.Integer), + LowInclusive: true, + HighInclusive: true, + Expression: model.ResultType(&types.Interval{PointType: types.Integer}), + }, + }, + }, + }, + wantResult: newOrFatal(t, result.Interval{ + Low: newOrFatal(t, int32(3)), + High: newOrFatal(t, int32(5)), + LowInclusive: true, + HighInclusive: true, + StaticType: &types.Interval{PointType: types.Integer}, + }), + }, + { + name: "Integer intervals with no overlap", + cql: "Interval[1, 3] intersect Interval[5, 7]", + wantResult: newOrFatal(t, nil), + }, + { + name: "Integer intervals touching at boundary", + cql: "Interval[1, 3] intersect Interval[3, 7]", + wantResult: newOrFatal(t, result.Interval{ + Low: newOrFatal(t, int32(3)), + High: newOrFatal(t, int32(3)), + LowInclusive: true, + HighInclusive: true, + StaticType: &types.Interval{PointType: types.Integer}, + }), + }, + { + name: "Integer intervals with exclusive bounds", + cql: "Interval[1, 3) intersect Interval(2, 7]", + wantResult: newOrFatal(t, nil), + }, + { + name: "Identical integer intervals", + cql: "Interval[1, 5] intersect Interval[1, 5]", + wantResult: newOrFatal(t, result.Interval{ + Low: newOrFatal(t, int32(1)), + High: newOrFatal(t, int32(5)), + LowInclusive: true, + HighInclusive: true, + StaticType: &types.Interval{PointType: types.Integer}, + }), + }, + // Null handling tests + { + name: "Left interval is null", + cql: "null as Interval intersect Interval[3, 7]", + wantResult: newOrFatal(t, nil), + }, + { + name: "Right interval is null", + cql: "Interval[1, 5] intersect null as Interval", + wantResult: newOrFatal(t, nil), + }, + { + name: "Both intervals are null", + cql: "null as Interval intersect null as Interval", + wantResult: newOrFatal(t, nil), + }, + // Decimal interval tests + { + name: "Decimal intervals with overlap", + cql: "Interval[1.5, 5.5] intersect Interval[3.0, 7.0]", + wantResult: newOrFatal(t, result.Interval{ + Low: newOrFatal(t, 3.0), + High: newOrFatal(t, 5.5), + LowInclusive: true, + HighInclusive: true, + StaticType: &types.Interval{PointType: types.Decimal}, + }), + }, + { + name: "Decimal intervals with no overlap", + cql: "Interval[1.0, 2.5] intersect Interval[3.0, 4.5]", + wantResult: newOrFatal(t, nil), + }, + // Long interval tests + { + name: "Long intervals with overlap", + cql: "Interval[1L, 5L] intersect Interval[3L, 7L]", + wantResult: newOrFatal(t, result.Interval{ + Low: newOrFatal(t, int64(3)), + High: newOrFatal(t, int64(5)), + LowInclusive: true, + HighInclusive: true, + StaticType: &types.Interval{PointType: types.Long}, + }), + }, + // Quantity interval tests + { + name: "Quantity intervals with overlap", + cql: "Interval[1'cm', 5'cm'] intersect Interval[3'cm', 7'cm']", + wantResult: newOrFatal(t, result.Interval{ + Low: newOrFatal(t, result.Quantity{Value: 3, Unit: "cm"}), + High: newOrFatal(t, result.Quantity{Value: 5, Unit: "cm"}), + LowInclusive: true, + HighInclusive: true, + StaticType: &types.Interval{PointType: types.Quantity}, + }), + }, + // Date interval tests + { + name: "Date intervals with overlap", + cql: "Interval[@2020-01-01, @2020-06-01] intersect Interval[@2020-03-01, @2020-09-01]", + wantResult: newOrFatal(t, result.Interval{ + Low: newOrFatal(t, result.Date{Date: time.Date(2020, time.March, 1, 0, 0, 0, 0, defaultEvalTimestamp.Location()), Precision: model.DAY}), + High: newOrFatal(t, result.Date{Date: time.Date(2020, time.June, 1, 0, 0, 0, 0, defaultEvalTimestamp.Location()), Precision: model.DAY}), + LowInclusive: true, + HighInclusive: true, + StaticType: &types.Interval{PointType: types.Date}, + }), + }, + { + name: "Date intervals with no overlap", + cql: "Interval[@2020-01-01, @2020-02-01] intersect Interval[@2020-03-01, @2020-04-01]", + wantResult: newOrFatal(t, nil), + }, + // DateTime interval tests + { + name: "DateTime intervals with overlap", + cql: "Interval[@2020-01-01T10:00:00, @2020-01-01T15:00:00] intersect Interval[@2020-01-01T12:00:00, @2020-01-01T18:00:00]", + wantResult: newOrFatal(t, result.Interval{ + Low: newOrFatal(t, result.DateTime{Date: time.Date(2020, time.January, 1, 12, 0, 0, 0, defaultEvalTimestamp.Location()), Precision: model.SECOND}), + High: newOrFatal(t, result.DateTime{Date: time.Date(2020, time.January, 1, 15, 0, 0, 0, defaultEvalTimestamp.Location()), Precision: model.SECOND}), + LowInclusive: true, + HighInclusive: true, + StaticType: &types.Interval{PointType: types.DateTime}, + }), + }, + // Edge cases + { + name: "First interval completely contains second", + cql: "Interval[1, 10] intersect Interval[3, 7]", + wantResult: newOrFatal(t, result.Interval{ + Low: newOrFatal(t, int32(3)), + High: newOrFatal(t, int32(7)), + LowInclusive: true, + HighInclusive: true, + StaticType: &types.Interval{PointType: types.Integer}, + }), + }, + { + name: "Second interval completely contains first", + cql: "Interval[3, 7] intersect Interval[1, 10]", + wantResult: newOrFatal(t, result.Interval{ + Low: newOrFatal(t, int32(3)), + High: newOrFatal(t, int32(7)), + LowInclusive: true, + HighInclusive: true, + StaticType: &types.Interval{PointType: types.Integer}, + }), + }, + { + name: "Intervals with mixed inclusivity", + cql: "Interval[1, 5) intersect Interval(3, 7]", + wantResult: newOrFatal(t, result.Interval{ + Low: newOrFatal(t, int32(4)), + High: newOrFatal(t, int32(4)), + LowInclusive: true, + HighInclusive: true, + StaticType: &types.Interval{PointType: types.Integer}, + }), + }, + // Functional syntax + { + name: "Functional syntax", + cql: "Intersect(Interval[1, 5], Interval[3, 7])", + wantResult: newOrFatal(t, result.Interval{ + Low: newOrFatal(t, int32(3)), + High: newOrFatal(t, int32(5)), + LowInclusive: true, + HighInclusive: true, + StaticType: &types.Interval{PointType: types.Integer}, + }), + }, + } + + 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) + } + if diff := cmp.Diff(tc.wantModel, getTESTRESULTModel(t, parsedLibs)); tc.wantModel != nil && diff != "" { + t.Errorf("Parse diff (-want +got):\n%s", diff) + } + + 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.wantResult, getTESTRESULT(t, results), protocmp.Transform()); diff != "" { + t.Errorf("Eval diff (-want +got)\n%v", diff) + } + }) + } +} + +func TestIntersectInterval_Error(t *testing.T) { + tests := []struct { + name string + cql string + wantEvalErrContains string + }{ + { + name: "Quantity intervals with different units", + cql: "Interval[1'cm', 5'cm'] intersect Interval[3'm', 7'm']", + wantEvalErrContains: "intersect operator received Quantities with differing unit values", + }, + } + + 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) + } + + _, err = interpreter.Eval(context.Background(), parsedLibs, defaultInterpreterConfig(t, p)) + if err == nil { + t.Fatal("Eval succeeded, wanted error") + } + if !strings.Contains(err.Error(), tc.wantEvalErrContains) { + t.Errorf("Unexpected evaluation error contents. got (%v), want contains (%v)", err.Error(), tc.wantEvalErrContains) + } + }) + } +} + 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..34e2cb6 100644 --- a/tests/spectests/exclusions/exclusions.go +++ b/tests/spectests/exclusions/exclusions.go @@ -227,7 +227,6 @@ func XMLTestFileExclusionDefinitions() map[string]XMLTestFileExclusions { "Ends", "Except", "Includes", - "Intersect", "Meets", "MeetsBefore", "MeetsAfter", @@ -311,6 +310,11 @@ func XMLTestFileExclusionDefinitions() map[string]XMLTestFileExclusions { // TODO: b/342064453 - Ambiguous match. "TestEqualNull", "TestInNullBoundaries", + "TestIntersectNull", + "TestIntersectNull1", + "TestIntersectNull2", + "TestIntersectNull3", + "TestIntersectNull4", }, }, "CqlListOperatorsTest.xml": { From 5fde27605bf5e7c2595d39d2985838f9fcfc73bc Mon Sep 17 00:00:00 2001 From: Victor Frank Date: Sun, 11 Oct 2026 10:57:05 -0400 Subject: [PATCH 2/4] #144 gofmt interval intersect changes --- interpreter/operator_interval.go | 1 - parser/operators.go | 2 +- tests/enginetests/operator_interval_test.go | 44 ++++++++++----------- 3 files changed, 23 insertions(+), 24 deletions(-) diff --git a/interpreter/operator_interval.go b/interpreter/operator_interval.go index d4a7cc6..8eb3e12 100644 --- a/interpreter/operator_interval.go +++ b/interpreter/operator_interval.go @@ -1074,7 +1074,6 @@ func (i *interpreter) evalIntersectIntervalTime(lStart, lEnd, rStart, rEnd resul return result.New(intersectionInterval) } - // Helper functions for min/max calculations func maxInt32(a, b int32) int32 { if a > b { diff --git a/parser/operators.go b/parser/operators.go index b72fe18..de21e15 100644 --- a/parser/operators.go +++ b/parser/operators.go @@ -83,7 +83,7 @@ func (v *visitor) resolveFunction(libraryName, funcName string, operands []model case *model.Intersect: leftType := resolved.WrappedOperands[0].GetResultType() rightType := resolved.WrappedOperands[1].GetResultType() - + // Check if this is a list intersect or interval intersect if listTypeLeft, ok := leftType.(*types.List); ok { // List intersect diff --git a/tests/enginetests/operator_interval_test.go b/tests/enginetests/operator_interval_test.go index f80b305..bcd06c1 100644 --- a/tests/enginetests/operator_interval_test.go +++ b/tests/enginetests/operator_interval_test.go @@ -171,8 +171,8 @@ func TestIntersectInterval(t *testing.T) { wantResult: newOrFatal(t, nil), }, { - name: "Integer intervals touching at boundary", - cql: "Interval[1, 3] intersect Interval[3, 7]", + name: "Integer intervals touching at boundary", + cql: "Interval[1, 3] intersect Interval[3, 7]", wantResult: newOrFatal(t, result.Interval{ Low: newOrFatal(t, int32(3)), High: newOrFatal(t, int32(3)), @@ -187,8 +187,8 @@ func TestIntersectInterval(t *testing.T) { wantResult: newOrFatal(t, nil), }, { - name: "Identical integer intervals", - cql: "Interval[1, 5] intersect Interval[1, 5]", + name: "Identical integer intervals", + cql: "Interval[1, 5] intersect Interval[1, 5]", wantResult: newOrFatal(t, result.Interval{ Low: newOrFatal(t, int32(1)), High: newOrFatal(t, int32(5)), @@ -215,8 +215,8 @@ func TestIntersectInterval(t *testing.T) { }, // Decimal interval tests { - name: "Decimal intervals with overlap", - cql: "Interval[1.5, 5.5] intersect Interval[3.0, 7.0]", + name: "Decimal intervals with overlap", + cql: "Interval[1.5, 5.5] intersect Interval[3.0, 7.0]", wantResult: newOrFatal(t, result.Interval{ Low: newOrFatal(t, 3.0), High: newOrFatal(t, 5.5), @@ -232,8 +232,8 @@ func TestIntersectInterval(t *testing.T) { }, // Long interval tests { - name: "Long intervals with overlap", - cql: "Interval[1L, 5L] intersect Interval[3L, 7L]", + name: "Long intervals with overlap", + cql: "Interval[1L, 5L] intersect Interval[3L, 7L]", wantResult: newOrFatal(t, result.Interval{ Low: newOrFatal(t, int64(3)), High: newOrFatal(t, int64(5)), @@ -244,8 +244,8 @@ func TestIntersectInterval(t *testing.T) { }, // Quantity interval tests { - name: "Quantity intervals with overlap", - cql: "Interval[1'cm', 5'cm'] intersect Interval[3'cm', 7'cm']", + name: "Quantity intervals with overlap", + cql: "Interval[1'cm', 5'cm'] intersect Interval[3'cm', 7'cm']", wantResult: newOrFatal(t, result.Interval{ Low: newOrFatal(t, result.Quantity{Value: 3, Unit: "cm"}), High: newOrFatal(t, result.Quantity{Value: 5, Unit: "cm"}), @@ -256,8 +256,8 @@ func TestIntersectInterval(t *testing.T) { }, // Date interval tests { - name: "Date intervals with overlap", - cql: "Interval[@2020-01-01, @2020-06-01] intersect Interval[@2020-03-01, @2020-09-01]", + name: "Date intervals with overlap", + cql: "Interval[@2020-01-01, @2020-06-01] intersect Interval[@2020-03-01, @2020-09-01]", wantResult: newOrFatal(t, result.Interval{ Low: newOrFatal(t, result.Date{Date: time.Date(2020, time.March, 1, 0, 0, 0, 0, defaultEvalTimestamp.Location()), Precision: model.DAY}), High: newOrFatal(t, result.Date{Date: time.Date(2020, time.June, 1, 0, 0, 0, 0, defaultEvalTimestamp.Location()), Precision: model.DAY}), @@ -273,8 +273,8 @@ func TestIntersectInterval(t *testing.T) { }, // DateTime interval tests { - name: "DateTime intervals with overlap", - cql: "Interval[@2020-01-01T10:00:00, @2020-01-01T15:00:00] intersect Interval[@2020-01-01T12:00:00, @2020-01-01T18:00:00]", + name: "DateTime intervals with overlap", + cql: "Interval[@2020-01-01T10:00:00, @2020-01-01T15:00:00] intersect Interval[@2020-01-01T12:00:00, @2020-01-01T18:00:00]", wantResult: newOrFatal(t, result.Interval{ Low: newOrFatal(t, result.DateTime{Date: time.Date(2020, time.January, 1, 12, 0, 0, 0, defaultEvalTimestamp.Location()), Precision: model.SECOND}), High: newOrFatal(t, result.DateTime{Date: time.Date(2020, time.January, 1, 15, 0, 0, 0, defaultEvalTimestamp.Location()), Precision: model.SECOND}), @@ -285,8 +285,8 @@ func TestIntersectInterval(t *testing.T) { }, // Edge cases { - name: "First interval completely contains second", - cql: "Interval[1, 10] intersect Interval[3, 7]", + name: "First interval completely contains second", + cql: "Interval[1, 10] intersect Interval[3, 7]", wantResult: newOrFatal(t, result.Interval{ Low: newOrFatal(t, int32(3)), High: newOrFatal(t, int32(7)), @@ -296,8 +296,8 @@ func TestIntersectInterval(t *testing.T) { }), }, { - name: "Second interval completely contains first", - cql: "Interval[3, 7] intersect Interval[1, 10]", + name: "Second interval completely contains first", + cql: "Interval[3, 7] intersect Interval[1, 10]", wantResult: newOrFatal(t, result.Interval{ Low: newOrFatal(t, int32(3)), High: newOrFatal(t, int32(7)), @@ -307,8 +307,8 @@ func TestIntersectInterval(t *testing.T) { }), }, { - name: "Intervals with mixed inclusivity", - cql: "Interval[1, 5) intersect Interval(3, 7]", + name: "Intervals with mixed inclusivity", + cql: "Interval[1, 5) intersect Interval(3, 7]", wantResult: newOrFatal(t, result.Interval{ Low: newOrFatal(t, int32(4)), High: newOrFatal(t, int32(4)), @@ -319,8 +319,8 @@ func TestIntersectInterval(t *testing.T) { }, // Functional syntax { - name: "Functional syntax", - cql: "Intersect(Interval[1, 5], Interval[3, 7])", + name: "Functional syntax", + cql: "Intersect(Interval[1, 5], Interval[3, 7])", wantResult: newOrFatal(t, result.Interval{ Low: newOrFatal(t, int32(3)), High: newOrFatal(t, int32(5)), From 380d51c7d79fa9089fe521fbeb6fd52e3ebced2f Mon Sep 17 00:00:00 2001 From: Victor Frank Date: Sun, 11 Oct 2026 11:08:05 -0400 Subject: [PATCH 3/4] #144 fix Intersect bound inclusivity for open intervals Discrete types (Long, Date, DateTime, Time) now return closed bounds from the effective start/end, as Integer already did. Decimal and Quantity use the intervals' own bounds so open bounds stay open. Adds open-bound test cases. --- interpreter/operator_interval.go | 189 ++++++++------------ tests/enginetests/operator_interval_test.go | 143 +++++++++++++++ 2 files changed, 220 insertions(+), 112 deletions(-) diff --git a/interpreter/operator_interval.go b/interpreter/operator_interval.go index 8eb3e12..f36dea6 100644 --- a/interpreter/operator_interval.go +++ b/interpreter/operator_interval.go @@ -720,24 +720,9 @@ func (i *interpreter) calculateNumeralIntersectionInt64(lStart, lEnd, rStart, rE return result.New(nil) } - // Calculate inclusivity for intersection bounds - // Start is inclusive if it matches an inclusive bound from either interval + // Effective bounds from startAndEnd() are closed, so the result is closed. startInclusive := true - if compareNumeral(intersectionStart, lStart) == leftEqualRight { - startInclusive = lInterval.LowInclusive - } - if compareNumeral(intersectionStart, rStart) == leftEqualRight { - startInclusive = startInclusive && rInterval.LowInclusive - } - - // End is inclusive if it matches an inclusive bound from either interval endInclusive := true - if compareNumeral(intersectionEnd, lEnd) == leftEqualRight { - endInclusive = lInterval.HighInclusive - } - if compareNumeral(intersectionEnd, rEnd) == leftEqualRight { - endInclusive = endInclusive && rInterval.HighInclusive - } // Create result values startVal, err := result.New(intersectionStart) @@ -761,29 +746,15 @@ func (i *interpreter) calculateNumeralIntersectionInt64(lStart, lEnd, rStart, rE return result.New(intersectionInterval) } -// calculateNumeralIntersectionFloat64 calculates the intersection of two float64 intervals -func (i *interpreter) calculateNumeralIntersectionFloat64(lStart, lEnd, rStart, rEnd float64, lInterval, rInterval result.Interval) (result.Value, error) { - // Calculate intersection bounds - intersectionStart := maxFloat64(lStart, rStart) - intersectionEnd := minFloat64(lEnd, rEnd) - - // Check if there's no overlap - if compareNumeral(intersectionStart, intersectionEnd) == leftAfterRight { +// calculateNumeralIntersectionFloat64 calculates the intersection of two float64 intervals. +// The bounds are the intervals' own bounds, not start()/end(), so open bounds stay open. +func (i *interpreter) calculateNumeralIntersectionFloat64(lStart, lEnd, rStart, rEnd float64, lLowIncl, lHighIncl, rLowIncl, rHighIncl bool, staticType *types.Interval) (result.Value, error) { + intersectionStart, startInclusive := intersectLowBound(lStart, lLowIncl, rStart, rLowIncl) + intersectionEnd, endInclusive := intersectHighBound(lEnd, lHighIncl, rEnd, rHighIncl) + if isEmptyFloat64Interval(intersectionStart, startInclusive, intersectionEnd, endInclusive) { return result.New(nil) } - // Create intersection interval with appropriate inclusivity - startInclusive := (compareNumeral(intersectionStart, lStart) == leftEqualRight && lInterval.LowInclusive) || - (compareNumeral(intersectionStart, rStart) == leftEqualRight && rInterval.LowInclusive) || - (compareNumeral(intersectionStart, lStart) == leftAfterRight && compareNumeral(intersectionStart, lEnd) == leftBeforeRight) || - (compareNumeral(intersectionStart, rStart) == leftAfterRight && compareNumeral(intersectionStart, rEnd) == leftBeforeRight) - - endInclusive := (compareNumeral(intersectionEnd, lEnd) == leftEqualRight && lInterval.HighInclusive) || - (compareNumeral(intersectionEnd, rEnd) == leftEqualRight && rInterval.HighInclusive) || - (compareNumeral(intersectionEnd, lStart) == leftAfterRight && compareNumeral(intersectionEnd, lEnd) == leftBeforeRight) || - (compareNumeral(intersectionEnd, rStart) == leftAfterRight && compareNumeral(intersectionEnd, rEnd) == leftBeforeRight) - - // Create result values startVal, err := result.New(intersectionStart) if err != nil { return result.Value{}, err @@ -792,50 +763,24 @@ func (i *interpreter) calculateNumeralIntersectionFloat64(lStart, lEnd, rStart, if err != nil { return result.Value{}, err } - - // Create the intersection interval - intersectionInterval := result.Interval{ + return result.New(result.Interval{ Low: startVal, High: endVal, LowInclusive: startInclusive, HighInclusive: endInclusive, - StaticType: lInterval.StaticType, - } - - return result.New(intersectionInterval) + StaticType: staticType, + }) } -// calculateNumeralIntersectionQuantity calculates the intersection of two Quantity intervals -func (i *interpreter) calculateNumeralIntersectionQuantity(lStart, lEnd, rStart, rEnd result.Quantity, lInterval, rInterval result.Interval) (result.Value, error) { - // Calculate intersection bounds - intersectionStart := maxFloat64(lStart.Value, rStart.Value) - intersectionEnd := minFloat64(lEnd.Value, rEnd.Value) - - // Check if there's no overlap - if compareNumeral(intersectionStart, intersectionEnd) == leftAfterRight { +// calculateNumeralIntersectionQuantity calculates the intersection of two Quantity intervals. +// The bounds are the intervals' own bounds, not start()/end(), so open bounds stay open. +func (i *interpreter) calculateNumeralIntersectionQuantity(lStart, lEnd, rStart, rEnd result.Quantity, lLowIncl, lHighIncl, rLowIncl, rHighIncl bool, staticType *types.Interval) (result.Value, error) { + intersectionStart, startInclusive := intersectLowBound(lStart.Value, lLowIncl, rStart.Value, rLowIncl) + intersectionEnd, endInclusive := intersectHighBound(lEnd.Value, lHighIncl, rEnd.Value, rHighIncl) + if isEmptyFloat64Interval(intersectionStart, startInclusive, intersectionEnd, endInclusive) { return result.New(nil) } - // Calculate inclusivity for intersection bounds - // Start is inclusive if it matches an inclusive bound from either interval - startInclusive := true - if compareNumeral(intersectionStart, lStart.Value) == leftEqualRight { - startInclusive = lInterval.LowInclusive - } - if compareNumeral(intersectionStart, rStart.Value) == leftEqualRight { - startInclusive = startInclusive && rInterval.LowInclusive - } - - // End is inclusive if it matches an inclusive bound from either interval - endInclusive := true - if compareNumeral(intersectionEnd, lEnd.Value) == leftEqualRight { - endInclusive = lInterval.HighInclusive - } - if compareNumeral(intersectionEnd, rEnd.Value) == leftEqualRight { - endInclusive = endInclusive && rInterval.HighInclusive - } - - // Create result values with Quantity type startVal, err := result.New(result.Quantity{Value: intersectionStart, Unit: lStart.Unit}) if err != nil { return result.Value{}, err @@ -844,17 +789,48 @@ func (i *interpreter) calculateNumeralIntersectionQuantity(lStart, lEnd, rStart, if err != nil { return result.Value{}, err } - - // Create the intersection interval - intersectionInterval := result.Interval{ + return result.New(result.Interval{ Low: startVal, High: endVal, LowInclusive: startInclusive, HighInclusive: endInclusive, - StaticType: lInterval.StaticType, + StaticType: staticType, + }) +} + +// intersectLowBound returns the greater low bound; on a tie it is closed only if both are. +func intersectLowBound(l float64, lIncl bool, r float64, rIncl bool) (float64, bool) { + switch { + case l > r: + return l, lIncl + case r > l: + return r, rIncl } + return l, lIncl && rIncl +} - return result.New(intersectionInterval) +// intersectHighBound returns the lesser high bound; on a tie it is closed only if both are. +func intersectHighBound(l float64, lIncl bool, r float64, rIncl bool) (float64, bool) { + switch { + case l < r: + return l, lIncl + case r < l: + return r, rIncl + } + return l, lIncl && rIncl +} + +func isEmptyFloat64Interval(low float64, lowIncl bool, high float64, highIncl bool) bool { + return low > high || (low == high && !(lowIncl && highIncl)) +} + +// ownOrEffectiveBound returns an interval's own bound and inclusivity, or the closed effective +// bound from start()/end() when its own bound is null. +func ownOrEffectiveBound(bound result.Value, inclusive bool, effective result.Value) (result.Value, bool) { + if result.IsNull(bound) { + return effective, true + } + return bound, inclusive } // evalIntersectIntervalDateTime handles intersection for date/time interval types @@ -927,16 +903,9 @@ func (i *interpreter) evalIntersectIntervalDate(lStart, lEnd, rStart, rEnd resul return result.New(nil) } - // Determine inclusivity for intersection bounds - startInclusive := (intersectionStartDT.Date.Equal(lStartDT.Date) && lInterval.LowInclusive) || - (intersectionStartDT.Date.Equal(rStartDT.Date) && lInterval.LowInclusive) || - (intersectionStartDT.Date.After(lStartDT.Date) && intersectionStartDT.Date.Before(lEndDT.Date)) || - (intersectionStartDT.Date.After(rStartDT.Date) && intersectionStartDT.Date.Before(rEndDT.Date)) - - endInclusive := (intersectionEndDT.Date.Equal(lEndDT.Date) && lInterval.HighInclusive) || - (intersectionEndDT.Date.Equal(rEndDT.Date) && lInterval.HighInclusive) || - (intersectionEndDT.Date.After(lStartDT.Date) && intersectionEndDT.Date.Before(lEndDT.Date)) || - (intersectionEndDT.Date.After(rStartDT.Date) && intersectionEndDT.Date.Before(rEndDT.Date)) + // Effective bounds from startAndEnd() are closed, so the result is closed. + startInclusive := true + endInclusive := true // Create the intersection interval with Date values intersectionInterval := result.Interval{ @@ -981,16 +950,9 @@ func (i *interpreter) evalIntersectIntervalDateTimeType(lStart, lEnd, rStart, rE return result.New(nil) } - // Determine inclusivity for intersection bounds - startInclusive := (intersectionStart.Date.Equal(lStartDT.Date) && lInterval.LowInclusive) || - (intersectionStart.Date.Equal(rStartDT.Date) && lInterval.LowInclusive) || - (intersectionStart.Date.After(lStartDT.Date) && intersectionStart.Date.Before(lEndDT.Date)) || - (intersectionStart.Date.After(rStartDT.Date) && intersectionStart.Date.Before(rEndDT.Date)) - - endInclusive := (intersectionEnd.Date.Equal(lEndDT.Date) && lInterval.HighInclusive) || - (intersectionEnd.Date.Equal(rEndDT.Date) && lInterval.HighInclusive) || - (intersectionEnd.Date.After(lStartDT.Date) && intersectionEnd.Date.Before(lEndDT.Date)) || - (intersectionEnd.Date.After(rStartDT.Date) && intersectionEnd.Date.Before(rEndDT.Date)) + // Effective bounds from startAndEnd() are closed, so the result is closed. + startInclusive := true + endInclusive := true // Create result values startVal, err := result.New(intersectionStart) @@ -1051,16 +1013,9 @@ func (i *interpreter) evalIntersectIntervalTime(lStart, lEnd, rStart, rEnd resul return result.New(nil) } - // Determine inclusivity for intersection bounds - startInclusive := (intersectionStartDT.Date.Equal(lStartDT.Date) && lInterval.LowInclusive) || - (intersectionStartDT.Date.Equal(rStartDT.Date) && lInterval.LowInclusive) || - (intersectionStartDT.Date.After(lStartDT.Date) && intersectionStartDT.Date.Before(lEndDT.Date)) || - (intersectionStartDT.Date.After(rStartDT.Date) && intersectionStartDT.Date.Before(rEndDT.Date)) - - endInclusive := (intersectionEndDT.Date.Equal(lEndDT.Date) && lInterval.HighInclusive) || - (intersectionEndDT.Date.Equal(rEndDT.Date) && lInterval.HighInclusive) || - (intersectionEndDT.Date.After(lStartDT.Date) && intersectionEndDT.Date.Before(lEndDT.Date)) || - (intersectionEndDT.Date.After(rStartDT.Date) && intersectionEndDT.Date.Before(rEndDT.Date)) + // Effective bounds from startAndEnd() are closed, so the result is closed. + startInclusive := true + endInclusive := true // Create the intersection interval with Time values intersectionInterval := result.Interval{ @@ -1219,17 +1174,22 @@ func (i *interpreter) evalIntersectIntervalDecimal(m model.IBinaryExpression, lO lInterval, _ := result.ToInterval(lObj) rInterval, _ := result.ToInterval(rObj) + lLow, lLowIncl := ownOrEffectiveBound(lInterval.Low, lInterval.LowInclusive, lStart) + lHigh, lHighIncl := ownOrEffectiveBound(lInterval.High, lInterval.HighInclusive, lEnd) + rLow, rLowIncl := ownOrEffectiveBound(rInterval.Low, rInterval.LowInclusive, rStart) + rHigh, rHighIncl := ownOrEffectiveBound(rInterval.High, rInterval.HighInclusive, rEnd) + // Convert to float64 values - lStartVal, lEndVal, err := applyToValues(lStart, lEnd, result.ToFloat64) + lStartVal, lEndVal, err := applyToValues(lLow, lHigh, result.ToFloat64) if err != nil { return result.Value{}, err } - rStartVal, rEndVal, err := applyToValues(rStart, rEnd, result.ToFloat64) + rStartVal, rEndVal, err := applyToValues(rLow, rHigh, result.ToFloat64) if err != nil { return result.Value{}, err } - return i.calculateNumeralIntersectionFloat64(lStartVal, lEndVal, rStartVal, rEndVal, lInterval, rInterval) + return i.calculateNumeralIntersectionFloat64(lStartVal, lEndVal, rStartVal, rEndVal, lLowIncl, lHighIncl, rLowIncl, rHighIncl, lInterval.StaticType) } func (i *interpreter) evalIntersectIntervalQuantity(m model.IBinaryExpression, lObj, rObj result.Value) (result.Value, error) { @@ -1257,12 +1217,17 @@ func (i *interpreter) evalIntersectIntervalQuantity(m model.IBinaryExpression, l lInterval, _ := result.ToInterval(lObj) rInterval, _ := result.ToInterval(rObj) + lLow, lLowIncl := ownOrEffectiveBound(lInterval.Low, lInterval.LowInclusive, lStart) + lHigh, lHighIncl := ownOrEffectiveBound(lInterval.High, lInterval.HighInclusive, lEnd) + rLow, rLowIncl := ownOrEffectiveBound(rInterval.Low, rInterval.LowInclusive, rStart) + rHigh, rHighIncl := ownOrEffectiveBound(rInterval.High, rInterval.HighInclusive, rEnd) + // Convert to Quantity values - lStartVal, lEndVal, err := applyToValues(lStart, lEnd, result.ToQuantity) + lStartVal, lEndVal, err := applyToValues(lLow, lHigh, result.ToQuantity) if err != nil { return result.Value{}, err } - rStartVal, rEndVal, err := applyToValues(rStart, rEnd, result.ToQuantity) + rStartVal, rEndVal, err := applyToValues(rLow, rHigh, result.ToQuantity) if err != nil { return result.Value{}, err } @@ -1272,5 +1237,5 @@ func (i *interpreter) evalIntersectIntervalQuantity(m model.IBinaryExpression, l return result.Value{}, fmt.Errorf("intersect operator received Quantities with differing unit values, unit conversion is not currently supported, got: %v, %v", lStartVal.Unit, rStartVal.Unit) } - return i.calculateNumeralIntersectionQuantity(lStartVal, lEndVal, rStartVal, rEndVal, lInterval, rInterval) + return i.calculateNumeralIntersectionQuantity(lStartVal, lEndVal, rStartVal, rEndVal, lLowIncl, lHighIncl, rLowIncl, rHighIncl, lInterval.StaticType) } diff --git a/tests/enginetests/operator_interval_test.go b/tests/enginetests/operator_interval_test.go index bcd06c1..69ea976 100644 --- a/tests/enginetests/operator_interval_test.go +++ b/tests/enginetests/operator_interval_test.go @@ -329,6 +329,149 @@ func TestIntersectInterval(t *testing.T) { StaticType: &types.Interval{PointType: types.Integer}, }), }, + // Open bounds + { + name: "Long intervals with open low bound", + cql: "Interval(3L, 7L] intersect Interval[1L, 10L]", + wantResult: newOrFatal(t, result.Interval{ + Low: newOrFatal(t, int64(4)), + High: newOrFatal(t, int64(7)), + LowInclusive: true, + HighInclusive: true, + StaticType: &types.Interval{PointType: types.Long}, + }), + }, + { + name: "Long intervals with open high bound", + cql: "Interval[1L, 7L) intersect Interval[1L, 10L]", + wantResult: newOrFatal(t, result.Interval{ + Low: newOrFatal(t, int64(1)), + High: newOrFatal(t, int64(6)), + LowInclusive: true, + HighInclusive: true, + StaticType: &types.Interval{PointType: types.Long}, + }), + }, + { + name: "Identical open Long intervals", + cql: "Interval(1L, 5L) intersect Interval(1L, 5L)", + wantResult: newOrFatal(t, result.Interval{ + Low: newOrFatal(t, int64(2)), + High: newOrFatal(t, int64(4)), + LowInclusive: true, + HighInclusive: true, + StaticType: &types.Interval{PointType: types.Long}, + }), + }, + { + name: "Long point in intersection with open low bound", + cql: "4L in (Interval(3L, 7L] intersect Interval[1L, 10L])", + wantResult: newOrFatal(t, true), + }, + { + name: "Decimal intervals with open bounds", + cql: "Interval(1.5, 5.5) intersect Interval(3.0, 7.0)", + wantResult: newOrFatal(t, result.Interval{ + Low: newOrFatal(t, 3.0), + High: newOrFatal(t, 5.5), + LowInclusive: false, + HighInclusive: false, + StaticType: &types.Interval{PointType: types.Decimal}, + }), + }, + { + name: "Decimal intervals with tied high bound", + cql: "Interval[1.5, 5.5) intersect Interval[3.0, 5.5]", + wantResult: newOrFatal(t, result.Interval{ + Low: newOrFatal(t, 3.0), + High: newOrFatal(t, 5.5), + LowInclusive: true, + HighInclusive: false, + StaticType: &types.Interval{PointType: types.Decimal}, + }), + }, + { + name: "Identical open Decimal intervals", + cql: "Interval(1.0, 5.0) intersect Interval(1.0, 5.0)", + wantResult: newOrFatal(t, result.Interval{ + Low: newOrFatal(t, 1.0), + High: newOrFatal(t, 5.0), + LowInclusive: false, + HighInclusive: false, + StaticType: &types.Interval{PointType: types.Decimal}, + }), + }, + { + name: "Decimal intervals touching at an open bound", + cql: "Interval[1.0, 3.0) intersect Interval[3.0, 5.0]", + wantResult: newOrFatal(t, nil), + }, + { + name: "Quantity intervals with open low bound", + cql: "Interval[1.0 'cm', 5.0 'cm'] intersect Interval(3.0 'cm', 7.0 'cm']", + wantResult: newOrFatal(t, result.Interval{ + Low: newOrFatal(t, result.Quantity{Value: 3.0, Unit: "cm"}), + High: newOrFatal(t, result.Quantity{Value: 5.0, Unit: "cm"}), + LowInclusive: false, + HighInclusive: true, + StaticType: &types.Interval{PointType: types.Quantity}, + }), + }, + { + name: "Date intervals with open low bound", + cql: "Interval(@2020-01-01, @2020-06-01] intersect Interval[@2020-01-02, @2020-09-01]", + wantResult: newOrFatal(t, result.Interval{ + Low: newOrFatal(t, result.Date{Date: time.Date(2020, time.January, 2, 0, 0, 0, 0, defaultEvalTimestamp.Location()), Precision: model.DAY}), + High: newOrFatal(t, result.Date{Date: time.Date(2020, time.June, 1, 0, 0, 0, 0, defaultEvalTimestamp.Location()), Precision: model.DAY}), + LowInclusive: true, + HighInclusive: true, + StaticType: &types.Interval{PointType: types.Date}, + }), + }, + { + name: "Date intervals with open high bound", + cql: "Interval[@2020-01-01, @2020-06-01) intersect Interval[@2020-03-01, @2020-05-31]", + wantResult: newOrFatal(t, result.Interval{ + Low: newOrFatal(t, result.Date{Date: time.Date(2020, time.March, 1, 0, 0, 0, 0, defaultEvalTimestamp.Location()), Precision: model.DAY}), + High: newOrFatal(t, result.Date{Date: time.Date(2020, time.May, 31, 0, 0, 0, 0, defaultEvalTimestamp.Location()), Precision: model.DAY}), + LowInclusive: true, + HighInclusive: true, + StaticType: &types.Interval{PointType: types.Date}, + }), + }, + { + name: "Date intervals with open high bound, operands swapped", + cql: "Interval[@2020-03-01, @2020-05-31] intersect Interval[@2020-01-01, @2020-06-01)", + wantResult: newOrFatal(t, result.Interval{ + Low: newOrFatal(t, result.Date{Date: time.Date(2020, time.March, 1, 0, 0, 0, 0, defaultEvalTimestamp.Location()), Precision: model.DAY}), + High: newOrFatal(t, result.Date{Date: time.Date(2020, time.May, 31, 0, 0, 0, 0, defaultEvalTimestamp.Location()), Precision: model.DAY}), + LowInclusive: true, + HighInclusive: true, + StaticType: &types.Interval{PointType: types.Date}, + }), + }, + { + name: "DateTime intervals with open low bound", + cql: "Interval(@2020-01-01T10:00:00.000, @2020-01-01T15:00:00.000] intersect Interval[@2020-01-01T10:00:00.001, @2020-01-01T18:00:00.000]", + wantResult: newOrFatal(t, result.Interval{ + Low: newOrFatal(t, result.DateTime{Date: time.Date(2020, time.January, 1, 10, 0, 0, 1000000, defaultEvalTimestamp.Location()), Precision: model.MILLISECOND}), + High: newOrFatal(t, result.DateTime{Date: time.Date(2020, time.January, 1, 15, 0, 0, 0, defaultEvalTimestamp.Location()), Precision: model.MILLISECOND}), + LowInclusive: true, + HighInclusive: true, + StaticType: &types.Interval{PointType: types.DateTime}, + }), + }, + { + name: "Time intervals with open low bound", + cql: "Interval(@T10:00:00.000, @T15:00:00.000] intersect Interval[@T10:00:00.001, @T18:00:00.000]", + wantResult: newOrFatal(t, result.Interval{ + Low: newOrFatal(t, result.Time{Date: time.Date(0, time.January, 1, 10, 0, 0, 1000000, defaultEvalTimestamp.Location()), Precision: model.MILLISECOND}), + High: newOrFatal(t, result.Time{Date: time.Date(0, time.January, 1, 15, 0, 0, 0, defaultEvalTimestamp.Location()), Precision: model.MILLISECOND}), + LowInclusive: true, + HighInclusive: true, + StaticType: &types.Interval{PointType: types.Time}, + }), + }, } for _, tc := range tests { From 024065a160e98187346fe7ca350d6ddf7be5b50b Mon Sep 17 00:00:00 2001 From: Victor Frank Date: Sun, 11 Oct 2026 11:24:46 -0400 Subject: [PATCH 4/4] #144 handle null open bounds and Quantity null bound units in Intersect A null open bound is unknown, so the result keeps that bound null and open while the known bounds still decide overlap (TestIntersectNull* now pass and are no longer excluded). Quantity unit checks now use every non-null bound, so a null closed bound no longer reports a unit mismatch. --- interpreter/operator_interval.go | 152 ++++++++++++++------ tests/enginetests/operator_interval_test.go | 76 ++++++++++ tests/spectests/exclusions/exclusions.go | 5 - 3 files changed, 183 insertions(+), 50 deletions(-) diff --git a/interpreter/operator_interval.go b/interpreter/operator_interval.go index f36dea6..99fcbfc 100644 --- a/interpreter/operator_interval.go +++ b/interpreter/operator_interval.go @@ -651,21 +651,6 @@ func (i *interpreter) evalIntersectInterval(m model.IBinaryExpression, lObj, rOb return result.New(nil) } - // Get start and end bounds for both intervals - lStart, lEnd, err := startAndEnd(lObj, &i.evaluationTimestamp) - if err != nil { - return result.Value{}, err - } - rStart, rEnd, err := startAndEnd(rObj, &i.evaluationTimestamp) - if err != nil { - return result.Value{}, err - } - - // If any bound is null, return null - if result.IsNull(lStart) || result.IsNull(lEnd) || result.IsNull(rStart) || result.IsNull(rEnd) { - return result.New(nil) - } - // This function only handles Date, DateTime, and Time intervals return i.evalIntersectIntervalDateTime(m, lObj, rObj) } @@ -824,6 +809,68 @@ func isEmptyFloat64Interval(low float64, lowIncl bool, high float64, highIncl bo return low > high || (low == high && !(lowIncl && highIncl)) } +// unknownBounds records which bounds of an intersection are unknown because an operand has a null +// open bound. +type unknownBounds struct { + start, end bool +} + +// fillUnknownBounds replaces an unknown start or end with the other interval's, so the known bounds +// still decide whether the intervals overlap. +func fillUnknownBounds(lStart, lEnd, rStart, rEnd *result.Value, pointType types.IType, evaluationTimestamp *time.Time) (unknownBounds, error) { + u := unknownBounds{ + start: result.IsNull(*lStart) || result.IsNull(*rStart), + end: result.IsNull(*lEnd) || result.IsNull(*rEnd), + } + var err error + switch { + case result.IsNull(*lStart) && result.IsNull(*rStart): + *lStart, err = minValue(pointType, evaluationTimestamp) + *rStart = *lStart + case result.IsNull(*lStart): + *lStart = *rStart + case result.IsNull(*rStart): + *rStart = *lStart + } + if err != nil { + return unknownBounds{}, err + } + switch { + case result.IsNull(*lEnd) && result.IsNull(*rEnd): + *lEnd, err = maxValue(pointType, evaluationTimestamp) + *rEnd = *lEnd + case result.IsNull(*lEnd): + *lEnd = *rEnd + case result.IsNull(*rEnd): + *rEnd = *lEnd + } + return u, err +} + +// apply sets the unknown bounds of an intersection result back to null and open. +func (u unknownBounds) apply(v result.Value, err error) (result.Value, error) { + if err != nil || result.IsNull(v) || (!u.start && !u.end) { + return v, err + } + interval, err := result.ToInterval(v) + if err != nil { + return result.Value{}, err + } + if u.start { + if interval.Low, err = result.New(nil); err != nil { + return result.Value{}, err + } + interval.LowInclusive = false + } + if u.end { + if interval.High, err = result.New(nil); err != nil { + return result.Value{}, err + } + interval.HighInclusive = false + } + return result.New(interval) +} + // ownOrEffectiveBound returns an interval's own bound and inclusivity, or the closed effective // bound from start()/end() when its own bound is null. func ownOrEffectiveBound(bound result.Value, inclusive bool, effective result.Value) (result.Value, bool) { @@ -845,22 +892,22 @@ func (i *interpreter) evalIntersectIntervalDateTime(m model.IBinaryExpression, l return result.Value{}, err } - // If any bound is null, return null - if result.IsNull(lStart) || result.IsNull(lEnd) || result.IsNull(rStart) || result.IsNull(rEnd) { - return result.New(nil) + lInterval, _ := result.ToInterval(lObj) + unknown, err := fillUnknownBounds(&lStart, &lEnd, &rStart, &rEnd, lInterval.StaticType.PointType, &i.evaluationTimestamp) + if err != nil { + return result.Value{}, err } // Get interval metadata for result construction - lInterval, _ := result.ToInterval(lObj) // Handle different date/time types switch lInterval.StaticType.PointType { case types.Date: - return i.evalIntersectIntervalDate(lStart, lEnd, rStart, rEnd, lInterval) + return unknown.apply(i.evalIntersectIntervalDate(lStart, lEnd, rStart, rEnd, lInterval)) case types.DateTime: - return i.evalIntersectIntervalDateTimeType(lStart, lEnd, rStart, rEnd, lInterval) + return unknown.apply(i.evalIntersectIntervalDateTimeType(lStart, lEnd, rStart, rEnd, lInterval)) case types.Time: - return i.evalIntersectIntervalTime(lStart, lEnd, rStart, rEnd, lInterval) + return unknown.apply(i.evalIntersectIntervalTime(lStart, lEnd, rStart, rEnd, lInterval)) default: return result.Value{}, fmt.Errorf("internal error - unsupported date/time type in evalIntersectIntervalDateTime: %v", lInterval.StaticType.PointType) } @@ -1089,13 +1136,13 @@ func (i *interpreter) evalIntersectIntervalInteger(m model.IBinaryExpression, lO return result.Value{}, err } - // If any bound is null, return null - if result.IsNull(lStart) || result.IsNull(lEnd) || result.IsNull(rStart) || result.IsNull(rEnd) { - return result.New(nil) + lInterval, _ := result.ToInterval(lObj) + unknown, err := fillUnknownBounds(&lStart, &lEnd, &rStart, &rEnd, lInterval.StaticType.PointType, &i.evaluationTimestamp) + if err != nil { + return result.Value{}, err } // Get interval metadata for result construction - lInterval, _ := result.ToInterval(lObj) rInterval, _ := result.ToInterval(rObj) // Convert to int32 values @@ -1108,7 +1155,7 @@ func (i *interpreter) evalIntersectIntervalInteger(m model.IBinaryExpression, lO return result.Value{}, err } - return i.calculateNumeralIntersectionInt32(lStartVal, lEndVal, rStartVal, rEndVal, lInterval, rInterval) + return unknown.apply(i.calculateNumeralIntersectionInt32(lStartVal, lEndVal, rStartVal, rEndVal, lInterval, rInterval)) } func (i *interpreter) evalIntersectIntervalLong(m model.IBinaryExpression, lObj, rObj result.Value) (result.Value, error) { @@ -1127,13 +1174,13 @@ func (i *interpreter) evalIntersectIntervalLong(m model.IBinaryExpression, lObj, return result.Value{}, err } - // If any bound is null, return null - if result.IsNull(lStart) || result.IsNull(lEnd) || result.IsNull(rStart) || result.IsNull(rEnd) { - return result.New(nil) + lInterval, _ := result.ToInterval(lObj) + unknown, err := fillUnknownBounds(&lStart, &lEnd, &rStart, &rEnd, lInterval.StaticType.PointType, &i.evaluationTimestamp) + if err != nil { + return result.Value{}, err } // Get interval metadata for result construction - lInterval, _ := result.ToInterval(lObj) rInterval, _ := result.ToInterval(rObj) // Convert to int64 values @@ -1146,7 +1193,7 @@ func (i *interpreter) evalIntersectIntervalLong(m model.IBinaryExpression, lObj, return result.Value{}, err } - return i.calculateNumeralIntersectionInt64(lStartVal, lEndVal, rStartVal, rEndVal, lInterval, rInterval) + return unknown.apply(i.calculateNumeralIntersectionInt64(lStartVal, lEndVal, rStartVal, rEndVal, lInterval, rInterval)) } func (i *interpreter) evalIntersectIntervalDecimal(m model.IBinaryExpression, lObj, rObj result.Value) (result.Value, error) { @@ -1165,13 +1212,13 @@ func (i *interpreter) evalIntersectIntervalDecimal(m model.IBinaryExpression, lO return result.Value{}, err } - // If any bound is null, return null - if result.IsNull(lStart) || result.IsNull(lEnd) || result.IsNull(rStart) || result.IsNull(rEnd) { - return result.New(nil) + lInterval, _ := result.ToInterval(lObj) + unknown, err := fillUnknownBounds(&lStart, &lEnd, &rStart, &rEnd, lInterval.StaticType.PointType, &i.evaluationTimestamp) + if err != nil { + return result.Value{}, err } // Get interval metadata for result construction - lInterval, _ := result.ToInterval(lObj) rInterval, _ := result.ToInterval(rObj) lLow, lLowIncl := ownOrEffectiveBound(lInterval.Low, lInterval.LowInclusive, lStart) @@ -1189,7 +1236,7 @@ func (i *interpreter) evalIntersectIntervalDecimal(m model.IBinaryExpression, lO return result.Value{}, err } - return i.calculateNumeralIntersectionFloat64(lStartVal, lEndVal, rStartVal, rEndVal, lLowIncl, lHighIncl, rLowIncl, rHighIncl, lInterval.StaticType) + return unknown.apply(i.calculateNumeralIntersectionFloat64(lStartVal, lEndVal, rStartVal, rEndVal, lLowIncl, lHighIncl, rLowIncl, rHighIncl, lInterval.StaticType)) } func (i *interpreter) evalIntersectIntervalQuantity(m model.IBinaryExpression, lObj, rObj result.Value) (result.Value, error) { @@ -1208,13 +1255,13 @@ func (i *interpreter) evalIntersectIntervalQuantity(m model.IBinaryExpression, l return result.Value{}, err } - // If any bound is null, return null - if result.IsNull(lStart) || result.IsNull(lEnd) || result.IsNull(rStart) || result.IsNull(rEnd) { - return result.New(nil) + lInterval, _ := result.ToInterval(lObj) + unknown, err := fillUnknownBounds(&lStart, &lEnd, &rStart, &rEnd, lInterval.StaticType.PointType, &i.evaluationTimestamp) + if err != nil { + return result.Value{}, err } // Get interval metadata for result construction - lInterval, _ := result.ToInterval(lObj) rInterval, _ := result.ToInterval(rObj) lLow, lLowIncl := ownOrEffectiveBound(lInterval.Low, lInterval.LowInclusive, lStart) @@ -1232,10 +1279,25 @@ func (i *interpreter) evalIntersectIntervalQuantity(m model.IBinaryExpression, l return result.Value{}, err } - // Check unit compatibility - if lStartVal.Unit != rStartVal.Unit { - return result.Value{}, fmt.Errorf("intersect operator received Quantities with differing unit values, unit conversion is not currently supported, got: %v, %v", lStartVal.Unit, rStartVal.Unit) + // Only non-null bounds carry a real unit; the min/max placeholders for null bounds take it on. + var unit model.Unit + for _, bound := range []result.Value{lInterval.Low, lInterval.High, rInterval.Low, rInterval.High} { + if result.IsNull(bound) { + continue + } + q, err := result.ToQuantity(bound) + if err != nil { + return result.Value{}, err + } + if unit == "" { + unit = q.Unit + } else if q.Unit != unit { + return result.Value{}, fmt.Errorf("intersect operator received Quantities with differing unit values, unit conversion is not currently supported, got: %v, %v", unit, q.Unit) + } + } + if unit != "" { + lStartVal.Unit, lEndVal.Unit, rStartVal.Unit, rEndVal.Unit = unit, unit, unit, unit } - return i.calculateNumeralIntersectionQuantity(lStartVal, lEndVal, rStartVal, rEndVal, lLowIncl, lHighIncl, rLowIncl, rHighIncl, lInterval.StaticType) + return unknown.apply(i.calculateNumeralIntersectionQuantity(lStartVal, lEndVal, rStartVal, rEndVal, lLowIncl, lHighIncl, rLowIncl, rHighIncl, lInterval.StaticType)) } diff --git a/tests/enginetests/operator_interval_test.go b/tests/enginetests/operator_interval_test.go index 69ea976..2d03c67 100644 --- a/tests/enginetests/operator_interval_test.go +++ b/tests/enginetests/operator_interval_test.go @@ -472,6 +472,77 @@ func TestIntersectInterval(t *testing.T) { StaticType: &types.Interval{PointType: types.Time}, }), }, + { + name: "Integer interval with null open high bound", + cql: "Interval[1, 10] intersect Interval[5, null)", + wantResult: newOrFatal(t, result.Interval{ + Low: newOrFatal(t, int32(5)), + High: newOrFatal(t, nil), + LowInclusive: true, + HighInclusive: false, + StaticType: &types.Interval{PointType: types.Integer}, + }), + }, + { + name: "Integer interval with null open high bound, operands swapped", + cql: "Interval[5, null) intersect Interval[1, 10]", + wantResult: newOrFatal(t, result.Interval{ + Low: newOrFatal(t, int32(5)), + High: newOrFatal(t, nil), + LowInclusive: true, + HighInclusive: false, + StaticType: &types.Interval{PointType: types.Integer}, + }), + }, + { + name: "Integer interval with null open low bound", + cql: "Interval(null, 5] intersect Interval[1, 10]", + wantResult: newOrFatal(t, result.Interval{ + Low: newOrFatal(t, nil), + High: newOrFatal(t, int32(5)), + LowInclusive: false, + HighInclusive: true, + StaticType: &types.Interval{PointType: types.Integer}, + }), + }, + { + name: "Integer interval with null open high bound and no overlap", + cql: "Interval[1, 3] intersect Interval[5, null)", + wantResult: newOrFatal(t, nil), + }, + { + name: "Decimal interval with null open high bound", + cql: "Interval[1.0, 10.0] intersect Interval[5.0, null)", + wantResult: newOrFatal(t, result.Interval{ + Low: newOrFatal(t, 5.0), + High: newOrFatal(t, nil), + LowInclusive: true, + HighInclusive: false, + StaticType: &types.Interval{PointType: types.Decimal}, + }), + }, + { + name: "Quantity interval with null closed low bound", + cql: "Interval[null, 5 'cm'] intersect Interval[1 'cm', 10 'cm']", + wantResult: newOrFatal(t, result.Interval{ + Low: newOrFatal(t, result.Quantity{Value: 1, Unit: "cm"}), + High: newOrFatal(t, result.Quantity{Value: 5, Unit: "cm"}), + LowInclusive: true, + HighInclusive: true, + StaticType: &types.Interval{PointType: types.Quantity}, + }), + }, + { + name: "Date interval with null open high bound", + cql: "Interval[@2020-01-01, @2020-06-01] intersect Interval[@2020-03-01, null)", + wantResult: newOrFatal(t, result.Interval{ + Low: newOrFatal(t, result.Date{Date: time.Date(2020, time.March, 1, 0, 0, 0, 0, defaultEvalTimestamp.Location()), Precision: model.DAY}), + High: newOrFatal(t, nil), + LowInclusive: true, + HighInclusive: false, + StaticType: &types.Interval{PointType: types.Date}, + }), + }, } for _, tc := range tests { @@ -507,6 +578,11 @@ func TestIntersectInterval_Error(t *testing.T) { cql: "Interval[1'cm', 5'cm'] intersect Interval[3'm', 7'm']", wantEvalErrContains: "intersect operator received Quantities with differing unit values", }, + { + name: "Quantity intervals with different high bound units", + cql: "Interval[1 'cm', 5 'cm'] intersect Interval[3 'cm', 7 'm']", + wantEvalErrContains: "intersect operator received Quantities with differing unit values", + }, } for _, tc := range tests { diff --git a/tests/spectests/exclusions/exclusions.go b/tests/spectests/exclusions/exclusions.go index 34e2cb6..df72cde 100644 --- a/tests/spectests/exclusions/exclusions.go +++ b/tests/spectests/exclusions/exclusions.go @@ -310,11 +310,6 @@ func XMLTestFileExclusionDefinitions() map[string]XMLTestFileExclusions { // TODO: b/342064453 - Ambiguous match. "TestEqualNull", "TestInNullBoundaries", - "TestIntersectNull", - "TestIntersectNull1", - "TestIntersectNull2", - "TestIntersectNull3", - "TestIntersectNull4", }, }, "CqlListOperatorsTest.xml": {