diff --git a/mdl-examples/bug-tests/retrieve-multiple-xpath-predicates.mdl b/mdl-examples/bug-tests/retrieve-multiple-xpath-predicates.mdl new file mode 100644 index 00000000..4465356e --- /dev/null +++ b/mdl-examples/bug-tests/retrieve-multiple-xpath-predicates.mdl @@ -0,0 +1,48 @@ +-- ============================================================================ +-- Retrieve with multiple XPath predicates +-- ============================================================================ +-- +-- Symptom (before fix): +-- A retrieve with several XPath predicates was parsed as a single chained +-- `and` expression. When the first predicate contained `or`, the writer +-- emitted one XPathConstraint without preserving the predicate boundary: +-- [A or B][C][D] -> [A or B and C and D] +-- That changes XPath operator precedence. +-- +-- After fix: +-- The parser keeps the original predicate boundaries as bracketed XPath +-- source and the writer stores the same `[predicate][predicate]` shape. +-- +-- Usage: +-- mxcli check mdl-examples/bug-tests/retrieve-multiple-xpath-predicates.mdl +-- mxcli exec mdl-examples/bug-tests/retrieve-multiple-xpath-predicates.mdl -p app.mpr +-- describe -> exec -> describe must preserve the three predicates. +-- ============================================================================ + +create module BugTestXPathPredicates; + +create entity BugTestXPathPredicates.Token ( + CreatedAt : datetime, + ItemId : string(100) +); +/ + +create entity BugTestXPathPredicates.Item ( + CreatedAt : datetime, + ItemId : string(100), + ExternalId : string(100) +); +/ + +create microflow BugTestXPathPredicates.MF_RetrieveWithPredicates ( + $Token: BugTestXPathPredicates.Token +) +returns list of BugTestXPathPredicates.Item as $Items +begin + retrieve $Items from BugTestXPathPredicates.Item + where [CreatedAt > $Token/CreatedAt or (CreatedAt = $Token/CreatedAt and ItemId > $Token/ItemId)] + [CreatedAt < '[%CurrentDateTime%]'] + [ExternalId != empty]; + return $Items; +end; +/ diff --git a/mdl/executor/cmd_microflows_builder_actions.go b/mdl/executor/cmd_microflows_builder_actions.go index 80b091dc..67ace554 100644 --- a/mdl/executor/cmd_microflows_builder_actions.go +++ b/mdl/executor/cmd_microflows_builder_actions.go @@ -625,7 +625,7 @@ func (fb *flowBuilder) addRetrieveAction(s *ast.RetrieveStmt) model.ID { // Convert WHERE expression if present // XPath constraints are stored with square brackets in BSON: [expression] if s.Where != nil { - dbSource.XPathConstraint = "[" + expressionToXPath(s.Where) + "]" + dbSource.XPathConstraint = retrieveXPathConstraint(s.Where) } // Convert SORT BY columns if present @@ -710,6 +710,14 @@ func (fb *flowBuilder) addRetrieveAction(s *ast.RetrieveStmt) model.ID { return activity.ID } +func retrieveXPathConstraint(expr ast.Expression) string { + xpath := expressionToXPath(expr) + if strings.HasPrefix(strings.TrimSpace(xpath), "[") && strings.HasSuffix(strings.TrimSpace(xpath), "]") { + return strings.TrimSpace(xpath) + } + return "[" + xpath + "]" +} + func (fb *flowBuilder) inferSortEntityRefSteps(sourceEntityQN, attrPath string) []microflows.EntityRefStep { attrEntityQN := entityQualifiedNameFromAttribute(attrPath) if attrEntityQN == "" || attrEntityQN == sourceEntityQN { diff --git a/mdl/executor/cmd_microflows_builder_retrieve_sort_test.go b/mdl/executor/cmd_microflows_builder_retrieve_sort_test.go index e36a10b2..b0a5913c 100644 --- a/mdl/executor/cmd_microflows_builder_retrieve_sort_test.go +++ b/mdl/executor/cmd_microflows_builder_retrieve_sort_test.go @@ -98,3 +98,48 @@ func TestAddRetrieveAction_AllowsAssociationPathSortAttribute(t *testing.T) { t.Fatalf("second sort entity ref steps = %#v, want none", got) } } + +func TestAddRetrieveAction_PreservesMultipleXPathPredicates(t *testing.T) { + fb := &flowBuilder{ + varTypes: map[string]string{}, + } + where := "[CreatedAt > $Token/CreatedAt or (CreatedAt = $Token/CreatedAt and ItemId > $Token/ItemId)][CreatedAt < '[%CurrentDateTime%]'][ExternalId != empty]" + + fb.addRetrieveAction(&ast.RetrieveStmt{ + Variable: "Items", + Source: ast.QualifiedName{ + Module: "Synthetic", + Name: "Item", + }, + Where: &ast.SourceExpr{ + Expression: &ast.BinaryExpr{ + Left: &ast.IdentifierExpr{Name: "CreatedAt"}, + Operator: ">", + Right: &ast.AttributePathExpr{Variable: "Token", Path: []string{"CreatedAt"}}, + }, + Source: where, + }, + }) + + if len(fb.errors) > 0 { + t.Fatalf("unexpected builder errors: %v", fb.errors) + } + if len(fb.objects) != 1 { + t.Fatalf("got %d objects, want 1", len(fb.objects)) + } + activity, ok := fb.objects[0].(*microflows.ActionActivity) + if !ok { + t.Fatalf("got object %T, want *microflows.ActionActivity", fb.objects[0]) + } + action, ok := activity.Action.(*microflows.RetrieveAction) + if !ok { + t.Fatalf("got action %T, want *microflows.RetrieveAction", activity.Action) + } + source, ok := action.Source.(*microflows.DatabaseRetrieveSource) + if !ok { + t.Fatalf("got source %T, want *microflows.DatabaseRetrieveSource", action.Source) + } + if source.XPathConstraint != where { + t.Fatalf("XPathConstraint = %q, want %q", source.XPathConstraint, where) + } +} diff --git a/mdl/visitor/visitor_microflow_statements.go b/mdl/visitor/visitor_microflow_statements.go index bbb2fc63..3a5405cb 100644 --- a/mdl/visitor/visitor_microflow_statements.go +++ b/mdl/visitor/visitor_microflow_statements.go @@ -1045,12 +1045,21 @@ func buildRetrieveStatement(ctx parser.IRetrieveStatementContext) *ast.RetrieveS stmt.Where = buildXPathSourceExpression(xpathExpr) } } else if len(xpathConstraints) > 1 { - // Multiple predicates [cond1][cond2] — combine with AND + // Multiple predicates [cond1][cond2] are semantically ANDed by + // XPath, but their predicate boundaries matter when one predicate + // contains OR. Preserve the bracketed source so the builder can + // write the same XPathConstraint shape back to the MPR. var andExprs []ast.Expression + var predicateSources []string for _, xc := range xpathConstraints { xcCtx := xc.(*parser.XpathConstraintContext) if xpathExpr := xcCtx.XpathExpr(); xpathExpr != nil { andExprs = append(andExprs, buildXPathSourceExpression(xpathExpr)) + if prc, ok := xpathExpr.(antlr.ParserRuleContext); ok { + if source := strings.TrimSpace(extractOriginalText(prc)); source != "" { + predicateSources = append(predicateSources, "["+source+"]") + } + } } } if len(andExprs) == 1 { @@ -1061,6 +1070,12 @@ func buildRetrieveStatement(ctx parser.IRetrieveStatementContext) *ast.RetrieveS for _, expr := range andExprs[1:] { result = &ast.BinaryExpr{Left: result, Operator: "and", Right: expr} } + if len(predicateSources) == len(andExprs) { + result = &ast.SourceExpr{ + Expression: result, + Source: strings.Join(predicateSources, ""), + } + } stmt.Where = result } } else if expr := retrCtx.Expression(0); expr != nil { diff --git a/mdl/visitor/visitor_test.go b/mdl/visitor/visitor_test.go index 74d9b720..7d87329b 100644 --- a/mdl/visitor/visitor_test.go +++ b/mdl/visitor/visitor_test.go @@ -1981,6 +1981,34 @@ END;` } } +func TestRetrieveMultipleXPathPredicatesPreservePredicateBoundaries(t *testing.T) { + input := `CREATE MICROFLOW Synthetic.PreserveXPathPredicates ( + $Token: Synthetic.Token +) +BEGIN + RETRIEVE $Items FROM Synthetic.Item + WHERE [CreatedAt > $Token/CreatedAt or (CreatedAt = $Token/CreatedAt and ItemId > $Token/ItemId)] + [CreatedAt < '[%CurrentDateTime%]'] + [ExternalId != empty]; +END;` + + prog, errs := Build(input) + if len(errs) > 0 { + t.Fatalf("unexpected parse errors: %v", errs) + } + + mf := prog.Statements[0].(*ast.CreateMicroflowStmt) + retrieveStmt := mf.Body[0].(*ast.RetrieveStmt) + source, ok := retrieveStmt.Where.(*ast.SourceExpr) + if !ok { + t.Fatalf("expected retrieve XPath SourceExpr, got %T", retrieveStmt.Where) + } + want := "[CreatedAt > $Token/CreatedAt or (CreatedAt = $Token/CreatedAt and ItemId > $Token/ItemId)][CreatedAt < '[%CurrentDateTime%]'][ExternalId != empty]" + if source.Source != want { + t.Fatalf("XPath source = %q, want %q", source.Source, want) + } +} + func TestTrailingExpressionWhitespacePreservedForRoundtripSlots(t *testing.T) { input := `CREATE MICROFLOW Synthetic.PreserveTrailingExpressionWhitespace ( $Object: Synthetic.Entity