1use fraiseql_error::{FraiseQLError, Result};
31
32#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct ProjectionField {
43 pub name: String,
45
46 pub is_composite: bool,
49
50 pub sub_fields: Option<Vec<ProjectionField>>,
58}
59
60impl ProjectionField {
61 #[must_use]
63 pub fn scalar(name: impl Into<String>) -> Self {
64 Self {
65 name: name.into(),
66 is_composite: false,
67 sub_fields: None,
68 }
69 }
70
71 #[must_use]
73 pub fn composite(name: impl Into<String>) -> Self {
74 Self {
75 name: name.into(),
76 is_composite: true,
77 sub_fields: None,
78 }
79 }
80
81 #[must_use]
86 pub fn composite_with_sub_fields(name: impl Into<String>, sub_fields: Vec<Self>) -> Self {
87 Self {
88 name: name.into(),
89 is_composite: true,
90 sub_fields: Some(sub_fields),
91 }
92 }
93}
94
95impl From<String> for ProjectionField {
96 fn from(name: String) -> Self {
97 Self::scalar(name)
98 }
99}
100
101fn validate_field_name(field: &str) -> Result<()> {
114 if field.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
115 Ok(())
116 } else {
117 Err(FraiseQLError::Validation {
118 message: format!(
119 "field name '{}' contains characters that cannot be safely projected; \
120 only ASCII alphanumeric characters and underscores are allowed",
121 field
122 ),
123 path: None,
124 })
125 }
126}
127
128fn to_snake_case(name: &str) -> String {
141 let mut result = String::new();
142 for (i, ch) in name.chars().enumerate() {
143 if ch.is_uppercase() && i > 0 {
144 result.push('_');
145 result.push(
146 ch.to_lowercase()
147 .next()
148 .expect("char::to_lowercase always yields at least one char"),
150 );
151 } else {
152 result.push(ch);
153 }
154 }
155 result
156}
157
158const MAX_PROJECTION_DEPTH: usize = 4;
164
165pub struct PostgresProjectionGenerator {
170 jsonb_column: String,
172}
173
174impl PostgresProjectionGenerator {
175 #[must_use]
179 pub fn new() -> Self {
180 Self::with_column("data")
181 }
182
183 #[must_use]
189 pub fn with_column(jsonb_column: &str) -> Self {
190 Self {
191 jsonb_column: jsonb_column.to_string(),
192 }
193 }
194
195 pub fn generate_projection_sql(&self, fields: &[String]) -> Result<String> {
230 if fields.is_empty() {
231 return Ok(format!("\"{}\"", self.jsonb_column));
233 }
234
235 for field in fields {
237 validate_field_name(field)?;
238 }
239
240 let field_pairs: Vec<String> = fields
242 .iter()
243 .map(|field| {
244 let safe_field = Self::escape_sql_string(field);
247 let jsonb_key = to_snake_case(field);
249 let safe_jsonb_key = Self::escape_sql_string(&jsonb_key);
250 format!("'{}', \"{}\"->>'{}' ", safe_field, self.jsonb_column, safe_jsonb_key)
251 })
252 .collect();
253
254 Ok(format!("jsonb_build_object({})", field_pairs.join(",")))
256 }
257
258 pub fn generate_typed_projection_sql(&self, fields: &[ProjectionField]) -> Result<String> {
278 if fields.is_empty() {
279 return Ok(format!("\"{}\"", self.jsonb_column));
280 }
281
282 let path = format!("\"{}\"", self.jsonb_column);
283 let field_pairs = fields
284 .iter()
285 .map(|field| Self::render_field(field, &path, 0))
286 .collect::<Result<Vec<_>>>()?;
287
288 Ok(format!("jsonb_build_object({})", field_pairs.join(",")))
289 }
290
291 fn render_field(field: &ProjectionField, path: &str, depth: usize) -> Result<String> {
299 let resp_key = Self::escape_sql_string(&field.name);
300 let jsonb_key = to_snake_case(&field.name);
301 let safe_jsonb_key = Self::escape_sql_string(&jsonb_key);
302
303 if depth < MAX_PROJECTION_DEPTH {
305 if let Some(subs) = &field.sub_fields {
306 if !subs.is_empty() {
307 let nested_path = format!("{}->'{}'" , path, safe_jsonb_key);
308 let inner = subs
309 .iter()
310 .map(|sf| Self::render_field(sf, &nested_path, depth + 1))
311 .collect::<Result<Vec<_>>>()?;
312 return Ok(format!("'{}', jsonb_build_object({})", resp_key, inner.join(",")));
313 }
314 }
315 }
316
317 let op = if field.is_composite { "->" } else { "->>" };
319 Ok(format!("'{}', {}{}'{}'", resp_key, path, op, safe_jsonb_key))
320 }
321
322 pub fn generate_select_clause(&self, table_alias: &str, fields: &[String]) -> Result<String> {
348 let projection = self.generate_projection_sql(fields)?;
349 Ok(format!(
350 "SELECT {} as \"{}\" FROM \"{}\" ",
351 projection, self.jsonb_column, table_alias
352 ))
353 }
354
355 fn escape_sql_string(s: &str) -> String {
361 s.replace('\'', "''")
362 }
363
364 #[allow(dead_code)] fn escape_identifier(field: &str) -> String {
372 format!("\"{}\"", field.replace('"', "\"\""))
373 }
374}
375
376impl Default for PostgresProjectionGenerator {
377 fn default() -> Self {
378 Self::new()
379 }
380}
381
382pub struct MySqlProjectionGenerator {
398 json_column: String,
399}
400
401impl MySqlProjectionGenerator {
402 #[must_use]
406 pub fn new() -> Self {
407 Self::with_column("data")
408 }
409
410 #[must_use]
416 pub fn with_column(json_column: &str) -> Self {
417 Self {
418 json_column: json_column.to_string(),
419 }
420 }
421
422 pub fn generate_projection_sql(&self, fields: &[String]) -> Result<String> {
439 if fields.is_empty() {
440 return Ok(format!("`{}`", self.json_column));
441 }
442
443 for field in fields {
445 validate_field_name(field)?;
446 }
447
448 let field_pairs: Vec<String> = fields
449 .iter()
450 .map(|field| {
451 let safe_field = Self::escape_sql_string(field);
453 let json_key = to_snake_case(field);
455 format!("'{}', JSON_EXTRACT(`{}`, '$.{}')", safe_field, self.json_column, json_key)
456 })
457 .collect();
458
459 Ok(format!("JSON_OBJECT({})", field_pairs.join(",")))
460 }
461
462 fn escape_sql_string(s: &str) -> String {
464 s.replace('\'', "''")
465 }
466
467 #[allow(dead_code)] fn escape_identifier(field: &str) -> String {
473 format!("`{}`", field.replace('`', "``"))
474 }
475}
476
477impl Default for MySqlProjectionGenerator {
478 fn default() -> Self {
479 Self::new()
480 }
481}
482
483pub struct SqliteProjectionGenerator {
499 json_column: String,
500}
501
502impl SqliteProjectionGenerator {
503 #[must_use]
507 pub fn new() -> Self {
508 Self::with_column("data")
509 }
510
511 #[must_use]
517 pub fn with_column(json_column: &str) -> Self {
518 Self {
519 json_column: json_column.to_string(),
520 }
521 }
522
523 pub fn generate_projection_sql(&self, fields: &[String]) -> Result<String> {
539 if fields.is_empty() {
540 return Ok(format!("\"{}\"", self.json_column));
541 }
542
543 for field in fields {
545 validate_field_name(field)?;
546 }
547
548 let field_pairs: Vec<String> = fields
549 .iter()
550 .map(|field| {
551 let safe_field = Self::escape_sql_string(field);
553 let json_key = to_snake_case(field);
555 format!(
556 "'{}', json_extract(\"{}\", '$.{}')",
557 safe_field, self.json_column, json_key
558 )
559 })
560 .collect();
561
562 Ok(format!("json_object({})", field_pairs.join(",")))
563 }
564
565 fn escape_sql_string(s: &str) -> String {
567 s.replace('\'', "''")
568 }
569
570 #[allow(dead_code)] fn escape_identifier(field: &str) -> String {
578 format!("\"{}\"", field.replace('"', "\"\""))
579 }
580}
581
582impl Default for SqliteProjectionGenerator {
583 fn default() -> Self {
584 Self::new()
585 }
586}
587
588#[cfg(test)]
589#[allow(clippy::unwrap_used)] mod tests {
591 use super::*;
592
593 #[test]
594 fn test_postgres_projection_single_field() {
595 let generator = PostgresProjectionGenerator::new();
596 let fields = vec!["id".to_string()];
597
598 let sql = generator.generate_projection_sql(&fields).unwrap();
599 assert_eq!(sql, "jsonb_build_object('id', \"data\"->>'id' )");
600 }
601
602 #[test]
603 fn test_postgres_projection_multiple_fields() {
604 let generator = PostgresProjectionGenerator::new();
605 let fields = vec!["id".to_string(), "name".to_string(), "email".to_string()];
606
607 let sql = generator.generate_projection_sql(&fields).unwrap();
608 assert!(sql.contains("jsonb_build_object("));
609 assert!(sql.contains("'id', \"data\"->>'id'"));
610 assert!(sql.contains("'name', \"data\"->>'name'"));
611 assert!(sql.contains("'email', \"data\"->>'email'"));
612 }
613
614 #[test]
615 fn test_postgres_projection_empty_fields() {
616 let generator = PostgresProjectionGenerator::new();
617 let fields: Vec<String> = vec![];
618
619 let sql = generator.generate_projection_sql(&fields).unwrap();
620 assert_eq!(sql, "\"data\"");
622 }
623
624 #[test]
625 fn test_postgres_projection_custom_column() {
626 let generator = PostgresProjectionGenerator::with_column("metadata");
627 let fields = vec!["id".to_string()];
628
629 let sql = generator.generate_projection_sql(&fields).unwrap();
630 assert_eq!(sql, "jsonb_build_object('id', \"metadata\"->>'id' )");
631 }
632
633 #[test]
634 fn test_postgres_select_clause() {
635 let generator = PostgresProjectionGenerator::new();
636 let fields = vec!["id".to_string(), "name".to_string()];
637
638 let sql = generator.generate_select_clause("users", &fields).unwrap();
639 assert!(sql.starts_with("SELECT jsonb_build_object("));
640 assert!(sql.contains("as \"data\""));
641 assert!(sql.contains("FROM \"users\""));
642 }
643
644 #[test]
645 fn test_escape_identifier_quoting() {
646 assert_eq!(PostgresProjectionGenerator::escape_identifier("id"), "\"id\"");
648 assert_eq!(PostgresProjectionGenerator::escape_identifier("user_id"), "\"user_id\"");
649 assert_eq!(PostgresProjectionGenerator::escape_identifier("field-name"), "\"field-name\"");
651 assert_eq!(PostgresProjectionGenerator::escape_identifier("field.name"), "\"field.name\"");
652 assert_eq!(
654 PostgresProjectionGenerator::escape_identifier("col\"inject"),
655 "\"col\"\"inject\""
656 );
657 }
658
659 #[test]
661 fn test_mysql_projection_single_field() {
662 let generator = MySqlProjectionGenerator::new();
663 let fields = vec!["id".to_string()];
664
665 let sql = generator.generate_projection_sql(&fields).unwrap();
666 assert_eq!(sql, "JSON_OBJECT('id', JSON_EXTRACT(`data`, '$.id'))");
667 }
668
669 #[test]
670 fn test_mysql_projection_multiple_fields() {
671 let generator = MySqlProjectionGenerator::new();
672 let fields = vec!["id".to_string(), "name".to_string(), "email".to_string()];
673
674 let sql = generator.generate_projection_sql(&fields).unwrap();
675 assert!(sql.contains("JSON_OBJECT("));
676 assert!(sql.contains("'id', JSON_EXTRACT(`data`, '$.id')"));
677 assert!(sql.contains("'name', JSON_EXTRACT(`data`, '$.name')"));
678 assert!(sql.contains("'email', JSON_EXTRACT(`data`, '$.email')"));
679 }
680
681 #[test]
682 fn test_mysql_projection_empty_fields() {
683 let generator = MySqlProjectionGenerator::new();
684 let fields: Vec<String> = vec![];
685
686 let sql = generator.generate_projection_sql(&fields).unwrap();
687 assert_eq!(sql, "`data`");
688 }
689
690 #[test]
691 fn test_mysql_projection_custom_column() {
692 let generator = MySqlProjectionGenerator::with_column("metadata");
693 let fields = vec!["id".to_string()];
694
695 let sql = generator.generate_projection_sql(&fields).unwrap();
696 assert_eq!(sql, "JSON_OBJECT('id', JSON_EXTRACT(`metadata`, '$.id'))");
697 }
698
699 #[test]
701 fn test_sqlite_projection_single_field() {
702 let generator = SqliteProjectionGenerator::new();
703 let fields = vec!["id".to_string()];
704
705 let sql = generator.generate_projection_sql(&fields).unwrap();
706 assert_eq!(sql, "json_object('id', json_extract(\"data\", '$.id'))");
707 }
708
709 #[test]
710 fn test_sqlite_projection_multiple_fields() {
711 let generator = SqliteProjectionGenerator::new();
712 let fields = vec!["id".to_string(), "name".to_string(), "email".to_string()];
713
714 let sql = generator.generate_projection_sql(&fields).unwrap();
715 assert!(sql.contains("json_object("));
716 assert!(sql.contains("'id', json_extract(\"data\", '$.id')"));
717 assert!(sql.contains("'name', json_extract(\"data\", '$.name')"));
718 assert!(sql.contains("'email', json_extract(\"data\", '$.email')"));
719 }
720
721 #[test]
722 fn test_sqlite_projection_empty_fields() {
723 let generator = SqliteProjectionGenerator::new();
724 let fields: Vec<String> = vec![];
725
726 let sql = generator.generate_projection_sql(&fields).unwrap();
727 assert_eq!(sql, "\"data\"");
728 }
729
730 #[test]
731 fn test_sqlite_projection_custom_column() {
732 let generator = SqliteProjectionGenerator::with_column("metadata");
733 let fields = vec!["id".to_string()];
734
735 let sql = generator.generate_projection_sql(&fields).unwrap();
736 assert_eq!(sql, "json_object('id', json_extract(\"metadata\", '$.id'))");
737 }
738
739 #[test]
744 fn test_to_snake_case_conversion() {
745 assert_eq!(super::to_snake_case("id"), "id");
747 assert_eq!(super::to_snake_case("firstName"), "first_name");
748 assert_eq!(super::to_snake_case("createdAt"), "created_at");
749 assert_eq!(super::to_snake_case("userId"), "user_id");
750 assert_eq!(super::to_snake_case("updatedAtTimestamp"), "updated_at_timestamp");
751 }
752
753 #[test]
754 fn test_postgres_projection_with_field_mapping_snake_case() {
755 let generator = PostgresProjectionGenerator::new();
761
762 let graphql_fields = vec![
765 "id".to_string(),
766 "firstName".to_string(),
767 "createdAt".to_string(),
768 ];
769
770 let sql = generator.generate_projection_sql(&graphql_fields).unwrap();
771
772 eprintln!("Generated SQL: {}", sql);
773
774 assert!(
786 !sql.contains("->>'firstName'") && !sql.contains("->>'createdAt'"),
787 "Regression: SQL is using camelCase keys for JSONB access. \
788 JSONB has snake_case keys ('first_name', 'created_at'). SQL: {}",
789 sql
790 );
791 }
792
793 #[test]
798 fn test_postgres_projection_sql_injection_in_field_name() {
799 let generator = PostgresProjectionGenerator::new();
802 let fields = vec!["user'name".to_string()];
803 let result = generator.generate_projection_sql(&fields);
804 assert!(result.is_err(), "Field name with single quote must be rejected");
805 }
806
807 #[test]
808 fn test_postgres_projection_rejects_field_with_semicolon() {
809 let generator = PostgresProjectionGenerator::new();
810 let fields = vec!["id; DROP TABLE users--".to_string()];
811 let result = generator.generate_projection_sql(&fields);
812 assert!(result.is_err(), "Field name with SQL injection characters must be rejected");
813 }
814
815 #[test]
816 fn test_mysql_projection_rejects_unsafe_field_name() {
817 let generator = MySqlProjectionGenerator::new();
818 let fields = vec!["field`hack".to_string()];
819 let result = generator.generate_projection_sql(&fields);
820 assert!(result.is_err(), "Field name with backtick must be rejected");
821 }
822
823 #[test]
824 fn test_sqlite_projection_rejects_unsafe_field_name() {
825 let generator = SqliteProjectionGenerator::new();
826 let fields = vec!["field\"inject".to_string()];
827 let result = generator.generate_projection_sql(&fields);
828 assert!(result.is_err(), "Field name with double-quote must be rejected");
829 }
830
831 #[test]
832 fn test_validate_field_name_accepts_valid_names() {
833 assert!(super::validate_field_name("id").is_ok());
834 assert!(super::validate_field_name("user_id").is_ok());
835 assert!(super::validate_field_name("firstName").is_ok());
836 assert!(super::validate_field_name("createdAt").is_ok());
837 assert!(super::validate_field_name("field123").is_ok());
838 assert!(super::validate_field_name("_private").is_ok());
839 }
840
841 #[test]
842 fn test_validate_field_name_rejects_unsafe_chars() {
843 assert!(super::validate_field_name("user'name").is_err());
844 assert!(super::validate_field_name("field-name").is_err());
845 assert!(super::validate_field_name("field.name").is_err());
846 assert!(super::validate_field_name("field;inject").is_err());
847 assert!(super::validate_field_name("field\"inject").is_err());
848 assert!(super::validate_field_name("field`hack").is_err());
849 }
850
851 #[test]
852 fn test_mysql_projection_sql_contains_json_object() {
853 let generator = MySqlProjectionGenerator::new();
854 let fields = vec!["email".to_string(), "name".to_string()];
855 let sql = generator.generate_projection_sql(&fields).unwrap();
856 assert!(sql.starts_with("JSON_OBJECT("), "MySQL projection must start with JSON_OBJECT");
857 }
858
859 #[test]
860 fn test_sqlite_projection_custom_column_appears_in_sql() {
861 let generator = SqliteProjectionGenerator::with_column("payload");
862 let fields = vec!["id".to_string()];
863 let sql = generator.generate_projection_sql(&fields).unwrap();
864 assert!(sql.contains("\"payload\""), "Custom column name must appear in SQLite SQL");
865 }
866
867 #[test]
868 fn test_postgres_projection_camel_to_snake_in_jsonb_key() {
869 let generator = PostgresProjectionGenerator::new();
870 let fields = vec!["updatedAt".to_string()];
871 let sql = generator.generate_projection_sql(&fields).unwrap();
872 assert!(
874 sql.contains("'updated_at'"),
875 "updatedAt must be mapped to updated_at for JSONB key"
876 );
877 assert!(sql.contains("'updatedAt'"), "Response key must remain camelCase");
879 }
880
881 #[test]
882 fn test_postgres_select_clause_contains_from() {
883 let generator = PostgresProjectionGenerator::new();
884 let fields = vec!["id".to_string()];
885 let sql = generator.generate_select_clause("orders", &fields).unwrap();
886 assert!(
887 sql.contains("FROM \"orders\""),
888 "SELECT clause must include FROM clause with table name"
889 );
890 assert!(sql.contains("SELECT"), "SELECT clause must start with SELECT");
891 }
892
893 #[test]
896 fn test_typed_projection_empty_fields_returns_data_column() {
897 let generator = PostgresProjectionGenerator::new();
898 let result = generator.generate_typed_projection_sql(&[]).unwrap();
899 assert_eq!(result, "\"data\"");
900 }
901
902 #[test]
903 fn test_typed_projection_scalar_field_uses_text_extraction() {
904 let generator = PostgresProjectionGenerator::new();
905 let fields = vec![ProjectionField::scalar("name")];
906 let sql = generator.generate_typed_projection_sql(&fields).unwrap();
907 assert!(sql.contains("->>'name'"), "scalar field must use ->> operator, got: {sql}");
909 assert!(!sql.contains("->'name'"), "scalar field must NOT use -> operator, got: {sql}");
910 }
911
912 #[test]
913 fn test_typed_projection_composite_field_uses_jsonb_extraction() {
914 let generator = PostgresProjectionGenerator::new();
915 let fields = vec![ProjectionField::composite("address")];
916 let sql = generator.generate_typed_projection_sql(&fields).unwrap();
917 assert!(sql.contains("->'address'"), "composite field must use -> operator, got: {sql}");
919 }
920
921 #[test]
922 fn test_typed_projection_mixed_scalar_and_composite() {
923 let generator = PostgresProjectionGenerator::new();
924 let fields = vec![
925 ProjectionField::scalar("id"),
926 ProjectionField::composite("address"),
927 ProjectionField::composite("tags"),
928 ProjectionField::scalar("email"),
929 ];
930 let sql = generator.generate_typed_projection_sql(&fields).unwrap();
931
932 assert!(sql.contains("->>'id'"), "id (scalar) must use ->>, got: {sql}");
934 assert!(sql.contains("->>'email'"), "email (scalar) must use ->>, got: {sql}");
935
936 assert!(sql.contains("->'address'"), "address (composite) must use ->, got: {sql}");
938 assert!(sql.contains("->'tags'"), "tags (composite) must use ->, got: {sql}");
939
940 assert!(
942 sql.starts_with("jsonb_build_object("),
943 "must wrap in jsonb_build_object, got: {sql}"
944 );
945 }
946
947 #[test]
948 fn test_typed_projection_camel_case_maps_to_snake_case_jsonb_key() {
949 let generator = PostgresProjectionGenerator::new();
950 let fields = vec![ProjectionField::scalar("firstName")];
951 let sql = generator.generate_typed_projection_sql(&fields).unwrap();
952 assert!(
954 sql.contains("'firstName'"),
955 "response key must be camelCase 'firstName', got: {sql}"
956 );
957 assert!(
958 sql.contains("->>'first_name'"),
959 "JSONB key must be snake_case 'first_name', got: {sql}"
960 );
961 }
962
963 #[test]
964 fn test_typed_projection_single_quote_in_field_name_escaped() {
965 let generator = PostgresProjectionGenerator::new();
966 let fields = vec![ProjectionField::scalar("it's")];
967 let sql = generator.generate_typed_projection_sql(&fields).unwrap();
968 assert!(
970 sql.contains("'it''s'"),
971 "single quote in field name must be escaped, got: {sql}"
972 );
973 }
974
975 #[test]
978 fn test_typed_projection_nested_sub_fields_generate_nested_jsonb_build_object() {
979 let generator = PostgresProjectionGenerator::new();
980 let fields = vec![
982 ProjectionField::scalar("id"),
983 ProjectionField::scalar("content"),
984 ProjectionField::composite_with_sub_fields("author", vec![
985 ProjectionField::scalar("id"),
986 ProjectionField::scalar("username"),
987 ProjectionField::scalar("fullName"),
988 ]),
989 ];
990 let sql = generator.generate_typed_projection_sql(&fields).unwrap();
991 assert!(
993 sql.contains("'author', jsonb_build_object("),
994 "author must produce nested jsonb_build_object, got: {sql}"
995 );
996 assert!(
998 sql.contains("'author'->>'id'"),
999 "nested 'id' must use path \"data\"->'author'->>'id', got: {sql}"
1000 );
1001 assert!(
1002 sql.contains("'author'->>'username'"),
1003 "nested 'username' must use correct path, got: {sql}"
1004 );
1005 assert!(
1007 sql.contains("->>'full_name'"),
1008 "fullName sub-field must map to snake_case 'full_name', got: {sql}"
1009 );
1010 assert!(
1012 sql.contains("\"data\"->>'id'"),
1013 "root id must use top-level data path, got: {sql}"
1014 );
1015 }
1016
1017 #[test]
1018 fn test_typed_projection_composite_without_sub_fields_returns_full_blob() {
1019 let generator = PostgresProjectionGenerator::new();
1021 let fields = vec![ProjectionField::composite("author")];
1022 let sql = generator.generate_typed_projection_sql(&fields).unwrap();
1023 assert!(
1024 sql.contains("\"data\"->'author'"),
1025 "composite without sub_fields must return full blob, got: {sql}"
1026 );
1027 assert!(
1030 !sql.contains("'author', jsonb_build_object("),
1031 "must NOT produce nested jsonb_build_object for author when sub_fields is None, got: {sql}"
1032 );
1033 }
1034
1035 #[test]
1036 fn test_typed_projection_depth_2_recursion() {
1037 let generator = PostgresProjectionGenerator::new();
1039 let fields = vec![
1040 ProjectionField::scalar("id"),
1041 ProjectionField::composite_with_sub_fields("author", vec![
1042 ProjectionField::scalar("id"),
1043 ProjectionField::composite_with_sub_fields("profile", vec![
1044 ProjectionField::scalar("bio"),
1045 ]),
1046 ]),
1047 ];
1048 let sql = generator.generate_typed_projection_sql(&fields).unwrap();
1049 assert!(
1050 sql.contains("'author', jsonb_build_object("),
1051 "author must be nested, got: {sql}"
1052 );
1053 assert!(
1054 sql.contains("'profile', jsonb_build_object("),
1055 "profile must be nested inside author, got: {sql}"
1056 );
1057 assert!(
1058 sql.contains("'profile'->>'bio'"),
1059 "bio must use depth-2 path, got: {sql}"
1060 );
1061 }
1062}