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 SQL expression a projection may emit verbatim, in place of reading a key
50/// out of the JSONB column (#959).
51///
52/// The inner string is private and there is **no public constructor**: the only
53/// way to obtain one is
54/// [`vector_distance_expr`](crate::order_by::vector_distance_expr), which builds
55/// it from a validated identifier, an operator drawn from a fixed set and a
56/// literal whose character set it re-checks. A projection is SQL the caller does
57/// not get to write, and this type is what enforces that across the crate
58/// boundary — `fraiseql-core` can carry one into a `ProjectionField` but cannot
59/// invent one.
60#[derive(Debug, Clone, PartialEq, Eq)]
61pub struct ComputedExpr(String);
62
63impl ComputedExpr {
64    /// Wrap an expression assembled from validated parts.
65    pub(crate) const fn from_validated_parts(sql: String) -> Self {
66        Self(sql)
67    }
68
69    /// The expression as it will appear in the SELECT list.
70    #[must_use]
71    pub fn as_sql(&self) -> &str {
72        &self.0
73    }
74}
75
76/// A field in a SQL projection with type information.
77///
78/// Used by typed projection generators to choose the correct JSONB extraction
79/// operator based on [`FieldKind`]: `->` (preserves JSONB) for composites and
80/// native scalars, `->>` (text) for text scalars (String, ID).
81///
82/// When `sub_fields` is populated on a composite field, `generate_typed_projection_sql`
83/// will recurse and emit a nested `jsonb_build_object(...)` instead of returning the full
84/// composite blob.  Leave `sub_fields` as `None` to get the existing `data->'field'`
85/// behaviour (full blob, no sub-selection).
86#[derive(Debug, Clone, PartialEq, Eq)]
87pub struct ProjectionField {
88    /// Output (response) key — the GraphQL alias if present, otherwise the field
89    /// name. Used verbatim as the key in the generated `jsonb_build_object`.
90    pub name: String,
91
92    /// Source GraphQL field name (camelCase) used to derive the JSONB column via
93    /// `to_snake_case`. Equals [`name`](Self::name) for unaliased fields, but
94    /// differs when the field is aliased: `myName: fullName` yields
95    /// `name = "myName"`, `source = "fullName"`, so the projection reads
96    /// `data->>'full_name'` while emitting it under the `myName` key (#418).
97    pub source: String,
98
99    /// Semantic kind of the field, controlling the JSONB extraction operator.
100    pub kind: FieldKind,
101
102    /// Sub-fields to project for composite (Object) types.
103    ///
104    /// When `Some` and non-empty, the generator recurses and produces a nested
105    /// `jsonb_build_object` instead of returning the entire composite blob.
106    /// Set to `None` (or `Some([])`) to fall back to `data->'field'`.
107    /// List fields should always use `None` — sub-projection inside aggregated
108    /// JSONB arrays is out of scope for this first iteration.
109    pub sub_fields: Option<Vec<ProjectionField>>,
110
111    /// A SQL expression this field's value comes from, instead of the JSONB
112    /// column (#959) — the vector distance a `nearest` query ordered by.
113    ///
114    /// When set it wins over [`kind`](Self::kind) and
115    /// [`source`](Self::source): the field is not stored, so there is no key to
116    /// read. See [`ComputedExpr`] for why an arbitrary string cannot get here.
117    pub computed: Option<ComputedExpr>,
118}
119
120impl ProjectionField {
121    /// Create a text scalar projection field (uses `->>` text extraction).
122    ///
123    /// Use for String and ID fields only. Other scalars (Int, Float, Boolean,
124    /// DateTime, etc.) should use [`Self::native`].
125    #[must_use]
126    pub fn scalar(name: impl Into<String>) -> Self {
127        let name = name.into();
128        Self {
129            source: name.clone(),
130            name,
131            kind: FieldKind::Text,
132            sub_fields: None,
133            computed: None,
134        }
135    }
136
137    /// Create a native JSON scalar projection field (uses `->` to preserve type).
138    ///
139    /// Use for Int, Float, Boolean, DateTime, Date, Time, Decimal, Vector, and
140    /// other non-text scalars. `->>` would coerce these to strings inside
141    /// `jsonb_build_object`, losing type information.
142    #[must_use]
143    pub fn native(name: impl Into<String>) -> Self {
144        let name = name.into();
145        Self {
146            source: name.clone(),
147            name,
148            kind: FieldKind::Native,
149            sub_fields: None,
150            computed: None,
151        }
152    }
153
154    /// Create a composite (object/list) projection field (uses `->` JSONB extraction).
155    #[must_use]
156    pub fn composite(name: impl Into<String>) -> Self {
157        let name = name.into();
158        Self {
159            source: name.clone(),
160            name,
161            kind: FieldKind::Composite,
162            sub_fields: None,
163            computed: None,
164        }
165    }
166
167    /// Create a composite projection field with known sub-fields.
168    ///
169    /// The generator will recurse into `sub_fields` and emit a nested
170    /// `jsonb_build_object(...)` rather than returning the full composite blob.
171    #[must_use]
172    pub fn composite_with_sub_fields(name: impl Into<String>, sub_fields: Vec<Self>) -> Self {
173        let name = name.into();
174        Self {
175            source: name.clone(),
176            name,
177            kind: FieldKind::Composite,
178            sub_fields: Some(sub_fields),
179            computed: None,
180        }
181    }
182
183    /// Create a field whose value is computed in SQL rather than read from the
184    /// JSONB column (#959).
185    ///
186    /// `kind` is [`FieldKind::Native`] because the expression yields a JSON
187    /// number, but the projection never consults it — a computed field has no
188    /// stored key to extract.
189    #[must_use]
190    pub fn computed(name: impl Into<String>, expr: ComputedExpr) -> Self {
191        let name = name.into();
192        Self {
193            source: name.clone(),
194            name,
195            kind: FieldKind::Native,
196            sub_fields: None,
197            computed: Some(expr),
198        }
199    }
200
201    /// Whether this field is a composite type (Object or List).
202    #[must_use]
203    pub const fn is_composite(&self) -> bool {
204        matches!(self.kind, FieldKind::Composite)
205    }
206}
207
208impl From<String> for ProjectionField {
209    fn from(name: String) -> Self {
210        Self::scalar(name)
211    }
212}
213
214/// Validate that a GraphQL field name contains only characters that are safe
215/// for use in SQL projections (alphanumeric characters and underscores only).
216///
217/// GraphQL field names in FraiseQL are either snake_case (schema definitions)
218/// or camelCase (after the compiler's automatic conversion). Both forms are
219/// subsets of `[a-zA-Z_][a-zA-Z0-9_]*`, so this function rejects any name
220/// that falls outside that alphabet.
221///
222/// # Errors
223///
224/// Returns `FraiseQLError::Validation` if `field` contains a character outside
225/// `[a-zA-Z0-9_]`.
226fn validate_field_name(field: &str) -> Result<()> {
227    if field.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
228        Ok(())
229    } else {
230        Err(FraiseQLError::Validation {
231            message: format!(
232                "field name '{}' contains characters that cannot be safely projected; \
233                 only ASCII alphanumeric characters and underscores are allowed",
234                field
235            ),
236            path:    None,
237        })
238    }
239}
240
241use crate::utils::to_snake_case;
242
243/// PostgreSQL SQL projection generator using jsonb_build_object.
244///
245/// Generates efficient PostgreSQL SQL that projects only requested JSONB fields,
246/// reducing payload size and JSON deserialization time.
247pub struct PostgresProjectionGenerator {
248    /// JSONB column name (typically "data")
249    jsonb_column: String,
250}
251
252impl PostgresProjectionGenerator {
253    /// Create new PostgreSQL projection generator with default JSONB column name.
254    ///
255    /// Default JSONB column: "data"
256    #[must_use]
257    pub fn new() -> Self {
258        Self::with_column("data")
259    }
260
261    /// Create projection generator with custom JSONB column name.
262    ///
263    /// # Arguments
264    ///
265    /// * `jsonb_column` - Name of the JSONB column in the database table
266    #[must_use]
267    pub fn with_column(jsonb_column: &str) -> Self {
268        Self {
269            jsonb_column: jsonb_column.to_string(),
270        }
271    }
272
273    /// Generate PostgreSQL projection SQL for specified fields.
274    ///
275    /// Generates a `jsonb_build_object()` call that selects only the requested fields
276    /// from the JSONB column, drastically reducing payload size.
277    ///
278    /// # Arguments
279    ///
280    /// * `fields` - GraphQL field names to project from JSONB
281    ///
282    /// # Returns
283    ///
284    /// SQL fragment that can be used in a SELECT clause, e.g.:
285    /// `jsonb_build_object('id', data->>'id', 'email', data->>'email')`
286    ///
287    /// # Example
288    ///
289    /// ```rust
290    /// use fraiseql_db::projection_generator::PostgresProjectionGenerator;
291    /// # use fraiseql_error::Result;
292    /// # fn example() -> Result<()> {
293    /// let generator = PostgresProjectionGenerator::new();
294    /// let fields = vec!["id".to_string(), "email".to_string()];
295    /// let sql = generator.generate_projection_sql(&fields)?;
296    /// // Returns:
297    /// // jsonb_build_object('id', data->>'id', 'email', data->>'email')
298    /// assert!(sql.contains("jsonb_build_object"));
299    /// # Ok(())
300    /// # }
301    /// ```
302    ///
303    /// # Errors
304    ///
305    /// Returns `FraiseQLError::Validation` if any field name contains characters
306    /// that cannot be safely included in a SQL projection.
307    pub fn generate_projection_sql(&self, fields: &[String]) -> Result<String> {
308        if fields.is_empty() {
309            // No fields to project, return pass-through
310            return Ok(format!("\"{}\"", self.jsonb_column));
311        }
312
313        // Validate all field names before generating any SQL.
314        for field in fields {
315            validate_field_name(field)?;
316        }
317
318        // Build the jsonb_build_object() call with all requested fields
319        let field_pairs: Vec<String> = fields
320            .iter()
321            .map(|field| {
322                // Response key uses the GraphQL field name (camelCase).
323                // Used as a SQL *string literal* key (inside single-quotes): escape ' → ''.
324                let safe_field = Self::escape_sql_string(field);
325                // JSONB key uses the original schema field name (snake_case).
326                let jsonb_key = to_snake_case(field);
327                let safe_jsonb_key = Self::escape_sql_string(&jsonb_key);
328                format!("'{}', \"{}\"->>'{}' ", safe_field, self.jsonb_column, safe_jsonb_key)
329            })
330            .collect();
331
332        // Format: jsonb_build_object('field1', data->>'field1', 'field2', data->>'field2', ...)
333        Ok(format!("jsonb_build_object({})", field_pairs.join(",")))
334    }
335
336    /// Generate type-aware PostgreSQL projection SQL.
337    ///
338    /// Uses `->` (JSONB extraction) for composite fields (objects, lists) and
339    /// `->>` (text extraction) for scalar fields. This avoids the unnecessary
340    /// text→JSON round-trip that occurs when `->>` is used for nested objects.
341    ///
342    /// When a composite field carries `sub_fields`, the generator recurses and
343    /// emits a nested `jsonb_build_object(...)` that selects only the requested
344    /// sub-fields rather than returning the entire blob, at every depth. `sub_fields` of
345    /// `Some(vec![])` selects nothing of the object and renders `jsonb_build_object()`.
346    /// There is no depth cap: the fields follow a selection, which the caller bounds
347    /// (`max_query_depth`), and a cap that fell back to `data->'field'` served every
348    /// stored key of the object below it. PostgreSQL 16's parser refuses a nest of 2 044
349    /// `jsonb_build_object` levels (`memory exhausted`), two orders of magnitude above
350    /// any bound a caller sets.
351    ///
352    /// # Arguments
353    ///
354    /// * `fields` - Projection fields with type information
355    ///
356    /// An empty field list projects an empty object, **not** the JSONB column.
357    /// "Nothing was requested" and "everything was requested" are opposite
358    /// answers, and returning the whole column for the first is how a `node(id:)`
359    /// lookup whose selection resolved to nothing served every column in the view
360    /// (#827).
361    ///
362    /// # Errors
363    ///
364    /// Returns `FraiseQLError::Validation` if any field name contains characters
365    /// that cannot be safely included in a SQL projection.
366    pub fn generate_typed_projection_sql(&self, fields: &[ProjectionField]) -> Result<String> {
367        if fields.is_empty() {
368            return Ok("jsonb_build_object()".to_string());
369        }
370
371        let path = format!("\"{}\"", self.jsonb_column);
372        let field_pairs = fields
373            .iter()
374            .map(|field| Self::render_field(field, &path))
375            .collect::<Result<Vec<_>>>()?;
376
377        Ok(format!("jsonb_build_object({})", field_pairs.join(",")))
378    }
379
380    /// Project the whole JSONB column **plus** a set of computed fields (#959).
381    ///
382    /// `"data" || jsonb_build_object('similarity', (…))`. Used where the row has
383    /// to come back whole — a policy-gated field needs the full parent to decide
384    /// on, and the stream strategy returns the stored blob — but a computed
385    /// value still has to reach the response. Concatenation, so a computed key
386    /// that collides with a stored one wins, which is the only reading that
387    /// matches what the caller asked for.
388    ///
389    /// # Errors
390    ///
391    /// Returns `FraiseQLError::Validation` if a field name cannot be safely
392    /// projected.
393    pub fn generate_merged_projection_sql(&self, computed: &[ProjectionField]) -> Result<String> {
394        if computed.is_empty() {
395            return Ok(format!("\"{}\"", self.jsonb_column));
396        }
397        let path = format!("\"{}\"", self.jsonb_column);
398        let pairs = computed
399            .iter()
400            .map(|field| Self::render_field(field, &path))
401            .collect::<Result<Vec<_>>>()?;
402        Ok(format!("{path} || jsonb_build_object({})", pairs.join(",")))
403    }
404
405    /// Recursively render one projection field as a `'key', <expr>` pair for
406    /// `jsonb_build_object`.
407    ///
408    /// * `field` — field to render
409    /// * `path`  — JSONB path prefix built so far (e.g. `"data"` at depth 0, `"data"->'author'` at
410    ///   depth 1)
411    fn render_field(field: &ProjectionField, path: &str) -> Result<String> {
412        // Output key is the (possibly aliased) response key; the JSONB column is
413        // derived from the *source* field name. For unaliased fields these are
414        // the same, but an aliased field (`myName: fullName`) must read
415        // `data->>'full_name'`, not `data->>'my_name'` (#418).
416        let resp_key = Self::escape_sql_string(&field.name);
417        // A computed field has no stored key: the expression is the value, at
418        // whatever depth the field appears (#959).
419        if let Some(expr) = &field.computed {
420            return Ok(format!("'{}', ({})", resp_key, expr.as_sql()));
421        }
422        let jsonb_key = to_snake_case(&field.source);
423        let safe_jsonb_key = Self::escape_sql_string(&jsonb_key);
424
425        // An object's sub-fields, at any depth — never the stored object in their place.
426        //
427        // Built only when the stored value IS an object (#1364): a JSON `null` or a missing
428        // key reads every sub-field as NULL, and an unconditional `jsonb_build_object`
429        // turned that into `{"id": null, ...}` — a nullable object that could never be
430        // `null`, and a non-null sub-field answered with `null`. The `CASE` with no `ELSE`
431        // yields NULL, and recursion applies the guard at every depth.
432        if let Some(subs) = &field.sub_fields {
433            let nested_path = format!("{}->'{}'", path, safe_jsonb_key);
434            let inner = subs
435                .iter()
436                .map(|sf| Self::render_field(sf, &nested_path))
437                .collect::<Result<Vec<_>>>()?;
438            return Ok(format!(
439                "'{}', CASE WHEN jsonb_typeof({}) = 'object' THEN jsonb_build_object({}) END",
440                resp_key,
441                nested_path,
442                inner.join(",")
443            ));
444        }
445
446        // Text: ->> (text cast, for String/ID).
447        // Native / Composite: -> (preserves native JSONB type).
448        let op = if field.kind == FieldKind::Text {
449            "->>"
450        } else {
451            "->"
452        };
453        Ok(format!("'{}', {}{}'{}'", resp_key, path, op, safe_jsonb_key))
454    }
455
456    /// Generate complete SELECT clause with projection for a table.
457    ///
458    /// # Arguments
459    ///
460    /// * `table_alias` - Table alias or name in the FROM clause
461    /// * `fields` - Fields to project
462    ///
463    /// # Returns
464    ///
465    /// Complete SELECT clause, e.g.: `SELECT jsonb_build_object(...) as data`
466    ///
467    /// # Example
468    ///
469    /// ```rust
470    /// use fraiseql_db::projection_generator::PostgresProjectionGenerator;
471    ///
472    /// let generator = PostgresProjectionGenerator::new();
473    /// let fields = vec!["id".to_string(), "name".to_string()];
474    /// let sql = generator.generate_select_clause("t", &fields).unwrap();
475    /// assert!(sql.contains("SELECT"));
476    /// ```
477    ///
478    /// # Errors
479    ///
480    /// Propagates any error from [`Self::generate_projection_sql`].
481    pub fn generate_select_clause(&self, table_alias: &str, fields: &[String]) -> Result<String> {
482        let projection = self.generate_projection_sql(fields)?;
483        Ok(format!(
484            "SELECT {} as \"{}\" FROM \"{}\" ",
485            projection, self.jsonb_column, table_alias
486        ))
487    }
488
489    /// Escape a value for use as a SQL *string literal* (inside single quotes).
490    ///
491    /// Doubles any embedded single-quote (`'` → `''`) to prevent SQL injection
492    /// when the field name is embedded as a string literal key, e.g. in
493    /// `jsonb_build_object('key', ...)` or `data->>'key'`.
494    fn escape_sql_string(s: &str) -> String {
495        s.replace('\'', "''")
496    }
497
498    /// Escape a SQL identifier using PostgreSQL double-quote quoting.
499    ///
500    /// Double-quote delimiters prevent identifier injection: any `"` within
501    /// the identifier is doubled (`""`), and the whole name is wrapped in `"`.
502    /// Use this when the name appears in an *identifier* position (column name,
503    /// table alias) rather than as a string literal.
504    #[allow(dead_code)] // Reason: available for callers embedding names as SQL identifiers
505    fn escape_identifier(field: &str) -> String {
506        format!("\"{}\"", field.replace('"', "\"\""))
507    }
508}
509
510impl Default for PostgresProjectionGenerator {
511    fn default() -> Self {
512        Self::new()
513    }
514}
515
516#[cfg(test)]
517mod tests;