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;