From be8e1388bcad8711d5c47df2b803755d1df42921 Mon Sep 17 00:00:00 2001 From: Hassan Abedi Date: Fri, 10 Apr 2026 13:01:56 +0200 Subject: [PATCH] Support qualified table names in single-table SQL queries --- AGENTS.md | 1 + README.md | 1 + src/planner/sql.rs | 87 +++++++++++++++++++++++++++++-------- tests/sql_pipeline_tests.rs | 19 ++++++++ 4 files changed, 89 insertions(+), 19 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2da0960..daac1f4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -73,6 +73,7 @@ Quick examples: - Existential variables generate labeled nulls (`Term::Null`). - The current SQL support is intentionally narrow: `SELECT-FROM-WHERE-ORDER BY` over predicate-backed tables, equality predicates combined with `AND`, comma-join style multi-table queries, table aliases, and ordering by output-column names. - Stable SQL column names come from explicit catalog registration or the frontend `schema ...` command, including for empty tables; otherwise the default names are positional such as `c0` and `c1`. +- Single-table SQL queries may use the table name as a qualifier when no alias is present. - Do not describe unsupported SQL features such as aggregates, grouping, or arbitrary expressions as implemented. - Relational and SQL modules should build on explicit schemas and logical plans, not call frontend helpers directly. - If you add parser, planner, or executor layers, keep their responsibilities separate. diff --git a/README.md b/README.md index dfe30f7..026aa8f 100644 --- a/README.md +++ b/README.md @@ -168,6 +168,7 @@ Current limits: - default column names are positional such as `c0`, `c1` - stable names require explicit catalog registration or `schema ...` in the frontend +- single-table queries may also use the table name as a qualifier when no alias is present - joins currently use comma-separated tables plus `WHERE` filtering - multi-table queries require qualified column names such as `Parent.child` - table aliases are supported via `FROM Parent AS p` diff --git a/src/planner/sql.rs b/src/planner/sql.rs index 7151b06..8a30e55 100644 --- a/src/planner/sql.rs +++ b/src/planner/sql.rs @@ -73,7 +73,7 @@ pub fn plan_select( let (mut plan, input_schema) = plan_from_tables(&select.from, catalog)?; if let Some(selection) = &select.selection { - let predicate = plan_expr(selection, &input_schema)?; + let predicate = plan_expr(selection, &input_schema, &select.from)?; plan = LogicalPlan::Filter { input: Box::new(plan), predicate, @@ -82,7 +82,7 @@ pub fn plan_select( if is_wildcard_projection(&select.projection) { let output_schema = plan.output_schema().clone(); - return maybe_apply_sort(plan, output_schema, &select.order_by); + return maybe_apply_sort(plan, output_schema, &select.order_by, &select.from); } let mut expressions = Vec::new(); @@ -90,11 +90,11 @@ pub fn plan_select( for (index, item) in select.projection.iter().enumerate() { match item { SelectItem::Expr { expr, alias } => { - let planned_expr = plan_expr(expr, &input_schema)?; + let planned_expr = plan_expr(expr, &input_schema, &select.from)?; let output_name = alias .clone() .unwrap_or_else(|| default_projection_name(expr, index + 1)); - let (data_type, nullable) = projection_metadata(expr, &input_schema)?; + let (data_type, nullable) = projection_metadata(expr, &input_schema, &select.from)?; expressions.push(NamedExpr { name: output_name.clone(), expr: planned_expr, @@ -112,7 +112,7 @@ pub fn plan_select( }; let output_schema = plan.output_schema().clone(); - maybe_apply_sort(plan, output_schema, &select.order_by) + maybe_apply_sort(plan, output_schema, &select.order_by, &select.from) } fn is_wildcard_projection(items: &[SelectItem]) -> bool { @@ -166,23 +166,25 @@ fn plan_from_tables( Ok((plan, combined_schema)) } -fn plan_expr(expr: &Expr, schema: &Schema) -> Result { +fn plan_expr( + expr: &Expr, + schema: &Schema, + tables: &[TableRef], +) -> Result { match expr { Expr::Identifier(name) => { - if schema.index_of(name).is_none() { - return Err(PlannerError::UnknownColumn(name.clone())); - } - Ok(LogicalExpr::Column(name.clone())) + let resolved = resolve_column_name(name, schema, tables)?; + Ok(LogicalExpr::Column(resolved)) } Expr::Literal(literal) => Ok(LogicalExpr::Literal(plan_literal(literal))), Expr::Binary { left, op, right } => match op { BinaryOp::Eq => Ok(LogicalExpr::Eq( - Box::new(plan_expr(left, schema)?), - Box::new(plan_expr(right, schema)?), + Box::new(plan_expr(left, schema, tables)?), + Box::new(plan_expr(right, schema, tables)?), )), BinaryOp::And => Ok(LogicalExpr::And( - Box::new(plan_expr(left, schema)?), - Box::new(plan_expr(right, schema)?), + Box::new(plan_expr(left, schema, tables)?), + Box::new(plan_expr(right, schema, tables)?), )), }, } @@ -192,6 +194,7 @@ fn maybe_apply_sort( plan: LogicalPlan, schema: Schema, order_by: &[OrderByItem], + tables: &[TableRef], ) -> Result { if order_by.is_empty() { return Ok(plan); @@ -203,9 +206,7 @@ fn maybe_apply_sort( Expr::Identifier(name) => name.clone(), _ => return Err(PlannerError::UnsupportedOrderBy), }; - if schema.index_of(&column).is_none() { - return Err(PlannerError::UnknownColumn(column)); - } + let column = resolve_column_name(&column, &schema, tables)?; keys.push(SortKey { column, direction: match item.direction { @@ -229,11 +230,16 @@ fn plan_literal(literal: &Literal) -> Value { } } -fn projection_metadata(expr: &Expr, schema: &Schema) -> Result<(DataType, bool), PlannerError> { +fn projection_metadata( + expr: &Expr, + schema: &Schema, + tables: &[TableRef], +) -> Result<(DataType, bool), PlannerError> { match expr { Expr::Identifier(name) => { + let resolved = resolve_column_name(name, schema, tables)?; let index = schema - .index_of(name) + .index_of(&resolved) .ok_or_else(|| PlannerError::UnknownColumn(name.clone()))?; let field = &schema.fields()[index]; Ok((field.data_type().clone(), field.nullable())) @@ -244,6 +250,27 @@ fn projection_metadata(expr: &Expr, schema: &Schema) -> Result<(DataType, bool), } } +fn resolve_column_name( + name: &str, + schema: &Schema, + tables: &[TableRef], +) -> Result { + if schema.index_of(name).is_some() { + return Ok(name.to_string()); + } + + if let Some((table_name, column_name)) = name.rsplit_once('.') + && tables.len() == 1 + && tables[0].alias.is_none() + && tables[0].name == table_name + && schema.index_of(column_name).is_some() + { + return Ok(column_name.to_string()); + } + + Err(PlannerError::UnknownColumn(name.to_string())) +} + fn default_projection_name(expr: &Expr, ordinal: usize) -> String { match expr { Expr::Identifier(name) => name.clone(), @@ -423,6 +450,28 @@ mod tests { assert_eq!(schema.fields()[0].name(), "p.parent"); } + #[test] + fn plans_single_table_with_qualified_table_name() { + let instance: Instance = vec![Atom::new( + "Parent", + vec![Term::constant("alice"), Term::constant("bob")], + )] + .into_iter() + .collect(); + let mut catalog = PredicateCatalog::from_instance(&instance).unwrap(); + catalog + .rename_columns("Parent", ["parent", "child"]) + .unwrap(); + + let select = + parse_select("SELECT Parent.parent FROM Parent WHERE Parent.child = 'bob'").unwrap(); + + let plan = plan_select(&select, &catalog).unwrap(); + let schema = plan.output_schema(); + assert_eq!(schema.len(), 1); + assert_eq!(schema.fields()[0].name(), "Parent.parent"); + } + #[test] fn plans_conjunctive_filter() { let instance: Instance = vec![Atom::new( diff --git a/tests/sql_pipeline_tests.rs b/tests/sql_pipeline_tests.rs index 0e12bf7..645fe99 100644 --- a/tests/sql_pipeline_tests.rs +++ b/tests/sql_pipeline_tests.rs @@ -101,6 +101,25 @@ fn select_uses_explicit_catalog_column_names() { assert_eq!(format!("{}", result.rows()[0].values()[0]), "alice"); } +#[test] +fn select_uses_qualified_table_name_in_single_table_query() { + let instance = parent_instance(); + let mut catalog = PredicateCatalog::from_instance(&instance).unwrap(); + catalog + .rename_columns("Parent", ["parent", "child"]) + .unwrap(); + + let select = + parse_select("SELECT Parent.parent FROM Parent WHERE Parent.child = 'bob'").unwrap(); + + let plan = plan_select(&select, &catalog).unwrap(); + let result = execute(&plan, &instance).unwrap(); + + assert_eq!(result.schema().fields()[0].name(), "Parent.parent"); + assert_eq!(result.rows().len(), 1); + assert_eq!(format!("{}", result.rows()[0].values()[0]), "alice"); +} + #[test] fn select_join_filters_cross_product_by_qualified_columns() { let instance: Instance = vec![