Skip to content
Merged
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
48 changes: 48 additions & 0 deletions mdl-examples/bug-tests/retrieve-multiple-xpath-predicates.mdl
Original file line number Diff line number Diff line change
@@ -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;
/
10 changes: 9 additions & 1 deletion mdl/executor/cmd_microflows_builder_actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
45 changes: 45 additions & 0 deletions mdl/executor/cmd_microflows_builder_retrieve_sort_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
17 changes: 16 additions & 1 deletion mdl/visitor/visitor_microflow_statements.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down
28 changes: 28 additions & 0 deletions mdl/visitor/visitor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading