diff --git a/engine/sqlite-query-plan/src/lib.rs b/engine/sqlite-query-plan/src/lib.rs index bfe1165..5eb066a 100644 --- a/engine/sqlite-query-plan/src/lib.rs +++ b/engine/sqlite-query-plan/src/lib.rs @@ -33,16 +33,27 @@ 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(), root_path_aliases(ir), &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,13 +77,8 @@ 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, &[], &selected_shape_aliases); joins.extend(planned_shape_values.joins); joins.extend(filter_joins); @@ -395,6 +401,158 @@ 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, + 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 } +} + +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 conflicts_with_existing_alias = used_aliases + .iter() + .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() + }; + + 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 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); @@ -443,17 +601,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(), @@ -470,6 +632,8 @@ fn plan_shape_values( child_shape, nested_alias, source_nullable || field.cardinality() == Cardinality::Optional, + &child_path, + selected_shape_aliases, computed_aliases, join_aliases, ); @@ -502,28 +666,80 @@ fn plan_shape_values( PlannedShapeValues { values, joins } } +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 (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 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_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, + )); + } + + joins +} + 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(), @@ -955,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, } } @@ -1292,7 +1508,25 @@ 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 { + 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() .expect("selected link field must have child shape"); @@ -1300,18 +1534,18 @@ 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() .to_ascii_lowercase() .to_string(), - target_alias: shape_field.output_name().to_string(), + target_alias: target_alias.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_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 8926929..ffee0f8 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") } @@ -121,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( @@ -190,6 +214,74 @@ 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 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(), + 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 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()]); @@ -246,3 +338,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 b8e9092..77e190a 100644 --- a/engine/sqlite-query-plan/src/tests/mod.rs +++ b/engine/sqlite-query-plan/src/tests/mod.rs @@ -9,13 +9,16 @@ use alloc::boxed::Box; use alloc::string::ToString; use alloc::vec; use fixtures::{ - empty_post_query, optional_post_author_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, + empty_post_query, optional_post_author_shape_field, + 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, }; use query_ir::{ Literal, ResolvedComputedField, ResolvedShape, ResolvedShapeField, ResolvedShapeItem, @@ -2059,6 +2062,172 @@ 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_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_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()]); + + 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] +#[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()]); diff --git a/tests/query-pipeline/tests/select_execution.rs b/tests/query-pipeline/tests/select_execution.rs index a500e40..381fa6b 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,37 @@ 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-3".to_string()), + SQLiteValuePlan::Text("carol@example.com".to_string()), + SQLiteValuePlan::Integer(50), + SQLiteValuePlan::Null, ], ) - .expect("first user fixture row should insert"); + .expect("third 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-2".to_string()), SQLiteValuePlan::Text("blocked@example.com".to_string()), SQLiteValuePlan::Integer(0), + SQLiteValuePlan::Text("user-3".to_string()), ], ) .expect("second user fixture row should insert"); + runner + .execute_with_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()), + ], + ) + .expect("first user fixture row should insert"); runner .execute_with_values( "INSERT INTO post (id, title, view_count, author_id) VALUES (?, ?, ?, ?)", @@ -275,6 +289,80 @@ 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_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 =