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)]
606mod tests;