Skip to content

Commit ba59d98

Browse files
NathanFlurryMasterPtato
authored andcommitted
feat: implement clickhouse-user-query
1 parent 7ea58ca commit ba59d98

File tree

12 files changed

+1594
-16
lines changed

12 files changed

+1594
-16
lines changed

Cargo.lock

Lines changed: 316 additions & 15 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 4 additions & 1 deletion
Large diffs are not rendered by default.
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
[package]
2+
name = "clickhouse-user-query"
3+
version.workspace = true
4+
edition.workspace = true
5+
authors.workspace = true
6+
license.workspace = true
7+
8+
[dependencies]
9+
clickhouse = "0.12"
10+
thiserror = "1.0"
11+
serde = { version = "1.0", features = ["derive"] }
12+
13+
[dev-dependencies]
14+
serde_json = "1.0"
15+
testcontainers = "0.24"
16+
tokio = { version = "1.0", features = ["full"] }
Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
use clickhouse::query::Query;
2+
use clickhouse::sql::Identifier;
3+
use serde::{Deserialize, Serialize};
4+
5+
use crate::error::{Result, UserQueryError};
6+
use crate::query::QueryExpr;
7+
use crate::schema::{PropertyType, Schema};
8+
9+
#[derive(Debug, Clone, Serialize, Deserialize)]
10+
pub struct UserDefinedQueryBuilder {
11+
where_clause: String,
12+
bind_values: Vec<BindValue>,
13+
}
14+
15+
#[derive(Debug, Clone, Serialize, Deserialize)]
16+
enum BindValue {
17+
Bool(bool),
18+
String(String),
19+
Number(f64),
20+
ArrayString(Vec<String>),
21+
}
22+
23+
impl UserDefinedQueryBuilder {
24+
pub fn new(schema: &Schema, expr: &QueryExpr) -> Result<Self> {
25+
let mut builder = QueryBuilder::new(schema);
26+
let where_clause = builder.build_where_clause(expr)?;
27+
28+
if where_clause.trim().is_empty() {
29+
return Err(UserQueryError::EmptyQuery);
30+
}
31+
32+
Ok(Self {
33+
where_clause,
34+
bind_values: builder.bind_values,
35+
})
36+
}
37+
38+
pub fn bind_to(&self, mut query: Query) -> Query {
39+
for bind_value in &self.bind_values {
40+
query = match bind_value {
41+
BindValue::Bool(v) => query.bind(*v),
42+
BindValue::String(v) => query.bind(v),
43+
BindValue::Number(v) => query.bind(*v),
44+
BindValue::ArrayString(v) => query.bind(v),
45+
};
46+
}
47+
query
48+
}
49+
50+
pub fn where_expr(&self) -> &str {
51+
&self.where_clause
52+
}
53+
}
54+
55+
struct QueryBuilder<'a> {
56+
schema: &'a Schema,
57+
bind_values: Vec<BindValue>,
58+
}
59+
60+
impl<'a> QueryBuilder<'a> {
61+
fn new(schema: &'a Schema) -> Self {
62+
Self {
63+
schema,
64+
bind_values: Vec::new(),
65+
}
66+
}
67+
68+
fn build_where_clause(&mut self, expr: &QueryExpr) -> Result<String> {
69+
match expr {
70+
QueryExpr::And { exprs } => {
71+
if exprs.is_empty() {
72+
return Err(UserQueryError::EmptyQuery);
73+
}
74+
let clauses: Result<Vec<_>> = exprs
75+
.iter()
76+
.map(|e| self.build_where_clause(e))
77+
.collect();
78+
Ok(format!("({})", clauses?.join(" AND ")))
79+
}
80+
QueryExpr::Or { exprs } => {
81+
if exprs.is_empty() {
82+
return Err(UserQueryError::EmptyQuery);
83+
}
84+
let clauses: Result<Vec<_>> = exprs
85+
.iter()
86+
.map(|e| self.build_where_clause(e))
87+
.collect();
88+
Ok(format!("({})", clauses?.join(" OR ")))
89+
}
90+
QueryExpr::BoolEqual { property, subproperty, value } => {
91+
self.validate_property_access(property, subproperty, &PropertyType::Bool)?;
92+
let column = self.build_column_reference(property, subproperty)?;
93+
self.bind_values.push(BindValue::Bool(*value));
94+
Ok(format!("{} = ?", column))
95+
}
96+
QueryExpr::BoolNotEqual { property, subproperty, value } => {
97+
self.validate_property_access(property, subproperty, &PropertyType::Bool)?;
98+
let column = self.build_column_reference(property, subproperty)?;
99+
self.bind_values.push(BindValue::Bool(*value));
100+
Ok(format!("{} != ?", column))
101+
}
102+
QueryExpr::StringEqual { property, subproperty, value } => {
103+
self.validate_property_access(property, subproperty, &PropertyType::String)?;
104+
let column = self.build_column_reference(property, subproperty)?;
105+
self.bind_values.push(BindValue::String(value.clone()));
106+
Ok(format!("{} = ?", column))
107+
}
108+
QueryExpr::StringNotEqual { property, subproperty, value } => {
109+
self.validate_property_access(property, subproperty, &PropertyType::String)?;
110+
let column = self.build_column_reference(property, subproperty)?;
111+
self.bind_values.push(BindValue::String(value.clone()));
112+
Ok(format!("{} != ?", column))
113+
}
114+
QueryExpr::ArrayContains { property, subproperty, values } => {
115+
if values.is_empty() {
116+
return Err(UserQueryError::EmptyArrayValues("ArrayContains".to_string()));
117+
}
118+
self.validate_property_access(property, subproperty, &PropertyType::ArrayString)?;
119+
let column = self.build_column_reference(property, subproperty)?;
120+
self.bind_values.push(BindValue::ArrayString(values.clone()));
121+
Ok(format!("hasAny({}, ?)", column))
122+
}
123+
QueryExpr::ArrayDoesNotContain { property, subproperty, values } => {
124+
if values.is_empty() {
125+
return Err(UserQueryError::EmptyArrayValues("ArrayDoesNotContain".to_string()));
126+
}
127+
self.validate_property_access(property, subproperty, &PropertyType::ArrayString)?;
128+
let column = self.build_column_reference(property, subproperty)?;
129+
self.bind_values.push(BindValue::ArrayString(values.clone()));
130+
Ok(format!("NOT hasAny({}, ?)", column))
131+
}
132+
QueryExpr::NumberEqual { property, subproperty, value } => {
133+
self.validate_property_access(property, subproperty, &PropertyType::Number)?;
134+
let column = self.build_column_reference(property, subproperty)?;
135+
self.bind_values.push(BindValue::Number(*value));
136+
Ok(format!("{} = ?", column))
137+
}
138+
QueryExpr::NumberNotEqual { property, subproperty, value } => {
139+
self.validate_property_access(property, subproperty, &PropertyType::Number)?;
140+
let column = self.build_column_reference(property, subproperty)?;
141+
self.bind_values.push(BindValue::Number(*value));
142+
Ok(format!("{} != ?", column))
143+
}
144+
QueryExpr::NumberLess { property, subproperty, value } => {
145+
self.validate_property_access(property, subproperty, &PropertyType::Number)?;
146+
let column = self.build_column_reference(property, subproperty)?;
147+
self.bind_values.push(BindValue::Number(*value));
148+
Ok(format!("{} < ?", column))
149+
}
150+
QueryExpr::NumberLessOrEqual { property, subproperty, value } => {
151+
self.validate_property_access(property, subproperty, &PropertyType::Number)?;
152+
let column = self.build_column_reference(property, subproperty)?;
153+
self.bind_values.push(BindValue::Number(*value));
154+
Ok(format!("{} <= ?", column))
155+
}
156+
QueryExpr::NumberGreater { property, subproperty, value } => {
157+
self.validate_property_access(property, subproperty, &PropertyType::Number)?;
158+
let column = self.build_column_reference(property, subproperty)?;
159+
self.bind_values.push(BindValue::Number(*value));
160+
Ok(format!("{} > ?", column))
161+
}
162+
QueryExpr::NumberGreaterOrEqual { property, subproperty, value } => {
163+
self.validate_property_access(property, subproperty, &PropertyType::Number)?;
164+
let column = self.build_column_reference(property, subproperty)?;
165+
self.bind_values.push(BindValue::Number(*value));
166+
Ok(format!("{} >= ?", column))
167+
}
168+
}
169+
}
170+
171+
fn validate_property_access(
172+
&self,
173+
property: &str,
174+
subproperty: &Option<String>,
175+
expected_type: &PropertyType,
176+
) -> Result<()> {
177+
let prop = self.schema.get_property(property)
178+
.ok_or_else(|| UserQueryError::PropertyNotFound(property.to_string()))?;
179+
180+
if subproperty.is_some() && !prop.supports_subproperties {
181+
return Err(UserQueryError::SubpropertiesNotSupported(property.to_string()));
182+
}
183+
184+
if &prop.ty != expected_type {
185+
return Err(UserQueryError::PropertyTypeMismatch(
186+
property.to_string(),
187+
expected_type.type_name().to_string(),
188+
prop.ty.type_name().to_string(),
189+
));
190+
}
191+
192+
Ok(())
193+
}
194+
195+
fn build_column_reference(&self, property: &str, subproperty: &Option<String>) -> Result<String> {
196+
let property_ident = Identifier(property);
197+
198+
match subproperty {
199+
Some(subprop) => {
200+
// For ClickHouse Map access, use string literal syntax
201+
Ok(format!("{}[{}]", property_ident.0, format!("'{}'", subprop.replace("'", "\\'"))))
202+
}
203+
None => Ok(property_ident.0.to_string()),
204+
}
205+
}
206+
}
207+
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
use thiserror::Error;
2+
3+
#[derive(Error, Debug)]
4+
pub enum UserQueryError {
5+
#[error("Property '{0}' not found in schema")]
6+
PropertyNotFound(String),
7+
8+
#[error("Property '{0}' does not support subproperties")]
9+
SubpropertiesNotSupported(String),
10+
11+
#[error("Property '{0}' type mismatch: expected {1}, got {2}")]
12+
PropertyTypeMismatch(String, String, String),
13+
14+
#[error("Invalid property or subproperty name '{0}': must contain only alphanumeric characters and underscores")]
15+
InvalidPropertyName(String),
16+
17+
#[error("Empty query expression")]
18+
EmptyQuery,
19+
20+
#[error("Empty array values in {0} operation")]
21+
EmptyArrayValues(String),
22+
}
23+
24+
pub type Result<T> = std::result::Result<T, UserQueryError>;
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
//! Safe ClickHouse user-defined query builder
2+
//!
3+
//! This crate provides a safe way to build ClickHouse queries from user-defined expressions
4+
//! while protecting against SQL injection attacks. All user inputs are properly validated
5+
//! and bound using parameterized queries.
6+
//!
7+
//! # Example
8+
//!
9+
//! ```rust
10+
//! use clickhouse_user_query::*;
11+
//!
12+
//! // Define the schema of allowed properties
13+
//! let schema = Schema::new(vec![
14+
//! Property::new("user_id".to_string(), false, PropertyType::String).unwrap(),
15+
//! Property::new("metadata".to_string(), true, PropertyType::String).unwrap(),
16+
//! Property::new("active".to_string(), false, PropertyType::Bool).unwrap(),
17+
//! Property::new("tags".to_string(), false, PropertyType::ArrayString).unwrap(),
18+
//! ]).unwrap();
19+
//!
20+
//! // Build a complex query expression
21+
//! let query_expr = QueryExpr::And {
22+
//! exprs: vec![
23+
//! QueryExpr::StringEqual {
24+
//! property: "user_id".to_string(),
25+
//! subproperty: None,
26+
//! value: "12345".to_string(),
27+
//! },
28+
//! QueryExpr::BoolEqual {
29+
//! property: "active".to_string(),
30+
//! subproperty: None,
31+
//! value: true,
32+
//! },
33+
//! QueryExpr::ArrayContains {
34+
//! property: "tags".to_string(),
35+
//! subproperty: None,
36+
//! values: vec!["premium".to_string(), "verified".to_string()],
37+
//! },
38+
//! ],
39+
//! };
40+
//!
41+
//! // Create the safe query builder
42+
//! let builder = UserDefinedQueryBuilder::new(&schema, &query_expr).unwrap();
43+
//!
44+
//! // Use with ClickHouse client (commented out since clickhouse client not available in tests)
45+
//! // let query = clickhouse::Client::default()
46+
//! // .query("SELECT * FROM users WHERE ?")
47+
//! // .bind(builder.where_expr());
48+
//! // let final_query = builder.bind_to(query);
49+
//! ```
50+
51+
// Re-export all public types for convenience
52+
pub use builder::UserDefinedQueryBuilder;
53+
pub use error::{Result, UserQueryError};
54+
pub use query::QueryExpr;
55+
pub use schema::{Property, PropertyType, Schema};
56+
57+
pub mod builder;
58+
pub mod error;
59+
pub mod query;
60+
pub mod schema;
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
use serde::{Deserialize, Serialize};
2+
3+
#[derive(Debug, Clone, Serialize, Deserialize)]
4+
#[serde(rename_all = "snake_case")]
5+
pub enum QueryExpr {
6+
And {
7+
exprs: Vec<QueryExpr>,
8+
},
9+
Or {
10+
exprs: Vec<QueryExpr>,
11+
},
12+
BoolEqual {
13+
property: String,
14+
subproperty: Option<String>,
15+
value: bool,
16+
},
17+
BoolNotEqual {
18+
property: String,
19+
subproperty: Option<String>,
20+
value: bool,
21+
},
22+
StringEqual {
23+
property: String,
24+
subproperty: Option<String>,
25+
value: String,
26+
},
27+
StringNotEqual {
28+
property: String,
29+
subproperty: Option<String>,
30+
value: String,
31+
},
32+
ArrayContains {
33+
property: String,
34+
subproperty: Option<String>,
35+
values: Vec<String>,
36+
},
37+
ArrayDoesNotContain {
38+
property: String,
39+
subproperty: Option<String>,
40+
values: Vec<String>,
41+
},
42+
NumberEqual {
43+
property: String,
44+
subproperty: Option<String>,
45+
value: f64,
46+
},
47+
NumberNotEqual {
48+
property: String,
49+
subproperty: Option<String>,
50+
value: f64,
51+
},
52+
NumberLess {
53+
property: String,
54+
subproperty: Option<String>,
55+
value: f64,
56+
},
57+
NumberLessOrEqual {
58+
property: String,
59+
subproperty: Option<String>,
60+
value: f64,
61+
},
62+
NumberGreater {
63+
property: String,
64+
subproperty: Option<String>,
65+
value: f64,
66+
},
67+
NumberGreaterOrEqual {
68+
property: String,
69+
subproperty: Option<String>,
70+
value: f64,
71+
},
72+
}
73+

0 commit comments

Comments
 (0)