From 4b53bd052ffd1f5cfb7be7640fffa01a666e8b39 Mon Sep 17 00:00:00 2001 From: Paul Horn Date: Wed, 15 Jan 2025 17:45:11 +0100 Subject: [PATCH 1/2] Add query! macro providing a more ergonomic way to create parmeterized queries --- lib/src/lib.rs | 2 +- lib/src/query.rs | 183 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 184 insertions(+), 1 deletion(-) diff --git a/lib/src/lib.rs b/lib/src/lib.rs index 3d911042..ef2f52a2 100644 --- a/lib/src/lib.rs +++ b/lib/src/lib.rs @@ -481,7 +481,7 @@ pub use crate::errors::{ Error, Neo4jClientErrorKind, Neo4jError, Neo4jErrorKind, Neo4jSecurityErrorKind, Result, }; pub use crate::graph::{query, Graph}; -pub use crate::query::Query; +pub use crate::query::{Query, QueryParameter}; pub use crate::row::{Node, Path, Point2D, Point3D, Relation, Row, UnboundedRelation}; pub use crate::stream::{DetachedRowStream, RowItem, RowStream}; pub use crate::txn::Txn; diff --git a/lib/src/query.rs b/lib/src/query.rs index fa5a99c6..e1cec9d8 100644 --- a/lib/src/query.rs +++ b/lib/src/query.rs @@ -1,3 +1,5 @@ +use std::cell::{Cell, RefCell}; + #[cfg(feature = "unstable-bolt-protocol-impl-v2")] use crate::bolt::{Discard, Summary, WrapExtra as _}; use crate::{ @@ -26,6 +28,11 @@ impl Query { } } + pub fn with_params(mut self, params: BoltMap) -> Self { + self.params = params; + self + } + pub fn param>(mut self, key: &str, value: T) -> Self { self.params.put(key.into(), value.into()); self @@ -68,6 +75,14 @@ impl Query { self.extra.value.contains_key(key) } + pub fn query(&self) -> &str { + &self.query + } + + pub fn get_params(&self) -> &BoltMap { + &self.params + } + pub(crate) async fn run(self, connection: &mut ManagedConnection) -> Result<()> { let request = BoltRequest::run(&self.query, self.params, self.extra); Self::try_run(request, connection) @@ -170,6 +185,15 @@ impl From<&str> for Query { } } +impl std::fmt::Debug for Query { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Query") + .field("query", &self.query) + .field("params", &self.params) + .finish_non_exhaustive() + } +} + type QueryResult = Result>; fn wrap_error(resp: impl IntoError, req: &'static str) -> QueryResult { @@ -217,6 +241,142 @@ fn unwrap_backoff(err: backoff::Error) -> Error { } } +#[doc(hidden)] +pub struct QueryParameter<'x, T> { + value: Cell>, + name: &'static str, + params: &'x RefCell, +} + +impl<'x, T: Into> QueryParameter<'x, T> { + #[allow(dead_code)] + pub fn new(value: T, name: &'static str, params: &'x RefCell) -> Self { + Self { + value: Cell::new(Some(value)), + name, + params, + } + } +} + +impl> std::fmt::Display for QueryParameter<'_, T> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let Some(v) = self.value.replace(None) else { + return Err(std::fmt::Error); + }; + self.params.borrow_mut().put(self.name.into(), v.into()); + write!(f, "${}", self.name) + } +} + +impl> std::fmt::Debug for QueryParameter<'_, T> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + std::fmt::Display::fmt(self, f) + } +} + +/// Create a query with a format! like syntax +/// +/// `query!` works similar to `format!`: +/// - The first argument is the query string with `{}` placeholders +/// - Following that is a list of `name = value` parmeters arguments +/// - All placeholders in the query strings are replaced with query parameters +/// +/// The macro is a compiler-supported alternative to using the `params` method on `Query`. +/// +/// ## Differences from `format!` and limitations +/// +/// - Implicit `{name}` bindings without adding a `name = ` argument does not +/// actually create a new parameter; It does default string interpolation instead. +/// - Formatting parameters are largely ignored and have no effect on the query string. +/// - Argument values need to implement `Into` instead of `Display` +/// (and don't need to implement the latter) +/// - Only named placeholders syntax is supported (`{}` instead of `{}`) +/// - This is because query parameters are always named +/// - By extension, adding an unnamed argument (e.g. `` instead of `name = `) is also not supported +/// +/// # Examples +/// +/// ``` +/// use neo4rs::{query, Query}; +/// +/// // This creates an unparametrized query. +/// let q: Query = query!("MATCH (n) RETURN n"); +/// assert_eq!(q.query(), "MATCH (n) RETURN n"); +/// assert!(q.get_params().is_empty()); +/// +/// // This creates a parametrized query. +/// let q: Query = query!("MATCH (n) WHERE n.value = {answer} RETURN n", answer = 42); +/// assert_eq!(q.query(), "MATCH (n) WHERE n.value = $answer RETURN n"); +/// assert_eq!(q.get_params().get::("answer").unwrap(), 42); +/// +/// // by contrast, using the implicit string interpolation syntax does not +/// // create a parameter, effectively being the same as `format!`. +/// let answer = 42; +/// let q: Query = query!("MATCH (n) WHERE n.value = {answer} RETURN n"); +/// assert_eq!(q.query(), "MATCH (n) WHERE n.value = 42 RETURN n"); +/// assert!(q.has_param_key("answer") == false); +/// +/// // The value can be any type that implements Into, it does not +/// // need to implement Display or Debug. +/// use neo4rs::{BoltInteger, BoltType}; +/// +/// struct Answer; +/// impl Into for Answer { +/// fn into(self) -> BoltType { +/// BoltType::Integer(BoltInteger::new(42)) +/// } +/// } +/// +/// let q: Query = query!("MATCH (n) WHERE n.value = {answer} RETURN n", answer = Answer); +/// assert_eq!(q.query(), "MATCH (n) WHERE n.value = $answer RETURN n"); +/// assert_eq!(q.get_params().get::("answer").unwrap(), 42); +/// ``` +#[macro_export] +macro_rules! query { + // Create a unparametrized query + ($query:expr) => { + $crate::Query::new(format!($query)) + }; + + // Create a parametrized query with a format! like syntax + ($query:expr $(, $($input:tt)*)?) => { + $crate::query!(@internal $query, [] $(; $($input)*)?) + }; + + (@internal $query:expr, [$($acc:tt)*]; $name:ident = $value:expr $(, $($rest:tt)*)?) => { + $crate::query!(@internal $query, [$($acc)* ($name = $value)] $(; $($rest)*)?) + }; + + (@internal $query:expr, [$($acc:tt)*]; $value:expr $(, $($rest:tt)*)?) => { + compile_error!("Only named parameter syntax (`name = value`) is supported"); + }; + + (@internal $query:expr, [$($acc:tt)*];) => { + $crate::query!(@final $query; $($acc)*) + }; + + (@internal $query:expr, [$($acc:tt)*]) => { + $crate::query!(@final $query; $($acc)*) + }; + + (@final $query:expr; $(($name:ident = $value:expr))*) => {{ + let params = $crate::BoltMap::default(); + let params = ::std::cell::RefCell::new(params); + + let query = format!($query, $( + $name = $crate::QueryParameter::new( + $value, + stringify!($name), + ¶ms, + ), + )*); + let params = params.into_inner(); + + $crate::Query::new(query).with_params(params) + }}; +} + #[cfg(test)] mod tests { use super::*; @@ -238,4 +398,27 @@ mod tests { assert!(q.has_param_key("name")); assert!(!q.has_param_key("country")); } + + #[test] + fn query_macro() { + let q = query!( + "MATCH (n) WHERE n.name = {name} AND n.age > {age} RETURN n", + age = 42, + name = "Frobniscante", + ); + + assert_eq!( + q.query.as_str(), + "MATCH (n) WHERE n.name = $name AND n.age > $age RETURN n" + ); + + assert_eq!( + q.params.get::("name").unwrap(), + String::from("Frobniscante") + ); + assert_eq!(q.params.get::("age").unwrap(), 42); + + assert!(q.has_param_key("name")); + assert!(!q.has_param_key("country")); + } } From 49a4f729947440c12e92c1ec3badddb8c117fba3 Mon Sep 17 00:00:00 2001 From: Paul Horn Date: Wed, 15 Jan 2025 18:19:41 +0100 Subject: [PATCH 2/2] Change examples/tests to use query macro where possible --- lib/tests/missing_properties.rs | 5 ++++- lib/tests/txn_change_db.rs | 23 +++++++++-------------- lib/tests/use_default_db.rs | 17 ++++++++++++----- 3 files changed, 25 insertions(+), 20 deletions(-) diff --git a/lib/tests/missing_properties.rs b/lib/tests/missing_properties.rs index 01f63447..c7735093 100644 --- a/lib/tests/missing_properties.rs +++ b/lib/tests/missing_properties.rs @@ -10,7 +10,10 @@ async fn missing_properties() { let a_val = None::; let mut result = graph - .execute(query("CREATE (ts:TestStruct {a: $a}) RETURN ts").param("a", a_val)) + .execute(query!( + "CREATE (ts:TestStruct {{a: {a}}}) RETURN ts", + a = a_val + )) .await .unwrap(); let row = result.next().await.unwrap().unwrap(); diff --git a/lib/tests/txn_change_db.rs b/lib/tests/txn_change_db.rs index bdfd4262..e975fedb 100644 --- a/lib/tests/txn_change_db.rs +++ b/lib/tests/txn_change_db.rs @@ -1,5 +1,5 @@ use futures::TryStreamExt; -use neo4rs::*; +use neo4rs::query; use serde::Deserialize; mod container; @@ -19,7 +19,7 @@ async fn txn_changes_db() { return; } - std::panic::panic_any(e); + std::panic::panic_any(e.to_string()); } }; let graph = neo4j.graph(); @@ -46,18 +46,13 @@ async fn txn_changes_db() { let mut txn = graph.start_txn().await.unwrap(); let mut databases = txn - .execute( - query(&format!( - concat!( - "SHOW TRANSACTIONS YIELD * WHERE username = $username AND currentQuery ", - "STARTS WITH $query AND toLower({status_field}) = $status RETURN database" - ), - status_field = status_field - )) - .param("username", "neo4j") - .param("query", "SHOW TRANSACTIONS YIELD ") - .param("status", "running"), - ) + .execute(query!( + "SHOW TRANSACTIONS YIELD * WHERE username = {username} AND currentQuery +STARTS WITH {query} AND toLower({status_field}) = {status} RETURN database", + username = "neo4j", + query = "SHOW TRANSACTIONS YIELD ", + status = "running", + )) .await .unwrap(); diff --git a/lib/tests/use_default_db.rs b/lib/tests/use_default_db.rs index 72ad5eea..64116a06 100644 --- a/lib/tests/use_default_db.rs +++ b/lib/tests/use_default_db.rs @@ -54,7 +54,10 @@ async fn use_default_db() { let id = uuid::Uuid::new_v4(); graph - .run(query("CREATE (:Node { uuid: $uuid })").param("uuid", id.to_string())) + .run(query!( + "CREATE (:Node {{ uuid: {uuid} }})", + uuid = id.to_string() + )) .await .unwrap(); @@ -62,8 +65,10 @@ async fn use_default_db() { let query_stream = graph .execute_on( dbname.as_str(), - query("MATCH (n:Node {uuid: $uuid}) RETURN count(n) AS result") - .param("uuid", id.to_string()), + query!( + "MATCH (n:Node {{uuid: {uuid}}}) RETURN count(n) AS result", + uuid = id.to_string() + ), Operation::Read, ) .await; @@ -72,8 +77,10 @@ async fn use_default_db() { let query_stream = graph .execute_on( dbname.as_str(), - query("MATCH (n:Node {uuid: $uuid}) RETURN count(n) AS result") - .param("uuid", id.to_string()), + query!( + "MATCH (n:Node {{uuid: {uuid}}}) RETURN count(n) AS result", + uuid = id.to_string() + ), ) .await;