fraiseql_wire/operators/field/mod.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 #[must_use]
88 pub fn to_sql(&self) -> String {
89 match self {
90 Field::JsonbField(name) => format!("(data->'{}')", name),
91 Field::DirectColumn(name) => name.clone(),
92 Field::JsonbPath(path) => {
93 if path.is_empty() {
94 return "data".to_string();
95 }
96
97 let mut sql = String::from("(data");
98 for (i, segment) in path.iter().enumerate() {
99 if i == path.len() - 1 {
100 // Last segment: use ->> for text extraction
101 sql.push_str(&format!("->>'{}\'", segment));
102 } else {
103 // Intermediate segments: use -> for JSON objects
104 sql.push_str(&format!("->'{}\'", segment));
105 }
106 }
107 sql.push(')');
108 sql
109 }
110 }
111 }
112}
113
114impl fmt::Display for Field {
115 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
116 match self {
117 Field::JsonbField(name) => write!(f, "data->'{}'", name),
118 Field::DirectColumn(name) => write!(f, "{}", name),
119 Field::JsonbPath(path) => {
120 write!(f, "data")?;
121 for (i, segment) in path.iter().enumerate() {
122 if i == path.len() - 1 {
123 write!(f, "->>{}", segment)?;
124 } else {
125 write!(f, "->{}", segment)?;
126 }
127 }
128 Ok(())
129 }
130 }
131 }
132}
133
134/// Represents a value to bind in a WHERE clause
135///
136/// # Examples
137///
138/// ```rust
139/// use fraiseql_wire::operators::Value;
140///
141/// let _ = Value::String("John".to_string());
142/// let _ = Value::Number(42.0);
143/// let _ = Value::Bool(true);
144/// let _ = Value::Null;
145/// let _ = Value::Array(vec![Value::String("a".to_string()), Value::String("b".to_string())]);
146/// ```
147#[derive(Debug, Clone)]
148#[non_exhaustive]
149pub enum Value {
150 /// String value
151 String(String),
152
153 /// Numeric value (f64 can represent i64, u64, f32 with precision)
154 Number(f64),
155
156 /// Boolean value
157 Bool(bool),
158
159 /// NULL
160 Null,
161
162 /// Array of values (for IN operators)
163 Array(Vec<Value>),
164
165 /// Vector of floats (for pgvector distance operators)
166 FloatArray(Vec<f32>),
167
168 /// Raw SQL expression (use with caution!)
169 ///
170 /// This should only be used for trusted SQL fragments,
171 /// never for user input.
172 RawSql(String),
173}
174
175impl Value {
176 /// Check if value is NULL
177 #[must_use]
178 pub const fn is_null(&self) -> bool {
179 matches!(self, Value::Null)
180 }
181
182 /// Convert value to SQL literal
183 ///
184 /// For parameterized queries, prefer using parameter placeholders ($1, $2, etc.)
185 /// This is primarily for documentation and debugging.
186 #[must_use]
187 pub fn to_sql_literal(&self) -> String {
188 match self {
189 Value::String(s) => format!("'{}'", s.replace('\'', "''")),
190 Value::Number(n) => n.to_string(),
191 Value::Bool(b) => b.to_string(),
192 Value::Null => "NULL".to_string(),
193 Value::Array(arr) => {
194 let items: Vec<String> = arr.iter().map(|v| v.to_sql_literal()).collect();
195 format!("ARRAY[{}]", items.join(", "))
196 }
197 Value::FloatArray(arr) => {
198 let items: Vec<String> = arr.iter().map(|f| f.to_string()).collect();
199 format!("[{}]", items.join(", "))
200 }
201 Value::RawSql(sql) => sql.clone(),
202 }
203 }
204}
205
206impl fmt::Display for Value {
207 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
208 write!(f, "{}", self.to_sql_literal())
209 }
210}
211
212/// Check if a field name is valid (alphanumeric + underscore)
213fn is_valid_field_name(name: &str) -> bool {
214 if name.is_empty() {
215 return false;
216 }
217
218 // First character must be alphabetic or underscore
219 let first = name
220 .chars()
221 .next()
222 .expect("empty name already returned false above");
223 if !first.is_alphabetic() && first != '_' {
224 return false;
225 }
226
227 // Remaining characters must be alphanumeric or underscore
228 name.chars().all(|c| c.is_alphanumeric() || c == '_')
229}
230
231#[cfg(test)]
232mod tests;