Skip to main content

fraiseql_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
19//! use fraiseql_db::projection_generator::PostgresProjectionGenerator;
20//! # use fraiseql_error::Result;
21//! # fn example() -> Result<()> {
22//! let generator = PostgresProjectionGenerator::new();
23//! let fields = vec!["id".to_string(), "name".to_string(), "email".to_string()];
24//! let sql = generator.generate_projection_sql(&fields)?;
25//! assert!(sql.contains("jsonb_build_object"));
26//! # Ok(())
27//! # }
28//! ```
29
30use fraiseql_error::{FraiseQLError, Result};
31
32/// A field in a SQL projection with type information.
33///
34/// Used by typed projection generators to choose the correct JSONB extraction
35/// operator: `->` (preserves JSONB) for objects/arrays, `->>` (text) for scalars.
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct ProjectionField {
38    /// GraphQL field name (camelCase).
39    pub name: String,
40
41    /// Whether this field is a composite type (Object or List) that should
42    /// be extracted as JSONB (`->`) rather than text (`->>`).
43    pub is_composite: bool,
44}
45
46impl ProjectionField {
47    /// Create a scalar projection field (uses `->>` text extraction).
48    #[must_use]
49    pub fn scalar(name: impl Into<String>) -> Self {
50        Self {
51            name:         name.into(),
52            is_composite: false,
53        }
54    }
55
56    /// Create a composite (object/list) projection field (uses `->` JSONB extraction).
57    #[must_use]
58    pub fn composite(name: impl Into<String>) -> Self {
59        Self {
60            name:         name.into(),
61            is_composite: true,
62        }
63    }
64}
65
66impl From<String> for ProjectionField {
67    fn from(name: String) -> Self {
68        Self::scalar(name)
69    }
70}
71
72/// Validate that a GraphQL field name contains only characters that are safe
73/// for use in SQL projections (alphanumeric characters and underscores only).
74///
75/// GraphQL field names in FraiseQL are either snake_case (schema definitions)
76/// or camelCase (after the compiler's automatic conversion). Both forms are
77/// subsets of `[a-zA-Z_][a-zA-Z0-9_]*`, so this function rejects any name
78/// that falls outside that alphabet.
79///
80/// # Errors
81///
82/// Returns `FraiseQLError::Validation` if `field` contains a character outside
83/// `[a-zA-Z0-9_]`.
84fn validate_field_name(field: &str) -> Result<()> {
85    if field.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
86        Ok(())
87    } else {
88        Err(FraiseQLError::Validation {
89            message: format!(
90                "field name '{}' contains characters that cannot be safely projected; \
91                 only ASCII alphanumeric characters and underscores are allowed",
92                field
93            ),
94            path:    None,
95        })
96    }
97}
98
99/// Convert camelCase field name to snake_case for JSON/JSONB key lookup.
100///
101/// FraiseQL converts schema field names from snake_case to camelCase for GraphQL spec compliance.
102/// However, JSON/JSONB keys are stored in their original snake_case form.
103/// This function reverses that conversion for JSON key access.
104///
105/// # Examples
106///
107/// ```text
108/// assert_eq!(to_snake_case("firstName"), "first_name");
109/// assert_eq!(to_snake_case("id"), "id");
110/// ```
111fn to_snake_case(name: &str) -> String {
112    let mut result = String::new();
113    for (i, ch) in name.chars().enumerate() {
114        if ch.is_uppercase() && i > 0 {
115            result.push('_');
116            result.push(
117                ch.to_lowercase()
118                    .next()
119                    // Reason: Unicode spec guarantees to_lowercase yields ≥ 1 char
120                    .expect("char::to_lowercase always yields at least one char"),
121            );
122        } else {
123            result.push(ch);
124        }
125    }
126    result
127}
128
129/// PostgreSQL SQL projection generator using jsonb_build_object.
130///
131/// Generates efficient PostgreSQL SQL that projects only requested JSONB fields,
132/// reducing payload size and JSON deserialization time.
133pub struct PostgresProjectionGenerator {
134    /// JSONB column name (typically "data")
135    jsonb_column: String,
136}
137
138impl PostgresProjectionGenerator {
139    /// Create new PostgreSQL projection generator with default JSONB column name.
140    ///
141    /// Default JSONB column: "data"
142    #[must_use]
143    pub fn new() -> Self {
144        Self::with_column("data")
145    }
146
147    /// Create projection generator with custom JSONB column name.
148    ///
149    /// # Arguments
150    ///
151    /// * `jsonb_column` - Name of the JSONB column in the database table
152    #[must_use]
153    pub fn with_column(jsonb_column: &str) -> Self {
154        Self {
155            jsonb_column: jsonb_column.to_string(),
156        }
157    }
158
159    /// Generate PostgreSQL projection SQL for specified fields.
160    ///
161    /// Generates a `jsonb_build_object()` call that selects only the requested fields
162    /// from the JSONB column, drastically reducing payload size.
163    ///
164    /// # Arguments
165    ///
166    /// * `fields` - GraphQL field names to project from JSONB
167    ///
168    /// # Returns
169    ///
170    /// SQL fragment that can be used in a SELECT clause, e.g.:
171    /// `jsonb_build_object('id', data->>'id', 'email', data->>'email')`
172    ///
173    /// # Example
174    ///
175    /// ```rust
176    /// use fraiseql_db::projection_generator::PostgresProjectionGenerator;
177    /// # use fraiseql_error::Result;
178    /// # fn example() -> Result<()> {
179    /// let generator = PostgresProjectionGenerator::new();
180    /// let fields = vec!["id".to_string(), "email".to_string()];
181    /// let sql = generator.generate_projection_sql(&fields)?;
182    /// // Returns:
183    /// // jsonb_build_object('id', data->>'id', 'email', data->>'email')
184    /// assert!(sql.contains("jsonb_build_object"));
185    /// # Ok(())
186    /// # }
187    /// ```
188    ///
189    /// # Errors
190    ///
191    /// Returns `FraiseQLError::Validation` if any field name contains characters
192    /// that cannot be safely included in a SQL projection.
193    pub fn generate_projection_sql(&self, fields: &[String]) -> Result<String> {
194        if fields.is_empty() {
195            // No fields to project, return pass-through
196            return Ok(format!("\"{}\"", self.jsonb_column));
197        }
198
199        // Validate all field names before generating any SQL.
200        for field in fields {
201            validate_field_name(field)?;
202        }
203
204        // Build the jsonb_build_object() call with all requested fields
205        let field_pairs: Vec<String> = fields
206            .iter()
207            .map(|field| {
208                // Response key uses the GraphQL field name (camelCase).
209                // Used as a SQL *string literal* key (inside single-quotes): escape ' → ''.
210                let safe_field = Self::escape_sql_string(field);
211                // JSONB key uses the original schema field name (snake_case).
212                let jsonb_key = to_snake_case(field);
213                let safe_jsonb_key = Self::escape_sql_string(&jsonb_key);
214                format!("'{}', \"{}\"->>'{}' ", safe_field, self.jsonb_column, safe_jsonb_key)
215            })
216            .collect();
217
218        // Format: jsonb_build_object('field1', data->>'field1', 'field2', data->>'field2', ...)
219        Ok(format!("jsonb_build_object({})", field_pairs.join(",")))
220    }
221
222    /// Generate type-aware PostgreSQL projection SQL.
223    ///
224    /// Uses `->` (JSONB extraction) for composite fields (objects, lists) and
225    /// `->>` (text extraction) for scalar fields. This avoids the unnecessary
226    /// text→JSON round-trip that occurs when `->>` is used for nested objects.
227    ///
228    /// # Arguments
229    ///
230    /// * `fields` - Projection fields with type information
231    ///
232    /// # Errors
233    ///
234    /// Returns `FraiseQLError::Validation` if any field name contains characters
235    /// that cannot be safely included in a SQL projection.
236    pub fn generate_typed_projection_sql(&self, fields: &[ProjectionField]) -> Result<String> {
237        if fields.is_empty() {
238            return Ok(format!("\"{}\"", self.jsonb_column));
239        }
240
241        let field_pairs: Vec<String> = fields
242            .iter()
243            .map(|field| {
244                let safe_field = Self::escape_sql_string(&field.name);
245                let jsonb_key = to_snake_case(&field.name);
246                let safe_jsonb_key = Self::escape_sql_string(&jsonb_key);
247                let operator = if field.is_composite { "->" } else { "->>" };
248                format!(
249                    "'{}', \"{}\"{}'{}' ",
250                    safe_field, self.jsonb_column, operator, safe_jsonb_key
251                )
252            })
253            .collect();
254
255        Ok(format!("jsonb_build_object({})", field_pairs.join(",")))
256    }
257
258    /// Generate complete SELECT clause with projection for a table.
259    ///
260    /// # Arguments
261    ///
262    /// * `table_alias` - Table alias or name in the FROM clause
263    /// * `fields` - Fields to project
264    ///
265    /// # Returns
266    ///
267    /// Complete SELECT clause, e.g.: `SELECT jsonb_build_object(...) as data`
268    ///
269    /// # Example
270    ///
271    /// ```rust
272    /// use fraiseql_db::projection_generator::PostgresProjectionGenerator;
273    ///
274    /// let generator = PostgresProjectionGenerator::new();
275    /// let fields = vec!["id".to_string(), "name".to_string()];
276    /// let sql = generator.generate_select_clause("t", &fields).unwrap();
277    /// assert!(sql.contains("SELECT"));
278    /// ```
279    ///
280    /// # Errors
281    ///
282    /// Propagates any error from [`Self::generate_projection_sql`].
283    pub fn generate_select_clause(&self, table_alias: &str, fields: &[String]) -> Result<String> {
284        let projection = self.generate_projection_sql(fields)?;
285        Ok(format!(
286            "SELECT {} as \"{}\" FROM \"{}\" ",
287            projection, self.jsonb_column, table_alias
288        ))
289    }
290
291    /// Escape a value for use as a SQL *string literal* (inside single quotes).
292    ///
293    /// Doubles any embedded single-quote (`'` → `''`) to prevent SQL injection
294    /// when the field name is embedded as a string literal key, e.g. in
295    /// `jsonb_build_object('key', ...)` or `data->>'key'`.
296    fn escape_sql_string(s: &str) -> String {
297        s.replace('\'', "''")
298    }
299
300    /// Escape a SQL identifier using PostgreSQL double-quote quoting.
301    ///
302    /// Double-quote delimiters prevent identifier injection: any `"` within
303    /// the identifier is doubled (`""`), and the whole name is wrapped in `"`.
304    /// Use this when the name appears in an *identifier* position (column name,
305    /// table alias) rather than as a string literal.
306    #[allow(dead_code)] // Reason: available for callers embedding names as SQL identifiers
307    fn escape_identifier(field: &str) -> String {
308        format!("\"{}\"", field.replace('"', "\"\""))
309    }
310}
311
312impl Default for PostgresProjectionGenerator {
313    fn default() -> Self {
314        Self::new()
315    }
316}
317
318/// MySQL SQL projection generator.
319///
320/// MySQL uses `JSON_OBJECT()` for field projection, similar to PostgreSQL's `jsonb_build_object()`.
321/// Generates efficient SQL that projects only requested JSON fields.
322///
323/// # Example
324///
325/// ```
326/// use fraiseql_db::projection_generator::MySqlProjectionGenerator;
327///
328/// let generator = MySqlProjectionGenerator::new();
329/// let fields = vec!["id".to_string(), "name".to_string()];
330/// let sql = generator.generate_projection_sql(&fields).unwrap();
331/// assert!(sql.contains("JSON_OBJECT"));
332/// ```
333pub struct MySqlProjectionGenerator {
334    json_column: String,
335}
336
337impl MySqlProjectionGenerator {
338    /// Create new MySQL projection generator with default JSON column name.
339    ///
340    /// Default JSON column: "data"
341    #[must_use]
342    pub fn new() -> Self {
343        Self::with_column("data")
344    }
345
346    /// Create projection generator with custom JSON column name.
347    ///
348    /// # Arguments
349    ///
350    /// * `json_column` - Name of the JSON column in the database table
351    #[must_use]
352    pub fn with_column(json_column: &str) -> Self {
353        Self {
354            json_column: json_column.to_string(),
355        }
356    }
357
358    /// Generate MySQL projection SQL for specified fields.
359    ///
360    /// Generates a `JSON_OBJECT()` call that selects only the requested fields
361    /// from the JSON column.
362    ///
363    /// # Arguments
364    ///
365    /// * `fields` - JSON field names to project
366    ///
367    /// # Returns
368    ///
369    /// SQL fragment that can be used in a SELECT clause
370    ///
371    /// # Errors
372    ///
373    /// Returns `FraiseQLError::Validation` if any field name cannot be safely projected.
374    pub fn generate_projection_sql(&self, fields: &[String]) -> Result<String> {
375        if fields.is_empty() {
376            return Ok(format!("`{}`", self.json_column));
377        }
378
379        // Validate all field names before generating any SQL.
380        for field in fields {
381            validate_field_name(field)?;
382        }
383
384        let field_pairs: Vec<String> = fields
385            .iter()
386            .map(|field| {
387                // Response key used as SQL string literal key — escape ' → ''.
388                let safe_field = Self::escape_sql_string(field);
389                // JSON key uses the original schema field name (snake_case).
390                let json_key = to_snake_case(field);
391                format!("'{}', JSON_EXTRACT(`{}`, '$.{}')", safe_field, self.json_column, json_key)
392            })
393            .collect();
394
395        Ok(format!("JSON_OBJECT({})", field_pairs.join(",")))
396    }
397
398    /// Escape a value for use as a SQL *string literal* (inside single quotes).
399    fn escape_sql_string(s: &str) -> String {
400        s.replace('\'', "''")
401    }
402
403    /// Escape a SQL identifier using MySQL backtick quoting.
404    ///
405    /// Use this when the name appears in an *identifier* position (column name,
406    /// table alias), not as a string literal.
407    #[allow(dead_code)] // Reason: available for callers embedding names as SQL identifiers
408    fn escape_identifier(field: &str) -> String {
409        format!("`{}`", field.replace('`', "``"))
410    }
411}
412
413impl Default for MySqlProjectionGenerator {
414    fn default() -> Self {
415        Self::new()
416    }
417}
418
419/// SQLite SQL projection generator.
420///
421/// SQLite's JSON support is more limited than PostgreSQL and MySQL.
422/// Uses `json_object()` with `json_extract()` to project fields.
423///
424/// # Example
425///
426/// ```
427/// use fraiseql_db::projection_generator::SqliteProjectionGenerator;
428///
429/// let generator = SqliteProjectionGenerator::new();
430/// let fields = vec!["id".to_string(), "name".to_string()];
431/// let sql = generator.generate_projection_sql(&fields).unwrap();
432/// assert!(sql.contains("json_object"));
433/// ```
434pub struct SqliteProjectionGenerator {
435    json_column: String,
436}
437
438impl SqliteProjectionGenerator {
439    /// Create new SQLite projection generator with default JSON column name.
440    ///
441    /// Default JSON column: "data"
442    #[must_use]
443    pub fn new() -> Self {
444        Self::with_column("data")
445    }
446
447    /// Create projection generator with custom JSON column name.
448    ///
449    /// # Arguments
450    ///
451    /// * `json_column` - Name of the JSON column in the database table
452    #[must_use]
453    pub fn with_column(json_column: &str) -> Self {
454        Self {
455            json_column: json_column.to_string(),
456        }
457    }
458
459    /// Generate SQLite projection SQL for specified fields.
460    ///
461    /// Generates a `json_object()` call that selects only the requested fields.
462    ///
463    /// # Arguments
464    ///
465    /// * `fields` - JSON field names to project
466    ///
467    /// # Returns
468    ///
469    /// SQL fragment that can be used in a SELECT clause
470    ///
471    /// # Errors
472    ///
473    /// Returns `FraiseQLError::Validation` if any field name cannot be safely projected.
474    pub fn generate_projection_sql(&self, fields: &[String]) -> Result<String> {
475        if fields.is_empty() {
476            return Ok(format!("\"{}\"", self.json_column));
477        }
478
479        // Validate all field names before generating any SQL.
480        for field in fields {
481            validate_field_name(field)?;
482        }
483
484        let field_pairs: Vec<String> = fields
485            .iter()
486            .map(|field| {
487                // Response key used as SQL string literal key — escape ' → ''.
488                let safe_field = Self::escape_sql_string(field);
489                // JSON key uses the original schema field name (snake_case).
490                let json_key = to_snake_case(field);
491                format!(
492                    "'{}', json_extract(\"{}\", '$.{}')",
493                    safe_field, self.json_column, json_key
494                )
495            })
496            .collect();
497
498        Ok(format!("json_object({})", field_pairs.join(",")))
499    }
500
501    /// Escape a value for use as a SQL *string literal* (inside single quotes).
502    fn escape_sql_string(s: &str) -> String {
503        s.replace('\'', "''")
504    }
505
506    /// Escape a SQL identifier using SQLite double-quote quoting.
507    ///
508    /// Double-quote delimiters prevent identifier injection: any `"` within
509    /// the identifier is doubled (`""`), and the whole name is wrapped in `"`.
510    /// Use this when the name appears in an *identifier* position (column name,
511    /// table alias), not as a string literal.
512    #[allow(dead_code)] // Reason: available for callers that embed field names as identifiers
513    fn escape_identifier(field: &str) -> String {
514        format!("\"{}\"", field.replace('"', "\"\""))
515    }
516}
517
518impl Default for SqliteProjectionGenerator {
519    fn default() -> Self {
520        Self::new()
521    }
522}
523
524#[cfg(test)]
525#[allow(clippy::unwrap_used)] // Reason: test code, panics are acceptable
526mod tests {
527    use super::*;
528
529    #[test]
530    fn test_postgres_projection_single_field() {
531        let generator = PostgresProjectionGenerator::new();
532        let fields = vec!["id".to_string()];
533
534        let sql = generator.generate_projection_sql(&fields).unwrap();
535        assert_eq!(sql, "jsonb_build_object('id', \"data\"->>'id' )");
536    }
537
538    #[test]
539    fn test_postgres_projection_multiple_fields() {
540        let generator = PostgresProjectionGenerator::new();
541        let fields = vec!["id".to_string(), "name".to_string(), "email".to_string()];
542
543        let sql = generator.generate_projection_sql(&fields).unwrap();
544        assert!(sql.contains("jsonb_build_object("));
545        assert!(sql.contains("'id', \"data\"->>'id'"));
546        assert!(sql.contains("'name', \"data\"->>'name'"));
547        assert!(sql.contains("'email', \"data\"->>'email'"));
548    }
549
550    #[test]
551    fn test_postgres_projection_empty_fields() {
552        let generator = PostgresProjectionGenerator::new();
553        let fields: Vec<String> = vec![];
554
555        let sql = generator.generate_projection_sql(&fields).unwrap();
556        // Empty projection should pass through the JSONB column
557        assert_eq!(sql, "\"data\"");
558    }
559
560    #[test]
561    fn test_postgres_projection_custom_column() {
562        let generator = PostgresProjectionGenerator::with_column("metadata");
563        let fields = vec!["id".to_string()];
564
565        let sql = generator.generate_projection_sql(&fields).unwrap();
566        assert_eq!(sql, "jsonb_build_object('id', \"metadata\"->>'id' )");
567    }
568
569    #[test]
570    fn test_postgres_select_clause() {
571        let generator = PostgresProjectionGenerator::new();
572        let fields = vec!["id".to_string(), "name".to_string()];
573
574        let sql = generator.generate_select_clause("users", &fields).unwrap();
575        assert!(sql.starts_with("SELECT jsonb_build_object("));
576        assert!(sql.contains("as \"data\""));
577        assert!(sql.contains("FROM \"users\""));
578    }
579
580    #[test]
581    fn test_escape_identifier_quoting() {
582        // Simple identifiers are wrapped in double-quotes.
583        assert_eq!(PostgresProjectionGenerator::escape_identifier("id"), "\"id\"");
584        assert_eq!(PostgresProjectionGenerator::escape_identifier("user_id"), "\"user_id\"");
585        // Special chars (hyphens, dots) are safe inside quotes.
586        assert_eq!(PostgresProjectionGenerator::escape_identifier("field-name"), "\"field-name\"");
587        assert_eq!(PostgresProjectionGenerator::escape_identifier("field.name"), "\"field.name\"");
588        // Double-quote chars inside the name are doubled.
589        assert_eq!(
590            PostgresProjectionGenerator::escape_identifier("col\"inject"),
591            "\"col\"\"inject\""
592        );
593    }
594
595    // MySQL Projection Generator Tests
596    #[test]
597    fn test_mysql_projection_single_field() {
598        let generator = MySqlProjectionGenerator::new();
599        let fields = vec!["id".to_string()];
600
601        let sql = generator.generate_projection_sql(&fields).unwrap();
602        assert_eq!(sql, "JSON_OBJECT('id', JSON_EXTRACT(`data`, '$.id'))");
603    }
604
605    #[test]
606    fn test_mysql_projection_multiple_fields() {
607        let generator = MySqlProjectionGenerator::new();
608        let fields = vec!["id".to_string(), "name".to_string(), "email".to_string()];
609
610        let sql = generator.generate_projection_sql(&fields).unwrap();
611        assert!(sql.contains("JSON_OBJECT("));
612        assert!(sql.contains("'id', JSON_EXTRACT(`data`, '$.id')"));
613        assert!(sql.contains("'name', JSON_EXTRACT(`data`, '$.name')"));
614        assert!(sql.contains("'email', JSON_EXTRACT(`data`, '$.email')"));
615    }
616
617    #[test]
618    fn test_mysql_projection_empty_fields() {
619        let generator = MySqlProjectionGenerator::new();
620        let fields: Vec<String> = vec![];
621
622        let sql = generator.generate_projection_sql(&fields).unwrap();
623        assert_eq!(sql, "`data`");
624    }
625
626    #[test]
627    fn test_mysql_projection_custom_column() {
628        let generator = MySqlProjectionGenerator::with_column("metadata");
629        let fields = vec!["id".to_string()];
630
631        let sql = generator.generate_projection_sql(&fields).unwrap();
632        assert_eq!(sql, "JSON_OBJECT('id', JSON_EXTRACT(`metadata`, '$.id'))");
633    }
634
635    // SQLite Projection Generator Tests
636    #[test]
637    fn test_sqlite_projection_single_field() {
638        let generator = SqliteProjectionGenerator::new();
639        let fields = vec!["id".to_string()];
640
641        let sql = generator.generate_projection_sql(&fields).unwrap();
642        assert_eq!(sql, "json_object('id', json_extract(\"data\", '$.id'))");
643    }
644
645    #[test]
646    fn test_sqlite_projection_multiple_fields() {
647        let generator = SqliteProjectionGenerator::new();
648        let fields = vec!["id".to_string(), "name".to_string(), "email".to_string()];
649
650        let sql = generator.generate_projection_sql(&fields).unwrap();
651        assert!(sql.contains("json_object("));
652        assert!(sql.contains("'id', json_extract(\"data\", '$.id')"));
653        assert!(sql.contains("'name', json_extract(\"data\", '$.name')"));
654        assert!(sql.contains("'email', json_extract(\"data\", '$.email')"));
655    }
656
657    #[test]
658    fn test_sqlite_projection_empty_fields() {
659        let generator = SqliteProjectionGenerator::new();
660        let fields: Vec<String> = vec![];
661
662        let sql = generator.generate_projection_sql(&fields).unwrap();
663        assert_eq!(sql, "\"data\"");
664    }
665
666    #[test]
667    fn test_sqlite_projection_custom_column() {
668        let generator = SqliteProjectionGenerator::with_column("metadata");
669        let fields = vec!["id".to_string()];
670
671        let sql = generator.generate_projection_sql(&fields).unwrap();
672        assert_eq!(sql, "json_object('id', json_extract(\"metadata\", '$.id'))");
673    }
674
675    // ========================================================================
676    // Issue #269: JSONB field extraction with snake_case/camelCase mapping
677    // ========================================================================
678
679    #[test]
680    fn test_to_snake_case_conversion() {
681        // Test camelCase to snake_case conversion
682        assert_eq!(super::to_snake_case("id"), "id");
683        assert_eq!(super::to_snake_case("firstName"), "first_name");
684        assert_eq!(super::to_snake_case("createdAt"), "created_at");
685        assert_eq!(super::to_snake_case("userId"), "user_id");
686        assert_eq!(super::to_snake_case("updatedAtTimestamp"), "updated_at_timestamp");
687    }
688
689    #[test]
690    fn test_postgres_projection_with_field_mapping_snake_case() {
691        // Problem: GraphQL converts field names to camelCase (first_name → firstName)
692        // But JSONB stores them in snake_case (first_name).
693        // When generating JSONB extraction SQL, we must use the original snake_case key,
694        // not the camelCase GraphQL name.
695
696        let generator = PostgresProjectionGenerator::new();
697
698        // Simulate what happens when fields come from GraphQL query
699        // These are camelCase field names (what GraphQL expects in response)
700        let graphql_fields = vec![
701            "id".to_string(),
702            "firstName".to_string(),
703            "createdAt".to_string(),
704        ];
705
706        let sql = generator.generate_projection_sql(&graphql_fields).unwrap();
707
708        eprintln!("Generated SQL: {}", sql);
709
710        // Current broken behavior generates:
711        // jsonb_build_object('id', data->>'id', 'firstName', data->>'firstName', 'createdAt',
712        // data->>'createdAt')
713        //
714        // This fails because JSONB has snake_case keys: first_name, created_at
715        // Result: data->>'firstName' returns NULL (key not found)
716
717        // Regression guard: SQL must use snake_case keys for JSONB access.
718        // camelCase field names in the schema (firstName, createdAt) must be
719        // mapped to snake_case in generated SQL (first_name, created_at) because
720        // PostgreSQL stores JSONB keys verbatim and FraiseQL always writes snake_case.
721        assert!(
722            !sql.contains("->>'firstName'") && !sql.contains("->>'createdAt'"),
723            "Regression: SQL is using camelCase keys for JSONB access. \
724             JSONB has snake_case keys ('first_name', 'created_at'). SQL: {}",
725            sql
726        );
727    }
728
729    // =========================================================================
730    // Additional projection_generator.rs tests
731    // =========================================================================
732
733    #[test]
734    fn test_postgres_projection_sql_injection_in_field_name() {
735        // A field name containing a single quote is rejected by the validator — it is
736        // not a valid GraphQL / FraiseQL field identifier and must never reach SQL.
737        let generator = PostgresProjectionGenerator::new();
738        let fields = vec!["user'name".to_string()];
739        let result = generator.generate_projection_sql(&fields);
740        assert!(result.is_err(), "Field name with single quote must be rejected");
741    }
742
743    #[test]
744    fn test_postgres_projection_rejects_field_with_semicolon() {
745        let generator = PostgresProjectionGenerator::new();
746        let fields = vec!["id; DROP TABLE users--".to_string()];
747        let result = generator.generate_projection_sql(&fields);
748        assert!(result.is_err(), "Field name with SQL injection characters must be rejected");
749    }
750
751    #[test]
752    fn test_mysql_projection_rejects_unsafe_field_name() {
753        let generator = MySqlProjectionGenerator::new();
754        let fields = vec!["field`hack".to_string()];
755        let result = generator.generate_projection_sql(&fields);
756        assert!(result.is_err(), "Field name with backtick must be rejected");
757    }
758
759    #[test]
760    fn test_sqlite_projection_rejects_unsafe_field_name() {
761        let generator = SqliteProjectionGenerator::new();
762        let fields = vec!["field\"inject".to_string()];
763        let result = generator.generate_projection_sql(&fields);
764        assert!(result.is_err(), "Field name with double-quote must be rejected");
765    }
766
767    #[test]
768    fn test_validate_field_name_accepts_valid_names() {
769        assert!(super::validate_field_name("id").is_ok());
770        assert!(super::validate_field_name("user_id").is_ok());
771        assert!(super::validate_field_name("firstName").is_ok());
772        assert!(super::validate_field_name("createdAt").is_ok());
773        assert!(super::validate_field_name("field123").is_ok());
774        assert!(super::validate_field_name("_private").is_ok());
775    }
776
777    #[test]
778    fn test_validate_field_name_rejects_unsafe_chars() {
779        assert!(super::validate_field_name("user'name").is_err());
780        assert!(super::validate_field_name("field-name").is_err());
781        assert!(super::validate_field_name("field.name").is_err());
782        assert!(super::validate_field_name("field;inject").is_err());
783        assert!(super::validate_field_name("field\"inject").is_err());
784        assert!(super::validate_field_name("field`hack").is_err());
785    }
786
787    #[test]
788    fn test_mysql_projection_sql_contains_json_object() {
789        let generator = MySqlProjectionGenerator::new();
790        let fields = vec!["email".to_string(), "name".to_string()];
791        let sql = generator.generate_projection_sql(&fields).unwrap();
792        assert!(sql.starts_with("JSON_OBJECT("), "MySQL projection must start with JSON_OBJECT");
793    }
794
795    #[test]
796    fn test_sqlite_projection_custom_column_appears_in_sql() {
797        let generator = SqliteProjectionGenerator::with_column("payload");
798        let fields = vec!["id".to_string()];
799        let sql = generator.generate_projection_sql(&fields).unwrap();
800        assert!(sql.contains("\"payload\""), "Custom column name must appear in SQLite SQL");
801    }
802
803    #[test]
804    fn test_postgres_projection_camel_to_snake_in_jsonb_key() {
805        let generator = PostgresProjectionGenerator::new();
806        let fields = vec!["updatedAt".to_string()];
807        let sql = generator.generate_projection_sql(&fields).unwrap();
808        // The JSONB extraction key should be snake_case
809        assert!(
810            sql.contains("'updated_at'"),
811            "updatedAt must be mapped to updated_at for JSONB key"
812        );
813        // The response key in jsonb_build_object should be the original camelCase
814        assert!(sql.contains("'updatedAt'"), "Response key must remain camelCase");
815    }
816
817    #[test]
818    fn test_postgres_select_clause_contains_from() {
819        let generator = PostgresProjectionGenerator::new();
820        let fields = vec!["id".to_string()];
821        let sql = generator.generate_select_clause("orders", &fields).unwrap();
822        assert!(
823            sql.contains("FROM \"orders\""),
824            "SELECT clause must include FROM clause with table name"
825        );
826        assert!(sql.contains("SELECT"), "SELECT clause must start with SELECT");
827    }
828
829    // ── generate_typed_projection_sql tests (C12) ─────────────────────────
830
831    #[test]
832    fn test_typed_projection_empty_fields_returns_data_column() {
833        let generator = PostgresProjectionGenerator::new();
834        let result = generator.generate_typed_projection_sql(&[]).unwrap();
835        assert_eq!(result, "\"data\"");
836    }
837
838    #[test]
839    fn test_typed_projection_scalar_field_uses_text_extraction() {
840        let generator = PostgresProjectionGenerator::new();
841        let fields = vec![ProjectionField {
842            name:         "name".to_string(),
843            is_composite: false,
844        }];
845        let sql = generator.generate_typed_projection_sql(&fields).unwrap();
846        // Scalar fields use ->> (text extraction)
847        assert!(sql.contains("->>'name'"), "scalar field must use ->> operator, got: {sql}");
848        assert!(!sql.contains("->'name'"), "scalar field must NOT use -> operator, got: {sql}");
849    }
850
851    #[test]
852    fn test_typed_projection_composite_field_uses_jsonb_extraction() {
853        let generator = PostgresProjectionGenerator::new();
854        let fields = vec![ProjectionField {
855            name:         "address".to_string(),
856            is_composite: true,
857        }];
858        let sql = generator.generate_typed_projection_sql(&fields).unwrap();
859        // Composite fields use -> (JSONB extraction, preserves structure)
860        assert!(sql.contains("->'address'"), "composite field must use -> operator, got: {sql}");
861    }
862
863    #[test]
864    fn test_typed_projection_mixed_scalar_and_composite() {
865        let generator = PostgresProjectionGenerator::new();
866        let fields = vec![
867            ProjectionField {
868                name:         "id".to_string(),
869                is_composite: false,
870            },
871            ProjectionField {
872                name:         "address".to_string(),
873                is_composite: true,
874            },
875            ProjectionField {
876                name:         "tags".to_string(),
877                is_composite: true,
878            },
879            ProjectionField {
880                name:         "email".to_string(),
881                is_composite: false,
882            },
883        ];
884        let sql = generator.generate_typed_projection_sql(&fields).unwrap();
885
886        // Scalars use ->>
887        assert!(sql.contains("->>'id'"), "id (scalar) must use ->>, got: {sql}");
888        assert!(sql.contains("->>'email'"), "email (scalar) must use ->>, got: {sql}");
889
890        // Composites use ->
891        assert!(sql.contains("->'address'"), "address (composite) must use ->, got: {sql}");
892        assert!(sql.contains("->'tags'"), "tags (composite) must use ->, got: {sql}");
893
894        // Must be wrapped in jsonb_build_object
895        assert!(
896            sql.starts_with("jsonb_build_object("),
897            "must wrap in jsonb_build_object, got: {sql}"
898        );
899    }
900
901    #[test]
902    fn test_typed_projection_camel_case_maps_to_snake_case_jsonb_key() {
903        let generator = PostgresProjectionGenerator::new();
904        let fields = vec![ProjectionField {
905            name:         "firstName".to_string(),
906            is_composite: false,
907        }];
908        let sql = generator.generate_typed_projection_sql(&fields).unwrap();
909        // Response key is camelCase, JSONB key is snake_case
910        assert!(
911            sql.contains("'firstName'"),
912            "response key must be camelCase 'firstName', got: {sql}"
913        );
914        assert!(
915            sql.contains("->>'first_name'"),
916            "JSONB key must be snake_case 'first_name', got: {sql}"
917        );
918    }
919
920    #[test]
921    fn test_typed_projection_single_quote_in_field_name_escaped() {
922        let generator = PostgresProjectionGenerator::new();
923        let fields = vec![ProjectionField {
924            name:         "it's".to_string(),
925            is_composite: false,
926        }];
927        let sql = generator.generate_typed_projection_sql(&fields).unwrap();
928        // Single quotes must be doubled for SQL safety
929        assert!(
930            sql.contains("'it''s'"),
931            "single quote in field name must be escaped, got: {sql}"
932        );
933    }
934}