diff --git a/README.md b/README.md index 72612e5..33f6f8d 100644 --- a/README.md +++ b/README.md @@ -118,6 +118,7 @@ Currently supported examples: SELECT * FROM Parent SELECT c0 FROM Parent SELECT c0 FROM Parent WHERE c1 = 'bob' +SELECT c0 FROM Parent WHERE c1 = 'bob' AND c0 = 'alice' SELECT c0 AS parent_name, 'seed' AS label FROM Parent SELECT Parent.parent, Ancestor.child FROM Parent, Ancestor @@ -164,6 +165,7 @@ Current limits: - 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` +- `WHERE` supports equality predicates combined with `AND` - no aggregates - projection aliases only via `AS` diff --git a/src/execution/mod.rs b/src/execution/mod.rs index 3c74649..a30f41c 100644 --- a/src/execution/mod.rs +++ b/src/execution/mod.rs @@ -101,6 +101,9 @@ fn eval_predicate( LogicalExpr::Eq(left, right) => Ok(eval_expr(left, row, schema)? .sql_eq(&eval_expr(right, row, schema)?) .unwrap_or(false)), + LogicalExpr::And(left, right) => { + Ok(eval_predicate(left, row, schema)? && eval_predicate(right, row, schema)?) + } _ => Ok(false), } } @@ -123,6 +126,9 @@ fn eval_expr( let right = eval_expr(right, row, schema)?; Ok(Value::Boolean(left.sql_eq(&right).unwrap_or(false))) } + LogicalExpr::And(left, right) => Ok(Value::Boolean( + eval_predicate(left, row, schema)? && eval_predicate(right, row, schema)?, + )), } } diff --git a/src/frontend/language.rs b/src/frontend/language.rs index 8aef025..a99ecb6 100644 --- a/src/frontend/language.rs +++ b/src/frontend/language.rs @@ -440,6 +440,18 @@ mod tests { } } + #[test] + fn parse_sql_command_with_and_filter() { + let command = + parse_command("sql SELECT c0 FROM Parent WHERE c1 = 'bob' AND c0 = 'alice';").unwrap(); + match command { + Command::Sql(select) => { + assert!(select.selection.is_some()); + } + other => panic!("unexpected command: {:?}", other), + } + } + #[test] fn parse_schema_command() { let command = parse_command("schema Parent(parent, child).").unwrap(); diff --git a/src/frontend/session.rs b/src/frontend/session.rs index 4048b83..aa1d979 100644 --- a/src/frontend/session.rs +++ b/src/frontend/session.rs @@ -443,4 +443,19 @@ mod tests { assert!(output.contains("alice | carol")); assert!(output.contains("bob | dave")); } + + #[test] + fn session_runs_sql_query_with_and_filter() { + let mut session = Session::new(); + let output = session + .execute_script( + "fact Parent(alice, bob).\n\ + fact Parent(bob, carol).\n\ + sql SELECT c0 FROM Parent WHERE c1 = 'bob' AND c0 = 'alice';", + ) + .unwrap(); + + assert!(output.contains("1 row(s)")); + assert!(output.contains("alice")); + } } diff --git a/src/planner/logical.rs b/src/planner/logical.rs index a808729..d4e8f0e 100644 --- a/src/planner/logical.rs +++ b/src/planner/logical.rs @@ -9,6 +9,8 @@ pub enum LogicalExpr { Literal(Value), /// Equality. Eq(Box, Box), + /// Boolean conjunction. + And(Box, Box), } /// A named output expression in a projection. diff --git a/src/planner/sql.rs b/src/planner/sql.rs index 3f514ce..dcbe40b 100644 --- a/src/planner/sql.rs +++ b/src/planner/sql.rs @@ -154,6 +154,10 @@ fn plan_expr(expr: &Expr, schema: &Schema) -> Result Box::new(plan_expr(left, schema)?), Box::new(plan_expr(right, schema)?), )), + BinaryOp::And => Ok(LogicalExpr::And( + Box::new(plan_expr(left, schema)?), + Box::new(plan_expr(right, schema)?), + )), }, } } @@ -332,4 +336,28 @@ mod tests { assert_eq!(schema.fields()[0].name(), "p.parent"); assert_eq!(schema.fields()[1].name(), "q.child"); } + + #[test] + fn plans_conjunctive_filter() { + let instance: Instance = vec![Atom::new( + "Parent", + vec![Term::constant("alice"), Term::constant("bob")], + )] + .into_iter() + .collect(); + let catalog = PredicateCatalog::from_instance(&instance).unwrap(); + let select = + parse_select("SELECT c0 FROM Parent WHERE c1 = 'bob' AND c0 = 'alice'").unwrap(); + + let plan = plan_select(&select, &catalog).unwrap(); + match plan { + LogicalPlan::Project { input, .. } => match *input { + LogicalPlan::Filter { predicate, .. } => { + assert!(matches!(predicate, LogicalExpr::And(_, _))); + } + other => panic!("unexpected input plan: {:?}", other), + }, + other => panic!("unexpected plan: {:?}", other), + } + } } diff --git a/src/sql/ast.rs b/src/sql/ast.rs index 57c7684..01e0269 100644 --- a/src/sql/ast.rs +++ b/src/sql/ast.rs @@ -56,4 +56,6 @@ pub enum Literal { pub enum BinaryOp { /// Equality. Eq, + /// Boolean conjunction. + And, } diff --git a/src/sql/parser.rs b/src/sql/parser.rs index 185388b..ab8c4cd 100644 --- a/src/sql/parser.rs +++ b/src/sql/parser.rs @@ -33,6 +33,7 @@ enum Token { From, Where, As, + And, Null, Identifier(String), String(String), @@ -136,6 +137,22 @@ impl Parser { } fn parse_expr(&mut self) -> Result { + let mut expr = self.parse_equality()?; + + while self.peek() == Some(&Token::And) { + self.index += 1; + let right = self.parse_equality()?; + expr = Expr::Binary { + left: Box::new(expr), + op: BinaryOp::And, + right: Box::new(right), + }; + } + + Ok(expr) + } + + fn parse_equality(&mut self) -> Result { let left = self.parse_operand()?; match self.next().ok_or(ParseError::UnexpectedEnd)? { Token::Eq => { @@ -219,6 +236,7 @@ fn tokenize(input: &str) -> Result, ParseError> { "FROM" => Token::From, "WHERE" => Token::Where, "AS" => Token::As, + "AND" => Token::And, "NULL" => Token::Null, _ => Token::Identifier(ident), }; @@ -286,6 +304,7 @@ fn render_token(token: &Token) -> String { Token::From => "FROM".to_string(), Token::Where => "WHERE".to_string(), Token::As => "AS".to_string(), + Token::And => "AND".to_string(), Token::Null => "NULL".to_string(), Token::Identifier(name) => name.clone(), Token::String(value) => format!("'{}'", value), @@ -395,4 +414,27 @@ mod tests { ] ); } + + #[test] + fn parses_conjunctive_where_clause() { + let select = + parse_select("SELECT c0 FROM Parent WHERE c1 = 'bob' AND c0 = 'alice'").unwrap(); + + assert_eq!( + select.selection, + Some(Expr::Binary { + left: Box::new(Expr::Binary { + left: Box::new(Expr::Identifier("c1".to_string())), + op: BinaryOp::Eq, + right: Box::new(Expr::Literal(Literal::String("bob".to_string()))), + }), + op: BinaryOp::And, + right: Box::new(Expr::Binary { + left: Box::new(Expr::Identifier("c0".to_string())), + op: BinaryOp::Eq, + right: Box::new(Expr::Literal(Literal::String("alice".to_string()))), + }), + }) + ); + } } diff --git a/tests/sql_pipeline_tests.rs b/tests/sql_pipeline_tests.rs index 0f5a106..2d7e3bb 100644 --- a/tests/sql_pipeline_tests.rs +++ b/tests/sql_pipeline_tests.rs @@ -204,3 +204,16 @@ fn select_self_join_uses_table_aliases() { vec!["alice -> carol".to_string(), "bob -> dave".to_string()] ); } + +#[test] +fn select_where_and_applies_multiple_filters() { + let instance = parent_instance(); + let catalog = PredicateCatalog::from_instance(&instance).unwrap(); + let select = parse_select("SELECT c0 FROM Parent WHERE c1 = 'bob' AND c0 = 'alice'").unwrap(); + + let plan = plan_select(&select, &catalog).unwrap(); + let result = execute(&plan, &instance).unwrap(); + + assert_eq!(result.rows().len(), 1); + assert_eq!(format!("{}", result.rows()[0].values()[0]), "alice"); +}