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