Support qualified table names in single-table SQL queries

This commit is contained in:
Hassan Abedi 2026-04-10 13:01:56 +02:00
parent 7c4cb70047
commit be8e1388bc
4 changed files with 89 additions and 19 deletions

View File

@ -73,6 +73,7 @@ Quick examples:
- Existential variables generate labeled nulls (`Term::Null`). - 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. - 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`. - 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. - 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. - 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. - If you add parser, planner, or executor layers, keep their responsibilities separate.

View File

@ -168,6 +168,7 @@ Current limits:
- default column names are positional such as `c0`, `c1` - default column names are positional such as `c0`, `c1`
- stable names require explicit catalog registration or `schema ...` in the frontend - 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 - joins currently use comma-separated tables plus `WHERE` filtering
- multi-table queries require qualified column names such as `Parent.child` - multi-table queries require qualified column names such as `Parent.child`
- table aliases are supported via `FROM Parent AS p` - table aliases are supported via `FROM Parent AS p`

View File

@ -73,7 +73,7 @@ pub fn plan_select(
let (mut plan, input_schema) = plan_from_tables(&select.from, catalog)?; let (mut plan, input_schema) = plan_from_tables(&select.from, catalog)?;
if let Some(selection) = &select.selection { 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 { plan = LogicalPlan::Filter {
input: Box::new(plan), input: Box::new(plan),
predicate, predicate,
@ -82,7 +82,7 @@ pub fn plan_select(
if is_wildcard_projection(&select.projection) { if is_wildcard_projection(&select.projection) {
let output_schema = plan.output_schema().clone(); 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(); let mut expressions = Vec::new();
@ -90,11 +90,11 @@ pub fn plan_select(
for (index, item) in select.projection.iter().enumerate() { for (index, item) in select.projection.iter().enumerate() {
match item { match item {
SelectItem::Expr { expr, alias } => { 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 let output_name = alias
.clone() .clone()
.unwrap_or_else(|| default_projection_name(expr, index + 1)); .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 { expressions.push(NamedExpr {
name: output_name.clone(), name: output_name.clone(),
expr: planned_expr, expr: planned_expr,
@ -112,7 +112,7 @@ pub fn plan_select(
}; };
let output_schema = plan.output_schema().clone(); 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 { fn is_wildcard_projection(items: &[SelectItem]) -> bool {
@ -166,23 +166,25 @@ fn plan_from_tables(
Ok((plan, combined_schema)) Ok((plan, combined_schema))
} }
fn plan_expr(expr: &Expr, schema: &Schema) -> Result<LogicalExpr, PlannerError> { fn plan_expr(
expr: &Expr,
schema: &Schema,
tables: &[TableRef],
) -> Result<LogicalExpr, PlannerError> {
match expr { match expr {
Expr::Identifier(name) => { Expr::Identifier(name) => {
if schema.index_of(name).is_none() { let resolved = resolve_column_name(name, schema, tables)?;
return Err(PlannerError::UnknownColumn(name.clone())); Ok(LogicalExpr::Column(resolved))
}
Ok(LogicalExpr::Column(name.clone()))
} }
Expr::Literal(literal) => Ok(LogicalExpr::Literal(plan_literal(literal))), Expr::Literal(literal) => Ok(LogicalExpr::Literal(plan_literal(literal))),
Expr::Binary { left, op, right } => match op { Expr::Binary { left, op, right } => match op {
BinaryOp::Eq => Ok(LogicalExpr::Eq( BinaryOp::Eq => Ok(LogicalExpr::Eq(
Box::new(plan_expr(left, schema)?), Box::new(plan_expr(left, schema, tables)?),
Box::new(plan_expr(right, schema)?), Box::new(plan_expr(right, schema, tables)?),
)), )),
BinaryOp::And => Ok(LogicalExpr::And( BinaryOp::And => Ok(LogicalExpr::And(
Box::new(plan_expr(left, schema)?), Box::new(plan_expr(left, schema, tables)?),
Box::new(plan_expr(right, schema)?), Box::new(plan_expr(right, schema, tables)?),
)), )),
}, },
} }
@ -192,6 +194,7 @@ fn maybe_apply_sort(
plan: LogicalPlan, plan: LogicalPlan,
schema: Schema, schema: Schema,
order_by: &[OrderByItem], order_by: &[OrderByItem],
tables: &[TableRef],
) -> Result<LogicalPlan, PlannerError> { ) -> Result<LogicalPlan, PlannerError> {
if order_by.is_empty() { if order_by.is_empty() {
return Ok(plan); return Ok(plan);
@ -203,9 +206,7 @@ fn maybe_apply_sort(
Expr::Identifier(name) => name.clone(), Expr::Identifier(name) => name.clone(),
_ => return Err(PlannerError::UnsupportedOrderBy), _ => return Err(PlannerError::UnsupportedOrderBy),
}; };
if schema.index_of(&column).is_none() { let column = resolve_column_name(&column, &schema, tables)?;
return Err(PlannerError::UnknownColumn(column));
}
keys.push(SortKey { keys.push(SortKey {
column, column,
direction: match item.direction { 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 { match expr {
Expr::Identifier(name) => { Expr::Identifier(name) => {
let resolved = resolve_column_name(name, schema, tables)?;
let index = schema let index = schema
.index_of(name) .index_of(&resolved)
.ok_or_else(|| PlannerError::UnknownColumn(name.clone()))?; .ok_or_else(|| PlannerError::UnknownColumn(name.clone()))?;
let field = &schema.fields()[index]; let field = &schema.fields()[index];
Ok((field.data_type().clone(), field.nullable())) 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<String, PlannerError> {
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 { fn default_projection_name(expr: &Expr, ordinal: usize) -> String {
match expr { match expr {
Expr::Identifier(name) => name.clone(), Expr::Identifier(name) => name.clone(),
@ -423,6 +450,28 @@ mod tests {
assert_eq!(schema.fields()[0].name(), "p.parent"); 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] #[test]
fn plans_conjunctive_filter() { fn plans_conjunctive_filter() {
let instance: Instance = vec![Atom::new( let instance: Instance = vec![Atom::new(

View File

@ -101,6 +101,25 @@ fn select_uses_explicit_catalog_column_names() {
assert_eq!(format!("{}", result.rows()[0].values()[0]), "alice"); 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] #[test]
fn select_join_filters_cross_product_by_qualified_columns() { fn select_join_filters_cross_product_by_qualified_columns() {
let instance: Instance = vec![ let instance: Instance = vec![