use chrono::{DateTime, TimeZone};
use serde::Serialize;
#[derive(Debug, Default)]
pub struct QueryBuilder {
params: Vec<(String, String)>,
}
impl QueryBuilder {
pub fn new() -> Self {
Self::default()
}
pub fn string(mut self, key: &str, value: impl Into<Option<String>>) -> Self {
if let Some(v) = value.into() {
self.params.push((key.to_string(), v));
}
self
}
pub fn string_array<I, T>(mut self, key: &str, values: I) -> Self
where
I: IntoIterator<Item = T>,
T: Into<Option<String>>,
{
for value in values {
if let Some(v) = value.into() {
self.params.push((key.to_string(), v));
}
}
self
}
pub fn int(mut self, key: &str, value: impl Into<Option<i64>>) -> Self {
if let Some(v) = value.into() {
self.params.push((key.to_string(), v.to_string()));
}
self
}
pub fn int_array<I, T>(mut self, key: &str, values: I) -> Self
where
I: IntoIterator<Item = T>,
T: Into<Option<i64>>,
{
for value in values {
if let Some(v) = value.into() {
self.params.push((key.to_string(), v.to_string()));
}
}
self
}
pub fn float(mut self, key: &str, value: impl Into<Option<f64>>) -> Self {
if let Some(v) = value.into() {
self.params.push((key.to_string(), v.to_string()));
}
self
}
pub fn float_array<I, T>(mut self, key: &str, values: I) -> Self
where
I: IntoIterator<Item = T>,
T: Into<Option<f64>>,
{
for value in values {
if let Some(v) = value.into() {
self.params.push((key.to_string(), v.to_string()));
}
}
self
}
pub fn bool(mut self, key: &str, value: impl Into<Option<bool>>) -> Self {
if let Some(v) = value.into() {
self.params.push((key.to_string(), v.to_string()));
}
self
}
pub fn bool_array<I, T>(mut self, key: &str, values: I) -> Self
where
I: IntoIterator<Item = T>,
T: Into<Option<bool>>,
{
for value in values {
if let Some(v) = value.into() {
self.params.push((key.to_string(), v.to_string()));
}
}
self
}
pub fn datetime<Tz: TimeZone>(
mut self,
key: &str,
value: impl Into<Option<DateTime<Tz>>>,
) -> Self
where
Tz::Offset: std::fmt::Display,
{
if let Some(v) = value.into() {
self.params.push((
key.to_string(),
v.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
));
}
self
}
pub fn date(mut self, key: &str, value: impl Into<Option<chrono::NaiveDate>>) -> Self {
if let Some(v) = value.into() {
let datetime = v.and_hms_opt(0, 0, 0).unwrap().and_utc();
self.params.push((
key.to_string(),
datetime.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
));
}
self
}
pub fn uuid(mut self, key: &str, value: impl Into<Option<uuid::Uuid>>) -> Self {
if let Some(v) = value.into() {
self.params.push((key.to_string(), v.to_string()));
}
self
}
pub fn serialize<T: Serialize>(mut self, key: &str, value: Option<T>) -> Self {
if let Some(v) = value {
if let Ok(serialized) = serde_json::to_string(&v) {
let cleaned = if serialized.starts_with('"') && serialized.ends_with('"') {
serialized.trim_matches('"').to_string()
} else {
serialized
};
self.params.push((key.to_string(), cleaned));
}
}
self
}
pub fn serialize_array<T: Serialize>(
mut self,
key: &str,
values: impl IntoIterator<Item = T>,
) -> Self {
for value in values {
if let Ok(serialized) = serde_json::to_string(&value) {
if serialized == "null" {
continue;
}
let cleaned = if serialized.starts_with('"') && serialized.ends_with('"') {
serialized.trim_matches('"').to_string()
} else {
serialized
};
self.params.push((key.to_string(), cleaned));
}
}
self
}
pub fn structured_query(mut self, key: &str, value: impl Into<Option<String>>) -> Self {
if let Some(query_str) = value.into() {
if let Ok(parsed_params) = parse_structured_query(&query_str) {
self.params.extend(parsed_params);
} else {
self.params.push((key.to_string(), query_str));
}
}
self
}
pub fn build(self) -> Option<Vec<(String, String)>> {
if self.params.is_empty() {
None
} else {
Some(self.params)
}
}
}
#[derive(Debug)]
pub enum QueryBuilderError {
InvalidQuerySyntax(String),
}
impl std::fmt::Display for QueryBuilderError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
QueryBuilderError::InvalidQuerySyntax(msg) => {
write!(f, "Invalid query syntax: {}", msg)
}
}
}
}
impl std::error::Error for QueryBuilderError {}
pub fn parse_structured_query(query: &str) -> Result<Vec<(String, String)>, QueryBuilderError> {
let mut params = Vec::new();
let terms = tokenize_query(query);
for term in terms {
if let Some((key, values)) = term.split_once(':') {
for value in values.split(',') {
let clean_value = value.trim_matches('"'); params.push((key.to_string(), clean_value.to_string()));
}
} else {
return Err(QueryBuilderError::InvalidQuerySyntax(format!(
"Cannot parse term '{}' - expected 'key:value' format for structured queries",
term
)));
}
}
Ok(params)
}
fn tokenize_query(input: &str) -> Vec<String> {
let mut tokens = Vec::new();
let mut current_token = String::new();
let mut in_quotes = false;
let mut chars = input.chars().peekable();
while let Some(c) = chars.next() {
match c {
'"' => {
in_quotes = !in_quotes;
current_token.push(c);
}
' ' if !in_quotes => {
if !current_token.is_empty() {
tokens.push(current_token.trim().to_string());
current_token.clear();
}
}
_ => {
current_token.push(c);
}
}
}
if !current_token.is_empty() {
tokens.push(current_token.trim().to_string());
}
tokens
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{NaiveDate, TimeZone, Utc};
#[test]
fn test_empty_builder_returns_none() {
let result = QueryBuilder::new().build();
assert!(result.is_none());
}
#[test]
fn test_string_param_some() {
let result = QueryBuilder::new()
.string("name", Some("alice".to_string()))
.build();
assert_eq!(
result,
Some(vec![("name".to_string(), "alice".to_string())])
);
}
#[test]
fn test_string_param_none_skipped() {
let result = QueryBuilder::new().string("name", None::<String>).build();
assert!(result.is_none());
}
#[test]
fn test_int_param() {
let result = QueryBuilder::new().int("page", Some(42i64)).build();
assert_eq!(result, Some(vec![("page".to_string(), "42".to_string())]));
}
#[test]
fn test_int_param_none_skipped() {
let result = QueryBuilder::new().int("page", None::<i64>).build();
assert!(result.is_none());
}
#[test]
fn test_float_param() {
let result = QueryBuilder::new().float("score", Some(3.14f64)).build();
assert_eq!(
result,
Some(vec![("score".to_string(), "3.14".to_string())])
);
}
#[test]
fn test_bool_param() {
let result = QueryBuilder::new().bool("active", Some(true)).build();
assert_eq!(
result,
Some(vec![("active".to_string(), "true".to_string())])
);
}
#[test]
fn test_uuid_param() {
let id = uuid::Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
let result = QueryBuilder::new().uuid("id", Some(id)).build();
assert_eq!(
result,
Some(vec![(
"id".to_string(),
"550e8400-e29b-41d4-a716-446655440000".to_string()
)])
);
}
#[test]
fn test_datetime_param_formats_rfc3339() {
let dt = Utc.with_ymd_and_hms(2024, 1, 15, 9, 30, 0).unwrap();
let result = QueryBuilder::new().datetime("since", Some(dt)).build();
assert_eq!(
result,
Some(vec![(
"since".to_string(),
"2024-01-15T09:30:00Z".to_string()
)])
);
}
#[test]
fn test_date_param_converts_to_midnight_utc() {
let date = NaiveDate::from_ymd_opt(2024, 1, 15).unwrap();
let result = QueryBuilder::new().date("on", Some(date)).build();
assert_eq!(
result,
Some(vec![("on".to_string(), "2024-01-15T00:00:00Z".to_string())])
);
}
#[test]
fn test_string_array_multiple_entries() {
let result = QueryBuilder::new()
.string_array(
"tag",
vec!["a".to_string(), "b".to_string(), "c".to_string()],
)
.build();
assert_eq!(
result,
Some(vec![
("tag".to_string(), "a".to_string()),
("tag".to_string(), "b".to_string()),
("tag".to_string(), "c".to_string()),
])
);
}
#[test]
fn test_int_array() {
let result = QueryBuilder::new()
.int_array("ids", vec![1i64, 2, 3])
.build();
assert_eq!(
result,
Some(vec![
("ids".to_string(), "1".to_string()),
("ids".to_string(), "2".to_string()),
("ids".to_string(), "3".to_string()),
])
);
}
#[test]
fn test_float_array() {
let result = QueryBuilder::new()
.float_array("scores", vec![1.1f64, 2.2])
.build();
assert_eq!(
result,
Some(vec![
("scores".to_string(), "1.1".to_string()),
("scores".to_string(), "2.2".to_string()),
])
);
}
#[test]
fn test_bool_array() {
let result = QueryBuilder::new()
.bool_array("flags", vec![true, false])
.build();
assert_eq!(
result,
Some(vec![
("flags".to_string(), "true".to_string()),
("flags".to_string(), "false".to_string()),
])
);
}
#[test]
fn test_serialize_strips_json_quotes() {
let result = QueryBuilder::new()
.serialize("status", Some("active"))
.build();
assert_eq!(
result,
Some(vec![("status".to_string(), "active".to_string())])
);
}
#[test]
fn test_serialize_none_skipped() {
let result = QueryBuilder::new()
.serialize::<String>("status", None)
.build();
assert!(result.is_none());
}
#[test]
fn test_serialize_numeric_no_quotes() {
let result = QueryBuilder::new().serialize("count", Some(42)).build();
assert_eq!(result, Some(vec![("count".to_string(), "42".to_string())]));
}
#[test]
fn test_serialize_array_skips_null() {
let values: Vec<Option<&str>> = vec![Some("a"), None, Some("b")];
let result = QueryBuilder::new().serialize_array("items", values).build();
assert_eq!(
result,
Some(vec![
("items".to_string(), "a".to_string()),
("items".to_string(), "b".to_string()),
])
);
}
#[test]
fn test_method_chaining() {
let result = QueryBuilder::new()
.string("name", Some("alice".to_string()))
.int("page", Some(1i64))
.bool("active", Some(true))
.build();
assert_eq!(
result,
Some(vec![
("name".to_string(), "alice".to_string()),
("page".to_string(), "1".to_string()),
("active".to_string(), "true".to_string()),
])
);
}
#[test]
fn test_parse_simple_key_value() {
let result = parse_structured_query("status:active").unwrap();
assert_eq!(result, vec![("status".to_string(), "active".to_string())]);
}
#[test]
fn test_parse_comma_separated_values() {
let result = parse_structured_query("type:sensor,camera").unwrap();
assert_eq!(
result,
vec![
("type".to_string(), "sensor".to_string()),
("type".to_string(), "camera".to_string()),
]
);
}
#[test]
fn test_parse_multiple_terms() {
let result = parse_structured_query("status:active type:sensor").unwrap();
assert_eq!(
result,
vec![
("status".to_string(), "active".to_string()),
("type".to_string(), "sensor".to_string()),
]
);
}
#[test]
fn test_parse_quoted_value() {
let result = parse_structured_query("location:\"New York\"").unwrap();
assert_eq!(
result,
vec![("location".to_string(), "New York".to_string())]
);
}
#[test]
fn test_parse_bare_word_returns_error() {
let result = parse_structured_query("bareword");
assert!(result.is_err());
}
#[test]
fn test_structured_query_builder_fallback() {
let result = QueryBuilder::new()
.structured_query("q", Some("bareword".to_string()))
.build();
assert_eq!(
result,
Some(vec![("q".to_string(), "bareword".to_string())])
);
}
#[test]
fn test_structured_query_builder_parses() {
let result = QueryBuilder::new()
.structured_query("q", Some("status:active".to_string()))
.build();
assert_eq!(
result,
Some(vec![("status".to_string(), "active".to_string())])
);
}
#[test]
fn test_structured_query_none_skipped() {
let result = QueryBuilder::new()
.structured_query("q", None::<String>)
.build();
assert!(result.is_none());
}
}