From 7c7e67f23ce85980bc81aad9f860ad942c860d9c Mon Sep 17 00:00:00 2001 From: "Kim, Hyeonseo" Date: Thu, 25 Jun 2026 15:53:22 +0900 Subject: [PATCH 1/4] Fix nested selected link joins Plan selected single-link joins recursively so nested result shapes use the parent selected alias as the join source. Preserve parent rows under optional selected sources by lowering descendant joins to LEFT joins. Assisted-by: Codex:gpt-5.5 --- engine/sqlite-query-plan/src/lib.rs | 54 ++++++++++++---- .../sqlite-query-plan/src/tests/fixtures.rs | 38 +++++++++++ engine/sqlite-query-plan/src/tests/mod.rs | 64 +++++++++++++++++-- .../query-pipeline/tests/select_execution.rs | 58 ++++++++++++++--- 4 files changed, 188 insertions(+), 26 deletions(-) diff --git a/engine/sqlite-query-plan/src/lib.rs b/engine/sqlite-query-plan/src/lib.rs index bfe1165..0c61775 100644 --- a/engine/sqlite-query-plan/src/lib.rs +++ b/engine/sqlite-query-plan/src/lib.rs @@ -66,13 +66,7 @@ pub fn plan_select(ir: &SelectQuery) -> SQLiteSelectPlan { None => (None, vec![]), }; - let mut joins: Vec = ir - .shape() - .fields() - .into_iter() - .filter(|field| field.child_shape().is_some()) - .map(SQLiteJoin::selected_single_link) - .collect(); + let mut joins = plan_selected_shape_joins(ir.shape(), "root", false); joins.extend(planned_shape_values.joins); joins.extend(filter_joins); @@ -502,6 +496,40 @@ fn plan_shape_values( PlannedShapeValues { values, joins } } +fn plan_selected_shape_joins( + shape: &query_ir::ResolvedShape, + source_alias: &str, + source_nullable: bool, +) -> Vec { + let mut joins = Vec::new(); + + for item in shape.items() { + let query_ir::ResolvedShapeItem::Field(field) = item else { + continue; + }; + + let Some(child_shape) = field.child_shape() else { + continue; + }; + + let target_alias = field.output_name(); + let join_cardinality = path_step_join_cardinality(source_nullable, field.cardinality()); + + joins.push(SQLiteJoin::selected_single_link( + source_alias, + field, + join_cardinality, + )); + joins.extend(plan_selected_shape_joins( + child_shape, + target_alias, + source_nullable || field.cardinality() == Cardinality::Optional, + )); + } + + joins +} + fn plan_result_shape( shape: &query_ir::ResolvedShape, source_alias: &str, @@ -1292,7 +1320,11 @@ pub struct SQLiteJoin { } impl SQLiteJoin { - pub fn selected_single_link(shape_field: &query_ir::ResolvedShapeField) -> Self { + pub fn selected_single_link( + source_alias: &str, + shape_field: &query_ir::ResolvedShapeField, + cardinality: Cardinality, + ) -> Self { let child_shape = shape_field .child_shape() .expect("selected link field must have child shape"); @@ -1300,8 +1332,8 @@ impl SQLiteJoin { let field = shape_field.field().clone(); Self { - kind: SQLiteJoinKind::for_single_link(shape_field.cardinality()), - source_alias: "root".to_string(), + kind: SQLiteJoinKind::for_single_link(cardinality), + source_alias: source_alias.to_string(), target_table: child_shape .source_object_type() .name() @@ -1309,7 +1341,7 @@ impl SQLiteJoin { .to_string(), target_alias: shape_field.output_name().to_string(), on: SQLiteJoinCondition { - left_alias: "root".to_string(), + left_alias: source_alias.to_string(), left_column: format!("{}_id", field.name()), right_alias: shape_field.output_name().to_string(), right_column: "id".to_string(), diff --git a/engine/sqlite-query-plan/src/tests/fixtures.rs b/engine/sqlite-query-plan/src/tests/fixtures.rs index 8926929..ac00116 100644 --- a/engine/sqlite-query-plan/src/tests/fixtures.rs +++ b/engine/sqlite-query-plan/src/tests/fixtures.rs @@ -190,6 +190,44 @@ pub fn post_author_shape_field() -> query_ir::ResolvedShapeField { ) } +pub fn user_best_friend_shape_field() -> query_ir::ResolvedShapeField { + let best_friend_shape = + query_ir::ResolvedShape::new(user_type(), vec![user_name_shape_field()]); + + query_ir::ResolvedShapeField::new( + "best_friend", + user_best_friend_field(), + schema_model::Cardinality::Required, + Some(best_friend_shape), + ) +} + +pub fn post_author_with_best_friend_shape_field() -> query_ir::ResolvedShapeField { + let author_shape = query_ir::ResolvedShape::new( + user_type(), + vec![user_name_shape_field(), user_best_friend_shape_field()], + ); + + query_ir::ResolvedShapeField::new( + "author", + post_author_field(), + schema_model::Cardinality::Required, + Some(author_shape), + ) +} + +pub fn optional_post_author_with_best_friend_shape_field() -> query_ir::ResolvedShapeField { + let author_shape = + query_ir::ResolvedShape::new(user_type(), vec![user_best_friend_shape_field()]); + + query_ir::ResolvedShapeField::new( + "author", + post_author_field(), + schema_model::Cardinality::Optional, + Some(author_shape), + ) +} + pub fn post_best_friend_shape_field() -> query_ir::ResolvedShapeField { let best_friend_shape = query_ir::ResolvedShape::new(user_type(), vec![user_name_shape_field()]); diff --git a/engine/sqlite-query-plan/src/tests/mod.rs b/engine/sqlite-query-plan/src/tests/mod.rs index b8e9092..40387a2 100644 --- a/engine/sqlite-query-plan/src/tests/mod.rs +++ b/engine/sqlite-query-plan/src/tests/mod.rs @@ -9,13 +9,14 @@ use alloc::boxed::Box; use alloc::string::ToString; use alloc::vec; use fixtures::{ - empty_post_query, optional_post_author_shape_field, post_author_field, + empty_post_query, optional_post_author_shape_field, + optional_post_author_with_best_friend_shape_field, post_author_field, post_author_name_path_value, post_author_score_path_value, post_author_shape_field, - post_author_shape_field_with_id_then_name, post_best_friend_field, - post_best_friend_shape_field, post_id_path_value, post_id_shape_field, post_query_with_shape, - post_title_field, post_title_path_value, post_title_shape_field, post_type, - post_view_count_path_value, user_best_friend_score_path_value, user_name_shape_field, - user_score_field, user_type, + post_author_shape_field_with_id_then_name, post_author_with_best_friend_shape_field, + post_best_friend_field, post_best_friend_shape_field, post_id_path_value, post_id_shape_field, + post_query_with_shape, post_title_field, post_title_path_value, post_title_shape_field, + post_type, post_view_count_path_value, user_best_friend_score_path_value, + user_name_shape_field, user_score_field, user_type, }; use query_ir::{ Literal, ResolvedComputedField, ResolvedShape, ResolvedShapeField, ResolvedShapeItem, @@ -2059,6 +2060,57 @@ fn sqlite_select_plan_can_join_selected_single_link() { } } +#[test] +fn sqlite_select_plan_can_join_nested_selected_single_link() { + let ir = post_query_with_shape(vec![post_author_with_best_friend_shape_field()]); + + let plan = plan_select(&ir); + let joins = plan.joins(); + + assert_eq!(joins.len(), 2); + + assert_eq!(joins[0].kind(), SQLiteJoinKind::Inner); + assert_eq!(joins[0].source_alias(), "root"); + assert_eq!(joins[0].target_table(), "user"); + assert_eq!(joins[0].target_alias(), "author"); + assert_eq!(joins[0].on().left_alias(), "root"); + assert_eq!(joins[0].on().left_column(), "author_id"); + assert_eq!(joins[0].on().right_alias(), "author"); + + assert_eq!(joins[1].kind(), SQLiteJoinKind::Inner); + assert_eq!(joins[1].source_alias(), "author"); + assert_eq!(joins[1].target_table(), "user"); + assert_eq!(joins[1].target_alias(), "best_friend"); + assert_eq!(joins[1].on().left_alias(), "author"); + assert_eq!(joins[1].on().left_column(), "best_friend_id"); + assert_eq!(joins[1].on().right_alias(), "best_friend"); + + match joins[1].reason() { + SQLiteJoinReason::SelectedSingleLink { field } => { + assert_eq!(field.name(), "best_friend"); + } + SQLiteJoinReason::PathTraversal { .. } => { + panic!("nested selected link join should be marked as selected single link") + } + } +} + +#[test] +fn sqlite_select_plan_uses_left_join_for_nested_selected_link_under_optional_source() { + let ir = post_query_with_shape(vec![optional_post_author_with_best_friend_shape_field()]); + + let plan = plan_select(&ir); + let joins = plan.joins(); + + assert_eq!(joins.len(), 2); + assert_eq!(joins[0].kind(), SQLiteJoinKind::Left); + assert_eq!(joins[0].source_alias(), "root"); + assert_eq!(joins[0].target_alias(), "author"); + assert_eq!(joins[1].kind(), SQLiteJoinKind::Left); + assert_eq!(joins[1].source_alias(), "author"); + assert_eq!(joins[1].target_alias(), "best_friend"); +} + #[test] fn sqlite_select_plan_can_project_selected_single_link_scalar_field() { let ir = post_query_with_shape(vec![post_author_shape_field()]); diff --git a/tests/query-pipeline/tests/select_execution.rs b/tests/query-pipeline/tests/select_execution.rs index a500e40..ff410cd 100644 --- a/tests/query-pipeline/tests/select_execution.rs +++ b/tests/query-pipeline/tests/select_execution.rs @@ -13,6 +13,7 @@ const BLOG_SCHEMA_SOURCE: &str = r#" type User { required email: str required score: int64 + link best_friend: User multi link posts: Post } @@ -77,24 +78,26 @@ fn insert_blog_fixture_rows(runner: &mut NativeSQLiteRunner) { // parsing, resolution, planning, and execution exist. runner .execute_with_values( - "INSERT INTO user (id, email, score) VALUES (?, ?, ?)", + "INSERT INTO user (id, email, score, best_friend_id) VALUES (?, ?, ?, ?)", &[ - SQLiteValuePlan::Text("user-1".to_string()), - SQLiteValuePlan::Text("alice@example.com".to_string()), - SQLiteValuePlan::Integer(100), + SQLiteValuePlan::Text("user-2".to_string()), + SQLiteValuePlan::Text("blocked@example.com".to_string()), + SQLiteValuePlan::Integer(0), + SQLiteValuePlan::Null, ], ) - .expect("first user fixture row should insert"); + .expect("second user fixture row should insert"); runner .execute_with_values( - "INSERT INTO user (id, email, score) VALUES (?, ?, ?)", + "INSERT INTO user (id, email, score, best_friend_id) VALUES (?, ?, ?, ?)", &[ + SQLiteValuePlan::Text("user-1".to_string()), + SQLiteValuePlan::Text("alice@example.com".to_string()), + SQLiteValuePlan::Integer(100), SQLiteValuePlan::Text("user-2".to_string()), - SQLiteValuePlan::Text("blocked@example.com".to_string()), - SQLiteValuePlan::Integer(0), ], ) - .expect("second user fixture row should insert"); + .expect("first user fixture row should insert"); runner .execute_with_values( "INSERT INTO post (id, title, view_count, author_id) VALUES (?, ?, ?, ?)", @@ -275,6 +278,43 @@ fn select_pipeline_executes_computed_projection() { ); } +#[test] +fn select_pipeline_executes_nested_selected_single_link_shape() { + let result = execute_query( + r#"select Post { + title, + author: { + email, + best_friend: { + email + } + } +} +filter .title = "Draft""#, + ); + + assert_eq!( + result.columns(), + &[ + "title".to_string(), + "id".to_string(), + "email".to_string(), + "id".to_string(), + "email".to_string(), + ] + ); + assert_eq!( + result.rows(), + &[vec![ + SQLiteCellValue::Text("Draft".to_string()), + SQLiteCellValue::Text("user-1".to_string()), + SQLiteCellValue::Text("alice@example.com".to_string()), + SQLiteCellValue::Text("user-2".to_string()), + SQLiteCellValue::Text("blocked@example.com".to_string()), + ]] + ); +} + #[test] fn select_pipeline_executes_unary_arithmetic_computed_projection() { let result = From 48dda4346351b355f58959361df857921ac3ca2f Mon Sep 17 00:00:00 2001 From: "Kim, Hyeonseo" Date: Thu, 25 Jun 2026 16:23:56 +0900 Subject: [PATCH 2/4] Fix selected link alias collisions Assign planner-owned SQL aliases to repeated nested selected links and reuse the alias plan across joins, selected values, and result-shape references. Assisted-by: Codex:gpt-5.5 --- engine/sqlite-query-plan/src/lib.rs | 165 +++++++++++++++--- .../sqlite-query-plan/src/tests/fixtures.rs | 23 +++ engine/sqlite-query-plan/src/tests/mod.rs | 66 ++++++- .../query-pipeline/tests/select_execution.rs | 50 +++++- 4 files changed, 281 insertions(+), 23 deletions(-) diff --git a/engine/sqlite-query-plan/src/lib.rs b/engine/sqlite-query-plan/src/lib.rs index 0c61775..fd517df 100644 --- a/engine/sqlite-query-plan/src/lib.rs +++ b/engine/sqlite-query-plan/src/lib.rs @@ -33,16 +33,26 @@ pub fn plan_select(ir: &SelectQuery) -> SQLiteSelectPlan { let selected_column_names = selected_field_column_names(ir.shape()); let mut select_aliases = SQLiteComputedAliasAllocator::new(selected_column_names.clone()); let mut join_aliases = SQLiteJoinAliasAllocator::new(selected_link_aliases(ir.shape())); + let selected_shape_aliases = plan_selected_shape_aliases(ir.shape(), &mut join_aliases); let planned_shape_values = plan_shape_values( ir.shape(), "root", false, + &[], + &selected_shape_aliases, &mut select_aliases, &mut join_aliases, ); let selected_values = planned_shape_values.values; let mut result_aliases = SQLiteComputedAliasAllocator::new(selected_column_names); - let result_shape = plan_result_shape(ir.shape(), "root", false, &mut result_aliases); + let result_shape = plan_result_shape( + ir.shape(), + "root", + false, + &[], + &selected_shape_aliases, + &mut result_aliases, + ); let planned_orders: Vec = ir .order_by() @@ -66,7 +76,8 @@ pub fn plan_select(ir: &SelectQuery) -> SQLiteSelectPlan { None => (None, vec![]), }; - let mut joins = plan_selected_shape_joins(ir.shape(), "root", false); + let mut joins = + plan_selected_shape_joins(ir.shape(), "root", false, &[], &selected_shape_aliases); joins.extend(planned_shape_values.joins); joins.extend(filter_joins); @@ -389,6 +400,80 @@ impl SQLiteJoinAliasAllocator { } } +struct SQLiteSelectedShapeAliases { + aliases: Vec, +} + +struct SQLiteSelectedShapeAlias { + shape_path: Vec, + sql_alias: String, +} + +impl SQLiteSelectedShapeAliases { + fn alias_for_path(&self, shape_path: &[usize]) -> &str { + self.aliases + .iter() + .find(|alias| alias.shape_path == shape_path) + .expect("selected shape alias should exist for nested field") + .sql_alias + .as_str() + } +} + +fn plan_selected_shape_aliases( + shape: &query_ir::ResolvedShape, + join_aliases: &mut SQLiteJoinAliasAllocator, +) -> SQLiteSelectedShapeAliases { + let mut aliases = Vec::new(); + let mut used_aliases = vec!["root".to_string()]; + collect_selected_shape_aliases(shape, &[], &mut used_aliases, &mut aliases, join_aliases); + + SQLiteSelectedShapeAliases { aliases } +} + +fn collect_selected_shape_aliases( + shape: &query_ir::ResolvedShape, + shape_path: &[usize], + used_aliases: &mut Vec, + aliases: &mut Vec, + join_aliases: &mut SQLiteJoinAliasAllocator, +) { + for (index, item) in shape.items().iter().enumerate() { + let query_ir::ResolvedShapeItem::Field(field) = item else { + continue; + }; + + let Some(child_shape) = field.child_shape() else { + continue; + }; + + let mut child_path = shape_path.to_vec(); + child_path.push(index); + let preferred_alias = field.output_name(); + let sql_alias = if used_aliases + .iter() + .any(|used_alias| used_alias == preferred_alias) + { + join_aliases.next_alias() + } else { + preferred_alias.to_string() + }; + + used_aliases.push(sql_alias.clone()); + aliases.push(SQLiteSelectedShapeAlias { + shape_path: child_path.clone(), + sql_alias, + }); + collect_selected_shape_aliases( + child_shape, + &child_path, + used_aliases, + aliases, + join_aliases, + ); + } +} + fn selected_field_column_names(shape: &query_ir::ResolvedShape) -> Vec { let mut column_names = Vec::new(); collect_selected_field_column_names(shape, false, &mut column_names); @@ -437,17 +522,21 @@ fn plan_shape_values( shape: &query_ir::ResolvedShape, source_alias: &str, source_nullable: bool, + shape_path: &[usize], + selected_shape_aliases: &SQLiteSelectedShapeAliases, computed_aliases: &mut SQLiteComputedAliasAllocator, join_aliases: &mut SQLiteJoinAliasAllocator, ) -> PlannedShapeValues { let mut values = Vec::new(); let mut joins = Vec::new(); - for item in shape.items() { + for (index, item) in shape.items().iter().enumerate() { match item { query_ir::ResolvedShapeItem::Field(field) => match field.child_shape() { Some(child_shape) => { - let nested_alias = field.output_name(); + let mut child_path = shape_path.to_vec(); + child_path.push(index); + let nested_alias = selected_shape_aliases.alias_for_path(&child_path); let child_id_field = FieldRef::new( schema_model::FieldId::new(1), child_shape.source_object_type().clone(), @@ -464,6 +553,8 @@ fn plan_shape_values( child_shape, nested_alias, source_nullable || field.cardinality() == Cardinality::Optional, + &child_path, + selected_shape_aliases, computed_aliases, join_aliases, ); @@ -500,10 +591,12 @@ fn plan_selected_shape_joins( shape: &query_ir::ResolvedShape, source_alias: &str, source_nullable: bool, + shape_path: &[usize], + selected_shape_aliases: &SQLiteSelectedShapeAliases, ) -> Vec { let mut joins = Vec::new(); - for item in shape.items() { + for (index, item) in shape.items().iter().enumerate() { let query_ir::ResolvedShapeItem::Field(field) = item else { continue; }; @@ -512,18 +605,23 @@ fn plan_selected_shape_joins( continue; }; - let target_alias = field.output_name(); + let mut child_path = shape_path.to_vec(); + child_path.push(index); + let target_alias = selected_shape_aliases.alias_for_path(&child_path); let join_cardinality = path_step_join_cardinality(source_nullable, field.cardinality()); - joins.push(SQLiteJoin::selected_single_link( + joins.push(SQLiteJoin::selected_single_link_with_alias( source_alias, field, + target_alias, join_cardinality, )); joins.extend(plan_selected_shape_joins( child_shape, target_alias, source_nullable || field.cardinality() == Cardinality::Optional, + &child_path, + selected_shape_aliases, )); } @@ -534,24 +632,35 @@ fn plan_result_shape( shape: &query_ir::ResolvedShape, source_alias: &str, include_identity: bool, + shape_path: &[usize], + selected_shape_aliases: &SQLiteSelectedShapeAliases, computed_aliases: &mut SQLiteComputedAliasAllocator, ) -> SQLiteResultShapePlan { let fields = shape .items() .iter() - .map(|item| match item { + .enumerate() + .map(|(index, item)| match item { query_ir::ResolvedShapeItem::Field(field) => match field.child_shape() { - Some(child_shape) => SQLiteResultField { - output_name: field.output_name().to_string(), - cardinality: field.cardinality(), - value: None, - nested_shape: Some(plan_result_shape( - child_shape, - field.output_name(), - true, - computed_aliases, - )), - }, + Some(child_shape) => { + let mut child_path = shape_path.to_vec(); + child_path.push(index); + let nested_alias = selected_shape_aliases.alias_for_path(&child_path); + + SQLiteResultField { + output_name: field.output_name().to_string(), + cardinality: field.cardinality(), + value: None, + nested_shape: Some(plan_result_shape( + child_shape, + nested_alias, + true, + &child_path, + selected_shape_aliases, + computed_aliases, + )), + } + } None => SQLiteResultField { output_name: field.output_name().to_string(), cardinality: field.cardinality(), @@ -1324,6 +1433,20 @@ impl SQLiteJoin { source_alias: &str, shape_field: &query_ir::ResolvedShapeField, cardinality: Cardinality, + ) -> Self { + Self::selected_single_link_with_alias( + source_alias, + shape_field, + shape_field.output_name(), + cardinality, + ) + } + + fn selected_single_link_with_alias( + source_alias: &str, + shape_field: &query_ir::ResolvedShapeField, + target_alias: &str, + cardinality: Cardinality, ) -> Self { let child_shape = shape_field .child_shape() @@ -1339,11 +1462,11 @@ impl SQLiteJoin { .name() .to_ascii_lowercase() .to_string(), - target_alias: shape_field.output_name().to_string(), + target_alias: target_alias.to_string(), on: SQLiteJoinCondition { left_alias: source_alias.to_string(), left_column: format!("{}_id", field.name()), - right_alias: shape_field.output_name().to_string(), + right_alias: target_alias.to_string(), right_column: "id".to_string(), }, reason: SQLiteJoinReason::SelectedSingleLink { field }, diff --git a/engine/sqlite-query-plan/src/tests/fixtures.rs b/engine/sqlite-query-plan/src/tests/fixtures.rs index ac00116..46d965f 100644 --- a/engine/sqlite-query-plan/src/tests/fixtures.rs +++ b/engine/sqlite-query-plan/src/tests/fixtures.rs @@ -202,6 +202,18 @@ pub fn user_best_friend_shape_field() -> query_ir::ResolvedShapeField { ) } +pub fn user_best_friend_with_best_friend_shape_field() -> query_ir::ResolvedShapeField { + let best_friend_shape = + query_ir::ResolvedShape::new(user_type(), vec![user_best_friend_shape_field()]); + + query_ir::ResolvedShapeField::new( + "best_friend", + user_best_friend_field(), + schema_model::Cardinality::Required, + Some(best_friend_shape), + ) +} + pub fn post_author_with_best_friend_shape_field() -> query_ir::ResolvedShapeField { let author_shape = query_ir::ResolvedShape::new( user_type(), @@ -284,3 +296,14 @@ pub fn post_query_with_shape(fields: Vec) -> query None, ) } + +pub fn user_query_with_shape(fields: Vec) -> query_ir::SelectQuery { + query_ir::SelectQuery::new( + user_type(), + query_ir::ResolvedShape::new(user_type(), fields), + None, + vec![], + None, + None, + ) +} diff --git a/engine/sqlite-query-plan/src/tests/mod.rs b/engine/sqlite-query-plan/src/tests/mod.rs index 40387a2..4098a7a 100644 --- a/engine/sqlite-query-plan/src/tests/mod.rs +++ b/engine/sqlite-query-plan/src/tests/mod.rs @@ -16,7 +16,8 @@ use fixtures::{ post_best_friend_field, post_best_friend_shape_field, post_id_path_value, post_id_shape_field, post_query_with_shape, post_title_field, post_title_path_value, post_title_shape_field, post_type, post_view_count_path_value, user_best_friend_score_path_value, - user_name_shape_field, user_score_field, user_type, + user_best_friend_with_best_friend_shape_field, user_name_shape_field, user_query_with_shape, + user_score_field, user_type, }; use query_ir::{ Literal, ResolvedComputedField, ResolvedShape, ResolvedShapeField, ResolvedShapeItem, @@ -2095,6 +2096,69 @@ fn sqlite_select_plan_can_join_nested_selected_single_link() { } } +#[test] +fn sqlite_select_plan_uses_unique_aliases_for_repeated_nested_selected_link_names() { + let ir = user_query_with_shape(vec![user_best_friend_with_best_friend_shape_field()]); + + let plan = plan_select(&ir); + let joins = plan.joins(); + + assert_eq!(joins.len(), 2); + assert_eq!(joins[0].source_alias(), "root"); + assert_eq!(joins[0].target_alias(), "best_friend"); + assert_eq!(joins[1].source_alias(), "best_friend"); + assert_ne!(joins[1].target_alias(), "best_friend"); + assert_ne!(joins[1].target_alias(), joins[0].target_alias()); + assert_eq!(joins[1].on().left_alias(), "best_friend"); + assert_eq!(joins[1].on().right_alias(), joins[1].target_alias()); + + assert_selected_field( + &plan.selected_values()[0], + "best_friend", + "id", + "id", + "id", + SQLiteValueRole::ObjectId, + ); + assert_selected_field( + &plan.selected_values()[1], + joins[1].target_alias(), + "id", + "id", + "id", + SQLiteValueRole::ObjectId, + ); + assert_selected_field( + &plan.selected_values()[2], + joins[1].target_alias(), + "name", + "name", + "name", + SQLiteValueRole::Scalar, + ); + + let first_friend = &plan.result_shape().fields()[0]; + let first_friend_shape = first_friend + .nested_shape() + .expect("first best_friend should have nested result shape"); + let second_friend = &first_friend_shape.fields()[0]; + let second_friend_shape = second_friend + .nested_shape() + .expect("second best_friend should have nested result shape"); + let second_friend_identity = second_friend_shape + .identity_value() + .expect("second best_friend shape should have identity value"); + let second_friend_name = second_friend_shape.fields()[0] + .value() + .expect("second best_friend name should point to a selected value"); + + assert_eq!( + second_friend_identity.source_alias(), + joins[1].target_alias() + ); + assert_eq!(second_friend_name.source_alias(), joins[1].target_alias()); +} + #[test] fn sqlite_select_plan_uses_left_join_for_nested_selected_link_under_optional_source() { let ir = post_query_with_shape(vec![optional_post_author_with_best_friend_shape_field()]); diff --git a/tests/query-pipeline/tests/select_execution.rs b/tests/query-pipeline/tests/select_execution.rs index ff410cd..381fa6b 100644 --- a/tests/query-pipeline/tests/select_execution.rs +++ b/tests/query-pipeline/tests/select_execution.rs @@ -76,6 +76,17 @@ fn insert_blog_fixture_rows(runner: &mut NativeSQLiteRunner) { // Temporary fixture setup: Gelite does not have an insert pipeline yet. // Replace these raw SQL inserts with Gelite insert statements once insert // parsing, resolution, planning, and execution exist. + runner + .execute_with_values( + "INSERT INTO user (id, email, score, best_friend_id) VALUES (?, ?, ?, ?)", + &[ + SQLiteValuePlan::Text("user-3".to_string()), + SQLiteValuePlan::Text("carol@example.com".to_string()), + SQLiteValuePlan::Integer(50), + SQLiteValuePlan::Null, + ], + ) + .expect("third user fixture row should insert"); runner .execute_with_values( "INSERT INTO user (id, email, score, best_friend_id) VALUES (?, ?, ?, ?)", @@ -83,7 +94,7 @@ fn insert_blog_fixture_rows(runner: &mut NativeSQLiteRunner) { SQLiteValuePlan::Text("user-2".to_string()), SQLiteValuePlan::Text("blocked@example.com".to_string()), SQLiteValuePlan::Integer(0), - SQLiteValuePlan::Null, + SQLiteValuePlan::Text("user-3".to_string()), ], ) .expect("second user fixture row should insert"); @@ -315,6 +326,43 @@ filter .title = "Draft""#, ); } +#[test] +fn select_pipeline_executes_repeated_nested_selected_single_link_names() { + let result = execute_query( + r#"select User { + email, + best_friend: { + email, + best_friend: { + email + } + } +} +filter .email = "alice@example.com""#, + ); + + assert_eq!( + result.columns(), + &[ + "email".to_string(), + "id".to_string(), + "email".to_string(), + "id".to_string(), + "email".to_string(), + ] + ); + assert_eq!( + result.rows(), + &[vec![ + SQLiteCellValue::Text("alice@example.com".to_string()), + SQLiteCellValue::Text("user-2".to_string()), + SQLiteCellValue::Text("blocked@example.com".to_string()), + SQLiteCellValue::Text("user-3".to_string()), + SQLiteCellValue::Text("carol@example.com".to_string()), + ]] + ); +} + #[test] fn select_pipeline_executes_unary_arithmetic_computed_projection() { let result = From a68dfc959e8864abf3706afbb7f3e0287253451c Mon Sep 17 00:00:00 2001 From: "Kim, Hyeonseo" Date: Thu, 25 Jun 2026 17:05:59 +0900 Subject: [PATCH 3/4] Avoid selected link alias collisions with root paths Reserve root path aliases before assigning nested selected link SQL aliases so selected joins do not collide with filter or ordering joins synthesized from the root source. Assisted-by: Codex:gpt-5.5 --- engine/sqlite-query-plan/src/lib.rs | 87 ++++++++++++++++++- .../sqlite-query-plan/src/tests/fixtures.rs | 20 +++++ engine/sqlite-query-plan/src/tests/mod.rs | 54 ++++++++++-- 3 files changed, 152 insertions(+), 9 deletions(-) diff --git a/engine/sqlite-query-plan/src/lib.rs b/engine/sqlite-query-plan/src/lib.rs index fd517df..3c5a14f 100644 --- a/engine/sqlite-query-plan/src/lib.rs +++ b/engine/sqlite-query-plan/src/lib.rs @@ -33,7 +33,8 @@ pub fn plan_select(ir: &SelectQuery) -> SQLiteSelectPlan { let selected_column_names = selected_field_column_names(ir.shape()); let mut select_aliases = SQLiteComputedAliasAllocator::new(selected_column_names.clone()); let mut join_aliases = SQLiteJoinAliasAllocator::new(selected_link_aliases(ir.shape())); - let selected_shape_aliases = plan_selected_shape_aliases(ir.shape(), &mut join_aliases); + let selected_shape_aliases = + plan_selected_shape_aliases(ir.shape(), root_path_aliases(ir), &mut join_aliases); let planned_shape_values = plan_shape_values( ir.shape(), "root", @@ -422,10 +423,12 @@ impl SQLiteSelectedShapeAliases { fn plan_selected_shape_aliases( shape: &query_ir::ResolvedShape, + reserved_root_path_aliases: Vec, join_aliases: &mut SQLiteJoinAliasAllocator, ) -> SQLiteSelectedShapeAliases { let mut aliases = Vec::new(); let mut used_aliases = vec!["root".to_string()]; + used_aliases.extend(reserved_root_path_aliases); collect_selected_shape_aliases(shape, &[], &mut used_aliases, &mut aliases, join_aliases); SQLiteSelectedShapeAliases { aliases } @@ -450,10 +453,10 @@ fn collect_selected_shape_aliases( let mut child_path = shape_path.to_vec(); child_path.push(index); let preferred_alias = field.output_name(); - let sql_alias = if used_aliases + let conflicts_with_existing_alias = used_aliases .iter() - .any(|used_alias| used_alias == preferred_alias) - { + .any(|used_alias| used_alias == preferred_alias); + let sql_alias = if !shape_path.is_empty() && conflicts_with_existing_alias { join_aliases.next_alias() } else { preferred_alias.to_string() @@ -474,6 +477,82 @@ fn collect_selected_shape_aliases( } } +fn root_path_aliases(ir: &SelectQuery) -> Vec { + let mut aliases = Vec::new(); + + if let Some(filter) = ir.filter() { + collect_root_path_aliases_from_expr(filter, &mut aliases); + } + + for order in ir.order_by() { + collect_root_path_aliases_from_value(order.value(), &mut aliases); + } + + collect_root_computed_path_aliases(ir.shape(), &mut aliases); + + aliases +} + +fn collect_root_computed_path_aliases(shape: &query_ir::ResolvedShape, aliases: &mut Vec) { + for item in shape.items() { + let query_ir::ResolvedShapeItem::Computed(computed) = item else { + continue; + }; + + collect_root_path_aliases_from_value(computed.value(), aliases); + } +} + +fn collect_root_path_aliases_from_expr(expr: &Expr, aliases: &mut Vec) { + match expr { + Expr::Compare(compare) => { + collect_root_path_aliases_from_value(compare.left(), aliases); + collect_root_path_aliases_from_value(compare.right(), aliases); + } + Expr::IsNull(value) | Expr::IsNotNull(value) => { + collect_root_path_aliases_from_value(value, aliases); + } + Expr::In(in_expr) => { + collect_root_path_aliases_from_value(in_expr.left(), aliases); + for value in in_expr.right() { + collect_root_path_aliases_from_value(value, aliases); + } + } + Expr::And(left, right) | Expr::Or(left, right) => { + collect_root_path_aliases_from_expr(left, aliases); + collect_root_path_aliases_from_expr(right, aliases); + } + Expr::Not(inner) => collect_root_path_aliases_from_expr(inner, aliases), + } +} + +fn collect_root_path_aliases_from_value(value: &query_ir::ValueExpr, aliases: &mut Vec) { + match value { + query_ir::ValueExpr::Path(path) => collect_root_path_alias_from_path(path, aliases), + query_ir::ValueExpr::Literal(_) => {} + query_ir::ValueExpr::Arithmetic(arithmetic) => { + collect_root_path_aliases_from_value(arithmetic.left(), aliases); + collect_root_path_aliases_from_value(arithmetic.right(), aliases); + } + query_ir::ValueExpr::UnaryArithmetic(unary) => { + collect_root_path_aliases_from_value(unary.operand(), aliases); + } + } +} + +fn collect_root_path_alias_from_path(path: &query_ir::ResolvedPath, aliases: &mut Vec) { + let Some(first_step) = path.steps().first() else { + return; + }; + + if matches!( + first_step.kind(), + query_ir::ResolvedPathStepKind::Link { .. } + ) { + aliases.push(first_step.field().name().to_string()); + } +} + fn selected_field_column_names(shape: &query_ir::ResolvedShape) -> Vec { let mut column_names = Vec::new(); collect_selected_field_column_names(shape, false, &mut column_names); diff --git a/engine/sqlite-query-plan/src/tests/fixtures.rs b/engine/sqlite-query-plan/src/tests/fixtures.rs index 46d965f..4e9017f 100644 --- a/engine/sqlite-query-plan/src/tests/fixtures.rs +++ b/engine/sqlite-query-plan/src/tests/fixtures.rs @@ -105,6 +105,26 @@ pub fn post_author_score_path_value() -> query_ir::ValueExpr { ) } +pub fn post_best_friend_name_path_value() -> query_ir::ValueExpr { + query_ir::ValueExpr::Path( + query_ir::ResolvedPath::try_new( + post_type(), + vec![ + query_ir::ResolvedPathStep::link( + post_best_friend_field(), + user_type(), + schema_model::Cardinality::Required, + ), + query_ir::ResolvedPathStep::scalar( + user_name_field(), + schema_model::Cardinality::Required, + ), + ], + ) + .expect("post best_friend name path should be valid"), + ) +} + pub fn user_type() -> ObjectTypeRef { ObjectTypeRef::new(ObjectTypeId::new(2), "User") } diff --git a/engine/sqlite-query-plan/src/tests/mod.rs b/engine/sqlite-query-plan/src/tests/mod.rs index 4098a7a..44ba2c5 100644 --- a/engine/sqlite-query-plan/src/tests/mod.rs +++ b/engine/sqlite-query-plan/src/tests/mod.rs @@ -13,11 +13,11 @@ use fixtures::{ optional_post_author_with_best_friend_shape_field, post_author_field, post_author_name_path_value, post_author_score_path_value, post_author_shape_field, post_author_shape_field_with_id_then_name, post_author_with_best_friend_shape_field, - post_best_friend_field, post_best_friend_shape_field, post_id_path_value, post_id_shape_field, - post_query_with_shape, post_title_field, post_title_path_value, post_title_shape_field, - post_type, post_view_count_path_value, user_best_friend_score_path_value, - user_best_friend_with_best_friend_shape_field, user_name_shape_field, user_query_with_shape, - user_score_field, user_type, + post_best_friend_field, post_best_friend_name_path_value, post_best_friend_shape_field, + post_id_path_value, post_id_shape_field, post_query_with_shape, post_title_field, + post_title_path_value, post_title_shape_field, post_type, post_view_count_path_value, + user_best_friend_score_path_value, user_best_friend_with_best_friend_shape_field, + user_name_shape_field, user_query_with_shape, user_score_field, user_type, }; use query_ir::{ Literal, ResolvedComputedField, ResolvedShape, ResolvedShapeField, ResolvedShapeItem, @@ -2159,6 +2159,50 @@ fn sqlite_select_plan_uses_unique_aliases_for_repeated_nested_selected_link_name assert_eq!(second_friend_name.source_alias(), joins[1].target_alias()); } +#[test] +fn sqlite_select_plan_avoids_root_path_alias_collision_with_nested_selected_link() { + let filter = query_ir::Expr::Compare(query_ir::CompareExpr::new( + post_best_friend_name_path_value(), + query_ir::CompareOp::Eq, + query_ir::ValueExpr::Literal(Literal::String("Carol".to_string())), + )); + let ir = SelectQuery::new( + post_type(), + ResolvedShape::new( + post_type(), + vec![post_author_with_best_friend_shape_field()], + ), + Some(filter), + vec![], + None, + None, + ); + + let plan = plan_select(&ir); + let joins = plan.joins(); + + assert_eq!(joins.len(), 3); + assert_eq!(joins[0].target_alias(), "author"); + assert_eq!(joins[1].source_alias(), "author"); + assert_ne!(joins[1].target_alias(), "best_friend"); + assert_eq!(joins[2].source_alias(), "root"); + assert_eq!(joins[2].target_alias(), "best_friend"); + + assert_selected_field( + &plan.selected_values()[3], + joins[1].target_alias(), + "name", + "name", + "name", + SQLiteValueRole::Scalar, + ); + + let Some(SQLiteWhereExpr::Compare(compare)) = plan.filter() else { + panic!("filter should be a compare expression"); + }; + assert_column_value(compare.left(), "best_friend", "name"); +} + #[test] fn sqlite_select_plan_uses_left_join_for_nested_selected_link_under_optional_source() { let ir = post_query_with_shape(vec![optional_post_author_with_best_friend_shape_field()]); From 991cd76e336e0e57d8be7f1d2fefe8a5412cab94 Mon Sep 17 00:00:00 2001 From: "Kim, Hyeonseo" Date: Thu, 25 Jun 2026 17:07:35 +0900 Subject: [PATCH 4/4] Preserve multi link cardinality under nullable sources Keep Many cardinality distinct from optional source nullability so unsupported multi-link selected shapes do not get planned as single-link joins. Assisted-by: Codex:gpt-5.5 --- engine/sqlite-query-plan/src/lib.rs | 8 +++---- .../sqlite-query-plan/src/tests/fixtures.rs | 22 +++++++++++++++++++ engine/sqlite-query-plan/src/tests/mod.rs | 21 +++++++++++++----- 3 files changed, 41 insertions(+), 10 deletions(-) diff --git a/engine/sqlite-query-plan/src/lib.rs b/engine/sqlite-query-plan/src/lib.rs index 3c5a14f..5eb066a 100644 --- a/engine/sqlite-query-plan/src/lib.rs +++ b/engine/sqlite-query-plan/src/lib.rs @@ -1171,10 +1171,10 @@ fn path_step_join_cardinality( current_nullable: bool, step_cardinality: Cardinality, ) -> Cardinality { - if current_nullable { - Cardinality::Optional - } else { - step_cardinality + match (current_nullable, step_cardinality) { + (_, Cardinality::Many) => Cardinality::Many, + (true, _) => Cardinality::Optional, + (false, cardinality) => cardinality, } } diff --git a/engine/sqlite-query-plan/src/tests/fixtures.rs b/engine/sqlite-query-plan/src/tests/fixtures.rs index 4e9017f..ffee0f8 100644 --- a/engine/sqlite-query-plan/src/tests/fixtures.rs +++ b/engine/sqlite-query-plan/src/tests/fixtures.rs @@ -141,6 +141,10 @@ pub fn user_best_friend_field() -> FieldRef { FieldRef::new(FieldId::new(4), user_type(), "best_friend") } +pub fn user_posts_field() -> FieldRef { + FieldRef::new(FieldId::new(5), user_type(), "posts") +} + pub fn user_best_friend_score_path_value() -> query_ir::ValueExpr { query_ir::ValueExpr::Path( query_ir::ResolvedPath::try_new( @@ -260,6 +264,24 @@ pub fn optional_post_author_with_best_friend_shape_field() -> query_ir::Resolved ) } +pub fn optional_post_author_with_posts_shape_field() -> query_ir::ResolvedShapeField { + let posts_shape = query_ir::ResolvedShape::new(post_type(), vec![post_title_shape_field()]); + let posts = query_ir::ResolvedShapeField::new( + "posts", + user_posts_field(), + schema_model::Cardinality::Many, + Some(posts_shape), + ); + let author_shape = query_ir::ResolvedShape::new(user_type(), vec![posts]); + + query_ir::ResolvedShapeField::new( + "author", + post_author_field(), + schema_model::Cardinality::Optional, + Some(author_shape), + ) +} + pub fn post_best_friend_shape_field() -> query_ir::ResolvedShapeField { let best_friend_shape = query_ir::ResolvedShape::new(user_type(), vec![user_name_shape_field()]); diff --git a/engine/sqlite-query-plan/src/tests/mod.rs b/engine/sqlite-query-plan/src/tests/mod.rs index 44ba2c5..77e190a 100644 --- a/engine/sqlite-query-plan/src/tests/mod.rs +++ b/engine/sqlite-query-plan/src/tests/mod.rs @@ -10,12 +10,13 @@ use alloc::string::ToString; use alloc::vec; use fixtures::{ empty_post_query, optional_post_author_shape_field, - optional_post_author_with_best_friend_shape_field, post_author_field, - post_author_name_path_value, post_author_score_path_value, post_author_shape_field, - post_author_shape_field_with_id_then_name, post_author_with_best_friend_shape_field, - post_best_friend_field, post_best_friend_name_path_value, post_best_friend_shape_field, - post_id_path_value, post_id_shape_field, post_query_with_shape, post_title_field, - post_title_path_value, post_title_shape_field, post_type, post_view_count_path_value, + optional_post_author_with_best_friend_shape_field, optional_post_author_with_posts_shape_field, + post_author_field, post_author_name_path_value, post_author_score_path_value, + post_author_shape_field, post_author_shape_field_with_id_then_name, + post_author_with_best_friend_shape_field, post_best_friend_field, + post_best_friend_name_path_value, post_best_friend_shape_field, post_id_path_value, + post_id_shape_field, post_query_with_shape, post_title_field, post_title_path_value, + post_title_shape_field, post_type, post_view_count_path_value, user_best_friend_score_path_value, user_best_friend_with_best_friend_shape_field, user_name_shape_field, user_query_with_shape, user_score_field, user_type, }; @@ -2219,6 +2220,14 @@ fn sqlite_select_plan_uses_left_join_for_nested_selected_link_under_optional_sou assert_eq!(joins[1].target_alias(), "best_friend"); } +#[test] +#[should_panic(expected = "multi link joins are not supported yet")] +fn sqlite_select_plan_preserves_multi_link_cardinality_under_optional_source() { + let ir = post_query_with_shape(vec![optional_post_author_with_posts_shape_field()]); + + let _ = plan_select(&ir); +} + #[test] fn sqlite_select_plan_can_project_selected_single_link_scalar_field() { let ir = post_query_with_shape(vec![post_author_shape_field()]);