@@ -8284,47 +8284,198 @@ pub(crate) fn build_chained_with_match_cte_plan(
82848284 // Performance optimization: Wrap non-ID columns with ANY() when aggregating
82858285 // This allows GROUP BY to only include ID column (more efficient)
82868286
8287- // Rewrite denormalized node aliases to edge aliases in RenderExpr.
8288- // For denormalized schemas (e.g., Airport in flights table),
8289- // PropertyAccessExp(a, OriginCityName) must become
8290- // PropertyAccessExp(r, OriginCityName) because FROM is `flights AS r`.
8291- fn rewrite_denormalized_aliases_in_expr(
8287+ // Resolve denormalized property access in RenderExpr.
8288+ // For denormalized schemas (e.g., Airport properties in flights table):
8289+ // 1. Rewrites table alias: a → r (node alias → edge alias)
8290+ // 2. Rewrites property name: uses get_properties_with_table_alias()
8291+ // to map Cypher property → correct DB column (from_node vs to_node aware)
8292+ //
8293+ // This centralizes what was previously split across:
8294+ // - rewrite_expression_with_property_mapping (property names via schema)
8295+ // - separate alias rewriting (table aliases)
8296+ // Both aspects are now handled here using the plan's
8297+ // get_properties_with_table_alias(), which knows the from/to position.
8298+ fn resolve_denormalized_property_in_expr(
82928299 expr: &mut RenderExpr,
82938300 plan: &LogicalPlan,
82948301 ) {
82958302 match expr {
82968303 RenderExpr::PropertyAccessExp(prop) => {
8297- if let Ok((_, Some(edge_alias) )) =
8304+ if let Ok((properties, table_alias_override )) =
82988305 plan.get_properties_with_table_alias(&prop.table_alias.0)
82998306 {
8300- if edge_alias != prop.table_alias.0 {
8301- log::info!(
8302- "🔧 Denormalized alias rewrite in WITH: '{}.{}' → '{}.{}'",
8303- prop.table_alias.0, prop.column.raw(),
8304- edge_alias, prop.column.raw()
8305- );
8306- prop.table_alias =
8307- crate::render_plan::render_expr::TableAlias(
8308- edge_alias,
8307+ if let Some(edge_alias) = table_alias_override {
8308+ // This is a denormalized node — resolve both alias and property.
8309+ // The properties list is (cypher_name, db_column) pairs
8310+ // from from_node_properties or to_node_properties,
8311+ // correctly distinguishing Origin* vs Dest* columns.
8312+ let current_col = prop.column.raw().to_string();
8313+
8314+ // Match by Cypher property name first (before schema rewriting),
8315+ // then by DB column name (after schema rewriting).
8316+ // This handles both pre- and post-rewritten expressions.
8317+ let mapped_column = properties
8318+ .iter()
8319+ .find(|(prop_name, _)| *prop_name == current_col)
8320+ .map(|(_, col)| col.clone())
8321+ .or_else(|| {
8322+ // The column may have been rewritten by schema mapping
8323+ // to a DB column (e.g., city → OriginCityName).
8324+ // Check if current_col matches any DB column in our
8325+ // properties list (correct side).
8326+ if properties.iter().any(|(_, col)| *col == current_col) {
8327+ Some(current_col.clone())
8328+ } else {
8329+ // Schema mapped to wrong side's column (e.g., b.city
8330+ // became b.OriginCityName but should be DestCityName).
8331+ // Reverse-lookup: find the Cypher property that maps
8332+ // to current_col using from/to_properties on the
8333+ // node schema, then map through our properties list.
8334+ // Scoped to the alias's node label to avoid false matches.
8335+ use crate::query_planner::logical_expr::expression_rewriter::find_label_for_alias_in_plan;
8336+ use crate::server::query_context::get_current_schema_with_fallback;
8337+ let node_label = find_label_for_alias_in_plan(plan, &prop.table_alias.0);
8338+ if let (Some(label), Some(schema)) = (node_label, get_current_schema_with_fallback()) {
8339+ if let Some(node_schema) = schema.all_node_schemas().get(&label) {
8340+ // Check from_properties
8341+ if let Some(from_props) = &node_schema.from_properties {
8342+ for (cypher_name, db_col) in from_props {
8343+ if *db_col == current_col {
8344+ if let Some((_, correct_col)) = properties
8345+ .iter()
8346+ .find(|(pn, _)| pn == cypher_name)
8347+ {
8348+ log::info!(
8349+ "🔧 Denormalized cross-side fix: '{}.{}' (from '{}') → '{}.{}'",
8350+ prop.table_alias.0, current_col,
8351+ cypher_name, edge_alias, correct_col
8352+ );
8353+ return Some(correct_col.clone());
8354+ }
8355+ }
8356+ }
8357+ }
8358+ // Check to_properties
8359+ if let Some(to_props) = &node_schema.to_properties {
8360+ for (cypher_name, db_col) in to_props {
8361+ if *db_col == current_col {
8362+ if let Some((_, correct_col)) = properties
8363+ .iter()
8364+ .find(|(pn, _)| pn == cypher_name)
8365+ {
8366+ log::info!(
8367+ "🔧 Denormalized cross-side fix: '{}.{}' (from '{}') → '{}.{}'",
8368+ prop.table_alias.0, current_col,
8369+ cypher_name, edge_alias, correct_col
8370+ );
8371+ return Some(correct_col.clone());
8372+ }
8373+ }
8374+ }
8375+ }
8376+ }
8377+ }
8378+ None
8379+ }
8380+ });
8381+
8382+ if let Some(actual_column) = mapped_column {
8383+ if edge_alias != prop.table_alias.0
8384+ || actual_column != current_col
8385+ {
8386+ log::info!(
8387+ "🔧 Denormalized property resolve in WITH: '{}.{}' → '{}.{}'",
8388+ prop.table_alias.0, current_col,
8389+ edge_alias, actual_column
8390+ );
8391+ prop.table_alias =
8392+ crate::render_plan::render_expr::TableAlias(
8393+ edge_alias,
8394+ );
8395+ prop.column =
8396+ crate::graph_catalog::expression_parser::PropertyValue::Column(
8397+ actual_column,
8398+ );
8399+ }
8400+ } else if edge_alias != prop.table_alias.0 {
8401+ // Property not in any mapping but alias needs rewriting
8402+ log::info!(
8403+ "🔧 Denormalized alias rewrite in WITH: '{}.{}' → '{}.{}'",
8404+ prop.table_alias.0, current_col,
8405+ edge_alias, current_col
83098406 );
8407+ prop.table_alias =
8408+ crate::render_plan::render_expr::TableAlias(
8409+ edge_alias,
8410+ );
8411+ }
83108412 }
83118413 }
83128414 }
83138415 RenderExpr::AggregateFnCall(agg) => {
83148416 for arg in &mut agg.args {
8315- rewrite_denormalized_aliases_in_expr (arg, plan);
8417+ resolve_denormalized_property_in_expr (arg, plan);
83168418 }
83178419 }
83188420 RenderExpr::ScalarFnCall(f) => {
83198421 for arg in &mut f.args {
8320- rewrite_denormalized_aliases_in_expr (arg, plan);
8422+ resolve_denormalized_property_in_expr (arg, plan);
83218423 }
83228424 }
83238425 RenderExpr::OperatorApplicationExp(op) => {
83248426 for operand in &mut op.operands {
8325- rewrite_denormalized_aliases_in_expr(operand, plan);
8427+ resolve_denormalized_property_in_expr(operand, plan);
8428+ }
8429+ }
8430+ RenderExpr::Case(case) => {
8431+ if let Some(expr) = &mut case.expr {
8432+ resolve_denormalized_property_in_expr(expr, plan);
8433+ }
8434+ for (cond, then_expr) in &mut case.when_then {
8435+ resolve_denormalized_property_in_expr(cond, plan);
8436+ resolve_denormalized_property_in_expr(then_expr, plan);
8437+ }
8438+ if let Some(else_expr) = &mut case.else_expr {
8439+ resolve_denormalized_property_in_expr(else_expr, plan);
8440+ }
8441+ }
8442+ RenderExpr::List(items) => {
8443+ for item in items {
8444+ resolve_denormalized_property_in_expr(item, plan);
83268445 }
83278446 }
8447+ RenderExpr::MapLiteral(entries) => {
8448+ for (_, value) in entries {
8449+ resolve_denormalized_property_in_expr(value, plan);
8450+ }
8451+ }
8452+ RenderExpr::ArraySubscript { array, index } => {
8453+ resolve_denormalized_property_in_expr(array, plan);
8454+ resolve_denormalized_property_in_expr(index, plan);
8455+ }
8456+ RenderExpr::ArraySlicing { array, from, to } => {
8457+ resolve_denormalized_property_in_expr(array, plan);
8458+ if let Some(f) = from {
8459+ resolve_denormalized_property_in_expr(f, plan);
8460+ }
8461+ if let Some(t) = to {
8462+ resolve_denormalized_property_in_expr(t, plan);
8463+ }
8464+ }
8465+ RenderExpr::InSubquery(insub) => {
8466+ resolve_denormalized_property_in_expr(&mut insub.expr, plan);
8467+ }
8468+ RenderExpr::ReduceExpr(reduce) => {
8469+ resolve_denormalized_property_in_expr(
8470+ &mut reduce.initial_value,
8471+ plan,
8472+ );
8473+ resolve_denormalized_property_in_expr(&mut reduce.list, plan);
8474+ resolve_denormalized_property_in_expr(
8475+ &mut reduce.expression,
8476+ plan,
8477+ );
8478+ }
83288479 _ => {}
83298480 }
83308481 }
@@ -8484,7 +8635,7 @@ pub(crate) fn build_chained_with_match_cte_plan(
84848635 let expr_result: Result<RenderExpr, _> = expanded_expr.try_into();
84858636 expr_result.ok().map(|mut expr| {
84868637 // Rewrite denormalized node aliases (e.g., a → r)
8487- rewrite_denormalized_aliases_in_expr (&mut expr, plan_to_render);
8638+ resolve_denormalized_property_in_expr (&mut expr, plan_to_render);
84888639
84898640 // 🔧 FIX: VLP CTE column rewriting for non-TableAlias WITH items
84908641 // When FROM is a VLP/multi-type CTE, PropertyAccess references
@@ -8789,7 +8940,7 @@ pub(crate) fn build_chained_with_match_cte_plan(
87898940 };
87908941 let rewritten = rewrite_expression_with_property_mapping(&item.expression, &rewrite_ctx);
87918942 let expr_vec: Vec<RenderExpr> = rewritten.try_into().ok().map(|mut expr: RenderExpr| {
8792- rewrite_denormalized_aliases_in_expr (&mut expr, plan_to_render);
8943+ resolve_denormalized_property_in_expr (&mut expr, plan_to_render);
87938944 expr
87948945 }).into_iter().collect();
87958946 expr_vec
0 commit comments