diff --git a/Cargo.lock b/Cargo.lock index 6d9d5be236..acea741d84 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5074,6 +5074,7 @@ checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" name = "tailcall" version = "0.1.0" dependencies = [ + "Inflector", "anyhow", "async-graphql", "async-graphql-extension-apollo-tracing", diff --git a/Cargo.toml b/Cargo.toml index 3fecda3193..50d350a5ca 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -158,6 +158,7 @@ tokio-test = "0.4.4" base64 = "0.22.1" tailcall-hasher = { path = "tailcall-hasher" } serde_json_borrow = "0.3.0" +Inflector = "0.11.4" [dev-dependencies] tailcall-prettier = { path = "tailcall-prettier" } diff --git a/src/core/config/config.rs b/src/core/config/config.rs index 1c20acbbc1..c169ddc723 100644 --- a/src/core/config/config.rs +++ b/src/core/config/config.rs @@ -610,6 +610,19 @@ pub struct AddField { } impl Config { + pub fn is_root_operation_type(&self, type_name: &str) -> bool { + let type_name = type_name.to_lowercase(); + + [ + &self.schema.query, + &self.schema.mutation, + &self.schema.subscription, + ] + .iter() + .filter_map(|&root_name| root_name.as_ref()) + .any(|root_name| root_name.to_lowercase() == type_name) + } + pub fn port(&self) -> u16 { self.server.port.unwrap_or(8000) } @@ -886,4 +899,43 @@ mod tests { assert_eq!(actual, expected); } + + #[test] + fn test_is_root_operation_type_with_query() { + let mut config = Config::default(); + config.schema.query = Some("Query".to_string()); + + assert!(config.is_root_operation_type("Query")); + assert!(!config.is_root_operation_type("Mutation")); + assert!(!config.is_root_operation_type("Subscription")); + } + + #[test] + fn test_is_root_operation_type_with_mutation() { + let mut config = Config::default(); + config.schema.mutation = Some("Mutation".to_string()); + + assert!(!config.is_root_operation_type("Query")); + assert!(config.is_root_operation_type("Mutation")); + assert!(!config.is_root_operation_type("Subscription")); + } + + #[test] + fn test_is_root_operation_type_with_subscription() { + let mut config = Config::default(); + config.schema.subscription = Some("Subscription".to_string()); + + assert!(!config.is_root_operation_type("Query")); + assert!(!config.is_root_operation_type("Mutation")); + assert!(config.is_root_operation_type("Subscription")); + } + + #[test] + fn test_is_root_operation_type_with_no_root_operation() { + let config = Config::default(); + + assert!(!config.is_root_operation_type("Query")); + assert!(!config.is_root_operation_type("Mutation")); + assert!(!config.is_root_operation_type("Subscription")); + } } diff --git a/src/core/config/transformer/mod.rs b/src/core/config/transformer/mod.rs index 54ad6f2660..3b72cb0927 100644 --- a/src/core/config/transformer/mod.rs +++ b/src/core/config/transformer/mod.rs @@ -2,11 +2,13 @@ mod ambiguous_type; mod consolidate_url; mod remove_unused; mod type_merger; +mod type_name_generator; pub use ambiguous_type::{AmbiguousType, Resolution}; pub use consolidate_url::ConsolidateURL; pub use remove_unused::RemoveUnused; pub use type_merger::TypeMerger; +pub use type_name_generator::TypeNameGenerator; use super::Config; use crate::core::valid::{Valid, Validator}; diff --git a/src/core/config/transformer/snapshots/tailcall__core__config__transformer__type_name_generator__test__type_name_generator_transform.snap b/src/core/config/transformer/snapshots/tailcall__core__config__transformer__type_name_generator__test__type_name_generator_transform.snap new file mode 100644 index 0000000000..4b15e72bc7 --- /dev/null +++ b/src/core/config/transformer/snapshots/tailcall__core__config__transformer__type_name_generator__test__type_name_generator_transform.snap @@ -0,0 +1,41 @@ +--- +source: src/core/config/transformer/type_name_generator.rs +expression: transformed_config.to_sdl() +--- +schema @server @upstream { + query: Query +} + +type Address { + city: String + geo: Geo + street: String + suite: String + zipcode: String +} + +type Company { + bs: String + catchPhrase: String + name: String +} + +type F1 { + address: Address + company: Company + email: String + id: Int + name: String + phone: String + username: String + website: String +} + +type Geo { + lat: String + lng: String +} + +type Query { + f1: [F1] @http(baseURL: "https://jsonplaceholder.typicode.com", path: "/users") +} diff --git a/src/core/config/transformer/snapshots/tailcall__core__config__transformer__type_name_generator__test__type_name_generator_with_cyclic_types.snap b/src/core/config/transformer/snapshots/tailcall__core__config__transformer__type_name_generator__test__type_name_generator_with_cyclic_types.snap new file mode 100644 index 0000000000..ea298a7cc2 --- /dev/null +++ b/src/core/config/transformer/snapshots/tailcall__core__config__transformer__type_name_generator__test__type_name_generator_with_cyclic_types.snap @@ -0,0 +1,30 @@ +--- +source: src/core/config/transformer/type_name_generator.rs +expression: transformed_config.to_sdl() +--- +schema @server @upstream { + query: Query +} + +type Author { + id: ID! + name: String! + posts: [Post]! +} + +type Cycle { + cycle: Cycle + id: ID! +} + +type Post { + author: Author! + content: String! + cycle: Cycle + id: ID! + title: String! +} + +type Query { + f1: [Author] @http(baseURL: "https://jsonplaceholder.typicode.com", path: "/users") +} diff --git a/src/core/config/transformer/type_name_generator.rs b/src/core/config/transformer/type_name_generator.rs new file mode 100644 index 0000000000..599247d403 --- /dev/null +++ b/src/core/config/transformer/type_name_generator.rs @@ -0,0 +1,181 @@ +use std::collections::{BTreeMap, HashSet}; + +use inflector::Inflector; + +use crate::core::config::transformer::Transform; +use crate::core::config::Config; +use crate::core::valid::Valid; + +#[derive(Debug, Default)] +struct CandidateStats { + frequency: u32, + priority: u8, +} + +struct CandidateConvergence<'a> { + /// maintains the generated candidates in the form of + /// {TypeName: {{candidate_name: {frequency: 1, priority: 0}}}} + candidates: BTreeMap>, + config: &'a Config, +} + +impl<'a> CandidateConvergence<'a> { + fn new(candate_gen: CandidateGeneration<'a>) -> Self { + Self { + candidates: candate_gen.candidates, + config: candate_gen.config, + } + } + + /// Converges on the most frequent candidate name for each type. + /// This method selects the most frequent candidate name for each type, + /// ensuring uniqueness. + fn converge(self) -> BTreeMap { + let mut finalized_candidates = BTreeMap::new(); + let mut converged_candidate_set = HashSet::new(); + + for (type_name, candidate_list) in self.candidates.iter() { + // Find the most frequent candidate that hasn't been converged yet and it's not + // already present in types. + if let Some((candidate_name, _)) = candidate_list + .iter() + .filter(|(candidate_name, _)| { + !converged_candidate_set.contains(candidate_name) + && !self.config.types.contains_key(*candidate_name) + }) + .max_by_key(|&(_, candidate)| (candidate.frequency, candidate.priority)) + { + let singularized_candidate_name = candidate_name.to_singular().to_pascal_case(); + finalized_candidates.insert(type_name.to_owned(), singularized_candidate_name); + converged_candidate_set.insert(candidate_name); + } + } + + finalized_candidates + } +} + +struct CandidateGeneration<'a> { + /// maintains the generated candidates in the form of + /// {TypeName: {{candidate_name: {frequency: 1, priority: 0}}}} + candidates: BTreeMap>, + config: &'a Config, +} + +impl<'a> CandidateGeneration<'a> { + fn new(config: &'a Config) -> Self { + Self { candidates: Default::default(), config } + } + + /// Generates candidate type names based on the provided configuration. + /// This method iterates over the configuration and collects candidate type + /// names for each type. + fn generate(mut self) -> CandidateConvergence<'a> { + for (type_name, type_info) in self.config.types.iter() { + for (field_name, field_info) in type_info.fields.iter() { + if self.config.is_scalar(&field_info.type_of) { + // If field type is scalar then ignore type name inference. + continue; + } + + let inner_map = self + .candidates + .entry(field_info.type_of.to_owned()) + .or_default(); + + if let Some(key_val) = inner_map.get_mut(field_name) { + key_val.frequency += 1 + } else { + // in order to infer the types correctly, always prioritize the non-operation + // types but final selection will still depend upon the + // frequency. + let priority = match self.config.is_root_operation_type(type_name) { + true => 0, + false => 1, + }; + + inner_map.insert( + field_name.to_owned(), + CandidateStats { frequency: 1, priority }, + ); + } + } + } + CandidateConvergence::new(self) + } +} + +pub struct TypeNameGenerator; + +impl TypeNameGenerator { + /// Generates type names based on inferred candidates from the provided + /// configuration. + fn generate_type_names(&self, mut config: Config) -> Config { + let finalized_candidates = CandidateGeneration::new(&config).generate().converge(); + + for (old_type_name, new_type_name) in finalized_candidates { + if let Some(type_) = config.types.remove(old_type_name.as_str()) { + // Add newly generated type. + config.types.insert(new_type_name.to_owned(), type_); + + // Replace all the instances of old name in config. + for actual_type in config.types.values_mut() { + for actual_field in actual_type.fields.values_mut() { + if actual_field.type_of == old_type_name { + // Update the field's type with the new name + actual_field.type_of.clone_from(&new_type_name); + } + } + } + } + } + config + } +} + +impl Transform for TypeNameGenerator { + fn transform(&self, config: Config) -> Valid { + let config = self.generate_type_names(config); + + Valid::succeed(config) + } +} + +#[cfg(test)] +mod test { + use std::fs; + + use anyhow::Ok; + use tailcall_fixtures::configs; + + use super::TypeNameGenerator; + use crate::core::config::transformer::Transform; + use crate::core::config::Config; + use crate::core::valid::Validator; + + fn read_fixture(path: &str) -> String { + fs::read_to_string(path).unwrap() + } + + #[test] + fn test_type_name_generator_transform() { + let config = Config::from_sdl(read_fixture(configs::AUTO_GENERATE_CONFIG).as_str()) + .to_result() + .unwrap(); + + let transformed_config = TypeNameGenerator.transform(config).to_result().unwrap(); + insta::assert_snapshot!(transformed_config.to_sdl()); + } + + #[test] + fn test_type_name_generator_with_cyclic_types() -> anyhow::Result<()> { + let config = Config::from_sdl(read_fixture(configs::CYCLIC_CONFIG).as_str()) + .to_result() + .unwrap(); + + let transformed_config = TypeNameGenerator.transform(config).to_result().unwrap(); + insta::assert_snapshot!(transformed_config.to_sdl()); + + Ok(()) + } +} diff --git a/src/core/generator/from_json.rs b/src/core/generator/from_json.rs index 0e23b85080..579bf976dd 100644 --- a/src/core/generator/from_json.rs +++ b/src/core/generator/from_json.rs @@ -5,7 +5,7 @@ use super::json::{ FieldBaseUrlGenerator, NameGenerator, QueryGenerator, SchemaGenerator, TypesGenerator, }; use crate::core::config::transformer::{ - ConsolidateURL, RemoveUnused, Transform, TransformerOps, TypeMerger, + ConsolidateURL, RemoveUnused, Transform, TransformerOps, TypeMerger, TypeNameGenerator, }; use crate::core::config::Config; use crate::core::valid::Validator; @@ -26,6 +26,7 @@ pub fn from_json( query: &str, ) -> anyhow::Result { let mut config = Config::default(); + // TODO: field names in operation type will be provided by user in config. let field_name_gen = NameGenerator::new("f"); let type_name_gen = NameGenerator::new("T"); @@ -39,6 +40,7 @@ pub fn from_json( .pipe(FieldBaseUrlGenerator::new(&request.url, query)) .pipe(RemoveUnused) .pipe(TypeMerger::new(0.8)) //TODO: take threshold value from user + .pipe(TypeNameGenerator) .transform(config) .to_result()?; } diff --git a/src/core/generator/snapshots/tailcall__core__generator__generator__test__read_all_with_different_domain_rest_api_gen.snap b/src/core/generator/snapshots/tailcall__core__generator__generator__test__read_all_with_different_domain_rest_api_gen.snap index 57519f7d23..7ef26502d0 100644 --- a/src/core/generator/snapshots/tailcall__core__generator__generator__test__read_all_with_different_domain_rest_api_gen.snap +++ b/src/core/generator/snapshots/tailcall__core__generator__generator__test__read_all_with_different_domain_rest_api_gen.snap @@ -6,13 +6,13 @@ schema @server @upstream(baseURL: "https://jsonplaceholder.typicode.com") { query: Query } -type Query { - f1(p1: Int!): [T1] @http(path: "/posts/{{.args.p1}}/comments") - f2(p1: Int!): T2 @http(path: "/posts/{{.args.p1}}") - f3(q: String): T19 @http(baseURL: "https://dummyjson.com", path: "/products/search", query: [{key: "q", value: "{{.args.q}}"}]) +type Dimension { + depth: Int + height: Int + width: Int } -type T1 { +type F1 { body: String email: String id: Int @@ -20,21 +20,42 @@ type T1 { postId: Int } -type T18 { +type F2 { + body: String + id: Int + title: String + userId: Int +} + +type F3 { + limit: Int + products: [Product] + skip: Int + total: Int +} + +type Meum { + barcode: String + createdAt: String + qrCode: String + updatedAt: String +} + +type Product { availabilityStatus: String brand: String category: String description: String - dimensions: T3 + dimensions: Dimension discountPercentage: Int id: Int images: [String] - meta: T5 + meta: Meum minimumOrderQuantity: Int price: Int rating: Int returnPolicy: String - reviews: [T4] + reviews: [Review] shippingInformation: String sku: String stock: Int @@ -45,37 +66,16 @@ type T18 { weight: Int } -type T19 { - limit: Int - products: [T18] - skip: Int - total: Int -} - -type T2 { - body: String - id: Int - title: String - userId: Int -} - -type T3 { - depth: Int - height: Int - width: Int +type Query { + f1(p1: Int!): [F1] @http(path: "/posts/{{.args.p1}}/comments") + f2(p1: Int!): F2 @http(path: "/posts/{{.args.p1}}") + f3(q: String): F3 @http(baseURL: "https://dummyjson.com", path: "/products/search", query: [{key: "q", value: "{{.args.q}}"}]) } -type T4 { +type Review { comment: String date: String rating: Int reviewerEmail: String reviewerName: String } - -type T5 { - barcode: String - createdAt: String - qrCode: String - updatedAt: String -} diff --git a/src/core/generator/snapshots/tailcall__core__generator__generator__test__read_all_with_rest_api_gen.snap b/src/core/generator/snapshots/tailcall__core__generator__generator__test__read_all_with_rest_api_gen.snap index ff741e9753..3066163fde 100644 --- a/src/core/generator/snapshots/tailcall__core__generator__generator__test__read_all_with_rest_api_gen.snap +++ b/src/core/generator/snapshots/tailcall__core__generator__generator__test__read_all_with_rest_api_gen.snap @@ -6,28 +6,23 @@ schema @server @upstream(baseURL: "http://jsonplaceholder.typicode.com") { query: Query } -type M1 { - lat: String - lng: String -} - -type M2 { +type Address { city: String - geo: M1 + geo: Geo street: String suite: String zipcode: String } -type M3 { +type Company { bs: String catchPhrase: String name: String } -type M4 { - address: M2 - company: M3 +type F2 { + address: Address + company: Company email: String id: Int name: String @@ -36,7 +31,12 @@ type M4 { website: String } +type Geo { + lat: String + lng: String +} + type Query { - f1: [M4] @http(path: "/users") - f2(p1: Int!): M4 @http(path: "/users/{{.args.p1}}") + f1: [F2] @http(path: "/users") + f2(p1: Int!): F2 @http(path: "/users/{{.args.p1}}") } diff --git a/src/core/generator/tests/snapshots/json_to_config_spec__incompatible_properties.json.snap b/src/core/generator/tests/snapshots/json_to_config_spec__incompatible_properties.json.snap index 74c6f72161..82c1f7674e 100644 --- a/src/core/generator/tests/snapshots/json_to_config_spec__incompatible_properties.json.snap +++ b/src/core/generator/tests/snapshots/json_to_config_spec__incompatible_properties.json.snap @@ -8,11 +8,11 @@ schema @server @upstream(baseURL: "https://example.com") { scalar Any -type Query { - f1: T1 @http(path: "/") -} - -type T1 { +type F1 { campaignTemplates: Any colors: [Any] } + +type Query { + f1: F1 @http(path: "/") +} diff --git a/src/core/generator/tests/snapshots/json_to_config_spec__list.json.snap b/src/core/generator/tests/snapshots/json_to_config_spec__list.json.snap index 33e1ae0a94..188f20e494 100644 --- a/src/core/generator/tests/snapshots/json_to_config_spec__list.json.snap +++ b/src/core/generator/tests/snapshots/json_to_config_spec__list.json.snap @@ -6,12 +6,12 @@ schema @server @upstream(baseURL: "https://example.com") { query: Query } -type Query { - f1: [T1] @http(path: "/users") -} - -type T1 { +type F1 { adult: Boolean age: Int name: String } + +type Query { + f1: [F1] @http(path: "/users") +} diff --git a/src/core/generator/tests/snapshots/json_to_config_spec__nested_list.json.snap b/src/core/generator/tests/snapshots/json_to_config_spec__nested_list.json.snap index e00ee35144..f0ce86e655 100644 --- a/src/core/generator/tests/snapshots/json_to_config_spec__nested_list.json.snap +++ b/src/core/generator/tests/snapshots/json_to_config_spec__nested_list.json.snap @@ -6,21 +6,21 @@ schema @server @upstream(baseURL: "https://example.com") { query: Query } -type Query { - f1(children: Boolean): T3 @http(path: "/users", query: [{key: "children", value: "{{.args.children}}"}]) -} - -type T1 { +type Children { age: Int name: String } -type T2 { +type F1 { + people: [People] +} + +type People { age: Int - children: [T1] + children: [Children] name: String } -type T3 { - people: [T2] +type Query { + f1(children: Boolean): F1 @http(path: "/users", query: [{key: "children", value: "{{.args.children}}"}]) } diff --git a/src/core/generator/tests/snapshots/json_to_config_spec__nested_same_properties.json.snap b/src/core/generator/tests/snapshots/json_to_config_spec__nested_same_properties.json.snap index 15ae3d8a98..c6c1a2b8ff 100644 --- a/src/core/generator/tests/snapshots/json_to_config_spec__nested_same_properties.json.snap +++ b/src/core/generator/tests/snapshots/json_to_config_spec__nested_same_properties.json.snap @@ -6,16 +6,20 @@ schema @server @upstream(baseURL: "https://example.com") { query: Query } -type Query { - f1: T4 @http(path: "/") +type Container { + age: Int } -type T1 { - age: Int +type F1 { + container: T3 +} + +type Query { + f1: F1 @http(path: "/") } type T2 { - container: T1 + container: Container name: String } @@ -23,7 +27,3 @@ type T3 { container: T2 name: String } - -type T4 { - container: T3 -} diff --git a/tailcall-fixtures/fixtures/configs/auto_generate_config.graphql b/tailcall-fixtures/fixtures/configs/auto_generate_config.graphql new file mode 100644 index 0000000000..8db82cd5e8 --- /dev/null +++ b/tailcall-fixtures/fixtures/configs/auto_generate_config.graphql @@ -0,0 +1,37 @@ +schema @server @upstream { + query: Query +} + +type Query { + f1: [RootType1] @http(baseURL: "https://jsonplaceholder.typicode.com", path: "/users") +} + +type T1 { + lat: String + lng: String +} + +type T2 { + city: String + geo: T1 + street: String + suite: String + zipcode: String +} + +type T3 { + bs: String + catchPhrase: String + name: String +} + +type RootType1 { + address: T2 + company: T3 + email: String + id: Int + name: String + phone: String + username: String + website: String +} diff --git a/tailcall-fixtures/fixtures/configs/cyclic_config.graphql b/tailcall-fixtures/fixtures/configs/cyclic_config.graphql new file mode 100644 index 0000000000..aad488a47d --- /dev/null +++ b/tailcall-fixtures/fixtures/configs/cyclic_config.graphql @@ -0,0 +1,26 @@ +schema @server @upstream { + query: Query +} + +type Query { + f1: [RootType1] @http(baseURL: "https://jsonplaceholder.typicode.com", path: "/users") +} + +type RootType1 { + id: ID! + name: String! + posts: [T32]! +} + +type T32 { + id: ID! + title: String! + content: String! + author: RootType1! + cycle: T33 +} + +type T33 { + id: ID! + cycle: T33 +}