Verified Commit ad08cec6 authored by Michael Angelo Rivera's avatar Michael Angelo Rivera Committed by GitLab
Browse files

fix(query-engine): resolve duplicate table aliases in fan-in joins

parent 53250747
Loading
Loading
Loading
Loading
+55 −0
Original line number Diff line number Diff line
@@ -1344,6 +1344,59 @@ async fn traversal_both_direction(ctx: &TestContext) {
    }
}

async fn traversal_shared_target_node(ctx: &TestContext) {
    seed(ctx).await;

    ctx.execute(
        "INSERT INTO gl_edge (traversal_path, source_id, source_kind, relationship_kind, target_id, target_kind) VALUES
         ('1/100/1000/', 1, 'User', 'AUTHORED', 3000, 'Note'),
         ('1/100/1000/', 1000, 'Project', 'CONTAINS', 3000, 'Note')",
    )
    .await;

    let value = run_pipeline(
        ctx,
        r#"{
            "query_type": "traversal",
            "nodes": [
                {"id": "u", "entity": "User", "columns": ["username"]},
                {"id": "n", "entity": "Note", "columns": ["note"]},
                {"id": "p", "entity": "Project", "columns": ["name"]}
            ],
            "relationships": [
                {"type": "AUTHORED", "from": "u", "to": "n"},
                {"type": "CONTAINS", "from": "p", "to": "n"}
            ],
            "limit": 50
        }"#,
        &allow_all(),
    )
    .await;

    assert_eq!(value["query_type"], "traversal");

    let nodes = value["nodes"].as_array().unwrap();
    let user_ids = node_ids(nodes, "User");
    let note_ids = node_ids(nodes, "Note");
    let project_ids = node_ids(nodes, "Project");
    assert!(user_ids.contains(&1), "User 1 should be present");
    assert!(note_ids.contains(&3000), "Note 3000 should be present");
    assert!(
        project_ids.contains(&1000),
        "Project 1000 should be present"
    );

    let edges = value["edges"].as_array().unwrap();
    assert!(
        edges.iter().any(|e| e["type"] == "AUTHORED"),
        "AUTHORED edge should exist"
    );
    assert!(
        edges.iter().any(|e| e["type"] == "CONTAINS"),
        "CONTAINS edge should exist"
    );
}

// ─────────────────────────────────────────────────────────────────────────────
// Column types — Bool, DateTime, Nullable
// ─────────────────────────────────────────────────────────────────────────────
@@ -1914,6 +1967,8 @@ async fn graph_formatter_e2e() {
        // Traversal — direction
        traversal_incoming_direction,
        traversal_both_direction,
        // Traversal — fan-in
        traversal_shared_target_node,
        // Aggregation — all functions
        aggregation_count_exact,
        aggregation_sum,
+76 −18
Original line number Diff line number Diff line
@@ -599,13 +599,16 @@ fn build_joins(
    let start_table = resolve_table(start)?;
    let mut result = TableRef::scan(&start_table, &start.id);
    let mut edge_aliases = HashMap::new();
    let mut joined = HashSet::new();
    joined.insert(start.id.clone());

    for (i, rel) in rels.iter().enumerate() {
        let target = find_node(nodes, &rel.to)?;
        let target_table = resolve_table(target)?;
        let source_joined = joined.contains(&rel.from);
        let target_joined = joined.contains(&rel.to);

        if rel.max_hops > 1 {
            // Multi-hop: UNION ALL subquery
            let alias = format!("hop_e{i}");
            edge_aliases.insert(i, alias.clone());

@@ -620,7 +623,6 @@ fn build_joins(
            if from_node.has_traversal_path {
                source_cond = Expr::and(edge_path_starts_with(&alias, &rel.from), source_cond);
            }
            result = TableRef::join(JoinType::Inner, result, union, source_cond);

            let mut target_cond = Expr::eq(
                Expr::col(&alias, to_col),
@@ -629,35 +631,91 @@ fn build_joins(
            if target.has_traversal_path {
                target_cond = Expr::and(edge_path_starts_with(&alias, &rel.to), target_cond);
            }

            let union_join_cond = match (source_joined, target_joined) {
                (true, true) => Expr::and(source_cond.clone(), target_cond.clone()),
                (true, false) => source_cond.clone(),
                (false, true) => target_cond.clone(),
                (false, false) => {
                    return Err(QueryError::Lowering(format!(
                        "disconnected relationship: neither '{}' nor '{}' are reachable",
                        rel.from, rel.to
                    )));
                }
            };

            result = TableRef::join(JoinType::Inner, result, union, union_join_cond);

            if !source_joined {
                let source_table = resolve_table(from_node)?;
                result = TableRef::join(
                    JoinType::Inner,
                    result,
                    TableRef::scan(&source_table, &rel.from),
                    source_cond,
                );
                joined.insert(rel.from.clone());
            }
            if !target_joined {
                result = TableRef::join(
                    JoinType::Inner,
                    result,
                    TableRef::scan(&target_table, &rel.to),
                    target_cond,
                );
                joined.insert(rel.to.clone());
            }
        } else {
            // Single-hop: direct edge join
            let alias = format!("e{i}");
            edge_aliases.insert(i, alias.clone());

            let from_node = find_node(nodes, &rel.from)?;
            let (edge, edge_type_cond) = edge_scan(&alias, &type_filter(&rel.types));
            let mut join_cond = source_join_cond(
            let source_cond = source_join_cond(
                &rel.from,
                &alias,
                rel.direction,
                from_node.has_traversal_path,
            );
            let target_cond =
                target_join_cond(&alias, &rel.to, rel.direction, target.has_traversal_path);

            let mut edge_join_cond = match (source_joined, target_joined) {
                (true, true) => Expr::and(source_cond.clone(), target_cond.clone()),
                (true, false) => source_cond.clone(),
                (false, true) => target_cond.clone(),
                (false, false) => {
                    return Err(QueryError::Lowering(format!(
                        "disconnected relationship: neither '{}' nor '{}' are reachable",
                        rel.from, rel.to
                    )));
                }
            };
            if let Some(tc) = edge_type_cond {
                join_cond = Expr::and(join_cond, tc);
                edge_join_cond = Expr::and(edge_join_cond, tc);
            }
            result = TableRef::join(JoinType::Inner, result, edge, join_cond);

            result = TableRef::join(JoinType::Inner, result, edge, edge_join_cond);

            if !source_joined {
                let source_table = resolve_table(from_node)?;
                result = TableRef::join(
                    JoinType::Inner,
                    result,
                    TableRef::scan(&source_table, &rel.from),
                    source_cond,
                );
                joined.insert(rel.from.clone());
            }
            if !target_joined {
                result = TableRef::join(
                    JoinType::Inner,
                    result,
                    TableRef::scan(&target_table, &rel.to),
                target_join_cond(&alias, &rel.to, rel.direction, target.has_traversal_path),
                    target_cond,
                );
                joined.insert(rel.to.clone());
            }
        }
    }