1use fraiseql_error::{FraiseQLError, Result};
31
32#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct ProjectionField {
38 pub name: String,
40
41 pub is_composite: bool,
44}
45
46impl ProjectionField {
47 #[must_use]
49 pub fn scalar(name: impl Into<String>) -> Self {
50 Self {
51 name: name.into(),
52 is_composite: false,
53 }
54 }
55
56 #[must_use]
58 pub fn composite(name: impl Into<String>) -> Self {
59 Self {
60 name: name.into(),
61 is_composite: true,
62 }
63 }
64}
65
66impl From<String> for ProjectionField {
67 fn from(name: String) -> Self {
68 Self::scalar(name)
69 }
70}
71
72fn validate_field_name(field: &str) -> Result<()> {
85 if field.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
86 Ok(())
87 } else {
88 Err(FraiseQLError::Validation {
89 message: format!(
90 "field name '{}' contains characters that cannot be safely projected; \
91 only ASCII alphanumeric characters and underscores are allowed",
92 field
93 ),
94 path: None,
95 })
96 }
97}
98
99fn to_snake_case(name: &str) -> String {
112 let mut result = String::new();
113 for (i, ch) in name.chars().enumerate() {
114 if ch.is_uppercase() && i > 0 {
115 result.push('_');
116 result.push(
117 ch.to_lowercase()
118 .next()
119 .expect("char::to_lowercase always yields at least one char"),
121 );
122 } else {
123 result.push(ch);
124 }
125 }
126 result
127}
128
129pub struct PostgresProjectionGenerator {
134 jsonb_column: String,
136}
137
138impl PostgresProjectionGenerator {
139 #[must_use]
143 pub fn new() -> Self {
144 Self::with_column("data")
145 }
146
147 #[must_use]
153 pub fn with_column(jsonb_column: &str) -> Self {
154 Self {
155 jsonb_column: jsonb_column.to_string(),
156 }
157 }
158
159 pub fn generate_projection_sql(&self, fields: &[String]) -> Result<String> {
194 if fields.is_empty() {
195 return Ok(format!("\"{}\"", self.jsonb_column));
197 }
198
199 for field in fields {
201 validate_field_name(field)?;
202 }
203
204 let field_pairs: Vec<String> = fields
206 .iter()
207 .map(|field| {
208 let safe_field = Self::escape_sql_string(field);
211 let jsonb_key = to_snake_case(field);
213 let safe_jsonb_key = Self::escape_sql_string(&jsonb_key);
214 format!("'{}', \"{}\"->>'{}' ", safe_field, self.jsonb_column, safe_jsonb_key)
215 })
216 .collect();
217
218 Ok(format!("jsonb_build_object({})", field_pairs.join(",")))
220 }
221
222 pub fn generate_typed_projection_sql(&self, fields: &[ProjectionField]) -> Result<String> {
237 if fields.is_empty() {
238 return Ok(format!("\"{}\"", self.jsonb_column));
239 }
240
241 let field_pairs: Vec<String> = fields
242 .iter()
243 .map(|field| {
244 let safe_field = Self::escape_sql_string(&field.name);
245 let jsonb_key = to_snake_case(&field.name);
246 let safe_jsonb_key = Self::escape_sql_string(&jsonb_key);
247 let operator = if field.is_composite { "->" } else { "->>" };
248 format!(
249 "'{}', \"{}\"{}'{}' ",
250 safe_field, self.jsonb_column, operator, safe_jsonb_key
251 )
252 })
253 .collect();
254
255 Ok(format!("jsonb_build_object({})", field_pairs.join(",")))
256 }
257
258 pub fn generate_select_clause(&self, table_alias: &str, fields: &[String]) -> Result<String> {
284 let projection = self.generate_projection_sql(fields)?;
285 Ok(format!(
286 "SELECT {} as \"{}\" FROM \"{}\" ",
287 projection, self.jsonb_column, table_alias
288 ))
289 }
290
291 fn escape_sql_string(s: &str) -> String {
297 s.replace('\'', "''")
298 }
299
300 #[allow(dead_code)] fn escape_identifier(field: &str) -> String {
308 format!("\"{}\"", field.replace('"', "\"\""))
309 }
310}
311
312impl Default for PostgresProjectionGenerator {
313 fn default() -> Self {
314 Self::new()
315 }
316}
317
318pub struct MySqlProjectionGenerator {
334 json_column: String,
335}
336
337impl MySqlProjectionGenerator {
338 #[must_use]
342 pub fn new() -> Self {
343 Self::with_column("data")
344 }
345
346 #[must_use]
352 pub fn with_column(json_column: &str) -> Self {
353 Self {
354 json_column: json_column.to_string(),
355 }
356 }
357
358 pub fn generate_projection_sql(&self, fields: &[String]) -> Result<String> {
375 if fields.is_empty() {
376 return Ok(format!("`{}`", self.json_column));
377 }
378
379 for field in fields {
381 validate_field_name(field)?;
382 }
383
384 let field_pairs: Vec<String> = fields
385 .iter()
386 .map(|field| {
387 let safe_field = Self::escape_sql_string(field);
389 let json_key = to_snake_case(field);
391 format!("'{}', JSON_EXTRACT(`{}`, '$.{}')", safe_field, self.json_column, json_key)
392 })
393 .collect();
394
395 Ok(format!("JSON_OBJECT({})", field_pairs.join(",")))
396 }
397
398 fn escape_sql_string(s: &str) -> String {
400 s.replace('\'', "''")
401 }
402
403 #[allow(dead_code)] fn escape_identifier(field: &str) -> String {
409 format!("`{}`", field.replace('`', "``"))
410 }
411}
412
413impl Default for MySqlProjectionGenerator {
414 fn default() -> Self {
415 Self::new()
416 }
417}
418
419pub struct SqliteProjectionGenerator {
435 json_column: String,
436}
437
438impl SqliteProjectionGenerator {
439 #[must_use]
443 pub fn new() -> Self {
444 Self::with_column("data")
445 }
446
447 #[must_use]
453 pub fn with_column(json_column: &str) -> Self {
454 Self {
455 json_column: json_column.to_string(),
456 }
457 }
458
459 pub fn generate_projection_sql(&self, fields: &[String]) -> Result<String> {
475 if fields.is_empty() {
476 return Ok(format!("\"{}\"", self.json_column));
477 }
478
479 for field in fields {
481 validate_field_name(field)?;
482 }
483
484 let field_pairs: Vec<String> = fields
485 .iter()
486 .map(|field| {
487 let safe_field = Self::escape_sql_string(field);
489 let json_key = to_snake_case(field);
491 format!(
492 "'{}', json_extract(\"{}\", '$.{}')",
493 safe_field, self.json_column, json_key
494 )
495 })
496 .collect();
497
498 Ok(format!("json_object({})", field_pairs.join(",")))
499 }
500
501 fn escape_sql_string(s: &str) -> String {
503 s.replace('\'', "''")
504 }
505
506 #[allow(dead_code)] fn escape_identifier(field: &str) -> String {
514 format!("\"{}\"", field.replace('"', "\"\""))
515 }
516}
517
518impl Default for SqliteProjectionGenerator {
519 fn default() -> Self {
520 Self::new()
521 }
522}
523
524#[cfg(test)]
525#[allow(clippy::unwrap_used)] mod tests {
527 use super::*;
528
529 #[test]
530 fn test_postgres_projection_single_field() {
531 let generator = PostgresProjectionGenerator::new();
532 let fields = vec!["id".to_string()];
533
534 let sql = generator.generate_projection_sql(&fields).unwrap();
535 assert_eq!(sql, "jsonb_build_object('id', \"data\"->>'id' )");
536 }
537
538 #[test]
539 fn test_postgres_projection_multiple_fields() {
540 let generator = PostgresProjectionGenerator::new();
541 let fields = vec!["id".to_string(), "name".to_string(), "email".to_string()];
542
543 let sql = generator.generate_projection_sql(&fields).unwrap();
544 assert!(sql.contains("jsonb_build_object("));
545 assert!(sql.contains("'id', \"data\"->>'id'"));
546 assert!(sql.contains("'name', \"data\"->>'name'"));
547 assert!(sql.contains("'email', \"data\"->>'email'"));
548 }
549
550 #[test]
551 fn test_postgres_projection_empty_fields() {
552 let generator = PostgresProjectionGenerator::new();
553 let fields: Vec<String> = vec![];
554
555 let sql = generator.generate_projection_sql(&fields).unwrap();
556 assert_eq!(sql, "\"data\"");
558 }
559
560 #[test]
561 fn test_postgres_projection_custom_column() {
562 let generator = PostgresProjectionGenerator::with_column("metadata");
563 let fields = vec!["id".to_string()];
564
565 let sql = generator.generate_projection_sql(&fields).unwrap();
566 assert_eq!(sql, "jsonb_build_object('id', \"metadata\"->>'id' )");
567 }
568
569 #[test]
570 fn test_postgres_select_clause() {
571 let generator = PostgresProjectionGenerator::new();
572 let fields = vec!["id".to_string(), "name".to_string()];
573
574 let sql = generator.generate_select_clause("users", &fields).unwrap();
575 assert!(sql.starts_with("SELECT jsonb_build_object("));
576 assert!(sql.contains("as \"data\""));
577 assert!(sql.contains("FROM \"users\""));
578 }
579
580 #[test]
581 fn test_escape_identifier_quoting() {
582 assert_eq!(PostgresProjectionGenerator::escape_identifier("id"), "\"id\"");
584 assert_eq!(PostgresProjectionGenerator::escape_identifier("user_id"), "\"user_id\"");
585 assert_eq!(PostgresProjectionGenerator::escape_identifier("field-name"), "\"field-name\"");
587 assert_eq!(PostgresProjectionGenerator::escape_identifier("field.name"), "\"field.name\"");
588 assert_eq!(
590 PostgresProjectionGenerator::escape_identifier("col\"inject"),
591 "\"col\"\"inject\""
592 );
593 }
594
595 #[test]
597 fn test_mysql_projection_single_field() {
598 let generator = MySqlProjectionGenerator::new();
599 let fields = vec!["id".to_string()];
600
601 let sql = generator.generate_projection_sql(&fields).unwrap();
602 assert_eq!(sql, "JSON_OBJECT('id', JSON_EXTRACT(`data`, '$.id'))");
603 }
604
605 #[test]
606 fn test_mysql_projection_multiple_fields() {
607 let generator = MySqlProjectionGenerator::new();
608 let fields = vec!["id".to_string(), "name".to_string(), "email".to_string()];
609
610 let sql = generator.generate_projection_sql(&fields).unwrap();
611 assert!(sql.contains("JSON_OBJECT("));
612 assert!(sql.contains("'id', JSON_EXTRACT(`data`, '$.id')"));
613 assert!(sql.contains("'name', JSON_EXTRACT(`data`, '$.name')"));
614 assert!(sql.contains("'email', JSON_EXTRACT(`data`, '$.email')"));
615 }
616
617 #[test]
618 fn test_mysql_projection_empty_fields() {
619 let generator = MySqlProjectionGenerator::new();
620 let fields: Vec<String> = vec![];
621
622 let sql = generator.generate_projection_sql(&fields).unwrap();
623 assert_eq!(sql, "`data`");
624 }
625
626 #[test]
627 fn test_mysql_projection_custom_column() {
628 let generator = MySqlProjectionGenerator::with_column("metadata");
629 let fields = vec!["id".to_string()];
630
631 let sql = generator.generate_projection_sql(&fields).unwrap();
632 assert_eq!(sql, "JSON_OBJECT('id', JSON_EXTRACT(`metadata`, '$.id'))");
633 }
634
635 #[test]
637 fn test_sqlite_projection_single_field() {
638 let generator = SqliteProjectionGenerator::new();
639 let fields = vec!["id".to_string()];
640
641 let sql = generator.generate_projection_sql(&fields).unwrap();
642 assert_eq!(sql, "json_object('id', json_extract(\"data\", '$.id'))");
643 }
644
645 #[test]
646 fn test_sqlite_projection_multiple_fields() {
647 let generator = SqliteProjectionGenerator::new();
648 let fields = vec!["id".to_string(), "name".to_string(), "email".to_string()];
649
650 let sql = generator.generate_projection_sql(&fields).unwrap();
651 assert!(sql.contains("json_object("));
652 assert!(sql.contains("'id', json_extract(\"data\", '$.id')"));
653 assert!(sql.contains("'name', json_extract(\"data\", '$.name')"));
654 assert!(sql.contains("'email', json_extract(\"data\", '$.email')"));
655 }
656
657 #[test]
658 fn test_sqlite_projection_empty_fields() {
659 let generator = SqliteProjectionGenerator::new();
660 let fields: Vec<String> = vec![];
661
662 let sql = generator.generate_projection_sql(&fields).unwrap();
663 assert_eq!(sql, "\"data\"");
664 }
665
666 #[test]
667 fn test_sqlite_projection_custom_column() {
668 let generator = SqliteProjectionGenerator::with_column("metadata");
669 let fields = vec!["id".to_string()];
670
671 let sql = generator.generate_projection_sql(&fields).unwrap();
672 assert_eq!(sql, "json_object('id', json_extract(\"metadata\", '$.id'))");
673 }
674
675 #[test]
680 fn test_to_snake_case_conversion() {
681 assert_eq!(super::to_snake_case("id"), "id");
683 assert_eq!(super::to_snake_case("firstName"), "first_name");
684 assert_eq!(super::to_snake_case("createdAt"), "created_at");
685 assert_eq!(super::to_snake_case("userId"), "user_id");
686 assert_eq!(super::to_snake_case("updatedAtTimestamp"), "updated_at_timestamp");
687 }
688
689 #[test]
690 fn test_postgres_projection_with_field_mapping_snake_case() {
691 let generator = PostgresProjectionGenerator::new();
697
698 let graphql_fields = vec![
701 "id".to_string(),
702 "firstName".to_string(),
703 "createdAt".to_string(),
704 ];
705
706 let sql = generator.generate_projection_sql(&graphql_fields).unwrap();
707
708 eprintln!("Generated SQL: {}", sql);
709
710 assert!(
722 !sql.contains("->>'firstName'") && !sql.contains("->>'createdAt'"),
723 "Regression: SQL is using camelCase keys for JSONB access. \
724 JSONB has snake_case keys ('first_name', 'created_at'). SQL: {}",
725 sql
726 );
727 }
728
729 #[test]
734 fn test_postgres_projection_sql_injection_in_field_name() {
735 let generator = PostgresProjectionGenerator::new();
738 let fields = vec!["user'name".to_string()];
739 let result = generator.generate_projection_sql(&fields);
740 assert!(result.is_err(), "Field name with single quote must be rejected");
741 }
742
743 #[test]
744 fn test_postgres_projection_rejects_field_with_semicolon() {
745 let generator = PostgresProjectionGenerator::new();
746 let fields = vec!["id; DROP TABLE users--".to_string()];
747 let result = generator.generate_projection_sql(&fields);
748 assert!(result.is_err(), "Field name with SQL injection characters must be rejected");
749 }
750
751 #[test]
752 fn test_mysql_projection_rejects_unsafe_field_name() {
753 let generator = MySqlProjectionGenerator::new();
754 let fields = vec!["field`hack".to_string()];
755 let result = generator.generate_projection_sql(&fields);
756 assert!(result.is_err(), "Field name with backtick must be rejected");
757 }
758
759 #[test]
760 fn test_sqlite_projection_rejects_unsafe_field_name() {
761 let generator = SqliteProjectionGenerator::new();
762 let fields = vec!["field\"inject".to_string()];
763 let result = generator.generate_projection_sql(&fields);
764 assert!(result.is_err(), "Field name with double-quote must be rejected");
765 }
766
767 #[test]
768 fn test_validate_field_name_accepts_valid_names() {
769 assert!(super::validate_field_name("id").is_ok());
770 assert!(super::validate_field_name("user_id").is_ok());
771 assert!(super::validate_field_name("firstName").is_ok());
772 assert!(super::validate_field_name("createdAt").is_ok());
773 assert!(super::validate_field_name("field123").is_ok());
774 assert!(super::validate_field_name("_private").is_ok());
775 }
776
777 #[test]
778 fn test_validate_field_name_rejects_unsafe_chars() {
779 assert!(super::validate_field_name("user'name").is_err());
780 assert!(super::validate_field_name("field-name").is_err());
781 assert!(super::validate_field_name("field.name").is_err());
782 assert!(super::validate_field_name("field;inject").is_err());
783 assert!(super::validate_field_name("field\"inject").is_err());
784 assert!(super::validate_field_name("field`hack").is_err());
785 }
786
787 #[test]
788 fn test_mysql_projection_sql_contains_json_object() {
789 let generator = MySqlProjectionGenerator::new();
790 let fields = vec!["email".to_string(), "name".to_string()];
791 let sql = generator.generate_projection_sql(&fields).unwrap();
792 assert!(sql.starts_with("JSON_OBJECT("), "MySQL projection must start with JSON_OBJECT");
793 }
794
795 #[test]
796 fn test_sqlite_projection_custom_column_appears_in_sql() {
797 let generator = SqliteProjectionGenerator::with_column("payload");
798 let fields = vec!["id".to_string()];
799 let sql = generator.generate_projection_sql(&fields).unwrap();
800 assert!(sql.contains("\"payload\""), "Custom column name must appear in SQLite SQL");
801 }
802
803 #[test]
804 fn test_postgres_projection_camel_to_snake_in_jsonb_key() {
805 let generator = PostgresProjectionGenerator::new();
806 let fields = vec!["updatedAt".to_string()];
807 let sql = generator.generate_projection_sql(&fields).unwrap();
808 assert!(
810 sql.contains("'updated_at'"),
811 "updatedAt must be mapped to updated_at for JSONB key"
812 );
813 assert!(sql.contains("'updatedAt'"), "Response key must remain camelCase");
815 }
816
817 #[test]
818 fn test_postgres_select_clause_contains_from() {
819 let generator = PostgresProjectionGenerator::new();
820 let fields = vec!["id".to_string()];
821 let sql = generator.generate_select_clause("orders", &fields).unwrap();
822 assert!(
823 sql.contains("FROM \"orders\""),
824 "SELECT clause must include FROM clause with table name"
825 );
826 assert!(sql.contains("SELECT"), "SELECT clause must start with SELECT");
827 }
828
829 #[test]
832 fn test_typed_projection_empty_fields_returns_data_column() {
833 let generator = PostgresProjectionGenerator::new();
834 let result = generator.generate_typed_projection_sql(&[]).unwrap();
835 assert_eq!(result, "\"data\"");
836 }
837
838 #[test]
839 fn test_typed_projection_scalar_field_uses_text_extraction() {
840 let generator = PostgresProjectionGenerator::new();
841 let fields = vec![ProjectionField {
842 name: "name".to_string(),
843 is_composite: false,
844 }];
845 let sql = generator.generate_typed_projection_sql(&fields).unwrap();
846 assert!(sql.contains("->>'name'"), "scalar field must use ->> operator, got: {sql}");
848 assert!(!sql.contains("->'name'"), "scalar field must NOT use -> operator, got: {sql}");
849 }
850
851 #[test]
852 fn test_typed_projection_composite_field_uses_jsonb_extraction() {
853 let generator = PostgresProjectionGenerator::new();
854 let fields = vec![ProjectionField {
855 name: "address".to_string(),
856 is_composite: true,
857 }];
858 let sql = generator.generate_typed_projection_sql(&fields).unwrap();
859 assert!(sql.contains("->'address'"), "composite field must use -> operator, got: {sql}");
861 }
862
863 #[test]
864 fn test_typed_projection_mixed_scalar_and_composite() {
865 let generator = PostgresProjectionGenerator::new();
866 let fields = vec![
867 ProjectionField {
868 name: "id".to_string(),
869 is_composite: false,
870 },
871 ProjectionField {
872 name: "address".to_string(),
873 is_composite: true,
874 },
875 ProjectionField {
876 name: "tags".to_string(),
877 is_composite: true,
878 },
879 ProjectionField {
880 name: "email".to_string(),
881 is_composite: false,
882 },
883 ];
884 let sql = generator.generate_typed_projection_sql(&fields).unwrap();
885
886 assert!(sql.contains("->>'id'"), "id (scalar) must use ->>, got: {sql}");
888 assert!(sql.contains("->>'email'"), "email (scalar) must use ->>, got: {sql}");
889
890 assert!(sql.contains("->'address'"), "address (composite) must use ->, got: {sql}");
892 assert!(sql.contains("->'tags'"), "tags (composite) must use ->, got: {sql}");
893
894 assert!(
896 sql.starts_with("jsonb_build_object("),
897 "must wrap in jsonb_build_object, got: {sql}"
898 );
899 }
900
901 #[test]
902 fn test_typed_projection_camel_case_maps_to_snake_case_jsonb_key() {
903 let generator = PostgresProjectionGenerator::new();
904 let fields = vec![ProjectionField {
905 name: "firstName".to_string(),
906 is_composite: false,
907 }];
908 let sql = generator.generate_typed_projection_sql(&fields).unwrap();
909 assert!(
911 sql.contains("'firstName'"),
912 "response key must be camelCase 'firstName', got: {sql}"
913 );
914 assert!(
915 sql.contains("->>'first_name'"),
916 "JSONB key must be snake_case 'first_name', got: {sql}"
917 );
918 }
919
920 #[test]
921 fn test_typed_projection_single_quote_in_field_name_escaped() {
922 let generator = PostgresProjectionGenerator::new();
923 let fields = vec![ProjectionField {
924 name: "it's".to_string(),
925 is_composite: false,
926 }];
927 let sql = generator.generate_typed_projection_sql(&fields).unwrap();
928 assert!(
930 sql.contains("'it''s'"),
931 "single quote in field name must be escaped, got: {sql}"
932 );
933 }
934}