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`).
- 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.

View File

@ -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`

View File

@ -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<LogicalExpr, PlannerError> {
fn plan_expr(
expr: &Expr,
schema: &Schema,
tables: &[TableRef],
) -> Result<LogicalExpr, PlannerError> {
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<LogicalPlan, PlannerError> {
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<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 {
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(

View File

@ -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![