From d52dcdf927710479331dddb7f80a8a61f3e88b32 Mon Sep 17 00:00:00 2001 From: Kiryl Mialeshka <8974488+meskill@users.noreply.github.com> Date: Wed, 2 Oct 2024 15:44:41 +0200 Subject: [PATCH] chore(2818): variance based merging of types (#2927) Co-authored-by: Tushar Mathur --- src/core/blueprint/mod.rs | 2 - src/core/config/config.rs | 13 +- src/core/config/config_module.rs | 2 + .../config_module/fixtures/enums-1.graphql | 21 + .../config_module/fixtures/enums-2.graphql | 9 + .../config_module/fixtures/enums-3.graphql | 13 + .../config_module/fixtures/router.graphql | 9 + .../fixtures/subgraph-posts.graphql | 43 ++ .../fixtures/subgraph-users.graphql | 52 ++ .../config_module/fixtures/types-1.graphql | 13 + .../config_module/fixtures/types-2.graphql | 5 + .../config_module/fixtures/types-3.graphql | 6 + .../config_module/fixtures/unions-1.graphql | 17 + .../config_module/fixtures/unions-2.graphql | 9 + src/core/config/config_module/merge.rs | 646 ++++++++++++++++++ ...g_module__merge__tests__enums_invalid.snap | 6 + ...fig_module__merge__tests__enums_valid.snap | 25 + ...dule__merge__tests__federation_router.snap | 61 ++ ...g_module__merge__tests__types_invalid.snap | 7 + ...fig_module__merge__tests__types_valid.snap | 19 + ...ig_module__merge__tests__unions_valid.snap | 25 + src/core/config/transformer/ambiguous_type.rs | 2 +- src/core/mod.rs | 4 +- src/core/variance.rs | 65 ++ src/core/{blueprint => }/wrapping_type.rs | 0 25 files changed, 1069 insertions(+), 5 deletions(-) create mode 100644 src/core/config/config_module/fixtures/enums-1.graphql create mode 100644 src/core/config/config_module/fixtures/enums-2.graphql create mode 100644 src/core/config/config_module/fixtures/enums-3.graphql create mode 100644 src/core/config/config_module/fixtures/router.graphql create mode 100644 src/core/config/config_module/fixtures/subgraph-posts.graphql create mode 100644 src/core/config/config_module/fixtures/subgraph-users.graphql create mode 100644 src/core/config/config_module/fixtures/types-1.graphql create mode 100644 src/core/config/config_module/fixtures/types-2.graphql create mode 100644 src/core/config/config_module/fixtures/types-3.graphql create mode 100644 src/core/config/config_module/fixtures/unions-1.graphql create mode 100644 src/core/config/config_module/fixtures/unions-2.graphql create mode 100644 src/core/config/config_module/merge.rs create mode 100644 src/core/config/config_module/snapshots/tailcall__core__config__config_module__merge__tests__enums_invalid.snap create mode 100644 src/core/config/config_module/snapshots/tailcall__core__config__config_module__merge__tests__enums_valid.snap create mode 100644 src/core/config/config_module/snapshots/tailcall__core__config__config_module__merge__tests__federation_router.snap create mode 100644 src/core/config/config_module/snapshots/tailcall__core__config__config_module__merge__tests__types_invalid.snap create mode 100644 src/core/config/config_module/snapshots/tailcall__core__config__config_module__merge__tests__types_valid.snap create mode 100644 src/core/config/config_module/snapshots/tailcall__core__config__config_module__merge__tests__unions_valid.snap create mode 100644 src/core/variance.rs rename src/core/{blueprint => }/wrapping_type.rs (100%) diff --git a/src/core/blueprint/mod.rs b/src/core/blueprint/mod.rs index 0260384c67..d461586baf 100644 --- a/src/core/blueprint/mod.rs +++ b/src/core/blueprint/mod.rs @@ -16,7 +16,6 @@ pub mod telemetry; mod timeout; mod union_resolver; mod upstream; -mod wrapping_type; pub use auth::*; pub use blueprint::*; @@ -31,7 +30,6 @@ pub use schema::*; pub use server::*; pub use timeout::GlobalTimeout; pub use upstream::*; -pub use wrapping_type::Type; use crate::core::config::ConfigModule; use crate::core::try_fold::TryFold; diff --git a/src/core/config/config.rs b/src/core/config/config.rs index 93b936570a..6b5da7d5e2 100644 --- a/src/core/config/config.rs +++ b/src/core/config/config.rs @@ -212,7 +212,15 @@ pub struct RootSchema { } #[derive( - Serialize, Deserialize, Clone, Debug, PartialEq, Eq, schemars::JsonSchema, DirectiveDefinition, + Serialize, + Deserialize, + Clone, + Debug, + PartialEq, + Eq, + schemars::JsonSchema, + DirectiveDefinition, + MergeRight, )] #[directive_definition(locations = "FieldDefinition")] #[serde(deny_unknown_fields)] @@ -332,6 +340,7 @@ impl Field { schemars::JsonSchema, DirectiveDefinition, InputDefinition, + MergeRight, )] #[directive_definition(locations = "FieldDefinition")] #[serde(deny_unknown_fields)] @@ -554,6 +563,8 @@ impl Config { types = self.find_connections(field.type_of.name(), types); } } + } else if self.find_enum(type_of).is_some() { + types.insert(type_of.into()); } types } diff --git a/src/core/config/config_module.rs b/src/core/config/config_module.rs index c3018d7d2a..00e737af5d 100644 --- a/src/core/config/config_module.rs +++ b/src/core/config/config_module.rs @@ -13,6 +13,8 @@ use crate::core::rest::{EndpointSet, Unchecked}; use crate::core::valid::{Valid, Validator}; use crate::core::Transform; +mod merge; + /// A wrapper on top of Config that contains all the resolved extensions and /// computed values. #[derive(Clone, Debug, Default, MergeRight)] diff --git a/src/core/config/config_module/fixtures/enums-1.graphql b/src/core/config/config_module/fixtures/enums-1.graphql new file mode 100644 index 0000000000..761de61667 --- /dev/null +++ b/src/core/config/config_module/fixtures/enums-1.graphql @@ -0,0 +1,21 @@ +schema { + query: Query +} + +type Query { + a: A +} + +enum enumInput { + A + B +} + +enum enumOutput { + A + B +} + +type A { + a(x: enumInput): enumOutput +} diff --git a/src/core/config/config_module/fixtures/enums-2.graphql b/src/core/config/config_module/fixtures/enums-2.graphql new file mode 100644 index 0000000000..60d38045d9 --- /dev/null +++ b/src/core/config/config_module/fixtures/enums-2.graphql @@ -0,0 +1,9 @@ +enum enumInput { + B + C +} + +enum enumOutput { + B + C +} diff --git a/src/core/config/config_module/fixtures/enums-3.graphql b/src/core/config/config_module/fixtures/enums-3.graphql new file mode 100644 index 0000000000..36075c85ff --- /dev/null +++ b/src/core/config/config_module/fixtures/enums-3.graphql @@ -0,0 +1,13 @@ +enum enumInput { + B + C +} + +enum enumOutput { + B + C +} + +type A { + b(x: enumOutput): enumInput +} diff --git a/src/core/config/config_module/fixtures/router.graphql b/src/core/config/config_module/fixtures/router.graphql new file mode 100644 index 0000000000..5288226c3d --- /dev/null +++ b/src/core/config/config_module/fixtures/router.graphql @@ -0,0 +1,9 @@ +schema { + # @link(src: "http://localhost:4000", type: SubGraph, meta: {name: "Users"}) + # @link(src: "http://localhost:5000", type: SubGraph, meta: {name: "Posts"}) + query: Query +} + +type Query { + version: String @expr(body: "test") +} diff --git a/src/core/config/config_module/fixtures/subgraph-posts.graphql b/src/core/config/config_module/fixtures/subgraph-posts.graphql new file mode 100644 index 0000000000..16cee982ba --- /dev/null +++ b/src/core/config/config_module/fixtures/subgraph-posts.graphql @@ -0,0 +1,43 @@ +schema + @server(port: 8000) + @upstream(baseURL: "http://jsonplaceholder.typicode.com", httpCache: 42, batch: {delay: 100}) { + query: Query +} + +type Query { + posts: [UserPost] @http(path: "/posts") + addComment(postId: Int!, comment: CommentInput!, premium: Boolean): Boolean @http(path: "/add-comment", method: POST) + searchComments(type: CommentSearch): [Comment] @http(path: "/comment") +} + +interface Post { + id: Int! + body: String +} + +enum Role { + ADMIN + EMPLOYEE +} + +type UserPost implements Post { + id: Int! + userId: Int! + title: String! + body: String +} + +input CommentInput { + userId: Int! + body: String! +} + +type Comment { + body: String +} + +enum CommentSearch { + TODAY + WEEK + MONTH +} diff --git a/src/core/config/config_module/fixtures/subgraph-users.graphql b/src/core/config/config_module/fixtures/subgraph-users.graphql new file mode 100644 index 0000000000..a6b1f6e05b --- /dev/null +++ b/src/core/config/config_module/fixtures/subgraph-users.graphql @@ -0,0 +1,52 @@ +schema + @server(port: 8000) + @upstream(baseURL: "http://jsonplaceholder.typicode.com", httpCache: 42, batch: {delay: 100}) { + query: Query +} + +type Query { + users: [User] @http(path: "/users") + user(id: Int!): User @http(path: "/users/{{.args.id}}") + addComment(postId: Int!, comment: CommentInput!): Boolean @http(path: "/add-comment") +} + +enum Role { + USER +} + +type User { + id: Int! + name: String! + username: String! + email: String! + phone: String + website: String + role: Role +} + +interface Post { + userId: Int! + user: User @http(path: "/users/{{.value.userId}}") +} + +type UserPost implements Post { + userId: Int! + title: String + user: User @http(path: "/users/{{.value.userId}}") +} + +input CommentInput { + userId: Int! + title: String + body: String! +} + +type Comment { + body: String +} + +enum CommentSearch { + WEEK + MONTH + YEAR +} diff --git a/src/core/config/config_module/fixtures/types-1.graphql b/src/core/config/config_module/fixtures/types-1.graphql new file mode 100644 index 0000000000..e81721ab6a --- /dev/null +++ b/src/core/config/config_module/fixtures/types-1.graphql @@ -0,0 +1,13 @@ +schema { + query: Query +} + +type Query { + a: A +} + +type A { + a: String + b: Int + c: Boolean +} diff --git a/src/core/config/config_module/fixtures/types-2.graphql b/src/core/config/config_module/fixtures/types-2.graphql new file mode 100644 index 0000000000..61f9994116 --- /dev/null +++ b/src/core/config/config_module/fixtures/types-2.graphql @@ -0,0 +1,5 @@ +type A { + b: Int! + d: Float! + e: String +} diff --git a/src/core/config/config_module/fixtures/types-3.graphql b/src/core/config/config_module/fixtures/types-3.graphql new file mode 100644 index 0000000000..545efca2af --- /dev/null +++ b/src/core/config/config_module/fixtures/types-3.graphql @@ -0,0 +1,6 @@ +type A { + a: Int + b: [Int] + c: Boolean + d: Float +} diff --git a/src/core/config/config_module/fixtures/unions-1.graphql b/src/core/config/config_module/fixtures/unions-1.graphql new file mode 100644 index 0000000000..0813e2c12c --- /dev/null +++ b/src/core/config/config_module/fixtures/unions-1.graphql @@ -0,0 +1,17 @@ +schema { + query: Query +} + +type Query { + b: B +} + +type A { + a: String +} + +type B { + b: String +} + +union Union = A | B diff --git a/src/core/config/config_module/fixtures/unions-2.graphql b/src/core/config/config_module/fixtures/unions-2.graphql new file mode 100644 index 0000000000..f4ffd47760 --- /dev/null +++ b/src/core/config/config_module/fixtures/unions-2.graphql @@ -0,0 +1,9 @@ +type B { + b: String +} + +type C { + c: Int +} + +union Union = B | C diff --git a/src/core/config/config_module/merge.rs b/src/core/config/config_module/merge.rs new file mode 100644 index 0000000000..357f3e3d17 --- /dev/null +++ b/src/core/config/config_module/merge.rs @@ -0,0 +1,646 @@ +use std::collections::BTreeMap; + +use indexmap::IndexMap; + +use super::{Cache, ConfigModule}; +use crate::core; +use crate::core::config::{Arg, Config, Enum, Field, Type}; +use crate::core::merge_right::MergeRight; +use crate::core::valid::{Valid, Validator}; +use crate::core::variance::{Contravariant, Covariant, Invariant}; + +impl core::Type { + fn merge(self, other: Self, non_null_merge: fn(bool, bool) -> bool) -> Valid { + use core::Type; + + match (self, other) { + ( + Type::Named { name, non_null }, + Type::Named { name: other_name, non_null: other_non_null }, + ) => { + if name != other_name { + return Valid::fail(format!( + "Type mismatch: expected `{}`, got `{}`", + &name, other_name + )); + } + + Valid::succeed(Type::Named { + name, + // non_null only if type is non_null for both sources + non_null: non_null_merge(non_null, other_non_null), + }) + } + ( + Type::List { of_type, non_null }, + Type::List { of_type: other_of_type, non_null: other_non_null }, + ) => (*of_type) + .merge(*other_of_type, non_null_merge) + .map(|of_type| Type::List { + of_type: Box::new(of_type), + non_null: non_null_merge(non_null, other_non_null), + }), + _ => Valid::fail("Type mismatch: expected list, got singular value".to_string()), + } + } +} + +impl Contravariant for core::Type { + /// Executes merge the way that the result type is non_null + /// if it is specified as non_null in at least one of the definitions. + /// That's a narrows merge i.e. the result narrows the input definitions + /// the way it could be handled by both self and other sources + fn shrink(self, other: Self) -> Valid { + #[inline] + fn non_null_merge(non_null: bool, other_non_null: bool) -> bool { + non_null || other_non_null + } + + self.merge(other, non_null_merge) + } +} + +impl Covariant for core::Type { + /// Executes merge the way that the result type is non_null only + /// if it is specified as non_null in both sources. + /// That's a wide merge i.e. the result wides the input definitions + /// the way it could be handled by both self and other sources + fn expand(self, other: Self) -> Valid { + #[inline] + fn non_null_merge(non_null: bool, other_non_null: bool) -> bool { + non_null && other_non_null + } + + self.merge(other, non_null_merge) + } +} + +impl Contravariant for Arg { + fn shrink(self, other: Self) -> Valid { + self.type_of.shrink(other.type_of).map(|type_of| Self { + type_of, + doc: self.doc.merge_right(other.doc), + modify: self.modify.merge_right(other.modify), + default_value: self.default_value.or(other.default_value), + }) + } +} + +impl Contravariant for Field { + fn shrink(self, other: Self) -> Valid { + self.type_of + .shrink(other.type_of) + .fuse(self.args.shrink(other.args)) + .map(|(type_of, args)| Self { + type_of, + args, + doc: self.doc.merge_right(other.doc), + modify: self.modify.merge_right(other.modify), + omit: self.omit.merge_right(other.omit), + cache: self.cache.merge_right(other.cache), + default_value: self.default_value.or(other.default_value), + protected: self.protected.merge_right(other.protected), + resolver: self.resolver.merge_right(other.resolver), + }) + } +} + +impl Covariant for Field { + fn expand(self, other: Self) -> Valid { + self.type_of + .expand(other.type_of) + // args are always merged with narrow + .fuse(self.args.shrink(other.args)) + .map(|(type_of, args)| Self { + type_of, + args, + doc: self.doc.merge_right(other.doc), + modify: self.modify.merge_right(other.modify), + omit: self.omit.merge_right(other.omit), + cache: self.cache.merge_right(other.cache), + default_value: self.default_value.or(other.default_value), + protected: self.protected.merge_right(other.protected), + resolver: self.resolver.merge_right(other.resolver), + }) + } +} + +impl Contravariant for Type { + fn shrink(self, other: Self) -> Valid { + self.fields.shrink(other.fields).map(|fields| Self { + fields, + // TODO: is not very clear how to merge added_fields here + added_fields: self.added_fields.merge_right(other.added_fields), + doc: self.doc.merge_right(other.doc), + implements: self.implements.merge_right(other.implements), + cache: self.cache.merge_right(other.cache), + protected: self.protected.merge_right(other.protected), + resolver: self.resolver.merge_right(other.resolver), + key: self.key.merge_right(other.key), + }) + } +} + +impl Covariant for Type { + fn expand(self, other: Self) -> Valid { + self.fields.expand(other.fields).map(|fields| Self { + fields, + // TODO: is not very clear how to merge added_fields here + added_fields: self.added_fields.merge_right(other.added_fields), + doc: self.doc.merge_right(other.doc), + implements: self.implements.merge_right(other.implements), + cache: self.cache.merge_right(other.cache), + protected: self.protected.merge_right(other.protected), + resolver: self.resolver.merge_right(other.resolver), + key: self.key.merge_right(other.key), + }) + } +} + +impl Contravariant for Enum { + fn shrink(mut self, other: Self) -> Valid { + self.variants.retain(|key| other.variants.contains(key)); + + Valid::succeed(Self { + variants: self.variants, + doc: self.doc.merge_right(other.doc), + }) + } +} + +impl Covariant for Enum { + fn expand(mut self, other: Self) -> Valid { + self.variants.extend(other.variants); + + Valid::succeed(Self { + variants: self.variants, + doc: self.doc.merge_right(other.doc), + }) + } +} + +impl Invariant for Cache { + fn unify(self, other: Self) -> Valid { + let mut types = self.config.types; + let mut enums = self.config.enums; + + Valid::from_iter(other.config.types, |(type_name, other_type)| { + let trace_name = type_name.clone(); + match types.remove(&type_name) { + Some(ty) => { + let is_self_input = self.input_types.contains(&type_name); + let is_other_input = other.input_types.contains(&type_name); + let is_self_output = self.output_types.contains(&type_name) + || self.interface_types.contains(&type_name); + let is_other_output = other.output_types.contains(&type_name) + || other.interface_types.contains(&type_name); + + match ( + is_self_input, + is_self_output, + is_other_input, + is_other_output, + ) { + // both input types + (true, false, true, false) => ty.shrink(other_type), + // both output types + (false, true, false, true) => ty.expand(other_type), + // if type is unknown on one side, we merge based on info from another side + (false, false, true, false) | (true, false, false, false) => { + ty.shrink(other_type) + } + (false, false, false, true) | (false, true, false, false) => { + ty.expand(other_type) + } + // if type is used as both input and output on either side + // generated validation error because we need to merge it differently + (true, true, _, _) | (_, _, true, true) => Valid::fail("Type is used both as input and output type that couldn't be merged for federation".to_string()), + // type is used differently on both sides + (true, false, false, true) | (false, true, true, false) => Valid::fail("Type is used as input type in one subgraph and output type in another".to_string()), + (false, false, false, false) => Valid::fail("Cannot infer the usage of type and therefore merge it from the subgraph".to_string()), + } + } + None => Valid::succeed(other_type), + } + .map(|ty| (type_name, ty)) + .trace(&trace_name) + }) + .fuse(Valid::from_iter(other.config.enums, |(name, other_enum)| { + let trace_name = name.clone(); + + match enums.remove(&name) { + Some(en) => { + let is_self_input = self.input_types.contains(&name); + let is_other_input = other.input_types.contains(&name); + let is_self_output = self.output_types.contains(&name); + let is_other_output = other.output_types.contains(&name); + + match (is_self_input, is_self_output, is_other_input, is_other_output) { + // both input types + (true, false, true, false) => en.shrink(other_enum), + // both output types + (false, true, false, true) => en.expand(other_enum), + // if type is unknown on one side, we merge based on info from another side + (false, false, true, false) | (true, false, false, false) => { + en.shrink(other_enum) + } + (false, false, false, true) | (false, true, false, false) => { + en.expand(other_enum) + } + // if type is used as both input and output on either side + // generated validation error because we need to merge it differently + (true, true, _, _) | (_, _, true, true) => { + if en == other_enum { + Valid::succeed(en) + } else { + Valid::fail("Enum is used both as input and output types and in that case the enum content should be equal for every subgraph".to_string()) + } + }, + // type is used differently on both sides + (true, false, false, true) | (false, true, true, false) => Valid::fail("Enum is used as input type in one subgraph and output type in another".to_string()), + (false, false, false, false) => Valid::fail("Cannot infer the usage of enum and therefore merge it from the subgraph".to_string()), + } + }, + None => Valid::succeed(other_enum), + } + .map(|en| (name, en)) + .trace(&trace_name) + })) + .map( |(merged_types, merged_enums)| { + types.extend(merged_types); + enums.extend(merged_enums); + + let config = Config { types, enums, unions: self.config.unions.merge_right(other.config.unions), ..self.config }; + + Cache { + config, + input_types: self.input_types.merge_right(other.input_types), + output_types: self.output_types.merge_right(other.output_types), + interface_types: self.interface_types.merge_right(other.interface_types), + } + }) + } +} + +impl Invariant for ConfigModule { + fn unify(self, other: Self) -> Valid { + self.cache + .unify(other.cache) + .map(|cache| Self { cache, extensions: self.extensions }) + } +} + +trait TypedEntry { + fn type_of(&self) -> &crate::core::Type; +} + +impl TypedEntry for Field { + fn type_of(&self) -> &crate::core::Type { + &self.type_of + } +} + +impl TypedEntry for Arg { + fn type_of(&self) -> &crate::core::Type { + &self.type_of + } +} + +trait FederatedMergeCollection: + IntoIterator + + FromIterator<(String, Self::Entry)> + + Extend<(String, Self::Entry)> +{ + type Entry: TypedEntry; + + fn remove(&mut self, name: &str) -> Option; +} + +impl FederatedMergeCollection for IndexMap { + type Entry = Entry; + + fn remove(&mut self, name: &str) -> Option { + self.swap_remove(name) + } +} + +impl FederatedMergeCollection for BTreeMap { + type Entry = Entry; + + fn remove(&mut self, name: &str) -> Option { + self.remove(name) + } +} + +impl Contravariant for C +where + C: FederatedMergeCollection, + C::Entry: Contravariant, +{ + fn shrink(mut self, other: Self) -> Valid { + Valid::from_iter(other, |(name, other_field)| { + match self.remove(&name) { + Some(field) => Contravariant::shrink(field, other_field).map(|merged| Some((name.clone(), merged))), + None => { + if other_field.type_of().is_nullable() { + Valid::succeed(None) + } else { + Valid::fail("Input arg is marked as non_null on the right side, but is not present on the left side".to_string()) + } + }, + } + .trace(&name) + }) + .fuse(Valid::from_iter(self, |(name, field)| { + if field.type_of().is_nullable() { + Valid::succeed(()) + } else { + Valid::fail("Input arg is marked as non_null on the left side, but is not present on the right side".to_string()).trace(&name) + } + })) + .map(|(merged_fields, _)| { + merged_fields.into_iter().flatten().collect() + }) + } +} + +impl Covariant for C +where + C: FederatedMergeCollection, + C::Entry: Covariant, +{ + fn expand(mut self, other: Self) -> Valid { + Valid::from_iter(other, |(name, other_field)| match self.remove(&name) { + Some(field) => field + .expand(other_field) + .map(|merged| (name.clone(), merged)) + .trace(&name), + None => Valid::succeed((name, other_field)), + }) + .map(|merged_fields| { + let mut merged_fields: C = merged_fields.into_iter().collect(); + merged_fields.extend(self); + + merged_fields + }) + } +} + +#[cfg(test)] +mod tests { + use anyhow::Result; + use insta::assert_snapshot; + + use super::*; + use crate::core::config::ConfigModule; + use crate::core::valid::Validator; + use crate::include_config; + + #[test] + fn test_types_valid() -> Result<()> { + let types1 = ConfigModule::from(include_config!("./fixtures/types-1.graphql")?); + let types2 = ConfigModule::from(include_config!("./fixtures/types-2.graphql")?); + + let merged = types1.unify(types2).to_result()?; + + assert_snapshot!(merged.to_sdl()); + + Ok(()) + } + + #[test] + fn test_types_invalid() -> Result<()> { + let types1 = ConfigModule::from(include_config!("./fixtures/types-1.graphql")?); + let types3 = ConfigModule::from(include_config!("./fixtures/types-3.graphql")?); + + let merged = types1.unify(types3).to_result(); + + assert_snapshot!(merged.unwrap_err()); + + Ok(()) + } + + #[test] + fn test_unions_valid() -> Result<()> { + let unions1 = ConfigModule::from(include_config!("./fixtures/unions-1.graphql")?); + let unions2 = ConfigModule::from(include_config!("./fixtures/unions-2.graphql")?); + + let merged = unions1.unify(unions2).to_result()?; + + assert_snapshot!(merged.to_sdl()); + + Ok(()) + } + + #[test] + fn test_enums_valid() -> Result<()> { + let enums1 = ConfigModule::from(include_config!("./fixtures/enums-1.graphql")?); + let enums2 = ConfigModule::from(include_config!("./fixtures/enums-2.graphql")?); + + let merged = enums1.unify(enums2).to_result()?; + + assert_snapshot!(merged.to_sdl()); + + Ok(()) + } + + #[test] + fn test_enums_invalid() -> Result<()> { + let enums1 = ConfigModule::from(include_config!("./fixtures/enums-1.graphql")?); + let enums3 = ConfigModule::from(include_config!("./fixtures/enums-3.graphql")?); + + let merged = enums1.unify(enums3).to_result(); + + assert_snapshot!(merged.unwrap_err()); + + Ok(()) + } + + #[test] + fn test_federation_router() -> Result<()> { + let router = ConfigModule::from(include_config!("./fixtures/router.graphql")?); + + let subgraph_users = + ConfigModule::from(include_config!("./fixtures/subgraph-users.graphql")?); + + let subgraph_posts = + ConfigModule::from(include_config!("./fixtures/subgraph-posts.graphql")?); + + let merged = router; + let merged = merged.unify(subgraph_users).to_result()?; + let merged = merged.unify(subgraph_posts).to_result()?; + + assert_snapshot!(merged.to_sdl()); + + Ok(()) + } + + mod core_type { + use super::*; + use crate::core::Type; + + mod expand { + use super::*; + + #[test] + fn test_equal() { + let a = Type::Named { name: "String".to_owned(), non_null: false }; + let b = Type::Named { name: "String".to_owned(), non_null: false }; + + assert_eq!( + a.expand(b), + Valid::succeed(Type::Named { name: "String".to_owned(), non_null: false }) + ); + + let a = Type::List { + of_type: Box::new(Type::Named { name: "Int".to_owned(), non_null: false }), + non_null: true, + }; + let b = Type::List { + of_type: Box::new(Type::Named { name: "Int".to_owned(), non_null: false }), + non_null: true, + }; + + assert_eq!( + a.expand(b), + Valid::succeed(Type::List { + of_type: Box::new(Type::Named { name: "Int".to_owned(), non_null: false }), + non_null: true, + }) + ); + } + + #[test] + fn test_different_non_null() { + let a = Type::Named { name: "String".to_owned(), non_null: false }; + let b = Type::Named { name: "String".to_owned(), non_null: true }; + + assert_eq!( + a.expand(b), + Valid::succeed(Type::Named { name: "String".to_owned(), non_null: false }) + ); + + let a = Type::List { + of_type: Box::new(Type::Named { name: "Int".to_owned(), non_null: false }), + non_null: false, + }; + let b = Type::List { + of_type: Box::new(Type::Named { name: "Int".to_owned(), non_null: true }), + non_null: true, + }; + + assert_eq!( + a.expand(b), + Valid::succeed(Type::List { + of_type: Box::new(Type::Named { name: "Int".to_owned(), non_null: false }), + non_null: false, + }) + ); + } + + #[test] + fn test_different_types() { + let a = Type::Named { name: "String".to_owned(), non_null: false }; + let b = Type::Named { name: "Int".to_owned(), non_null: false }; + + assert_eq!( + a.expand(b), + Valid::fail("Type mismatch: expected `String`, got `Int`".to_owned()) + ); + + let a = Type::List { + of_type: Box::new(Type::Named { name: "Int".to_owned(), non_null: false }), + non_null: true, + }; + let b = Type::Named { name: "Int".to_owned(), non_null: false }; + + assert_eq!( + a.expand(b), + Valid::fail("Type mismatch: expected list, got singular value".to_owned()) + ); + } + } + + mod shrink { + use super::*; + + #[test] + fn test_equal() { + let a = Type::Named { name: "String".to_owned(), non_null: false }; + let b = Type::Named { name: "String".to_owned(), non_null: false }; + + assert_eq!( + a.shrink(b), + Valid::succeed(Type::Named { name: "String".to_owned(), non_null: false }) + ); + + let a = Type::List { + of_type: Box::new(Type::Named { name: "Int".to_owned(), non_null: false }), + non_null: true, + }; + let b = Type::List { + of_type: Box::new(Type::Named { name: "Int".to_owned(), non_null: false }), + non_null: true, + }; + + assert_eq!( + a.shrink(b), + Valid::succeed(Type::List { + of_type: Box::new(Type::Named { name: "Int".to_owned(), non_null: false }), + non_null: true, + }) + ); + } + + #[test] + fn test_different_non_null() { + let a = Type::Named { name: "String".to_owned(), non_null: false }; + let b = Type::Named { name: "String".to_owned(), non_null: true }; + + assert_eq!( + a.shrink(b), + Valid::succeed(Type::Named { name: "String".to_owned(), non_null: true }) + ); + + let a = Type::List { + of_type: Box::new(Type::Named { name: "Int".to_owned(), non_null: false }), + non_null: false, + }; + let b = Type::List { + of_type: Box::new(Type::Named { name: "Int".to_owned(), non_null: true }), + non_null: true, + }; + + assert_eq!( + a.shrink(b), + Valid::succeed(Type::List { + of_type: Box::new(Type::Named { name: "Int".to_owned(), non_null: true }), + non_null: true, + }) + ); + } + + #[test] + fn test_different_types() { + let a = Type::Named { name: "String".to_owned(), non_null: false }; + let b = Type::Named { name: "Int".to_owned(), non_null: false }; + + assert_eq!( + a.shrink(b), + Valid::fail("Type mismatch: expected `String`, got `Int`".to_owned()) + ); + + let a = Type::List { + of_type: Box::new(Type::Named { name: "Int".to_owned(), non_null: false }), + non_null: true, + }; + let b = Type::Named { name: "Int".to_owned(), non_null: false }; + + assert_eq!( + a.shrink(b), + Valid::fail("Type mismatch: expected list, got singular value".to_owned()) + ); + } + } + } +} diff --git a/src/core/config/config_module/snapshots/tailcall__core__config__config_module__merge__tests__enums_invalid.snap b/src/core/config/config_module/snapshots/tailcall__core__config__config_module__merge__tests__enums_invalid.snap new file mode 100644 index 0000000000..8ecb8fa6c5 --- /dev/null +++ b/src/core/config/config_module/snapshots/tailcall__core__config__config_module__merge__tests__enums_invalid.snap @@ -0,0 +1,6 @@ +--- +source: src/core/config/config_module/merge.rs +expression: merged.unwrap_err() +--- +Validation Error +• Enum is used as input type in one subgraph and output type in another [enumOutput] diff --git a/src/core/config/config_module/snapshots/tailcall__core__config__config_module__merge__tests__enums_valid.snap b/src/core/config/config_module/snapshots/tailcall__core__config__config_module__merge__tests__enums_valid.snap new file mode 100644 index 0000000000..956dea8fed --- /dev/null +++ b/src/core/config/config_module/snapshots/tailcall__core__config__config_module__merge__tests__enums_valid.snap @@ -0,0 +1,25 @@ +--- +source: src/core/config/config_module/merge.rs +expression: merged.to_sdl() +--- +schema @server @upstream { + query: Query +} + +enum enumInput { + B +} + +enum enumOutput { + A + B + C +} + +type A { + a(x: enumInput): enumOutput +} + +type Query { + a: A +} diff --git a/src/core/config/config_module/snapshots/tailcall__core__config__config_module__merge__tests__federation_router.snap b/src/core/config/config_module/snapshots/tailcall__core__config__config_module__merge__tests__federation_router.snap new file mode 100644 index 0000000000..16f3b73062 --- /dev/null +++ b/src/core/config/config_module/snapshots/tailcall__core__config__config_module__merge__tests__federation_router.snap @@ -0,0 +1,61 @@ +--- +source: src/core/config/config_module/merge.rs +expression: merged.to_sdl() +--- +schema @server @upstream { + query: Query +} + +input CommentInput { + body: String! + userId: Int! +} + +interface Post { + body: String + id: Int! + user: User @http(path: "/users/{{.value.userId}}") + userId: Int! +} + +enum CommentSearch { + MONTH + WEEK +} + +enum Role { + ADMIN + EMPLOYEE + USER +} + +type Comment { + body: String +} + +type Query { + addComment(postId: Int!, comment: CommentInput!): Boolean @http(method: "POST", path: "/add-comment") + posts: [UserPost] @http(path: "/posts") + searchComments(type: CommentSearch): [Comment] @http(path: "/comment") + user(id: Int!): User @http(path: "/users/{{.args.id}}") + users: [User] @http(path: "/users") + version: String @expr(body: "test") +} + +type User { + email: String! + id: Int! + name: String! + phone: String + role: Role + username: String! + website: String +} + +type UserPost implements Post { + body: String + id: Int! + title: String + user: User @http(path: "/users/{{.value.userId}}") + userId: Int! +} diff --git a/src/core/config/config_module/snapshots/tailcall__core__config__config_module__merge__tests__types_invalid.snap b/src/core/config/config_module/snapshots/tailcall__core__config__config_module__merge__tests__types_invalid.snap new file mode 100644 index 0000000000..565380877c --- /dev/null +++ b/src/core/config/config_module/snapshots/tailcall__core__config__config_module__merge__tests__types_invalid.snap @@ -0,0 +1,7 @@ +--- +source: src/core/config/config_module/merge.rs +expression: merged.unwrap_err() +--- +Validation Error +• Type mismatch: expected `String`, got `Int` [A, a] +• Type mismatch: expected list, got singular value [A, b] diff --git a/src/core/config/config_module/snapshots/tailcall__core__config__config_module__merge__tests__types_valid.snap b/src/core/config/config_module/snapshots/tailcall__core__config__config_module__merge__tests__types_valid.snap new file mode 100644 index 0000000000..68cc9dfe6f --- /dev/null +++ b/src/core/config/config_module/snapshots/tailcall__core__config__config_module__merge__tests__types_valid.snap @@ -0,0 +1,19 @@ +--- +source: src/core/config/config_module/merge.rs +expression: merged.to_sdl() +--- +schema @server @upstream { + query: Query +} + +type A { + a: String + b: Int + c: Boolean + d: Float! + e: String +} + +type Query { + a: A +} diff --git a/src/core/config/config_module/snapshots/tailcall__core__config__config_module__merge__tests__unions_valid.snap b/src/core/config/config_module/snapshots/tailcall__core__config__config_module__merge__tests__unions_valid.snap new file mode 100644 index 0000000000..78906a4f64 --- /dev/null +++ b/src/core/config/config_module/snapshots/tailcall__core__config__config_module__merge__tests__unions_valid.snap @@ -0,0 +1,25 @@ +--- +source: src/core/config/config_module/merge.rs +expression: merged.to_sdl() +--- +schema @server @upstream { + query: Query +} + +union Union = A | B | C + +type A { + a: String +} + +type B { + b: String +} + +type C { + c: Int +} + +type Query { + b: B +} diff --git a/src/core/config/transformer/ambiguous_type.rs b/src/core/config/transformer/ambiguous_type.rs index 47da6187bb..225ad81f6c 100644 --- a/src/core/config/transformer/ambiguous_type.rs +++ b/src/core/config/transformer/ambiguous_type.rs @@ -73,8 +73,8 @@ impl Transform for AmbiguousType { .trace(current_name) } else { let mut resolution_map = HashMap::new(); - resolution_map = insert_resolution(resolution_map, current_name, resolution); if let Some(ty) = config.types.get(current_name) { + resolution_map = insert_resolution(resolution_map, current_name, resolution); for field in ty.fields.values() { for args in field.args.values() { // if arg is of output type then it should be changed to that of diff --git a/src/core/mod.rs b/src/core/mod.rs index 6bc4548190..b219127e6f 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -40,7 +40,9 @@ pub mod tracing; mod transform; pub mod try_fold; pub mod valid; +mod variance; pub mod worker; +mod wrapping_type; // Re-export everything from `tailcall_macros` as `macros` use std::borrow::Cow; @@ -48,7 +50,6 @@ use std::hash::Hash; use std::num::NonZeroU64; use async_graphql_value::ConstValue; -pub use blueprint::Type; pub use errata::Errata; pub use error::{Error, Result}; use http::Response; @@ -56,6 +57,7 @@ use ir::model::IoId; pub use mustache::Mustache; pub use tailcall_macros as macros; pub use transform::Transform; +pub use wrapping_type::Type; const DEFAULT_VERIFY_SSL: bool = true; pub const fn default_verify_ssl() -> Option { diff --git a/src/core/variance.rs b/src/core/variance.rs new file mode 100644 index 0000000000..f4c809b521 --- /dev/null +++ b/src/core/variance.rs @@ -0,0 +1,65 @@ +use crate::core::merge_right::MergeRight; +use crate::core::primitive::Primitive; +use crate::core::valid::Valid; + +/// A trait representing types that are **invariant** with respect to merging +/// operations. +/// +/// In type theory, an invariant type is neither covariant nor contravariant. +/// When merging two values of an invariant type, the result does not expand or +/// shrink in terms of its constraints or possibilities. Instead, merging +/// typically follows custom logic to unify the two values into one, preserving +/// their essential properties without introducing additional flexibility or +/// restrictions. +/// +/// The `unify` method defines how two values of the type can be combined while +/// maintaining their invariance. This is useful in scenarios where a type must +/// strictly adhere to certain constraints, and any merging must respect those +/// constraints. +pub trait Invariant: Sized { + fn unify(self, other: Self) -> Valid; +} + +/// A trait representing types that exhibit **contravariant** behavior during +/// merging operations. +/// +/// In type theory, a contravariant type allows substitution with more general +/// (less specific) types. In the context of merging, a contravariant type can +/// "shrink" when combined with another value, resulting in a type that is more +/// restrictive or specific than either of the original types. +/// +/// The `shrink` method defines how two values of the type can be merged into a +/// new value that represents a narrower scope or more specific constraints. +/// This is useful in scenarios where combining two values should result in the +/// most restrictive interpretation. +pub trait Contravariant: Sized { + fn shrink(self, other: Self) -> Valid; +} + +/// A trait representing types that exhibit **covariant** behavior during +/// merging operations. +/// +/// In type theory, a covariant type allows substitution with more specific +/// (less general) types. In the context of merging, a covariant type can +/// "expand" when combined with another value, resulting in a type that is less +/// restrictive or more general than either of the original types. +/// +/// The `expand` method defines how two values of the type can be merged into a +/// new value that represents a broader scope or fewer constraints. This is +/// useful in scenarios where combining two values should result in the most +/// permissive interpretation. +pub trait Covariant: Sized { + fn expand(self, other: Self) -> Valid; +} + +/// Implements the `Invariant` trait for all types that implement `Primitive`. +/// +/// This implementation defines how two primitive values can be unified without +/// changing their invariance. The `unify` method uses the `merge_right` +/// function to combine `self` and `other`, preserving the essential properties +/// of the type. +impl Invariant for A { + fn unify(self, other: Self) -> Valid { + Valid::succeed(self.merge_right(other)) + } +} diff --git a/src/core/blueprint/wrapping_type.rs b/src/core/wrapping_type.rs similarity index 100% rename from src/core/blueprint/wrapping_type.rs rename to src/core/wrapping_type.rs