From 39e36b91d897ff741acac13d465bd87e61feba92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=B6zg=C3=BCr?= <44574065+ozgurcancal@users.noreply.github.com> Date: Thu, 26 Jun 2025 00:07:50 +0300 Subject: [PATCH 1/3] Add query_raw method for raw SQL execution - Update documentation and tests --- examples/README.md | 3 +- src/lib.rs | 18 +++ src/query.rs | 18 ++- src/sql/mod.rs | 3 + tests/it/main.rs | 1 + tests/it/query_raw.rs | 299 ++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 340 insertions(+), 2 deletions(-) create mode 100644 tests/it/query_raw.rs diff --git a/examples/README.md b/examples/README.md index d99975dc..bc33acc0 100644 --- a/examples/README.md +++ b/examples/README.md @@ -8,7 +8,8 @@ If something is missing, or you found a mistake in one of these examples, please ### General usage -- [usage.rs](usage.rs) - creating tables, executing other DDLs, inserting the data, and selecting it back. Optional cargo features: `inserter`. +- [usage.rs](usage.rs) - creating tables, executing other DDLs, inserting the data, and selecting it back. Additionally, it covers `WATCH` queries. Optional cargo features: `inserter`, `watch`. +- [query_raw.rs](query_raw.rs) - raw queries without parameter binding, with question mark escaping. FORMAT is the RowBinary by default - [mock.rs](mock.rs) - writing tests with `mock` feature. Cargo features: requires `test-util`. - [inserter.rs](inserter.rs) - using the client-side batching via the `inserter` feature. Cargo features: requires `inserter`. - [async_insert.rs](async_insert.rs) - using the server-side batching via the [asynchronous inserts](https://clickhouse.com/docs/en/optimize/asynchronous-inserts) ClickHouse feature diff --git a/src/lib.rs b/src/lib.rs index d2830e50..b7d15102 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -319,6 +319,24 @@ impl Client { query::Query::new(self, query) } + /// Starts a new SELECT/DDL query that will be used as-is without any processing. + /// + /// # Key Differences from `query()` + /// + /// - **No parameter binding**: Question marks are treated as literal characters + /// - **Raw SQL execution**: The query is sent to ClickHouse exactly as written + /// - **No SQL injection protection**: Since no parameter binding occurs, ensure + /// your SQL is safe and doesn't contain user input + /// + /// # Parameters + /// + /// - **Input**: `&str` - the raw SQL query to be executed + /// - **Output**: [`Query`] - the query builder that executes the query + /// + pub fn query_raw(&self, query: &str) -> query::Query { + query::Query::raw(self, query) + } + /// Enables or disables [`Row`] data types validation against the database schema /// at the cost of performance. Validation is enabled by default, and in this mode, /// the client will use `RowBinaryWithNamesAndTypes` format. diff --git a/src/query.rs b/src/query.rs index 346836c6..243a2150 100644 --- a/src/query.rs +++ b/src/query.rs @@ -23,6 +23,7 @@ use crate::headers::with_authentication; pub struct Query { client: Client, sql: SqlBuilder, + raw: bool, } impl Query { @@ -30,6 +31,15 @@ impl Query { Self { client: client.clone(), sql: SqlBuilder::new(template), + raw: false, + } + } + /// Creates a new query that will be used as-is without any processing. + pub(crate) fn raw(client: &Client, query: &str) -> Self { + Self { + client: client.clone(), + sql: SqlBuilder::raw(query), + raw: true, } } @@ -53,6 +63,9 @@ impl Query { /// [`Identifier`]: crate::sql::Identifier #[track_caller] pub fn bind(mut self, value: impl Bind) -> Self { + if self.raw { + panic!("bind() cannot be used with raw queries"); + } self.sql.bind_arg(value); self } @@ -84,7 +97,10 @@ impl Query { /// # Ok(()) } /// ``` pub fn fetch(mut self) -> Result> { - self.sql.bind_fields::(); + // skip binding if raw query + if !self.raw { + self.sql.bind_fields::(); + } let validation = self.client.get_validation(); if validation { diff --git a/src/sql/mod.rs b/src/sql/mod.rs index d4a7e3b8..cce6c55f 100644 --- a/src/sql/mod.rs +++ b/src/sql/mod.rs @@ -74,6 +74,9 @@ impl SqlBuilder { SqlBuilder::InProgress(parts, None) } + pub(crate) fn raw(query: &str) -> Self { + Self::InProgress(vec![Part::Text(query.to_string())], None) + } pub(crate) fn set_output_format(&mut self, format: impl Into) { if let Self::InProgress(_, format_opt) = self { diff --git a/tests/it/main.rs b/tests/it/main.rs index 5bc4f5e0..68923a88 100644 --- a/tests/it/main.rs +++ b/tests/it/main.rs @@ -177,6 +177,7 @@ mod ip; mod mock; mod nested; mod query; +mod query_raw; mod rbwnat; mod time; mod user_agent; diff --git a/tests/it/query_raw.rs b/tests/it/query_raw.rs new file mode 100644 index 00000000..34f5b97e --- /dev/null +++ b/tests/it/query_raw.rs @@ -0,0 +1,299 @@ +use clickhouse::Row; +use serde::{Deserialize, Serialize}; + +#[derive(Row, Deserialize, Debug)] +struct PersonName<'a> { + name: &'a str, +} + +#[derive(Row, Deserialize, Debug)] +struct PersonInfo { + name: String, + age: u32, +} + +#[tokio::test] +async fn verify_raw_query_basic_functionality() { + let client = prepare_database!(); + + // The key test: verify that ? characters don't cause binding errors + let result = client + .query_raw("SELECT 1 WHERE 'test?' = 'test?'") + .fetch_bytes("TSV") + .unwrap(); + + let mut data = Vec::new(); + let mut cursor = result; + while let Some(chunk) = cursor.next().await.unwrap() { + data.extend_from_slice(&chunk); + } + let response = String::from_utf8(data).unwrap(); + + // Should return "1\n" - proving the query executed successfully + assert_eq!(response.trim(), "1"); + + // Contrast: regular query with ? should fail + let regular_result = client + .query("SELECT 1 WHERE 'test?' = 'test?'") + .fetch_bytes("TSV"); + + // This should fail because ? is treated as a bind parameter + assert!(regular_result.is_err()); + if let Err(error) = regular_result { + let error_msg = error.to_string(); + assert!(error_msg.contains("unbound")); + } +} + +#[tokio::test] +async fn fetch_with_single_field_struct() { + let client = prepare_database!(); + + client + .query("CREATE TABLE test_users(name String) ENGINE = Memory") + .execute() + .await + .unwrap(); + + client + .query_raw("INSERT INTO test_users VALUES ('Alice?'), ('Bob??'), ('Charlie???')") + .execute() + .await + .unwrap(); + + // Test raw query with struct fetching + let sql = "SELECT name FROM test_users ORDER BY name"; + + let mut cursor = client.query_raw(sql).fetch::>().unwrap(); + + let mut names = Vec::new(); + while let Some(PersonName { name }) = cursor.next().await.unwrap() { + names.push(name.to_string()); + } + + assert_eq!(names, vec!["Alice?", "Bob??", "Charlie???"]); +} + +#[tokio::test] +async fn fetch_with_multi_field_struct() { + let client = prepare_database!(); + + // Create a test table + client + .query("CREATE TABLE test_persons(name String, age UInt32) ENGINE = Memory") + .execute() + .await + .unwrap(); + + // Insert test data with question marks in names + client + .query_raw("INSERT INTO test_persons VALUES ('What?', 25), ('How??', 30), ('Why???', 35)") + .execute() + .await + .unwrap(); + + // Test raw query with multi-field struct + let sql = "SELECT name, age FROM test_persons ORDER BY age"; + + let mut cursor = client.query_raw(sql).fetch::().unwrap(); + + let mut persons = Vec::new(); + while let Some(person) = cursor.next().await.unwrap() { + persons.push((person.name.clone(), person.age)); + } + + assert_eq!( + persons, + vec![ + ("What?".to_string(), 25), + ("How??".to_string(), 30), + ("Why???".to_string(), 35) + ] + ); +} + +#[tokio::test] +async fn compare_raw_vs_regular_query_with_structs() { + let client = prepare_database!(); + + client + .query("CREATE TABLE test_comparison(name String) ENGINE = Memory") + .execute() + .await + .unwrap(); + + client + .query_raw("INSERT INTO test_comparison VALUES ('Alice?')") + .execute() + .await + .unwrap(); + + // Regular query with ? should fail due to unbound parameter + let regular_result = client + .query("SELECT name FROM test_comparison WHERE name = 'Alice?'") + .fetch::>(); + + assert!(regular_result.is_err()); + if let Err(error) = regular_result { + let error_msg = error.to_string(); + assert!(error_msg.contains("unbound")); + } + + // Raw query with ? should succeed ) + let raw_result = client + .query_raw("SELECT name FROM test_comparison WHERE name = 'Alice?'") + .fetch::>() + .unwrap(); + + let mut names = Vec::new(); + let mut cursor = raw_result; + while let Some(PersonName { name }) = cursor.next().await.unwrap() { + names.push(name.to_string()); + } + + assert_eq!(names, vec!["Alice?"]); +} + +#[tokio::test] +async fn mixed_question_mark() { + let client = prepare_database!(); + + // Test various question mark patterns with bytes fetch to avoid format issues + let patterns = vec![ + ("SELECT 1 WHERE 'test?' = 'test?'", "?"), + ("SELECT 2 WHERE 'test??' = 'test??'", "??"), + ("SELECT 3 WHERE 'test???' = 'test???'", "???"), + ( + "SELECT 4 WHERE 'What? How?? Why???' = 'What? How?? Why???'", + "mixed", + ), + ]; + + for (sql, pattern_type) in patterns { + let result = client.query_raw(sql).fetch_bytes("TSV").unwrap(); + + let mut data = Vec::new(); + let mut cursor = result; + while let Some(chunk) = cursor.next().await.unwrap() { + data.extend_from_slice(&chunk); + } + let response = String::from_utf8(data).unwrap(); + + // Should return the expected number + assert!( + !response.trim().is_empty(), + "Query should return data for pattern: {}", + pattern_type + ); + } +} + +#[tokio::test] +async fn question_marks_in_comments() { + let client = prepare_database!(); + + // Test question marks in SQL comments - should work without binding + let result = client + .query_raw("SELECT 1 /* What? How?? Why??? */ WHERE 1=1") + .fetch_bytes("TSV") + .unwrap(); + + let mut data = Vec::new(); + let mut cursor = result; + while let Some(chunk) = cursor.next().await.unwrap() { + data.extend_from_slice(&chunk); + } + let response = String::from_utf8(data).unwrap(); + + assert_eq!(response.trim(), "1"); +} + +#[tokio::test] +async fn contrast_with_regular_query() { + let client = prepare_database!(); + + // This should fail with regular query because of unbound parameter + let result = client + .query("SELECT 1 WHERE 'test?' = 'test?'") + .fetch_bytes("TSV"); + + // Regular query should fail due to unbound ? + assert!(result.is_err()); + if let Err(error) = result { + let error_msg = error.to_string(); + assert!(error_msg.contains("unbound")); + } + + // But raw query should succeed + let raw_result = client + .query_raw("SELECT 1 WHERE 'test?' = 'test?'") + .fetch_bytes("TSV") + .unwrap(); + + let mut data = Vec::new(); + let mut cursor = raw_result; + while let Some(chunk) = cursor.next().await.unwrap() { + data.extend_from_slice(&chunk); + } + let response = String::from_utf8(data).unwrap(); + + assert_eq!(response.trim(), "1"); +} + +#[tokio::test] +async fn complex_sql_with_question_marks() { + use clickhouse::Row; + use serde::{Deserialize, Serialize}; + + #[derive(Debug, Row, Serialize, Deserialize)] + struct TestResult { + question: String, + confusion: String, + bewilderment: String, + answer: String, + } + + let client = prepare_database!(); + + // Test a more complex SQL query with question marks in various contexts + let sql = r#" + SELECT + 'What is this?' as question, + 'How does this work??' as confusion, + 'Why would you do this???' as bewilderment, + CASE + WHEN 1=1 THEN 'Yes?' + ELSE 'No??' + END as answer + WHERE 'test?' LIKE '%?' + "#; + + let result = client.query_raw(sql).fetch_one::().await; + + assert!(result.is_ok()); + let row = result.unwrap(); + assert_eq!(row.question, "What is this?"); + assert_eq!(row.confusion, "How does this work??"); + assert_eq!(row.bewilderment, "Why would you do this???"); + assert_eq!(row.answer, "Yes?"); +} + +#[tokio::test] +async fn query_raw_preserves_exact_sql() { + let client = prepare_database!(); +//check client + + // Test that raw query preserves the exact SQL including whitespace and formatting + let sql = "SELECT 1 WHERE 'test?' = 'test?' "; + + let result = client.query_raw(sql).fetch_bytes("TSV").unwrap(); + + let mut data = Vec::new(); + let mut cursor = result; + while let Some(chunk) = cursor.next().await.unwrap() { + data.extend_from_slice(&chunk); + } + let response = String::from_utf8(data).unwrap(); + + assert_eq!(response.trim(), "1"); +} From b12ad8f2615406e45524f2d9d96a4eb0be108392 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=B6zg=C3=BCr?= <44574065+ozgurcancal@users.noreply.github.com> Date: Thu, 26 Jun 2025 17:17:45 +0300 Subject: [PATCH 2/3] Add test to verify logged SQL matches executed statement --- src/lib.rs | 2 +- tests/it/query_raw.rs | 49 ++++++++++++++++++++++++++++++------------- 2 files changed, 36 insertions(+), 15 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index b7d15102..8d3effe4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -332,7 +332,7 @@ impl Client { /// /// - **Input**: `&str` - the raw SQL query to be executed /// - **Output**: [`Query`] - the query builder that executes the query - /// + /// pub fn query_raw(&self, query: &str) -> query::Query { query::Query::raw(self, query) } diff --git a/tests/it/query_raw.rs b/tests/it/query_raw.rs index 34f5b97e..6f291770 100644 --- a/tests/it/query_raw.rs +++ b/tests/it/query_raw.rs @@ -61,7 +61,7 @@ async fn fetch_with_single_field_struct() { .await .unwrap(); - // Test raw query with struct fetching + // Test raw query with struct fetching let sql = "SELECT name FROM test_users ORDER BY name"; let mut cursor = client.query_raw(sql).fetch::>().unwrap(); @@ -92,7 +92,7 @@ async fn fetch_with_multi_field_struct() { .await .unwrap(); - // Test raw query with multi-field struct + // Test raw query with multi-field struct let sql = "SELECT name, age FROM test_persons ORDER BY age"; let mut cursor = client.query_raw(sql).fetch::().unwrap(); @@ -279,21 +279,42 @@ async fn complex_sql_with_question_marks() { } #[tokio::test] -async fn query_raw_preserves_exact_sql() { +async fn query_matches_log() { + use uuid::Uuid; + + // setup let client = prepare_database!(); -//check client + let query_id = Uuid::new_v4().to_string(); // unique per run + let sql = "SELECT 1 WHERE 'x?' = 'x?'"; // raw statement to verify - // Test that raw query preserves the exact SQL including whitespace and formatting - let sql = "SELECT 1 WHERE 'test?' = 'test?' "; + // execute with explicit query_id + client + .query_raw(sql) + .with_option("query_id", &query_id) + .execute() + .await + .expect("executing raw SQL failed"); - let result = client.query_raw(sql).fetch_bytes("TSV").unwrap(); + crate::flush_query_log(&client).await; - let mut data = Vec::new(); - let mut cursor = result; - while let Some(chunk) = cursor.next().await.unwrap() { - data.extend_from_slice(&chunk); - } - let response = String::from_utf8(data).unwrap(); + // read log row *inline* + let log_sql = format!( + "SELECT query \ + FROM system.query_log \ + WHERE query_id = '{}' LIMIT 1", + query_id + ); - assert_eq!(response.trim(), "1"); + let logged_sql: String = client + .query_raw(&log_sql) + .fetch_one() + .await + .expect("log entry not found"); + + // assertion + assert_eq!( + logged_sql.trim(), + sql.trim(), + "Logged SQL differs from the statement sent" + ); } From 2a3e0f8b73b13ee118b9755dfebb4bb5d70274b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=B6zg=C3=BCr?= <44574065+ozgurcancal@users.noreply.github.com> Date: Thu, 26 Jun 2025 19:00:28 +0300 Subject: [PATCH 3/3] docs: improve documentation for query_raw method --- examples/README.md | 2 +- src/lib.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/README.md b/examples/README.md index bc33acc0..fc4835f3 100644 --- a/examples/README.md +++ b/examples/README.md @@ -9,7 +9,7 @@ If something is missing, or you found a mistake in one of these examples, please ### General usage - [usage.rs](usage.rs) - creating tables, executing other DDLs, inserting the data, and selecting it back. Additionally, it covers `WATCH` queries. Optional cargo features: `inserter`, `watch`. -- [query_raw.rs](query_raw.rs) - raw queries without parameter binding, with question mark escaping. FORMAT is the RowBinary by default +- [query_raw.rs](query_raw.rs) - raw queries executes SQL queries exactly as written, without parameter binding or preprocessing. This allows queries containing literal question marks that would otherwise be treated as bind parameters. FORMAT is the RowBinary by default - [mock.rs](mock.rs) - writing tests with `mock` feature. Cargo features: requires `test-util`. - [inserter.rs](inserter.rs) - using the client-side batching via the `inserter` feature. Cargo features: requires `inserter`. - [async_insert.rs](async_insert.rs) - using the server-side batching via the [asynchronous inserts](https://clickhouse.com/docs/en/optimize/asynchronous-inserts) ClickHouse feature diff --git a/src/lib.rs b/src/lib.rs index 8d3effe4..ed54ed1d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -331,7 +331,7 @@ impl Client { /// # Parameters /// /// - **Input**: `&str` - the raw SQL query to be executed - /// - **Output**: [`Query`] - the query builder that executes the query + /// - **Output**: [`query::Query`] - the query builder that executes the query /// pub fn query_raw(&self, query: &str) -> query::Query { query::Query::raw(self, query)