You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
memos/internal/filter/parser.go

1021 lines
29 KiB
Go

package filter
import (
"time"
"github.com/pkg/errors"
exprv1 "google.golang.org/genproto/googleapis/api/expr/v1alpha1"
)
// parseContext carries the schema plus the frozen evaluation time used to fold
// the `now` variable into a constant. Freezing once per compile guarantees a
// single filter observes a single instant.
type parseContext struct {
schema Schema
now time.Time
}
func buildCondition(expr *exprv1.Expr, pc parseContext) (Condition, error) {
switch v := expr.ExprKind.(type) {
case *exprv1.Expr_CallExpr:
return buildCallCondition(v.CallExpr, pc)
case *exprv1.Expr_ConstExpr:
val, err := getConstValue(expr)
if err != nil {
return nil, err
}
if v, ok := val.(bool); ok {
return &ConstantCondition{Value: v}, nil
}
return nil, errors.New("filter must evaluate to a boolean value")
case *exprv1.Expr_IdentExpr:
name := v.IdentExpr.GetName()
field, ok := pc.schema.Field(name)
if !ok {
return nil, errors.Errorf("unknown identifier %q", name)
}
if field.Type != FieldTypeBool {
return nil, errors.Errorf("identifier %q is not boolean", name)
}
return &FieldPredicateCondition{Field: name}, nil
case *exprv1.Expr_ComprehensionExpr:
return buildComprehensionCondition(v.ComprehensionExpr, pc.schema)
default:
return nil, errors.New("unsupported top-level expression")
}
}
func buildCallCondition(call *exprv1.Expr_Call, pc parseContext) (Condition, error) {
switch call.Function {
case "_&&_":
if len(call.Args) != 2 {
return nil, errors.New("logical AND expects two arguments")
}
left, err := buildCondition(call.Args[0], pc)
if err != nil {
return nil, err
}
right, err := buildCondition(call.Args[1], pc)
if err != nil {
return nil, err
}
return &LogicalCondition{
Operator: LogicalAnd,
Left: left,
Right: right,
}, nil
case "_||_":
if len(call.Args) != 2 {
return nil, errors.New("logical OR expects two arguments")
}
left, err := buildCondition(call.Args[0], pc)
if err != nil {
return nil, err
}
right, err := buildCondition(call.Args[1], pc)
if err != nil {
return nil, err
}
return &LogicalCondition{
Operator: LogicalOr,
Left: left,
Right: right,
}, nil
case "!_":
if len(call.Args) != 1 {
return nil, errors.New("logical NOT expects one argument")
}
child, err := buildCondition(call.Args[0], pc)
if err != nil {
return nil, err
}
return &NotCondition{Expr: child}, nil
case "_==_", "_!=_", "_<_", "_>_", "_<=_", "_>=_":
return buildComparisonCondition(call, pc)
case "@in":
return buildInCondition(call, pc)
case "contains":
return buildTextMatchCondition(call, pc.schema, TextMatchContains)
case "startsWith":
return buildTextMatchCondition(call, pc.schema, TextMatchPrefix)
case "endsWith":
return buildTextMatchCondition(call, pc.schema, TextMatchSuffix)
case "matches":
return buildMatchesCondition(call, pc.schema)
case "sets.contains", "sets.intersects", "sets.equivalent":
return buildSetCondition(call, pc)
default:
val, ok, err := evaluateBool(call)
if err != nil {
return nil, err
}
if ok {
return &ConstantCondition{Value: val}, nil
}
return nil, errors.Errorf("unsupported call expression %q", call.Function)
}
}
func buildComparisonCondition(call *exprv1.Expr_Call, pc parseContext) (Condition, error) {
if len(call.Args) != 2 {
return nil, errors.New("comparison expects two arguments")
}
op, err := toComparisonOperator(call.Function)
if err != nil {
return nil, err
}
left, err := buildValueExpr(call.Args[0], pc)
if err != nil {
return nil, err
}
right, err := buildValueExpr(call.Args[1], pc)
if err != nil {
return nil, err
}
// The renderer expects a field/function/accessor on the left. A folded
// literal on the left (e.g. now.getMonth() == created_ts.getMonth()) swaps
// operands; two literals fold to a constant outcome.
if leftLit, ok := left.(*LiteralValue); ok {
if rightLit, ok := right.(*LiteralValue); ok {
outcome, err := compareLiterals(leftLit.Value, op, rightLit.Value)
if err != nil {
return nil, err
}
return &ConstantCondition{Value: outcome}, nil
}
left, right = right, left
op = mirrorComparisonOperator(op)
}
// If the left side is a field, validate allowed operators.
if field, ok := left.(*FieldRef); ok {
def, exists := pc.schema.Field(field.Name)
if !exists {
return nil, errors.Errorf("unknown identifier %q", field.Name)
}
if def.Kind == FieldKindVirtualAlias {
def, exists = pc.schema.ResolveAlias(field.Name)
if !exists {
return nil, errors.Errorf("invalid alias %q", field.Name)
}
}
if def.AllowedComparisonOps != nil {
if _, allowed := def.AllowedComparisonOps[op]; !allowed {
return nil, errors.Errorf("operator %s not allowed for field %q", op, field.Name)
}
}
}
return &ComparisonCondition{
Left: left,
Operator: op,
Right: right,
}, nil
}
// mirrorComparisonOperator flips an operator so swapped operands keep the
// original meaning (a < b ⇔ b > a).
func mirrorComparisonOperator(op ComparisonOperator) ComparisonOperator {
switch op {
case CompareLt:
return CompareGt
case CompareLte:
return CompareGte
case CompareGt:
return CompareLt
case CompareGte:
return CompareLte
default:
return op
}
}
// compareLiterals evaluates a comparison whose operands both folded to
// constants (e.g. now.getFullYear() >= 2026) into a boolean outcome.
func compareLiterals(left any, op ComparisonOperator, right any) (bool, error) {
if l, r, ok := asFloats(left, right); ok {
switch op {
case CompareEq:
return l == r, nil
case CompareNeq:
return l != r, nil
case CompareLt:
return l < r, nil
case CompareLte:
return l <= r, nil
case CompareGt:
return l > r, nil
case CompareGte:
return l >= r, nil
}
}
if l, ok := left.(string); ok {
if r, ok := right.(string); ok {
switch op {
case CompareEq:
return l == r, nil
case CompareNeq:
return l != r, nil
case CompareLt:
return l < r, nil
case CompareLte:
return l <= r, nil
case CompareGt:
return l > r, nil
case CompareGte:
return l >= r, nil
}
}
}
if l, ok := left.(bool); ok {
if r, ok := right.(bool); ok {
switch op {
case CompareEq:
return l == r, nil
case CompareNeq:
return l != r, nil
}
}
}
return false, errors.Errorf("unsupported constant comparison %T %s %T", left, op, right)
}
// asFloats widens both operands to float64 when each is numeric.
func asFloats(left, right any) (float64, float64, bool) {
l, lok := toFloat(left)
r, rok := toFloat(right)
return l, r, lok && rok
}
func toFloat(v any) (float64, bool) {
switch x := v.(type) {
case int64:
return float64(x), true
case float64:
return x, true
}
return 0, false
}
func buildInCondition(call *exprv1.Expr_Call, pc parseContext) (Condition, error) {
if len(call.Args) != 2 {
return nil, errors.New("in operator expects two arguments")
}
// Handle identifier in list syntax.
if identName, err := getIdentName(call.Args[0]); err == nil {
if field, ok := pc.schema.Field(identName); ok && field.Kind == FieldKindVirtualAlias {
if _, aliasOk := pc.schema.ResolveAlias(identName); !aliasOk {
return nil, errors.Errorf("invalid alias %q", identName)
}
} else if !ok {
return nil, errors.Errorf("unknown identifier %q", identName)
}
if listExpr := call.Args[1].GetListExpr(); listExpr != nil {
values := make([]ValueExpr, 0, len(listExpr.Elements))
for _, element := range listExpr.Elements {
value, err := buildValueExpr(element, pc)
if err != nil {
return nil, err
}
values = append(values, value)
}
return &InCondition{
Left: &FieldRef{Name: identName},
Values: values,
}, nil
}
}
// Handle "value in identifier" syntax.
if identName, err := getIdentName(call.Args[1]); err == nil {
if _, ok := pc.schema.Field(identName); !ok {
return nil, errors.Errorf("unknown identifier %q", identName)
}
element, err := buildValueExpr(call.Args[0], pc)
if err != nil {
return nil, err
}
return &ElementInCondition{
Element: element,
Field: identName,
}, nil
}
return nil, errors.New("invalid use of in operator")
}
func buildTextMatchCondition(call *exprv1.Expr_Call, schema Schema, mode TextMatchMode) (Condition, error) {
if call.Target == nil {
return nil, errors.New("text match requires a target")
}
targetName, err := getIdentName(call.Target)
if err != nil {
return nil, err
}
field, ok := schema.Field(targetName)
if !ok {
return nil, errors.Errorf("unknown identifier %q", targetName)
}
if !field.SupportsContains {
return nil, errors.Errorf("identifier %q does not support text matching", targetName)
}
if len(call.Args) != 1 {
return nil, errors.New("text match expects exactly one argument")
}
value, err := getConstValue(call.Args[0])
if err != nil {
return nil, errors.Wrap(err, "text match only supports literal arguments")
}
str, ok := value.(string)
if !ok {
return nil, errors.New("text match argument must be a string")
}
return &TextMatchCondition{
Field: targetName,
Mode: mode,
Value: str,
}, nil
}
func buildMatchesCondition(call *exprv1.Expr_Call, schema Schema) (Condition, error) {
if call.Target == nil {
return nil, errors.New("matches requires a target")
}
targetName, err := getIdentName(call.Target)
if err != nil {
return nil, err
}
field, ok := schema.Field(targetName)
if !ok {
return nil, errors.Errorf("unknown identifier %q", targetName)
}
if !field.SupportsContains {
return nil, errors.Errorf("identifier %q does not support matches()", targetName)
}
if len(call.Args) != 1 {
return nil, errors.New("matches expects exactly one argument")
}
value, err := getConstValue(call.Args[0])
if err != nil {
return nil, errors.Wrap(err, "matches only supports literal arguments")
}
pattern, ok := value.(string)
if !ok {
return nil, errors.New("matches argument must be a string")
}
return &RegexCondition{
Field: targetName,
Pattern: pattern,
}, nil
}
func buildValueExpr(expr *exprv1.Expr, pc parseContext) (ValueExpr, error) {
if identName, err := getIdentName(expr); err == nil {
// `now` is not a schema field; it folds to the frozen evaluation time.
if identName == "now" {
return &LiteralValue{Value: pc.now.Unix()}, nil
}
if _, ok := pc.schema.Field(identName); !ok {
return nil, errors.Errorf("unknown identifier %q", identName)
}
return &FieldRef{Name: identName}, nil
}
if literal, err := getConstValue(expr); err == nil {
return &LiteralValue{Value: literal}, nil
}
if value, ok, err := evaluateNumeric(expr, pc.now); err != nil {
return nil, err
} else if ok {
return &LiteralValue{Value: value}, nil
}
if boolVal, ok, err := evaluateBoolExpr(expr); err != nil {
return nil, err
} else if ok {
return &LiteralValue{Value: boolVal}, nil
}
if call := expr.GetCallExpr(); call != nil {
if call.Target != nil && isTimestampAccessor(call.Function) {
return buildTimestampAccessor(call, pc)
}
switch call.Function {
case "size":
if len(call.Args) != 1 {
return nil, errors.New("size() expects one argument")
}
arg, err := buildValueExpr(call.Args[0], pc)
if err != nil {
return nil, err
}
return &FunctionValue{
Name: "size",
Args: []ValueExpr{arg},
}, nil
case "_+_", "_-_", "_*_":
value, ok, err := evaluateNumeric(expr, pc.now)
if err != nil {
return nil, err
}
if ok {
return &LiteralValue{Value: value}, nil
}
default:
// Fall through to error return below
}
}
return nil, errors.New("unsupported value expression")
}
func toComparisonOperator(fn string) (ComparisonOperator, error) {
switch fn {
case "_==_":
return CompareEq, nil
case "_!=_":
return CompareNeq, nil
case "_<_":
return CompareLt, nil
case "_>_":
return CompareGt, nil
case "_<=_":
return CompareLte, nil
case "_>=_":
return CompareGte, nil
default:
return "", errors.Errorf("unsupported comparison operator %q", fn)
}
}
func getIdentName(expr *exprv1.Expr) (string, error) {
if ident := expr.GetIdentExpr(); ident != nil {
return ident.GetName(), nil
}
return "", errors.New("expression is not an identifier")
}
func getConstValue(expr *exprv1.Expr) (interface{}, error) {
v, ok := expr.ExprKind.(*exprv1.Expr_ConstExpr)
if !ok {
return nil, errors.New("expression is not a literal")
}
switch x := v.ConstExpr.ConstantKind.(type) {
case *exprv1.Constant_StringValue:
return v.ConstExpr.GetStringValue(), nil
case *exprv1.Constant_Int64Value:
return v.ConstExpr.GetInt64Value(), nil
case *exprv1.Constant_Uint64Value:
return int64(v.ConstExpr.GetUint64Value()), nil
case *exprv1.Constant_DoubleValue:
return v.ConstExpr.GetDoubleValue(), nil
case *exprv1.Constant_BoolValue:
return v.ConstExpr.GetBoolValue(), nil
case *exprv1.Constant_NullValue:
return nil, nil
default:
return nil, errors.Errorf("unsupported constant %T", x)
}
}
func evaluateBool(call *exprv1.Expr_Call) (bool, bool, error) {
val, ok, err := evaluateBoolExpr(&exprv1.Expr{ExprKind: &exprv1.Expr_CallExpr{CallExpr: call}})
return val, ok, err
}
func evaluateBoolExpr(expr *exprv1.Expr) (bool, bool, error) {
if literal, err := getConstValue(expr); err == nil {
if b, ok := literal.(bool); ok {
return b, true, nil
}
return false, false, nil
}
if call := expr.GetCallExpr(); call != nil && call.Function == "!_" {
if len(call.Args) != 1 {
return false, false, errors.New("NOT expects exactly one argument")
}
val, ok, err := evaluateBoolExpr(call.Args[0])
if err != nil || !ok {
return false, false, err
}
return !val, true, nil
}
return false, false, nil
}
// evaluateNumeric constant-folds an expression to an int64 measured in seconds:
// timestamps and `now` fold to Unix epoch seconds, durations fold to a number of
// seconds, and the two combine through standard arithmetic. CEL has already
// type-checked the operand combinations, so the folded int math is well-formed.
func evaluateNumeric(expr *exprv1.Expr, now time.Time) (int64, bool, error) {
if literal, err := getConstValue(expr); err == nil {
switch v := literal.(type) {
case int64:
return v, true, nil
case float64:
return int64(v), true, nil
}
return 0, false, nil
}
// The `now` variable folds to the frozen evaluation time.
if ident := expr.GetIdentExpr(); ident != nil {
if ident.GetName() == "now" {
return now.Unix(), true, nil
}
return 0, false, nil
}
call := expr.GetCallExpr()
if call == nil {
return 0, false, nil
}
switch call.Function {
case "timestamp":
return evaluateTimestamp(call)
case "duration":
return evaluateDuration(call)
case "_+_", "_-_", "_*_", "_/_", "_%_":
if len(call.Args) != 2 {
return 0, false, errors.New("arithmetic requires two arguments")
}
left, ok, err := evaluateNumeric(call.Args[0], now)
if err != nil {
return 0, false, err
}
if !ok {
return 0, false, nil
}
right, ok, err := evaluateNumeric(call.Args[1], now)
if err != nil {
return 0, false, err
}
if !ok {
return 0, false, nil
}
switch call.Function {
case "_+_":
return left + right, true, nil
case "_-_":
return left - right, true, nil
case "_*_":
return left * right, true, nil
case "_/_":
if right == 0 {
return 0, false, errors.New("division by zero")
}
return left / right, true, nil
case "_%_":
if right == 0 {
return 0, false, errors.New("modulo by zero")
}
return left % right, true, nil
default:
return 0, false, errors.Errorf("unsupported arithmetic operator %q", call.Function)
}
default:
return 0, false, nil
}
}
// evaluateTimestamp folds timestamp("RFC3339") and timestamp(<epoch int>) into
// Unix epoch seconds.
func evaluateTimestamp(call *exprv1.Expr_Call) (int64, bool, error) {
if len(call.Args) != 1 {
return 0, false, errors.New("timestamp() expects one argument")
}
value, err := getConstValue(call.Args[0])
if err != nil {
return 0, false, errors.Wrap(err, "timestamp() only supports literal arguments")
}
switch v := value.(type) {
case string:
ts, err := time.Parse(time.RFC3339, v)
if err != nil {
return 0, false, errors.Wrap(err, "invalid timestamp literal")
}
return ts.Unix(), true, nil
case int64:
return v, true, nil
default:
return 0, false, errors.New("timestamp() argument must be an RFC3339 string or epoch int")
}
}
// evaluateDuration folds duration("<go-duration>") into a number of seconds.
func evaluateDuration(call *exprv1.Expr_Call) (int64, bool, error) {
if len(call.Args) != 1 {
return 0, false, errors.New("duration() expects one argument")
}
value, err := getConstValue(call.Args[0])
if err != nil {
return 0, false, errors.Wrap(err, "duration() only supports literal arguments")
}
str, ok := value.(string)
if !ok {
return 0, false, errors.New("duration() argument must be a string")
}
d, err := time.ParseDuration(str)
if err != nil {
return 0, false, errors.Wrap(err, "invalid duration literal")
}
return int64(d.Seconds()), true, nil
}
// timestampAccessors is the set of supported CEL timestamp accessor methods.
var timestampAccessors = map[string]bool{
"getFullYear": true,
"getMonth": true,
"getDate": true,
"getDayOfMonth": true,
"getDayOfWeek": true,
"getDayOfYear": true,
"getHours": true,
"getMinutes": true,
"getSeconds": true,
}
func isTimestampAccessor(name string) bool {
return timestampAccessors[name]
}
// buildTimestampAccessor converts created_ts.getMonth() into a FieldAccessorValue.
// A `now` target folds to a literal date part of the frozen evaluation time, so
// expressions like created_ts.getMonth() == now.getMonth() stay dynamic per compile.
// Timezone arguments are rejected; extraction is UTC (see renderer).
func buildTimestampAccessor(call *exprv1.Expr_Call, pc parseContext) (ValueExpr, error) {
targetName, err := getIdentName(call.Target)
if err != nil {
return nil, errors.Wrap(err, "timestamp accessor requires a field target")
}
if len(call.Args) != 0 {
return nil, errors.Errorf("%s() with a timezone argument is not supported", call.Function)
}
if targetName == "now" {
return &LiteralValue{Value: foldNowAccessor(call.Function, pc.now)}, nil
}
field, ok := pc.schema.Field(targetName)
if !ok {
return nil, errors.Errorf("unknown identifier %q", targetName)
}
if field.Type != FieldTypeTimestamp {
return nil, errors.Errorf("%s() is only valid on timestamp fields, got %q", call.Function, targetName)
}
return &FieldAccessorValue{Field: targetName, Accessor: call.Function}, nil
}
// foldNowAccessor evaluates a timestamp accessor against the frozen evaluation
// time in UTC, matching CEL result bases (0-based month, day-of-month, day-of-week
// with 0 = Sunday, and day-of-year; 1-based getDate).
func foldNowAccessor(accessor string, now time.Time) int64 {
t := now.UTC()
switch accessor {
case "getFullYear":
return int64(t.Year())
case "getMonth":
return int64(t.Month()) - 1
case "getDate":
return int64(t.Day())
case "getDayOfMonth":
return int64(t.Day()) - 1
case "getDayOfWeek":
return int64(t.Weekday())
case "getDayOfYear":
return int64(t.YearDay()) - 1
case "getHours":
return int64(t.Hour())
case "getMinutes":
return int64(t.Minute())
case "getSeconds":
return int64(t.Second())
}
return 0
}
// buildSetCondition desugars ext.Sets() operations over a JSON list field into
// existing IR: membership reduces to ElementInCondition, and equivalence adds a
// length check. This relies on the list field being a set (no duplicates), which
// holds for memo tags.
func buildSetCondition(call *exprv1.Expr_Call, pc parseContext) (Condition, error) {
if len(call.Args) != 2 {
return nil, errors.Errorf("%s expects two arguments", call.Function)
}
fieldName, err := getIdentName(call.Args[0])
if err != nil {
return nil, errors.Wrap(err, "set operations require a list field as the first argument")
}
field, ok := pc.schema.Field(fieldName)
if !ok {
return nil, errors.Errorf("unknown identifier %q", fieldName)
}
if field.Kind != FieldKindJSONList {
return nil, errors.Errorf("set operations require a list field, got %q", fieldName)
}
listExpr := call.Args[1].GetListExpr()
if listExpr == nil {
return nil, errors.New("set operations require a list literal as the second argument")
}
values := make([]string, 0, len(listExpr.Elements))
for _, el := range listExpr.Elements {
v, err := getConstValue(el)
if err != nil {
return nil, errors.Wrap(err, "set operations only support literal string elements")
}
s, ok := v.(string)
if !ok {
return nil, errors.New("set operations require string elements")
}
values = append(values, s)
}
membership := func(s string) Condition {
return &ElementInCondition{Element: &LiteralValue{Value: s}, Field: fieldName}
}
sizeEquals := func(n int) Condition {
return &ComparisonCondition{
Left: &FunctionValue{Name: "size", Args: []ValueExpr{&FieldRef{Name: fieldName}}},
Operator: CompareEq,
Right: &LiteralValue{Value: int64(n)},
}
}
switch call.Function {
case "sets.contains":
if len(values) == 0 {
return &ConstantCondition{Value: true}, nil
}
return combineConditions(LogicalAnd, mapConditions(values, membership)), nil
case "sets.intersects":
if len(values) == 0 {
return &ConstantCondition{Value: false}, nil
}
return combineConditions(LogicalOr, mapConditions(values, membership)), nil
case "sets.equivalent":
distinct := distinctStrings(values)
if len(distinct) == 0 {
return sizeEquals(0), nil
}
contains := combineConditions(LogicalAnd, mapConditions(distinct, membership))
return &LogicalCondition{Operator: LogicalAnd, Left: contains, Right: sizeEquals(len(distinct))}, nil
default:
return nil, errors.Errorf("unsupported set operation %q", call.Function)
}
}
func mapConditions(values []string, f func(string) Condition) []Condition {
conds := make([]Condition, 0, len(values))
for _, v := range values {
conds = append(conds, f(v))
}
return conds
}
func combineConditions(op LogicalOperator, conds []Condition) Condition {
result := conds[0]
for _, c := range conds[1:] {
result = &LogicalCondition{Operator: op, Left: result, Right: c}
}
return result
}
func distinctStrings(values []string) []string {
seen := make(map[string]bool, len(values))
out := make([]string, 0, len(values))
for _, v := range values {
if !seen[v] {
seen[v] = true
out = append(out, v)
}
}
return out
}
// buildComprehensionCondition handles CEL comprehension expressions (exists, all, etc.).
func buildComprehensionCondition(comp *exprv1.Expr_Comprehension, schema Schema) (Condition, error) {
// Determine the comprehension kind by examining the loop initialization and step
kind, err := detectComprehensionKind(comp)
if err != nil {
return nil, err
}
// Get the field being iterated over
iterRangeIdent := comp.IterRange.GetIdentExpr()
if iterRangeIdent == nil {
return nil, errors.New("comprehension range must be a field identifier")
}
fieldName := iterRangeIdent.GetName()
// Validate the field
field, ok := schema.Field(fieldName)
if !ok {
return nil, errors.Errorf("unknown field %q in comprehension", fieldName)
}
if field.Kind != FieldKindJSONList {
return nil, errors.Errorf("field %q does not support comprehension (must be a list)", fieldName)
}
// Extract the predicate from the loop step
predicate, err := extractPredicate(comp, schema)
if err != nil {
return nil, err
}
return &ListComprehensionCondition{
Kind: kind,
Field: fieldName,
IterVar: comp.IterVar,
Predicate: predicate,
}, nil
}
// detectComprehensionKind determines if this is an exists() macro.
// Only exists() is currently supported.
func detectComprehensionKind(comp *exprv1.Expr_Comprehension) (ComprehensionKind, error) {
// Check the accumulator initialization
accuInit := comp.AccuInit.GetConstExpr()
if accuInit == nil {
return "", errors.New("comprehension accumulator must be initialized with a constant")
}
// exists() starts with false and uses OR (||) in loop step
if !accuInit.GetBoolValue() {
if step := comp.LoopStep.GetCallExpr(); step != nil && step.Function == "_||_" {
return ComprehensionExists, nil
}
}
// all() starts with true and uses AND (&&) in the loop step.
if accuInit.GetBoolValue() {
if step := comp.LoopStep.GetCallExpr(); step != nil && step.Function == "_&&_" {
return ComprehensionAll, nil
}
}
// exists_one() starts at int(0) and increments via a conditional (predicate ?
// accu + 1 : accu) in the loop step.
if _, isInt := accuInit.GetConstantKind().(*exprv1.Constant_Int64Value); isInt {
if step := comp.LoopStep.GetCallExpr(); step != nil && step.Function == "_?_:_" {
return ComprehensionExistsOne, nil
}
}
return "", errors.New("unsupported comprehension type (supported: exists, all, exists_one)")
}
// extractPredicate extracts the predicate expression from the comprehension loop step.
func extractPredicate(comp *exprv1.Expr_Comprehension, _ Schema) (PredicateExpr, error) {
// The loop step is: @result || predicate(t) for exists
// or: @result && predicate(t) for all
step := comp.LoopStep.GetCallExpr()
if step == nil {
return nil, errors.New("comprehension loop step must be a call expression")
}
// exists/all: accu || predicate / accu && predicate -> predicate is arg[1].
// exists_one: predicate ? accu + 1 : accu -> predicate is arg[0].
var predicateExpr *exprv1.Expr
if step.Function == "_?_:_" {
if len(step.Args) != 3 {
return nil, errors.New("exists_one loop step must have three arguments")
}
predicateExpr = step.Args[0]
} else {
if len(step.Args) != 2 {
return nil, errors.New("comprehension loop step must have two arguments")
}
predicateExpr = step.Args[1]
}
predicateCall := predicateExpr.GetCallExpr()
if predicateCall == nil {
return nil, errors.New("comprehension predicate must be a function call")
}
// Handle different predicate functions
switch predicateCall.Function {
case "_==_":
return buildEqualsPredicate(predicateCall, comp.IterVar)
case "startsWith":
return buildStartsWithPredicate(predicateCall, comp.IterVar)
case "endsWith":
return buildEndsWithPredicate(predicateCall, comp.IterVar)
case "contains":
return buildContainsPredicate(predicateCall, comp.IterVar)
default:
return nil, errors.Errorf(`unsupported predicate function %q in comprehension (supported: ==, startsWith, endsWith, contains)`, predicateCall.Function)
}
}
// buildEqualsPredicate extracts the value from t == "value".
func buildEqualsPredicate(call *exprv1.Expr_Call, iterVar string) (PredicateExpr, error) {
if len(call.Args) != 2 {
return nil, errors.New("equality predicate expects exactly two arguments")
}
var constExpr *exprv1.Expr
switch {
case isIterVarExpr(call.Args[0], iterVar):
constExpr = call.Args[1]
case isIterVarExpr(call.Args[1], iterVar):
constExpr = call.Args[0]
default:
return nil, errors.Errorf("equality predicate must compare against the iteration variable %q", iterVar)
}
value, err := getConstValue(constExpr)
if err != nil {
return nil, errors.Wrap(err, "equality argument must be a constant string")
}
valueStr, ok := value.(string)
if !ok {
return nil, errors.New("equality argument must be a string")
}
return &EqualsPredicate{Value: valueStr}, nil
}
func isIterVarExpr(expr *exprv1.Expr, iterVar string) bool {
target := expr.GetIdentExpr()
return target != nil && target.GetName() == iterVar
}
// buildStartsWithPredicate extracts the pattern from t.startsWith("prefix").
func buildStartsWithPredicate(call *exprv1.Expr_Call, iterVar string) (PredicateExpr, error) {
// Verify the target is the iteration variable
if target := call.Target.GetIdentExpr(); target == nil || target.GetName() != iterVar {
return nil, errors.Errorf("startsWith target must be the iteration variable %q", iterVar)
}
if len(call.Args) != 1 {
return nil, errors.New("startsWith expects exactly one argument")
}
prefix, err := getConstValue(call.Args[0])
if err != nil {
return nil, errors.Wrap(err, "startsWith argument must be a constant string")
}
prefixStr, ok := prefix.(string)
if !ok {
return nil, errors.New("startsWith argument must be a string")
}
return &StartsWithPredicate{Prefix: prefixStr}, nil
}
// buildEndsWithPredicate extracts the pattern from t.endsWith("suffix").
func buildEndsWithPredicate(call *exprv1.Expr_Call, iterVar string) (PredicateExpr, error) {
if target := call.Target.GetIdentExpr(); target == nil || target.GetName() != iterVar {
return nil, errors.Errorf("endsWith target must be the iteration variable %q", iterVar)
}
if len(call.Args) != 1 {
return nil, errors.New("endsWith expects exactly one argument")
}
suffix, err := getConstValue(call.Args[0])
if err != nil {
return nil, errors.Wrap(err, "endsWith argument must be a constant string")
}
suffixStr, ok := suffix.(string)
if !ok {
return nil, errors.New("endsWith argument must be a string")
}
return &EndsWithPredicate{Suffix: suffixStr}, nil
}
// buildContainsPredicate extracts the pattern from t.contains("substring").
func buildContainsPredicate(call *exprv1.Expr_Call, iterVar string) (PredicateExpr, error) {
if target := call.Target.GetIdentExpr(); target == nil || target.GetName() != iterVar {
return nil, errors.Errorf("contains target must be the iteration variable %q", iterVar)
}
if len(call.Args) != 1 {
return nil, errors.New("contains expects exactly one argument")
}
substring, err := getConstValue(call.Args[0])
if err != nil {
return nil, errors.Wrap(err, "contains argument must be a constant string")
}
substringStr, ok := substring.(string)
if !ok {
return nil, errors.New("contains argument must be a string")
}
return &ContainsPredicate{Substring: substringStr}, nil
}