From 6f92b03070068326cb236eae99fffa394116d243 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mario=20Alejandro=20Montoya=20Corte=CC=81s?= Date: Wed, 5 Feb 2025 13:06:05 -0500 Subject: [PATCH 1/3] Printer for the plan, ie: EXPLAIN * Improve output of plan * Fix print of alias and the output of joins * Allow the test utils of the plan to be used elsewhere * Use the arrow only for nodes, make timing optional * Apply new clippy hints and remove old test logic --- Cargo.lock | 19 + Cargo.toml | 1 + crates/core/Cargo.toml | 1 + crates/core/src/sql/ast.rs | 2 +- crates/expr/src/check.rs | 8 +- crates/expr/src/lib.rs | 2 + crates/expr/src/statement.rs | 8 +- crates/physical-plan/Cargo.toml | 2 + crates/physical-plan/src/compile.rs | 32 +- crates/physical-plan/src/dml.rs | 4 + crates/physical-plan/src/lib.rs | 42 ++ crates/physical-plan/src/plan.rs | 929 +++++++++++++--------------- crates/physical-plan/src/printer.rs | 757 +++++++++++++++++++++++ 13 files changed, 1305 insertions(+), 502 deletions(-) create mode 100644 crates/physical-plan/src/printer.rs diff --git a/Cargo.lock b/Cargo.lock index 7f3d803d5c0..1e0a6ccb045 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1484,6 +1484,12 @@ dependencies = [ "syn 2.0.87", ] +[[package]] +name = "dissimilar" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59f8e79d1fbf76bdfbde321e902714bf6c49df88a7dda6fc682fc2979226962d" + [[package]] name = "duct" version = "0.13.7" @@ -1670,6 +1676,16 @@ dependencies = [ "serde", ] +[[package]] +name = "expect-test" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63af43ff4431e848fb47472a920f14fa71c24de13255a5692e93d4e90302acb0" +dependencies = [ + "dissimilar", + "once_cell", +] + [[package]] name = "fallible-iterator" version = "0.2.0" @@ -5035,6 +5051,7 @@ dependencies = [ "enum-as-inner", "enum-map", "env_logger", + "expect-test", "faststr", "flate2", "fs2", @@ -5275,6 +5292,8 @@ version = "1.0.0-rc4" dependencies = [ "anyhow", "derive_more", + "expect-test", + "itertools 0.12.1", "pretty_assertions", "spacetimedb-expr", "spacetimedb-lib", diff --git a/Cargo.toml b/Cargo.toml index 969e526bddd..045c7178f2b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -158,6 +158,7 @@ enum-as-inner = "0.6" enum-map = "2.6.3" env_logger = "0.10" ethnum = { version = "1.5.0", features = ["serde"] } +expect-test = "1.5.0" flate2 = "1.0.24" foldhash = "0.1.4" fs-err = "2.9.0" diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index cf309d756ea..4d7aebe4881 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -51,6 +51,7 @@ derive_more.workspace = true dirs.workspace = true enum-as-inner.workspace = true enum-map.workspace = true +expect-test.workspace = true flate2.workspace = true fs2.workspace = true futures.workspace = true diff --git a/crates/core/src/sql/ast.rs b/crates/core/src/sql/ast.rs index 4fd4fa4ab32..e365d43f8b8 100644 --- a/crates/core/src/sql/ast.rs +++ b/crates/core/src/sql/ast.rs @@ -977,7 +977,7 @@ pub(crate) fn compile_to_ast( ) -> Result, DBError> { // NOTE: The following ensures compliance with the 1.0 sql api. // Come 1.0, it will have replaced the current compilation stack. - compile_sql_stmt(sql_text, &SchemaViewer::new(tx, auth))?; + compile_sql_stmt(sql_text, &SchemaViewer::new(tx, auth), false)?; let dialect = PostgreSqlDialect {}; let ast = Parser::parse_sql(&dialect, sql_text).map_err(|error| DBError::SqlParser { diff --git a/crates/expr/src/check.rs b/crates/expr/src/check.rs index c1471121746..60e1bc3de3c 100644 --- a/crates/expr/src/check.rs +++ b/crates/expr/src/check.rs @@ -165,11 +165,17 @@ pub fn type_subscription(ast: SqlSelect, tx: &impl SchemaView) -> TypingResult

(sql: &'a str, tx: &impl SchemaView) -> TypingResult> { +pub fn compile_sql_sub<'a>(sql: &'a str, tx: &impl SchemaView, with_timings: bool) -> TypingResult> { + let planning_time = if with_timings { + Some(std::time::Instant::now()) + } else { + None + }; Ok(StatementCtx { statement: Statement::Select(ProjectList::Name(parse_and_type_sub(sql, tx)?)), sql, source: StatementSource::Subscription, + planning_time: planning_time.map(|t| t.elapsed()), }) } diff --git a/crates/expr/src/lib.rs b/crates/expr/src/lib.rs index 25ae6696297..2706e10ea77 100644 --- a/crates/expr/src/lib.rs +++ b/crates/expr/src/lib.rs @@ -305,6 +305,7 @@ pub(crate) fn parse(value: &str, ty: &AlgebraicType) -> anyhow::Result { pub statement: Statement, pub sql: &'a str, pub source: StatementSource, + pub planning_time: Option, } diff --git a/crates/expr/src/statement.rs b/crates/expr/src/statement.rs index 90e3f61ecdd..1957b5a8012 100644 --- a/crates/expr/src/statement.rs +++ b/crates/expr/src/statement.rs @@ -419,12 +419,18 @@ pub fn parse_and_type_sql(sql: &str, tx: &impl SchemaView) -> TypingResult(sql: &'a str, tx: &impl SchemaView) -> TypingResult> { +pub fn compile_sql_stmt<'a>(sql: &'a str, tx: &impl SchemaView, with_timings: bool) -> TypingResult> { + let planning_time = if with_timings { + Some(std::time::Instant::now()) + } else { + None + }; let statement = parse_and_type_sql(sql, tx)?; Ok(StatementCtx { statement, sql, source: StatementSource::Query, + planning_time: planning_time.map(|t| t.elapsed()), }) } diff --git a/crates/physical-plan/Cargo.toml b/crates/physical-plan/Cargo.toml index fb9f62a4a98..68ebb8c6ad6 100644 --- a/crates/physical-plan/Cargo.toml +++ b/crates/physical-plan/Cargo.toml @@ -9,6 +9,8 @@ description = "The physical query plan for the SpacetimeDB query engine" [dependencies] anyhow.workspace = true derive_more.workspace = true +expect-test.workspace = true +itertools.workspace = true spacetimedb-lib.workspace = true spacetimedb-primitives.workspace = true spacetimedb-schema.workspace = true diff --git a/crates/physical-plan/src/compile.rs b/crates/physical-plan/src/compile.rs index bcc211739e5..2d00d72f4b7 100644 --- a/crates/physical-plan/src/compile.rs +++ b/crates/physical-plan/src/compile.rs @@ -5,8 +5,10 @@ use std::collections::HashMap; use crate::dml::{DeletePlan, MutationPlan, UpdatePlan}; use crate::plan::{HashJoin, Label, PhysicalExpr, PhysicalPlan, ProjectListPlan, ProjectPlan, Semi, TupleField}; +use crate::{PhysicalCtx, PlanCtx}; use spacetimedb_expr::expr::{Expr, FieldProject, LeftDeepJoin, ProjectList, ProjectName, RelExpr, Relvar}; -use spacetimedb_expr::statement::DML; +use spacetimedb_expr::statement::{Statement, DML}; +use spacetimedb_expr::StatementCtx; pub trait VarLabel { fn label(&mut self, name: &str) -> Label; @@ -121,6 +123,12 @@ struct NamesToIds { map: HashMap, } +impl NamesToIds { + fn into_map(self) -> HashMap { + self.map + } +} + impl VarLabel for NamesToIds { fn label(&mut self, name: &str) -> Label { if let Some(id) = self.map.get(name) { @@ -143,7 +151,11 @@ pub fn compile_select(project: ProjectName) -> ProjectPlan { /// Note, this utility is applicable to a generic selections. /// In particular, it supports explicit column projections. pub fn compile_select_list(project: ProjectList) -> ProjectListPlan { - compile_project_list(&mut NamesToIds::default(), project) + compile_select_list_raw(&mut NamesToIds::default(), project) +} + +pub fn compile_select_list_raw(var: &mut impl VarLabel, project: ProjectList) -> ProjectListPlan { + compile_project_list(var, project) } /// Converts a logical DML statement into a physical plan, @@ -155,3 +167,19 @@ pub fn compile_dml_plan(stmt: DML) -> MutationPlan { DML::Update(update) => MutationPlan::Update(UpdatePlan::compile(update)), } } + +pub fn compile(ast: StatementCtx<'_>) -> PhysicalCtx<'_> { + let mut vars = NamesToIds::default(); + let plan = match ast.statement { + Statement::Select(project) => PlanCtx::ProjectList(compile_select_list_raw(&mut vars, project)), + Statement::DML(stmt) => PlanCtx::DML(compile_dml_plan(stmt)), + }; + + PhysicalCtx { + plan, + sql: ast.sql, + vars: vars.into_map(), + source: ast.source, + planning_time: None, + } +} diff --git a/crates/physical-plan/src/dml.rs b/crates/physical-plan/src/dml.rs index 2aa7a5bd636..1cfce7be8d2 100644 --- a/crates/physical-plan/src/dml.rs +++ b/crates/physical-plan/src/dml.rs @@ -12,6 +12,7 @@ use spacetimedb_schema::schema::TableSchema; use crate::{compile::compile_select, plan::ProjectPlan}; /// A plan for mutating a table in the database +#[derive(Debug)] pub enum MutationPlan { Insert(InsertPlan), Delete(DeletePlan), @@ -30,6 +31,7 @@ impl MutationPlan { } /// A plan for inserting rows into a table +#[derive(Debug)] pub struct InsertPlan { pub table: Arc, pub rows: Vec, @@ -44,6 +46,7 @@ impl From for InsertPlan { } /// A plan for deleting rows from a table +#[derive(Debug)] pub struct DeletePlan { pub table: Arc, pub filter: ProjectPlan, @@ -77,6 +80,7 @@ impl DeletePlan { } /// A plan for updating rows in a table +#[derive(Debug)] pub struct UpdatePlan { pub table: Arc, pub columns: Vec<(ColId, AlgebraicValue)>, diff --git a/crates/physical-plan/src/lib.rs b/crates/physical-plan/src/lib.rs index c847069d006..16ec8239d98 100644 --- a/crates/physical-plan/src/lib.rs +++ b/crates/physical-plan/src/lib.rs @@ -1,4 +1,46 @@ +use crate::dml::MutationPlan; +use crate::plan::ProjectListPlan; +use anyhow::Result; +use spacetimedb_expr::StatementSource; +use std::collections::HashMap; + pub mod compile; pub mod dml; pub mod plan; +pub mod printer; pub mod rules; + +#[derive(Debug)] +pub enum PlanCtx { + ProjectList(ProjectListPlan), + DML(MutationPlan), +} + +impl PlanCtx { + pub(crate) fn optimize(self) -> Result { + Ok(match self { + Self::ProjectList(plan) => Self::ProjectList(plan.optimize()?), + Self::DML(plan) => Self::DML(plan.optimize()?), + }) + } +} + +/// A physical context for the result of a query compilation. +#[derive(Debug)] +pub struct PhysicalCtx<'a> { + pub plan: PlanCtx, + pub sql: &'a str, + // A map from table names to their labels + pub vars: HashMap, + pub source: StatementSource, + pub planning_time: Option, +} + +impl PhysicalCtx<'_> { + pub fn optimize(self) -> Result { + Ok(Self { + plan: self.plan.optimize()?, + ..self + }) + } +} diff --git a/crates/physical-plan/src/plan.rs b/crates/physical-plan/src/plan.rs index 027eb47d4da..09afe6de0aa 100644 --- a/crates/physical-plan/src/plan.rs +++ b/crates/physical-plan/src/plan.rs @@ -6,7 +6,6 @@ use std::{ use anyhow::Result; use derive_more::From; -use spacetimedb_expr::StatementSource; use spacetimedb_lib::{query::Delta, sats::size_of::SizeOf, AlgebraicValue, ProductValue}; use spacetimedb_primitives::{ColId, ColSet, IndexId}; use spacetimedb_schema::schema::{IndexSchema, TableSchema}; @@ -985,39 +984,87 @@ impl PhysicalExpr { } } -/// A physical context for the result of a query compilation. -pub struct PhysicalCtx<'a> { - pub plan: ProjectListPlan, - pub sql: &'a str, - pub source: StatementSource, +pub mod tests_utils { + use crate::compile::compile; + use crate::printer::{Explain, ExplainOptions}; + use crate::PhysicalCtx; + use expect_test::Expect; + use spacetimedb_expr::check::{compile_sql_sub, SchemaView}; + use spacetimedb_expr::statement::compile_sql_stmt; + + fn sub<'a>(db: &'a impl SchemaView, sql: &'a str) -> PhysicalCtx<'a> { + let plan = compile_sql_sub(sql, db, true).unwrap(); + compile(plan) + } + + fn query<'a>(db: &'a impl SchemaView, sql: &'a str) -> PhysicalCtx<'a> { + let plan = compile_sql_stmt(sql, db, true).unwrap(); + compile(plan) + } + + fn check(plan: PhysicalCtx, options: ExplainOptions, expect: Expect) { + let plan = if options.optimize { + plan.optimize().unwrap() + } else { + plan + }; + + let explain = Explain::new(&plan).with_options(options); + + let explain = explain.build(); + expect.assert_eq(&explain.to_string()); + } + + pub fn check_sub(db: &impl SchemaView, options: ExplainOptions, sql: &str, expect: Expect) { + let plan = sub(db, sql); + check(plan, options, expect); + } + + pub fn check_query(db: &impl SchemaView, options: ExplainOptions, sql: &str, expect: Expect) { + let plan = query(db, sql); + check(plan, options, expect); + } } #[cfg(test)] mod tests { - use std::sync::Arc; + use super::*; - use pretty_assertions::assert_eq; - use spacetimedb_expr::check::{parse_and_type_sub, SchemaView}; + use crate::printer::ExplainOptions; + use expect_test::{expect, Expect}; + use spacetimedb_expr::check::SchemaView; use spacetimedb_lib::{ db::auth::{StAccess, StTableType}, - AlgebraicType, AlgebraicValue, + AlgebraicType, }; use spacetimedb_primitives::{ColId, ColList, ColSet, TableId}; use spacetimedb_schema::{ def::{BTreeAlgorithm, ConstraintData, IndexAlgorithm, UniqueConstraintData}, schema::{ColumnSchema, ConstraintSchema, IndexSchema, TableSchema}, }; - use spacetimedb_sql_parser::ast::BinOp; - - use crate::{ - compile::compile_select, - plan::{HashJoin, IxJoin, IxScan, PhysicalPlan, Sarg, Semi, TupleField}, - }; - - use super::{PhysicalExpr, ProjectPlan}; struct SchemaViewer { schemas: Vec>, + options: ExplainOptions, + } + + impl SchemaViewer { + fn new(schemas: Vec>) -> Self { + Self { + schemas, + options: ExplainOptions::default(), + } + } + + fn with_options(mut self, options: ExplainOptions) -> Self { + self.options = options; + self + } + + fn optimize(mut self, optimize: bool) -> Self { + self.options = self.options.optimize(optimize); + self + } } impl SchemaView for SchemaViewer { @@ -1086,6 +1133,14 @@ mod tests { ) } + fn check_sub(db: &SchemaViewer, sql: &str, expect: Expect) { + tests_utils::check_sub(db, db.options, sql, expect); + } + + fn check_query(db: &SchemaViewer, sql: &str, expect: Expect) { + tests_utils::check_query(db, db.options, sql, expect); + } + /// No rewrites applied to a simple table scan #[test] fn table_scan_noop() { @@ -1100,30 +1155,21 @@ mod tests { Some(0), )); - let db = SchemaViewer { - schemas: vec![t.clone()], - }; - - let sql = "select * from t"; - - let lp = parse_and_type_sub(sql, &db).unwrap(); - let pp = compile_select(lp).optimize().unwrap(); - - match pp { - ProjectPlan::None(PhysicalPlan::TableScan(schema, _, _)) => { - assert_eq!(schema.table_id, t_id); - } - proj => panic!("unexpected project: {:#?}", proj), - }; + let db = SchemaViewer::new(vec![t.clone()]); + check_sub( + &db, + "select * from t", + expect![[r#" +Seq Scan on t + Output: t.id, t.x"#]], + ); } /// No rewrites applied to a table scan + filter #[test] fn filter_noop() { - let t_id = TableId(1); - let t = Arc::new(schema( - t_id, + TableId(1), "t", &[("id", AlgebraicType::U64), ("x", AlgebraicType::U64)], &[&[0]], @@ -1131,38 +1177,25 @@ mod tests { Some(0), )); - let db = SchemaViewer { - schemas: vec![t.clone()], - }; - - let sql = "select * from t where x = 5"; - - let lp = parse_and_type_sub(sql, &db).unwrap(); - let pp = compile_select(lp).optimize().unwrap(); + let db = SchemaViewer::new(vec![t]); - match pp { - ProjectPlan::None(PhysicalPlan::Filter(input, PhysicalExpr::BinOp(BinOp::Eq, field, value))) => { - assert!(matches!(*field, PhysicalExpr::Field(TupleField { field_pos: 1, .. }))); - assert!(matches!(*value, PhysicalExpr::Value(AlgebraicValue::U64(5)))); - - match *input { - PhysicalPlan::TableScan(schema, _, _) => { - assert_eq!(schema.table_id, t_id); - } - plan => panic!("unexpected plan: {:#?}", plan), - } - } - proj => panic!("unexpected project: {:#?}", proj), - }; + check_sub( + &db, + "select * from t where x = 5", + expect![[r#" +Seq Scan on t + Filter: (t.x = U64(5)) + Output: t.id, t.x"#]], + ); } /// Given the following operator notation: /// - /// x: join - /// p: project - /// s: select - /// ix: index scan - /// rx: right index semijoin + /// x: join + /// p: project + /// s: select + /// ix: index scan + /// rx: right index semijoin /// /// This test takes the following logical plan: /// @@ -1224,132 +1257,41 @@ mod tests { Some(0), )); - let db = SchemaViewer { - schemas: vec![u.clone(), l.clone(), b.clone()], - }; + let db = SchemaViewer::new(vec![u.clone(), l.clone(), b.clone()]).optimize(true); - let sql = " + check_sub( + &db, + " select b.* from u join l as p on u.entity_id = p.entity_id join l as q on p.chunk = q.chunk join b on q.entity_id = b.entity_id - where u.identity = 5 - "; - let lp = parse_and_type_sub(sql, &db).unwrap(); - let pp = compile_select(lp).optimize().unwrap(); - - // Plan: - // rx - // / \ - // rx b - // / \ - // rx l - // / \ - // ix(u) l - let plan = match pp { - ProjectPlan::None(plan) => plan, - proj => panic!("unexpected project: {:#?}", proj), - }; - - // Plan: - // rx - // / \ - // rx b - // / \ - // rx l - // / \ - // ix(u) l - let plan = match plan { - PhysicalPlan::IxJoin( - IxJoin { - lhs, - rhs, - rhs_field: ColId(0), - unique: true, - lhs_field: TupleField { field_pos: 0, .. }, - .. - }, - Semi::Rhs, - ) => { - assert_eq!(rhs.table_id, b_id); - *lhs - } - plan => panic!("unexpected plan: {:#?}", plan), - }; - - // Plan: - // rx - // / \ - // rx l - // / \ - // ix(u) l - let plan = match plan { - PhysicalPlan::IxJoin( - IxJoin { - lhs, - rhs, - rhs_field: ColId(1), - unique: false, - lhs_field: TupleField { field_pos: 1, .. }, - .. - }, - Semi::Rhs, - ) => { - assert_eq!(rhs.table_id, l_id); - *lhs - } - plan => panic!("unexpected plan: {:#?}", plan), - }; - - // Plan: - // rx - // / \ - // ix(u) l - let plan = match plan { - PhysicalPlan::IxJoin( - IxJoin { - lhs, - rhs, - rhs_field: ColId(0), - unique: true, - lhs_field: TupleField { field_pos: 1, .. }, - .. - }, - Semi::Rhs, - ) => { - assert_eq!(rhs.table_id, l_id); - *lhs - } - plan => panic!("unexpected plan: {:#?}", plan), - }; - - // Plan: ix(u) - match plan { - PhysicalPlan::IxScan( - IxScan { - schema, - prefix, - arg: Sarg::Eq(ColId(0), AlgebraicValue::U64(5)), - .. - }, - _, - ) => { - assert!(prefix.is_empty()); - assert_eq!(schema.table_id, u_id); - } - plan => panic!("unexpected plan: {:#?}", plan), - } + where u.identity = 5", + expect![[r#" +Index Join: Rhs on b + -> Index Join: Rhs on q + -> Index Join: Rhs on p + -> Index Scan using Index id 0 on u + Index Cond: (u.identity = U64(5)) + Inner Unique: true + Join Cond: (u.entity_id = p.entity_id) + Inner Unique: false + Join Cond: (p.chunk = q.chunk) + Inner Unique: true + Join Cond: (q.entity_id = b.entity_id) + Output: b.entity_id, b.misc"#]], + ); } /// Given the following operator notation: /// - /// x: join - /// p: project - /// s: select - /// ix: index scan - /// rx: right index semijoin - /// rj: right hash semijoin + /// x: join + /// p: project + /// s: select + /// ix: index scan + /// rx: right index semijoin + /// rj: right hash semijoin /// /// This test takes the following logical plan: /// @@ -1415,166 +1357,39 @@ mod tests { Some(0), )); - let db = SchemaViewer { - schemas: vec![m.clone(), w.clone(), p.clone()], - }; + let db = SchemaViewer::new(vec![m.clone(), w.clone(), p.clone()]).optimize(false); - let sql = " + check_sub( + &db, + " select p.* from m join m as n on m.manager = n.manager join w as u on n.employee = u.employee join w as v on u.project = v.project join p on p.id = v.project - where 5 = m.employee and 5 = v.employee - "; - let lp = parse_and_type_sub(sql, &db).unwrap(); - let pp = compile_select(lp).optimize().unwrap(); - - // Plan: - // rx - // / \ - // rj p - // / \ - // rx ix(w) - // / \ - // rx w - // / \ - // ix(m) m - let plan = match pp { - ProjectPlan::None(plan) => plan, - proj => panic!("unexpected project: {:#?}", proj), - }; - - // Plan: - // rx - // / \ - // rj p - // / \ - // rx ix(w) - // / \ - // rx w - // / \ - // ix(m) m - let plan = match plan { - PhysicalPlan::IxJoin( - IxJoin { - lhs, - rhs, - rhs_field: ColId(0), - unique: true, - lhs_field: TupleField { field_pos: 1, .. }, - .. - }, - Semi::Rhs, - ) => { - assert_eq!(rhs.table_id, p_id); - *lhs - } - plan => panic!("unexpected plan: {:#?}", plan), - }; - - // Plan: - // rj - // / \ - // rx ix(w) - // / \ - // rx w - // / \ - // ix(m) m - let (rhs, lhs) = match plan { - PhysicalPlan::HashJoin( - HashJoin { - lhs, - rhs, - lhs_field: TupleField { field_pos: 1, .. }, - rhs_field: TupleField { field_pos: 1, .. }, - unique: true, - }, - Semi::Rhs, - ) => (*rhs, *lhs), - plan => panic!("unexpected plan: {:#?}", plan), - }; - - // Plan: ix(w) - match rhs { - PhysicalPlan::IxScan( - IxScan { - schema, - prefix, - arg: Sarg::Eq(ColId(0), AlgebraicValue::U64(5)), - .. - }, - _, - ) => { - assert!(prefix.is_empty()); - assert_eq!(schema.table_id, w_id); - } - plan => panic!("unexpected plan: {:#?}", plan), - } - - // Plan: - // rx - // / \ - // rx w - // / \ - // ix(m) m - let plan = match lhs { - PhysicalPlan::IxJoin( - IxJoin { - lhs, - rhs, - rhs_field: ColId(0), - unique: false, - lhs_field: TupleField { field_pos: 0, .. }, - .. - }, - Semi::Rhs, - ) => { - assert_eq!(rhs.table_id, w_id); - *lhs - } - plan => panic!("unexpected plan: {:#?}", plan), - }; - - // Plan: - // rx - // / \ - // ix(m) m - let plan = match plan { - PhysicalPlan::IxJoin( - IxJoin { - lhs, - rhs, - rhs_field: ColId(1), - unique: false, - lhs_field: TupleField { field_pos: 1, .. }, - .. - }, - Semi::Rhs, - ) => { - assert_eq!(rhs.table_id, m_id); - *lhs - } - plan => panic!("unexpected plan: {:#?}", plan), - }; - - // Plan: ix(m) - match plan { - PhysicalPlan::IxScan( - IxScan { - schema, - prefix, - arg: Sarg::Eq(ColId(0), AlgebraicValue::U64(5)), - .. - }, - _, - ) => { - assert!(prefix.is_empty()); - assert_eq!(schema.table_id, m_id); - } - plan => panic!("unexpected plan: {:#?}", plan), - } + where 5 = m.employee and 5 = v.employee", + expect![[r#" +Hash Join + -> Hash Join + -> Hash Join + -> Hash Join + -> Seq Scan on m + -> Seq Scan on n + Inner Unique: false + Join Cond: (m.manager = n.manager) + -> Seq Scan on u + Inner Unique: false + Join Cond: (n.employee = u.employee) + -> Seq Scan on v + Inner Unique: false + Join Cond: (u.project = v.project) + -> Seq Scan on p + Inner Unique: false + Join Cond: (p.id = v.project) + Filter: (m.employee = U64(5) AND v.employee = U64(5)) + Output: p.id, p.name"#]], + ); } /// Test single and multi-column index selections @@ -1596,188 +1411,308 @@ mod tests { None, )); - let db = SchemaViewer { - schemas: vec![t.clone()], - }; - - let sql = "select * from t where x = 3 and y = 4 and z = 5"; - let lp = parse_and_type_sub(sql, &db).unwrap(); - let pp = compile_select(lp).optimize().unwrap(); - + let db = SchemaViewer::new(vec![t.clone()]).optimize(true); // Select index on (x, y, z) - match pp { - ProjectPlan::None(PhysicalPlan::IxScan( - IxScan { - schema, prefix, arg, .. - }, - _, - )) => { - assert_eq!(schema.table_id, t_id); - assert_eq!(arg, Sarg::Eq(ColId(3), AlgebraicValue::U8(5))); - assert_eq!( - prefix, - vec![(ColId(1), AlgebraicValue::U8(3)), (ColId(2), AlgebraicValue::U8(4))] - ); - } - proj => panic!("unexpected plan: {:#?}", proj), - }; + check_sub( + &db, + "select * from t where x = 3 and y = 4 and z = 5", + expect![ + r#" +Index Scan using Index id 2 on t + Index Cond: (t.z = U8(5), t.x = U8(3), t.y = U8(4)) + Output: t.w, t.x, t.y, t.z"# + ], + ); // Test permutations of the same query - let sql = "select * from t where z = 5 and y = 4 and x = 3"; - let lp = parse_and_type_sub(sql, &db).unwrap(); - let pp = compile_select(lp).optimize().unwrap(); + check_sub( + &db, + "select * from t where z = 5 and y = 4 and x = 3", + expect![ + r#" +Index Scan using Index id 2 on t + Index Cond: (t.x = U8(3), t.z = U8(5), t.y = U8(4)) + Output: t.w, t.x, t.y, t.z"# + ], + ); + // Select index on x + check_sub( + &db, + "select * from t where x = 3 and y = 4", + expect![ + r#" +Index Scan using Index id 0 on t + Index Cond: (t.x = U8(3)) + Filter: (t.y = U8(4)) + Output: t.w, t.x, t.y, t.z"# + ], + ); + // Select index on x + check_query( + &db, + "select * from t where w = 5 and x = 4", + expect![ + r#" +Index Scan using Index id 0 on t + Index Cond: (t.x = U8(4)) + Filter: (t.w = U8(5)) + Output: t.w, t.x, t.y, t.z"# + ], + ); + // Do not select index on (y, z) + check_query( + &db, + "select * from t where y = 4", + expect![ + r#" +Seq Scan on t + Filter: (t.y = U8(4)) + Output: t.w, t.x, t.y, t.z"# + ], + ); - match pp { - ProjectPlan::None(PhysicalPlan::IxScan( - IxScan { - schema, prefix, arg, .. - }, - _, - )) => { - assert_eq!(schema.table_id, t_id); - assert_eq!(arg, Sarg::Eq(ColId(1), AlgebraicValue::U8(3))); - assert_eq!( - prefix, - vec![(ColId(3), AlgebraicValue::U8(5)), (ColId(2), AlgebraicValue::U8(4))] - ); - } - proj => panic!("unexpected plan: {:#?}", proj), - }; + // Select index on [y, z] + check_query( + &db, + "select * from t where y = 1 and z = 2", + expect![ + r#" +Index Scan using Index id 1 on t + Index Cond: (t.z = U8(2), t.y = U8(1)) + Output: t.w, t.x, t.y, t.z"# + ], + ); - let sql = "select * from t where x = 3 and y = 4"; - let lp = parse_and_type_sub(sql, &db).unwrap(); - let pp = compile_select(lp).optimize().unwrap(); + // Check permutations of the same query + check_query( + &db, + "select * from t where z = 2 and y = 1", + expect![ + r#" +Index Scan using Index id 1 on t + Index Cond: (t.y = U8(1), t.z = U8(2)) + Output: t.w, t.x, t.y, t.z"# + ], + ); + // Select index on (y, z) and filter on (w) + check_query( + &db, + "select * from t where w = 1 and y = 2 and z = 3", + expect![ + r#" +Index Scan using Index id 1 on t + Index Cond: (t.z = U8(3), t.y = U8(2)) + Filter: (t.w = U8(1)) + Output: t.w, t.x, t.y, t.z"# + ], + ); + } - // Select index on x - let plan = match pp { - ProjectPlan::None(PhysicalPlan::Filter(input, PhysicalExpr::BinOp(BinOp::Eq, field, value))) => { - assert!(matches!(*field, PhysicalExpr::Field(TupleField { field_pos: 2, .. }))); - assert!(matches!(*value, PhysicalExpr::Value(AlgebraicValue::U8(4)))); - *input - } - proj => panic!("unexpected plan: {:#?}", proj), - }; + fn data() -> SchemaViewer { + let m_id = TableId(1); + let w_id = TableId(2); + let p_id = TableId(3); - match plan { - PhysicalPlan::IxScan( - IxScan { - schema, prefix, arg, .. - }, - _, - ) => { - assert_eq!(schema.table_id, t_id); - assert_eq!(arg, Sarg::Eq(ColId(1), AlgebraicValue::U8(3))); - assert!(prefix.is_empty()); - } - plan => panic!("unexpected plan: {:#?}", plan), - }; + let m = Arc::new(schema( + m_id, + "m", + &[("employee", AlgebraicType::U64), ("manager", AlgebraicType::U64)], + &[&[0], &[1]], + &[&[0]], + Some(0), + )); - let sql = "select * from t where w = 5 and x = 4"; - let lp = parse_and_type_sub(sql, &db).unwrap(); - let pp = compile_select(lp).optimize().unwrap(); + let w = Arc::new(schema( + w_id, + "w", + &[("employee", AlgebraicType::U64), ("project", AlgebraicType::U64)], + &[&[0], &[1], &[0, 1]], + &[&[0, 1]], + None, + )); - // Select index on x - let plan = match pp { - ProjectPlan::None(PhysicalPlan::Filter(input, PhysicalExpr::BinOp(BinOp::Eq, field, value))) => { - assert!(matches!(*field, PhysicalExpr::Field(TupleField { field_pos: 0, .. }))); - assert!(matches!(*value, PhysicalExpr::Value(AlgebraicValue::U8(5)))); - *input - } - proj => panic!("unexpected plan: {:#?}", proj), - }; + let p = Arc::new(schema( + p_id, + "p", + &[("id", AlgebraicType::U64), ("name", AlgebraicType::String)], + &[&[0]], + &[&[0]], + Some(0), + )); - match plan { - PhysicalPlan::IxScan( - IxScan { - schema, prefix, arg, .. - }, - _, - ) => { - assert_eq!(schema.table_id, t_id); - assert_eq!(arg, Sarg::Eq(ColId(1), AlgebraicValue::U8(4))); - assert!(prefix.is_empty()); - } - plan => panic!("unexpected plan: {:#?}", plan), - }; + SchemaViewer::new(vec![m.clone(), w.clone(), p.clone()]).with_options(ExplainOptions::default().optimize(false)) + } - let sql = "select * from t where y = 1"; - let lp = parse_and_type_sub(sql, &db).unwrap(); - let pp = compile_select(lp).optimize().unwrap(); + #[test] + fn plan_metadata() { + let db = data().with_options(ExplainOptions::new().with_schema().with_source().optimize(true)); + check_query( + &db, + "SELECT m.* FROM m CROSS JOIN p WHERE m.employee = 1", + expect![ + r#" +Query: SELECT m.* FROM m CROSS JOIN p WHERE m.employee = 1 +Nested Loop + -> Index Scan using Index id 0 on m + Index Cond: (m.employee = U64(1)) + -> Seq Scan on p:2 + Output: m.employee, m.manager +------- +Schema: + +Label: m, TableId:1 + Columns: employee, manager + Indexes: Index id 0: (m.employee), Index id 1: (m.manager) + Constraints: Constraint id 0: Unique(m.employee) +Label: p, TableId:3 + Columns: id, name + Indexes: Index id 0: (p.id) + Constraints: Constraint id 0: Unique(p.id)"# + ], + ); + } - // Do not select index on (y, z) - match pp { - ProjectPlan::None(PhysicalPlan::Filter(input, PhysicalExpr::BinOp(BinOp::Eq, field, value))) => { - assert!(matches!(*input, PhysicalPlan::TableScan(..))); - assert!(matches!(*field, PhysicalExpr::Field(TupleField { field_pos: 2, .. }))); - assert!(matches!(*value, PhysicalExpr::Value(AlgebraicValue::U8(1)))); - } - proj => panic!("unexpected plan: {:#?}", proj), - }; + #[test] + fn table_scan() { + let db = data(); + check_sub( + &db, + "SELECT * FROM p", + expect![ + r#" + Seq Scan on p + Output: p.id, p.name"# + ], + ); + } - // Select index on [y, z] - let sql = "select * from t where y = 1 and z = 2"; - let lp = parse_and_type_sub(sql, &db).unwrap(); - let pp = compile_select(lp).optimize().unwrap(); + #[test] + fn table_alias() { + let db = data(); + check_sub( + &db, + "SELECT * FROM p as b", + expect![ + r#" + Seq Scan on b + Output: b.id, b.name"# + ], + ); + check_sub( + &db, + "select p.* + from w + join m as p", + expect![ + r#" + Nested Loop + -> Seq Scan on w + -> Seq Scan on p + Output: p.employee, p.manager"# + ], + ); + } - match pp { - ProjectPlan::None(PhysicalPlan::IxScan( - IxScan { - schema, prefix, arg, .. - }, - _, - )) => { - assert_eq!(schema.table_id, t_id); - assert_eq!(arg, Sarg::Eq(ColId(3), AlgebraicValue::U8(2))); - assert_eq!(prefix, vec![(ColId(2), AlgebraicValue::U8(1))]); - } - proj => panic!("unexpected plan: {:#?}", proj), - }; + #[test] + fn table_project() { + let db = data(); + check_query( + &db, + "SELECT id FROM p", + expect![ + r#" + Seq Scan on p + Output: p.id"# + ], + ); + + check_query( + &db, + "SELECT p.id,m.employee FROM m CROSS JOIN p", + expect![ + r#" + Nested Loop + -> Seq Scan on m + -> Seq Scan on p + Output: p.id, m.employee"# + ], + ); + } - // Check permutations of the same query - let sql = "select * from t where z = 2 and y = 1"; - let lp = parse_and_type_sub(sql, &db).unwrap(); - let pp = compile_select(lp).optimize().unwrap(); + #[test] + fn table_scan_filter() { + let db = data(); + + check_sub( + &db, + "SELECT * FROM p WHERE id > 1", + expect![[r#" + Seq Scan on p + Filter: (p.id > U64(1)) + Output: p.id, p.name"#]], + ); + } - match pp { - ProjectPlan::None(PhysicalPlan::IxScan( - IxScan { - schema, prefix, arg, .. - }, - _, - )) => { - assert_eq!(schema.table_id, t_id); - assert_eq!(arg, Sarg::Eq(ColId(2), AlgebraicValue::U8(1))); - assert_eq!(prefix, vec![(ColId(3), AlgebraicValue::U8(2))]); - } - proj => panic!("unexpected plan: {:#?}", proj), - }; + #[test] + fn index_scan_filter() { + let db = data().optimize(true); + + check_sub( + &db, + "SELECT m.* FROM m WHERE employee = 1", + expect![[r#" +Index Scan using Index id 0 on m + Index Cond: (m.employee = U64(1)) + Output: m.employee, m.manager"#]], + ); + } - // Select index on (y, z) and filter on (w) - let sql = "select * from t where w = 1 and y = 2 and z = 3"; - let lp = parse_and_type_sub(sql, &db).unwrap(); - let pp = compile_select(lp).optimize().unwrap(); - - let plan = match pp { - ProjectPlan::None(PhysicalPlan::Filter(input, PhysicalExpr::BinOp(BinOp::Eq, field, value))) => { - assert!(matches!(*field, PhysicalExpr::Field(TupleField { field_pos: 0, .. }))); - assert!(matches!(*value, PhysicalExpr::Value(AlgebraicValue::U8(1)))); - *input - } - proj => panic!("unexpected plan: {:#?}", proj), - }; + #[test] + fn cross_join() { + let db = data(); + + check_sub( + &db, + "SELECT p.* FROM m JOIN p", + expect![[r#" + Nested Loop + -> Seq Scan on m + -> Seq Scan on p + Output: p.id, p.name"#]], + ); + } - match plan { - PhysicalPlan::IxScan( - IxScan { - schema, prefix, arg, .. - }, - _, - ) => { - assert_eq!(schema.table_id, t_id); - assert_eq!(arg, Sarg::Eq(ColId(3), AlgebraicValue::U8(3))); - assert_eq!(prefix, vec![(ColId(2), AlgebraicValue::U8(2))]); - } - plan => panic!("unexpected plan: {:#?}", plan), - }; + #[test] + fn hash_join() { + let db = data(); + + check_sub( + &db, + "SELECT p.* FROM m JOIN p ON m.employee = p.id where m.employee = 1", + expect![[r#" + Hash Join + -> Seq Scan on m + -> Seq Scan on p + Inner Unique: false + Join Cond: (m.employee = p.id) + Filter: (m.employee = U64(1)) + Output: p.id, p.name"#]], + ); + } + + #[test] + fn semi_join() { + let db = data().optimize(true); + + check_sub( + &db, + "SELECT p.* FROM m JOIN p ON m.employee = p.id", + expect![[r#" +Index Join: Rhs on p + -> Seq Scan on m + Inner Unique: true + Join Cond: (m.employee = p.id) + Output: p.id, p.name"#]], + ); } } diff --git a/crates/physical-plan/src/printer.rs b/crates/physical-plan/src/printer.rs new file mode 100644 index 00000000000..2275f424700 --- /dev/null +++ b/crates/physical-plan/src/printer.rs @@ -0,0 +1,757 @@ +use crate::plan::{IxScan, Label, PhysicalExpr, PhysicalPlan, ProjectListPlan, ProjectPlan, Sarg, Semi, TupleField}; +use crate::{PhysicalCtx, PlanCtx}; +use itertools::Itertools; +use spacetimedb_expr::StatementSource; +use spacetimedb_lib::AlgebraicValue; +use spacetimedb_primitives::{ColId, ConstraintId, IndexId}; +use spacetimedb_schema::def::ConstraintData; +use spacetimedb_schema::schema::{IndexSchema, TableSchema}; +use spacetimedb_sql_parser::ast::BinOp; +use std::collections::{BTreeMap, HashMap}; +use std::fmt; +use std::ops::Bound; + +fn range_to_op(lower: &Bound, upper: &Bound) -> BinOp { + match (lower, upper) { + (Bound::Included(_), Bound::Included(_)) => BinOp::Lte, + (Bound::Included(_), Bound::Excluded(_)) => BinOp::Lt, + (Bound::Excluded(_), Bound::Included(_)) => BinOp::Gt, + (Bound::Excluded(_), Bound::Excluded(_)) => BinOp::Gte, + (Bound::Unbounded, Bound::Included(_)) => BinOp::Lte, + (Bound::Unbounded, Bound::Excluded(_)) => BinOp::Lt, + (Bound::Included(_), Bound::Unbounded) => BinOp::Gte, + (Bound::Excluded(_), Bound::Unbounded) => BinOp::Gt, + (Bound::Unbounded, Bound::Unbounded) => BinOp::Eq, + } +} + +/// The options for the printer +/// +/// By default: +/// +/// * `show_source: false` +/// * `show_schema: false` +/// * `show_timings: false` +/// +/// * `optimize: true` +#[derive(Debug, Copy, Clone)] +pub struct ExplainOptions { + pub show_source: bool, + pub show_schema: bool, + pub show_timings: bool, + pub optimize: bool, +} + +impl ExplainOptions { + pub fn new() -> Self { + Self { + show_source: false, + show_schema: false, + show_timings: false, + optimize: true, + } + } + + pub fn with_source(mut self) -> Self { + self.show_source = true; + self + } + + pub fn with_schema(mut self) -> Self { + self.show_schema = true; + self + } + + pub fn with_timings(mut self) -> Self { + self.show_timings = true; + self + } + + pub fn optimize(mut self, optimize: bool) -> Self { + self.optimize = optimize; + self + } +} + +impl Default for ExplainOptions { + fn default() -> Self { + Self::new() + } +} + +/// The name or alias of the `table` with his schema +struct Schema<'a> { + /// The table schema + table: &'a TableSchema, + /// The table name *OR* alias + name: &'a str, +} + +/// A map of labels to table schema and name (that is potentially an alias) +struct Labels<'a> { + // To keep the output consistent between runs... + labels: BTreeMap>, +} + +impl<'a> Labels<'a> { + pub fn new() -> Self { + Self { + labels: Default::default(), + } + } + + /// Insert a new label with the [`TableSchema`] and `name` + fn insert(&mut self, idx: Label, table: &'a TableSchema, name: &'a str) { + self.labels.entry(idx.0).or_insert(Schema { table, name }); + } + + /// Get the table schema by [`Label`] + fn table_by_label(&self, label: &Label) -> Option<&Schema<'a>> { + self.labels.get(&label.0) + } + + fn _field(&self, label: &Label, col: usize) -> Option> { + if let Some(schema) = self.table_by_label(label) { + if let Some(field) = schema.table.get_column(col) { + return Some(Field { + table: schema.name, + field: field.col_name.as_ref(), + }); + } + } + None + } + + /// Get the field by [`Label`] of the `table` and the column index + fn label(&self, label: &Label, col: ColId) -> Option> { + self._field(label, col.idx()) + } + + /// Get the field by [`TupleField`] + fn field(&self, field: &TupleField) -> Option> { + self._field(&field.label, field.field_pos) + } +} + +/// A pretty printer for physical expressions +struct PrintExpr<'a> { + expr: &'a PhysicalExpr, + labels: &'a Labels<'a>, +} + +/// A pretty printer for sargable expressions +struct PrintSarg<'a> { + expr: &'a Sarg, + label: Label, + labels: &'a Labels<'a>, + prefix: &'a [(ColId, AlgebraicValue)], +} + +/// A pretty printer for objects that could have a empty name +pub enum PrintName<'a> { + Named { object: &'a str, name: &'a str }, + Id { object: &'a str, id: usize }, +} + +impl<'a> PrintName<'a> { + fn new(object: &'a str, id: usize, name: &'a str) -> Self { + if name.is_empty() { + Self::Id { object, id } + } else { + Self::Named { object, name } + } + } + + fn index(index_id: IndexId, index_name: &'a str) -> Self { + Self::new("Index", index_id.idx(), index_name) + } + + fn constraint(constraint_id: ConstraintId, constraint_name: &'a str) -> Self { + Self::new("Constraint", constraint_id.idx(), constraint_name) + } +} + +/// A pretty printer for indexes +pub struct PrintIndex<'a> { + name: PrintName<'a>, + cols: Vec>, +} + +impl<'a> PrintIndex<'a> { + pub fn new(idx: &'a IndexSchema, table: &'a TableSchema) -> Self { + let cols = idx + .index_algorithm + .columns() + .iter() + .map(|x| Field { + table: table.table_name.as_ref(), + field: table.get_column(x.idx()).unwrap().col_name.as_ref(), + }) + .collect_vec(); + + Self { + name: PrintName::index(idx.index_id, &idx.index_name), + cols, + } + } +} + +/// A formated line of output +pub enum Line<'a> { + TableScan { + table: &'a str, + label: usize, + ident: u16, + }, + Filter { + expr: &'a PhysicalExpr, + ident: u16, + }, + FilterIxScan { + idx: &'a IxScan, + label: Label, + ident: u16, + }, + IxScan { + table_name: &'a str, + index: PrintName<'a>, + ident: u16, + }, + IxJoin { + semi: &'a Semi, + rhs: String, + ident: u16, + }, + HashJoin { + semi: &'a Semi, + ident: u16, + }, + NlJoin { + ident: u16, + }, + JoinExpr { + unique: bool, + lhs: Field<'a>, + rhs: Field<'a>, + ident: u16, + }, +} + +impl Line<'_> { + pub fn ident(&self) -> usize { + let ident = match self { + Line::TableScan { ident, .. } => *ident, + Line::Filter { ident, .. } => *ident, + Line::FilterIxScan { ident, .. } => *ident, + Line::IxScan { ident, .. } => *ident, + Line::IxJoin { ident, .. } => *ident, + Line::HashJoin { ident, .. } => *ident, + Line::NlJoin { ident, .. } => *ident, + Line::JoinExpr { ident, .. } => *ident, + }; + ident as usize + } +} + +/// A `field` in a `table` +#[derive(Debug, Clone)] +pub struct Field<'a> { + table: &'a str, + field: &'a str, +} + +/// The output of the plan, aka the projected columns +enum Output<'a> { + Unknown, + Star(Vec>), + Fields(Vec>), +} + +impl<'a> Output<'a> { + fn tuples(fields: &[TupleField], lines: &Lines<'a>) -> Vec> { + fields.iter().map(|field| lines.labels.field(field).unwrap()).collect() + } + + fn fields(schema: &Schema<'a>) -> Vec> { + schema + .table + .columns() + .iter() + .map(|x| Field { + table: schema.name, + field: &x.col_name, + }) + .collect() + } +} + +/// A list of lines to print +struct Lines<'a> { + lines: Vec>, + labels: Labels<'a>, + output: Output<'a>, + /// A map of label to table name or alias + vars: HashMap, +} + +impl<'a> Lines<'a> { + pub fn new(vars: HashMap) -> Self { + Self { + lines: Vec::new(), + labels: Labels::new(), + output: Output::Unknown, + vars, + } + } + + pub fn add(&mut self, line: Line<'a>) { + self.lines.push(line); + } + + /// Resolve the label to the [`TableSchema`], and add it to the list of labels + pub fn add_table(&mut self, label: Label, table: &'a TableSchema) { + let name = self.vars.get(&label.0).copied().unwrap_or(table.table_name.as_ref()); + self.labels.insert(label, table, name); + } +} + +fn eval_expr<'a>(lines: &mut Lines<'a>, expr: &'a PhysicalExpr, ident: u16) { + lines.add(Line::Filter { expr, ident }); +} + +/// Determine the output of a join using the direction of the [`Semi`] join +fn output_join<'a>(lines: &Lines<'a>, semi: &'a Semi, label_lhs: Label, label_rhs: Label) -> Output<'a> { + match semi { + Semi::Lhs => { + let schema = lines.labels.table_by_label(&label_lhs).unwrap(); + Output::Star(Output::fields(schema)) + } + Semi::Rhs => { + let schema = lines.labels.table_by_label(&label_rhs).unwrap(); + Output::Star(Output::fields(schema)) + } + Semi::All => { + let schema = lines.labels.table_by_label(&label_lhs).unwrap(); + let lhs = Output::fields(schema); + let schema = lines.labels.table_by_label(&label_rhs).unwrap(); + let rhs = Output::fields(schema); + + Output::Star(lhs.iter().chain(rhs.iter()).cloned().collect()) + } + } +} + +fn eval_plan<'a>(lines: &mut Lines<'a>, plan: &'a PhysicalPlan, ident: u16) { + match plan { + PhysicalPlan::TableScan(schema, label, _delta) => { + lines.add_table(*label, schema); + + let schema = lines.labels.table_by_label(label).unwrap(); + + lines.output = Output::Star(Output::fields(schema)); + + lines.add(Line::TableScan { + table: schema.name, + label: label.0, + ident, + }); + } + PhysicalPlan::IxScan(idx, label) => { + lines.add_table(*label, &idx.schema); + let schema = lines.labels.table_by_label(label).unwrap(); + lines.output = Output::Star(Output::fields(schema)); + + let index = idx.schema.indexes.iter().find(|x| x.index_id == idx.index_id).unwrap(); + + lines.add(Line::IxScan { + table_name: &idx.schema.table_name, + index: PrintName::index(idx.index_id, &index.index_name), + ident, + }); + + lines.add(Line::FilterIxScan { + idx, + label: *label, + ident: ident + 4, + }); + } + PhysicalPlan::IxJoin(idx, semi) => { + lines.add_table(idx.rhs_label, &idx.rhs); + //let lhs = lines.labels.table_by_label(&idx.lhs_field.label).unwrap(); + let rhs = lines.labels.table_by_label(&idx.rhs_label).unwrap(); + lines.add(Line::IxJoin { + semi, + ident, + rhs: rhs.name.to_string(), + }); + + eval_plan(lines, &idx.lhs, ident + 2); + + lines.output = output_join(lines, semi, idx.lhs_field.label, idx.rhs_label); + + let lhs = lines.labels.field(&idx.lhs_field).unwrap(); + let rhs = lines.labels.label(&idx.rhs_label, idx.rhs_field).unwrap(); + + lines.add(Line::JoinExpr { + unique: idx.unique, + lhs, + rhs, + ident: ident + 2, + }); + } + PhysicalPlan::HashJoin(idx, semi) => { + lines.add(Line::HashJoin { semi, ident }); + + eval_plan(lines, &idx.lhs, ident + 2); + eval_plan(lines, &idx.rhs, ident + 2); + + lines.output = output_join(lines, semi, idx.lhs_field.label, idx.rhs_field.label); + + let lhs = lines.labels.field(&idx.lhs_field).unwrap(); + let rhs = lines.labels.field(&idx.rhs_field).unwrap(); + + lines.add(Line::JoinExpr { + unique: idx.unique, + lhs, + rhs, + ident: ident + 2, + }); + } + PhysicalPlan::NLJoin(lhs, rhs) => { + lines.add(Line::NlJoin { ident }); + + eval_plan(lines, lhs, ident + 2); + eval_plan(lines, rhs, ident + 2); + } + PhysicalPlan::Filter(plan, filter) => { + eval_plan(lines, plan, ident); + eval_expr(lines, filter, ident + 2); + } + } +} + +/// A pretty printer for physical plans +/// +/// The printer will format the plan in a human-readable format, suitable for the `EXPLAIN` command. +/// +/// It also supports: +/// +/// - Showing the source SQL statement +/// - Showing the schema of the tables +/// - Showing the planning time +pub struct Explain<'a> { + ctx: &'a PhysicalCtx<'a>, + lines: Vec>, + labels: Labels<'a>, + output: Output<'a>, + options: ExplainOptions, +} + +impl<'a> Explain<'a> { + pub fn new(ctx: &'a PhysicalCtx<'a>) -> Self { + Self { + ctx, + lines: Vec::new(), + labels: Labels::new(), + output: Output::Unknown, + options: ExplainOptions::new(), + } + } + + /// Set the options for the printer + pub fn with_options(mut self, options: ExplainOptions) -> Self { + self.options = options; + self + } + + /// Evaluate the plan and build the lines to print + fn lines(&self) -> Lines<'a> { + let mut lines = Lines::new(self.ctx.vars.iter().map(|(x, y)| (*y, x.as_str())).collect()); + + match &self.ctx.plan { + PlanCtx::ProjectList(plan) => match plan { + ProjectListPlan::Name(ProjectPlan::None(plan)) => { + eval_plan(&mut lines, plan, 0); + } + ProjectListPlan::Name(ProjectPlan::Name(plan, label, _count)) => { + eval_plan(&mut lines, plan, 0); + let schema = lines.labels.table_by_label(label).unwrap(); + lines.output = Output::Star(Output::fields(schema)); + } + ProjectListPlan::List(plan, fields) => { + eval_plan(&mut lines, plan, 0); + lines.output = Output::Fields(Output::tuples(fields, &lines)); + } + }, + PlanCtx::DML(_plan) => { + todo!() + } + } + + lines + } + + /// Build the `Explain` output + pub fn build(self) -> Self { + let lines = self.lines(); + Self { + lines: lines.lines, + labels: lines.labels, + output: lines.output, + ..self + } + } +} + +impl fmt::Display for PrintExpr<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self.expr { + PhysicalExpr::LogOp(op, expr) => { + write!( + f, + "{}", + expr.iter() + .map(|expr| PrintExpr { + expr, + labels: self.labels + }) + .join(&format!(" {} ", op)) + ) + } + PhysicalExpr::BinOp(op, lhs, rhs) => { + write!( + f, + "{} {} {}", + PrintExpr { + expr: lhs, + labels: self.labels + }, + op, + PrintExpr { + expr: rhs, + labels: self.labels + } + ) + } + PhysicalExpr::Value(val) => { + write!(f, "{:?}", val) + } + PhysicalExpr::Field(field) => { + let col = self.labels.field(field).unwrap(); + write!(f, "{col}") + } + } + } +} + +impl fmt::Display for PrintSarg<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self.expr { + Sarg::Eq(lhs, rhs) => { + let col = self.labels.label(&self.label, *lhs).unwrap(); + write!(f, "{col} = {:?}", rhs)?; + for (col, val) in self.prefix { + let col = self.labels.label(&self.label, *col).unwrap(); + write!(f, ", {col} = {:?}", val)?; + } + Ok(()) + } + Sarg::Range(col, lower, upper) => { + let col = self.labels.label(&self.label, *col).unwrap(); + let op = range_to_op(lower, upper); + write!(f, "{col} {:?} {op}{:?}", lower, upper) + } + } + } +} + +impl fmt::Display for Field<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}.{}", self.table, self.field) + } +} + +impl fmt::Display for PrintName<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + PrintName::Named { object, name } => write!(f, "{} {}", object, name), + PrintName::Id { object, id } => write!(f, "{} id {}", object, id), + } + } +} + +impl fmt::Display for PrintIndex<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}: ", self.name)?; + write!(f, "({})", self.cols.iter().join(", ")) + } +} + +impl fmt::Display for Explain<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let ctx = self.ctx; + + if self.options.show_source { + match ctx.source { + StatementSource::Subscription => write!(f, "Subscription: {}", ctx.sql)?, + StatementSource::Query => write!(f, "Query: {}", ctx.sql)?, + } + + writeln!(f)?; + } + + for line in &self.lines { + let ident = line.ident(); + let arrow = if ident > 0 { "-> " } else { "" }; + + match line { + Line::TableScan { table, label, ident: _ } => { + if self.options.show_schema { + write!(f, "{:ident$}{arrow}Seq Scan on {table}:{label}", "")?; + } else { + write!(f, "{:ident$}{arrow}Seq Scan on {table}", "")?; + } + } + Line::IxScan { + table_name, + index, + ident: _, + } => { + write!(f, "{:ident$}{arrow}Index Scan using {index} on {table_name}", "")?; + } + Line::Filter { expr, ident: _ } => { + write!(f, "{:ident$}Filter: ", "")?; + write!( + f, + "({})", + PrintExpr { + expr, + labels: &self.labels, + } + )?; + } + Line::FilterIxScan { idx, label, ident: _ } => { + write!(f, "{:ident$}Index Cond: ", "")?; + write!( + f, + "({})", + PrintSarg { + expr: &idx.arg, + prefix: &idx.prefix, + labels: &self.labels, + label: *label, + }, + )?; + } + + Line::IxJoin { semi, rhs, ident: _ } => { + write!(f, "{:ident$}{arrow}Index Join: {semi:?} on {rhs}", "")?; + } + Line::HashJoin { semi, ident: _ } => match semi { + Semi::All => { + write!(f, "{:ident$}{arrow}Hash Join", "")?; + } + semi => { + write!(f, "{:ident$}{arrow}Hash Join: {semi:?}", "")?; + } + }, + Line::NlJoin { ident: _ } => { + write!(f, "{:ident$}{arrow}Nested Loop", "")?; + } + Line::JoinExpr { + unique, + lhs, + rhs, + ident: _, + } => { + writeln!(f, "{:ident$}Inner Unique: {unique}", "")?; + write!(f, "{:ident$}Join Cond: ({} = {})", "", lhs, rhs)?; + } + } + writeln!(f)?; + } + + let columns = match &self.output { + Output::Unknown => None, + Output::Star(fields) => { + let columns = fields.iter().map(|x| format!("{}", x)).join(", "); + Some(columns) + } + Output::Fields(fields) => { + let columns = fields.iter().map(|x| format!("{}", x)).join(", "); + Some(columns) + } + }; + let end = if self.options.show_timings || self.options.show_schema { + "\n" + } else { + "" + }; + if let Some(columns) = columns { + write!(f, " Output: {columns}{end}")?; + } else { + write!(f, " Output: ?{end}")?; + } + + if self.options.show_timings { + let end = if self.options.show_schema { "\n" } else { "" }; + write!(f, "Planning Time: {:?}{end}", ctx.planning_time)?; + } + + if self.options.show_schema { + writeln!(f, "-------")?; + writeln!(f, "Schema:")?; + writeln!(f)?; + for (pos, (_label, schema)) in self.labels.labels.iter().enumerate() { + writeln!(f, "Label: {}, TableId:{}", schema.name, schema.table.table_id)?; + let columns = schema.table.columns().iter().map(|x| &x.col_name).join(", "); + writeln!(f, " Columns: {columns}")?; + + writeln!( + f, + " Indexes: {}", + schema + .table + .indexes + .iter() + .map(|x| PrintIndex::new(x, schema.table)) + .join(", ") + )?; + + write!( + f, + " Constraints: {}", + schema + .table + .constraints + .iter() + .map(|x| { + match &x.data { + ConstraintData::Unique(idx) => format!( + "{}: Unique({})", + PrintName::constraint(x.constraint_id, &x.constraint_name), + idx.columns + .iter() + .map(|x| { + Field { + table: schema.name, + field: &schema.table.columns()[x.idx()].col_name, + } + }) + .join(", ") + ), + _ => "".to_string(), + } + }) + .join(", ") + )?; + + if pos < self.labels.labels.len() - 1 { + writeln!(f)?; + } + } + } + + Ok(()) + } +} From 9675cc549177569bc1b0d20b22d7d7fc978bed94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mario=20Alejandro=20Montoya=20Corte=CC=81s?= Date: Thu, 6 Feb 2025 10:24:54 -0500 Subject: [PATCH 2/3] `EXPLAIN` for `dml` --- crates/physical-plan/src/plan.rs | 74 ++++++++++++++++++++++++ crates/physical-plan/src/printer.rs | 89 ++++++++++++++++++++++++++++- 2 files changed, 161 insertions(+), 2 deletions(-) diff --git a/crates/physical-plan/src/plan.rs b/crates/physical-plan/src/plan.rs index 09afe6de0aa..26ee598edf1 100644 --- a/crates/physical-plan/src/plan.rs +++ b/crates/physical-plan/src/plan.rs @@ -1471,6 +1471,9 @@ Seq Scan on t ], ); + // TODO: The `Index Cond` should be `y, z` ie: match the index columns + // This is not to be fixed on the printer side, but on the planner side + // Select index on [y, z] check_query( &db, @@ -1715,4 +1718,75 @@ Index Join: Rhs on p Output: p.id, p.name"#]], ); } + + #[test] + fn insert() { + let db = data(); + + check_query( + &db, + "INSERT INTO p (id, name) VALUES (1, 'foo')", + expect![[r#" +Insert on p + Output: void"#]], + ); + } + + #[test] + fn update() { + let db = data().with_options(ExplainOptions::default().optimize(true)); + + check_query( + &db, + "UPDATE p SET name = 'bar'", + expect![[r#" +Update on p SET (p.name = String("bar")) + -> Seq Scan on p + Output: void"#]], + ); + + check_query( + &db, + "UPDATE p SET name = 'bar' WHERE id = 1", + expect![[r#" +Update on p SET (p.name = String("bar")) + -> Index Scan using Index id 0 on p + Index Cond: (p.id = U64(1)) + Output: void"#]], + ); + + check_query( + &db, + "UPDATE p SET id = 2 WHERE name = 'bar'", + expect![[r#" +Update on p SET (p.id = U64(2)) + -> Seq Scan on p + Filter: (p.name = String("bar")) + Output: void"#]], + ); + } + + #[test] + fn delete() { + let db = data(); + + check_query( + &db, + "DELETE FROM p", + expect![[r#" +Delete on p + -> Seq Scan on p + Output: void"#]], + ); + + check_query( + &db, + "DELETE FROM p WHERE id = 1", + expect![[r#" +Delete on p + -> Seq Scan on p + Filter: (p.id = U64(1)) + Output: void"#]], + ); + } } diff --git a/crates/physical-plan/src/printer.rs b/crates/physical-plan/src/printer.rs index 2275f424700..c201d795b1d 100644 --- a/crates/physical-plan/src/printer.rs +++ b/crates/physical-plan/src/printer.rs @@ -1,3 +1,4 @@ +use crate::dml::MutationPlan; use crate::plan::{IxScan, Label, PhysicalExpr, PhysicalPlan, ProjectListPlan, ProjectPlan, Sarg, Semi, TupleField}; use crate::{PhysicalCtx, PlanCtx}; use itertools::Itertools; @@ -235,6 +236,19 @@ pub enum Line<'a> { rhs: Field<'a>, ident: u16, }, + Insert { + table_name: &'a str, + ident: u16, + }, + Update { + table_name: &'a str, + columns: Vec<(Field<'a>, &'a AlgebraicValue)>, + ident: u16, + }, + Delete { + table_name: &'a str, + ident: u16, + }, } impl Line<'_> { @@ -248,6 +262,9 @@ impl Line<'_> { Line::HashJoin { ident, .. } => *ident, Line::NlJoin { ident, .. } => *ident, Line::JoinExpr { ident, .. } => *ident, + Line::Insert { ident, .. } => *ident, + Line::Update { ident, .. } => *ident, + Line::Delete { ident, .. } => *ident, }; ident as usize } @@ -265,6 +282,7 @@ enum Output<'a> { Unknown, Star(Vec>), Fields(Vec>), + Empty, } impl<'a> Output<'a> { @@ -283,6 +301,22 @@ impl<'a> Output<'a> { }) .collect() } + + fn fields_update( + schema: &'a TableSchema, + fields: &'a [(ColId, AlgebraicValue)], + ) -> Vec<(Field<'a>, &'a AlgebraicValue)> { + fields + .iter() + .map(|(col, value)| { + let field = Field { + table: schema.table_name.as_ref(), + field: &schema.get_column(col.idx()).unwrap().col_name, + }; + (field, value) + }) + .collect() + } } /// A list of lines to print @@ -430,6 +464,39 @@ fn eval_plan<'a>(lines: &mut Lines<'a>, plan: &'a PhysicalPlan, ident: u16) { } } +fn eval_dml_plan<'a>(lines: &mut Lines<'a>, plan: &'a MutationPlan, ident: u16) { + match plan { + MutationPlan::Insert(plan) => { + let schema = &plan.table; + + lines.add(Line::Insert { + table_name: &schema.table_name, + ident, + }); + } + + MutationPlan::Delete(plan) => { + let schema = &plan.table; + + lines.add(Line::Delete { + table_name: &schema.table_name, + ident, + }); + eval_plan(lines, &plan.filter, ident + 2); + } + MutationPlan::Update(plan) => { + let schema = &plan.table; + + lines.add(Line::Update { + table_name: &schema.table_name, + columns: Output::fields_update(schema, &plan.columns), + ident, + }); + eval_plan(lines, &plan.filter, ident + 2); + } + } + lines.output = Output::Empty; +} /// A pretty printer for physical plans /// /// The printer will format the plan in a human-readable format, suitable for the `EXPLAIN` command. @@ -483,8 +550,8 @@ impl<'a> Explain<'a> { lines.output = Output::Fields(Output::tuples(fields, &lines)); } }, - PlanCtx::DML(_plan) => { - todo!() + PlanCtx::DML(plan) => { + eval_dml_plan(&mut lines, plan, 0); } } @@ -667,6 +734,23 @@ impl fmt::Display for Explain<'_> { writeln!(f, "{:ident$}Inner Unique: {unique}", "")?; write!(f, "{:ident$}Join Cond: ({} = {})", "", lhs, rhs)?; } + Line::Insert { table_name, ident: _ } => { + write!(f, "{:ident$}{arrow}Insert on {table_name}", "")?; + } + Line::Update { + table_name, + columns, + ident: _, + } => { + let columns = columns + .iter() + .map(|(field, value)| format!("{} = {:?}", field, value)) + .join(", "); + write!(f, "{:ident$}{arrow}Update on {table_name} SET ({columns })", "")?; + } + Line::Delete { table_name, ident: _ } => { + write!(f, "{:ident$}{arrow}Delete on {table_name}", "")?; + } } writeln!(f)?; } @@ -681,6 +765,7 @@ impl fmt::Display for Explain<'_> { let columns = fields.iter().map(|x| format!("{}", x)).join(", "); Some(columns) } + Output::Empty => Some("void".to_string()), }; let end = if self.options.show_timings || self.options.show_schema { "\n" From d3d1b058280f8c79a33758c74dd54c35a7789fef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mario=20Alejandro=20Montoya=20Corte=CC=81s?= Date: Fri, 27 Dec 2024 13:01:56 -0500 Subject: [PATCH 3/3] Moving sql test on core to EXPLAIN --- crates/core/src/db/relational_db.rs | 19 + crates/core/src/sql/compiler.rs | 683 ++++++++----------- crates/core/src/subscription/subscription.rs | 50 +- 3 files changed, 335 insertions(+), 417 deletions(-) diff --git a/crates/core/src/db/relational_db.rs b/crates/core/src/db/relational_db.rs index 02f01d92d9c..994baddcec2 100644 --- a/crates/core/src/db/relational_db.rs +++ b/crates/core/src/db/relational_db.rs @@ -1309,10 +1309,15 @@ fn default_row_count_fn(db: Identity) -> RowCountFn { #[cfg(any(test, feature = "test"))] pub mod tests_utils { use super::*; + use crate::sql::ast::{SchemaViewer, TableSchemaView}; use core::ops::Deref; use durability::EmptyHistory; + use expect_test::Expect; + use spacetimedb_lib::identity::AuthCtx; use spacetimedb_lib::{bsatn::to_vec, ser::Serialize}; use spacetimedb_paths::FromPathUnchecked; + use spacetimedb_physical_plan::plan::tests_utils::*; + use spacetimedb_physical_plan::printer::ExplainOptions; use tempfile::TempDir; /// A [`RelationalDB`] in a temporary directory. @@ -1600,6 +1605,20 @@ pub mod tests_utils { Self(log) } } + + pub fn expect_query(tx: &T, sql: &str, expect: Expect) { + let auth = AuthCtx::for_testing(); + let schema = SchemaViewer::new(tx, &auth); + + check_query(&schema, ExplainOptions::default(), sql, expect) + } + + pub fn expect_sub(tx: &T, sql: &str, expect: Expect) { + let auth = AuthCtx::for_testing(); + let schema = SchemaViewer::new(tx, &auth); + + check_sub(&schema, ExplainOptions::default(), sql, expect) + } } #[cfg(test)] diff --git a/crates/core/src/sql/compiler.rs b/crates/core/src/sql/compiler.rs index 2404114f708..8ee79c83397 100644 --- a/crates/core/src/sql/compiler.rs +++ b/crates/core/src/sql/compiler.rs @@ -231,16 +231,18 @@ fn compile_statement(db: &RelationalDB, statement: SqlAst) -> Result, val: AlgebraicValue) -> TableId { - let val = Bound::Included(val); - assert_index_scan(op, cols, val.clone(), val) - } - - fn assert_select(op: &Query) { - assert!(matches!(op, Query::Select(_))); - } - fn compile_sql( db: &RelationalDB, tx: &T, @@ -277,69 +270,6 @@ mod tests { super::compile_sql(db, &AuthCtx::for_testing(), tx, sql) } - #[test] - fn compile_eq() -> ResultTest<()> { - let db = TestDB::durable()?; - - // Create table [test] without any indexes - let schema = &[("a", AlgebraicType::U64)]; - let indexes = &[]; - db.create_table_for_test("test", schema, indexes)?; - - let tx = db.begin_tx(Workload::ForTests); - // Compile query - let sql = "select * from test where a = 1"; - let CrudExpr::Query(QueryExpr { source: _, query }) = compile_sql(&db, &tx, sql)?.remove(0) else { - panic!("Expected QueryExpr"); - }; - assert_eq!(1, query.len()); - assert_select(&query[0]); - Ok(()) - } - - #[test] - fn compile_not_eq() -> ResultTest<()> { - let db = TestDB::durable()?; - - // Create table [test] with cols [a, b] and index on [b]. - db.create_table_for_test( - "test", - &[("a", AlgebraicType::U64), ("b", AlgebraicType::U64)], - &[1.into(), 0.into()], - )?; - - let tx = db.begin_tx(Workload::ForTests); - // Should work with any qualified field. - let sql = "select * from test where a = 1 and b <> 3"; - let CrudExpr::Query(QueryExpr { source: _, query }) = compile_sql(&db, &tx, sql)?.remove(0) else { - panic!("Expected QueryExpr"); - }; - assert_eq!(2, query.len()); - assert_one_eq_index_scan(&query[0], 0, 1u64.into()); - assert_select(&query[1]); - Ok(()) - } - - #[test] - fn compile_index_eq_basic() -> ResultTest<()> { - let db = TestDB::durable()?; - - // Create table [test] with index on [a] - let schema = &[("a", AlgebraicType::U64)]; - let indexes = &[0.into()]; - db.create_table_for_test("test", schema, indexes)?; - - let tx = db.begin_tx(Workload::ForTests); - //Compile query - let sql = "select * from test where a = 1"; - let CrudExpr::Query(QueryExpr { source: _, query }) = compile_sql(&db, &tx, sql)?.remove(0) else { - panic!("Expected QueryExpr"); - }; - assert_eq!(1, query.len()); - assert_one_eq_index_scan(&query[0], 0, 1u64.into()); - Ok(()) - } - #[test] fn compile_eq_identity_address() -> ResultTest<()> { let db = TestDB::durable()?; @@ -383,20 +313,17 @@ mod tests { let rows = run_for_testing(&db, sql)?; let tx = db.begin_tx(Workload::ForTests); - let CrudExpr::Query(QueryExpr { - source: _, - query: mut ops, - }) = compile_sql(&db, &tx, sql)?.remove(0) - else { - panic!("Expected QueryExpr"); - }; - - assert_eq!(1, ops.len()); - - // Assert no index scan - let Query::Select(_) = ops.remove(0) else { - panic!("Expected Select"); - }; + //TODO(sql): Need support for `hex` for `Identity` and `Address` + expect_sub( + &tx, + sql, + expect![ + r#" +Seq Scan on test + Filter: (test.identity = Product(ProductValue { elements: [U256(0)] }) AND test.identity_mix = Product(ProductValue { elements: [U256(66881570474734122340206661186171449911238385879609763950734840199973682801998)] }) AND test.address = Product(ProductValue { elements: [U128(Packed(0))] }) AND test.address = Product(ProductValue { elements: [U128(Packed(0))] })) + Output: test.identity, test.identity_mix, test.address"# + ], + ); assert_eq!(rows, vec![row]); @@ -457,24 +384,81 @@ mod tests { } #[test] - fn compile_eq_and_eq() -> ResultTest<()> { + fn compile_eq() -> ResultTest<()> { let db = TestDB::durable()?; - // Create table [test] with index on [b] - let schema = &[("a", AlgebraicType::U64), ("b", AlgebraicType::U64)]; - let indexes = &[1.into()]; + // Create table [test] without any indexes + let schema = &[("a", AlgebraicType::U64)]; + let indexes = &[]; db.create_table_for_test("test", schema, indexes)?; let tx = db.begin_tx(Workload::ForTests); - // Note, order does not matter. - // The sargable predicate occurs last, but we can still generate an index scan. - let sql = "select * from test where a = 1 and b = 2"; - let CrudExpr::Query(QueryExpr { source: _, query }) = compile_sql(&db, &tx, sql)?.remove(0) else { - panic!("Expected QueryExpr"); - }; - assert_eq!(2, query.len()); - assert_one_eq_index_scan(&query[0], 1, 2u64.into()); - assert_select(&query[1]); + + expect_sub( + &tx, + "select * from test where a = 1", + expect![ + r#" + Seq Scan on test + Filter: (test.a = U64(1)) + Output: test.a"# + ], + ); + + Ok(()) + } + + #[test] + fn compile_not_eq() -> ResultTest<()> { + let db = TestDB::durable()?; + + // Create table [test] with cols [a, b] and index on [b]. + db.create_table_for_test( + "test", + &[("a", AlgebraicType::U64), ("b", AlgebraicType::U64)], + &[1.into(), 0.into()], + )?; + + let tx = db.begin_tx(Workload::ForTests); + // Should work with any qualified field. + + expect_sub( + &tx, + "select * from test where a = 1 and b <> 3", + expect![ + r#" +Index Scan using Index test_a_idx_btree on test + Index Cond: (test.a = U64(1)) + Filter: (test.b <> U64(3)) + Output: test.a, test.b"# + ], + ); + + Ok(()) + } + + #[test] + fn compile_index_eq_basic() -> ResultTest<()> { + let db = TestDB::durable()?; + + // Create table [test] with index on [a] + let schema = &[("a", AlgebraicType::U64)]; + let indexes = &[0.into()]; + db.create_table_for_test("test", schema, indexes)?; + + let tx = db.begin_tx(Workload::ForTests); + + expect_query( + &tx, + "select * from test where a = 1", + expect![ + r#" +Index Scan using Index test_a_idx_btree on test + Index Cond: (test.a = U64(1)) + Output: test.a"# + ], + ); + Ok(()) } @@ -490,13 +474,18 @@ mod tests { let tx = db.begin_tx(Workload::ForTests); // Note, order does not matter. // The sargable predicate occurs first adn we can generate an index scan. - let sql = "select * from test where b = 2 and a = 1"; - let CrudExpr::Query(QueryExpr { source: _, query }) = compile_sql(&db, &tx, sql)?.remove(0) else { - panic!("Expected QueryExpr"); - }; - assert_eq!(2, query.len()); - assert_one_eq_index_scan(&query[0], 1, 2u64.into()); - assert_select(&query[1]); + expect_query( + &tx, + "select * from test where b = 2 and a = 1", + expect![ + r#" +Index Scan using Index test_b_idx_btree on test + Index Cond: (test.b = U64(2)) + Filter: (test.a = U64(1)) + Output: test.a, test.b"# + ], + ); + Ok(()) } @@ -514,12 +503,18 @@ mod tests { db.create_table_for_test_multi_column("test", schema, col_list![0, 1])?; let tx = db.begin_mut_tx(IsolationLevel::Serializable, Workload::ForTests); - let sql = "select * from test where b = 2 and a = 1"; - let CrudExpr::Query(QueryExpr { source: _, query }) = compile_sql(&db, &tx, sql)?.remove(0) else { - panic!("Expected QueryExpr"); - }; - assert_eq!(1, query.len()); - assert_one_eq_index_scan(&query[0], col_list![0, 1], product![1u64, 2u64].into()); + + expect_query( + &tx, + "select * from test where b = 2 and a = 1", + expect![ + r#" +Index Scan using Index test_a_b_idx_btree on test + Index Cond: (test.a = U64(1), test.b = U64(2)) + Output: test.a, test.b, test.c, test.d"# + ], + ); + Ok(()) } @@ -533,14 +528,18 @@ mod tests { db.create_table_for_test("test", schema, indexes)?; let tx = db.begin_tx(Workload::ForTests); - // Compile query - let sql = "select * from test where a = 1 or b = 2"; - let CrudExpr::Query(QueryExpr { source: _, query }) = compile_sql(&db, &tx, sql)?.remove(0) else { - panic!("Expected QueryExpr"); - }; - assert_eq!(1, query.len()); - // Assert no index scan because OR is not sargable. - assert_select(&query[0]); + + expect_query( + &tx, + "select * from test where a = 1 or b = 2", + expect![ + r#" +Seq Scan on test + Filter: (test.a = U64(1) OR test.b = U64(2)) + Output: test.a, test.b"# + ], + ); + Ok(()) } @@ -556,11 +555,18 @@ mod tests { let tx = db.begin_tx(Workload::ForTests); // Compile query let sql = "select * from test where b > 2"; - let CrudExpr::Query(QueryExpr { source: _, query }) = compile_sql(&db, &tx, sql)?.remove(0) else { - panic!("Expected QueryExpr"); - }; - assert_eq!(1, query.len()); - assert_index_scan(&query[0], 1, Bound::Excluded(AlgebraicValue::U64(2)), Bound::Unbounded); + + //TODO(sql): Need support for index scans for ranges + expect_query( + &tx, + sql, + expect![ + r#" + Index Scan on test + Filter: (test.b > U64(2)) + Output: test.a, test.b"# + ], + ); Ok(()) } @@ -588,6 +594,18 @@ mod tests { Bound::Excluded(AlgebraicValue::U64(5)), ); + //TODO(sql): Need support for index scans for ranges + expect_query( + &tx, + sql, + expect![ + r#" + Index Scan on test + Filter: (test.b > U64(2) AND test.b < U64(5)) + Output: test.a, test.b"# + ], + ); + Ok(()) } @@ -603,13 +621,18 @@ mod tests { let tx = db.begin_tx(Workload::ForTests); // Note, order matters - the equality condition occurs first which // means an index scan will be generated rather than the range condition. - let sql = "select * from test where a = 3 and b > 2 and b < 5"; - let CrudExpr::Query(QueryExpr { source: _, query }) = compile_sql(&db, &tx, sql)?.remove(0) else { - panic!("Expected QueryExpr"); - }; - assert_eq!(2, query.len()); - assert_one_eq_index_scan(&query[0], 0, 3u64.into()); - assert_select(&query[1]); + expect_query( + &tx, + "select * from test where a = 3 and b > 2 and b < 5", + expect![ + r#" +Index Scan using Index test_a_idx_btree on test + Index Cond: (test.a = U64(3)) + Filter: (test.b < U64(5) AND test.b > U64(2)) + Output: test.a, test.b"# + ], + ); + Ok(()) } @@ -620,50 +643,30 @@ mod tests { // Create table [lhs] with index on [a] let schema = &[("a", AlgebraicType::U64), ("b", AlgebraicType::U64)]; let indexes = &[0.into()]; - let lhs_id = db.create_table_for_test("lhs", schema, indexes)?; + db.create_table_for_test("lhs", schema, indexes)?; // Create table [rhs] with no indexes let schema = &[("b", AlgebraicType::U64), ("c", AlgebraicType::U64)]; let indexes = &[]; - let rhs_id = db.create_table_for_test("rhs", schema, indexes)?; + db.create_table_for_test("rhs", schema, indexes)?; let tx = db.begin_tx(Workload::ForTests); // Should push sargable equality condition below join - let sql = "select lhs.* from lhs join rhs on lhs.b = rhs.b where lhs.a = 3"; - let exp = compile_sql(&db, &tx, sql)?.remove(0); - - let CrudExpr::Query(QueryExpr { - source: source_lhs, - query, - .. - }) = exp - else { - panic!("unexpected expression: {:#?}", exp); - }; - - assert_eq!(source_lhs.table_id().unwrap(), lhs_id); - assert_eq!(query.len(), 3); - - // First operation in the pipeline should be an index scan - let table_id = assert_one_eq_index_scan(&query[0], 0, 3u64.into()); - - assert_eq!(table_id, lhs_id); - - // Followed by a join with the rhs table - let Query::JoinInner(JoinExpr { - ref rhs, - col_lhs, - col_rhs, - inner: Some(ref inner_header), - }) = query[1] - else { - panic!("unexpected operator {:#?}", query[1]); - }; + expect_sub( + &tx, + "select lhs.* from lhs join rhs on lhs.b = rhs.b where lhs.a = 3", + expect![ + r#" +Hash Join: Lhs + -> Index Scan using Index lhs_a_idx_btree on lhs + Index Cond: (lhs.a = U64(3)) + -> Seq Scan on rhs + Inner Unique: false + Join Cond: (lhs.b = rhs.b) + Output: lhs.a, lhs.b"# + ], + ); - assert_eq!(rhs.source.table_id().unwrap(), rhs_id); - assert_eq!(col_lhs, 1.into()); - assert_eq!(col_rhs, 0.into()); - assert_eq!(&**inner_header, &source_lhs.head().extend(rhs.source.head())); Ok(()) } @@ -673,54 +676,29 @@ mod tests { // Create table [lhs] with no indexes let schema = &[("a", AlgebraicType::U64), ("b", AlgebraicType::U64)]; - let lhs_id = db.create_table_for_test("lhs", schema, &[])?; + db.create_table_for_test("lhs", schema, &[])?; // Create table [rhs] with no indexes let schema = &[("b", AlgebraicType::U64), ("c", AlgebraicType::U64)]; - let rhs_id = db.create_table_for_test("rhs", schema, &[])?; + db.create_table_for_test("rhs", schema, &[])?; let tx = db.begin_tx(Workload::ForTests); // Should push equality condition below join - let sql = "select lhs.* from lhs join rhs on lhs.b = rhs.b where lhs.a = 3"; - let exp = compile_sql(&db, &tx, sql)?.remove(0); - - let CrudExpr::Query(QueryExpr { - source: source_lhs, - query, - .. - }) = exp - else { - panic!("unexpected expression: {:#?}", exp); - }; - assert_eq!(source_lhs.table_id().unwrap(), lhs_id); - assert_eq!(query.len(), 3); - - // The first operation in the pipeline should be a selection with `col#0 = 3` - let Query::Select(ColumnOp::ColCmpVal { - cmp: OpCmp::Eq, - lhs: ColId(0), - rhs: AlgebraicValue::U64(3), - }) = query[0] - else { - panic!("unexpected operator {:#?}", query[0]); - }; - - // The join should follow the selection - let Query::JoinInner(JoinExpr { - ref rhs, - col_lhs, - col_rhs, - inner: Some(ref inner_header), - }) = query[1] - else { - panic!("unexpected operator {:#?}", query[1]); - }; + expect_query( + &tx, + "select lhs.* from lhs join rhs on lhs.b = rhs.b where lhs.a = 3", + expect![ + r#" +Hash Join: Lhs + -> Seq Scan on lhs + Filter: (lhs.a = U64(3)) + -> Seq Scan on rhs + Inner Unique: false + Join Cond: (lhs.b = rhs.b) + Output: lhs.a, lhs.b"# + ], + ); - assert_eq!(rhs.source.table_id().unwrap(), rhs_id); - assert_eq!(col_lhs, 1.into()); - assert_eq!(col_rhs, 0.into()); - assert_eq!(&**inner_header, &source_lhs.head().extend(rhs.source.head())); - assert!(rhs.query.is_empty()); Ok(()) } @@ -730,53 +708,29 @@ mod tests { // Create table [lhs] with no indexes let schema = &[("a", AlgebraicType::U64), ("b", AlgebraicType::U64)]; - let lhs_id = db.create_table_for_test("lhs", schema, &[])?; + db.create_table_for_test("lhs", schema, &[])?; // Create table [rhs] with no indexes let schema = &[("b", AlgebraicType::U64), ("c", AlgebraicType::U64)]; - let rhs_id = db.create_table_for_test("rhs", schema, &[])?; + db.create_table_for_test("rhs", schema, &[])?; let tx = db.begin_tx(Workload::ForTests); - // Should push equality condition below join - let sql = "select lhs.* from lhs join rhs on lhs.b = rhs.b where rhs.c = 3"; - let exp = compile_sql(&db, &tx, sql)?.remove(0); - - let CrudExpr::Query(QueryExpr { - source: source_lhs, - query, - .. - }) = exp - else { - panic!("unexpected expression: {:#?}", exp); - }; - assert_eq!(source_lhs.table_id().unwrap(), lhs_id); - assert_eq!(query.len(), 1); - - // First and only operation in the pipeline should be a join - let Query::JoinInner(JoinExpr { - ref rhs, - col_lhs, - col_rhs, - inner: None, - }) = query[0] - else { - panic!("unexpected operator {:#?}", query[0]); - }; + expect_query( + &tx, + "select lhs.* from lhs join rhs on lhs.b = rhs.b where rhs.c = 3", + expect![ + r#" +Hash Join: Rhs + -> Seq Scan on rhs + Filter: (rhs.c = U64(3)) + -> Seq Scan on lhs + Inner Unique: false + Join Cond: (rhs.b = lhs.b) + Output: lhs.a, lhs.b"# + ], + ); - assert_eq!(rhs.source.table_id().unwrap(), rhs_id); - assert_eq!(col_lhs, 1.into()); - assert_eq!(col_rhs, 0.into()); - - // The selection should be pushed onto the rhs of the join - let Query::Select(ColumnOp::ColCmpVal { - cmp: OpCmp::Eq, - lhs: ColId(1), - rhs: AlgebraicValue::U64(3), - }) = rhs.query[0] - else { - panic!("unexpected operator {:#?}", rhs.query[0]); - }; Ok(()) } @@ -787,63 +741,32 @@ mod tests { // Create table [lhs] with index on [a] let schema = &[("a", AlgebraicType::U64), ("b", AlgebraicType::U64)]; let indexes = &[0.into()]; - let lhs_id = db.create_table_for_test("lhs", schema, indexes)?; + db.create_table_for_test("lhs", schema, indexes)?; // Create table [rhs] with index on [c] let schema = &[("b", AlgebraicType::U64), ("c", AlgebraicType::U64)]; let indexes = &[1.into()]; - let rhs_id = db.create_table_for_test("rhs", schema, indexes)?; + db.create_table_for_test("rhs", schema, indexes)?; let tx = db.begin_tx(Workload::ForTests); // Should push the sargable equality condition into the join's left arg. // Should push the sargable range condition into the join's right arg. - let sql = "select lhs.* from lhs join rhs on lhs.b = rhs.b where lhs.a = 3 and rhs.c < 4"; - let exp = compile_sql(&db, &tx, sql)?.remove(0); - - let CrudExpr::Query(QueryExpr { - source: source_lhs, - query, - .. - }) = exp - else { - panic!("unexpected result from compilation: {:?}", exp); - }; - - assert_eq!(source_lhs.table_id().unwrap(), lhs_id); - assert_eq!(query.len(), 3); - - // First operation in the pipeline should be an index scan - let table_id = assert_one_eq_index_scan(&query[0], 0, 3u64.into()); - - assert_eq!(table_id, lhs_id); - - // Followed by a join - let Query::JoinInner(JoinExpr { - ref rhs, - col_lhs, - col_rhs, - inner: Some(ref inner_header), - }) = query[1] - else { - panic!("unexpected operator {:#?}", query[1]); - }; - - assert_eq!(rhs.source.table_id().unwrap(), rhs_id); - assert_eq!(col_lhs, 1.into()); - assert_eq!(col_rhs, 0.into()); - assert_eq!(&**inner_header, &source_lhs.head().extend(rhs.source.head())); - - assert_eq!(1, rhs.query.len()); - - // The right side of the join should be an index scan - let table_id = assert_index_scan( - &rhs.query[0], - 1, - Bound::Unbounded, - Bound::Excluded(AlgebraicValue::U64(4)), + expect_sub( + &tx, + "select lhs.* from lhs join rhs on lhs.b = rhs.b where lhs.a = 3 and rhs.c < 4", + expect![ + r#" +Hash Join: Lhs + -> Index Scan using Index lhs_a_idx_btree on lhs + Index Cond: (lhs.a = U64(3)) + -> Seq Scan on rhs + Filter: (rhs.c < U64(4)) + Inner Unique: false + Join Cond: (lhs.b = rhs.b) + Output: lhs.a, lhs.b"# + ], ); - assert_eq!(table_id, rhs_id); Ok(()) } @@ -854,7 +777,7 @@ mod tests { // Create table [lhs] with index on [b] let schema = &[("a", AlgebraicType::U64), ("b", AlgebraicType::U64)]; let indexes = &[1.into()]; - let lhs_id = db.create_table_for_test("lhs", schema, indexes)?; + db.create_table_for_test("lhs", schema, indexes)?; // Create table [rhs] with index on [b, c] let schema = &[ @@ -863,69 +786,25 @@ mod tests { ("d", AlgebraicType::U64), ]; let indexes = &[0.into(), 1.into()]; - let rhs_id = db.create_table_for_test("rhs", schema, indexes)?; + db.create_table_for_test("rhs", schema, indexes)?; let tx = db.begin_tx(Workload::ForTests); // Should generate an index join since there is an index on `lhs.b`. // Should push the sargable range condition into the index join's probe side. - let sql = "select lhs.* from lhs join rhs on lhs.b = rhs.b where rhs.c > 2 and rhs.c < 4 and rhs.d = 3"; - let exp = compile_sql(&db, &tx, sql)?.remove(0); - - let CrudExpr::Query(QueryExpr { - source: SourceExpr::DbTable(DbTable { table_id, .. }), - query, - .. - }) = exp - else { - panic!("unexpected result from compilation: {:?}", exp); - }; - - assert_eq!(table_id, lhs_id); - assert_eq!(query.len(), 1); - - let Query::IndexJoin(IndexJoin { - probe_side: - QueryExpr { - source: SourceExpr::DbTable(DbTable { table_id, .. }), - query: rhs, - }, - probe_col, - index_side: SourceExpr::DbTable(DbTable { - table_id: index_table, .. - }), - index_col, - .. - }) = &query[0] - else { - panic!("unexpected operator {:#?}", query[0]); - }; - - assert_eq!(*table_id, rhs_id); - assert_eq!(*index_table, lhs_id); - assert_eq!(index_col, &1.into()); - assert_eq!(*probe_col, 0.into()); - - assert_eq!(2, rhs.len()); - - // The probe side of the join should be an index scan - let table_id = assert_index_scan( - &rhs[0], - 1, - Bound::Excluded(AlgebraicValue::U64(2)), - Bound::Excluded(AlgebraicValue::U64(4)), + expect_sub( + &tx, + "select lhs.* from lhs join rhs on lhs.b = rhs.b where rhs.c > 2 and rhs.c < 4 and rhs.d = 3", + expect![ + r#" +Index Join: Rhs on lhs + -> Seq Scan on rhs + Filter: (rhs.c > U64(2) AND rhs.c < U64(4) AND rhs.d = U64(3)) + Inner Unique: false + Join Cond: (rhs.b = lhs.b) + Output: lhs.a, lhs.b"# + ], ); - assert_eq!(table_id, rhs_id); - - // Followed by a selection - let Query::Select(ColumnOp::ColCmpVal { - cmp: OpCmp::Eq, - lhs: ColId(2), - rhs: AlgebraicValue::U64(3), - }) = rhs[1] - else { - panic!("unexpected operator {:#?}", rhs[0]); - }; Ok(()) } @@ -936,7 +815,7 @@ mod tests { // Create table [lhs] with index on [b] let schema = &[("a", AlgebraicType::U64), ("b", AlgebraicType::U64)]; let indexes = &[1.into()]; - let lhs_id = db.create_table_for_test("lhs", schema, indexes)?; + db.create_table_for_test("lhs", schema, indexes)?; // Create table [rhs] with index on [b, c] let schema = &[ @@ -945,64 +824,26 @@ mod tests { ("d", AlgebraicType::U64), ]; let indexes = col_list![0, 1]; - let rhs_id = db.create_table_for_test_multi_column("rhs", schema, indexes)?; + db.create_table_for_test_multi_column("rhs", schema, indexes)?; let tx = db.begin_tx(Workload::ForTests); // Should generate an index join since there is an index on `lhs.b`. // Should push the sargable range condition into the index join's probe side. - let sql = "select lhs.* from lhs join rhs on lhs.b = rhs.b where rhs.c = 2 and rhs.b = 4 and rhs.d = 3"; - let exp = compile_sql(&db, &tx, sql)?.remove(0); - - let CrudExpr::Query(QueryExpr { - source: SourceExpr::DbTable(DbTable { table_id, .. }), - query, - .. - }) = exp - else { - panic!("unexpected result from compilation: {:?}", exp); - }; - - assert_eq!(table_id, lhs_id); - assert_eq!(query.len(), 1); - - let Query::IndexJoin(IndexJoin { - probe_side: - QueryExpr { - source: SourceExpr::DbTable(DbTable { table_id, .. }), - query: rhs, - }, - probe_col, - index_side: SourceExpr::DbTable(DbTable { - table_id: index_table, .. - }), - index_col, - .. - }) = &query[0] - else { - panic!("unexpected operator {:#?}", query[0]); - }; - - assert_eq!(*table_id, rhs_id); - assert_eq!(*index_table, lhs_id); - assert_eq!(index_col, &1.into()); - assert_eq!(*probe_col, 0.into()); - - assert_eq!(2, rhs.len()); - - // The probe side of the join should be an index scan - let table_id = assert_one_eq_index_scan(&rhs[0], col_list![0, 1], product![4u64, 2u64].into()); - - assert_eq!(table_id, rhs_id); + expect_sub( + &tx, + "select lhs.* from lhs join rhs on lhs.b = rhs.b where rhs.c = 2 and rhs.b = 4 and rhs.d = 3", + expect![ + r#" +Index Join: Rhs on lhs + -> Index Scan using Index rhs_b_c_idx_btree on rhs + Index Cond: (rhs.b = U64(4), rhs.c = U64(2)) + Filter: (rhs.d = U64(3)) + Inner Unique: false + Join Cond: (rhs.b = lhs.b) + Output: lhs.a, lhs.b"# + ], + ); - // Followed by a selection - let Query::Select(ColumnOp::ColCmpVal { - cmp: OpCmp::Eq, - lhs: ColId(2), - rhs: AlgebraicValue::U64(3), - }) = rhs[1] - else { - panic!("unexpected operator {:#?}", rhs[0]); - }; Ok(()) } @@ -1011,12 +852,22 @@ mod tests { let db = TestDB::durable()?; db.create_table_for_test("A", &[("x", AlgebraicType::U64)], &[])?; db.create_table_for_test("B", &[("y", AlgebraicType::U64)], &[])?; - assert!(compile_sql( - &db, - &db.begin_tx(Workload::ForTests), - "select B.* from B join A on B.y = A.x" - ) - .is_ok()); + + let tx = db.begin_tx(Workload::ForTests); + expect_sub( + &tx, + "select B.* from B join A on B.y = A.x", + expect![ + r#" +Hash Join: Lhs + -> Seq Scan on B + -> Seq Scan on A + Inner Unique: false + Join Cond: (B.y = A.x) + Output: B.y"# + ], + ); + Ok(()) } diff --git a/crates/core/src/subscription/subscription.rs b/crates/core/src/subscription/subscription.rs index b63e369863f..52019f61964 100644 --- a/crates/core/src/subscription/subscription.rs +++ b/crates/core/src/subscription/subscription.rs @@ -653,9 +653,10 @@ pub(crate) fn legacy_get_all( #[cfg(test)] mod tests { use super::*; - use crate::db::relational_db::tests_utils::TestDB; + use crate::db::relational_db::tests_utils::{expect_sub, TestDB}; use crate::execution_context::Workload; use crate::sql::compiler::compile_sql; + use expect_test::expect; use spacetimedb_lib::relation::DbTable; use spacetimedb_lib::{error::ResultTest, identity::AuthCtx}; use spacetimedb_sats::{product, AlgebraicType}; @@ -699,6 +700,21 @@ mod tests { panic!("expected an index join, but got {:#?}", join); }; + //TODO(sql): Remove manual checks to just `EXPLAIN` the query. + expect_sub( + &tx, + sql, + expect![ + r#" +Index Join: Rhs on lhs + -> Seq Scan on rhs + Filter: (rhs.c > U64(2) AND rhs.c < U64(4) AND rhs.d = U64(3)) + Inner Unique: false + Join Cond: (rhs.b = lhs.b) + Output: lhs.a, lhs.b"# + ], + ); + // Create an insert for an incremental update. let delta = vec![product![0u64, 0u64]]; @@ -779,6 +795,22 @@ mod tests { panic!("expected an index join, but got {:#?}", join); }; + //TODO(sql): Remove manual checks to just `EXPLAIN` the query. + // Why this generate same plan than the previous test? 'compile_incremental_index_join_index_side' + expect_sub( + &tx, + sql, + expect![ + r#" +Index Join: Rhs on lhs + -> Seq Scan on rhs + Filter: (rhs.c > U64(2) AND rhs.c < U64(4) AND rhs.d = U64(3)) + Inner Unique: false + Join Cond: (rhs.b = lhs.b) + Output: lhs.a, lhs.b"# + ], + ); + // Create an insert for an incremental update. let delta = vec![product![0u64, 0u64, 0u64]]; @@ -868,6 +900,22 @@ mod tests { src_join ); + //TODO(sql): Remove manual checks to just `EXPLAIN` the query. + // Why this generate same plan than the previous test? 'compile_incremental_index_join_index_side' + expect_sub( + &tx, + sql, + expect![ + r#" +Index Join: Rhs on lhs + -> Seq Scan on rhs + Filter: (rhs.c > U64(2) AND rhs.c < U64(4) AND rhs.d = U64(3)) + Inner Unique: false + Join Cond: (rhs.b = lhs.b) + Output: lhs.a, lhs.b"# + ], + ); + let incr = IncrementalJoin::new(&expr).expect("Failed to construct IncrementalJoin"); let virtual_plan = &incr.virtual_plan;