Skip to main content

fraiseql_core/db/
projection_generator.rs

1//! SQL Projection Query Generator
2//!
3//! Generates database-specific SQL for field projection optimization.
4//!
5//! # Overview
6//!
7//! When a schema type has a `SqlProjectionHint`, this module generates the actual SQL
8//! to project only requested fields at the database level, reducing network payload
9//! and JSON deserialization overhead.
10//!
11//! # Supported Databases
12//!
13//! - PostgreSQL: Uses `jsonb_build_object()` for efficient field selection
14//! - MySQL, SQLite, SQL Server: Multi-database support
15//!
16//! # Example
17//!
18//! ```rust,ignore
19//! use fraiseql_core::db::projection_generator::PostgresProjectionGenerator;
20//!
21//! let generator = PostgresProjectionGenerator::new();
22//! let fields = vec!["id".to_string(), "name".to_string(), "email".to_string()];
23//! let sql = generator.generate_projection_sql("user_data", &fields)?;
24//! // Returns: jsonb_build_object('id', data->>'id', 'name', data->>'name', 'email', data->>'email')
25//! ```
26
27use crate::error::Result;
28
29/// Convert camelCase field name to snake_case for JSON/JSONB key lookup.
30///
31/// FraiseQL converts schema field names from snake_case to camelCase for GraphQL spec compliance.
32/// However, JSON/JSONB keys are stored in their original snake_case form.
33/// This function reverses that conversion for JSON key access.
34///
35/// # Examples
36///
37/// ```rust,ignore
38/// assert_eq!(to_snake_case("firstName"), "first_name");
39/// assert_eq!(to_snake_case("id"), "id");
40/// ```
41fn to_snake_case(name: &str) -> String {
42    let mut result = String::new();
43    for (i, ch) in name.chars().enumerate() {
44        if ch.is_uppercase() && i > 0 {
45            result.push('_');
46            result.push(
47                ch.to_lowercase()
48                    .next()
49                    .expect("char::to_lowercase always yields at least one char"),
50            );
51        } else {
52            result.push(ch);
53        }
54    }
55    result
56}
57
58/// PostgreSQL SQL projection generator using jsonb_build_object.
59///
60/// Generates efficient PostgreSQL SQL that projects only requested JSONB fields,
61/// reducing payload size and JSON deserialization time.
62pub struct PostgresProjectionGenerator {
63    /// JSONB column name (typically "data")
64    jsonb_column: String,
65}
66
67impl PostgresProjectionGenerator {
68    /// Create new PostgreSQL projection generator with default JSONB column name.
69    ///
70    /// Default JSONB column: "data"
71    #[must_use]
72    pub fn new() -> Self {
73        Self::with_column("data")
74    }
75
76    /// Create projection generator with custom JSONB column name.
77    ///
78    /// # Arguments
79    ///
80    /// * `jsonb_column` - Name of the JSONB column in the database table
81    #[must_use]
82    pub fn with_column(jsonb_column: &str) -> Self {
83        Self {
84            jsonb_column: jsonb_column.to_string(),
85        }
86    }
87
88    /// Generate PostgreSQL projection SQL for specified fields.
89    ///
90    /// Generates a `jsonb_build_object()` call that selects only the requested fields
91    /// from the JSONB column, drastically reducing payload size.
92    ///
93    /// # Arguments
94    ///
95    /// * `fields` - GraphQL field names to project from JSONB
96    ///
97    /// # Returns
98    ///
99    /// SQL fragment that can be used in a SELECT clause, e.g.:
100    /// `jsonb_build_object('id', data->>'id', 'email', data->>'email')`
101    ///
102    /// # Example
103    ///
104    /// ```rust,ignore
105    /// let generator = PostgresProjectionGenerator::new();
106    /// let fields = vec!["id".to_string(), "email".to_string()];
107    /// let sql = generator.generate_projection_sql(&fields)?;
108    /// // Returns:
109    /// // jsonb_build_object('id', data->>'id', 'email', data->>'email')
110    /// ```
111    pub fn generate_projection_sql(&self, fields: &[String]) -> Result<String> {
112        if fields.is_empty() {
113            // No fields to project, return pass-through
114            return Ok(format!("\"{}\"", self.jsonb_column));
115        }
116
117        // Build the jsonb_build_object() call with all requested fields
118        let field_pairs: Vec<String> = fields
119            .iter()
120            .map(|field| {
121                // Response key uses the GraphQL field name (camelCase)
122                let safe_field = Self::escape_identifier(field);
123                // JSONB key uses the original schema field name (snake_case)
124                let jsonb_key = to_snake_case(field);
125                let safe_jsonb_key = Self::escape_identifier(&jsonb_key);
126                format!("'{}', \"{}\"->>'{}' ", safe_field, self.jsonb_column, safe_jsonb_key)
127            })
128            .collect();
129
130        // Format: jsonb_build_object('field1', data->>'field1', 'field2', data->>'field2', ...)
131        Ok(format!("jsonb_build_object({})", field_pairs.join(",")))
132    }
133
134    /// Generate complete SELECT clause with projection for a table.
135    ///
136    /// # Arguments
137    ///
138    /// * `table_alias` - Table alias or name in the FROM clause
139    /// * `fields` - Fields to project
140    ///
141    /// # Returns
142    ///
143    /// Complete SELECT clause, e.g.: `SELECT jsonb_build_object(...) as data`
144    ///
145    /// # Example
146    ///
147    /// ```rust,ignore
148    /// let sql = generator.generate_select_clause("t", &fields)?;
149    /// // Returns: SELECT jsonb_build_object(...) as data
150    /// ```
151    pub fn generate_select_clause(&self, table_alias: &str, fields: &[String]) -> Result<String> {
152        let projection = self.generate_projection_sql(fields)?;
153        Ok(format!(
154            "SELECT {} as \"{}\" FROM \"{}\" ",
155            projection, self.jsonb_column, table_alias
156        ))
157    }
158
159    /// Check if field name is safe for SQL (no injection).
160    ///
161    /// PostgreSQL identifiers can contain alphanumeric, underscore, and dollar signs.
162    /// This is a conservative check - in production, use parameterized queries.
163    fn is_safe_identifier(field: &str) -> bool {
164        !field.is_empty() && field.chars().all(|c| c.is_alphanumeric() || c == '_' || c == '$')
165    }
166
167    /// Escape SQL identifier safely.
168    ///
169    /// For production use, should be parameterized. This is defensive escaping
170    /// by replacing single quotes with double quotes (PostgreSQL convention).
171    fn escape_identifier(field: &str) -> String {
172        // Validate field name
173        if !Self::is_safe_identifier(field) {
174            // In production, would reject or sanitize more strictly
175            // For now, pass through with warning logged at runtime
176            return field.to_string();
177        }
178        field.to_string()
179    }
180}
181
182impl Default for PostgresProjectionGenerator {
183    fn default() -> Self {
184        Self::new()
185    }
186}
187
188/// MySQL SQL projection generator.
189///
190/// MySQL uses `JSON_OBJECT()` for field projection, similar to PostgreSQL's `jsonb_build_object()`.
191/// Generates efficient SQL that projects only requested JSON fields.
192///
193/// # Example
194///
195/// ```
196/// use fraiseql_core::db::projection_generator::MySqlProjectionGenerator;
197///
198/// let generator = MySqlProjectionGenerator::new();
199/// let fields = vec!["id".to_string(), "name".to_string()];
200/// let sql = generator.generate_projection_sql(&fields).unwrap();
201/// assert!(sql.contains("JSON_OBJECT"));
202/// ```
203pub struct MySqlProjectionGenerator {
204    json_column: String,
205}
206
207impl MySqlProjectionGenerator {
208    /// Create new MySQL projection generator with default JSON column name.
209    ///
210    /// Default JSON column: "data"
211    #[must_use]
212    pub fn new() -> Self {
213        Self::with_column("data")
214    }
215
216    /// Create projection generator with custom JSON column name.
217    ///
218    /// # Arguments
219    ///
220    /// * `json_column` - Name of the JSON column in the database table
221    #[must_use]
222    pub fn with_column(json_column: &str) -> Self {
223        Self {
224            json_column: json_column.to_string(),
225        }
226    }
227
228    /// Generate MySQL projection SQL for specified fields.
229    ///
230    /// Generates a `JSON_OBJECT()` call that selects only the requested fields
231    /// from the JSON column.
232    ///
233    /// # Arguments
234    ///
235    /// * `fields` - JSON field names to project
236    ///
237    /// # Returns
238    ///
239    /// SQL fragment that can be used in a SELECT clause
240    pub fn generate_projection_sql(&self, fields: &[String]) -> Result<String> {
241        if fields.is_empty() {
242            return Ok(format!("`{}`", self.json_column));
243        }
244
245        let field_pairs: Vec<String> = fields
246            .iter()
247            .map(|field| {
248                // Response key uses the GraphQL field name (camelCase)
249                let safe_field = Self::escape_identifier(field);
250                // JSON key uses the original schema field name (snake_case)
251                let json_key = to_snake_case(field);
252                format!("'{}', JSON_EXTRACT(`{}`, '$.{}')", safe_field, self.json_column, json_key)
253            })
254            .collect();
255
256        Ok(format!("JSON_OBJECT({})", field_pairs.join(",")))
257    }
258
259    /// Check if field name is safe for SQL.
260    fn is_safe_identifier(field: &str) -> bool {
261        !field.is_empty() && field.chars().all(|c| c.is_alphanumeric() || c == '_' || c == '$')
262    }
263
264    /// Escape SQL identifier safely.
265    fn escape_identifier(field: &str) -> String {
266        if !Self::is_safe_identifier(field) {
267            return field.to_string();
268        }
269        field.to_string()
270    }
271}
272
273impl Default for MySqlProjectionGenerator {
274    fn default() -> Self {
275        Self::new()
276    }
277}
278
279/// SQLite SQL projection generator.
280///
281/// SQLite's JSON support is more limited than PostgreSQL and MySQL.
282/// Uses `json_object()` with `json_extract()` to project fields.
283///
284/// # Example
285///
286/// ```
287/// use fraiseql_core::db::projection_generator::SqliteProjectionGenerator;
288///
289/// let generator = SqliteProjectionGenerator::new();
290/// let fields = vec!["id".to_string(), "name".to_string()];
291/// let sql = generator.generate_projection_sql(&fields).unwrap();
292/// assert!(sql.contains("json_object"));
293/// ```
294pub struct SqliteProjectionGenerator {
295    json_column: String,
296}
297
298impl SqliteProjectionGenerator {
299    /// Create new SQLite projection generator with default JSON column name.
300    ///
301    /// Default JSON column: "data"
302    #[must_use]
303    pub fn new() -> Self {
304        Self::with_column("data")
305    }
306
307    /// Create projection generator with custom JSON column name.
308    ///
309    /// # Arguments
310    ///
311    /// * `json_column` - Name of the JSON column in the database table
312    #[must_use]
313    pub fn with_column(json_column: &str) -> Self {
314        Self {
315            json_column: json_column.to_string(),
316        }
317    }
318
319    /// Generate SQLite projection SQL for specified fields.
320    ///
321    /// Generates a `json_object()` call that selects only the requested fields.
322    ///
323    /// # Arguments
324    ///
325    /// * `fields` - JSON field names to project
326    ///
327    /// # Returns
328    ///
329    /// SQL fragment that can be used in a SELECT clause
330    pub fn generate_projection_sql(&self, fields: &[String]) -> Result<String> {
331        if fields.is_empty() {
332            return Ok(format!("\"{}\"", self.json_column));
333        }
334
335        let field_pairs: Vec<String> = fields
336            .iter()
337            .map(|field| {
338                // Response key uses the GraphQL field name (camelCase)
339                let safe_field = Self::escape_identifier(field);
340                // JSON key uses the original schema field name (snake_case)
341                let json_key = to_snake_case(field);
342                format!(
343                    "'{}', json_extract(\"{}\", '$.{}')",
344                    safe_field, self.json_column, json_key
345                )
346            })
347            .collect();
348
349        Ok(format!("json_object({})", field_pairs.join(",")))
350    }
351
352    /// Check if field name is safe for SQL.
353    fn is_safe_identifier(field: &str) -> bool {
354        !field.is_empty() && field.chars().all(|c| c.is_alphanumeric() || c == '_' || c == '$')
355    }
356
357    /// Escape SQL identifier safely.
358    fn escape_identifier(field: &str) -> String {
359        if !Self::is_safe_identifier(field) {
360            return field.to_string();
361        }
362        field.to_string()
363    }
364}
365
366impl Default for SqliteProjectionGenerator {
367    fn default() -> Self {
368        Self::new()
369    }
370}
371
372#[cfg(test)]
373mod tests {
374    use super::*;
375
376    #[test]
377    fn test_postgres_projection_single_field() {
378        let generator = PostgresProjectionGenerator::new();
379        let fields = vec!["id".to_string()];
380
381        let sql = generator.generate_projection_sql(&fields).unwrap();
382        assert_eq!(sql, "jsonb_build_object('id', \"data\"->>'id' )");
383    }
384
385    #[test]
386    fn test_postgres_projection_multiple_fields() {
387        let generator = PostgresProjectionGenerator::new();
388        let fields = vec!["id".to_string(), "name".to_string(), "email".to_string()];
389
390        let sql = generator.generate_projection_sql(&fields).unwrap();
391        assert!(sql.contains("jsonb_build_object("));
392        assert!(sql.contains("'id', \"data\"->>'id'"));
393        assert!(sql.contains("'name', \"data\"->>'name'"));
394        assert!(sql.contains("'email', \"data\"->>'email'"));
395    }
396
397    #[test]
398    fn test_postgres_projection_empty_fields() {
399        let generator = PostgresProjectionGenerator::new();
400        let fields: Vec<String> = vec![];
401
402        let sql = generator.generate_projection_sql(&fields).unwrap();
403        // Empty projection should pass through the JSONB column
404        assert_eq!(sql, "\"data\"");
405    }
406
407    #[test]
408    fn test_postgres_projection_custom_column() {
409        let generator = PostgresProjectionGenerator::with_column("metadata");
410        let fields = vec!["id".to_string()];
411
412        let sql = generator.generate_projection_sql(&fields).unwrap();
413        assert_eq!(sql, "jsonb_build_object('id', \"metadata\"->>'id' )");
414    }
415
416    #[test]
417    fn test_postgres_select_clause() {
418        let generator = PostgresProjectionGenerator::new();
419        let fields = vec!["id".to_string(), "name".to_string()];
420
421        let sql = generator.generate_select_clause("users", &fields).unwrap();
422        assert!(sql.starts_with("SELECT jsonb_build_object("));
423        assert!(sql.contains("as \"data\""));
424        assert!(sql.contains("FROM \"users\""));
425    }
426
427    #[test]
428    fn test_identifier_validation() {
429        assert!(PostgresProjectionGenerator::is_safe_identifier("id"));
430        assert!(PostgresProjectionGenerator::is_safe_identifier("user_id"));
431        assert!(PostgresProjectionGenerator::is_safe_identifier("user$data"));
432        assert!(PostgresProjectionGenerator::is_safe_identifier("field123"));
433        assert!(!PostgresProjectionGenerator::is_safe_identifier("field-name")); // hyphen not allowed
434        assert!(!PostgresProjectionGenerator::is_safe_identifier("field.name")); // dot not allowed
435        assert!(!PostgresProjectionGenerator::is_safe_identifier("")); // empty not allowed
436    }
437
438    // MySQL Projection Generator Tests
439    #[test]
440    fn test_mysql_projection_single_field() {
441        let generator = MySqlProjectionGenerator::new();
442        let fields = vec!["id".to_string()];
443
444        let sql = generator.generate_projection_sql(&fields).unwrap();
445        assert_eq!(sql, "JSON_OBJECT('id', JSON_EXTRACT(`data`, '$.id'))");
446    }
447
448    #[test]
449    fn test_mysql_projection_multiple_fields() {
450        let generator = MySqlProjectionGenerator::new();
451        let fields = vec!["id".to_string(), "name".to_string(), "email".to_string()];
452
453        let sql = generator.generate_projection_sql(&fields).unwrap();
454        assert!(sql.contains("JSON_OBJECT("));
455        assert!(sql.contains("'id', JSON_EXTRACT(`data`, '$.id')"));
456        assert!(sql.contains("'name', JSON_EXTRACT(`data`, '$.name')"));
457        assert!(sql.contains("'email', JSON_EXTRACT(`data`, '$.email')"));
458    }
459
460    #[test]
461    fn test_mysql_projection_empty_fields() {
462        let generator = MySqlProjectionGenerator::new();
463        let fields: Vec<String> = vec![];
464
465        let sql = generator.generate_projection_sql(&fields).unwrap();
466        assert_eq!(sql, "`data`");
467    }
468
469    #[test]
470    fn test_mysql_projection_custom_column() {
471        let generator = MySqlProjectionGenerator::with_column("metadata");
472        let fields = vec!["id".to_string()];
473
474        let sql = generator.generate_projection_sql(&fields).unwrap();
475        assert_eq!(sql, "JSON_OBJECT('id', JSON_EXTRACT(`metadata`, '$.id'))");
476    }
477
478    // SQLite Projection Generator Tests
479    #[test]
480    fn test_sqlite_projection_single_field() {
481        let generator = SqliteProjectionGenerator::new();
482        let fields = vec!["id".to_string()];
483
484        let sql = generator.generate_projection_sql(&fields).unwrap();
485        assert_eq!(sql, "json_object('id', json_extract(\"data\", '$.id'))");
486    }
487
488    #[test]
489    fn test_sqlite_projection_multiple_fields() {
490        let generator = SqliteProjectionGenerator::new();
491        let fields = vec!["id".to_string(), "name".to_string(), "email".to_string()];
492
493        let sql = generator.generate_projection_sql(&fields).unwrap();
494        assert!(sql.contains("json_object("));
495        assert!(sql.contains("'id', json_extract(\"data\", '$.id')"));
496        assert!(sql.contains("'name', json_extract(\"data\", '$.name')"));
497        assert!(sql.contains("'email', json_extract(\"data\", '$.email')"));
498    }
499
500    #[test]
501    fn test_sqlite_projection_empty_fields() {
502        let generator = SqliteProjectionGenerator::new();
503        let fields: Vec<String> = vec![];
504
505        let sql = generator.generate_projection_sql(&fields).unwrap();
506        assert_eq!(sql, "\"data\"");
507    }
508
509    #[test]
510    fn test_sqlite_projection_custom_column() {
511        let generator = SqliteProjectionGenerator::with_column("metadata");
512        let fields = vec!["id".to_string()];
513
514        let sql = generator.generate_projection_sql(&fields).unwrap();
515        assert_eq!(sql, "json_object('id', json_extract(\"metadata\", '$.id'))");
516    }
517
518    // ========================================================================
519    // Issue #269: JSONB field extraction with snake_case/camelCase mapping
520    // ========================================================================
521
522    #[test]
523    fn test_to_snake_case_conversion() {
524        // Test camelCase to snake_case conversion
525        assert_eq!(super::to_snake_case("id"), "id");
526        assert_eq!(super::to_snake_case("firstName"), "first_name");
527        assert_eq!(super::to_snake_case("createdAt"), "created_at");
528        assert_eq!(super::to_snake_case("userId"), "user_id");
529        assert_eq!(super::to_snake_case("updatedAtTimestamp"), "updated_at_timestamp");
530    }
531
532    #[test]
533    fn test_postgres_projection_with_field_mapping_snake_case() {
534        // Problem: GraphQL converts field names to camelCase (first_name → firstName)
535        // But JSONB stores them in snake_case (first_name).
536        // When generating JSONB extraction SQL, we must use the original snake_case key,
537        // not the camelCase GraphQL name.
538
539        let generator = PostgresProjectionGenerator::new();
540
541        // Simulate what happens when fields come from GraphQL query
542        // These are camelCase field names (what GraphQL expects in response)
543        let graphql_fields = vec![
544            "id".to_string(),
545            "firstName".to_string(),
546            "createdAt".to_string(),
547        ];
548
549        let sql = generator.generate_projection_sql(&graphql_fields).unwrap();
550
551        eprintln!("Generated SQL: {}", sql);
552
553        // Current broken behavior generates:
554        // jsonb_build_object('id', data->>'id', 'firstName', data->>'firstName', 'createdAt',
555        // data->>'createdAt')
556        //
557        // This fails because JSONB has snake_case keys: first_name, created_at
558        // Result: data->>'firstName' returns NULL (key not found)
559
560        // Test the bug: SQL should NOT have camelCase JSONB access
561        // We expect this to FAIL until we implement field mapping
562        assert!(
563            !sql.contains("->>'firstName'") && !sql.contains("->>'createdAt'"),
564            "BUG: SQL is using camelCase keys for JSONB access. \
565             JSONB has snake_case keys like 'first_name', 'created_at'. SQL: {}",
566            sql
567        );
568    }
569}