Fix SQL/frontend naming mismatches and multiline scripts

This commit is contained in:
Hassan Abedi 2026-04-10 10:56:44 +02:00
parent 744edb8bde
commit 964a0d8308
7 changed files with 99 additions and 16 deletions

View File

@ -28,7 +28,7 @@ This document tracks the current state and next steps for the repository.
- [x] Minimal SQL AST and parser
- [x] Logical plan scaffolding
- [x] Logical-plan execution for the first SQL slice
- [x] `SELECT-FROM-WHERE` support with positional or named columns
- [x] `SELECT-FROM-WHERE-ORDER BY` support with positional or named columns
- [x] Basic multi-table SQL joins via qualified-column filtering
- [x] Table aliases for self-joins and qualified references
- [x] Basic `ORDER BY` support over output columns

View File

@ -22,6 +22,8 @@ pub enum Command {
pub fn parse_script(input: &str) -> Result<Vec<Command>, String> {
let mut commands = Vec::new();
let mut pending = String::new();
let mut start_line = 0usize;
for (index, raw_line) in input.lines().enumerate() {
let line = raw_line.trim();
@ -29,7 +31,26 @@ pub fn parse_script(input: &str) -> Result<Vec<Command>, String> {
continue;
}
let command = parse_command(line).map_err(|err| format!("line {}: {}", index + 1, err))?;
if pending.is_empty() {
start_line = index + 1;
} else {
pending.push(' ');
}
pending.push_str(line);
if !command_is_complete(&pending) {
continue;
}
let command =
parse_command(&pending).map_err(|err| format!("line {}: {}", start_line, err))?;
commands.push(command);
pending.clear();
}
if !pending.is_empty() {
let command =
parse_command(&pending).map_err(|err| format!("line {}: {}", start_line, err))?;
commands.push(command);
}
@ -62,11 +83,15 @@ pub fn parse_command(input: &str) -> Result<Command, String> {
if let Some(rest) = strip_keyword(trimmed, "schema") {
let atom = parse_atom(trim_suffix(rest, '.')?)?;
validate_identifier(&atom.predicate, "schema table")?;
let columns = atom
.terms
.into_iter()
.map(|term| match term {
Term::Constant(name) => Ok(name),
Term::Constant(name) => {
validate_identifier(&name, "schema column")?;
Ok(name)
}
Term::Null(_) | Term::Variable(_) => {
Err("schema columns must be constant identifiers".to_string())
}
@ -121,6 +146,18 @@ pub fn parse_command(input: &str) -> Result<Command, String> {
Err("unknown command; try `help`".to_string())
}
fn command_is_complete(input: &str) -> bool {
let trimmed = input.trim();
trimmed.ends_with('.')
|| trimmed.ends_with(';')
|| trimmed.ends_with('?')
|| trimmed.eq_ignore_ascii_case("run")
|| trimmed.eq_ignore_ascii_case("show facts")
|| trimmed.eq_ignore_ascii_case("show rules")
|| trimmed.eq_ignore_ascii_case("reset")
|| trimmed.eq_ignore_ascii_case("help")
}
fn strip_keyword<'a>(input: &'a str, keyword: &str) -> Option<&'a str> {
let prefix = input.get(..keyword.len())?;
if !prefix.eq_ignore_ascii_case(keyword) {
@ -258,11 +295,20 @@ fn validate_identifier(value: &str, label: &str) -> Result<(), String> {
return Err(format!("{} cannot be empty", label));
}
if value.chars().all(is_identifier_char) {
Ok(())
} else {
Err(format!("invalid {} `{}`", label, value))
let mut chars = value.chars();
let Some(first) = chars.next() else {
return Err(format!("{} cannot be empty", label));
};
if !is_identifier_start_char(first) || !chars.all(is_identifier_char) {
return Err(format!("invalid {} `{}`", label, value));
}
Ok(())
}
fn is_identifier_start_char(ch: char) -> bool {
ch.is_ascii_alphanumeric() || ch == '_'
}
fn is_identifier_char(ch: char) -> bool {
@ -475,6 +521,32 @@ mod tests {
}
}
#[test]
fn parse_schema_command_rejects_non_identifier_columns() {
let error = parse_command(r#"schema Parent("given name", child)."#).unwrap_err();
assert_eq!(error, "invalid schema column `given name`");
}
#[test]
fn parse_script_supports_multiline_sql() {
let script = "fact Parent(alice, bob).\n\
fact Parent(bob, carol).\n\
schema Parent(parent, child).\n\
sql SELECT parent\n\
FROM Parent\n\
WHERE child = 'bob';";
let commands = parse_script(script).unwrap();
assert_eq!(commands.len(), 4);
match &commands[3] {
Command::Sql(select) => {
assert_eq!(select.from.len(), 1);
assert_eq!(select.from[0].name, "Parent");
}
other => panic!("unexpected command: {:?}", other),
}
}
#[test]
fn parse_query_command() {
let command = parse_command("query Ancestor(?X, ?Y), Parent(?Y, ?Z)?").unwrap();

View File

@ -3,7 +3,8 @@
//! The current codebase primarily contains a chase-based reasoning core plus
//! lightweight frontends for experimenting with rule-driven query answering.
//! It also contains an early relational and SQL scaffold for a narrow
//! single-table `SELECT-FROM-WHERE` slice. It is not yet a full SQL engine.
//! `SELECT-FROM-WHERE-ORDER BY` slice with basic joins, aliases, and
//! conjunctions. It is not yet a full SQL engine.
pub mod catalog;
pub mod chase;

View File

@ -5,8 +5,9 @@
//! - [`logical`]: plan and expression data structures
//! - [`sql`]: translation from SQL AST into the current logical-plan subset
//!
//! At the moment this is intentionally small and only covers the first
//! single-table SQL slice.
//! At the moment this is intentionally small and covers the current SQL slice:
//! filtering, ordering, projection, and basic joins over predicate-backed
//! tables.
pub mod logical;
pub mod sql;

View File

@ -1,4 +1,4 @@
/// A parsed `SELECT-FROM-WHERE` statement in the current SQL subset.
/// A parsed `SELECT-FROM-WHERE-ORDER BY` statement in the current SQL subset.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Select {
/// Output expressions requested by the query.

View File

@ -1,7 +1,8 @@
//! Minimal SQL front-end scaffolding.
//!
//! The current SQL layer supports a narrow `SELECT-FROM-WHERE` subset over one
//! predicate-backed table. It provides:
//! The current SQL layer supports a narrow `SELECT-FROM-WHERE-ORDER BY` subset
//! over predicate-backed tables, including comma-join style multi-table
//! queries, table aliases, and `AND` in filter predicates. It provides:
//!
//! - a small AST in [`ast`]
//! - a parser in [`parser`]

View File

@ -48,7 +48,7 @@ enum Token {
Eq,
}
/// Parse a `SELECT-FROM-WHERE` query in the current SQL subset.
/// Parse a `SELECT-FROM-WHERE-ORDER BY` query in the current SQL subset.
pub fn parse_select(input: &str) -> Result<Select, ParseError> {
let tokens = tokenize(input)?;
let mut parser = Parser::new(tokens);
@ -337,11 +337,11 @@ where
}
fn is_identifier_start(ch: char) -> bool {
ch.is_ascii_alphabetic() || ch == '_'
ch.is_ascii_alphanumeric() || ch == '_'
}
fn is_identifier_part(ch: char) -> bool {
ch.is_ascii_alphanumeric() || matches!(ch, '_' | '.')
ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | ':' | '.')
}
fn render_token(token: &Token) -> String {
@ -507,4 +507,12 @@ mod tests {
]
);
}
#[test]
fn parses_frontend_style_identifiers() {
let select = parse_select("SELECT * FROM Employee-Records:2025").unwrap();
assert_eq!(select.from.len(), 1);
assert_eq!(select.from[0].name, "Employee-Records:2025");
}
}