fraiseql_core/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,ignore
19//! use fraiseql_core::db::projection_generator::PostgresProjectionGenerator;
20//!
21//! let generator = PostgresProjectionGenerator::new();
22//! let fields = vec!["id".to_string(), "name".to_string(), "email".to_string()];
23//! let sql = generator.generate_projection_sql("user_data", &fields)?;
24//! // Returns: jsonb_build_object('id', data->>'id', 'name', data->>'name', 'email', data->>'email')
25//! ```
26
27use crate::error::Result;
28
29/// Convert camelCase field name to snake_case for JSON/JSONB key lookup.
30///
31/// FraiseQL converts schema field names from snake_case to camelCase for GraphQL spec compliance.
32/// However, JSON/JSONB keys are stored in their original snake_case form.
33/// This function reverses that conversion for JSON key access.
34///
35/// # Examples
36///
37/// ```rust,ignore
38/// assert_eq!(to_snake_case("firstName"), "first_name");
39/// assert_eq!(to_snake_case("id"), "id");
40/// ```
41fn to_snake_case(name: &str) -> String {
42 let mut result = String::new();
43 for (i, ch) in name.chars().enumerate() {
44 if ch.is_uppercase() && i > 0 {
45 result.push('_');
46 result.push(
47 ch.to_lowercase()
48 .next()
49 .expect("char::to_lowercase always yields at least one char"),
50 );
51 } else {
52 result.push(ch);
53 }
54 }
55 result
56}
57
58/// PostgreSQL SQL projection generator using jsonb_build_object.
59///
60/// Generates efficient PostgreSQL SQL that projects only requested JSONB fields,
61/// reducing payload size and JSON deserialization time.
62pub struct PostgresProjectionGenerator {
63 /// JSONB column name (typically "data")
64 jsonb_column: String,
65}
66
67impl PostgresProjectionGenerator {
68 /// Create new PostgreSQL projection generator with default JSONB column name.
69 ///
70 /// Default JSONB column: "data"
71 #[must_use]
72 pub fn new() -> Self {
73 Self::with_column("data")
74 }
75
76 /// Create projection generator with custom JSONB column name.
77 ///
78 /// # Arguments
79 ///
80 /// * `jsonb_column` - Name of the JSONB column in the database table
81 #[must_use]
82 pub fn with_column(jsonb_column: &str) -> Self {
83 Self {
84 jsonb_column: jsonb_column.to_string(),
85 }
86 }
87
88 /// Generate PostgreSQL projection SQL for specified fields.
89 ///
90 /// Generates a `jsonb_build_object()` call that selects only the requested fields
91 /// from the JSONB column, drastically reducing payload size.
92 ///
93 /// # Arguments
94 ///
95 /// * `fields` - GraphQL field names to project from JSONB
96 ///
97 /// # Returns
98 ///
99 /// SQL fragment that can be used in a SELECT clause, e.g.:
100 /// `jsonb_build_object('id', data->>'id', 'email', data->>'email')`
101 ///
102 /// # Example
103 ///
104 /// ```rust,ignore
105 /// let generator = PostgresProjectionGenerator::new();
106 /// let fields = vec!["id".to_string(), "email".to_string()];
107 /// let sql = generator.generate_projection_sql(&fields)?;
108 /// // Returns:
109 /// // jsonb_build_object('id', data->>'id', 'email', data->>'email')
110 /// ```
111 pub fn generate_projection_sql(&self, fields: &[String]) -> Result<String> {
112 if fields.is_empty() {
113 // No fields to project, return pass-through
114 return Ok(format!("\"{}\"", self.jsonb_column));
115 }
116
117 // Build the jsonb_build_object() call with all requested fields
118 let field_pairs: Vec<String> = fields
119 .iter()
120 .map(|field| {
121 // Response key uses the GraphQL field name (camelCase)
122 let safe_field = Self::escape_identifier(field);
123 // JSONB key uses the original schema field name (snake_case)
124 let jsonb_key = to_snake_case(field);
125 let safe_jsonb_key = Self::escape_identifier(&jsonb_key);
126 format!("'{}', \"{}\"->>'{}' ", safe_field, self.jsonb_column, safe_jsonb_key)
127 })
128 .collect();
129
130 // Format: jsonb_build_object('field1', data->>'field1', 'field2', data->>'field2', ...)
131 Ok(format!("jsonb_build_object({})", field_pairs.join(",")))
132 }
133
134 /// Generate complete SELECT clause with projection for a table.
135 ///
136 /// # Arguments
137 ///
138 /// * `table_alias` - Table alias or name in the FROM clause
139 /// * `fields` - Fields to project
140 ///
141 /// # Returns
142 ///
143 /// Complete SELECT clause, e.g.: `SELECT jsonb_build_object(...) as data`
144 ///
145 /// # Example
146 ///
147 /// ```rust,ignore
148 /// let sql = generator.generate_select_clause("t", &fields)?;
149 /// // Returns: SELECT jsonb_build_object(...) as data
150 /// ```
151 pub fn generate_select_clause(&self, table_alias: &str, fields: &[String]) -> Result<String> {
152 let projection = self.generate_projection_sql(fields)?;
153 Ok(format!(
154 "SELECT {} as \"{}\" FROM \"{}\" ",
155 projection, self.jsonb_column, table_alias
156 ))
157 }
158
159 /// Check if field name is safe for SQL (no injection).
160 ///
161 /// PostgreSQL identifiers can contain alphanumeric, underscore, and dollar signs.
162 /// This is a conservative check - in production, use parameterized queries.
163 fn is_safe_identifier(field: &str) -> bool {
164 !field.is_empty() && field.chars().all(|c| c.is_alphanumeric() || c == '_' || c == '$')
165 }
166
167 /// Escape SQL identifier safely.
168 ///
169 /// For production use, should be parameterized. This is defensive escaping
170 /// by replacing single quotes with double quotes (PostgreSQL convention).
171 fn escape_identifier(field: &str) -> String {
172 // Validate field name
173 if !Self::is_safe_identifier(field) {
174 // In production, would reject or sanitize more strictly
175 // For now, pass through with warning logged at runtime
176 return field.to_string();
177 }
178 field.to_string()
179 }
180}
181
182impl Default for PostgresProjectionGenerator {
183 fn default() -> Self {
184 Self::new()
185 }
186}
187
188/// MySQL SQL projection generator.
189///
190/// MySQL uses `JSON_OBJECT()` for field projection, similar to PostgreSQL's `jsonb_build_object()`.
191/// Generates efficient SQL that projects only requested JSON fields.
192///
193/// # Example
194///
195/// ```
196/// use fraiseql_core::db::projection_generator::MySqlProjectionGenerator;
197///
198/// let generator = MySqlProjectionGenerator::new();
199/// let fields = vec!["id".to_string(), "name".to_string()];
200/// let sql = generator.generate_projection_sql(&fields).unwrap();
201/// assert!(sql.contains("JSON_OBJECT"));
202/// ```
203pub struct MySqlProjectionGenerator {
204 json_column: String,
205}
206
207impl MySqlProjectionGenerator {
208 /// Create new MySQL projection generator with default JSON column name.
209 ///
210 /// Default JSON column: "data"
211 #[must_use]
212 pub fn new() -> Self {
213 Self::with_column("data")
214 }
215
216 /// Create projection generator with custom JSON column name.
217 ///
218 /// # Arguments
219 ///
220 /// * `json_column` - Name of the JSON column in the database table
221 #[must_use]
222 pub fn with_column(json_column: &str) -> Self {
223 Self {
224 json_column: json_column.to_string(),
225 }
226 }
227
228 /// Generate MySQL projection SQL for specified fields.
229 ///
230 /// Generates a `JSON_OBJECT()` call that selects only the requested fields
231 /// from the JSON column.
232 ///
233 /// # Arguments
234 ///
235 /// * `fields` - JSON field names to project
236 ///
237 /// # Returns
238 ///
239 /// SQL fragment that can be used in a SELECT clause
240 pub fn generate_projection_sql(&self, fields: &[String]) -> Result<String> {
241 if fields.is_empty() {
242 return Ok(format!("`{}`", self.json_column));
243 }
244
245 let field_pairs: Vec<String> = fields
246 .iter()
247 .map(|field| {
248 // Response key uses the GraphQL field name (camelCase)
249 let safe_field = Self::escape_identifier(field);
250 // JSON key uses the original schema field name (snake_case)
251 let json_key = to_snake_case(field);
252 format!("'{}', JSON_EXTRACT(`{}`, '$.{}')", safe_field, self.json_column, json_key)
253 })
254 .collect();
255
256 Ok(format!("JSON_OBJECT({})", field_pairs.join(",")))
257 }
258
259 /// Check if field name is safe for SQL.
260 fn is_safe_identifier(field: &str) -> bool {
261 !field.is_empty() && field.chars().all(|c| c.is_alphanumeric() || c == '_' || c == '$')
262 }
263
264 /// Escape SQL identifier safely.
265 fn escape_identifier(field: &str) -> String {
266 if !Self::is_safe_identifier(field) {
267 return field.to_string();
268 }
269 field.to_string()
270 }
271}
272
273impl Default for MySqlProjectionGenerator {
274 fn default() -> Self {
275 Self::new()
276 }
277}
278
279/// SQLite SQL projection generator.
280///
281/// SQLite's JSON support is more limited than PostgreSQL and MySQL.
282/// Uses `json_object()` with `json_extract()` to project fields.
283///
284/// # Example
285///
286/// ```
287/// use fraiseql_core::db::projection_generator::SqliteProjectionGenerator;
288///
289/// let generator = SqliteProjectionGenerator::new();
290/// let fields = vec!["id".to_string(), "name".to_string()];
291/// let sql = generator.generate_projection_sql(&fields).unwrap();
292/// assert!(sql.contains("json_object"));
293/// ```
294pub struct SqliteProjectionGenerator {
295 json_column: String,
296}
297
298impl SqliteProjectionGenerator {
299 /// Create new SQLite projection generator with default JSON column name.
300 ///
301 /// Default JSON column: "data"
302 #[must_use]
303 pub fn new() -> Self {
304 Self::with_column("data")
305 }
306
307 /// Create projection generator with custom JSON column name.
308 ///
309 /// # Arguments
310 ///
311 /// * `json_column` - Name of the JSON column in the database table
312 #[must_use]
313 pub fn with_column(json_column: &str) -> Self {
314 Self {
315 json_column: json_column.to_string(),
316 }
317 }
318
319 /// Generate SQLite projection SQL for specified fields.
320 ///
321 /// Generates a `json_object()` call that selects only the requested fields.
322 ///
323 /// # Arguments
324 ///
325 /// * `fields` - JSON field names to project
326 ///
327 /// # Returns
328 ///
329 /// SQL fragment that can be used in a SELECT clause
330 pub fn generate_projection_sql(&self, fields: &[String]) -> Result<String> {
331 if fields.is_empty() {
332 return Ok(format!("\"{}\"", self.json_column));
333 }
334
335 let field_pairs: Vec<String> = fields
336 .iter()
337 .map(|field| {
338 // Response key uses the GraphQL field name (camelCase)
339 let safe_field = Self::escape_identifier(field);
340 // JSON key uses the original schema field name (snake_case)
341 let json_key = to_snake_case(field);
342 format!(
343 "'{}', json_extract(\"{}\", '$.{}')",
344 safe_field, self.json_column, json_key
345 )
346 })
347 .collect();
348
349 Ok(format!("json_object({})", field_pairs.join(",")))
350 }
351
352 /// Check if field name is safe for SQL.
353 fn is_safe_identifier(field: &str) -> bool {
354 !field.is_empty() && field.chars().all(|c| c.is_alphanumeric() || c == '_' || c == '$')
355 }
356
357 /// Escape SQL identifier safely.
358 fn escape_identifier(field: &str) -> String {
359 if !Self::is_safe_identifier(field) {
360 return field.to_string();
361 }
362 field.to_string()
363 }
364}
365
366impl Default for SqliteProjectionGenerator {
367 fn default() -> Self {
368 Self::new()
369 }
370}
371
372#[cfg(test)]
373mod tests {
374 use super::*;
375
376 #[test]
377 fn test_postgres_projection_single_field() {
378 let generator = PostgresProjectionGenerator::new();
379 let fields = vec!["id".to_string()];
380
381 let sql = generator.generate_projection_sql(&fields).unwrap();
382 assert_eq!(sql, "jsonb_build_object('id', \"data\"->>'id' )");
383 }
384
385 #[test]
386 fn test_postgres_projection_multiple_fields() {
387 let generator = PostgresProjectionGenerator::new();
388 let fields = vec!["id".to_string(), "name".to_string(), "email".to_string()];
389
390 let sql = generator.generate_projection_sql(&fields).unwrap();
391 assert!(sql.contains("jsonb_build_object("));
392 assert!(sql.contains("'id', \"data\"->>'id'"));
393 assert!(sql.contains("'name', \"data\"->>'name'"));
394 assert!(sql.contains("'email', \"data\"->>'email'"));
395 }
396
397 #[test]
398 fn test_postgres_projection_empty_fields() {
399 let generator = PostgresProjectionGenerator::new();
400 let fields: Vec<String> = vec![];
401
402 let sql = generator.generate_projection_sql(&fields).unwrap();
403 // Empty projection should pass through the JSONB column
404 assert_eq!(sql, "\"data\"");
405 }
406
407 #[test]
408 fn test_postgres_projection_custom_column() {
409 let generator = PostgresProjectionGenerator::with_column("metadata");
410 let fields = vec!["id".to_string()];
411
412 let sql = generator.generate_projection_sql(&fields).unwrap();
413 assert_eq!(sql, "jsonb_build_object('id', \"metadata\"->>'id' )");
414 }
415
416 #[test]
417 fn test_postgres_select_clause() {
418 let generator = PostgresProjectionGenerator::new();
419 let fields = vec!["id".to_string(), "name".to_string()];
420
421 let sql = generator.generate_select_clause("users", &fields).unwrap();
422 assert!(sql.starts_with("SELECT jsonb_build_object("));
423 assert!(sql.contains("as \"data\""));
424 assert!(sql.contains("FROM \"users\""));
425 }
426
427 #[test]
428 fn test_identifier_validation() {
429 assert!(PostgresProjectionGenerator::is_safe_identifier("id"));
430 assert!(PostgresProjectionGenerator::is_safe_identifier("user_id"));
431 assert!(PostgresProjectionGenerator::is_safe_identifier("user$data"));
432 assert!(PostgresProjectionGenerator::is_safe_identifier("field123"));
433 assert!(!PostgresProjectionGenerator::is_safe_identifier("field-name")); // hyphen not allowed
434 assert!(!PostgresProjectionGenerator::is_safe_identifier("field.name")); // dot not allowed
435 assert!(!PostgresProjectionGenerator::is_safe_identifier("")); // empty not allowed
436 }
437
438 // MySQL Projection Generator Tests
439 #[test]
440 fn test_mysql_projection_single_field() {
441 let generator = MySqlProjectionGenerator::new();
442 let fields = vec!["id".to_string()];
443
444 let sql = generator.generate_projection_sql(&fields).unwrap();
445 assert_eq!(sql, "JSON_OBJECT('id', JSON_EXTRACT(`data`, '$.id'))");
446 }
447
448 #[test]
449 fn test_mysql_projection_multiple_fields() {
450 let generator = MySqlProjectionGenerator::new();
451 let fields = vec!["id".to_string(), "name".to_string(), "email".to_string()];
452
453 let sql = generator.generate_projection_sql(&fields).unwrap();
454 assert!(sql.contains("JSON_OBJECT("));
455 assert!(sql.contains("'id', JSON_EXTRACT(`data`, '$.id')"));
456 assert!(sql.contains("'name', JSON_EXTRACT(`data`, '$.name')"));
457 assert!(sql.contains("'email', JSON_EXTRACT(`data`, '$.email')"));
458 }
459
460 #[test]
461 fn test_mysql_projection_empty_fields() {
462 let generator = MySqlProjectionGenerator::new();
463 let fields: Vec<String> = vec![];
464
465 let sql = generator.generate_projection_sql(&fields).unwrap();
466 assert_eq!(sql, "`data`");
467 }
468
469 #[test]
470 fn test_mysql_projection_custom_column() {
471 let generator = MySqlProjectionGenerator::with_column("metadata");
472 let fields = vec!["id".to_string()];
473
474 let sql = generator.generate_projection_sql(&fields).unwrap();
475 assert_eq!(sql, "JSON_OBJECT('id', JSON_EXTRACT(`metadata`, '$.id'))");
476 }
477
478 // SQLite Projection Generator Tests
479 #[test]
480 fn test_sqlite_projection_single_field() {
481 let generator = SqliteProjectionGenerator::new();
482 let fields = vec!["id".to_string()];
483
484 let sql = generator.generate_projection_sql(&fields).unwrap();
485 assert_eq!(sql, "json_object('id', json_extract(\"data\", '$.id'))");
486 }
487
488 #[test]
489 fn test_sqlite_projection_multiple_fields() {
490 let generator = SqliteProjectionGenerator::new();
491 let fields = vec!["id".to_string(), "name".to_string(), "email".to_string()];
492
493 let sql = generator.generate_projection_sql(&fields).unwrap();
494 assert!(sql.contains("json_object("));
495 assert!(sql.contains("'id', json_extract(\"data\", '$.id')"));
496 assert!(sql.contains("'name', json_extract(\"data\", '$.name')"));
497 assert!(sql.contains("'email', json_extract(\"data\", '$.email')"));
498 }
499
500 #[test]
501 fn test_sqlite_projection_empty_fields() {
502 let generator = SqliteProjectionGenerator::new();
503 let fields: Vec<String> = vec![];
504
505 let sql = generator.generate_projection_sql(&fields).unwrap();
506 assert_eq!(sql, "\"data\"");
507 }
508
509 #[test]
510 fn test_sqlite_projection_custom_column() {
511 let generator = SqliteProjectionGenerator::with_column("metadata");
512 let fields = vec!["id".to_string()];
513
514 let sql = generator.generate_projection_sql(&fields).unwrap();
515 assert_eq!(sql, "json_object('id', json_extract(\"metadata\", '$.id'))");
516 }
517
518 // ========================================================================
519 // Issue #269: JSONB field extraction with snake_case/camelCase mapping
520 // ========================================================================
521
522 #[test]
523 fn test_to_snake_case_conversion() {
524 // Test camelCase to snake_case conversion
525 assert_eq!(super::to_snake_case("id"), "id");
526 assert_eq!(super::to_snake_case("firstName"), "first_name");
527 assert_eq!(super::to_snake_case("createdAt"), "created_at");
528 assert_eq!(super::to_snake_case("userId"), "user_id");
529 assert_eq!(super::to_snake_case("updatedAtTimestamp"), "updated_at_timestamp");
530 }
531
532 #[test]
533 fn test_postgres_projection_with_field_mapping_snake_case() {
534 // Problem: GraphQL converts field names to camelCase (first_name → firstName)
535 // But JSONB stores them in snake_case (first_name).
536 // When generating JSONB extraction SQL, we must use the original snake_case key,
537 // not the camelCase GraphQL name.
538
539 let generator = PostgresProjectionGenerator::new();
540
541 // Simulate what happens when fields come from GraphQL query
542 // These are camelCase field names (what GraphQL expects in response)
543 let graphql_fields = vec![
544 "id".to_string(),
545 "firstName".to_string(),
546 "createdAt".to_string(),
547 ];
548
549 let sql = generator.generate_projection_sql(&graphql_fields).unwrap();
550
551 eprintln!("Generated SQL: {}", sql);
552
553 // Current broken behavior generates:
554 // jsonb_build_object('id', data->>'id', 'firstName', data->>'firstName', 'createdAt',
555 // data->>'createdAt')
556 //
557 // This fails because JSONB has snake_case keys: first_name, created_at
558 // Result: data->>'firstName' returns NULL (key not found)
559
560 // Test the bug: SQL should NOT have camelCase JSONB access
561 // We expect this to FAIL until we implement field mapping
562 assert!(
563 !sql.contains("->>'firstName'") && !sql.contains("->>'createdAt'"),
564 "BUG: SQL is using camelCase keys for JSONB access. \
565 JSONB has snake_case keys like 'first_name', 'created_at'. SQL: {}",
566 sql
567 );
568 }
569}