From 5cd9cd4d636d52a677b88bbe9a7c40816cc8164e Mon Sep 17 00:00:00 2001 From: Victor Frank Date: Mon, 30 Jun 2025 19:49:33 -0500 Subject: [PATCH] #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": {