Skip to main content

fraiseql_wire/operators/
field.rs

1//! Field and value type definitions for operators
2//!
3//! Provides type-safe representations of database fields and values
4//! to prevent SQL injection and improve API ergonomics.
5
6use std::fmt;
7
8/// Represents a field reference in a WHERE clause or ORDER BY
9///
10/// Supports both JSONB payload fields and direct database columns,
11/// with automatic type casting and proper SQL generation.
12///
13/// # Examples
14///
15/// ```rust
16/// use fraiseql_wire::operators::Field;
17///
18/// // JSONB field: (data->>'name')
19/// let _ = Field::JsonbField("name".to_string());
20///
21/// // Direct column: created_at
22/// let _ = Field::DirectColumn("created_at".to_string());
23///
24/// // Nested JSONB: (data->'user'->>'name')
25/// let _ = Field::JsonbPath(vec!["user".to_string(), "name".to_string()]);
26/// ```
27#[derive(Debug, Clone, PartialEq, Eq)]
28#[non_exhaustive]
29pub enum Field {
30    /// A field extracted from the JSONB `data` column with text extraction (->>)
31    ///
32    /// The value is extracted as text and wrapped in parentheses.
33    ///
34    /// Generated SQL: `(data->>'field_name')`
35    JsonbField(String),
36
37    /// A direct database column (not from JSONB)
38    ///
39    /// Uses the native type stored in the database.
40    ///
41    /// Generated SQL: `column_name`
42    DirectColumn(String),
43
44    /// A nested path within the JSONB `data` column
45    ///
46    /// The path is traversed left-to-right, with intermediate steps using `->` (JSON navigation)
47    /// and the final step using `->>` (text extraction).
48    ///
49    /// All extracted values are text and wrapped in parentheses.
50    ///
51    /// Generated SQL: `(data->'path[0]'->...->>'path[n]')`
52    JsonbPath(Vec<String>),
53}
54
55impl Field {
56    /// Validate field name to prevent SQL injection
57    ///
58    /// Allows: alphanumeric, underscore
59    /// Disallows: quotes, brackets, dashes, special characters
60    ///
61    /// # Errors
62    ///
63    /// Returns an error string if any field name (or path segment) contains characters
64    /// other than alphanumeric and underscore.
65    pub fn validate(&self) -> Result<(), String> {
66        let name = match self {
67            Field::JsonbField(n) => n,
68            Field::DirectColumn(n) => n,
69            Field::JsonbPath(path) => {
70                for segment in path {
71                    if !is_valid_field_name(segment) {
72                        return Err(format!("Invalid field name in path: {}", segment));
73                    }
74                }
75                return Ok(());
76            }
77        };
78
79        if !is_valid_field_name(name) {
80            return Err(format!("Invalid field name: {}", name));
81        }
82
83        Ok(())
84    }
85
86    /// Generate SQL for this field
87    pub fn to_sql(&self) -> String {
88        match self {
89            Field::JsonbField(name) => format!("(data->'{}')", name),
90            Field::DirectColumn(name) => name.clone(),
91            Field::JsonbPath(path) => {
92                if path.is_empty() {
93                    return "data".to_string();
94                }
95
96                let mut sql = String::from("(data");
97                for (i, segment) in path.iter().enumerate() {
98                    if i == path.len() - 1 {
99                        // Last segment: use ->> for text extraction
100                        sql.push_str(&format!("->>'{}\'", segment));
101                    } else {
102                        // Intermediate segments: use -> for JSON objects
103                        sql.push_str(&format!("->'{}\'", segment));
104                    }
105                }
106                sql.push(')');
107                sql
108            }
109        }
110    }
111}
112
113impl fmt::Display for Field {
114    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
115        match self {
116            Field::JsonbField(name) => write!(f, "data->'{}'", name),
117            Field::DirectColumn(name) => write!(f, "{}", name),
118            Field::JsonbPath(path) => {
119                write!(f, "data")?;
120                for (i, segment) in path.iter().enumerate() {
121                    if i == path.len() - 1 {
122                        write!(f, "->>{}", segment)?;
123                    } else {
124                        write!(f, "->{}", segment)?;
125                    }
126                }
127                Ok(())
128            }
129        }
130    }
131}
132
133/// Represents a value to bind in a WHERE clause
134///
135/// # Examples
136///
137/// ```rust
138/// use fraiseql_wire::operators::Value;
139///
140/// let _ = Value::String("John".to_string());
141/// let _ = Value::Number(42.0);
142/// let _ = Value::Bool(true);
143/// let _ = Value::Null;
144/// let _ = Value::Array(vec![Value::String("a".to_string()), Value::String("b".to_string())]);
145/// ```
146#[derive(Debug, Clone)]
147#[non_exhaustive]
148pub enum Value {
149    /// String value
150    String(String),
151
152    /// Numeric value (f64 can represent i64, u64, f32 with precision)
153    Number(f64),
154
155    /// Boolean value
156    Bool(bool),
157
158    /// NULL
159    Null,
160
161    /// Array of values (for IN operators)
162    Array(Vec<Value>),
163
164    /// Vector of floats (for pgvector distance operators)
165    FloatArray(Vec<f32>),
166
167    /// Raw SQL expression (use with caution!)
168    ///
169    /// This should only be used for trusted SQL fragments,
170    /// never for user input.
171    RawSql(String),
172}
173
174impl Value {
175    /// Check if value is NULL
176    pub const fn is_null(&self) -> bool {
177        matches!(self, Value::Null)
178    }
179
180    /// Convert value to SQL literal
181    ///
182    /// For parameterized queries, prefer using parameter placeholders ($1, $2, etc.)
183    /// This is primarily for documentation and debugging.
184    pub fn to_sql_literal(&self) -> String {
185        match self {
186            Value::String(s) => format!("'{}'", s.replace('\'', "''")),
187            Value::Number(n) => n.to_string(),
188            Value::Bool(b) => b.to_string(),
189            Value::Null => "NULL".to_string(),
190            Value::Array(arr) => {
191                let items: Vec<String> = arr.iter().map(|v| v.to_sql_literal()).collect();
192                format!("ARRAY[{}]", items.join(", "))
193            }
194            Value::FloatArray(arr) => {
195                let items: Vec<String> = arr.iter().map(|f| f.to_string()).collect();
196                format!("[{}]", items.join(", "))
197            }
198            Value::RawSql(sql) => sql.clone(),
199        }
200    }
201}
202
203impl fmt::Display for Value {
204    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
205        write!(f, "{}", self.to_sql_literal())
206    }
207}
208
209/// Check if a field name is valid (alphanumeric + underscore)
210fn is_valid_field_name(name: &str) -> bool {
211    if name.is_empty() {
212        return false;
213    }
214
215    // First character must be alphabetic or underscore
216    let first = name
217        .chars()
218        .next()
219        .expect("empty name already returned false above");
220    if !first.is_alphabetic() && first != '_' {
221        return false;
222    }
223
224    // Remaining characters must be alphanumeric or underscore
225    name.chars().all(|c| c.is_alphanumeric() || c == '_')
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231
232    #[test]
233    fn test_valid_field_names() {
234        assert!(is_valid_field_name("name"));
235        assert!(is_valid_field_name("_private"));
236        assert!(is_valid_field_name("field_123"));
237        assert!(is_valid_field_name("a"));
238    }
239
240    #[test]
241    fn test_invalid_field_names() {
242        assert!(!is_valid_field_name(""));
243        assert!(!is_valid_field_name("123field")); // starts with digit
244        assert!(!is_valid_field_name("field-name")); // contains dash
245        assert!(!is_valid_field_name("field.name")); // contains dot
246        assert!(!is_valid_field_name("field'name")); // contains quote
247    }
248
249    #[test]
250    fn test_field_validation() {
251        Field::JsonbField("name".to_string())
252            .validate()
253            .unwrap_or_else(|e| panic!("expected Ok for valid field 'name': {e}"));
254
255        let result = Field::JsonbField("name-invalid".to_string()).validate();
256        assert!(
257            result.is_err(),
258            "expected Err for field 'name-invalid', got: {result:?}"
259        );
260
261        Field::JsonbPath(vec!["user".to_string(), "name".to_string()])
262            .validate()
263            .unwrap_or_else(|e| panic!("expected Ok for valid JsonbPath [user, name]: {e}"));
264    }
265
266    #[test]
267    fn test_field_to_sql_jsonb() {
268        let field = Field::JsonbField("name".to_string());
269        assert_eq!(field.to_sql(), "(data->'name')");
270    }
271
272    #[test]
273    fn test_field_to_sql_direct() {
274        let field = Field::DirectColumn("created_at".to_string());
275        assert_eq!(field.to_sql(), "created_at");
276    }
277
278    #[test]
279    fn test_field_to_sql_path() {
280        let field = Field::JsonbPath(vec!["user".to_string(), "name".to_string()]);
281        assert_eq!(field.to_sql(), "(data->'user'->>'name')");
282    }
283
284    #[test]
285    fn test_value_to_sql_literal() {
286        assert_eq!(Value::String("test".to_string()).to_sql_literal(), "'test'");
287        assert_eq!(Value::Number(42.0).to_sql_literal(), "42");
288        assert_eq!(Value::Bool(true).to_sql_literal(), "true");
289        assert_eq!(Value::Null.to_sql_literal(), "NULL");
290    }
291
292    #[test]
293    fn test_value_string_escaping() {
294        let val = Value::String("O'Brien".to_string());
295        assert_eq!(val.to_sql_literal(), "'O''Brien'");
296    }
297}