1use fraiseql_error::{FraiseQLError, Result};
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum FieldKind {
40 Text,
42 Native,
45 Composite,
47}
48
49#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct ProjectionField {
61 pub name: String,
63
64 pub kind: FieldKind,
66
67 pub sub_fields: Option<Vec<ProjectionField>>,
75}
76
77impl ProjectionField {
78 #[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 #[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 #[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 #[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 #[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
141fn 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
170const MAX_PROJECTION_DEPTH: usize = 4;
176
177pub struct PostgresProjectionGenerator {
182 jsonb_column: String,
184}
185
186impl PostgresProjectionGenerator {
187 #[must_use]
191 pub fn new() -> Self {
192 Self::with_column("data")
193 }
194
195 #[must_use]
201 pub fn with_column(jsonb_column: &str) -> Self {
202 Self {
203 jsonb_column: jsonb_column.to_string(),
204 }
205 }
206
207 pub fn generate_projection_sql(&self, fields: &[String]) -> Result<String> {
242 if fields.is_empty() {
243 return Ok(format!("\"{}\"", self.jsonb_column));
245 }
246
247 for field in fields {
249 validate_field_name(field)?;
250 }
251
252 let field_pairs: Vec<String> = fields
254 .iter()
255 .map(|field| {
256 let safe_field = Self::escape_sql_string(field);
259 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 Ok(format!("jsonb_build_object({})", field_pairs.join(",")))
268 }
269
270 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 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 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 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 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 fn escape_sql_string(s: &str) -> String {
378 s.replace('\'', "''")
379 }
380
381 #[allow(dead_code)] 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
399pub struct MySqlProjectionGenerator {
415 json_column: String,
416}
417
418impl MySqlProjectionGenerator {
419 #[must_use]
423 pub fn new() -> Self {
424 Self::with_column("data")
425 }
426
427 #[must_use]
433 pub fn with_column(json_column: &str) -> Self {
434 Self {
435 json_column: json_column.to_string(),
436 }
437 }
438
439 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 for field in fields {
462 validate_field_name(field)?;
463 }
464
465 let field_pairs: Vec<String> = fields
466 .iter()
467 .map(|field| {
468 let safe_field = Self::escape_sql_string(field);
470 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 fn escape_sql_string(s: &str) -> String {
481 s.replace('\'', "''")
482 }
483
484 #[allow(dead_code)] 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
500pub struct SqliteProjectionGenerator {
516 json_column: String,
517}
518
519impl SqliteProjectionGenerator {
520 #[must_use]
524 pub fn new() -> Self {
525 Self::with_column("data")
526 }
527
528 #[must_use]
534 pub fn with_column(json_column: &str) -> Self {
535 Self {
536 json_column: json_column.to_string(),
537 }
538 }
539
540 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 for field in fields {
562 validate_field_name(field)?;
563 }
564
565 let field_pairs: Vec<String> = fields
566 .iter()
567 .map(|field| {
568 let safe_field = Self::escape_sql_string(field);
570 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 fn escape_sql_string(s: &str) -> String {
584 s.replace('\'', "''")
585 }
586
587 #[allow(dead_code)] 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)]
606#[allow(clippy::unwrap_used)] mod tests {
608 use super::*;
609
610 #[test]
611 fn test_postgres_projection_single_field() {
612 let generator = PostgresProjectionGenerator::new();
613 let fields = vec!["id".to_string()];
614
615 let sql = generator.generate_projection_sql(&fields).unwrap();
616 assert_eq!(sql, "jsonb_build_object('id', \"data\"->>'id' )");
617 }
618
619 #[test]
620 fn test_postgres_projection_multiple_fields() {
621 let generator = PostgresProjectionGenerator::new();
622 let fields = vec!["id".to_string(), "name".to_string(), "email".to_string()];
623
624 let sql = generator.generate_projection_sql(&fields).unwrap();
625 assert!(sql.contains("jsonb_build_object("));
626 assert!(sql.contains("'id', \"data\"->>'id'"));
627 assert!(sql.contains("'name', \"data\"->>'name'"));
628 assert!(sql.contains("'email', \"data\"->>'email'"));
629 }
630
631 #[test]
632 fn test_postgres_projection_empty_fields() {
633 let generator = PostgresProjectionGenerator::new();
634 let fields: Vec<String> = vec![];
635
636 let sql = generator.generate_projection_sql(&fields).unwrap();
637 assert_eq!(sql, "\"data\"");
639 }
640
641 #[test]
642 fn test_postgres_projection_custom_column() {
643 let generator = PostgresProjectionGenerator::with_column("metadata");
644 let fields = vec!["id".to_string()];
645
646 let sql = generator.generate_projection_sql(&fields).unwrap();
647 assert_eq!(sql, "jsonb_build_object('id', \"metadata\"->>'id' )");
648 }
649
650 #[test]
651 fn test_postgres_select_clause() {
652 let generator = PostgresProjectionGenerator::new();
653 let fields = vec!["id".to_string(), "name".to_string()];
654
655 let sql = generator.generate_select_clause("users", &fields).unwrap();
656 assert!(sql.starts_with("SELECT jsonb_build_object("));
657 assert!(sql.contains("as \"data\""));
658 assert!(sql.contains("FROM \"users\""));
659 }
660
661 #[test]
662 fn test_escape_identifier_quoting() {
663 assert_eq!(PostgresProjectionGenerator::escape_identifier("id"), "\"id\"");
665 assert_eq!(PostgresProjectionGenerator::escape_identifier("user_id"), "\"user_id\"");
666 assert_eq!(PostgresProjectionGenerator::escape_identifier("field-name"), "\"field-name\"");
668 assert_eq!(PostgresProjectionGenerator::escape_identifier("field.name"), "\"field.name\"");
669 assert_eq!(
671 PostgresProjectionGenerator::escape_identifier("col\"inject"),
672 "\"col\"\"inject\""
673 );
674 }
675
676 #[test]
678 fn test_mysql_projection_single_field() {
679 let generator = MySqlProjectionGenerator::new();
680 let fields = vec!["id".to_string()];
681
682 let sql = generator.generate_projection_sql(&fields).unwrap();
683 assert_eq!(sql, "JSON_OBJECT('id', JSON_EXTRACT(`data`, '$.id'))");
684 }
685
686 #[test]
687 fn test_mysql_projection_multiple_fields() {
688 let generator = MySqlProjectionGenerator::new();
689 let fields = vec!["id".to_string(), "name".to_string(), "email".to_string()];
690
691 let sql = generator.generate_projection_sql(&fields).unwrap();
692 assert!(sql.contains("JSON_OBJECT("));
693 assert!(sql.contains("'id', JSON_EXTRACT(`data`, '$.id')"));
694 assert!(sql.contains("'name', JSON_EXTRACT(`data`, '$.name')"));
695 assert!(sql.contains("'email', JSON_EXTRACT(`data`, '$.email')"));
696 }
697
698 #[test]
699 fn test_mysql_projection_empty_fields() {
700 let generator = MySqlProjectionGenerator::new();
701 let fields: Vec<String> = vec![];
702
703 let sql = generator.generate_projection_sql(&fields).unwrap();
704 assert_eq!(sql, "`data`");
705 }
706
707 #[test]
708 fn test_mysql_projection_custom_column() {
709 let generator = MySqlProjectionGenerator::with_column("metadata");
710 let fields = vec!["id".to_string()];
711
712 let sql = generator.generate_projection_sql(&fields).unwrap();
713 assert_eq!(sql, "JSON_OBJECT('id', JSON_EXTRACT(`metadata`, '$.id'))");
714 }
715
716 #[test]
718 fn test_sqlite_projection_single_field() {
719 let generator = SqliteProjectionGenerator::new();
720 let fields = vec!["id".to_string()];
721
722 let sql = generator.generate_projection_sql(&fields).unwrap();
723 assert_eq!(sql, "json_object('id', json_extract(\"data\", '$.id'))");
724 }
725
726 #[test]
727 fn test_sqlite_projection_multiple_fields() {
728 let generator = SqliteProjectionGenerator::new();
729 let fields = vec!["id".to_string(), "name".to_string(), "email".to_string()];
730
731 let sql = generator.generate_projection_sql(&fields).unwrap();
732 assert!(sql.contains("json_object("));
733 assert!(sql.contains("'id', json_extract(\"data\", '$.id')"));
734 assert!(sql.contains("'name', json_extract(\"data\", '$.name')"));
735 assert!(sql.contains("'email', json_extract(\"data\", '$.email')"));
736 }
737
738 #[test]
739 fn test_sqlite_projection_empty_fields() {
740 let generator = SqliteProjectionGenerator::new();
741 let fields: Vec<String> = vec![];
742
743 let sql = generator.generate_projection_sql(&fields).unwrap();
744 assert_eq!(sql, "\"data\"");
745 }
746
747 #[test]
748 fn test_sqlite_projection_custom_column() {
749 let generator = SqliteProjectionGenerator::with_column("metadata");
750 let fields = vec!["id".to_string()];
751
752 let sql = generator.generate_projection_sql(&fields).unwrap();
753 assert_eq!(sql, "json_object('id', json_extract(\"metadata\", '$.id'))");
754 }
755
756 #[test]
761 fn test_to_snake_case_conversion() {
762 assert_eq!(super::to_snake_case("id"), "id");
764 assert_eq!(super::to_snake_case("firstName"), "first_name");
765 assert_eq!(super::to_snake_case("createdAt"), "created_at");
766 assert_eq!(super::to_snake_case("userId"), "user_id");
767 assert_eq!(super::to_snake_case("updatedAtTimestamp"), "updated_at_timestamp");
768 }
769
770 #[test]
771 fn test_postgres_projection_with_field_mapping_snake_case() {
772 let generator = PostgresProjectionGenerator::new();
778
779 let graphql_fields = vec![
782 "id".to_string(),
783 "firstName".to_string(),
784 "createdAt".to_string(),
785 ];
786
787 let sql = generator.generate_projection_sql(&graphql_fields).unwrap();
788
789 eprintln!("Generated SQL: {}", sql);
790
791 assert!(
803 !sql.contains("->>'firstName'") && !sql.contains("->>'createdAt'"),
804 "Regression: SQL is using camelCase keys for JSONB access. \
805 JSONB has snake_case keys ('first_name', 'created_at'). SQL: {}",
806 sql
807 );
808 }
809
810 #[test]
815 fn test_postgres_projection_sql_injection_in_field_name() {
816 let generator = PostgresProjectionGenerator::new();
819 let fields = vec!["user'name".to_string()];
820 let result = generator.generate_projection_sql(&fields);
821 assert!(result.is_err(), "Field name with single quote must be rejected");
822 }
823
824 #[test]
825 fn test_postgres_projection_rejects_field_with_semicolon() {
826 let generator = PostgresProjectionGenerator::new();
827 let fields = vec!["id; DROP TABLE users--".to_string()];
828 let result = generator.generate_projection_sql(&fields);
829 assert!(result.is_err(), "Field name with SQL injection characters must be rejected");
830 }
831
832 #[test]
833 fn test_mysql_projection_rejects_unsafe_field_name() {
834 let generator = MySqlProjectionGenerator::new();
835 let fields = vec!["field`hack".to_string()];
836 let result = generator.generate_projection_sql(&fields);
837 assert!(result.is_err(), "Field name with backtick must be rejected");
838 }
839
840 #[test]
841 fn test_sqlite_projection_rejects_unsafe_field_name() {
842 let generator = SqliteProjectionGenerator::new();
843 let fields = vec!["field\"inject".to_string()];
844 let result = generator.generate_projection_sql(&fields);
845 assert!(result.is_err(), "Field name with double-quote must be rejected");
846 }
847
848 #[test]
849 fn test_validate_field_name_accepts_valid_names() {
850 assert!(super::validate_field_name("id").is_ok());
851 assert!(super::validate_field_name("user_id").is_ok());
852 assert!(super::validate_field_name("firstName").is_ok());
853 assert!(super::validate_field_name("createdAt").is_ok());
854 assert!(super::validate_field_name("field123").is_ok());
855 assert!(super::validate_field_name("_private").is_ok());
856 }
857
858 #[test]
859 fn test_validate_field_name_rejects_unsafe_chars() {
860 assert!(super::validate_field_name("user'name").is_err());
861 assert!(super::validate_field_name("field-name").is_err());
862 assert!(super::validate_field_name("field.name").is_err());
863 assert!(super::validate_field_name("field;inject").is_err());
864 assert!(super::validate_field_name("field\"inject").is_err());
865 assert!(super::validate_field_name("field`hack").is_err());
866 }
867
868 #[test]
869 fn test_mysql_projection_sql_contains_json_object() {
870 let generator = MySqlProjectionGenerator::new();
871 let fields = vec!["email".to_string(), "name".to_string()];
872 let sql = generator.generate_projection_sql(&fields).unwrap();
873 assert!(sql.starts_with("JSON_OBJECT("), "MySQL projection must start with JSON_OBJECT");
874 }
875
876 #[test]
877 fn test_sqlite_projection_custom_column_appears_in_sql() {
878 let generator = SqliteProjectionGenerator::with_column("payload");
879 let fields = vec!["id".to_string()];
880 let sql = generator.generate_projection_sql(&fields).unwrap();
881 assert!(sql.contains("\"payload\""), "Custom column name must appear in SQLite SQL");
882 }
883
884 #[test]
885 fn test_postgres_projection_camel_to_snake_in_jsonb_key() {
886 let generator = PostgresProjectionGenerator::new();
887 let fields = vec!["updatedAt".to_string()];
888 let sql = generator.generate_projection_sql(&fields).unwrap();
889 assert!(
891 sql.contains("'updated_at'"),
892 "updatedAt must be mapped to updated_at for JSONB key"
893 );
894 assert!(sql.contains("'updatedAt'"), "Response key must remain camelCase");
896 }
897
898 #[test]
899 fn test_postgres_select_clause_contains_from() {
900 let generator = PostgresProjectionGenerator::new();
901 let fields = vec!["id".to_string()];
902 let sql = generator.generate_select_clause("orders", &fields).unwrap();
903 assert!(
904 sql.contains("FROM \"orders\""),
905 "SELECT clause must include FROM clause with table name"
906 );
907 assert!(sql.contains("SELECT"), "SELECT clause must start with SELECT");
908 }
909
910 #[test]
913 fn test_typed_projection_empty_fields_returns_data_column() {
914 let generator = PostgresProjectionGenerator::new();
915 let result = generator.generate_typed_projection_sql(&[]).unwrap();
916 assert_eq!(result, "\"data\"");
917 }
918
919 #[test]
920 fn test_typed_projection_text_field_uses_text_extraction() {
921 let generator = PostgresProjectionGenerator::new();
922 let fields = vec![ProjectionField::scalar("name")];
923 let sql = generator.generate_typed_projection_sql(&fields).unwrap();
924 assert!(sql.contains("->>'name'"), "text field must use ->> operator, got: {sql}");
926 assert!(!sql.contains("->'name'"), "text field must NOT use -> operator, got: {sql}");
927 }
928
929 #[test]
930 fn test_typed_projection_composite_field_uses_jsonb_extraction() {
931 let generator = PostgresProjectionGenerator::new();
932 let fields = vec![ProjectionField::composite("address")];
933 let sql = generator.generate_typed_projection_sql(&fields).unwrap();
934 assert!(sql.contains("->'address'"), "composite field must use -> operator, got: {sql}");
936 }
937
938 #[test]
939 fn test_typed_projection_mixed_text_native_and_composite() {
940 let generator = PostgresProjectionGenerator::new();
941 let fields = vec![
942 ProjectionField::scalar("id"),
943 ProjectionField::native("age"),
944 ProjectionField::composite("address"),
945 ProjectionField::composite("tags"),
946 ProjectionField::scalar("email"),
947 ];
948 let sql = generator.generate_typed_projection_sql(&fields).unwrap();
949
950 assert!(sql.contains("->>'id'"), "id (text) must use ->>, got: {sql}");
952 assert!(sql.contains("->>'email'"), "email (text) must use ->>, got: {sql}");
953
954 assert!(sql.contains("->'age'"), "age (native) must use ->, got: {sql}");
956
957 assert!(sql.contains("->'address'"), "address (composite) must use ->, got: {sql}");
959 assert!(sql.contains("->'tags'"), "tags (composite) must use ->, got: {sql}");
960
961 assert!(
963 sql.starts_with("jsonb_build_object("),
964 "must wrap in jsonb_build_object, got: {sql}"
965 );
966 }
967
968 #[test]
969 fn test_typed_projection_camel_case_maps_to_snake_case_jsonb_key() {
970 let generator = PostgresProjectionGenerator::new();
971 let fields = vec![ProjectionField::scalar("firstName")];
972 let sql = generator.generate_typed_projection_sql(&fields).unwrap();
973 assert!(
975 sql.contains("'firstName'"),
976 "response key must be camelCase 'firstName', got: {sql}"
977 );
978 assert!(
979 sql.contains("->>'first_name'"),
980 "JSONB key must be snake_case 'first_name', got: {sql}"
981 );
982 }
983
984 #[test]
985 fn test_typed_projection_single_quote_in_field_name_escaped() {
986 let generator = PostgresProjectionGenerator::new();
987 let fields = vec![ProjectionField::scalar("it's")];
988 let sql = generator.generate_typed_projection_sql(&fields).unwrap();
989 assert!(
991 sql.contains("'it''s'"),
992 "single quote in field name must be escaped, got: {sql}"
993 );
994 }
995
996 #[test]
999 fn test_native_field_uses_jsonb_extraction() {
1000 let generator = PostgresProjectionGenerator::new();
1001 let fields = vec![
1002 ProjectionField::native("isActive"),
1003 ProjectionField::scalar("name"),
1004 ];
1005 let sql = generator.generate_typed_projection_sql(&fields).unwrap();
1006 assert!(sql.contains("->'is_active'"), "native field must use -> operator, got: {sql}");
1008 assert!(sql.contains("->>'name'"), "text scalar field must use ->> operator, got: {sql}");
1010 }
1011
1012 #[test]
1013 fn test_native_field_mixed_with_composite() {
1014 let generator = PostgresProjectionGenerator::new();
1015 let fields = vec![
1016 ProjectionField::native("isActive"),
1017 ProjectionField::composite("address"),
1018 ProjectionField::scalar("email"),
1019 ];
1020 let sql = generator.generate_typed_projection_sql(&fields).unwrap();
1021 assert!(sql.contains("->'is_active'"), "native uses ->, got: {sql}");
1022 assert!(sql.contains("->'address'"), "composite uses ->, got: {sql}");
1023 assert!(sql.contains("->>'email'"), "text scalar uses ->>, got: {sql}");
1024 }
1025
1026 #[test]
1027 fn test_native_int_and_float_use_jsonb_extraction() {
1028 let generator = PostgresProjectionGenerator::new();
1029 let fields = vec![
1030 ProjectionField::native("age"),
1031 ProjectionField::native("price"),
1032 ProjectionField::scalar("name"),
1033 ProjectionField::scalar("id"),
1034 ];
1035 let sql = generator.generate_typed_projection_sql(&fields).unwrap();
1036 assert!(sql.contains("->'age'"), "int (native) must use ->, got: {sql}");
1038 assert!(sql.contains("->'price'"), "float (native) must use ->, got: {sql}");
1039 assert!(sql.contains("->>'name'"), "string (text) must use ->>, got: {sql}");
1041 assert!(sql.contains("->>'id'"), "id (text) must use ->>, got: {sql}");
1042 }
1043
1044 #[test]
1047 fn test_typed_projection_nested_sub_fields_generate_nested_jsonb_build_object() {
1048 let generator = PostgresProjectionGenerator::new();
1049 let fields = vec![
1051 ProjectionField::scalar("id"),
1052 ProjectionField::scalar("content"),
1053 ProjectionField::composite_with_sub_fields(
1054 "author",
1055 vec![
1056 ProjectionField::scalar("id"),
1057 ProjectionField::scalar("username"),
1058 ProjectionField::scalar("fullName"),
1059 ],
1060 ),
1061 ];
1062 let sql = generator.generate_typed_projection_sql(&fields).unwrap();
1063 assert!(
1065 sql.contains("'author', jsonb_build_object("),
1066 "author must produce nested jsonb_build_object, got: {sql}"
1067 );
1068 assert!(
1070 sql.contains("'author'->>'id'"),
1071 "nested 'id' must use path \"data\"->'author'->>'id', got: {sql}"
1072 );
1073 assert!(
1074 sql.contains("'author'->>'username'"),
1075 "nested 'username' must use correct path, got: {sql}"
1076 );
1077 assert!(
1079 sql.contains("->>'full_name'"),
1080 "fullName sub-field must map to snake_case 'full_name', got: {sql}"
1081 );
1082 assert!(
1084 sql.contains("\"data\"->>'id'"),
1085 "root id must use top-level data path, got: {sql}"
1086 );
1087 }
1088
1089 #[test]
1090 fn test_typed_projection_composite_without_sub_fields_returns_full_blob() {
1091 let generator = PostgresProjectionGenerator::new();
1093 let fields = vec![ProjectionField::composite("author")];
1094 let sql = generator.generate_typed_projection_sql(&fields).unwrap();
1095 assert!(
1096 sql.contains("\"data\"->'author'"),
1097 "composite without sub_fields must return full blob, got: {sql}"
1098 );
1099 assert!(
1102 !sql.contains("'author', jsonb_build_object("),
1103 "must NOT produce nested jsonb_build_object for author when sub_fields is None, got: {sql}"
1104 );
1105 }
1106
1107 #[test]
1108 fn test_typed_projection_depth_2_recursion() {
1109 let generator = PostgresProjectionGenerator::new();
1111 let fields = vec![
1112 ProjectionField::scalar("id"),
1113 ProjectionField::composite_with_sub_fields(
1114 "author",
1115 vec![
1116 ProjectionField::scalar("id"),
1117 ProjectionField::composite_with_sub_fields(
1118 "profile",
1119 vec![ProjectionField::scalar("bio")],
1120 ),
1121 ],
1122 ),
1123 ];
1124 let sql = generator.generate_typed_projection_sql(&fields).unwrap();
1125 assert!(
1126 sql.contains("'author', jsonb_build_object("),
1127 "author must be nested, got: {sql}"
1128 );
1129 assert!(
1130 sql.contains("'profile', jsonb_build_object("),
1131 "profile must be nested inside author, got: {sql}"
1132 );
1133 assert!(sql.contains("'profile'->>'bio'"), "bio must use depth-2 path, got: {sql}");
1134 }
1135}