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    /// Output (response) key — the GraphQL alias if present, otherwise the field
62    /// name. Used verbatim as the key in the generated `jsonb_build_object`.
63    pub name: String,
64
65    /// Source GraphQL field name (camelCase) used to derive the JSONB column via
66    /// `to_snake_case`. Equals [`name`](Self::name) for unaliased fields, but
67    /// differs when the field is aliased: `myName: fullName` yields
68    /// `name = "myName"`, `source = "fullName"`, so the projection reads
69    /// `data->>'full_name'` while emitting it under the `myName` key (#418).
70    pub source: String,
71
72    /// Semantic kind of the field, controlling the JSONB extraction operator.
73    pub kind: FieldKind,
74
75    /// Sub-fields to project for composite (Object) types.
76    ///
77    /// When `Some` and non-empty, the generator recurses and produces a nested
78    /// `jsonb_build_object` instead of returning the entire composite blob.
79    /// Set to `None` (or `Some([])`) to fall back to `data->'field'`.
80    /// List fields should always use `None` — sub-projection inside aggregated
81    /// JSONB arrays is out of scope for this first iteration.
82    pub sub_fields: Option<Vec<ProjectionField>>,
83}
84
85impl ProjectionField {
86    /// Create a text scalar projection field (uses `->>` text extraction).
87    ///
88    /// Use for String and ID fields only. Other scalars (Int, Float, Boolean,
89    /// DateTime, etc.) should use [`Self::native`].
90    #[must_use]
91    pub fn scalar(name: impl Into<String>) -> Self {
92        let name = name.into();
93        Self {
94            source: name.clone(),
95            name,
96            kind: FieldKind::Text,
97            sub_fields: None,
98        }
99    }
100
101    /// Create a native JSON scalar projection field (uses `->` to preserve type).
102    ///
103    /// Use for Int, Float, Boolean, DateTime, Date, Time, Decimal, Vector, and
104    /// other non-text scalars. `->>` would coerce these to strings inside
105    /// `jsonb_build_object`, losing type information.
106    #[must_use]
107    pub fn native(name: impl Into<String>) -> Self {
108        let name = name.into();
109        Self {
110            source: name.clone(),
111            name,
112            kind: FieldKind::Native,
113            sub_fields: None,
114        }
115    }
116
117    /// Create a composite (object/list) projection field (uses `->` JSONB extraction).
118    #[must_use]
119    pub fn composite(name: impl Into<String>) -> Self {
120        let name = name.into();
121        Self {
122            source: name.clone(),
123            name,
124            kind: FieldKind::Composite,
125            sub_fields: None,
126        }
127    }
128
129    /// Create a composite projection field with known sub-fields.
130    ///
131    /// The generator will recurse into `sub_fields` and emit a nested
132    /// `jsonb_build_object(...)` rather than returning the full composite blob.
133    #[must_use]
134    pub fn composite_with_sub_fields(name: impl Into<String>, sub_fields: Vec<Self>) -> Self {
135        let name = name.into();
136        Self {
137            source: name.clone(),
138            name,
139            kind: FieldKind::Composite,
140            sub_fields: Some(sub_fields),
141        }
142    }
143
144    /// Whether this field is a composite type (Object or List).
145    #[must_use]
146    pub const fn is_composite(&self) -> bool {
147        matches!(self.kind, FieldKind::Composite)
148    }
149}
150
151impl From<String> for ProjectionField {
152    fn from(name: String) -> Self {
153        Self::scalar(name)
154    }
155}
156
157/// Validate that a GraphQL field name contains only characters that are safe
158/// for use in SQL projections (alphanumeric characters and underscores only).
159///
160/// GraphQL field names in FraiseQL are either snake_case (schema definitions)
161/// or camelCase (after the compiler's automatic conversion). Both forms are
162/// subsets of `[a-zA-Z_][a-zA-Z0-9_]*`, so this function rejects any name
163/// that falls outside that alphabet.
164///
165/// # Errors
166///
167/// Returns `FraiseQLError::Validation` if `field` contains a character outside
168/// `[a-zA-Z0-9_]`.
169fn validate_field_name(field: &str) -> Result<()> {
170    if field.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
171        Ok(())
172    } else {
173        Err(FraiseQLError::Validation {
174            message: format!(
175                "field name '{}' contains characters that cannot be safely projected; \
176                 only ASCII alphanumeric characters and underscores are allowed",
177                field
178            ),
179            path:    None,
180        })
181    }
182}
183
184use crate::utils::to_snake_case;
185
186/// Maximum nesting depth for recursive JSONB projection.
187///
188/// Prevents pathological schemas from producing unbounded SQL. Fields at depth ≥ this
189/// value fall back to `data->'field'` (full composite blob), matching the pre-recursion
190/// behaviour.
191const MAX_PROJECTION_DEPTH: usize = 4;
192
193/// PostgreSQL SQL projection generator using jsonb_build_object.
194///
195/// Generates efficient PostgreSQL SQL that projects only requested JSONB fields,
196/// reducing payload size and JSON deserialization time.
197pub struct PostgresProjectionGenerator {
198    /// JSONB column name (typically "data")
199    jsonb_column: String,
200}
201
202impl PostgresProjectionGenerator {
203    /// Create new PostgreSQL projection generator with default JSONB column name.
204    ///
205    /// Default JSONB column: "data"
206    #[must_use]
207    pub fn new() -> Self {
208        Self::with_column("data")
209    }
210
211    /// Create projection generator with custom JSONB column name.
212    ///
213    /// # Arguments
214    ///
215    /// * `jsonb_column` - Name of the JSONB column in the database table
216    #[must_use]
217    pub fn with_column(jsonb_column: &str) -> Self {
218        Self {
219            jsonb_column: jsonb_column.to_string(),
220        }
221    }
222
223    /// Generate PostgreSQL projection SQL for specified fields.
224    ///
225    /// Generates a `jsonb_build_object()` call that selects only the requested fields
226    /// from the JSONB column, drastically reducing payload size.
227    ///
228    /// # Arguments
229    ///
230    /// * `fields` - GraphQL field names to project from JSONB
231    ///
232    /// # Returns
233    ///
234    /// SQL fragment that can be used in a SELECT clause, e.g.:
235    /// `jsonb_build_object('id', data->>'id', 'email', data->>'email')`
236    ///
237    /// # Example
238    ///
239    /// ```rust
240    /// use fraiseql_db::projection_generator::PostgresProjectionGenerator;
241    /// # use fraiseql_error::Result;
242    /// # fn example() -> Result<()> {
243    /// let generator = PostgresProjectionGenerator::new();
244    /// let fields = vec!["id".to_string(), "email".to_string()];
245    /// let sql = generator.generate_projection_sql(&fields)?;
246    /// // Returns:
247    /// // jsonb_build_object('id', data->>'id', 'email', data->>'email')
248    /// assert!(sql.contains("jsonb_build_object"));
249    /// # Ok(())
250    /// # }
251    /// ```
252    ///
253    /// # Errors
254    ///
255    /// Returns `FraiseQLError::Validation` if any field name contains characters
256    /// that cannot be safely included in a SQL projection.
257    pub fn generate_projection_sql(&self, fields: &[String]) -> Result<String> {
258        if fields.is_empty() {
259            // No fields to project, return pass-through
260            return Ok(format!("\"{}\"", self.jsonb_column));
261        }
262
263        // Validate all field names before generating any SQL.
264        for field in fields {
265            validate_field_name(field)?;
266        }
267
268        // Build the jsonb_build_object() call with all requested fields
269        let field_pairs: Vec<String> = fields
270            .iter()
271            .map(|field| {
272                // Response key uses the GraphQL field name (camelCase).
273                // Used as a SQL *string literal* key (inside single-quotes): escape ' → ''.
274                let safe_field = Self::escape_sql_string(field);
275                // JSONB key uses the original schema field name (snake_case).
276                let jsonb_key = to_snake_case(field);
277                let safe_jsonb_key = Self::escape_sql_string(&jsonb_key);
278                format!("'{}', \"{}\"->>'{}' ", safe_field, self.jsonb_column, safe_jsonb_key)
279            })
280            .collect();
281
282        // Format: jsonb_build_object('field1', data->>'field1', 'field2', data->>'field2', ...)
283        Ok(format!("jsonb_build_object({})", field_pairs.join(",")))
284    }
285
286    /// Generate type-aware PostgreSQL projection SQL.
287    ///
288    /// Uses `->` (JSONB extraction) for composite fields (objects, lists) and
289    /// `->>` (text extraction) for scalar fields. This avoids the unnecessary
290    /// text→JSON round-trip that occurs when `->>` is used for nested objects.
291    ///
292    /// When a composite field carries `sub_fields`, the generator recurses and
293    /// emits a nested `jsonb_build_object(...)` that selects only the requested
294    /// sub-fields rather than returning the entire blob.  Recursion is capped at
295    /// `MAX_PROJECTION_DEPTH` levels; deeper fields fall back to `data->'field'`.
296    ///
297    /// # Arguments
298    ///
299    /// * `fields` - Projection fields with type information
300    ///
301    /// # Errors
302    ///
303    /// Returns `FraiseQLError::Validation` if any field name contains characters
304    /// that cannot be safely included in a SQL projection.
305    pub fn generate_typed_projection_sql(&self, fields: &[ProjectionField]) -> Result<String> {
306        if fields.is_empty() {
307            return Ok(format!("\"{}\"", self.jsonb_column));
308        }
309
310        let path = format!("\"{}\"", self.jsonb_column);
311        let field_pairs = fields
312            .iter()
313            .map(|field| Self::render_field(field, &path, 0))
314            .collect::<Result<Vec<_>>>()?;
315
316        Ok(format!("jsonb_build_object({})", field_pairs.join(",")))
317    }
318
319    /// Recursively render one projection field as a `'key', <expr>` pair for
320    /// `jsonb_build_object`.
321    ///
322    /// * `field` — field to render
323    /// * `path`  — JSONB path prefix built so far (e.g. `"data"` at depth 0, `"data"->'author'` at
324    ///   depth 1)
325    /// * `depth` — current recursion depth (capped at `MAX_PROJECTION_DEPTH`)
326    fn render_field(field: &ProjectionField, path: &str, depth: usize) -> Result<String> {
327        // Output key is the (possibly aliased) response key; the JSONB column is
328        // derived from the *source* field name. For unaliased fields these are
329        // the same, but an aliased field (`myName: fullName`) must read
330        // `data->>'full_name'`, not `data->>'my_name'` (#418).
331        let resp_key = Self::escape_sql_string(&field.name);
332        let jsonb_key = to_snake_case(&field.source);
333        let safe_jsonb_key = Self::escape_sql_string(&jsonb_key);
334
335        // Recurse into Object sub-fields when available and within depth limit.
336        if depth < MAX_PROJECTION_DEPTH {
337            if let Some(subs) = &field.sub_fields {
338                if !subs.is_empty() {
339                    let nested_path = format!("{}->'{}'", path, safe_jsonb_key);
340                    let inner = subs
341                        .iter()
342                        .map(|sf| Self::render_field(sf, &nested_path, depth + 1))
343                        .collect::<Result<Vec<_>>>()?;
344                    return Ok(format!("'{}', jsonb_build_object({})", resp_key, inner.join(",")));
345                }
346            }
347        }
348
349        // Text: ->> (text cast, for String/ID).
350        // Native / Composite: -> (preserves native JSONB type).
351        let op = if field.kind == FieldKind::Text {
352            "->>"
353        } else {
354            "->"
355        };
356        Ok(format!("'{}', {}{}'{}'", resp_key, path, op, safe_jsonb_key))
357    }
358
359    /// Generate complete SELECT clause with projection for a table.
360    ///
361    /// # Arguments
362    ///
363    /// * `table_alias` - Table alias or name in the FROM clause
364    /// * `fields` - Fields to project
365    ///
366    /// # Returns
367    ///
368    /// Complete SELECT clause, e.g.: `SELECT jsonb_build_object(...) as data`
369    ///
370    /// # Example
371    ///
372    /// ```rust
373    /// use fraiseql_db::projection_generator::PostgresProjectionGenerator;
374    ///
375    /// let generator = PostgresProjectionGenerator::new();
376    /// let fields = vec!["id".to_string(), "name".to_string()];
377    /// let sql = generator.generate_select_clause("t", &fields).unwrap();
378    /// assert!(sql.contains("SELECT"));
379    /// ```
380    ///
381    /// # Errors
382    ///
383    /// Propagates any error from [`Self::generate_projection_sql`].
384    pub fn generate_select_clause(&self, table_alias: &str, fields: &[String]) -> Result<String> {
385        let projection = self.generate_projection_sql(fields)?;
386        Ok(format!(
387            "SELECT {} as \"{}\" FROM \"{}\" ",
388            projection, self.jsonb_column, table_alias
389        ))
390    }
391
392    /// Escape a value for use as a SQL *string literal* (inside single quotes).
393    ///
394    /// Doubles any embedded single-quote (`'` → `''`) to prevent SQL injection
395    /// when the field name is embedded as a string literal key, e.g. in
396    /// `jsonb_build_object('key', ...)` or `data->>'key'`.
397    fn escape_sql_string(s: &str) -> String {
398        s.replace('\'', "''")
399    }
400
401    /// Escape a SQL identifier using PostgreSQL double-quote quoting.
402    ///
403    /// Double-quote delimiters prevent identifier injection: any `"` within
404    /// the identifier is doubled (`""`), and the whole name is wrapped in `"`.
405    /// Use this when the name appears in an *identifier* position (column name,
406    /// table alias) rather than 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 PostgresProjectionGenerator {
414    fn default() -> Self {
415        Self::new()
416    }
417}
418
419/// MySQL SQL projection generator.
420///
421/// MySQL uses `JSON_OBJECT()` for field projection, similar to PostgreSQL's `jsonb_build_object()`.
422/// Generates efficient SQL that projects only requested JSON fields.
423///
424/// # Example
425///
426/// ```
427/// use fraiseql_db::projection_generator::MySqlProjectionGenerator;
428///
429/// let generator = MySqlProjectionGenerator::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 MySqlProjectionGenerator {
435    json_column: String,
436}
437
438impl MySqlProjectionGenerator {
439    /// Create new MySQL 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 MySQL projection SQL for specified fields.
460    ///
461    /// Generates a `JSON_OBJECT()` call that selects only the requested fields
462    /// from the JSON column.
463    ///
464    /// # Arguments
465    ///
466    /// * `fields` - JSON field names to project
467    ///
468    /// # Returns
469    ///
470    /// SQL fragment that can be used in a SELECT clause
471    ///
472    /// # Errors
473    ///
474    /// Returns `FraiseQLError::Validation` if any field name cannot be safely projected.
475    pub fn generate_projection_sql(&self, fields: &[String]) -> Result<String> {
476        if fields.is_empty() {
477            return Ok(format!("`{}`", self.json_column));
478        }
479
480        // Validate all field names before generating any SQL.
481        for field in fields {
482            validate_field_name(field)?;
483        }
484
485        let field_pairs: Vec<String> = fields
486            .iter()
487            .map(|field| {
488                // Response key used as SQL string literal key — escape ' → ''.
489                let safe_field = Self::escape_sql_string(field);
490                // JSON key uses the original schema field name (snake_case).
491                let json_key = to_snake_case(field);
492                format!("'{}', JSON_EXTRACT(`{}`, '$.{}')", safe_field, self.json_column, json_key)
493            })
494            .collect();
495
496        Ok(format!("JSON_OBJECT({})", field_pairs.join(",")))
497    }
498
499    /// Escape a value for use as a SQL *string literal* (inside single quotes).
500    fn escape_sql_string(s: &str) -> String {
501        s.replace('\'', "''")
502    }
503
504    /// Escape a SQL identifier using MySQL backtick quoting.
505    ///
506    /// Use this when the name appears in an *identifier* position (column name,
507    /// table alias), not as a string literal.
508    #[allow(dead_code)] // Reason: available for callers embedding names as SQL identifiers
509    fn escape_identifier(field: &str) -> String {
510        format!("`{}`", field.replace('`', "``"))
511    }
512}
513
514impl Default for MySqlProjectionGenerator {
515    fn default() -> Self {
516        Self::new()
517    }
518}
519
520/// SQLite SQL projection generator.
521///
522/// SQLite's JSON support is more limited than PostgreSQL and MySQL.
523/// Uses `json_object()` with `json_extract()` to project fields.
524///
525/// # Example
526///
527/// ```
528/// use fraiseql_db::projection_generator::SqliteProjectionGenerator;
529///
530/// let generator = SqliteProjectionGenerator::new();
531/// let fields = vec!["id".to_string(), "name".to_string()];
532/// let sql = generator.generate_projection_sql(&fields).unwrap();
533/// assert!(sql.contains("json_object"));
534/// ```
535pub struct SqliteProjectionGenerator {
536    json_column: String,
537}
538
539impl SqliteProjectionGenerator {
540    /// Create new SQLite projection generator with default JSON column name.
541    ///
542    /// Default JSON column: "data"
543    #[must_use]
544    pub fn new() -> Self {
545        Self::with_column("data")
546    }
547
548    /// Create projection generator with custom JSON column name.
549    ///
550    /// # Arguments
551    ///
552    /// * `json_column` - Name of the JSON column in the database table
553    #[must_use]
554    pub fn with_column(json_column: &str) -> Self {
555        Self {
556            json_column: json_column.to_string(),
557        }
558    }
559
560    /// Generate SQLite projection SQL for specified fields.
561    ///
562    /// Generates a `json_object()` call that selects only the requested fields.
563    ///
564    /// # Arguments
565    ///
566    /// * `fields` - JSON field names to project
567    ///
568    /// # Returns
569    ///
570    /// SQL fragment that can be used in a SELECT clause
571    ///
572    /// # Errors
573    ///
574    /// Returns `FraiseQLError::Validation` if any field name cannot be safely projected.
575    pub fn generate_projection_sql(&self, fields: &[String]) -> Result<String> {
576        if fields.is_empty() {
577            return Ok(format!("\"{}\"", self.json_column));
578        }
579
580        // Validate all field names before generating any SQL.
581        for field in fields {
582            validate_field_name(field)?;
583        }
584
585        let field_pairs: Vec<String> = fields
586            .iter()
587            .map(|field| {
588                // Response key used as SQL string literal key — escape ' → ''.
589                let safe_field = Self::escape_sql_string(field);
590                // JSON key uses the original schema field name (snake_case).
591                let json_key = to_snake_case(field);
592                format!(
593                    "'{}', json_extract(\"{}\", '$.{}')",
594                    safe_field, self.json_column, json_key
595                )
596            })
597            .collect();
598
599        Ok(format!("json_object({})", field_pairs.join(",")))
600    }
601
602    /// Escape a value for use as a SQL *string literal* (inside single quotes).
603    fn escape_sql_string(s: &str) -> String {
604        s.replace('\'', "''")
605    }
606
607    /// Escape a SQL identifier using SQLite double-quote quoting.
608    ///
609    /// Double-quote delimiters prevent identifier injection: any `"` within
610    /// the identifier is doubled (`""`), and the whole name is wrapped in `"`.
611    /// Use this when the name appears in an *identifier* position (column name,
612    /// table alias), not as a string literal.
613    #[allow(dead_code)] // Reason: available for callers that embed field names as identifiers
614    fn escape_identifier(field: &str) -> String {
615        format!("\"{}\"", field.replace('"', "\"\""))
616    }
617}
618
619impl Default for SqliteProjectionGenerator {
620    fn default() -> Self {
621        Self::new()
622    }
623}
624
625#[cfg(test)]
626mod tests;