From 8d2078de5571bca025aef11622cc007e972add99 Mon Sep 17 00:00:00 2001 From: Victor Frank Date: Mon, 30 Jun 2025 19:49:33 -0500 Subject: [PATCH 1/3] #142 add interval support in Except --- interpreter/operator_dispatcher.go | 29 ++ interpreter/operator_interval.go | 482 ++++++++++++++++++++ parser/operators.go | 12 +- tests/enginetests/operator_interval_test.go | 218 +++++++++ tests/spectests/exclusions/exclusions.go | 3 +- 5 files changed, 742 insertions(+), 2 deletions(-) diff --git a/interpreter/operator_dispatcher.go b/interpreter/operator_dispatcher.go index af782da..05eae4f 100644 --- a/interpreter/operator_dispatcher.go +++ b/interpreter/operator_dispatcher.go @@ -1217,6 +1217,35 @@ func (i *interpreter) binaryOverloads(m model.IBinaryExpression) ([]convert.Over Operands: []types.IType{&types.List{ElementType: types.Any}, &types.List{ElementType: types.Any}}, Result: evalExcept, }, + // Interval overloads + { + Operands: []types.IType{&types.Interval{PointType: types.Integer}, &types.Interval{PointType: types.Integer}}, + Result: evalExceptIntervalNumeral, + }, + { + Operands: []types.IType{&types.Interval{PointType: types.Long}, &types.Interval{PointType: types.Long}}, + Result: evalExceptIntervalNumeral, + }, + { + Operands: []types.IType{&types.Interval{PointType: types.Decimal}, &types.Interval{PointType: types.Decimal}}, + Result: evalExceptIntervalNumeral, + }, + { + Operands: []types.IType{&types.Interval{PointType: types.Date}, &types.Interval{PointType: types.Date}}, + Result: i.evalExceptIntervalDateTime, + }, + { + Operands: []types.IType{&types.Interval{PointType: types.DateTime}, &types.Interval{PointType: types.DateTime}}, + Result: i.evalExceptIntervalDateTime, + }, + { + Operands: []types.IType{&types.Interval{PointType: types.Time}, &types.Interval{PointType: types.Time}}, + Result: i.evalExceptIntervalDateTime, + }, + { + Operands: []types.IType{&types.Interval{PointType: types.Quantity}, &types.Interval{PointType: types.Quantity}}, + Result: evalExceptIntervalQuantity, + }, }, nil case *model.Intersect: return []convert.Overload[evalBinarySignature]{ diff --git a/interpreter/operator_interval.go b/interpreter/operator_interval.go index e0461dc..83ac608 100644 --- a/interpreter/operator_interval.go +++ b/interpreter/operator_interval.go @@ -641,3 +641,485 @@ func evalWidthInterval(m model.IUnaryExpression, intervalObj result.Value) (resu } return result.Value{}, fmt.Errorf("internal error - unsupported point type in evalWidthInterval: %v", start.RuntimeType()) } + +// evalExceptIntervalInteger handles except for integer intervals +func (i *interpreter) evalExceptIntervalInteger(leftStart, leftEnd, rightStart, rightEnd result.Value, leftInterval *result.Interval) (result.Value, error) { + ls, le, rs, re, err := applyToValues4(leftStart, leftEnd, rightStart, rightEnd, result.ToInt32) + if err != nil { + return result.Value{}, err + } + + // No overlap cases + if le < rs || ls > re { + return createCleanInterval(leftStart, leftEnd, leftInterval.LowInclusive, leftInterval.HighInclusive, leftInterval.StaticType) + } + + // Complete overlap - left is completely contained in right + if rs <= ls && le <= re { + return result.New(nil) + } + + // Properly contained check - right is properly contained in left + if ls < rs && re < le { + return result.New(nil) + } + + // Partial overlap cases + if rs <= ls && ls < re && re < le { + // Left overlap: return [re+1, le] + newStart, err := result.New(re + 1) + if err != nil { + return result.Value{}, err + } + return createCleanInterval(newStart, leftEnd, true, leftInterval.HighInclusive, leftInterval.StaticType) + } + + if ls < rs && rs < le && le <= re { + // Right overlap: return [ls, rs-1] + newEnd, err := result.New(rs - 1) + if err != nil { + return result.Value{}, err + } + return createCleanInterval(leftStart, newEnd, leftInterval.LowInclusive, true, leftInterval.StaticType) + } + + return result.New(nil) +} + +// evalExceptIntervalDate handles except for date intervals +func (i *interpreter) evalExceptIntervalDate(leftStart, leftEnd, rightStart, rightEnd result.Value, leftInterval *result.Interval) (result.Value, error) { + ls, le, rs, re, err := applyToValues4(leftStart, leftEnd, rightStart, rightEnd, result.ToDateTime) + if err != nil { + return result.Value{}, err + } + + // No overlap cases + comp1, err := compareDateTimeWithPrecision(le, rs, model.DAY) + if err != nil { + return result.Value{}, err + } + comp2, err := compareDateTimeWithPrecision(ls, re, model.DAY) + if err != nil { + return result.Value{}, err + } + if comp1 == leftBeforeRight || comp2 == leftAfterRight { + return createCleanInterval(leftStart, leftEnd, leftInterval.LowInclusive, leftInterval.HighInclusive, leftInterval.StaticType) + } + + // Complete overlap - left is completely contained in right + comp3, err := compareDateTimeWithPrecision(rs, ls, model.DAY) + if err != nil { + return result.Value{}, err + } + comp4, err := compareDateTimeWithPrecision(le, re, model.DAY) + if err != nil { + return result.Value{}, err + } + if (comp3 == leftBeforeRight || comp3 == leftEqualRight) && (comp4 == leftBeforeRight || comp4 == leftEqualRight) { + return result.New(nil) + } + + // Properly contained check - right is properly contained in left + comp5, err := compareDateTimeWithPrecision(ls, rs, model.DAY) + if err != nil { + return result.Value{}, err + } + comp6, err := compareDateTimeWithPrecision(re, le, model.DAY) + if err != nil { + return result.Value{}, err + } + if comp5 == leftBeforeRight && comp6 == leftBeforeRight { + return result.New(nil) + } + + // TODO: For dates, we need to handle partial overlaps carefully + return result.New(nil) +} + +// Helper function to apply a conversion function to 4 values +func applyToValues4[T any](v1, v2, v3, v4 result.Value, converter func(result.Value) (T, error)) (T, T, T, T, error) { + var zero T + r1, err := converter(v1) + if err != nil { + return zero, zero, zero, zero, err + } + r2, err := converter(v2) + if err != nil { + return zero, zero, zero, zero, err + } + r3, err := converter(v3) + if err != nil { + return zero, zero, zero, zero, err + } + r4, err := converter(v4) + if err != nil { + return zero, zero, zero, zero, err + } + return r1, r2, r3, r4, nil +} + +// createCleanInterval creates a new interval without source metadata +func createCleanInterval(low, high result.Value, lowInclusive, highInclusive bool, staticType *types.Interval) (result.Value, error) { + return result.New(result.Interval{ + Low: low, + High: high, + LowInclusive: lowInclusive, + HighInclusive: highInclusive, + StaticType: staticType, + }) +} + +// evalExceptIntervalNumeral handles except for numeric intervals (Integer, Long, Decimal) +func evalExceptIntervalNumeral(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 interval bounds + leftStart, leftEnd, err := startAndEnd(lObj, nil) + if err != nil { + return result.Value{}, err + } + rightStart, rightEnd, err := startAndEnd(rObj, nil) + if err != nil { + return result.Value{}, err + } + + // If any bound is null, return null + if result.IsNull(leftStart) || result.IsNull(leftEnd) || result.IsNull(rightStart) || result.IsNull(rightEnd) { + return result.New(nil) + } + + // Get the interval type for creating result + leftInterval, err := result.ToInterval(lObj) + if err != nil { + return result.Value{}, err + } + + // Dispatch based on point type + switch leftStart.RuntimeType() { + case types.Integer: + ls, le, rs, re, err := applyToValues4(leftStart, leftEnd, rightStart, rightEnd, result.ToInt32) + if err != nil { + return result.Value{}, err + } + return evalExceptIntervalNumeralLogic(ls, le, rs, re, leftStart, leftEnd, &leftInterval, func(val int32) (result.Value, error) { + return result.New(val + 1) + }, func(val int32) (result.Value, error) { + return result.New(val - 1) + }) + case types.Long: + ls, le, rs, re, err := applyToValues4(leftStart, leftEnd, rightStart, rightEnd, result.ToInt64) + if err != nil { + return result.Value{}, err + } + return evalExceptIntervalNumeralLogic(ls, le, rs, re, leftStart, leftEnd, &leftInterval, func(val int64) (result.Value, error) { + return result.New(val + 1) + }, func(val int64) (result.Value, error) { + return result.New(val - 1) + }) + case types.Decimal: + ls, le, rs, re, err := applyToValues4(leftStart, leftEnd, rightStart, rightEnd, result.ToFloat64) + if err != nil { + return result.Value{}, err + } + const epsilon = 1e-8 + return evalExceptIntervalNumeralLogic(ls, le, rs, re, leftStart, leftEnd, &leftInterval, func(val float64) (result.Value, error) { + return result.New(val + epsilon) + }, func(val float64) (result.Value, error) { + return result.New(val - epsilon) + }) + default: + return result.Value{}, fmt.Errorf("unsupported numeric interval point type for except: %v", leftStart.RuntimeType()) + } +} + +// evalExceptIntervalNumeralLogic contains the core logic for numeric interval except operations +func evalExceptIntervalNumeralLogic[T comparable](ls, le, rs, re T, leftStart, leftEnd result.Value, leftInterval *result.Interval, successor func(T) (result.Value, error), predecessor func(T) (result.Value, error)) (result.Value, error) { + // No overlap cases + if compareValues(le, rs) < 0 || compareValues(ls, re) > 0 { + return createCleanInterval(leftStart, leftEnd, leftInterval.LowInclusive, leftInterval.HighInclusive, leftInterval.StaticType) + } + + // Complete overlap - left is completely contained in right + if compareValues(rs, ls) <= 0 && compareValues(le, re) <= 0 { + return result.New(nil) + } + + // Properly contained check - right is properly contained in left + if compareValues(ls, rs) < 0 && compareValues(re, le) < 0 { + return result.New(nil) + } + + // Partial overlap cases + if compareValues(rs, ls) <= 0 && compareValues(ls, re) < 0 && compareValues(re, le) < 0 { + // Left overlap: return [re+1, le] + newStart, err := successor(re) + if err != nil { + return result.Value{}, err + } + return createCleanInterval(newStart, leftEnd, true, leftInterval.HighInclusive, leftInterval.StaticType) + } + + if compareValues(ls, rs) < 0 && compareValues(rs, le) < 0 && compareValues(le, re) <= 0 { + // Right overlap: return [ls, rs-1] + newEnd, err := predecessor(rs) + if err != nil { + return result.Value{}, err + } + return createCleanInterval(leftStart, newEnd, leftInterval.LowInclusive, true, leftInterval.StaticType) + } + + return result.New(nil) +} + +// compareValues compares two values of the same type +func compareValues[T comparable](a, b T) int { + switch any(a).(type) { + case int32: + aVal := any(a).(int32) + bVal := any(b).(int32) + if aVal < bVal { + return -1 + } else if aVal > bVal { + return 1 + } + return 0 + case int64: + aVal := any(a).(int64) + bVal := any(b).(int64) + if aVal < bVal { + return -1 + } else if aVal > bVal { + return 1 + } + return 0 + case float64: + aVal := any(a).(float64) + bVal := any(b).(float64) + if aVal < bVal { + return -1 + } else if aVal > bVal { + return 1 + } + return 0 + default: + // For other types, use basic comparison + if any(a) == any(b) { + return 0 + } + // This is a fallback - in practice we should handle all numeric types above + return -1 + } +} + +// evalExceptIntervalQuantity handles except for quantity intervals +func evalExceptIntervalQuantity(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 interval bounds + leftStart, leftEnd, err := startAndEnd(lObj, nil) + if err != nil { + return result.Value{}, err + } + rightStart, rightEnd, err := startAndEnd(rObj, nil) + if err != nil { + return result.Value{}, err + } + + // If any bound is null, return null + if result.IsNull(leftStart) || result.IsNull(leftEnd) || result.IsNull(rightStart) || result.IsNull(rightEnd) { + return result.New(nil) + } + + // Get the interval type for creating result + leftInterval, err := result.ToInterval(lObj) + if err != nil { + return result.Value{}, err + } + + ls, le, rs, re, err := applyToValues4(leftStart, leftEnd, rightStart, rightEnd, result.ToQuantity) + if err != nil { + return result.Value{}, err + } + + // Check unit compatibility + if ls.Unit != rs.Unit || le.Unit != re.Unit { + return result.Value{}, fmt.Errorf("except operator received Quantities with differing unit values") + } + + // No overlap cases + if le.Value < rs.Value || ls.Value > re.Value { + return createCleanInterval(leftStart, leftEnd, leftInterval.LowInclusive, leftInterval.HighInclusive, leftInterval.StaticType) + } + + // Complete overlap - left is completely contained in right + if rs.Value <= ls.Value && le.Value <= re.Value { + return result.New(nil) + } + + // Properly contained check - right is properly contained in left + if ls.Value < rs.Value && re.Value < le.Value { + return result.New(nil) + } + + // Partial overlap cases + const epsilon = 1e-8 + if rs.Value <= ls.Value && ls.Value < re.Value && re.Value < le.Value { + // Left overlap: return [re+epsilon, le] + newStart, err := result.New(result.Quantity{Value: re.Value + epsilon, Unit: re.Unit}) + if err != nil { + return result.Value{}, err + } + return createCleanInterval(newStart, leftEnd, true, leftInterval.HighInclusive, leftInterval.StaticType) + } + + if ls.Value < rs.Value && rs.Value < le.Value && le.Value <= re.Value { + // Right overlap: return [ls, rs-epsilon] + newEnd, err := result.New(result.Quantity{Value: rs.Value - epsilon, Unit: rs.Unit}) + if err != nil { + return result.Value{}, err + } + return createCleanInterval(leftStart, newEnd, leftInterval.LowInclusive, true, leftInterval.StaticType) + } + + return result.New(nil) +} + +// evalExceptIntervalDateTime handles except for date/datetime/time intervals +func (i *interpreter) evalExceptIntervalDateTime(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 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 + } + + // If any bound is null, return null + if result.IsNull(leftStart) || result.IsNull(leftEnd) || result.IsNull(rightStart) || result.IsNull(rightEnd) { + return result.New(nil) + } + + // Get the interval type for creating result + leftInterval, err := result.ToInterval(lObj) + if err != nil { + return result.Value{}, err + } + + // Determine the precision based on the point type + var precision model.DateTimePrecision + switch leftStart.RuntimeType() { + case types.Date: + precision = model.DAY + case types.DateTime: + precision = model.DAY + case types.Time: + precision = model.MILLISECOND + default: + return result.Value{}, fmt.Errorf("unsupported temporal type for except: %v", leftStart.RuntimeType()) + } + + ls, le, rs, re, err := applyToValues4(leftStart, leftEnd, rightStart, rightEnd, result.ToDateTime) + if err != nil { + return result.Value{}, err + } + + // No overlap cases + comp1, err := compareDateTimeWithPrecision(le, rs, precision) + if err != nil { + return result.Value{}, err + } + comp2, err := compareDateTimeWithPrecision(ls, re, precision) + if err != nil { + return result.Value{}, err + } + if comp1 == leftBeforeRight || comp2 == leftAfterRight { + return createCleanInterval(leftStart, leftEnd, leftInterval.LowInclusive, leftInterval.HighInclusive, leftInterval.StaticType) + } + + // Complete overlap - left is completely contained in right + comp3, err := compareDateTimeWithPrecision(rs, ls, precision) + if err != nil { + return result.Value{}, err + } + comp4, err := compareDateTimeWithPrecision(le, re, precision) + if err != nil { + return result.Value{}, err + } + if (comp3 == leftBeforeRight || comp3 == leftEqualRight) && (comp4 == leftBeforeRight || comp4 == leftEqualRight) { + return result.New(nil) + } + + // Properly contained check - right is properly contained in left + comp5, err := compareDateTimeWithPrecision(ls, rs, precision) + if err != nil { + return result.Value{}, err + } + comp6, err := compareDateTimeWithPrecision(re, le, precision) + if err != nil { + return result.Value{}, err + } + if comp5 == leftBeforeRight && comp6 == leftBeforeRight { + return result.New(nil) + } + + // Partial overlap cases - need to handle date/time arithmetic + // Left overlap: rs <= ls < re < le -> return [re+1, le] + comp7, err := compareDateTimeWithPrecision(rs, ls, precision) + if err != nil { + return result.Value{}, err + } + comp8, err := compareDateTimeWithPrecision(ls, re, precision) + if err != nil { + return result.Value{}, err + } + comp9, err := compareDateTimeWithPrecision(re, le, precision) + if err != nil { + return result.Value{}, err + } + if (comp7 == leftBeforeRight || comp7 == leftEqualRight) && comp8 == leftBeforeRight && comp9 == leftBeforeRight { + // Calculate the successor of re + newStart, err := successor(rightEnd, &i.evaluationTimestamp) + if err != nil { + return result.Value{}, err + } + return createCleanInterval(newStart, leftEnd, true, leftInterval.HighInclusive, leftInterval.StaticType) + } + + // Right overlap: ls < rs < le <= re -> return [ls, rs-1] + comp10, err := compareDateTimeWithPrecision(ls, rs, precision) + if err != nil { + return result.Value{}, err + } + comp11, err := compareDateTimeWithPrecision(rs, le, precision) + if err != nil { + return result.Value{}, err + } + comp12, err := compareDateTimeWithPrecision(le, re, precision) + if err != nil { + return result.Value{}, err + } + if comp10 == leftBeforeRight && comp11 == leftBeforeRight && (comp12 == leftBeforeRight || comp12 == leftEqualRight) { + // Calculate the predecessor of rs + newEnd, err := predecessor(rightStart, &i.evaluationTimestamp) + if err != nil { + return result.Value{}, err + } + return createCleanInterval(leftStart, newEnd, leftInterval.LowInclusive, true, leftInterval.StaticType) + } + + // TODO: if we get here, it's a complex case that we can't handle yet + return result.New(nil) +} diff --git a/parser/operators.go b/parser/operators.go index 1f616c2..d49a34c 100644 --- a/parser/operators.go +++ b/parser/operators.go @@ -1767,7 +1767,17 @@ func (p *Parser) loadSystemOperators() error { // LIST OPERATORS - https://cql.hl7.org/09-b-cqlreference.html#list-operators-2 { name: "Except", - operands: [][]types.IType{{&types.List{ElementType: types.Any}, &types.List{ElementType: types.Any}}}, + operands: [][]types.IType{ + {&types.List{ElementType: types.Any}, &types.List{ElementType: types.Any}}, + // INTERVAL OPERATORS - https://cql.hl7.org/09-b-cqlreference.html#interval-operators-3 + {&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.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}}, + {&types.Interval{PointType: types.Quantity}, &types.Interval{PointType: types.Quantity}}, + }, model: func() model.IExpression { return &model.Except{ BinaryExpression: &model.BinaryExpression{}, diff --git a/tests/enginetests/operator_interval_test.go b/tests/enginetests/operator_interval_test.go index 595a902..347eb46 100644 --- a/tests/enginetests/operator_interval_test.go +++ b/tests/enginetests/operator_interval_test.go @@ -1930,6 +1930,219 @@ func TestIntervalWidth(t *testing.T) { } } +func TestExceptInterval(t *testing.T) { + tests := []struct { + name string + cql string + wantModel model.IExpression + wantResult result.Value + }{ + { + name: "Integer interval except - no overlap", + cql: "Interval[0, 5] except Interval[10, 15]", + wantModel: &model.Except{ + BinaryExpression: &model.BinaryExpression{ + Expression: model.ResultType(&types.Interval{PointType: types.Integer}), + Operands: []model.IExpression{ + &model.Interval{ + Low: model.NewLiteral("0", 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("10", types.Integer), + High: model.NewLiteral("15", types.Integer), + LowInclusive: true, + HighInclusive: true, + Expression: model.ResultType(&types.Interval{PointType: types.Integer}), + }, + }, + }, + }, + wantResult: newOrFatal(t, result.Interval{ + Low: newOrFatal(t, int32(0)), + High: newOrFatal(t, int32(5)), + LowInclusive: true, + HighInclusive: true, + StaticType: &types.Interval{PointType: types.Integer}, + }), + }, + { + name: "Integer interval except - partial overlap left", + cql: "Interval[0, 5] except Interval[3, 7]", + wantResult: newOrFatal(t, result.Interval{ + Low: newOrFatal(t, int32(0)), + High: newOrFatal(t, int32(2)), + LowInclusive: true, + HighInclusive: true, + StaticType: &types.Interval{PointType: types.Integer}, + }), + }, + { + name: "Integer interval except - partial overlap right", + cql: "Interval[5, 10] except Interval[0, 7]", + wantResult: newOrFatal(t, result.Interval{ + Low: newOrFatal(t, int32(8)), + High: newOrFatal(t, int32(10)), + LowInclusive: true, + HighInclusive: true, + StaticType: &types.Interval{PointType: types.Integer}, + }), + }, + { + name: "Integer interval except - properly contained", + cql: "Interval[0, 10] except Interval[3, 7]", + wantResult: newOrFatal(t, nil), + }, + { + name: "Integer interval except - complete overlap", + cql: "Interval[3, 7] except Interval[0, 10]", + wantResult: newOrFatal(t, nil), + }, + { + name: "Integer interval except - identical intervals", + cql: "Interval[0, 5] except Interval[0, 5]", + wantResult: newOrFatal(t, nil), + }, + { + name: "Integer interval except - null left", + cql: "null as Interval except Interval[0, 5]", + wantResult: newOrFatal(t, nil), + }, + { + name: "Integer interval except - null right", + cql: "Interval[0, 5] except null as Interval", + wantResult: newOrFatal(t, nil), + }, + // Decimal intervals + { + name: "Decimal interval except - no overlap", + cql: "Interval[0.0, 5.0] except Interval[10.0, 15.0]", + wantResult: newOrFatal(t, result.Interval{ + Low: newOrFatal(t, 0.0), + High: newOrFatal(t, 5.0), + LowInclusive: true, + HighInclusive: true, + StaticType: &types.Interval{PointType: types.Decimal}, + }), + }, + { + name: "Decimal interval except - partial overlap", + cql: "Interval[0.0, 5.0] except Interval[3.0, 7.0]", + wantResult: newOrFatal(t, result.Interval{ + Low: newOrFatal(t, 0.0), + High: newOrFatal(t, 2.99999999), + LowInclusive: true, + HighInclusive: true, + StaticType: &types.Interval{PointType: types.Decimal}, + }), + }, + // Long intervals + { + name: "Long interval except - no overlap", + cql: "Interval[0L, 5L] except Interval[10L, 15L]", + wantResult: newOrFatal(t, result.Interval{ + Low: newOrFatal(t, int64(0)), + High: newOrFatal(t, int64(5)), + LowInclusive: true, + HighInclusive: true, + StaticType: &types.Interval{PointType: types.Long}, + }), + }, + { + name: "Long interval except - partial overlap", + cql: "Interval[0L, 5L] except Interval[3L, 7L]", + wantResult: newOrFatal(t, result.Interval{ + Low: newOrFatal(t, int64(0)), + High: newOrFatal(t, int64(2)), + LowInclusive: true, + HighInclusive: true, + StaticType: &types.Interval{PointType: types.Long}, + }), + }, + // Quantity intervals + { + name: "Quantity interval except - no overlap", + cql: "Interval[0'cm', 5'cm'] except Interval[10'cm', 15'cm']", + wantResult: newOrFatal(t, result.Interval{ + Low: newOrFatal(t, result.Quantity{Value: 0, Unit: "cm"}), + High: newOrFatal(t, result.Quantity{Value: 5, Unit: "cm"}), + LowInclusive: true, + HighInclusive: true, + StaticType: &types.Interval{PointType: types.Quantity}, + }), + }, + { + name: "Quantity interval except - partial overlap", + cql: "Interval[0'cm', 5'cm'] except Interval[3'cm', 7'cm']", + wantResult: newOrFatal(t, result.Interval{ + Low: newOrFatal(t, result.Quantity{Value: 0, Unit: "cm"}), + High: newOrFatal(t, result.Quantity{Value: 2.99999999, Unit: "cm"}), + LowInclusive: true, + HighInclusive: true, + StaticType: &types.Interval{PointType: types.Quantity}, + }), + }, + // Date intervals (simplified - returns null for complex cases) + { + name: "Date interval except - no overlap", + cql: "Interval[@2020-01-01, @2020-01-05] except Interval[@2020-01-10, @2020-01-15]", + wantResult: newOrFatal(t, result.Interval{ + Low: newOrFatal(t, result.Date{Date: time.Date(2020, time.January, 1, 0, 0, 0, 0, defaultEvalTimestamp.Location()), Precision: model.DAY}), + High: newOrFatal(t, result.Date{Date: time.Date(2020, time.January, 5, 0, 0, 0, 0, defaultEvalTimestamp.Location()), Precision: model.DAY}), + LowInclusive: true, + HighInclusive: true, + StaticType: &types.Interval{PointType: types.Date}, + }), + }, + { + name: "Date interval except - complete overlap", + cql: "Interval[@2020-01-03, @2020-01-07] except Interval[@2020-01-01, @2020-01-10]", + wantResult: newOrFatal(t, nil), + }, + // DateTime intervals (simplified - returns null for complex cases) + { + name: "DateTime interval except - no overlap", + cql: "Interval[@2020-01-01T, @2020-01-05T] except Interval[@2020-01-10T, @2020-01-15T]", + wantResult: newOrFatal(t, result.Interval{ + Low: newOrFatal(t, result.DateTime{Date: time.Date(2020, time.January, 1, 0, 0, 0, 0, defaultEvalTimestamp.Location()), Precision: model.DAY}), + High: newOrFatal(t, result.DateTime{Date: time.Date(2020, time.January, 5, 0, 0, 0, 0, defaultEvalTimestamp.Location()), Precision: model.DAY}), + LowInclusive: true, + HighInclusive: true, + StaticType: &types.Interval{PointType: types.DateTime}, + }), + }, + { + name: "DateTime interval except - complete overlap", + cql: "Interval[@2020-01-03T, @2020-01-07T] except Interval[@2020-01-01T, @2020-01-10T]", + wantResult: 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) + } + 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 TestComparison_Error(t *testing.T) { tests := []struct { name string @@ -1947,6 +2160,11 @@ func TestComparison_Error(t *testing.T) { cql: "1'cm' in Interval[1'cm', 2'm']", wantEvalErrContains: "in operator recieved Quantities with differing unit values", }, + { + name: "Quantity interval except with mismatched units", + cql: "Interval[1'cm', 2'cm'] except Interval[1'm', 2'm']", + wantEvalErrContains: "except operator received Quantities with differing unit values", + }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { diff --git a/tests/spectests/exclusions/exclusions.go b/tests/spectests/exclusions/exclusions.go index 50d255e..3c171c4 100644 --- a/tests/spectests/exclusions/exclusions.go +++ b/tests/spectests/exclusions/exclusions.go @@ -225,7 +225,6 @@ func XMLTestFileExclusionDefinitions() map[string]XMLTestFileExclusions { "Collapse", "Expand", "Ends", - "Except", "Includes", "Intersect", "Meets", @@ -311,6 +310,8 @@ func XMLTestFileExclusionDefinitions() map[string]XMLTestFileExclusions { // TODO: b/342064453 - Ambiguous match. "TestEqualNull", "TestInNullBoundaries", + "NullInterval", + "TestExceptNull", }, }, "CqlListOperatorsTest.xml": { From 0c7c02f1c94d3e800702570a1a8bd1ae011c9823 Mon Sep 17 00:00:00 2001 From: Victor Frank Date: Sun, 11 Oct 2026 10:56:49 -0400 Subject: [PATCH 2/3] #142 gofmt interval except test cases and operator entry --- parser/operators.go | 2 +- tests/enginetests/operator_interval_test.go | 40 ++++++++++----------- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/parser/operators.go b/parser/operators.go index d49a34c..491aec8 100644 --- a/parser/operators.go +++ b/parser/operators.go @@ -1766,7 +1766,7 @@ func (p *Parser) loadSystemOperators() error { }, // LIST OPERATORS - https://cql.hl7.org/09-b-cqlreference.html#list-operators-2 { - name: "Except", + name: "Except", operands: [][]types.IType{ {&types.List{ElementType: types.Any}, &types.List{ElementType: types.Any}}, // INTERVAL OPERATORS - https://cql.hl7.org/09-b-cqlreference.html#interval-operators-3 diff --git a/tests/enginetests/operator_interval_test.go b/tests/enginetests/operator_interval_test.go index 347eb46..0a8af50 100644 --- a/tests/enginetests/operator_interval_test.go +++ b/tests/enginetests/operator_interval_test.go @@ -1970,8 +1970,8 @@ func TestExceptInterval(t *testing.T) { }), }, { - name: "Integer interval except - partial overlap left", - cql: "Interval[0, 5] except Interval[3, 7]", + name: "Integer interval except - partial overlap left", + cql: "Interval[0, 5] except Interval[3, 7]", wantResult: newOrFatal(t, result.Interval{ Low: newOrFatal(t, int32(0)), High: newOrFatal(t, int32(2)), @@ -1981,8 +1981,8 @@ func TestExceptInterval(t *testing.T) { }), }, { - name: "Integer interval except - partial overlap right", - cql: "Interval[5, 10] except Interval[0, 7]", + name: "Integer interval except - partial overlap right", + cql: "Interval[5, 10] except Interval[0, 7]", wantResult: newOrFatal(t, result.Interval{ Low: newOrFatal(t, int32(8)), High: newOrFatal(t, int32(10)), @@ -2018,8 +2018,8 @@ func TestExceptInterval(t *testing.T) { }, // Decimal intervals { - name: "Decimal interval except - no overlap", - cql: "Interval[0.0, 5.0] except Interval[10.0, 15.0]", + name: "Decimal interval except - no overlap", + cql: "Interval[0.0, 5.0] except Interval[10.0, 15.0]", wantResult: newOrFatal(t, result.Interval{ Low: newOrFatal(t, 0.0), High: newOrFatal(t, 5.0), @@ -2029,8 +2029,8 @@ func TestExceptInterval(t *testing.T) { }), }, { - name: "Decimal interval except - partial overlap", - cql: "Interval[0.0, 5.0] except Interval[3.0, 7.0]", + name: "Decimal interval except - partial overlap", + cql: "Interval[0.0, 5.0] except Interval[3.0, 7.0]", wantResult: newOrFatal(t, result.Interval{ Low: newOrFatal(t, 0.0), High: newOrFatal(t, 2.99999999), @@ -2041,8 +2041,8 @@ func TestExceptInterval(t *testing.T) { }, // Long intervals { - name: "Long interval except - no overlap", - cql: "Interval[0L, 5L] except Interval[10L, 15L]", + name: "Long interval except - no overlap", + cql: "Interval[0L, 5L] except Interval[10L, 15L]", wantResult: newOrFatal(t, result.Interval{ Low: newOrFatal(t, int64(0)), High: newOrFatal(t, int64(5)), @@ -2052,8 +2052,8 @@ func TestExceptInterval(t *testing.T) { }), }, { - name: "Long interval except - partial overlap", - cql: "Interval[0L, 5L] except Interval[3L, 7L]", + name: "Long interval except - partial overlap", + cql: "Interval[0L, 5L] except Interval[3L, 7L]", wantResult: newOrFatal(t, result.Interval{ Low: newOrFatal(t, int64(0)), High: newOrFatal(t, int64(2)), @@ -2064,8 +2064,8 @@ func TestExceptInterval(t *testing.T) { }, // Quantity intervals { - name: "Quantity interval except - no overlap", - cql: "Interval[0'cm', 5'cm'] except Interval[10'cm', 15'cm']", + name: "Quantity interval except - no overlap", + cql: "Interval[0'cm', 5'cm'] except Interval[10'cm', 15'cm']", wantResult: newOrFatal(t, result.Interval{ Low: newOrFatal(t, result.Quantity{Value: 0, Unit: "cm"}), High: newOrFatal(t, result.Quantity{Value: 5, Unit: "cm"}), @@ -2075,8 +2075,8 @@ func TestExceptInterval(t *testing.T) { }), }, { - name: "Quantity interval except - partial overlap", - cql: "Interval[0'cm', 5'cm'] except Interval[3'cm', 7'cm']", + name: "Quantity interval except - partial overlap", + cql: "Interval[0'cm', 5'cm'] except Interval[3'cm', 7'cm']", wantResult: newOrFatal(t, result.Interval{ Low: newOrFatal(t, result.Quantity{Value: 0, Unit: "cm"}), High: newOrFatal(t, result.Quantity{Value: 2.99999999, Unit: "cm"}), @@ -2087,8 +2087,8 @@ func TestExceptInterval(t *testing.T) { }, // Date intervals (simplified - returns null for complex cases) { - name: "Date interval except - no overlap", - cql: "Interval[@2020-01-01, @2020-01-05] except Interval[@2020-01-10, @2020-01-15]", + name: "Date interval except - no overlap", + cql: "Interval[@2020-01-01, @2020-01-05] except Interval[@2020-01-10, @2020-01-15]", wantResult: newOrFatal(t, result.Interval{ Low: newOrFatal(t, result.Date{Date: time.Date(2020, time.January, 1, 0, 0, 0, 0, defaultEvalTimestamp.Location()), Precision: model.DAY}), High: newOrFatal(t, result.Date{Date: time.Date(2020, time.January, 5, 0, 0, 0, 0, defaultEvalTimestamp.Location()), Precision: model.DAY}), @@ -2104,8 +2104,8 @@ func TestExceptInterval(t *testing.T) { }, // DateTime intervals (simplified - returns null for complex cases) { - name: "DateTime interval except - no overlap", - cql: "Interval[@2020-01-01T, @2020-01-05T] except Interval[@2020-01-10T, @2020-01-15T]", + name: "DateTime interval except - no overlap", + cql: "Interval[@2020-01-01T, @2020-01-05T] except Interval[@2020-01-10T, @2020-01-15T]", wantResult: newOrFatal(t, result.Interval{ Low: newOrFatal(t, result.DateTime{Date: time.Date(2020, time.January, 1, 0, 0, 0, 0, defaultEvalTimestamp.Location()), Precision: model.DAY}), High: newOrFatal(t, result.DateTime{Date: time.Date(2020, time.January, 5, 0, 0, 0, 0, defaultEvalTimestamp.Location()), Precision: model.DAY}), From 454ea387daf34f85c1923b2dda903060be9098a0 Mon Sep 17 00:00:00 2001 From: Victor Frank Date: Sun, 11 Oct 2026 11:10:15 -0400 Subject: [PATCH 3/3] #142 fix interval except boundary, precision and unit handling Replace the per-type except implementations with one evalExceptInterval that compares the closed start/end of both operands: - intervals touching at a boundary now return the remaining part ([0,10] except [10,20] is [0,9]) instead of null - Date/DateTime/Time compare at the values' own precision, not DAY; uncertain comparisons return null - results keep the left operand's original bound and inclusivity on the side that is not cut, so open bounds are no longer shifted - Quantities with comparable units are converted; incomparable units return null instead of an evaluation error Add test cases for each of these. --- interpreter/operator_dispatcher.go | 14 +- interpreter/operator_interval.go | 488 +++----------------- tests/enginetests/operator_interval_test.go | 160 ++++++- 3 files changed, 233 insertions(+), 429 deletions(-) diff --git a/interpreter/operator_dispatcher.go b/interpreter/operator_dispatcher.go index 05eae4f..612a59a 100644 --- a/interpreter/operator_dispatcher.go +++ b/interpreter/operator_dispatcher.go @@ -1220,31 +1220,31 @@ func (i *interpreter) binaryOverloads(m model.IBinaryExpression) ([]convert.Over // Interval overloads { Operands: []types.IType{&types.Interval{PointType: types.Integer}, &types.Interval{PointType: types.Integer}}, - Result: evalExceptIntervalNumeral, + Result: i.evalExceptInterval, }, { Operands: []types.IType{&types.Interval{PointType: types.Long}, &types.Interval{PointType: types.Long}}, - Result: evalExceptIntervalNumeral, + Result: i.evalExceptInterval, }, { Operands: []types.IType{&types.Interval{PointType: types.Decimal}, &types.Interval{PointType: types.Decimal}}, - Result: evalExceptIntervalNumeral, + Result: i.evalExceptInterval, }, { Operands: []types.IType{&types.Interval{PointType: types.Date}, &types.Interval{PointType: types.Date}}, - Result: i.evalExceptIntervalDateTime, + Result: i.evalExceptInterval, }, { Operands: []types.IType{&types.Interval{PointType: types.DateTime}, &types.Interval{PointType: types.DateTime}}, - Result: i.evalExceptIntervalDateTime, + Result: i.evalExceptInterval, }, { Operands: []types.IType{&types.Interval{PointType: types.Time}, &types.Interval{PointType: types.Time}}, - Result: i.evalExceptIntervalDateTime, + Result: i.evalExceptInterval, }, { Operands: []types.IType{&types.Interval{PointType: types.Quantity}, &types.Interval{PointType: types.Quantity}}, - Result: evalExceptIntervalQuantity, + Result: i.evalExceptInterval, }, }, nil case *model.Intersect: diff --git a/interpreter/operator_interval.go b/interpreter/operator_interval.go index 83ac608..5f3390c 100644 --- a/interpreter/operator_interval.go +++ b/interpreter/operator_interval.go @@ -642,484 +642,140 @@ func evalWidthInterval(m model.IUnaryExpression, intervalObj result.Value) (resu return result.Value{}, fmt.Errorf("internal error - unsupported point type in evalWidthInterval: %v", start.RuntimeType()) } -// evalExceptIntervalInteger handles except for integer intervals -func (i *interpreter) evalExceptIntervalInteger(leftStart, leftEnd, rightStart, rightEnd result.Value, leftInterval *result.Interval) (result.Value, error) { - ls, le, rs, re, err := applyToValues4(leftStart, leftEnd, rightStart, rightEnd, result.ToInt32) - if err != nil { - return result.Value{}, err - } - - // No overlap cases - if le < rs || ls > re { - return createCleanInterval(leftStart, leftEnd, leftInterval.LowInclusive, leftInterval.HighInclusive, leftInterval.StaticType) - } - - // Complete overlap - left is completely contained in right - if rs <= ls && le <= re { - return result.New(nil) - } - - // Properly contained check - right is properly contained in left - if ls < rs && re < le { +// except(left Interval, right Interval) Interval +// https://cql.hl7.org/09-b-cqlreference.html#except-1 +// Returns null if the result is empty, if right is properly contained within left, or if the +// bounds cannot be compared (uncertain DateTime precision, incomparable Quantity units). +func (i *interpreter) evalExceptInterval(m model.IBinaryExpression, lObj, rObj result.Value) (result.Value, error) { + if result.IsNull(lObj) || result.IsNull(rObj) { return result.New(nil) } - - // Partial overlap cases - if rs <= ls && ls < re && re < le { - // Left overlap: return [re+1, le] - newStart, err := result.New(re + 1) - if err != nil { - return result.Value{}, err - } - return createCleanInterval(newStart, leftEnd, true, leftInterval.HighInclusive, leftInterval.StaticType) - } - - if ls < rs && rs < le && le <= re { - // Right overlap: return [ls, rs-1] - newEnd, err := result.New(rs - 1) - if err != nil { - return result.Value{}, err - } - return createCleanInterval(leftStart, newEnd, leftInterval.LowInclusive, true, leftInterval.StaticType) - } - - return result.New(nil) -} - -// evalExceptIntervalDate handles except for date intervals -func (i *interpreter) evalExceptIntervalDate(leftStart, leftEnd, rightStart, rightEnd result.Value, leftInterval *result.Interval) (result.Value, error) { - ls, le, rs, re, err := applyToValues4(leftStart, leftEnd, rightStart, rightEnd, result.ToDateTime) - if err != nil { - return result.Value{}, err - } - - // No overlap cases - comp1, err := compareDateTimeWithPrecision(le, rs, model.DAY) + left, err := result.ToInterval(lObj) if err != nil { return result.Value{}, err } - comp2, err := compareDateTimeWithPrecision(ls, re, model.DAY) - if err != nil { - return result.Value{}, err - } - if comp1 == leftBeforeRight || comp2 == leftAfterRight { - return createCleanInterval(leftStart, leftEnd, leftInterval.LowInclusive, leftInterval.HighInclusive, leftInterval.StaticType) - } - - // Complete overlap - left is completely contained in right - comp3, err := compareDateTimeWithPrecision(rs, ls, model.DAY) + ls, le, err := startAndEnd(lObj, &i.evaluationTimestamp) if err != nil { return result.Value{}, err } - comp4, err := compareDateTimeWithPrecision(le, re, model.DAY) + rs, re, err := startAndEnd(rObj, &i.evaluationTimestamp) if err != nil { return result.Value{}, err } - if (comp3 == leftBeforeRight || comp3 == leftEqualRight) && (comp4 == leftBeforeRight || comp4 == leftEqualRight) { + if result.IsNull(ls) || result.IsNull(le) || result.IsNull(rs) || result.IsNull(re) { return result.New(nil) } - // Properly contained check - right is properly contained in left - comp5, err := compareDateTimeWithPrecision(ls, rs, model.DAY) + rsToLe, err := compareIntervalPoints(rs, le) if err != nil { return result.Value{}, err } - comp6, err := compareDateTimeWithPrecision(re, le, model.DAY) + reToLs, err := compareIntervalPoints(re, ls) if err != nil { return result.Value{}, err } - if comp5 == leftBeforeRight && comp6 == leftBeforeRight { - return result.New(nil) - } - - // TODO: For dates, we need to handle partial overlaps carefully - return result.New(nil) -} - -// Helper function to apply a conversion function to 4 values -func applyToValues4[T any](v1, v2, v3, v4 result.Value, converter func(result.Value) (T, error)) (T, T, T, T, error) { - var zero T - r1, err := converter(v1) - if err != nil { - return zero, zero, zero, zero, err - } - r2, err := converter(v2) - if err != nil { - return zero, zero, zero, zero, err - } - r3, err := converter(v3) - if err != nil { - return zero, zero, zero, zero, err - } - r4, err := converter(v4) - if err != nil { - return zero, zero, zero, zero, err - } - return r1, r2, r3, r4, nil -} - -// createCleanInterval creates a new interval without source metadata -func createCleanInterval(low, high result.Value, lowInclusive, highInclusive bool, staticType *types.Interval) (result.Value, error) { - return result.New(result.Interval{ - Low: low, - High: high, - LowInclusive: lowInclusive, - HighInclusive: highInclusive, - StaticType: staticType, - }) -} - -// evalExceptIntervalNumeral handles except for numeric intervals (Integer, Long, Decimal) -func evalExceptIntervalNumeral(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 interval bounds - leftStart, leftEnd, err := startAndEnd(lObj, nil) + lsToRs, err := compareIntervalPoints(ls, rs) if err != nil { return result.Value{}, err } - rightStart, rightEnd, err := startAndEnd(rObj, nil) + leToRe, err := compareIntervalPoints(le, re) if err != nil { return result.Value{}, err } - - // If any bound is null, return null - if result.IsNull(leftStart) || result.IsNull(leftEnd) || result.IsNull(rightStart) || result.IsNull(rightEnd) { - return result.New(nil) + for _, c := range []comparison{rsToLe, reToLs, lsToRs, leToRe} { + if c != leftBeforeRight && c != leftEqualRight && c != leftAfterRight { + return result.New(nil) + } } - // Get the interval type for creating result - leftInterval, err := result.ToInterval(lObj) - if err != nil { - return result.Value{}, err + if rsToLe == leftAfterRight || reToLs == leftBeforeRight { + return lObj, nil } - - // Dispatch based on point type - switch leftStart.RuntimeType() { - case types.Integer: - ls, le, rs, re, err := applyToValues4(leftStart, leftEnd, rightStart, rightEnd, result.ToInt32) - if err != nil { - return result.Value{}, err - } - return evalExceptIntervalNumeralLogic(ls, le, rs, re, leftStart, leftEnd, &leftInterval, func(val int32) (result.Value, error) { - return result.New(val + 1) - }, func(val int32) (result.Value, error) { - return result.New(val - 1) - }) - case types.Long: - ls, le, rs, re, err := applyToValues4(leftStart, leftEnd, rightStart, rightEnd, result.ToInt64) + if lsToRs == leftBeforeRight && leToRe != leftAfterRight { + rs, err := toUnitOf(rs, le) if err != nil { return result.Value{}, err } - return evalExceptIntervalNumeralLogic(ls, le, rs, re, leftStart, leftEnd, &leftInterval, func(val int64) (result.Value, error) { - return result.New(val + 1) - }, func(val int64) (result.Value, error) { - return result.New(val - 1) - }) - case types.Decimal: - ls, le, rs, re, err := applyToValues4(leftStart, leftEnd, rightStart, rightEnd, result.ToFloat64) + high, err := predecessor(rs, &i.evaluationTimestamp) if err != nil { return result.Value{}, err } - const epsilon = 1e-8 - return evalExceptIntervalNumeralLogic(ls, le, rs, re, leftStart, leftEnd, &leftInterval, func(val float64) (result.Value, error) { - return result.New(val + epsilon) - }, func(val float64) (result.Value, error) { - return result.New(val - epsilon) - }) - default: - return result.Value{}, fmt.Errorf("unsupported numeric interval point type for except: %v", leftStart.RuntimeType()) + return result.New(result.Interval{Low: left.Low, High: high, LowInclusive: left.LowInclusive, HighInclusive: true, StaticType: left.StaticType}) } -} - -// evalExceptIntervalNumeralLogic contains the core logic for numeric interval except operations -func evalExceptIntervalNumeralLogic[T comparable](ls, le, rs, re T, leftStart, leftEnd result.Value, leftInterval *result.Interval, successor func(T) (result.Value, error), predecessor func(T) (result.Value, error)) (result.Value, error) { - // No overlap cases - if compareValues(le, rs) < 0 || compareValues(ls, re) > 0 { - return createCleanInterval(leftStart, leftEnd, leftInterval.LowInclusive, leftInterval.HighInclusive, leftInterval.StaticType) - } - - // Complete overlap - left is completely contained in right - if compareValues(rs, ls) <= 0 && compareValues(le, re) <= 0 { - return result.New(nil) - } - - // Properly contained check - right is properly contained in left - if compareValues(ls, rs) < 0 && compareValues(re, le) < 0 { - return result.New(nil) - } - - // Partial overlap cases - if compareValues(rs, ls) <= 0 && compareValues(ls, re) < 0 && compareValues(re, le) < 0 { - // Left overlap: return [re+1, le] - newStart, err := successor(re) + if leToRe == leftAfterRight && lsToRs != leftBeforeRight { + re, err := toUnitOf(re, ls) if err != nil { return result.Value{}, err } - return createCleanInterval(newStart, leftEnd, true, leftInterval.HighInclusive, leftInterval.StaticType) - } - - if compareValues(ls, rs) < 0 && compareValues(rs, le) < 0 && compareValues(le, re) <= 0 { - // Right overlap: return [ls, rs-1] - newEnd, err := predecessor(rs) + low, err := successor(re, &i.evaluationTimestamp) if err != nil { return result.Value{}, err } - return createCleanInterval(leftStart, newEnd, leftInterval.LowInclusive, true, leftInterval.StaticType) + return result.New(result.Interval{Low: low, High: left.High, LowInclusive: true, HighInclusive: left.HighInclusive, StaticType: left.StaticType}) } - return result.New(nil) } -// compareValues compares two values of the same type -func compareValues[T comparable](a, b T) int { - switch any(a).(type) { - case int32: - aVal := any(a).(int32) - bVal := any(b).(int32) - if aVal < bVal { - return -1 - } else if aVal > bVal { - return 1 - } - return 0 - case int64: - aVal := any(a).(int64) - bVal := any(b).(int64) - if aVal < bVal { - return -1 - } else if aVal > bVal { - return 1 +// compareIntervalPoints compares two non-null interval points of the same type. Quantities with +// different units are compared after unit conversion; unconvertible units return unsetComparison. +func compareIntervalPoints(a, b result.Value) (comparison, error) { + switch a.RuntimeType() { + case types.Integer: + l, r, err := applyToValues(a, b, result.ToInt32) + if err != nil { + return unsetComparison, err } - return 0 - case float64: - aVal := any(a).(float64) - bVal := any(b).(float64) - if aVal < bVal { - return -1 - } else if aVal > bVal { - return 1 + return compareNumeral(l, r), nil + case types.Long: + l, r, err := applyToValues(a, b, result.ToInt64) + if err != nil { + return unsetComparison, err } - return 0 - default: - // For other types, use basic comparison - if any(a) == any(b) { - return 0 + return compareNumeral(l, r), nil + case types.Decimal: + l, r, err := applyToValues(a, b, result.ToFloat64) + if err != nil { + return unsetComparison, err } - // This is a fallback - in practice we should handle all numeric types above - return -1 - } -} - -// evalExceptIntervalQuantity handles except for quantity intervals -func evalExceptIntervalQuantity(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 interval bounds - leftStart, leftEnd, err := startAndEnd(lObj, nil) - if err != nil { - return result.Value{}, err - } - rightStart, rightEnd, err := startAndEnd(rObj, nil) - if err != nil { - return result.Value{}, err - } - - // If any bound is null, return null - if result.IsNull(leftStart) || result.IsNull(leftEnd) || result.IsNull(rightStart) || result.IsNull(rightEnd) { - return result.New(nil) - } - - // Get the interval type for creating result - leftInterval, err := result.ToInterval(lObj) - if err != nil { - return result.Value{}, err - } - - ls, le, rs, re, err := applyToValues4(leftStart, leftEnd, rightStart, rightEnd, result.ToQuantity) - if err != nil { - return result.Value{}, err - } - - // Check unit compatibility - if ls.Unit != rs.Unit || le.Unit != re.Unit { - return result.Value{}, fmt.Errorf("except operator received Quantities with differing unit values") - } - - // No overlap cases - if le.Value < rs.Value || ls.Value > re.Value { - return createCleanInterval(leftStart, leftEnd, leftInterval.LowInclusive, leftInterval.HighInclusive, leftInterval.StaticType) - } - - // Complete overlap - left is completely contained in right - if rs.Value <= ls.Value && le.Value <= re.Value { - return result.New(nil) - } - - // Properly contained check - right is properly contained in left - if ls.Value < rs.Value && re.Value < le.Value { - return result.New(nil) - } - - // Partial overlap cases - const epsilon = 1e-8 - if rs.Value <= ls.Value && ls.Value < re.Value && re.Value < le.Value { - // Left overlap: return [re+epsilon, le] - newStart, err := result.New(result.Quantity{Value: re.Value + epsilon, Unit: re.Unit}) + return compareNumeral(l, r), nil + case types.Quantity: + l, r, err := applyToValues(a, b, result.ToQuantity) if err != nil { - return result.Value{}, err + return unsetComparison, err } - return createCleanInterval(newStart, leftEnd, true, leftInterval.HighInclusive, leftInterval.StaticType) - } - - if ls.Value < rs.Value && rs.Value < le.Value && le.Value <= re.Value { - // Right overlap: return [ls, rs-epsilon] - newEnd, err := result.New(result.Quantity{Value: rs.Value - epsilon, Unit: rs.Unit}) + lv := l.Value + if l.Unit != r.Unit { + if lv, err = ucum.ConvertUnit(l.Value, string(l.Unit), string(r.Unit)); err != nil { + return unsetComparison, nil + } + } + return compareNumeral(lv, r.Value), nil + case types.Date, types.DateTime, types.Time: + l, r, err := applyToValues(a, b, result.ToDateTime) if err != nil { - return result.Value{}, err + return unsetComparison, err } - return createCleanInterval(leftStart, newEnd, leftInterval.LowInclusive, true, leftInterval.StaticType) - } - - return result.New(nil) -} - -// evalExceptIntervalDateTime handles except for date/datetime/time intervals -func (i *interpreter) evalExceptIntervalDateTime(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 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 - } - - // If any bound is null, return null - if result.IsNull(leftStart) || result.IsNull(leftEnd) || result.IsNull(rightStart) || result.IsNull(rightEnd) { - return result.New(nil) - } - - // Get the interval type for creating result - leftInterval, err := result.ToInterval(lObj) - if err != nil { - return result.Value{}, err - } - - // Determine the precision based on the point type - var precision model.DateTimePrecision - switch leftStart.RuntimeType() { - case types.Date: - precision = model.DAY - case types.DateTime: - precision = model.DAY - case types.Time: - precision = model.MILLISECOND + return compareDateTime(l, r) default: - return result.Value{}, fmt.Errorf("unsupported temporal type for except: %v", leftStart.RuntimeType()) - } - - ls, le, rs, re, err := applyToValues4(leftStart, leftEnd, rightStart, rightEnd, result.ToDateTime) - if err != nil { - return result.Value{}, err - } - - // No overlap cases - comp1, err := compareDateTimeWithPrecision(le, rs, precision) - if err != nil { - return result.Value{}, err - } - comp2, err := compareDateTimeWithPrecision(ls, re, precision) - if err != nil { - return result.Value{}, err - } - if comp1 == leftBeforeRight || comp2 == leftAfterRight { - return createCleanInterval(leftStart, leftEnd, leftInterval.LowInclusive, leftInterval.HighInclusive, leftInterval.StaticType) - } - - // Complete overlap - left is completely contained in right - comp3, err := compareDateTimeWithPrecision(rs, ls, precision) - if err != nil { - return result.Value{}, err - } - comp4, err := compareDateTimeWithPrecision(le, re, precision) - if err != nil { - return result.Value{}, err - } - if (comp3 == leftBeforeRight || comp3 == leftEqualRight) && (comp4 == leftBeforeRight || comp4 == leftEqualRight) { - return result.New(nil) - } - - // Properly contained check - right is properly contained in left - comp5, err := compareDateTimeWithPrecision(ls, rs, precision) - if err != nil { - return result.Value{}, err - } - comp6, err := compareDateTimeWithPrecision(re, le, precision) - if err != nil { - return result.Value{}, err - } - if comp5 == leftBeforeRight && comp6 == leftBeforeRight { - return result.New(nil) + return unsetComparison, fmt.Errorf("internal error - unsupported point type in except: %v", a.RuntimeType()) } +} - // Partial overlap cases - need to handle date/time arithmetic - // Left overlap: rs <= ls < re < le -> return [re+1, le] - comp7, err := compareDateTimeWithPrecision(rs, ls, precision) - if err != nil { - return result.Value{}, err +// toUnitOf converts a Quantity v to the unit of Quantity target. Other types are returned as is. +func toUnitOf(v, target result.Value) (result.Value, error) { + if v.RuntimeType() != types.Quantity { + return v, nil } - comp8, err := compareDateTimeWithPrecision(ls, re, precision) + q, t, err := applyToValues(v, target, result.ToQuantity) if err != nil { return result.Value{}, err } - comp9, err := compareDateTimeWithPrecision(re, le, precision) - if err != nil { - return result.Value{}, err - } - if (comp7 == leftBeforeRight || comp7 == leftEqualRight) && comp8 == leftBeforeRight && comp9 == leftBeforeRight { - // Calculate the successor of re - newStart, err := successor(rightEnd, &i.evaluationTimestamp) - if err != nil { - return result.Value{}, err - } - return createCleanInterval(newStart, leftEnd, true, leftInterval.HighInclusive, leftInterval.StaticType) + if q.Unit == t.Unit { + return v, nil } - - // Right overlap: ls < rs < le <= re -> return [ls, rs-1] - comp10, err := compareDateTimeWithPrecision(ls, rs, precision) + converted, err := ucum.ConvertUnit(q.Value, string(q.Unit), string(t.Unit)) if err != nil { return result.Value{}, err } - comp11, err := compareDateTimeWithPrecision(rs, le, precision) - if err != nil { - return result.Value{}, err - } - comp12, err := compareDateTimeWithPrecision(le, re, precision) - if err != nil { - return result.Value{}, err - } - if comp10 == leftBeforeRight && comp11 == leftBeforeRight && (comp12 == leftBeforeRight || comp12 == leftEqualRight) { - // Calculate the predecessor of rs - newEnd, err := predecessor(rightStart, &i.evaluationTimestamp) - if err != nil { - return result.Value{}, err - } - return createCleanInterval(leftStart, newEnd, leftInterval.LowInclusive, true, leftInterval.StaticType) - } - - // TODO: if we get here, it's a complex case that we can't handle yet - return result.New(nil) + return result.New(result.Quantity{Value: converted, Unit: t.Unit}) } diff --git a/tests/enginetests/operator_interval_test.go b/tests/enginetests/operator_interval_test.go index 0a8af50..7e4bb21 100644 --- a/tests/enginetests/operator_interval_test.go +++ b/tests/enginetests/operator_interval_test.go @@ -2085,7 +2085,7 @@ func TestExceptInterval(t *testing.T) { StaticType: &types.Interval{PointType: types.Quantity}, }), }, - // Date intervals (simplified - returns null for complex cases) + // Date intervals { name: "Date interval except - no overlap", cql: "Interval[@2020-01-01, @2020-01-05] except Interval[@2020-01-10, @2020-01-15]", @@ -2119,6 +2119,159 @@ func TestExceptInterval(t *testing.T) { cql: "Interval[@2020-01-03T, @2020-01-07T] except Interval[@2020-01-01T, @2020-01-10T]", wantResult: newOrFatal(t, nil), }, + { + name: "Integer interval except - right ends at left start", + cql: "Interval[0, 10] except Interval[-5, 0]", + wantResult: newOrFatal(t, result.Interval{ + Low: newOrFatal(t, int32(1)), + High: newOrFatal(t, int32(10)), + LowInclusive: true, + HighInclusive: true, + StaticType: &types.Interval{PointType: types.Integer}, + }), + }, + { + name: "Integer interval except - right starts at left end", + cql: "Interval[0, 10] except Interval[10, 20]", + wantResult: newOrFatal(t, result.Interval{ + Low: newOrFatal(t, int32(0)), + High: newOrFatal(t, int32(9)), + LowInclusive: true, + HighInclusive: true, + StaticType: &types.Interval{PointType: types.Integer}, + }), + }, + { + name: "Integer interval except - right is left start point", + cql: "Interval[0, 10] except Interval[0, 0]", + wantResult: newOrFatal(t, result.Interval{ + Low: newOrFatal(t, int32(1)), + High: newOrFatal(t, int32(10)), + LowInclusive: true, + HighInclusive: true, + StaticType: &types.Interval{PointType: types.Integer}, + }), + }, + { + name: "Integer interval except - open bounds", + cql: "Interval(1, 5) except Interval(0, 3)", + wantResult: newOrFatal(t, result.Interval{ + Low: newOrFatal(t, int32(3)), + High: newOrFatal(t, int32(5)), + LowInclusive: true, + HighInclusive: false, + StaticType: &types.Interval{PointType: types.Integer}, + }), + }, + { + name: "Integer interval except - no overlap keeps open bounds", + cql: "Interval(0, 5) except Interval[10, 20]", + wantResult: newOrFatal(t, result.Interval{ + Low: newOrFatal(t, int32(0)), + High: newOrFatal(t, int32(5)), + LowInclusive: false, + HighInclusive: false, + StaticType: &types.Interval{PointType: types.Integer}, + }), + }, + { + name: "Integer interval except - partial overlap keeps open low bound", + cql: "Interval(0, 10] except Interval[5, 20]", + wantResult: newOrFatal(t, result.Interval{ + Low: newOrFatal(t, int32(0)), + High: newOrFatal(t, int32(4)), + LowInclusive: false, + HighInclusive: true, + StaticType: &types.Interval{PointType: types.Integer}, + }), + }, + { + name: "Integer interval except - null low bound", + cql: "Interval[null, 10] except Interval[5, 20]", + wantResult: newOrFatal(t, result.Interval{ + Low: newOrFatal(t, nil), + High: newOrFatal(t, int32(4)), + LowInclusive: true, + HighInclusive: true, + StaticType: &types.Interval{PointType: types.Integer}, + }), + }, + { + name: "Long interval except - right starts at left end", + cql: "Interval[0L, 10L] except Interval[10L, 20L]", + wantResult: newOrFatal(t, result.Interval{ + Low: newOrFatal(t, int64(0)), + High: newOrFatal(t, int64(9)), + LowInclusive: true, + HighInclusive: true, + StaticType: &types.Interval{PointType: types.Long}, + }), + }, + { + name: "Quantity interval except - right starts at left end", + cql: "Interval[0'cm', 10'cm'] except Interval[10'cm', 20'cm']", + wantResult: newOrFatal(t, result.Interval{ + Low: newOrFatal(t, result.Quantity{Value: 0, Unit: "cm"}), + High: newOrFatal(t, result.Quantity{Value: 9.99999999, Unit: "cm"}), + LowInclusive: true, + HighInclusive: true, + StaticType: &types.Interval{PointType: types.Quantity}, + }), + }, + { + name: "Quantity interval except - differing comparable units", + cql: "Interval[0'cm', 10'cm'] except Interval[50'mm', 200'mm']", + wantResult: newOrFatal(t, result.Interval{ + Low: newOrFatal(t, result.Quantity{Value: 0, Unit: "cm"}), + High: newOrFatal(t, result.Quantity{Value: 4.99999999, Unit: "cm"}), + LowInclusive: true, + HighInclusive: true, + StaticType: &types.Interval{PointType: types.Quantity}, + }), + }, + { + name: "Date interval except - right ends at left start", + cql: "Interval[@2020-01-05, @2020-01-10] except Interval[@2020-01-01, @2020-01-05]", + wantResult: newOrFatal(t, result.Interval{ + Low: newOrFatal(t, result.Date{Date: time.Date(2020, time.January, 6, 0, 0, 0, 0, defaultEvalTimestamp.Location()), Precision: model.DAY}), + High: newOrFatal(t, result.Date{Date: time.Date(2020, time.January, 10, 0, 0, 0, 0, defaultEvalTimestamp.Location()), Precision: model.DAY}), + LowInclusive: true, + HighInclusive: true, + StaticType: &types.Interval{PointType: types.Date}, + }), + }, + { + name: "DateTime interval except - same day partial overlap", + cql: "Interval[@2020-01-01T10:00:00.000Z, @2020-01-01T12:00:00.000Z] except Interval[@2020-01-01T11:00:00.000Z, @2020-01-01T13:00:00.000Z]", + wantResult: newOrFatal(t, result.Interval{ + Low: newOrFatal(t, result.DateTime{Date: time.Date(2020, time.January, 1, 10, 0, 0, 0, time.UTC), Precision: model.MILLISECOND}), + High: newOrFatal(t, result.DateTime{Date: time.Date(2020, time.January, 1, 10, 59, 59, 999000000, time.UTC), Precision: model.MILLISECOND}), + LowInclusive: true, + HighInclusive: true, + StaticType: &types.Interval{PointType: types.DateTime}, + }), + }, + { + name: "DateTime interval except - same day no overlap", + cql: "Interval[@2020-01-01T10:00:00.000Z, @2020-01-01T12:00:00.000Z] except Interval[@2020-01-01T13:00:00.000Z, @2020-01-01T14:00:00.000Z]", + wantResult: newOrFatal(t, result.Interval{ + Low: newOrFatal(t, result.DateTime{Date: time.Date(2020, time.January, 1, 10, 0, 0, 0, time.UTC), Precision: model.MILLISECOND}), + High: newOrFatal(t, result.DateTime{Date: time.Date(2020, time.January, 1, 12, 0, 0, 0, time.UTC), Precision: model.MILLISECOND}), + LowInclusive: true, + HighInclusive: true, + StaticType: &types.Interval{PointType: types.DateTime}, + }), + }, + { + name: "Quantity interval except - incomparable units", + cql: "Interval[0'cm', 10'cm'] except Interval[5'g', 20'g']", + wantResult: newOrFatal(t, nil), + }, + { + name: "Date interval except - uncertain precision", + cql: "Interval[@2020-01-01, @2020-01-10] except Interval[@2020-01, @2020-02]", + wantResult: newOrFatal(t, nil), + }, } for _, tc := range tests { @@ -2160,11 +2313,6 @@ func TestComparison_Error(t *testing.T) { cql: "1'cm' in Interval[1'cm', 2'm']", wantEvalErrContains: "in operator recieved Quantities with differing unit values", }, - { - name: "Quantity interval except with mismatched units", - cql: "Interval[1'cm', 2'cm'] except Interval[1'm', 2'm']", - wantEvalErrContains: "except operator received Quantities with differing unit values", - }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) {