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