Skip to content

Commit

Permalink
feat: implement type name generator transform for configuration. (#2118)
Browse files Browse the repository at this point in the history
Co-authored-by: Ranjit Mahadik <[email protected]>
Co-authored-by: Tushar Mathur <[email protected]>
  • Loading branch information
3 people authored Jun 10, 2024
1 parent 0a57572 commit 0ef45aa
Show file tree
Hide file tree
Showing 16 changed files with 450 additions and 77 deletions.
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down
52 changes: 52 additions & 0 deletions src/core/config/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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"));
}
}
2 changes: 2 additions & 0 deletions src/core/config/transformer/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down
Original file line number Diff line number Diff line change
@@ -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")
}
Original file line number Diff line number Diff line change
@@ -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")
}
181 changes: 181 additions & 0 deletions src/core/config/transformer/type_name_generator.rs
Original file line number Diff line number Diff line change
@@ -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<String, BTreeMap<String, CandidateStats>>,
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<String, String> {
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<String, BTreeMap<String, CandidateStats>>,
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<Config, String> {
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(())
}
}
4 changes: 3 additions & 1 deletion src/core/generator/from_json.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -26,6 +26,7 @@ pub fn from_json(
query: &str,
) -> anyhow::Result<Config> {
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");

Expand All @@ -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()?;
}
Expand Down
Loading

1 comment on commit 0ef45aa

@github-actions
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Running 30s test @ http://localhost:8000/graphql

4 threads and 100 connections

Thread Stats Avg Stdev Max +/- Stdev
Latency 6.67ms 3.01ms 81.48ms 72.67%
Req/Sec 3.79k 194.84 4.30k 89.67%

452896 requests in 30.01s, 2.27GB read

Requests/sec: 15093.25

Transfer/sec: 77.47MB

Please sign in to comment.