Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions interpreter/operator_dispatcher.go
Original file line number Diff line number Diff line change
Expand Up @@ -1410,6 +1410,17 @@ func (i *interpreter) naryOverloads(m model.INaryExpression) ([]convert.Overload
Result: evalRound,
},
}, nil
case *model.Substring:
return []convert.Overload[evalNarySignature]{
{
Operands: []types.IType{types.String, types.Integer},
Result: evalSubstring,
},
{
Operands: []types.IType{types.String, types.Integer, types.Integer},
Result: evalSubstring,
},
}, nil
default:
return nil, fmt.Errorf("unsupported Nary Expression %v", m.GetName())
}
Expand Down
90 changes: 90 additions & 0 deletions interpreter/operator_string.go
Original file line number Diff line number Diff line change
Expand Up @@ -448,3 +448,93 @@ func evalMatches(m model.IBinaryExpression, argString, patternString result.Valu
res := match != ""
return result.New(res)
}

// Substring(stringToSub String, startIndex Integer) String
// Substring(stringToSub String, startIndex Integer, length Integer) String
// https://cql.hl7.org/09-b-cqlreference.html#substring
func evalSubstring(m model.INaryExpression, operands []result.Value) (result.Value, error) {
if len(operands) < 2 || len(operands) > 3 {
return result.Value{}, fmt.Errorf("substring expects 2 or 3 arguments, got %d", len(operands))
}

// Check for null operands first
if result.IsNull(operands[0]) || result.IsNull(operands[1]) {
return result.New(nil)
}

stringToSub, startIndex, err := extractSubstringBaseArgs(operands)
if err != nil {
return result.Value{}, err
}

runes := []rune(stringToSub)
stringLen := int32(len(runes))

// Special case: If string is empty and startIndex is 0, return empty string
if stringLen == 0 && startIndex == 0 {
return result.New("")
}
Comment on lines +473 to +476
Copy link
Collaborator Author

@suyashkumar suyashkumar May 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what should Substring('', 0) produce? The current code returns an empty string instead of Null (it seems reasonable to get the identical string when doing Substring(str, 0) == str to me). The spec would probably imply Null instead of empty string though. Could be worth clarifying/discussing, happy to change that back to the strict spec behavior.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think empty string is reasonable here.

I put up a change on the cqltests repo with this as a suggested new case cqframework/cql-tests#55


// Rule: If startIndex is less than 0 or greater than or equal to the length of the stringToSub, the result is null.
if startIndex < 0 || startIndex >= stringLen {
return result.New(nil)
}

// Handle three-argument form: Substring(stringToSub, startIndex, length)
if len(operands) == 3 {
return substringWithLength(runes, startIndex, operands[2])
}

// Handle two-argument form: Substring(stringToSub, startIndex)
// Rule: If length is not specified, the result is the substring of stringToSub starting at startIndex.
return result.New(string(runes[startIndex:]))
}

// extractSubstringBaseArgs extracts and validates the first two arguments for Substring
// Note: This assumes operands are already checked for null
func extractSubstringBaseArgs(operands []result.Value) (string, int32, error) {
// Operand 0: stringToSub (String)
stringToSub, err := result.ToString(operands[0])
if err != nil {
return "", 0, fmt.Errorf("could not convert stringToSub to string: %w", err)
}

// Operand 1: startIndex (Integer)
startIndex, err := result.ToInt32(operands[1])
if err != nil {
return "", 0, fmt.Errorf("could not convert startIndex to int32: %w", err)
}

return stringToSub, startIndex, nil
}

// substringWithLength handles the three-argument form of Substring
func substringWithLength(runes []rune, startIndex int32, lengthOperand result.Value) (result.Value, error) {
if result.IsNull(lengthOperand) {
return result.New(nil)
}

length, err := result.ToInt32(lengthOperand)
if err != nil {
return result.Value{}, fmt.Errorf("could not convert length to int32: %w", err)
}

// Rule: If length is less than 0, the result is null.
if length < 0 {
return result.New(nil)
}

// Rule: If length is 0, the result is an empty string.
if length == 0 {
return result.New("")
}

stringLen := int32(len(runes))
endIndex := startIndex + length
// Rule: If length is greater than the remaining characters, include characters to the end.
if endIndex > stringLen {
endIndex = stringLen
}

return result.New(string(runes[startIndex:endIndex]))
}
9 changes: 6 additions & 3 deletions model/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -1054,9 +1054,9 @@ type Union struct{ *BinaryExpression }
type Split struct{ *BinaryExpression }

// Substring ELM Expression https://cql.hl7.org/09-b-cqlreference.html#substring
// Substring is an OperatorExpression in ELM, but we're modeling it as a BinaryExpression since in CQL
// it takes two or three arguments (string, start, length).
type Substring struct{ *BinaryExpression }
// Substring is an OperatorExpression in ELM. It takes two or three arguments (string, start, length).
// We model it as a NaryExpression to handle both overloads.
type Substring struct{ *NaryExpression }

// Indexer ELM Expression https://cql.hl7.org/04-logicalspecification.html#indexer.
type Indexer struct{ *BinaryExpression }
Expand Down Expand Up @@ -1642,6 +1642,9 @@ func (i *Indexer) GetName() string { return "Indexer" }
// GetName returns the name of the system operator.
func (a *IndexOf) GetName() string { return "IndexOf" }

// GetName returns the name of the system operator.
func (s *Substring) GetName() string { return "Substring" }

// GetName returns the name of the system operator.
func (m *Median) GetName() string { return "Median" }

Expand Down
15 changes: 15 additions & 0 deletions parser/operators.go
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,21 @@ func (p *Parser) loadSystemOperators() error {
}
},
},
{
name: "Substring",
operands: [][]types.IType{
{types.String, types.Integer},
{types.String, types.Integer, types.Integer},
},
model: func() model.IExpression {
return &model.Substring{
// NaryExpression is used here because Substring can have 2 or 3 operands.
NaryExpression: &model.NaryExpression{
Expression: model.ResultType(types.String),
},
}
},
},
// CONVERT QUANTITY OPERATOR
{
name: "ConvertQuantity",
Expand Down
Loading
Loading