Skip to main content

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