From 7111a682ffd6138295b1f4e7d3b077a304ae07a9 Mon Sep 17 00:00:00 2001 From: Hassan Abedi Date: Fri, 10 Apr 2026 12:56:24 +0200 Subject: [PATCH] Fix single-table aliases and empty schema tables --- AGENTS.md | 2 +- README.md | 6 ++++- src/frontend/session.rs | 56 +++++++++++++++++++++++++++++++++++++---- src/planner/sql.rs | 32 +++++++++++++++++++++-- 4 files changed, 87 insertions(+), 9 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6bc4a62..2da0960 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -72,7 +72,7 @@ Quick examples: - New chase variants should be composable with existing infrastructure. - 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; 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`. - 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 870c1a4..dfe30f7 100644 --- a/README.md +++ b/README.md @@ -137,8 +137,12 @@ In the REPL or script runner, use the `sql` command and end the statement with sql SELECT c0 FROM Parent WHERE c1 = 'bob'; ``` +`fact`, `rule`, `schema`, `sql`, `query`, and `explain` commands may also span +multiple lines in `.ech` scripts as long as the final line ends with the normal +terminator. + You can also register stable column names for a predicate-backed table in the -frontend before running SQL: +frontend before running SQL, including tables that currently have no facts: ```text schema Parent(parent, child). diff --git a/src/frontend/session.rs b/src/frontend/session.rs index abeaeec..21875e4 100644 --- a/src/frontend/session.rs +++ b/src/frontend/session.rs @@ -3,13 +3,13 @@ use std::collections::HashMap; use std::fmt; -use crate::catalog::PredicateCatalog; +use crate::catalog::{CatalogError, PredicateCatalog}; use crate::chase::{ Atom, Instance, MaterializedState, Rule, Substitution, find_matches, materialize, }; use crate::execution::execute; use crate::planner::sql::plan_select; -use crate::relational::ResultSet; +use crate::relational::{DataType, Field, ResultSet, Schema}; use super::language::{Command, parse_script}; use super::provenance::explain_atom; @@ -143,9 +143,24 @@ impl Session { let mut catalog = PredicateCatalog::from_instance(instance).map_err(|err| err.to_string())?; for (table, columns) in &self.column_names { - catalog - .rename_columns(table, columns.clone()) - .map_err(|err| err.to_string())?; + match catalog.schema_for(table) { + Ok(_) => { + catalog + .rename_columns(table, columns.clone()) + .map_err(|err| err.to_string())?; + } + Err(CatalogError::UnknownTable(_)) => { + let schema = Schema::new( + columns + .iter() + .cloned() + .map(|name| Field::new(name, DataType::Text, false)) + .collect(), + ); + catalog.register_table(table.clone(), schema); + } + Err(err) => return Err(err.to_string()), + } } let plan = plan_select(select, &catalog).map_err(|err| err.to_string())?; let result = execute(&plan, instance).map_err(|err| err.to_string())?; @@ -401,6 +416,21 @@ mod tests { assert!(output.contains("alice")); } + #[test] + fn session_runs_sql_query_with_declared_empty_table() { + let mut session = Session::new(); + let output = session + .execute_script( + "schema Parent(parent, child).\n\ + sql SELECT parent FROM Parent;", + ) + .unwrap(); + + assert!(output.contains("Registered schema for Parent: parent, child")); + assert!(output.contains("0 row(s)")); + assert!(output.contains("parent")); + } + #[test] fn session_runs_sql_join_query() { let mut session = Session::new(); @@ -443,6 +473,22 @@ mod tests { assert!(output.contains("bob | dave")); } + #[test] + fn session_runs_sql_query_with_single_table_alias() { + let mut session = Session::new(); + let output = session + .execute_script( + "fact Parent(alice, bob).\n\ + schema Parent(parent, child).\n\ + sql SELECT p.parent FROM Parent AS p WHERE p.child = 'bob';", + ) + .unwrap(); + + assert!(output.contains("1 row(s)")); + assert!(output.contains("p.parent")); + assert!(output.contains("alice")); + } + #[test] fn session_runs_sql_query_with_and_filter() { let mut session = Session::new(); diff --git a/src/planner/sql.rs b/src/planner/sql.rs index 3564181..d7c5846 100644 --- a/src/planner/sql.rs +++ b/src/planner/sql.rs @@ -125,7 +125,8 @@ fn plan_from_tables( return Err(PlannerError::DuplicateSourceName(first_name)); } - let first_schema = input_schema_for_table(first, catalog, tables.len() > 1)?; + let first_schema = + input_schema_for_table(first, catalog, should_qualify_columns(first, tables))?; let mut plan = LogicalPlan::Scan { table: first.name.clone(), schema: first_schema.clone(), @@ -138,7 +139,8 @@ fn plan_from_tables( return Err(PlannerError::DuplicateSourceName(qualified_name)); } - let right_schema = input_schema_for_table(table, catalog, tables.len() > 1)?; + let right_schema = + input_schema_for_table(table, catalog, should_qualify_columns(table, tables))?; let join_schema = combine_schemas(&combined_schema, &right_schema); let right_plan = LogicalPlan::Scan { table: table.name.clone(), @@ -266,6 +268,10 @@ fn input_schema_for_table( Ok(Schema::new(fields)) } +fn should_qualify_columns(table: &TableRef, tables: &[TableRef]) -> bool { + table.alias.is_some() || tables.len() > 1 +} + fn source_name(table: &TableRef) -> String { table.alias.clone().unwrap_or_else(|| table.name.clone()) } @@ -386,6 +392,28 @@ mod tests { assert_eq!(schema.fields()[1].name(), "q.child"); } + #[test] + fn plans_single_table_with_alias() { + 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 p.parent FROM Parent AS p WHERE p.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(), "p.parent"); + } + #[test] fn plans_conjunctive_filter() { let instance: Instance = vec![Atom::new(