use std::fmt;
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum Field {
JsonbField(String),
DirectColumn(String),
JsonbPath(Vec<String>),
}
impl Field {
pub fn validate(&self) -> Result<(), String> {
let name = match self {
Field::JsonbField(n) => n,
Field::DirectColumn(n) => n,
Field::JsonbPath(path) => {
for segment in path {
if !is_valid_field_name(segment) {
return Err(format!("Invalid field name in path: {}", segment));
}
}
return Ok(());
}
};
if !is_valid_field_name(name) {
return Err(format!("Invalid field name: {}", name));
}
Ok(())
}
pub fn to_sql(&self) -> String {
match self {
Field::JsonbField(name) => format!("(data->'{}')", name),
Field::DirectColumn(name) => name.clone(),
Field::JsonbPath(path) => {
if path.is_empty() {
return "data".to_string();
}
let mut sql = String::from("(data");
for (i, segment) in path.iter().enumerate() {
if i == path.len() - 1 {
sql.push_str(&format!("->>'{}\'", segment));
} else {
sql.push_str(&format!("->'{}\'", segment));
}
}
sql.push(')');
sql
}
}
}
}
impl fmt::Display for Field {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Field::JsonbField(name) => write!(f, "data->'{}'", name),
Field::DirectColumn(name) => write!(f, "{}", name),
Field::JsonbPath(path) => {
write!(f, "data")?;
for (i, segment) in path.iter().enumerate() {
if i == path.len() - 1 {
write!(f, "->>{}", segment)?;
} else {
write!(f, "->{}", segment)?;
}
}
Ok(())
}
}
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub enum Value {
String(String),
Number(f64),
Bool(bool),
Null,
Array(Vec<Value>),
FloatArray(Vec<f32>),
RawSql(String),
}
impl Value {
pub const fn is_null(&self) -> bool {
matches!(self, Value::Null)
}
pub fn to_sql_literal(&self) -> String {
match self {
Value::String(s) => format!("'{}'", s.replace('\'', "''")),
Value::Number(n) => n.to_string(),
Value::Bool(b) => b.to_string(),
Value::Null => "NULL".to_string(),
Value::Array(arr) => {
let items: Vec<String> = arr.iter().map(|v| v.to_sql_literal()).collect();
format!("ARRAY[{}]", items.join(", "))
}
Value::FloatArray(arr) => {
let items: Vec<String> = arr.iter().map(|f| f.to_string()).collect();
format!("[{}]", items.join(", "))
}
Value::RawSql(sql) => sql.clone(),
}
}
}
impl fmt::Display for Value {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.to_sql_literal())
}
}
fn is_valid_field_name(name: &str) -> bool {
if name.is_empty() {
return false;
}
let first = name
.chars()
.next()
.expect("empty name already returned false above");
if !first.is_alphabetic() && first != '_' {
return false;
}
name.chars().all(|c| c.is_alphanumeric() || c == '_')
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_valid_field_names() {
assert!(is_valid_field_name("name"));
assert!(is_valid_field_name("_private"));
assert!(is_valid_field_name("field_123"));
assert!(is_valid_field_name("a"));
}
#[test]
fn test_invalid_field_names() {
assert!(!is_valid_field_name(""));
assert!(!is_valid_field_name("123field")); assert!(!is_valid_field_name("field-name")); assert!(!is_valid_field_name("field.name")); assert!(!is_valid_field_name("field'name")); }
#[test]
fn test_field_validation() {
Field::JsonbField("name".to_string())
.validate()
.unwrap_or_else(|e| panic!("expected Ok for valid field 'name': {e}"));
let result = Field::JsonbField("name-invalid".to_string()).validate();
assert!(
result.is_err(),
"expected Err for field 'name-invalid', got: {result:?}"
);
Field::JsonbPath(vec!["user".to_string(), "name".to_string()])
.validate()
.unwrap_or_else(|e| panic!("expected Ok for valid JsonbPath [user, name]: {e}"));
}
#[test]
fn test_field_to_sql_jsonb() {
let field = Field::JsonbField("name".to_string());
assert_eq!(field.to_sql(), "(data->'name')");
}
#[test]
fn test_field_to_sql_direct() {
let field = Field::DirectColumn("created_at".to_string());
assert_eq!(field.to_sql(), "created_at");
}
#[test]
fn test_field_to_sql_path() {
let field = Field::JsonbPath(vec!["user".to_string(), "name".to_string()]);
assert_eq!(field.to_sql(), "(data->'user'->>'name')");
}
#[test]
fn test_value_to_sql_literal() {
assert_eq!(Value::String("test".to_string()).to_sql_literal(), "'test'");
assert_eq!(Value::Number(42.0).to_sql_literal(), "42");
assert_eq!(Value::Bool(true).to_sql_literal(), "true");
assert_eq!(Value::Null.to_sql_literal(), "NULL");
}
#[test]
fn test_value_string_escaping() {
let val = Value::String("O'Brien".to_string());
assert_eq!(val.to_sql_literal(), "'O''Brien'");
}
}