Fix SQL/frontend naming mismatches and multiline scripts
This commit is contained in:
parent
744edb8bde
commit
964a0d8308
@ -28,7 +28,7 @@ This document tracks the current state and next steps for the repository.
|
|||||||
- [x] Minimal SQL AST and parser
|
- [x] Minimal SQL AST and parser
|
||||||
- [x] Logical plan scaffolding
|
- [x] Logical plan scaffolding
|
||||||
- [x] Logical-plan execution for the first SQL slice
|
- [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] Basic multi-table SQL joins via qualified-column filtering
|
||||||
- [x] Table aliases for self-joins and qualified references
|
- [x] Table aliases for self-joins and qualified references
|
||||||
- [x] Basic `ORDER BY` support over output columns
|
- [x] Basic `ORDER BY` support over output columns
|
||||||
|
|||||||
@ -22,6 +22,8 @@ pub enum Command {
|
|||||||
|
|
||||||
pub fn parse_script(input: &str) -> Result<Vec<Command>, String> {
|
pub fn parse_script(input: &str) -> Result<Vec<Command>, String> {
|
||||||
let mut commands = Vec::new();
|
let mut commands = Vec::new();
|
||||||
|
let mut pending = String::new();
|
||||||
|
let mut start_line = 0usize;
|
||||||
|
|
||||||
for (index, raw_line) in input.lines().enumerate() {
|
for (index, raw_line) in input.lines().enumerate() {
|
||||||
let line = raw_line.trim();
|
let line = raw_line.trim();
|
||||||
@ -29,7 +31,26 @@ pub fn parse_script(input: &str) -> Result<Vec<Command>, String> {
|
|||||||
continue;
|
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);
|
commands.push(command);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -62,11 +83,15 @@ pub fn parse_command(input: &str) -> Result<Command, String> {
|
|||||||
|
|
||||||
if let Some(rest) = strip_keyword(trimmed, "schema") {
|
if let Some(rest) = strip_keyword(trimmed, "schema") {
|
||||||
let atom = parse_atom(trim_suffix(rest, '.')?)?;
|
let atom = parse_atom(trim_suffix(rest, '.')?)?;
|
||||||
|
validate_identifier(&atom.predicate, "schema table")?;
|
||||||
let columns = atom
|
let columns = atom
|
||||||
.terms
|
.terms
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|term| match term {
|
.map(|term| match term {
|
||||||
Term::Constant(name) => Ok(name),
|
Term::Constant(name) => {
|
||||||
|
validate_identifier(&name, "schema column")?;
|
||||||
|
Ok(name)
|
||||||
|
}
|
||||||
Term::Null(_) | Term::Variable(_) => {
|
Term::Null(_) | Term::Variable(_) => {
|
||||||
Err("schema columns must be constant identifiers".to_string())
|
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())
|
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> {
|
fn strip_keyword<'a>(input: &'a str, keyword: &str) -> Option<&'a str> {
|
||||||
let prefix = input.get(..keyword.len())?;
|
let prefix = input.get(..keyword.len())?;
|
||||||
if !prefix.eq_ignore_ascii_case(keyword) {
|
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));
|
return Err(format!("{} cannot be empty", label));
|
||||||
}
|
}
|
||||||
|
|
||||||
if value.chars().all(is_identifier_char) {
|
let mut chars = value.chars();
|
||||||
Ok(())
|
let Some(first) = chars.next() else {
|
||||||
} else {
|
return Err(format!("{} cannot be empty", label));
|
||||||
Err(format!("invalid {} `{}`", label, value))
|
};
|
||||||
|
|
||||||
|
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 {
|
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]
|
#[test]
|
||||||
fn parse_query_command() {
|
fn parse_query_command() {
|
||||||
let command = parse_command("query Ancestor(?X, ?Y), Parent(?Y, ?Z)?").unwrap();
|
let command = parse_command("query Ancestor(?X, ?Y), Parent(?Y, ?Z)?").unwrap();
|
||||||
|
|||||||
@ -3,7 +3,8 @@
|
|||||||
//! The current codebase primarily contains a chase-based reasoning core plus
|
//! The current codebase primarily contains a chase-based reasoning core plus
|
||||||
//! lightweight frontends for experimenting with rule-driven query answering.
|
//! lightweight frontends for experimenting with rule-driven query answering.
|
||||||
//! It also contains an early relational and SQL scaffold for a narrow
|
//! 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 catalog;
|
||||||
pub mod chase;
|
pub mod chase;
|
||||||
|
|||||||
@ -5,8 +5,9 @@
|
|||||||
//! - [`logical`]: plan and expression data structures
|
//! - [`logical`]: plan and expression data structures
|
||||||
//! - [`sql`]: translation from SQL AST into the current logical-plan subset
|
//! - [`sql`]: translation from SQL AST into the current logical-plan subset
|
||||||
//!
|
//!
|
||||||
//! At the moment this is intentionally small and only covers the first
|
//! At the moment this is intentionally small and covers the current SQL slice:
|
||||||
//! single-table SQL slice.
|
//! filtering, ordering, projection, and basic joins over predicate-backed
|
||||||
|
//! tables.
|
||||||
|
|
||||||
pub mod logical;
|
pub mod logical;
|
||||||
pub mod sql;
|
pub mod sql;
|
||||||
|
|||||||
@ -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)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct Select {
|
pub struct Select {
|
||||||
/// Output expressions requested by the query.
|
/// Output expressions requested by the query.
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
//! Minimal SQL front-end scaffolding.
|
//! Minimal SQL front-end scaffolding.
|
||||||
//!
|
//!
|
||||||
//! The current SQL layer supports a narrow `SELECT-FROM-WHERE` subset over one
|
//! The current SQL layer supports a narrow `SELECT-FROM-WHERE-ORDER BY` subset
|
||||||
//! predicate-backed table. It provides:
|
//! 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 small AST in [`ast`]
|
||||||
//! - a parser in [`parser`]
|
//! - a parser in [`parser`]
|
||||||
|
|||||||
@ -48,7 +48,7 @@ enum Token {
|
|||||||
Eq,
|
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> {
|
pub fn parse_select(input: &str) -> Result<Select, ParseError> {
|
||||||
let tokens = tokenize(input)?;
|
let tokens = tokenize(input)?;
|
||||||
let mut parser = Parser::new(tokens);
|
let mut parser = Parser::new(tokens);
|
||||||
@ -337,11 +337,11 @@ where
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn is_identifier_start(ch: char) -> bool {
|
fn is_identifier_start(ch: char) -> bool {
|
||||||
ch.is_ascii_alphabetic() || ch == '_'
|
ch.is_ascii_alphanumeric() || ch == '_'
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_identifier_part(ch: char) -> bool {
|
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 {
|
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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user