From 1e30eb4aa15b26a2ce5a36d539d42f85f6f9e753 Mon Sep 17 00:00:00 2001 From: Emmanuel Keller Date: Fri, 14 Jul 2023 13:22:37 +0100 Subject: [PATCH] feat: Implements SELECT/EXPLAIN FULL (#2258) --- lib/src/dbs/explanation.rs | 100 ++++++++++++++++ lib/src/dbs/iterator.rs | 79 ++++-------- lib/src/dbs/mod.rs | 1 + lib/src/dbs/statement.rs | 7 +- lib/src/sql/explain.rs | 52 ++++++++ lib/src/sql/mod.rs | 2 + lib/src/sql/statements/select.rs | 10 +- lib/src/sql/value/serde/ser/explain/mod.rs | 1 + lib/src/sql/value/serde/ser/explain/opt.rs | 74 ++++++++++++ lib/src/sql/value/serde/ser/mod.rs | 1 + .../sql/value/serde/ser/statement/select.rs | 21 +++- lib/src/sql/value/value.rs | 2 +- lib/tests/matches.rs | 10 +- lib/tests/select.rs | 113 +++++++++++++++++- 14 files changed, 399 insertions(+), 74 deletions(-) create mode 100644 lib/src/dbs/explanation.rs create mode 100644 lib/src/sql/explain.rs create mode 100644 lib/src/sql/value/serde/ser/explain/mod.rs create mode 100644 lib/src/sql/value/serde/ser/explain/opt.rs diff --git a/lib/src/dbs/explanation.rs b/lib/src/dbs/explanation.rs new file mode 100644 index 00000000..726b2851 --- /dev/null +++ b/lib/src/dbs/explanation.rs @@ -0,0 +1,100 @@ +use crate::dbs::Iterable; +use crate::sql::{Explain, Object, Value}; +use std::collections::HashMap; + +#[derive(Default)] +pub(super) struct Explanation(Vec); + +impl Explanation { + pub(super) fn new(e: Option<&Explain>, iterables: &Vec) -> (bool, Option) { + match e { + None => (true, None), + Some(e) => { + let mut exp = Self::default(); + for i in iterables { + exp.add_iter(i); + } + (e.0, Some(exp)) + } + } + } + + fn add_iter(&mut self, iter: &Iterable) { + self.0.push(ExplainItem::new_iter(iter)); + } + + pub(super) fn add_fetch(&mut self, count: usize) { + self.0.push(ExplainItem::new_fetch(count)); + } + + pub(super) fn output(self, results: &mut Vec) { + for e in self.0 { + results.push(e.into()); + } + } +} + +struct ExplainItem { + name: Value, + details: Vec<(&'static str, Value)>, +} + +impl ExplainItem { + fn new_fetch(count: usize) -> Self { + Self { + name: "Fetch".into(), + details: vec![("count", count.into())], + } + } + + fn new_iter(iter: &Iterable) -> Self { + match iter { + Iterable::Value(v) => Self { + name: "Iterate Value".into(), + details: vec![("value", v.to_owned())], + }, + Iterable::Table(t) => Self { + name: "Iterate Table".into(), + details: vec![("table", Value::from(t.0.to_owned()))], + }, + Iterable::Thing(t) => Self { + name: "Iterate Thing".into(), + details: vec![("thing", Value::Thing(t.to_owned()))], + }, + Iterable::Range(r) => Self { + name: "Iterate Range".into(), + details: vec![("table", Value::from(r.tb.to_owned()))], + }, + Iterable::Edges(e) => Self { + name: "Iterate Edges".into(), + details: vec![("from", Value::Thing(e.from.to_owned()))], + }, + Iterable::Mergeable(t, v) => Self { + name: "Iterate Mergeable".into(), + details: vec![("thing", Value::Thing(t.to_owned())), ("value", v.to_owned())], + }, + Iterable::Relatable(t1, t2, t3) => Self { + name: "Iterate Relatable".into(), + details: vec![ + ("thing-1", Value::Thing(t1.to_owned())), + ("thing-2", Value::Thing(t2.to_owned())), + ("thing-3", Value::Thing(t3.to_owned())), + ], + }, + Iterable::Index(t, p) => Self { + name: "Iterate Index".into(), + details: vec![("table", Value::from(t.0.to_owned())), ("plan", p.explain())], + }, + } + } +} + +impl From for Value { + fn from(i: ExplainItem) -> Self { + let explain = Object::from(HashMap::from([ + ("operation", i.name), + ("detail", Value::Object(Object::from(HashMap::from_iter(i.details)))), + ])); + Value::from(explain) + } +} diff --git a/lib/src/dbs/iterator.rs b/lib/src/dbs/iterator.rs index 6266cdcf..7595414f 100644 --- a/lib/src/dbs/iterator.rs +++ b/lib/src/dbs/iterator.rs @@ -1,5 +1,6 @@ use crate::ctx::Canceller; use crate::ctx::Context; +use crate::dbs::explanation::Explanation; use crate::dbs::Statement; use crate::dbs::{Options, Transaction}; use crate::doc::CursorDoc; @@ -14,11 +15,10 @@ use crate::sql::range::Range; use crate::sql::table::Table; use crate::sql::thing::Thing; use crate::sql::value::Value; -use crate::sql::Object; use async_recursion::async_recursion; use std::borrow::Cow; use std::cmp::Ordering; -use std::collections::{BTreeMap, HashMap}; +use std::collections::BTreeMap; use std::mem; pub(crate) enum Iterable { @@ -88,8 +88,11 @@ impl Iterator { self.setup_limit(&cancel_ctx, opt, txn, stm).await?; // Process the query START clause self.setup_start(&cancel_ctx, opt, txn, stm).await?; - // Process any EXPLAIN clause - if !self.output_explain(&cancel_ctx, opt, txn, stm)? { + + // Extract the expected behaviour depending on the presence of EXPLAIN with or without FULL + let (do_iterate, mut explanation) = Explanation::new(stm.explain(), &self.entries); + + if do_iterate { // Process prepared values self.iterate(&cancel_ctx, opt, txn, stm).await?; // Return any document errors @@ -106,9 +109,21 @@ impl Iterator { self.output_start(ctx, opt, txn, stm).await?; // Process any LIMIT clause self.output_limit(ctx, opt, txn, stm).await?; - // Process any FETCH clause - self.output_fetch(ctx, opt, txn, stm).await?; + + if let Some(e) = &mut explanation { + e.add_fetch(self.results.len()); + self.results.clear(); + } else { + // Process any FETCH clause + self.output_fetch(ctx, opt, txn, stm).await?; + } } + + // Output the explanation if any + if let Some(e) = explanation { + e.output(&mut self.results); + } + // Output the results Ok(mem::take(&mut self.results).into()) } @@ -352,58 +367,6 @@ impl Iterator { Ok(()) } - #[inline] - fn output_explain( - &mut self, - _ctx: &Context<'_>, - _opt: &Options, - _txn: &Transaction, - stm: &Statement<'_>, - ) -> Result { - if !stm.explain() { - return Ok(false); - } - for iter in &self.entries { - let (operation, detail) = match iter { - Iterable::Value(v) => ("Iterate Value", vec![("value", v.to_owned())]), - Iterable::Table(t) => { - ("Iterate Table", vec![("table", Value::from(t.0.to_owned()))]) - } - Iterable::Thing(t) => { - ("Iterate Thing", vec![("thing", Value::Thing(t.to_owned()))]) - } - Iterable::Range(r) => { - ("Iterate Range", vec![("table", Value::from(r.tb.to_owned()))]) - } - Iterable::Edges(e) => { - ("Iterate Edges", vec![("from", Value::Thing(e.from.to_owned()))]) - } - Iterable::Mergeable(t, v) => ( - "Iterate Mergeable", - vec![("thing", Value::Thing(t.to_owned())), ("value", v.to_owned())], - ), - Iterable::Relatable(t1, t2, t3) => ( - "Iterate Relatable", - vec![ - ("thing-1", Value::Thing(t1.to_owned())), - ("thing-2", Value::Thing(t2.to_owned())), - ("thing-3", Value::Thing(t3.to_owned())), - ], - ), - Iterable::Index(t, p) => ( - "Iterate Index", - vec![("table", Value::from(t.0.to_owned())), ("plan", p.explain())], - ), - }; - let explain = Object::from(HashMap::from([ - ("operation", Value::from(operation)), - ("detail", Value::Object(Object::from(HashMap::from_iter(detail)))), - ])); - self.results.push(Value::Object(explain)); - } - Ok(true) - } - #[cfg(target_arch = "wasm32")] #[async_recursion(?Send)] async fn iterate( diff --git a/lib/src/dbs/mod.rs b/lib/src/dbs/mod.rs index 0a38d0e4..cfedf0b1 100644 --- a/lib/src/dbs/mod.rs +++ b/lib/src/dbs/mod.rs @@ -4,6 +4,7 @@ //! and executors to process the operations. This module also gives a `context` to the transaction. mod auth; mod executor; +mod explanation; mod iterator; mod notification; mod options; diff --git a/lib/src/dbs/statement.rs b/lib/src/dbs/statement.rs index 254e0c99..ce4e765b 100644 --- a/lib/src/dbs/statement.rs +++ b/lib/src/dbs/statement.rs @@ -16,6 +16,7 @@ use crate::sql::statements::relate::RelateStatement; use crate::sql::statements::select::SelectStatement; use crate::sql::statements::show::ShowStatement; use crate::sql::statements::update::UpdateStatement; +use crate::sql::Explain; use std::fmt; #[derive(Clone, Debug)] @@ -211,10 +212,10 @@ impl<'a> Statement<'a> { } /// Returns any EXPLAIN clause if specified #[inline] - pub fn explain(&self) -> bool { + pub fn explain(&self) -> Option<&Explain> { match self { - Statement::Select(v) => v.explain, - _ => false, + Statement::Select(v) => v.explain.as_ref(), + _ => None, } } } diff --git a/lib/src/sql/explain.rs b/lib/src/sql/explain.rs new file mode 100644 index 00000000..1c54f3b1 --- /dev/null +++ b/lib/src/sql/explain.rs @@ -0,0 +1,52 @@ +use crate::sql::comment::shouldbespace; +use crate::sql::error::IResult; +use nom::bytes::complete::tag_no_case; +use nom::combinator::opt; +use nom::sequence::tuple; +use serde::{Deserialize, Serialize}; +use std::fmt; + +#[derive(Clone, Debug, Default, Eq, PartialEq, PartialOrd, Serialize, Deserialize, Hash)] +pub struct Explain(pub bool); + +impl fmt::Display for Explain { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str("EXPLAIN")?; + if self.0 { + f.write_str(" FULL")?; + } + Ok(()) + } +} + +pub fn explain(i: &str) -> IResult<&str, Explain> { + let (i, _) = tag_no_case("EXPLAIN")(i)?; + let (i, full) = opt(tuple((shouldbespace, tag_no_case("FULL"))))(i)?; + Ok((i, Explain(full.is_some()))) +} + +#[cfg(test)] +mod tests { + + use super::*; + + #[test] + fn explain_statement() { + let sql = "EXPLAIN"; + let res = explain(sql); + assert!(res.is_ok()); + let out = res.unwrap().1; + assert_eq!(out, Explain(false)); + assert_eq!("EXPLAIN", format!("{}", out)); + } + + #[test] + fn explain_full_statement() { + let sql = "EXPLAIN FULL"; + let res = explain(sql); + assert!(res.is_ok()); + let out = res.unwrap().1; + assert_eq!(out, Explain(true)); + assert_eq!("EXPLAIN FULL", format!("{}", out)); + } +} diff --git a/lib/src/sql/mod.rs b/lib/src/sql/mod.rs index d2fbf789..a26f480f 100644 --- a/lib/src/sql/mod.rs +++ b/lib/src/sql/mod.rs @@ -19,6 +19,7 @@ pub(crate) mod edges; pub(crate) mod ending; pub(crate) mod error; pub(crate) mod escape; +pub(crate) mod explain; pub(crate) mod expression; pub(crate) mod fetch; pub(crate) mod field; @@ -89,6 +90,7 @@ pub use self::dir::Dir; pub use self::duration::Duration; pub use self::edges::Edges; pub use self::error::Error; +pub use self::explain::Explain; pub use self::expression::Expression; pub use self::fetch::Fetch; pub use self::fetch::Fetchs; diff --git a/lib/src/sql/statements/select.rs b/lib/src/sql/statements/select.rs index 94e6bd30..39707cb1 100644 --- a/lib/src/sql/statements/select.rs +++ b/lib/src/sql/statements/select.rs @@ -10,6 +10,7 @@ use crate::idx::planner::QueryPlanner; use crate::sql::comment::shouldbespace; use crate::sql::cond::{cond, Cond}; use crate::sql::error::IResult; +use crate::sql::explain::{explain, Explain}; use crate::sql::fetch::{fetch, Fetchs}; use crate::sql::field::{fields, Field, Fields}; use crate::sql::group::{group, Groups}; @@ -44,7 +45,7 @@ pub struct SelectStatement { pub version: Option, pub timeout: Option, pub parallel: bool, - pub explain: bool, + pub explain: Option, } impl SelectStatement { @@ -172,6 +173,9 @@ impl fmt::Display for SelectStatement { if self.parallel { f.write_str(" PARALLEL")? } + if let Some(ref v) = self.explain { + write!(f, " {v}")? + } Ok(()) } } @@ -197,7 +201,7 @@ pub fn select(i: &str) -> IResult<&str, SelectStatement> { let (i, version) = opt(preceded(shouldbespace, version))(i)?; let (i, timeout) = opt(preceded(shouldbespace, timeout))(i)?; let (i, parallel) = opt(preceded(shouldbespace, tag_no_case("PARALLEL")))(i)?; - let (i, explain) = opt(preceded(shouldbespace, tag_no_case("EXPLAIN")))(i)?; + let (i, explain) = opt(preceded(shouldbespace, explain))(i)?; Ok(( i, SelectStatement { @@ -213,7 +217,7 @@ pub fn select(i: &str) -> IResult<&str, SelectStatement> { version, timeout, parallel: parallel.is_some(), - explain: explain.is_some(), + explain, }, )) } diff --git a/lib/src/sql/value/serde/ser/explain/mod.rs b/lib/src/sql/value/serde/ser/explain/mod.rs new file mode 100644 index 00000000..0db91d8e --- /dev/null +++ b/lib/src/sql/value/serde/ser/explain/mod.rs @@ -0,0 +1 @@ +pub(super) mod opt; diff --git a/lib/src/sql/value/serde/ser/explain/opt.rs b/lib/src/sql/value/serde/ser/explain/opt.rs new file mode 100644 index 00000000..0218b9e3 --- /dev/null +++ b/lib/src/sql/value/serde/ser/explain/opt.rs @@ -0,0 +1,74 @@ +use crate::err::Error; +use crate::sql::value::serde::ser; +use crate::sql::Explain; +use serde::ser::Impossible; +use serde::ser::Serialize; + +pub struct Serializer; + +impl ser::Serializer for Serializer { + type Ok = Option; + type Error = Error; + + type SerializeSeq = Impossible, Error>; + type SerializeTuple = Impossible, Error>; + type SerializeTupleStruct = Impossible, Error>; + type SerializeTupleVariant = Impossible, Error>; + type SerializeMap = Impossible, Error>; + type SerializeStruct = Impossible, Error>; + type SerializeStructVariant = Impossible, Error>; + + const EXPECTED: &'static str = "an `Option`"; + + #[inline] + fn serialize_none(self) -> Result { + Ok(None) + } + + #[inline] + fn serialize_some(self, value: &T) -> Result + where + T: ?Sized + Serialize, + { + value.serialize(self.wrap()) + } + + #[inline] + fn serialize_newtype_struct( + self, + _name: &'static str, + value: &T, + ) -> Result + where + T: ?Sized + Serialize, + { + Ok(Some(Explain(value.serialize(ser::primitive::bool::Serializer.wrap())?))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ser::Serializer as _; + + #[test] + fn none() { + let option: Option = None; + let serialized = option.serialize(Serializer.wrap()).unwrap(); + assert_eq!(option, serialized); + } + + #[test] + fn some_default() { + let option = Some(Explain::default()); + let serialized = option.serialize(Serializer.wrap()).unwrap(); + assert_eq!(option, serialized); + } + + #[test] + fn some_full() { + let option = Some(Explain(true)); + let serialized = option.serialize(Serializer.wrap()).unwrap(); + assert_eq!(option, serialized); + } +} diff --git a/lib/src/sql/value/serde/ser/mod.rs b/lib/src/sql/value/serde/ser/mod.rs index b1d25aad..f480776b 100644 --- a/lib/src/sql/value/serde/ser/mod.rs +++ b/lib/src/sql/value/serde/ser/mod.rs @@ -8,6 +8,7 @@ mod decimal; mod dir; mod duration; mod edges; +mod explain; mod expression; mod fetch; mod field; diff --git a/lib/src/sql/value/serde/ser/statement/select.rs b/lib/src/sql/value/serde/ser/statement/select.rs index 74e6f791..dda14d5b 100644 --- a/lib/src/sql/value/serde/ser/statement/select.rs +++ b/lib/src/sql/value/serde/ser/statement/select.rs @@ -1,4 +1,5 @@ use crate::err::Error; +use crate::sql::explain::Explain; use crate::sql::statements::SelectStatement; use crate::sql::value::serde::ser; use crate::sql::Cond; @@ -57,7 +58,7 @@ pub struct SerializeSelectStatement { version: Option, timeout: Option, parallel: Option, - explain: Option, + explain: Option, } impl serde::ser::SerializeStruct for SerializeSelectStatement { @@ -106,7 +107,7 @@ impl serde::ser::SerializeStruct for SerializeSelectStatement { self.parallel = Some(value.serialize(ser::primitive::bool::Serializer.wrap())?); } "explain" => { - self.explain = Some(value.serialize(ser::primitive::bool::Serializer.wrap())?); + self.explain = value.serialize(ser::explain::opt::Serializer.wrap())?; } key => { return Err(Error::custom(format!("unexpected field `SelectStatement::{key}`"))); @@ -116,12 +117,12 @@ impl serde::ser::SerializeStruct for SerializeSelectStatement { } fn end(self) -> Result { - match (self.expr, self.what, self.parallel, self.explain) { - (Some(expr), Some(what), Some(parallel), Some(explain)) => Ok(SelectStatement { + match (self.expr, self.what, self.parallel) { + (Some(expr), Some(what), Some(parallel)) => Ok(SelectStatement { expr, what, parallel, - explain, + explain: self.explain, cond: self.cond, split: self.split, group: self.group, @@ -237,4 +238,14 @@ mod tests { let value: SelectStatement = stmt.serialize(Serializer.wrap()).unwrap(); assert_eq!(value, stmt); } + + #[test] + fn with_explain() { + let stmt = SelectStatement { + explain: Some(Default::default()), + ..Default::default() + }; + let value: SelectStatement = stmt.serialize(Serializer.wrap()).unwrap(); + assert_eq!(value, stmt); + } } diff --git a/lib/src/sql/value/value.rs b/lib/src/sql/value/value.rs index b3fd7f59..7da8b193 100644 --- a/lib/src/sql/value/value.rs +++ b/lib/src/sql/value/value.rs @@ -2937,7 +2937,7 @@ mod tests { assert_eq!(24, std::mem::size_of::()); assert_eq!(24, std::mem::size_of::()); assert_eq!(56, std::mem::size_of::()); - assert_eq!(48, std::mem::size_of::()); + assert_eq!(40, std::mem::size_of::()); assert_eq!(16, std::mem::size_of::()); assert_eq!(8, std::mem::size_of::>()); assert_eq!(8, std::mem::size_of::>()); diff --git a/lib/tests/matches.rs b/lib/tests/matches.rs index d6822260..b44c9532 100644 --- a/lib/tests/matches.rs +++ b/lib/tests/matches.rs @@ -59,7 +59,7 @@ async fn select_where_matches_without_using_index_iterator() -> Result<(), Error CREATE blog:2 SET title = 'Foo Bar!'; DEFINE ANALYZER simple TOKENIZERS blank,class FILTERS lowercase; DEFINE INDEX blog_title ON blog FIELDS title SEARCH ANALYZER simple BM25(1.2,0.75) HIGHLIGHTS; - SELECT id FROM blog WHERE (title @0@ 'hello' AND identifier > 0) OR (title @1@ 'world' AND identifier < 99) EXPLAIN; + SELECT id FROM blog WHERE (title @0@ 'hello' AND identifier > 0) OR (title @1@ 'world' AND identifier < 99) EXPLAIN FULL; SELECT id,search::highlight('', '', 1) AS title FROM blog WHERE (title @0@ 'hello' AND identifier > 0) OR (title @1@ 'world' AND identifier < 99); "; let dbs = Datastore::new("memory").await?; @@ -79,7 +79,13 @@ async fn select_where_matches_without_using_index_iterator() -> Result<(), Error table: 'blog', }, operation: 'Iterate Table' - } + }, + { + detail: { + count: 1, + }, + operation: 'Fetch' + }, ]", ); assert_eq!(tmp, val); diff --git a/lib/tests/select.rs b/lib/tests/select.rs index d22f080a..021de68a 100644 --- a/lib/tests/select.rs +++ b/lib/tests/select.rs @@ -72,11 +72,12 @@ async fn select_expression_value() -> Result<(), Error> { CREATE thing:b SET number = -5, boolean = false; SELECT VALUE -number FROM thing; SELECT VALUE !boolean FROM thing; + SELECT VALUE !boolean FROM thing EXPLAIN FULL; "; let dbs = Datastore::new("memory").await?; let ses = Session::for_kv().with_ns("test").with_db("test"); let res = &mut dbs.execute(&sql, &ses, None).await?; - assert_eq!(res.len(), 4); + assert_eq!(res.len(), 5); // let tmp = res.remove(0).result?; let val = Value::parse( @@ -120,6 +121,25 @@ async fn select_expression_value() -> Result<(), Error> { ); assert_eq!(tmp, val); // + let tmp = res.remove(0).result?; + let val = Value::parse( + "[ + { + detail: { + table: 'thing', + }, + operation: 'Iterate Table' + }, + { + detail: { + count: 2, + }, + operation: 'Fetch' + } + ]", + ); + assert_eq!(tmp, val); + // Ok(()) } @@ -267,11 +287,12 @@ async fn select_where_field_is_thing_and_with_index() -> Result<(), Error> { CREATE post:1 SET author = person:tobie; CREATE post:2 SET author = person:tobie; SELECT * FROM post WHERE author = person:tobie EXPLAIN; + SELECT * FROM post WHERE author = person:tobie EXPLAIN FULL; SELECT * FROM post WHERE author = person:tobie;"; let dbs = Datastore::new("memory").await?; let ses = Session::for_kv().with_ns("test").with_db("test"); let res = &mut dbs.execute(sql, &ses, None).await?; - assert_eq!(res.len(), 6); + assert_eq!(res.len(), 7); // let _ = res.remove(0).result?; let _ = res.remove(0).result?; @@ -297,6 +318,30 @@ async fn select_where_field_is_thing_and_with_index() -> Result<(), Error> { assert_eq!(tmp, val); // let tmp = res.remove(0).result?; + let val = Value::parse( + "[ + { + detail: { + plan: { + index: 'author', + operator: '=', + value: person:tobie + }, + table: 'post', + }, + operation: 'Iterate Index' + }, + { + detail: { + count: 2, + }, + operation: 'Fetch' + } + ]", + ); + assert_eq!(tmp, val); + // + let tmp = res.remove(0).result?; let val = Value::parse( "[ { @@ -455,3 +500,67 @@ async fn select_where_and_with_fulltext_index() -> Result<(), Error> { assert_eq!(tmp, val); Ok(()) } + +#[tokio::test] +async fn select_where_explain() -> Result<(), Error> { + let sql = " + CREATE person:tobie SET name = 'Tobie'; + CREATE person:jaime SET name = 'Jaime'; + CREATE software:surreal SET name = 'SurrealDB'; + SELECT * FROM person,software EXPLAIN; + SELECT * FROM person,software EXPLAIN FULL;"; + let dbs = Datastore::new("memory").await?; + let ses = Session::for_kv().with_ns("test").with_db("test"); + let res = &mut dbs.execute(sql, &ses, None).await?; + assert_eq!(res.len(), 5); + // + let _ = res.remove(0).result?; + let _ = res.remove(0).result?; + let _ = res.remove(0).result?; + // + let tmp = res.remove(0).result?; + let val = Value::parse( + "[ + { + detail: { + table: 'person', + }, + operation: 'Iterate Table' + }, + { + detail: { + table: 'software', + }, + operation: 'Iterate Table' + } + ]", + ); + assert_eq!(tmp, val); + // + let tmp = res.remove(0).result?; + let val = Value::parse( + "[ + { + detail: { + table: 'person', + }, + operation: 'Iterate Table' + }, + { + detail: { + table: 'software', + }, + operation: 'Iterate Table' + }, + { + detail: { + count: 3, + }, + operation: 'Fetch' + }, + ]", + ); + assert_eq!(tmp, val); + // + Ok(()) +}