diff --git a/Cargo.lock b/Cargo.lock index edac08ad57..65e11a7c3b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1458,6 +1458,15 @@ version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1462739cb27611015575c0c11df5df7601141071f07518d56fcc1be504cbec97" +[[package]] +name = "client_rs_static" +version = "0.0.1" +dependencies = [ + "reqwest 0.12.4", + "serde 1.0.204", + "serde_json", +] + [[package]] name = "clipboard-win" version = "5.4.0" @@ -9445,9 +9454,11 @@ checksum = "566cafdd92868e0939d3fb961bd0dc25fcfaaed179291093b3d43e6b3150ea10" dependencies = [ "base64 0.22.1", "bytes", + "encoding_rs", "futures-channel", "futures-core", "futures-util", + "h2 0.4.5", "http 1.1.0", "http-body 1.0.1", "http-body-util", @@ -9467,6 +9478,7 @@ dependencies = [ "serde_json", "serde_urlencoded", "sync_wrapper 0.1.2", + "system-configuration", "tokio", "tokio-native-tls", "tower-service", diff --git a/Cargo.toml b/Cargo.toml index 27def3fcf0..56ab7ba5e6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ members = [ "src/meta-cli", "src/metagen", "src/metagen/src/fdk_rust/static", + "src/metagen/src/client_rs/static", "src/mt_deno", "src/typegate/engine", "src/typegate/standalone", diff --git a/src/metagen/src/client_py/mod.rs b/src/metagen/src/client_py/mod.rs index eed6d56140..369aa9b7e2 100644 --- a/src/metagen/src/client_py/mod.rs +++ b/src/metagen/src/client_py/mod.rs @@ -126,7 +126,7 @@ fn render_client_py(_config: &ClienPyGenConfig, tg: &Typegraph) -> anyhow::Resul }; let name_mapper = Rc::new(name_mapper); - let node_metas = render_node_metas(dest, &manifest, name_mapper.clone())?; + let (node_metas, named_types) = render_node_metas(dest, &manifest, name_mapper.clone())?; let data_types = render_data_types(dest, &manifest, name_mapper.clone())?; let data_types = Rc::new(data_types); let selection_names = @@ -147,6 +147,15 @@ class QueryGraph(QueryGraphBase): "{ty_name}": "{gql_ty}","# )?; } + for id in named_types { + let ty_name = &tg.types[id as usize].base().title; + let gql_ty = get_gql_type(&tg.types, id, false); + write!( + dest, + r#" + "{ty_name}": "{gql_ty}","# + )?; + } write!( dest, r#" @@ -264,10 +273,14 @@ fn render_node_metas( dest: &mut GenDestBuf, manifest: &RenderManifest, name_mapper: Rc, -) -> Result { +) -> Result<(NameMemo, HashSet)> { + let named_types = Rc::new(std::sync::Mutex::new(HashSet::new())); let mut renderer = TypeRenderer::new( name_mapper.nodes.clone(), - Rc::new(node_metas::PyNodeMetasRenderer { name_mapper }), + Rc::new(node_metas::PyNodeMetasRenderer { + name_mapper, + named_types: named_types.clone(), + }), ); for &id in &manifest.node_metas { _ = renderer.render(id)?; @@ -283,7 +296,10 @@ class NodeDescs: {methods} "# )?; - Ok(memo) + Ok(( + memo, + Rc::try_unwrap(named_types).unwrap().into_inner().unwrap(), + )) } struct NameMapper { diff --git a/src/metagen/src/client_py/node_metas.rs b/src/metagen/src/client_py/node_metas.rs index 2739819010..8af65d9a66 100644 --- a/src/metagen/src/client_py/node_metas.rs +++ b/src/metagen/src/client_py/node_metas.rs @@ -10,6 +10,7 @@ use crate::{interlude::*, shared::types::*}; pub struct PyNodeMetasRenderer { pub name_mapper: Rc, + pub named_types: Rc>>, } impl PyNodeMetasRenderer { @@ -57,8 +58,10 @@ impl PyNodeMetasRenderer { r#" @staticmethod def {ty_name}(): + return_node = NodeDescs.{return_node}() return NodeMeta( - sub_nodes=NodeDescs.{return_node}().sub_nodes,"# + sub_nodes=return_node.sub_nodes, + variants=return_node.variants,"# )?; if let Some(fields) = argument_fields { write!( @@ -85,6 +88,37 @@ impl PyNodeMetasRenderer { dest, r#" ) +"# + )?; + Ok(()) + } + + fn render_for_union( + &self, + dest: &mut TypeRenderer, + ty_name: &str, + variants: IndexMap>, + ) -> std::fmt::Result { + write!( + dest, + r#" + @staticmethod + def {ty_name}(): + return NodeMeta( + variants={{"# + )?; + for (key, node_ref) in variants { + write!( + dest, + r#" + "{key}": NodeDescs.{node_ref},"# + )?; + } + write!( + dest, + r#" + }}, + ) "# )?; Ok(()) @@ -114,10 +148,14 @@ impl RenderType for PyNodeMetasRenderer { | TypeNode::List { data: ListTypeData { items: item, .. }, .. - } => renderer.render_subgraph(*item, cursor)?.0.unwrap().to_string(), + } => renderer + .render_subgraph(*item, cursor)? + .0 + .unwrap() + .to_string(), TypeNode::Function { data, base } => { let (return_ty_name, _cyclic) = renderer.render_subgraph(data.output, cursor)?; - let return_ty_name = return_ty_name.unwrap() ; + let return_ty_name = return_ty_name.unwrap(); let props = match renderer.nodes[data.input as usize].deref() { TypeNode::Object { data, .. } if !data.properties.is_empty() => { let props = data @@ -154,16 +192,43 @@ impl RenderType for PyNodeMetasRenderer { ty_name } TypeNode::Either { - .. - // data: EitherTypeData { one_of: variants }, - // base, + data: EitherTypeData { one_of: variants }, + base, } | TypeNode::Union { - .. - // data: UnionTypeData { any_of: variants }, - // base, + data: UnionTypeData { any_of: variants }, + base, } => { - todo!("unions are wip") + let mut named_set = vec![]; + let variants = variants + .iter() + .filter_map(|&inner| { + if !renderer.is_composite(inner) { + return None; + } + named_set.push(inner); + let (ty_name, _cyclic) = match renderer.render_subgraph(inner, cursor) { + Ok(val) => val, + Err(err) => return Some(Err(err)), + }; + let ty_name = ty_name.unwrap(); + Some(eyre::Ok(( + renderer.nodes[inner as usize].deref().base().title.clone(), + ty_name, + ))) + }) + .collect::, _>>()?; + if !variants.is_empty() { + { + let mut named_types = self.named_types.lock().unwrap(); + named_types.extend(named_set) + } + let ty_name = normalize_type_title(&base.title); + self.render_for_union(renderer, &ty_name, variants)?; + ty_name + } else { + "scalar".into() + } } }; Ok(name) diff --git a/src/metagen/src/client_py/selections.rs b/src/metagen/src/client_py/selections.rs index b6397f6c83..4b66385539 100644 --- a/src/metagen/src/client_py/selections.rs +++ b/src/metagen/src/client_py/selections.rs @@ -43,6 +43,40 @@ impl PyNodeSelectionsRenderer { writeln!(dest)?; Ok(()) } + + fn render_for_union( + &self, + dest: &mut TypeRenderer, + ty_name: &str, + variants: IndexMap, + ) -> std::fmt::Result { + writeln!(dest, r#"{ty_name} = typing.TypedDict("{ty_name}", {{"#)?; + writeln!(dest, r#" "_": SelectionFlags,"#)?; + for (_name, (variant_ty, select_ty)) in &variants { + use SelectionTy::*; + match select_ty { + Scalar | ScalarArgs { .. } => { + // scalars always get selected if the union node + // gets selected + unreachable!() + } + Composite { select_ty } => writeln!( + dest, + // use variant_ty as key instead of normalized struct name + // we want it to match the varaint name from the NodeMetas + // later so no normlalization is used + r#" "{variant_ty}": CompositeSelectNoArgs["{select_ty}"],"# + )?, + CompositeArgs { arg_ty, select_ty } => writeln!( + dest, + r#" "{variant_ty}": CompositeSelectArgs["{arg_ty}", "{select_ty}"],"# + )?, + }; + } + writeln!(dest, "}}, total=False)")?; + writeln!(dest)?; + Ok(()) + } } impl RenderType for PyNodeSelectionsRenderer { @@ -56,22 +90,32 @@ impl RenderType for PyNodeSelectionsRenderer { | TypeNode::String { .. } | TypeNode::File { .. } => unreachable!("scalars don't get to have selections"), TypeNode::Any { .. } => unimplemented!("Any type support not implemented"), - TypeNode::Optional { data: OptionalTypeData { item, .. }, .. } - | TypeNode::List { data: ListTypeData { items: item, .. }, .. } - | TypeNode::Function { data:FunctionTypeData { output: item, .. }, .. } - => renderer.render_subgraph(*item, cursor)?.0.unwrap().to_string(), + TypeNode::Optional { + data: OptionalTypeData { item, .. }, + .. + } + | TypeNode::List { + data: ListTypeData { items: item, .. }, + .. + } + | TypeNode::Function { + data: FunctionTypeData { output: item, .. }, + .. + } => renderer + .render_subgraph(*item, cursor)? + .0 + .unwrap() + .to_string(), TypeNode::Object { data, base } => { let props = data .properties .iter() // generate property types first .map(|(name, &dep_id)| { - eyre::Ok( - ( - normalize_struct_prop_name(name), - selection_for_field(dep_id, &self.arg_ty_names, renderer, cursor)? - ) - ) + eyre::Ok(( + normalize_struct_prop_name(name), + selection_for_field(dep_id, &self.arg_ty_names, renderer, cursor)?, + )) }) .collect::, _>>()?; let node_name = &base.title; @@ -81,16 +125,40 @@ impl RenderType for PyNodeSelectionsRenderer { ty_name } TypeNode::Either { - .. - // data: EitherTypeData { one_of: variants }, - // base, + data: EitherTypeData { one_of: variants }, + base, } | TypeNode::Union { - .. - // data: UnionTypeData { any_of: variants }, - // base, + data: UnionTypeData { any_of: variants }, + base, } => { - todo!("unions are wip") + let variants = variants + .iter() + .filter_map(|&inner| { + if !renderer.is_composite(inner) { + return None; + } + let ty_name = renderer.nodes[inner as usize].deref().base().title.clone(); + let struct_prop_name = + normalize_struct_prop_name(&normalize_type_title(&ty_name[..])); + + let selection = match selection_for_field( + inner, + &self.arg_ty_names, + renderer, + cursor, + ) { + Ok(selection) => selection, + Err(err) => return Some(Err(err)), + }; + + Some(eyre::Ok((struct_prop_name, (ty_name, selection)))) + }) + .collect::, _>>()?; + let ty_name = normalize_type_title(&base.title); + let ty_name = format!("{ty_name}Selections").to_pascal_case(); + self.render_for_union(renderer, &ty_name, variants)?; + ty_name } }; Ok(name) diff --git a/src/metagen/src/client_py/static/client.py b/src/metagen/src/client_py/static/client.py index 63ab95a030..d943e26f9f 100644 --- a/src/metagen/src/client_py/static/client.py +++ b/src/metagen/src/client_py/static/client.py @@ -8,7 +8,7 @@ def selection_to_nodes( selection: "SelectionErased", - metas: typing.Dict[str, typing.Callable[[], "NodeMeta"]], + metas: typing.Dict[str, "NodeMetaFn"], parent_path: str, ) -> typing.List["SelectNode[typing.Any]"]: out = [] @@ -45,7 +45,7 @@ def selection_to_nodes( continue if isinstance(instance_selection, Alias): raise Exception( - f"nested Alias node discovored at {parent_path}.{instance_name}" + f"nested Alias node discovered at {parent_path}.{instance_name}" ) instance_args: typing.Optional[NodeArgs] = None @@ -74,8 +74,8 @@ def selection_to_nodes( ) instance_args[key] = NodeArgValue(ty_name, val) - sub_nodes: typing.Optional[typing.List[SelectNode]] = None - if meta.sub_nodes is not None: + sub_nodes: SubNodes = None + if meta.sub_nodes is not None or meta.variants is not None: sub_selections = instance_selection # if node requires both selection and arg, it must be @@ -89,6 +89,7 @@ def selection_to_nodes( ) sub_selections = sub_selections[1] + # we got a tuple selection when this shouldn't be the case elif isinstance(sub_selections, tuple): raise Exception( f"node at {parent_path}.{instance_name} " @@ -96,6 +97,10 @@ def selection_to_nodes( + f"but selection is typeof {type(instance_selection)}", ) + # flags are recursive for any subnode that's not specified + if sub_selections is None: + sub_selections = {"_": flags} + # selection types are always TypedDicts as well if not isinstance(sub_selections, dict): raise Exception( @@ -103,11 +108,58 @@ def selection_to_nodes( + "is a no argument composite but first element of " + f"selection is typeof {type(instance_selection)}", ) - sub_nodes = selection_to_nodes( - typing.cast("SelectionErased", sub_selections), - meta.sub_nodes, - f"{parent_path}.{instance_name}", - ) + + if meta.sub_nodes is not None: + if meta.variants is not None: + raise Exception( + "unreachable: union/either NodeMetas can't have subnodes" + ) + sub_nodes = selection_to_nodes( + typing.cast("SelectionErased", sub_selections), + meta.sub_nodes, + f"{parent_path}.{instance_name}", + ) + else: + assert meta.variants is not None + union_selections: typing.Dict[str, typing.List[SelectNode]] = {} + for variant_ty, variant_meta in meta.variants.items(): + variant_meta = variant_meta() + + # this union member is a scalar + if variant_meta.sub_nodes is None: + continue + + variant_select = sub_selections.pop(variant_ty, None) + nodes = ( + selection_to_nodes( + typing.cast("SelectionErased", variant_select), + variant_meta.sub_nodes, + f"{parent_path}.{instance_name}.variant({variant_ty})", + ) + if variant_select is not None + else [] + ) + + # we select __typename for each variant + # even if the user is not interested in the variant + nodes.append( + SelectNode( + node_name="__typename", + instance_name="__typename", + args=None, + sub_nodes=None, + ) + ) + + union_selections[variant_ty] = nodes + + if len(sub_selections) > 0: + raise Exception( + f"node at {parent_path}.{instance_name} " + + "has none of the variants called " + + str(sub_selections.keys()), + ) + sub_nodes = union_selections node = SelectNode(node_name, instance_name, instance_args, sub_nodes) out.append(node) @@ -137,12 +189,21 @@ def selection_to_nodes( # +SubNodes = typing.Union[ + None, + # atomic composite + typing.List["SelectNode"], + # union/either selection + typing.Dict[str, typing.List["SelectNode"]], +] + + @dc.dataclass class SelectNode(typing.Generic[Out]): node_name: str instance_name: str args: typing.Optional["NodeArgs"] - sub_nodes: typing.Optional[typing.List["SelectNode"]] + sub_nodes: SubNodes @dc.dataclass @@ -155,9 +216,13 @@ class MutationNode(SelectNode[Out]): pass +NodeMetaFn = typing.Callable[[], "NodeMeta"] + + @dc.dataclass class NodeMeta: - sub_nodes: typing.Optional[typing.Dict[str, typing.Callable[[], "NodeMeta"]]] = None + sub_nodes: typing.Optional[typing.Dict[str, NodeMetaFn]] = None + variants: typing.Optional[typing.Dict[str, NodeMetaFn]] = None arg_types: typing.Optional[typing.Dict[str, str]] = None @@ -275,6 +340,7 @@ class GraphQLResponse: def convert_query_node_gql( + ty_to_gql_ty_map: typing.Dict[str, str], node: SelectNode, variables: typing.Dict[str, NodeArgValue], ): @@ -292,10 +358,32 @@ def convert_query_node_gql( if len(arg_row): out += f"({arg_row[:-2]})" - if node.sub_nodes is not None: + # if it's a dict, it'll be a union selection + if isinstance(node.sub_nodes, dict): + sub_node_list = "" + for variant_ty, sub_nodes in node.sub_nodes.items(): + # fetch the gql variant name so we can do + # type assertions + gql_ty = ty_to_gql_ty_map[variant_ty] + if gql_ty is None: + raise Exception( + f"unreachable: no graphql type found for variant {variant_ty}" + ) + gql_ty = gql_ty.strip("!") + + sub_node_list += f"... on {gql_ty} {{ " + for node in sub_nodes: + sub_node_list += ( + f"{convert_query_node_gql(ty_to_gql_ty_map, node, variables)} " + ) + sub_node_list += "}" + out += f" {{ {sub_node_list}}}" + elif isinstance(node.sub_nodes, list): sub_node_list = "" for node in node.sub_nodes: - sub_node_list += f"{convert_query_node_gql(node, variables)} " + sub_node_list += ( + f"{convert_query_node_gql(ty_to_gql_ty_map, node, variables)} " + ) out += f" {{ {sub_node_list}}}" return out @@ -321,7 +409,7 @@ def build_gql( root_nodes = "" for key, node in query.items(): fixed_node = SelectNode(node.node_name, key, node.args, node.sub_nodes) - root_nodes += f" {convert_query_node_gql(fixed_node, variables)}\n" + root_nodes += f" {convert_query_node_gql(self.ty_to_gql_ty_map, fixed_node, variables)}\n" args_row = "" for key, val in variables.items(): args_row += f"${key}: {self.ty_to_gql_ty_map[val.type_name]}, " @@ -330,7 +418,9 @@ def build_gql( args_row = f"({args_row[:-2]})" doc = f"{ty} {name}{args_row} {{\n{root_nodes}}}" - return (doc, {key: val.value for key, val in variables.items()}) + variables = {key: val.value for key, val in variables.items()} + # print(doc, variables) + return (doc, variables) def build_req( self, @@ -411,8 +501,6 @@ def query( doc, variables = self.build_gql( {key: val for key, val in inp.items()}, "query", name ) - # print(doc,variables) - # return {} return self.fetch(doc, variables, opts) def mutation( diff --git a/src/metagen/src/client_rs/static/client.rs b/src/metagen/src/client_rs/static/client.rs index 18ee47ae86..d16d30fa61 100644 --- a/src/metagen/src/client_rs/static/client.rs +++ b/src/metagen/src/client_rs/static/client.rs @@ -19,6 +19,9 @@ fn to_json_value(val: T) -> serde_json::Value { /// - arguments are associated with their types /// - aliases get splatted into the node tree /// - light query validation takes place +/// +/// I.e. the user's selection is joined with the description of the graph found +/// in the static NodeMetas to fill in any blank spaces fn selection_to_node_set( selection: SelectionErasedMap, metas: &HashMap, @@ -38,7 +41,10 @@ fn selection_to_node_set( continue; }; + // we can have multiple selection instances for a node + // if aliases are involved let node_instances = match node_selection { + // this noe was not selected SelectionErased::None => continue, SelectionErased::Scalar => vec![(node_name.clone(), NodeArgsErased::None, None)], SelectionErased::ScalarArgs(args) => { @@ -66,104 +72,145 @@ fn selection_to_node_set( let meta = meta_fn(); for (instance_name, args, select) in node_instances { - let args = if let Some(arg_types) = &meta.arg_types { - match args { - NodeArgsErased::Inline(args) => { - let instance_args = check_node_args(args, arg_types).map_err(|name| { - SelectionError::UnexpectedArgs { - name, - path: format!("{parent_path}.{instance_name}"), - } - })?; - Some(NodeArgsMerged::Inline(instance_args)) + out.push(selection_to_select_node( + instance_name, + node_name.clone(), + args, + select, + &parent_path, + &meta, + )?) + } + } + Ok(out) +} + +fn selection_to_select_node( + instance_name: CowStr, + node_name: CowStr, + args: NodeArgsErased, + select: Option, + parent_path: &str, + meta: &NodeMeta, +) -> Result { + let args = if let Some(arg_types) = &meta.arg_types { + match args { + NodeArgsErased::Inline(args) => { + let instance_args = check_node_args(args, arg_types).map_err(|name| { + SelectionError::UnexpectedArgs { + name, + path: format!("{parent_path}.{instance_name}"), } - NodeArgsErased::Placeholder(ph) => Some(NodeArgsMerged::Placeholder { - value: ph, - // FIXME: this clone can be improved - arg_types: arg_types.clone(), - }), - NodeArgsErased::None => { - return Err(SelectionError::MissingArgs { + })?; + Some(NodeArgsMerged::Inline(instance_args)) + } + NodeArgsErased::Placeholder(ph) => Some(NodeArgsMerged::Placeholder { + value: ph, + // FIXME: this clone can be improved + arg_types: arg_types.clone(), + }), + NodeArgsErased::None => { + return Err(SelectionError::MissingArgs { + path: format!("{parent_path}.{instance_name}"), + }) + } + } + } else { + None + }; + let sub_nodes = match (&meta.variants, &meta.sub_nodes) { + (Some(_), Some(_)) => unreachable!("union/either node metas can't have sub_nodes"), + (None, None) => SubNodes::None, + (variants, sub_nodes) => { + let Some(select) = select else { + return Err(SelectionError::MissingSubNodes { + path: format!("{parent_path}.{instance_name}"), + }); + }; + match select { + CompositeSelection::Atomic(select) => { + let Some(sub_nodes) = sub_nodes else { + return Err(SelectionError::UnexpectedUnion { path: format!("{parent_path}.{instance_name}"), - }) - } + }); + }; + SubNodes::Atomic(selection_to_node_set( + select, + sub_nodes, + format!("{parent_path}.{instance_name}"), + )?) } - } else { - None - }; - let sub_nodes = match (&meta.variants, &meta.sub_nodes) { - (Some(_), Some(_)) => unreachable!("union/either types can't have sub_nodes"), - (None, None) => SubNodes::None, - (variants, sub_nodes) => { - let Some(select) = select else { - return Err(SelectionError::MissingSubNodes { + CompositeSelection::Union(mut variant_select) => { + let Some(variants) = variants else { + return Err(SelectionError::MissingUnion { path: format!("{parent_path}.{instance_name}"), }); }; - match select { - CompositeSelection::Atomic(select) => { - let Some(sub_nodes) = sub_nodes else { - return Err(SelectionError::UnexpectedUnion { - path: format!("{parent_path}.{instance_name}"), - }); - }; - SubNodes::Atomic(selection_to_node_set( + let mut out = HashMap::new(); + for (variant_ty, variant_meta) in variants { + let variant_meta = variant_meta(); + // this union member is a scalar + let Some(sub_nodes) = variant_meta.sub_nodes else { + continue; + }; + let mut nodes = if let Some(select) = variant_select.remove(variant_ty) { + selection_to_node_set( select, - sub_nodes, - format!("{parent_path}.{instance_name}"), - )?) - } - CompositeSelection::Union(variant_select) => { - let Some(variants) = variants else { - return Err(SelectionError::MissingUnion { - path: format!("{parent_path}.{instance_name}"), - }); - }; - let mut out = HashMap::new(); - for (variant_ty, select) in variant_select { - let Some(variant_meta) = variants.get(&variant_ty[..]) else { - return Err(SelectionError::UnexpectedVariant { - path: format!("{parent_path}.{instance_name}"), - varaint_ty: variant_ty.clone(), - }); - }; - let variant_meta = variant_meta(); - // this union member is a scalar - let Some(sub_nodes) = variant_meta.sub_nodes else { - continue; - }; - let nodes = selection_to_node_set( - select, - &sub_nodes, - format!("{parent_path}.{instance_name}"), - )?; - out.insert(variant_ty, nodes); - } - SubNodes::Union(out) - } + &sub_nodes, + format!("{parent_path}.{instance_name}.variant({variant_ty})"), + )? + } else { + vec![] + }; + nodes.push(SelectNodeErased { + node_name: "__typename".into(), + instance_name: "__typename".into(), + args: None, + sub_nodes: SubNodes::None, + }); + out.insert(variant_ty.clone(), nodes); + } + if !variant_select.is_empty() { + return Err(SelectionError::UnexpectedVariants { + path: format!("{parent_path}.{instance_name}"), + varaint_tys: variant_select.into_keys().collect(), + }); } + SubNodes::Union(out) } - }; - - out.push(SelectNodeErased { - node_name: node_name.clone(), - instance_name, - args, - sub_nodes, - }) + } } - } - Ok(out) + }; + Ok(SelectNodeErased { + node_name, + instance_name, + args, + sub_nodes, + }) } #[derive(Debug)] pub enum SelectionError { - MissingArgs { path: String }, - MissingSubNodes { path: String }, - MissingUnion { path: String }, - UnexpectedArgs { path: String, name: String }, - UnexpectedUnion { path: String }, - UnexpectedVariant { path: String, varaint_ty: CowStr }, + MissingArgs { + path: String, + }, + MissingSubNodes { + path: String, + }, + MissingUnion { + path: String, + }, + UnexpectedArgs { + path: String, + name: String, + }, + UnexpectedUnion { + path: String, + }, + UnexpectedVariants { + path: String, + varaint_tys: Vec, + }, } impl std::fmt::Display for SelectionError { @@ -184,8 +231,14 @@ impl std::fmt::Display for SelectionError { f, "node at {path} is an atomic type but union selection provided" ), - SelectionError::UnexpectedVariant { path, varaint_ty } => { - write!(f, "node at {path} has no variant called '{varaint_ty}'") + SelectionError::UnexpectedVariants { + path, + varaint_tys: varaint_ty, + } => { + write!( + f, + "node at {path} has none of the variants called '{varaint_ty:?}'" + ) } } } @@ -1001,8 +1054,8 @@ macro_rules! impl_selection_traits { macro_rules! impl_union_selection_traits { ($ty:ident,$(($variant_ty:tt, $field:tt)),+) => { impl From<$ty> for CompositeSelection { - fn from(_value: $ty) -> CompositeSelection { - /*CompositeSelection::Union( + fn from(value: $ty) -> CompositeSelection { + CompositeSelection::Union( [ $({ let selection = @@ -1013,8 +1066,7 @@ macro_rules! impl_union_selection_traits { .into_iter() .filter_map(|val| val) .collect(), - )*/ - panic!("unions/either are wip") + ) } } }; diff --git a/src/metagen/src/client_ts/mod.rs b/src/metagen/src/client_ts/mod.rs index 4f796ed47a..b52d6e2b0c 100644 --- a/src/metagen/src/client_ts/mod.rs +++ b/src/metagen/src/client_ts/mod.rs @@ -124,7 +124,7 @@ fn render_client_ts(_config: &ClienTsGenConfig, tg: &Typegraph) -> anyhow::Resul }; let name_mapper = Rc::new(name_mapper); - let node_metas = render_node_metas(dest, &manifest, name_mapper.clone())?; + let (node_metas, named_types) = render_node_metas(dest, &manifest, name_mapper.clone())?; let data_types = render_data_types(dest, &manifest, name_mapper.clone())?; let data_types = Rc::new(data_types); let selection_names = @@ -145,6 +145,15 @@ export class QueryGraph extends _QueryGraphBase {{ "{ty_name}": "{gql_ty}","# )?; } + for id in named_types { + let ty_name = &tg.types[id as usize].base().title; + let gql_ty = get_gql_type(&tg.types, id, false); + write!( + dest, + r#" + "{ty_name}": "{gql_ty}","# + )?; + } write!( dest, r#" @@ -270,10 +279,14 @@ fn render_node_metas( dest: &mut GenDestBuf, manifest: &RenderManifest, name_mapper: Rc, -) -> Result { +) -> Result<(NameMemo, HashSet)> { + let named_types = Rc::new(std::sync::Mutex::new(HashSet::new())); let mut renderer = TypeRenderer::new( name_mapper.nodes.clone(), - Rc::new(node_metas::TsNodeMetasRenderer { name_mapper }), + Rc::new(node_metas::TsNodeMetasRenderer { + name_mapper, + named_types: named_types.clone(), + }), ); for &id in &manifest.node_metas { _ = renderer.render(id)?; @@ -290,7 +303,10 @@ const nodeMetas = {{ }}; "# )?; - Ok(memo) + Ok(( + memo, + Rc::try_unwrap(named_types).unwrap().into_inner().unwrap(), + )) } struct NameMapper { diff --git a/src/metagen/src/client_ts/node_metas.rs b/src/metagen/src/client_ts/node_metas.rs index 95e6414f77..2682cfe24c 100644 --- a/src/metagen/src/client_ts/node_metas.rs +++ b/src/metagen/src/client_ts/node_metas.rs @@ -10,6 +10,7 @@ use crate::{interlude::*, shared::types::*}; pub struct TsNodeMetasRenderer { pub name_mapper: Rc, + pub named_types: Rc>>, } impl TsNodeMetasRenderer { @@ -87,6 +88,36 @@ impl TsNodeMetasRenderer { )?; Ok(()) } + + fn render_for_union( + &self, + dest: &mut TypeRenderer, + ty_name: &str, + variants: IndexMap>, + ) -> std::fmt::Result { + write!( + dest, + r#" + {ty_name}(): NodeMeta {{ + return {{ + variants: ["# + )?; + for (key, node_ref) in variants { + write!( + dest, + r#" + ["{key}", nodeMetas.{node_ref}],"# + )?; + } + write!( + dest, + r#" + ], + }}; + }},"# + )?; + Ok(()) + } } impl RenderType for TsNodeMetasRenderer { @@ -112,10 +143,14 @@ impl RenderType for TsNodeMetasRenderer { | TypeNode::List { data: ListTypeData { items: item, .. }, .. - } => renderer.render_subgraph(*item, cursor)?.0.unwrap().to_string(), + } => renderer + .render_subgraph(*item, cursor)? + .0 + .unwrap() + .to_string(), TypeNode::Function { data, base } => { let (return_ty_name, _cyclic) = renderer.render_subgraph(data.output, cursor)?; - let return_ty_name = return_ty_name.unwrap() ; + let return_ty_name = return_ty_name.unwrap(); let props = match renderer.nodes[data.input as usize].deref() { TypeNode::Object { data, .. } if !data.properties.is_empty() => { let props = data @@ -152,30 +187,43 @@ impl RenderType for TsNodeMetasRenderer { ty_name } TypeNode::Either { - .. - // data: EitherTypeData { one_of: variants }, - // base, + data: EitherTypeData { one_of: variants }, + base, } | TypeNode::Union { - .. - // data: UnionTypeData { any_of: variants }, - // base, + data: UnionTypeData { any_of: variants }, + base, } => { - // let variants = variants - // .iter() - // .map(|&inner| { - // let (ty_name, _cyclic) = renderer.render_subgraph(inner, cursor)?; - // let ty_name = match ty_name { - // RenderedName::Name(name) => name, - // RenderedName::Placeholder(name) => name, - // }; - // Ok::<_, anyhow::Error>(ty_name) - // }) - // .collect::, _>>()?; - // let ty_name = normalize_type_title(&base.title); - // self.render_union_type(renderer, &ty_name, variants)?; - // ty_name - todo!("unions are wip") + let mut named_set = vec![]; + let variants = variants + .iter() + .filter_map(|&inner| { + if !renderer.is_composite(inner) { + return None; + } + named_set.push(inner); + let (ty_name, _cyclic) = match renderer.render_subgraph(inner, cursor) { + Ok(val) => val, + Err(err) => return Some(Err(err)), + }; + let ty_name = ty_name.unwrap(); + Some(eyre::Ok(( + renderer.nodes[inner as usize].deref().base().title.clone(), + ty_name, + ))) + }) + .collect::, _>>()?; + if !variants.is_empty() { + { + let mut named_types = self.named_types.lock().unwrap(); + named_types.extend(named_set) + } + let ty_name = normalize_type_title(&base.title); + self.render_for_union(renderer, &ty_name, variants)?; + ty_name + } else { + "scalar".into() + } } }; Ok(name) diff --git a/src/metagen/src/client_ts/selections.rs b/src/metagen/src/client_ts/selections.rs index bec3f0aa39..c1ac6e68e0 100644 --- a/src/metagen/src/client_ts/selections.rs +++ b/src/metagen/src/client_ts/selections.rs @@ -44,6 +44,44 @@ impl TsNodeSelectionsRenderer { writeln!(dest, "}};")?; Ok(()) } + + fn render_for_union( + &self, + dest: &mut TypeRenderer, + ty_name: &str, + variants: IndexMap, + ) -> std::fmt::Result { + writeln!( + dest, + "export type {ty_name} = {{ + _?: SelectionFlags;" + )?; + for (_name, (variant_ty, select_ty)) in &variants { + use SelectionTy::*; + match select_ty { + Scalar | ScalarArgs { .. } => { + // scalars always get selected if the union node + // gets selected + unreachable!() + } + Composite { select_ty } => { + // use variant_ty as key instead of normalized struct name + // we want it to match the varaint name from the NodeMetas + // later so no normlalization is used + writeln!( + dest, + r#" "{variant_ty}"?: CompositeSelectNoArgs<{select_ty}>;"# + )? + } + CompositeArgs { arg_ty, select_ty } => writeln!( + dest, + r#" "{variant_ty}"?: CompositeSelectArgs<{arg_ty}, {select_ty}>;"# + )?, + }; + } + writeln!(dest, "}};")?; + Ok(()) + } } impl RenderType for TsNodeSelectionsRenderer { @@ -61,22 +99,32 @@ impl RenderType for TsNodeSelectionsRenderer { | TypeNode::String { .. } | TypeNode::File { .. } => unreachable!("scalars don't get to have selections"), TypeNode::Any { .. } => unimplemented!("Any type support not implemented"), - TypeNode::Optional { data: OptionalTypeData { item, .. }, .. } - | TypeNode::List { data: ListTypeData { items: item, .. }, .. } - | TypeNode::Function { data:FunctionTypeData { output:item,.. }, .. } - => renderer.render_subgraph(*item, cursor)?.0.unwrap().to_string(), + TypeNode::Optional { + data: OptionalTypeData { item, .. }, + .. + } + | TypeNode::List { + data: ListTypeData { items: item, .. }, + .. + } + | TypeNode::Function { + data: FunctionTypeData { output: item, .. }, + .. + } => renderer + .render_subgraph(*item, cursor)? + .0 + .unwrap() + .to_string(), TypeNode::Object { data, base } => { let props = data .properties .iter() // generate property types first .map(|(name, &dep_id)| { - eyre::Ok( - ( - normalize_struct_prop_name(name), - selection_for_field(dep_id, &self.arg_ty_names, renderer, cursor)? - ) - ) + eyre::Ok(( + normalize_struct_prop_name(name), + selection_for_field(dep_id, &self.arg_ty_names, renderer, cursor)?, + )) }) .collect::, _>>()?; let node_name = &base.title; @@ -86,30 +134,40 @@ impl RenderType for TsNodeSelectionsRenderer { ty_name } TypeNode::Either { - .. - // data: EitherTypeData { one_of: variants }, - // base, + data: EitherTypeData { one_of: variants }, + base, } | TypeNode::Union { - .. - // data: UnionTypeData { any_of: variants }, - // base, + data: UnionTypeData { any_of: variants }, + base, } => { - // let variants = variants - // .iter() - // .map(|&inner| { - // let (ty_name, _cyclic) = renderer.render_subgraph(inner, cursor)?; - // let ty_name = match ty_name { - // RenderedName::Name(name) => name, - // RenderedName::Placeholder(name) => name, - // }; - // Ok::<_, anyhow::Error>(ty_name) - // }) - // .collect::, _>>()?; - // let ty_name = normalize_type_title(&base.title); - // self.render_union_type(renderer, &ty_name, variants)?; - // ty_name - todo!("unions are wip") + let variants = variants + .iter() + .filter_map(|&inner| { + if !renderer.is_composite(inner) { + return None; + } + let ty_name = renderer.nodes[inner as usize].deref().base().title.clone(); + let struct_prop_name = + normalize_struct_prop_name(&normalize_type_title(&ty_name[..])); + + let selection = match selection_for_field( + inner, + &self.arg_ty_names, + renderer, + cursor, + ) { + Ok(selection) => selection, + Err(err) => return Some(Err(err)), + }; + + Some(eyre::Ok((struct_prop_name, (ty_name, selection)))) + }) + .collect::, _>>()?; + let ty_name = normalize_type_title(&base.title); + let ty_name = format!("{ty_name}Selections").to_pascal_case(); + self.render_for_union(renderer, &ty_name, variants)?; + ty_name } }; Ok(name) diff --git a/src/metagen/src/client_ts/static/mod.ts b/src/metagen/src/client_ts/static/mod.ts index 9b72775235..3c7ba074fa 100644 --- a/src/metagen/src/client_ts/static/mod.ts +++ b/src/metagen/src/client_ts/static/mod.ts @@ -20,7 +20,7 @@ function _selectionToNodeSet( continue; } - const { argumentTypes, subNodes } = metaFn(); + const { argumentTypes, subNodes, variants } = metaFn(); const nodeInstances = nodeSelection instanceof Alias ? nodeSelection.aliases() @@ -34,7 +34,7 @@ function _selectionToNodeSet( } if (instanceSelection instanceof Alias) { throw new Error( - `nested Alias discovored at ${parentPath}.${instanceName}`, + `nested Alias discovered at ${parentPath}.${instanceName}`, ); } const node: SelectNode = { instanceName, nodeName }; @@ -70,7 +70,7 @@ function _selectionToNodeSet( } } - if (subNodes) { + if (subNodes || variants) { // sanity check selection object let subSelections = instanceSelection; if (argumentTypes) { @@ -89,6 +89,11 @@ function _selectionToNodeSet( `but selection is typeof ${typeof subSelections}`, ); } + if (subSelections == undefined) { + subSelections = { + _: selection._, + }; + } if (typeof subSelections != "object") { throw new Error( `node at ${parentPath}.${nodeName} ` + @@ -97,14 +102,53 @@ function _selectionToNodeSet( ); } - node.subNodes = _selectionToNodeSet( - // assume it's a Selection. If it's an argument - // object, mismatch between the node desc should hopefully - // catch it - subSelections as Selection, - subNodes, - `${parentPath}.${instanceName}`, - ); + if (subNodes) { + if (variants) { + throw new Error( + "unreachable: union/either NodeMetas can't have subnodes", + ); + } + node.subNodes = _selectionToNodeSet( + // assume it's a Selection. If it's an argument + // object, mismatch between the node desc should hopefully + // catch it + subSelections as Selection, + subNodes, + `${parentPath}.${instanceName}`, + ); + } else { + const unionSelections = {} as Record; + const foundVariants = new Set([...Object.keys(subSelections)]); + for (const [variantTy, variant_meta_fn] of variants!) { + const variant_meta = variant_meta_fn(); + // this union member is a scalar + if (!variant_meta.subNodes) { + continue; + } + foundVariants.delete(variantTy); + const variant_select = subSelections[variantTy]; + const nodes = variant_select + ? _selectionToNodeSet( + variant_select as Selection, + variant_meta.subNodes, + `${parentPath}.${instanceName}.variant(${variantTy})`, + ) + : []; + nodes.push({ + nodeName: "__typename", + instanceName: "__typename", + }); + unionSelections[variantTy] = nodes; + } + if (foundVariants.size > 0) { + throw new Error( + `node at ${parentPath}.${instanceName} ` + + "has none of the variants called " + + [...foundVariants.keys()], + ); + } + node.subNodes = unionSelections; + } } out.push(node); @@ -123,11 +167,13 @@ function _selectionToNodeSet( /* Query node types section */ +type SubNodes = undefined | SelectNode[] | Record; + type SelectNode<_Out = unknown> = { nodeName: string; instanceName: string; args?: NodeArgs; - subNodes?: SelectNode[]; + subNodes?: SubNodes; }; export class QueryNode { @@ -167,6 +213,7 @@ type QueryDocOut = T extends type NodeMeta = { subNodes?: [string, () => NodeMeta][]; + variants?: [string, () => NodeMeta][]; argumentTypes?: { [name: string]: string }; }; @@ -294,6 +341,7 @@ export type GraphQlTransportOptions = Omit & { }; function convertQueryNodeGql( + typeToGqlTypeMap: Record, node: SelectNode, variables: Map, ) { @@ -316,9 +364,31 @@ function convertQueryNodeGql( const subNodes = node.subNodes; if (subNodes) { - out = `${out} { ${ - subNodes.map((node) => convertQueryNodeGql(node, variables)).join(" ") - } }`; + if (Array.isArray(subNodes)) { + out = `${out} { ${ + subNodes.map((node) => + convertQueryNodeGql(typeToGqlTypeMap, node, variables) + ).join(" ") + } }`; + } else { + out = `${out} { ${ + Object.entries(subNodes).map(([variantTy, subNodes]) => { + let gqlTy = typeToGqlTypeMap[variantTy]; + if (!gqlTy) { + throw new Error( + `unreachable: no graphql type found for variant ${variantTy}`, + ); + } + gqlTy = gqlTy.replace(/[!]+$/, ""); + + return `... on ${gqlTy} {${ + subNodes.map((node) => + convertQueryNodeGql(typeToGqlTypeMap, node, variables) + ).join(" ") + }}`; + }).join(" ") + } }`; + } } return out; } @@ -327,6 +397,7 @@ function buildGql( typeToGqlTypeMap: Record, query: Record, ty: "query" | "mutation", + // deno-lint-ignore no-inferrable-types name: string = "", ) { const variables = new Map(); @@ -335,12 +406,12 @@ function buildGql( .entries(query) .map(([key, node]) => { const fixedNode = { ...node, instanceName: key }; - return convertQueryNodeGql(fixedNode, variables); + return convertQueryNodeGql(typeToGqlTypeMap, fixedNode, variables); }) .join("\n "); let argsRow = [...variables.entries()] - .map(([key, val]) => `$${key}: ${typeToGqlTypeMap[val.typeName]}`) + .map(([key, val]) => `$${key}: ${typeToGqlTypeMap[val.typeName]} `) .join(", "); if (argsRow.length > 0) { // graphql doesn't like empty parentheses so we only @@ -350,7 +421,7 @@ function buildGql( const doc = `${ty} ${name}${argsRow} { ${rootNodes} -}`; + } `; return { doc, variables: Object.fromEntries( @@ -382,9 +453,9 @@ async function fetchGql( }), }); if (!res.ok) { - const body = await res.text().catch((err) => `error reading body: ${err}`); + const body = await res.text().catch((err) => `error reading body: ${err} `); throw new (Error as ErrorPolyfill)( - `graphql request to ${addr} failed with status ${res.status}: ${body}`, + `graphql request to ${addr} failed with status ${res.status}: ${body} `, { cause: { response: res, @@ -399,7 +470,7 @@ async function fetchGql( { cause: { response: res, - body: await res.text().catch((err) => `error reading body: ${err}`), + body: await res.text().catch((err) => `error reading body: ${err} `), }, }, ); diff --git a/tests/metagen/metagen_test.ts b/tests/metagen/metagen_test.ts index dae3f5508c..12ad310f80 100644 --- a/tests/metagen/metagen_test.ts +++ b/tests/metagen/metagen_test.ts @@ -565,16 +565,23 @@ Meta.test({ expectedSchemaM, expectedSchemaQ, expectedSchemaM, - /* zod.object({ + zod.object({ scalarUnion: zod.string(), compositeUnion1: postSchema, - compositeUnion2: zod.undefined(), + compositeUnion2: zod.object({}), mixedUnion: zod.string(), - }), */ + }), ]); const cases = [ { skip: false, + name: "client_rs", + command: $`cargo run`.cwd( + join(scriptsPath, "rs"), + ), + expected: expectedSchema, + }, + { name: "client_ts", // NOTE: dax replaces commands to deno with // commands to xtask so we go through bah @@ -590,13 +597,6 @@ Meta.test({ ), expected: expectedSchema, }, - { - name: "client_rs", - command: $`cargo run`.cwd( - join(scriptsPath, "rs"), - ), - expected: expectedSchema, - }, ]; await using _engine = await metaTest.engine( @@ -607,6 +607,8 @@ Meta.test({ continue; } await metaTest.should(name, async () => { + // const res = await command + // .env({ "TG_PORT": metaTest.port.toString() }); const res = await command .env({ "TG_PORT": metaTest.port.toString() }).text(); expected.parse(JSON.parse(res)); diff --git a/tests/metagen/typegraphs/sample.ts b/tests/metagen/typegraphs/sample.ts index 345f9e8efb..1ab9422a47 100644 --- a/tests/metagen/typegraphs/sample.ts +++ b/tests/metagen/typegraphs/sample.ts @@ -34,9 +34,9 @@ export const tg = await typegraph({ posts: t.list(g.ref("post")), }, { name: "user" }); - /* const compositeUnion = t.union([post, user]); + const compositeUnion = t.union([post, user]); const scalarUnion = t.union([t.string(), t.integer()]); - const mixedUnion = t.union([post, user, t.string(), t.integer()]); */ + const mixedUnion = t.union([post, user, t.string(), t.integer()]); g.expose( { @@ -64,7 +64,7 @@ export const tg = await typegraph({ effect: fx.update(), }, ), - /* scalarUnion: deno.func( + scalarUnion: deno.func( t.struct({ id: t.string() }), scalarUnion, { @@ -84,7 +84,7 @@ export const tg = await typegraph({ { code: () => "hello", }, - ), */ + ), }, Policy.public(), ); diff --git a/tests/metagen/typegraphs/sample/py/client.py b/tests/metagen/typegraphs/sample/py/client.py index e5132afb47..ca9c4f372d 100644 --- a/tests/metagen/typegraphs/sample/py/client.py +++ b/tests/metagen/typegraphs/sample/py/client.py @@ -11,7 +11,7 @@ def selection_to_nodes( selection: "SelectionErased", - metas: typing.Dict[str, typing.Callable[[], "NodeMeta"]], + metas: typing.Dict[str, "NodeMetaFn"], parent_path: str, ) -> typing.List["SelectNode[typing.Any]"]: out = [] @@ -48,7 +48,7 @@ def selection_to_nodes( continue if isinstance(instance_selection, Alias): raise Exception( - f"nested Alias node discovored at {parent_path}.{instance_name}" + f"nested Alias node discovered at {parent_path}.{instance_name}" ) instance_args: typing.Optional[NodeArgs] = None @@ -77,8 +77,8 @@ def selection_to_nodes( ) instance_args[key] = NodeArgValue(ty_name, val) - sub_nodes: typing.Optional[typing.List[SelectNode]] = None - if meta.sub_nodes is not None: + sub_nodes: SubNodes = None + if meta.sub_nodes is not None or meta.variants is not None: sub_selections = instance_selection # if node requires both selection and arg, it must be @@ -92,6 +92,7 @@ def selection_to_nodes( ) sub_selections = sub_selections[1] + # we got a tuple selection when this shouldn't be the case elif isinstance(sub_selections, tuple): raise Exception( f"node at {parent_path}.{instance_name} " @@ -99,6 +100,10 @@ def selection_to_nodes( + f"but selection is typeof {type(instance_selection)}", ) + # flags are recursive for any subnode that's not specified + if sub_selections is None: + sub_selections = {"_": flags} + # selection types are always TypedDicts as well if not isinstance(sub_selections, dict): raise Exception( @@ -106,11 +111,58 @@ def selection_to_nodes( + "is a no argument composite but first element of " + f"selection is typeof {type(instance_selection)}", ) - sub_nodes = selection_to_nodes( - typing.cast("SelectionErased", sub_selections), - meta.sub_nodes, - f"{parent_path}.{instance_name}", - ) + + if meta.sub_nodes is not None: + if meta.variants is not None: + raise Exception( + "unreachable: union/either NodeMetas can't have subnodes" + ) + sub_nodes = selection_to_nodes( + typing.cast("SelectionErased", sub_selections), + meta.sub_nodes, + f"{parent_path}.{instance_name}", + ) + else: + assert meta.variants is not None + union_selections: typing.Dict[str, typing.List[SelectNode]] = {} + for variant_ty, variant_meta in meta.variants.items(): + variant_meta = variant_meta() + + # this union member is a scalar + if variant_meta.sub_nodes is None: + continue + + variant_select = sub_selections.pop(variant_ty, None) + nodes = ( + selection_to_nodes( + typing.cast("SelectionErased", variant_select), + variant_meta.sub_nodes, + f"{parent_path}.{instance_name}.variant({variant_ty})", + ) + if variant_select is not None + else [] + ) + + # we select __typename for each variant + # even if the user is not interested in the variant + nodes.append( + SelectNode( + node_name="__typename", + instance_name="__typename", + args=None, + sub_nodes=None, + ) + ) + + union_selections[variant_ty] = nodes + + if len(sub_selections) > 0: + raise Exception( + f"node at {parent_path}.{instance_name} " + + "has none of the variants called " + + str(sub_selections.keys()), + ) + sub_nodes = union_selections node = SelectNode(node_name, instance_name, instance_args, sub_nodes) out.append(node) @@ -140,12 +192,21 @@ def selection_to_nodes( # +SubNodes = typing.Union[ + None, + # atomic composite + typing.List["SelectNode"], + # union/either selection + typing.Dict[str, typing.List["SelectNode"]], +] + + @dc.dataclass class SelectNode(typing.Generic[Out]): node_name: str instance_name: str args: typing.Optional["NodeArgs"] - sub_nodes: typing.Optional[typing.List["SelectNode"]] + sub_nodes: SubNodes @dc.dataclass @@ -158,9 +219,13 @@ class MutationNode(SelectNode[Out]): pass +NodeMetaFn = typing.Callable[[], "NodeMeta"] + + @dc.dataclass class NodeMeta: - sub_nodes: typing.Optional[typing.Dict[str, typing.Callable[[], "NodeMeta"]]] = None + sub_nodes: typing.Optional[typing.Dict[str, NodeMetaFn]] = None + variants: typing.Optional[typing.Dict[str, NodeMetaFn]] = None arg_types: typing.Optional[typing.Dict[str, str]] = None @@ -278,6 +343,7 @@ class GraphQLResponse: def convert_query_node_gql( + ty_to_gql_ty_map: typing.Dict[str, str], node: SelectNode, variables: typing.Dict[str, NodeArgValue], ): @@ -295,10 +361,32 @@ def convert_query_node_gql( if len(arg_row): out += f"({arg_row[:-2]})" - if node.sub_nodes is not None: + # if it's a dict, it'll be a union selection + if isinstance(node.sub_nodes, dict): + sub_node_list = "" + for variant_ty, sub_nodes in node.sub_nodes.items(): + # fetch the gql variant name so we can do + # type assertions + gql_ty = ty_to_gql_ty_map[variant_ty] + if gql_ty is None: + raise Exception( + f"unreachable: no graphql type found for variant {variant_ty}" + ) + gql_ty = gql_ty.strip("!") + + sub_node_list += f"... on {gql_ty} {{ " + for node in sub_nodes: + sub_node_list += ( + f"{convert_query_node_gql(ty_to_gql_ty_map, node, variables)} " + ) + sub_node_list += "}" + out += f" {{ {sub_node_list}}}" + elif isinstance(node.sub_nodes, list): sub_node_list = "" for node in node.sub_nodes: - sub_node_list += f"{convert_query_node_gql(node, variables)} " + sub_node_list += ( + f"{convert_query_node_gql(ty_to_gql_ty_map, node, variables)} " + ) out += f" {{ {sub_node_list}}}" return out @@ -324,7 +412,7 @@ def build_gql( root_nodes = "" for key, node in query.items(): fixed_node = SelectNode(node.node_name, key, node.args, node.sub_nodes) - root_nodes += f" {convert_query_node_gql(fixed_node, variables)}\n" + root_nodes += f" {convert_query_node_gql(self.ty_to_gql_ty_map, fixed_node, variables)}\n" args_row = "" for key, val in variables.items(): args_row += f"${key}: {self.ty_to_gql_ty_map[val.type_name]}, " @@ -333,7 +421,9 @@ def build_gql( args_row = f"({args_row[:-2]})" doc = f"{ty} {name}{args_row} {{\n{root_nodes}}}" - return (doc, {key: val.value for key, val in variables.items()}) + variables = {key: val.value for key, val in variables.items()} + # print(doc, variables) + return (doc, variables) def build_req( self, @@ -414,8 +504,6 @@ def query( doc, variables = self.build_gql( {key: val for key, val in inp.items()}, "query", name ) - # print(doc,variables) - # return {} return self.fetch(doc, variables, opts) def mutation( @@ -532,15 +620,49 @@ def Post(): ) @staticmethod - def RootCompositeNoArgsFn(): + def RootGetPostsFn(): + return_node = NodeDescs.Post() + return NodeMeta( + sub_nodes=return_node.sub_nodes, + variants=return_node.variants, + ) + + @staticmethod + def User(): return NodeMeta( - sub_nodes=NodeDescs.Post().sub_nodes, + sub_nodes={ + "id": NodeDescs.scalar, + "email": NodeDescs.scalar, + "posts": NodeDescs.Post, + }, + ) + + @staticmethod + def RootCompositeUnionFnOutput(): + return NodeMeta( + variants={ + "post": NodeDescs.Post, + "user": NodeDescs.User, + }, + ) + + @staticmethod + def RootCompositeUnionFn(): + return_node = NodeDescs.RootCompositeUnionFnOutput() + return NodeMeta( + sub_nodes=return_node.sub_nodes, + variants=return_node.variants, + arg_types={ + "id": "RootScalarNoArgsFnOutput", + }, ) @staticmethod def RootScalarArgsFn(): + return_node = NodeDescs.scalar() return NodeMeta( - sub_nodes=NodeDescs.scalar().sub_nodes, + sub_nodes=return_node.sub_nodes, + variants=return_node.variants, arg_types={ "id": "UserIdStringUuid", "slug": "PostSlugString", @@ -549,42 +671,81 @@ def RootScalarArgsFn(): ) @staticmethod - def RootCompositeArgsFn(): + def RootMixedUnionFnOutput(): + return NodeMeta( + variants={ + "post": NodeDescs.Post, + "user": NodeDescs.User, + }, + ) + + @staticmethod + def RootMixedUnionFn(): + return_node = NodeDescs.RootMixedUnionFnOutput() return NodeMeta( - sub_nodes=NodeDescs.Post().sub_nodes, + sub_nodes=return_node.sub_nodes, + variants=return_node.variants, arg_types={ "id": "RootScalarNoArgsFnOutput", }, ) @staticmethod - def RootGetPostsFn(): + def RootScalarUnionFn(): + return_node = NodeDescs.scalar() return NodeMeta( - sub_nodes=NodeDescs.Post().sub_nodes, + sub_nodes=return_node.sub_nodes, + variants=return_node.variants, + arg_types={ + "id": "RootScalarNoArgsFnOutput", + }, ) @staticmethod def RootScalarNoArgsFn(): + return_node = NodeDescs.scalar() return NodeMeta( - sub_nodes=NodeDescs.scalar().sub_nodes, + sub_nodes=return_node.sub_nodes, + variants=return_node.variants, ) @staticmethod - def User(): + def RootGetUserFn(): + return_node = NodeDescs.User() return NodeMeta( - sub_nodes={ - "id": NodeDescs.scalar, - "email": NodeDescs.scalar, - "posts": NodeDescs.Post, - }, + sub_nodes=return_node.sub_nodes, + variants=return_node.variants, ) @staticmethod - def RootGetUserFn(): + def RootCompositeNoArgsFn(): + return_node = NodeDescs.Post() return NodeMeta( - sub_nodes=NodeDescs.User().sub_nodes, + sub_nodes=return_node.sub_nodes, + variants=return_node.variants, ) + @staticmethod + def RootCompositeArgsFn(): + return_node = NodeDescs.Post() + return NodeMeta( + sub_nodes=return_node.sub_nodes, + variants=return_node.variants, + arg_types={ + "id": "RootScalarNoArgsFnOutput", + }, + ) + + +RootScalarNoArgsFnOutput = str + +RootCompositeArgsFnInput = typing.TypedDict( + "RootCompositeArgsFnInput", + { + "id": RootScalarNoArgsFnOutput, + }, + total=False, +) UserIdStringUuid = str @@ -600,15 +761,13 @@ def RootGetUserFn(): total=False, ) -RootScalarNoArgsFnOutput = str +RootScalarUnionFnOutputT1Integer = int + +RootScalarUnionFnOutput = typing.Union[ + RootScalarNoArgsFnOutput, + RootScalarUnionFnOutputT1Integer, +] -RootCompositeArgsFnInput = typing.TypedDict( - "RootCompositeArgsFnInput", - { - "id": RootScalarNoArgsFnOutput, - }, - total=False, -) UserEmailStringEmail = str @@ -624,6 +783,19 @@ def RootGetUserFn(): total=False, ) +RootCompositeUnionFnOutput = typing.Union[ + Post, + User, +] + + +RootMixedUnionFnOutput = typing.Union[ + Post, + User, + RootScalarNoArgsFnOutput, + RootScalarUnionFnOutputT1Integer, +] + PostSelections = typing.TypedDict( "PostSelections", @@ -647,6 +819,26 @@ def RootGetUserFn(): total=False, ) +RootCompositeUnionFnOutputSelections = typing.TypedDict( + "RootCompositeUnionFnOutputSelections", + { + "_": SelectionFlags, + "post": CompositeSelectNoArgs["PostSelections"], + "user": CompositeSelectNoArgs["UserSelections"], + }, + total=False, +) + +RootMixedUnionFnOutputSelections = typing.TypedDict( + "RootMixedUnionFnOutputSelections", + { + "_": SelectionFlags, + "post": CompositeSelectNoArgs["PostSelections"], + "user": CompositeSelectNoArgs["UserSelections"], + }, + total=False, +) + class QueryGraph(QueryGraphBase): def __init__(self): @@ -655,6 +847,8 @@ def __init__(self): "UserIdStringUuid": "ID!", "PostSlugString": "String!", "RootScalarNoArgsFnOutput": "String!", + "user": "user!", + "post": "post!", } ) @@ -709,3 +903,35 @@ def composite_args( return MutationNode( node.node_name, node.instance_name, node.args, node.sub_nodes ) + + def scalar_union( + self, args: typing.Union[RootCompositeArgsFnInput, PlaceholderArgs] + ) -> QueryNode[RootScalarUnionFnOutput]: + node = selection_to_nodes( + {"scalarUnion": args}, {"scalarUnion": NodeDescs.RootScalarUnionFn}, "$q" + )[0] + return QueryNode(node.node_name, node.instance_name, node.args, node.sub_nodes) + + def composite_union( + self, + args: typing.Union[RootCompositeArgsFnInput, PlaceholderArgs], + select: RootCompositeUnionFnOutputSelections, + ) -> QueryNode[RootCompositeUnionFnOutput]: + node = selection_to_nodes( + {"compositeUnion": (args, select)}, + {"compositeUnion": NodeDescs.RootCompositeUnionFn}, + "$q", + )[0] + return QueryNode(node.node_name, node.instance_name, node.args, node.sub_nodes) + + def mixed_union( + self, + args: typing.Union[RootCompositeArgsFnInput, PlaceholderArgs], + select: RootMixedUnionFnOutputSelections, + ) -> QueryNode[RootMixedUnionFnOutput]: + node = selection_to_nodes( + {"mixedUnion": (args, select)}, + {"mixedUnion": NodeDescs.RootMixedUnionFn}, + "$q", + )[0] + return QueryNode(node.node_name, node.instance_name, node.args, node.sub_nodes) diff --git a/tests/metagen/typegraphs/sample/py/main.py b/tests/metagen/typegraphs/sample/py/main.py index 084f56a775..e44d491f0b 100644 --- a/tests/metagen/typegraphs/sample/py/main.py +++ b/tests/metagen/typegraphs/sample/py/main.py @@ -107,4 +107,35 @@ } ) -print(json.dumps([res1, res1a, res2, res3, res4])) +res5 = gql_client.query( + { + "scalarUnion": qg.scalar_union( + { + "id": "94be5420-8c4a-4e67-b4f4-e1b2b54832a2", + } + ), + "compositeUnion1": qg.composite_union( + { + "id": "94be5420-8c4a-4e67-b4f4-e1b2b54832a2", + }, + {"post": {"_": SelectionFlags(select_all=True)}}, + ), + "compositeUnion2": qg.composite_union( + { + "id": "94be5420-8c4a-4e67-b4f4-e1b2b54832a2", + }, + {"user": {"_": SelectionFlags(select_all=True)}}, + ), + "mixedUnion": qg.mixed_union( + { + "id": "94be5420-8c4a-4e67-b4f4-e1b2b54832a2", + }, + { + "post": {"_": SelectionFlags(select_all=True)}, + "user": {"_": SelectionFlags(select_all=True)}, + }, + ), + } +) + +print(json.dumps([res1, res1a, res2, res3, res4, res5])) diff --git a/tests/metagen/typegraphs/sample/rs/client.rs b/tests/metagen/typegraphs/sample/rs/client.rs index 93e00d2f5a..7ebe590d08 100644 --- a/tests/metagen/typegraphs/sample/rs/client.rs +++ b/tests/metagen/typegraphs/sample/rs/client.rs @@ -22,6 +22,9 @@ fn to_json_value(val: T) -> serde_json::Value { /// - arguments are associated with their types /// - aliases get splatted into the node tree /// - light query validation takes place +/// +/// I.e. the user's selection is joined with the description of the graph found +/// in the static NodeMetas to fill in any blank spaces fn selection_to_node_set( selection: SelectionErasedMap, metas: &HashMap, @@ -41,7 +44,10 @@ fn selection_to_node_set( continue; }; + // we can have multiple selection instances for a node + // if aliases are involved let node_instances = match node_selection { + // this noe was not selected SelectionErased::None => continue, SelectionErased::Scalar => vec![(node_name.clone(), NodeArgsErased::None, None)], SelectionErased::ScalarArgs(args) => { @@ -69,104 +75,145 @@ fn selection_to_node_set( let meta = meta_fn(); for (instance_name, args, select) in node_instances { - let args = if let Some(arg_types) = &meta.arg_types { - match args { - NodeArgsErased::Inline(args) => { - let instance_args = check_node_args(args, arg_types).map_err(|name| { - SelectionError::UnexpectedArgs { - name, - path: format!("{parent_path}.{instance_name}"), - } - })?; - Some(NodeArgsMerged::Inline(instance_args)) + out.push(selection_to_select_node( + instance_name, + node_name.clone(), + args, + select, + &parent_path, + &meta, + )?) + } + } + Ok(out) +} + +fn selection_to_select_node( + instance_name: CowStr, + node_name: CowStr, + args: NodeArgsErased, + select: Option, + parent_path: &str, + meta: &NodeMeta, +) -> Result { + let args = if let Some(arg_types) = &meta.arg_types { + match args { + NodeArgsErased::Inline(args) => { + let instance_args = check_node_args(args, arg_types).map_err(|name| { + SelectionError::UnexpectedArgs { + name, + path: format!("{parent_path}.{instance_name}"), } - NodeArgsErased::Placeholder(ph) => Some(NodeArgsMerged::Placeholder { - value: ph, - // FIXME: this clone can be improved - arg_types: arg_types.clone(), - }), - NodeArgsErased::None => { - return Err(SelectionError::MissingArgs { + })?; + Some(NodeArgsMerged::Inline(instance_args)) + } + NodeArgsErased::Placeholder(ph) => Some(NodeArgsMerged::Placeholder { + value: ph, + // FIXME: this clone can be improved + arg_types: arg_types.clone(), + }), + NodeArgsErased::None => { + return Err(SelectionError::MissingArgs { + path: format!("{parent_path}.{instance_name}"), + }) + } + } + } else { + None + }; + let sub_nodes = match (&meta.variants, &meta.sub_nodes) { + (Some(_), Some(_)) => unreachable!("union/either node metas can't have sub_nodes"), + (None, None) => SubNodes::None, + (variants, sub_nodes) => { + let Some(select) = select else { + return Err(SelectionError::MissingSubNodes { + path: format!("{parent_path}.{instance_name}"), + }); + }; + match select { + CompositeSelection::Atomic(select) => { + let Some(sub_nodes) = sub_nodes else { + return Err(SelectionError::UnexpectedUnion { path: format!("{parent_path}.{instance_name}"), - }) - } + }); + }; + SubNodes::Atomic(selection_to_node_set( + select, + sub_nodes, + format!("{parent_path}.{instance_name}"), + )?) } - } else { - None - }; - let sub_nodes = match (&meta.variants, &meta.sub_nodes) { - (Some(_), Some(_)) => unreachable!("union/either types can't have sub_nodes"), - (None, None) => SubNodes::None, - (variants, sub_nodes) => { - let Some(select) = select else { - return Err(SelectionError::MissingSubNodes { + CompositeSelection::Union(mut variant_select) => { + let Some(variants) = variants else { + return Err(SelectionError::MissingUnion { path: format!("{parent_path}.{instance_name}"), }); }; - match select { - CompositeSelection::Atomic(select) => { - let Some(sub_nodes) = sub_nodes else { - return Err(SelectionError::UnexpectedUnion { - path: format!("{parent_path}.{instance_name}"), - }); - }; - SubNodes::Atomic(selection_to_node_set( + let mut out = HashMap::new(); + for (variant_ty, variant_meta) in variants { + let variant_meta = variant_meta(); + // this union member is a scalar + let Some(sub_nodes) = variant_meta.sub_nodes else { + continue; + }; + let mut nodes = if let Some(select) = variant_select.remove(variant_ty) { + selection_to_node_set( select, - sub_nodes, - format!("{parent_path}.{instance_name}"), - )?) - } - CompositeSelection::Union(variant_select) => { - let Some(variants) = variants else { - return Err(SelectionError::MissingUnion { - path: format!("{parent_path}.{instance_name}"), - }); - }; - let mut out = HashMap::new(); - for (variant_ty, select) in variant_select { - let Some(variant_meta) = variants.get(&variant_ty[..]) else { - return Err(SelectionError::UnexpectedVariant { - path: format!("{parent_path}.{instance_name}"), - varaint_ty: variant_ty.clone(), - }); - }; - let variant_meta = variant_meta(); - // this union member is a scalar - let Some(sub_nodes) = variant_meta.sub_nodes else { - continue; - }; - let nodes = selection_to_node_set( - select, - &sub_nodes, - format!("{parent_path}.{instance_name}"), - )?; - out.insert(variant_ty, nodes); - } - SubNodes::Union(out) - } + &sub_nodes, + format!("{parent_path}.{instance_name}.variant({variant_ty})"), + )? + } else { + vec![] + }; + nodes.push(SelectNodeErased { + node_name: "__typename".into(), + instance_name: "__typename".into(), + args: None, + sub_nodes: SubNodes::None, + }); + out.insert(variant_ty.clone(), nodes); + } + if !variant_select.is_empty() { + return Err(SelectionError::UnexpectedVariants { + path: format!("{parent_path}.{instance_name}"), + varaint_tys: variant_select.into_keys().collect(), + }); } + SubNodes::Union(out) } - }; - - out.push(SelectNodeErased { - node_name: node_name.clone(), - instance_name, - args, - sub_nodes, - }) + } } - } - Ok(out) + }; + Ok(SelectNodeErased { + node_name, + instance_name, + args, + sub_nodes, + }) } #[derive(Debug)] pub enum SelectionError { - MissingArgs { path: String }, - MissingSubNodes { path: String }, - MissingUnion { path: String }, - UnexpectedArgs { path: String, name: String }, - UnexpectedUnion { path: String }, - UnexpectedVariant { path: String, varaint_ty: CowStr }, + MissingArgs { + path: String, + }, + MissingSubNodes { + path: String, + }, + MissingUnion { + path: String, + }, + UnexpectedArgs { + path: String, + name: String, + }, + UnexpectedUnion { + path: String, + }, + UnexpectedVariants { + path: String, + varaint_tys: Vec, + }, } impl std::fmt::Display for SelectionError { @@ -187,8 +234,14 @@ impl std::fmt::Display for SelectionError { f, "node at {path} is an atomic type but union selection provided" ), - SelectionError::UnexpectedVariant { path, varaint_ty } => { - write!(f, "node at {path} has no variant called '{varaint_ty}'") + SelectionError::UnexpectedVariants { + path, + varaint_tys: varaint_ty, + } => { + write!( + f, + "node at {path} has none of the variants called '{varaint_ty:?}'" + ) } } } @@ -1004,8 +1057,8 @@ macro_rules! impl_selection_traits { macro_rules! impl_union_selection_traits { ($ty:ident,$(($variant_ty:tt, $field:tt)),+) => { impl From<$ty> for CompositeSelection { - fn from(_value: $ty) -> CompositeSelection { - /*CompositeSelection::Union( + fn from(value: $ty) -> CompositeSelection { + CompositeSelection::Union( [ $({ let selection = @@ -1016,8 +1069,7 @@ macro_rules! impl_union_selection_traits { .into_iter() .filter_map(|val| val) .collect(), - )*/ - panic!("unions/either are wip") + ) } } }; @@ -2166,11 +2218,24 @@ mod node_metas { ), } } - pub fn RootGetUserFn() -> NodeMeta { - NodeMeta { ..User() } + pub fn RootMixedUnionFnOutput() -> NodeMeta { + NodeMeta { + arg_types: None, + sub_nodes: None, + variants: Some( + [ + ("post".into(), Post as NodeMetaFn), + ("user".into(), User as NodeMetaFn), + ] + .into(), + ), + } } - pub fn RootScalarNoArgsFn() -> NodeMeta { - NodeMeta { ..scalar() } + pub fn RootMixedUnionFn() -> NodeMeta { + NodeMeta { + arg_types: Some([("id".into(), "RootScalarNoArgsFnOutput".into())].into()), + ..RootMixedUnionFnOutput() + } } pub fn RootScalarArgsFn() -> NodeMeta { NodeMeta { @@ -2185,18 +2250,49 @@ mod node_metas { ..scalar() } } + pub fn RootCompositeArgsFn() -> NodeMeta { + NodeMeta { + arg_types: Some([("id".into(), "RootScalarNoArgsFnOutput".into())].into()), + ..Post() + } + } + pub fn RootScalarNoArgsFn() -> NodeMeta { + NodeMeta { ..scalar() } + } + pub fn RootGetUserFn() -> NodeMeta { + NodeMeta { ..User() } + } pub fn RootGetPostsFn() -> NodeMeta { NodeMeta { ..Post() } } - pub fn RootCompositeNoArgsFn() -> NodeMeta { - NodeMeta { ..Post() } + pub fn RootCompositeUnionFnOutput() -> NodeMeta { + NodeMeta { + arg_types: None, + sub_nodes: None, + variants: Some( + [ + ("post".into(), Post as NodeMetaFn), + ("user".into(), User as NodeMetaFn), + ] + .into(), + ), + } } - pub fn RootCompositeArgsFn() -> NodeMeta { + pub fn RootCompositeUnionFn() -> NodeMeta { NodeMeta { arg_types: Some([("id".into(), "RootScalarNoArgsFnOutput".into())].into()), - ..Post() + ..RootCompositeUnionFnOutput() } } + pub fn RootScalarUnionFn() -> NodeMeta { + NodeMeta { + arg_types: Some([("id".into(), "RootScalarNoArgsFnOutput".into())].into()), + ..scalar() + } + } + pub fn RootCompositeNoArgsFn() -> NodeMeta { + NodeMeta { ..Post() } + } } use types::*; pub mod types { @@ -2221,6 +2317,27 @@ pub mod types { pub email: Option, pub posts: Option, } + pub type RootScalarUnionFnOutputT1Integer = i64; + #[derive(Debug, serde::Serialize, serde::Deserialize)] + #[serde(untagged)] + pub enum RootScalarUnionFnOutput { + RootScalarNoArgsFnOutput(RootScalarNoArgsFnOutput), + RootScalarUnionFnOutputT1Integer(RootScalarUnionFnOutputT1Integer), + } + #[derive(Debug, serde::Serialize, serde::Deserialize)] + #[serde(untagged)] + pub enum RootCompositeUnionFnOutput { + PostPartial(PostPartial), + UserPartial(UserPartial), + } + #[derive(Debug, serde::Serialize, serde::Deserialize)] + #[serde(untagged)] + pub enum RootMixedUnionFnOutput { + PostPartial(PostPartial), + UserPartial(UserPartial), + RootScalarNoArgsFnOutput(RootScalarNoArgsFnOutput), + RootScalarUnionFnOutputT1Integer(RootScalarUnionFnOutputT1Integer), + } } #[derive(Default, Debug)] pub struct PostSelections { @@ -2236,6 +2353,26 @@ pub struct UserSelections { pub posts: CompositeSelect, ATy>, } impl_selection_traits!(UserSelections, id, email, posts); +#[derive(Default, Debug)] +pub struct RootCompositeUnionFnOutputSelections { + pub post: CompositeSelect, NoAlias>, + pub user: CompositeSelect, NoAlias>, +} +impl_union_selection_traits!( + RootCompositeUnionFnOutputSelections, + ("post", post), + ("user", user) +); +#[derive(Default, Debug)] +pub struct RootMixedUnionFnOutputSelections { + pub post: CompositeSelect, NoAlias>, + pub user: CompositeSelect, NoAlias>, +} +impl_union_selection_traits!( + RootMixedUnionFnOutputSelections, + ("post", post), + ("user", user) +); impl QueryGraph { pub fn new(addr: Url) -> Self { @@ -2246,6 +2383,8 @@ impl QueryGraph { ("UserIdStringUuid".into(), "ID!".into()), ("PostSlugString".into(), "String!".into()), ("RootScalarNoArgsFnOutput".into(), "String!".into()), + ("post".into(), "post!".into()), + ("user".into(), "user!".into()), ] .into(), ), @@ -2328,4 +2467,58 @@ impl QueryGraph { _marker: PhantomData, } } + pub fn scalar_union( + &self, + args: impl Into>, + ) -> QueryNode { + let nodes = selection_to_node_set( + SelectionErasedMap( + [( + "scalarUnion".into(), + SelectionErased::ScalarArgs(args.into().into()), + )] + .into(), + ), + &[( + "scalarUnion".into(), + node_metas::RootScalarUnionFn as NodeMetaFn, + )] + .into(), + "$q".into(), + ) + .unwrap(); + QueryNode(nodes.into_iter().next().unwrap(), PhantomData) + } + pub fn composite_union( + &self, + args: impl Into>, + ) -> UnselectedNode< + RootCompositeUnionFnOutputSelections, + RootCompositeUnionFnOutputSelections, + QueryMarker, + RootCompositeUnionFnOutput, + > { + UnselectedNode { + root_name: "compositeUnion".into(), + root_meta: node_metas::RootCompositeUnionFn, + args: args.into().into(), + _marker: PhantomData, + } + } + pub fn mixed_union( + &self, + args: impl Into>, + ) -> UnselectedNode< + RootMixedUnionFnOutputSelections, + RootMixedUnionFnOutputSelections, + QueryMarker, + RootMixedUnionFnOutput, + > { + UnselectedNode { + root_name: "mixedUnion".into(), + root_meta: node_metas::RootMixedUnionFn, + args: args.into().into(), + _marker: PhantomData, + } + } } diff --git a/tests/metagen/typegraphs/sample/rs/main.rs b/tests/metagen/typegraphs/sample/rs/main.rs index dbd988eeb1..2bcf3358fc 100644 --- a/tests/metagen/typegraphs/sample/rs/main.rs +++ b/tests/metagen/typegraphs/sample/rs/main.rs @@ -104,37 +104,37 @@ fn main() -> Result<(), BoxErr> { )) .await?; - /* let res5 = gql - .query(( - api1.scalar_union(types::Object28Partial { - id: Some("94be5420-8c4a-4e67-b4f4-e1b2b54832a2".into()), - }), - // allows ignoring some members - api1.composite_union(types::Object28Partial { - id: Some("94be5420-8c4a-4e67-b4f4-e1b2b54832a2".into()), - }) - .select(Union9Selections { - post: select(all()), - ..default() - })?, - // returns empty if returned type wasn't selected - // in union member - api1.composite_union(types::Object28Partial { - id: Some("94be5420-8c4a-4e67-b4f4-e1b2b54832a2".into()), - }) - .select(Union9Selections { - user: select(all()), - ..default() - })?, - api1.mixed_union(types::Object28Partial { - id: Some("94be5420-8c4a-4e67-b4f4-e1b2b54832a2".into()), - }) - .select(Union15Selections { - post: select(all()), - user: select(all()), - })?, - )) - .await?; */ + let res5 = gql + .query(( + api1.scalar_union(types::RootCompositeArgsFnInputPartial { + id: Some("94be5420-8c4a-4e67-b4f4-e1b2b54832a2".into()), + }), + // allows ignoring some members + api1.composite_union(types::RootCompositeArgsFnInputPartial { + id: Some("94be5420-8c4a-4e67-b4f4-e1b2b54832a2".into()), + }) + .select(RootCompositeUnionFnOutputSelections { + post: select(all()), + ..default() + })?, + // returns empty if returned type wasn't selected + // in union member + api1.composite_union(types::RootCompositeArgsFnInputPartial { + id: Some("94be5420-8c4a-4e67-b4f4-e1b2b54832a2".into()), + }) + .select(RootCompositeUnionFnOutputSelections { + user: select(all()), + ..default() + })?, + api1.mixed_union(types::RootCompositeArgsFnInputPartial { + id: Some("94be5420-8c4a-4e67-b4f4-e1b2b54832a2".into()), + }) + .select(RootMixedUnionFnOutputSelections { + post: select(all()), + user: select(all()), + })?, + )) + .await?; println!( "{}", serde_json::to_string_pretty(&serde_json::json!([ @@ -163,12 +163,12 @@ fn main() -> Result<(), BoxErr> { "compositeNoArgs": res4.1, "compositeArgs": res4.2, }, - /* { + { "scalarUnion": res5.0, "compositeUnion1": res5.1, "compositeUnion2": res5.2, "mixedUnion": res5.3 - } */ + } ]))? ); Ok(()) diff --git a/tests/metagen/typegraphs/sample/ts/client.ts b/tests/metagen/typegraphs/sample/ts/client.ts index debe7f362e..e408ba8b9e 100644 --- a/tests/metagen/typegraphs/sample/ts/client.ts +++ b/tests/metagen/typegraphs/sample/ts/client.ts @@ -23,7 +23,7 @@ function _selectionToNodeSet( continue; } - const { argumentTypes, subNodes } = metaFn(); + const { argumentTypes, subNodes, variants } = metaFn(); const nodeInstances = nodeSelection instanceof Alias ? nodeSelection.aliases() @@ -37,7 +37,7 @@ function _selectionToNodeSet( } if (instanceSelection instanceof Alias) { throw new Error( - `nested Alias discovored at ${parentPath}.${instanceName}`, + `nested Alias discovered at ${parentPath}.${instanceName}`, ); } const node: SelectNode = { instanceName, nodeName }; @@ -73,7 +73,7 @@ function _selectionToNodeSet( } } - if (subNodes) { + if (subNodes || variants) { // sanity check selection object let subSelections = instanceSelection; if (argumentTypes) { @@ -92,6 +92,11 @@ function _selectionToNodeSet( `but selection is typeof ${typeof subSelections}`, ); } + if (subSelections == undefined) { + subSelections = { + _: selection._, + }; + } if (typeof subSelections != "object") { throw new Error( `node at ${parentPath}.${nodeName} ` + @@ -100,14 +105,53 @@ function _selectionToNodeSet( ); } - node.subNodes = _selectionToNodeSet( - // assume it's a Selection. If it's an argument - // object, mismatch between the node desc should hopefully - // catch it - subSelections as Selection, - subNodes, - `${parentPath}.${instanceName}`, - ); + if (subNodes) { + if (variants) { + throw new Error( + "unreachable: union/either NodeMetas can't have subnodes", + ); + } + node.subNodes = _selectionToNodeSet( + // assume it's a Selection. If it's an argument + // object, mismatch between the node desc should hopefully + // catch it + subSelections as Selection, + subNodes, + `${parentPath}.${instanceName}`, + ); + } else { + const unionSelections = {} as Record; + const foundVariants = new Set([...Object.keys(subSelections)]); + for (const [variantTy, variant_meta_fn] of variants!) { + const variant_meta = variant_meta_fn(); + // this union member is a scalar + if (!variant_meta.subNodes) { + continue; + } + foundVariants.delete(variantTy); + const variant_select = subSelections[variantTy]; + const nodes = variant_select + ? _selectionToNodeSet( + variant_select as Selection, + variant_meta.subNodes, + `${parentPath}.${instanceName}.variant(${variantTy})`, + ) + : []; + nodes.push({ + nodeName: "__typename", + instanceName: "__typename", + }); + unionSelections[variantTy] = nodes; + } + if (foundVariants.size > 0) { + throw new Error( + `node at ${parentPath}.${instanceName} ` + + "has none of the variants called " + + [...foundVariants.keys()], + ); + } + node.subNodes = unionSelections; + } } out.push(node); @@ -126,11 +170,13 @@ function _selectionToNodeSet( /* Query node types section */ +type SubNodes = undefined | SelectNode[] | Record; + type SelectNode<_Out = unknown> = { nodeName: string; instanceName: string; args?: NodeArgs; - subNodes?: SelectNode[]; + subNodes?: SubNodes; }; export class QueryNode { @@ -170,6 +216,7 @@ type QueryDocOut = T extends type NodeMeta = { subNodes?: [string, () => NodeMeta][]; + variants?: [string, () => NodeMeta][]; argumentTypes?: { [name: string]: string }; }; @@ -297,6 +344,7 @@ export type GraphQlTransportOptions = Omit & { }; function convertQueryNodeGql( + typeToGqlTypeMap: Record, node: SelectNode, variables: Map, ) { @@ -319,9 +367,31 @@ function convertQueryNodeGql( const subNodes = node.subNodes; if (subNodes) { - out = `${out} { ${ - subNodes.map((node) => convertQueryNodeGql(node, variables)).join(" ") - } }`; + if (Array.isArray(subNodes)) { + out = `${out} { ${ + subNodes.map((node) => + convertQueryNodeGql(typeToGqlTypeMap, node, variables) + ).join(" ") + } }`; + } else { + out = `${out} { ${ + Object.entries(subNodes).map(([variantTy, subNodes]) => { + let gqlTy = typeToGqlTypeMap[variantTy]; + if (!gqlTy) { + throw new Error( + `unreachable: no graphql type found for variant ${variantTy}`, + ); + } + gqlTy = gqlTy.replace(/[!]+$/, ""); + + return `... on ${gqlTy} {${ + subNodes.map((node) => + convertQueryNodeGql(typeToGqlTypeMap, node, variables) + ).join(" ") + }}`; + }).join(" ") + } }`; + } } return out; } @@ -330,6 +400,7 @@ function buildGql( typeToGqlTypeMap: Record, query: Record, ty: "query" | "mutation", + // deno-lint-ignore no-inferrable-types name: string = "", ) { const variables = new Map(); @@ -338,12 +409,12 @@ function buildGql( .entries(query) .map(([key, node]) => { const fixedNode = { ...node, instanceName: key }; - return convertQueryNodeGql(fixedNode, variables); + return convertQueryNodeGql(typeToGqlTypeMap, fixedNode, variables); }) .join("\n "); let argsRow = [...variables.entries()] - .map(([key, val]) => `$${key}: ${typeToGqlTypeMap[val.typeName]}`) + .map(([key, val]) => `$${key}: ${typeToGqlTypeMap[val.typeName]} `) .join(", "); if (argsRow.length > 0) { // graphql doesn't like empty parentheses so we only @@ -353,7 +424,7 @@ function buildGql( const doc = `${ty} ${name}${argsRow} { ${rootNodes} -}`; + } `; return { doc, variables: Object.fromEntries( @@ -385,9 +456,9 @@ async function fetchGql( }), }); if (!res.ok) { - const body = await res.text().catch((err) => `error reading body: ${err}`); + const body = await res.text().catch((err) => `error reading body: ${err} `); throw new (Error as ErrorPolyfill)( - `graphql request to ${addr} failed with status ${res.status}: ${body}`, + `graphql request to ${addr} failed with status ${res.status}: ${body} `, { cause: { response: res, @@ -402,7 +473,7 @@ async function fetchGql( { cause: { response: res, - body: await res.text().catch((err) => `error reading body: ${err}`), + body: await res.text().catch((err) => `error reading body: ${err} `), }, }, ); @@ -656,9 +727,12 @@ const nodeMetas = { ], }; }, - RootGetPostsFn(): NodeMeta { + RootCompositeArgsFn(): NodeMeta { return { ...nodeMetas.Post(), + argumentTypes: { + id: "RootScalarNoArgsFnOutput", + }, }; }, RootScalarNoArgsFn(): NodeMeta { @@ -666,29 +740,24 @@ const nodeMetas = { ...nodeMetas.scalar(), }; }, - RootScalarArgsFn(): NodeMeta { - return { - ...nodeMetas.scalar(), - argumentTypes: { - id: "UserIdStringUuid", - slug: "PostSlugString", - title: "PostSlugString", - }, - }; - }, RootCompositeNoArgsFn(): NodeMeta { return { ...nodeMetas.Post(), }; }, - RootCompositeArgsFn(): NodeMeta { + RootScalarUnionFn(): NodeMeta { return { - ...nodeMetas.Post(), + ...nodeMetas.scalar(), argumentTypes: { id: "RootScalarNoArgsFnOutput", }, }; }, + RootGetPostsFn(): NodeMeta { + return { + ...nodeMetas.Post(), + }; + }, User(): NodeMeta { return { subNodes: [ @@ -698,11 +767,53 @@ const nodeMetas = { ], }; }, + RootMixedUnionFnOutput(): NodeMeta { + return { + variants: [ + ["post", nodeMetas.Post], + ["user", nodeMetas.User], + ], + }; + }, + RootMixedUnionFn(): NodeMeta { + return { + ...nodeMetas.RootMixedUnionFnOutput(), + argumentTypes: { + id: "RootScalarNoArgsFnOutput", + }, + }; + }, RootGetUserFn(): NodeMeta { return { ...nodeMetas.User(), }; }, + RootCompositeUnionFnOutput(): NodeMeta { + return { + variants: [ + ["post", nodeMetas.Post], + ["user", nodeMetas.User], + ], + }; + }, + RootCompositeUnionFn(): NodeMeta { + return { + ...nodeMetas.RootCompositeUnionFnOutput(), + argumentTypes: { + id: "RootScalarNoArgsFnOutput", + }, + }; + }, + RootScalarArgsFn(): NodeMeta { + return { + ...nodeMetas.scalar(), + argumentTypes: { + id: "UserIdStringUuid", + slug: "PostSlugString", + title: "PostSlugString", + }, + }; + }, }; export type UserIdStringUuid = string; export type PostSlugString = string; @@ -722,6 +833,18 @@ export type User = { email: UserEmailStringEmail; posts: UserPostsPostList; }; +export type RootCompositeUnionFnOutput = + | (Post) + | (User); +export type RootScalarUnionFnOutputT1Integer = number; +export type RootMixedUnionFnOutput = + | (Post) + | (User) + | (RootScalarNoArgsFnOutput) + | (RootScalarUnionFnOutputT1Integer); +export type RootScalarUnionFnOutput = + | (RootScalarNoArgsFnOutput) + | (RootScalarUnionFnOutputT1Integer); export type PostSelections = { _?: SelectionFlags; @@ -735,6 +858,16 @@ export type UserSelections = { email?: ScalarSelectNoArgs; posts?: CompositeSelectNoArgs; }; +export type RootMixedUnionFnOutputSelections = { + _?: SelectionFlags; + "post"?: CompositeSelectNoArgs; + "user"?: CompositeSelectNoArgs; +}; +export type RootCompositeUnionFnOutputSelections = { + _?: SelectionFlags; + "post"?: CompositeSelectNoArgs; + "user"?: CompositeSelectNoArgs; +}; export class QueryGraph extends _QueryGraphBase { constructor() { @@ -742,6 +875,8 @@ export class QueryGraph extends _QueryGraphBase { "UserIdStringUuid": "ID!", "PostSlugString": "String!", "RootScalarNoArgsFnOutput": "String!", + "post": "post!", + "user": "user!", }); } @@ -793,4 +928,28 @@ export class QueryGraph extends _QueryGraphBase { )[0]; return new MutationNode(inner) as MutationNode; } + scalarUnion(args: RootCompositeArgsFnInput | PlaceholderArgs) { + const inner = _selectionToNodeSet( + { "scalarUnion": args }, + [["scalarUnion", nodeMetas.RootScalarUnionFn]], + "$q", + )[0]; + return new QueryNode(inner) as QueryNode; + } + compositeUnion(args: RootCompositeArgsFnInput | PlaceholderArgs, select: RootCompositeUnionFnOutputSelections) { + const inner = _selectionToNodeSet( + { "compositeUnion": [args, select] }, + [["compositeUnion", nodeMetas.RootCompositeUnionFn]], + "$q", + )[0]; + return new QueryNode(inner) as QueryNode; + } + mixedUnion(args: RootCompositeArgsFnInput | PlaceholderArgs, select: RootMixedUnionFnOutputSelections) { + const inner = _selectionToNodeSet( + { "mixedUnion": [args, select] }, + [["mixedUnion", nodeMetas.RootMixedUnionFn]], + "$q", + )[0]; + return new QueryNode(inner) as QueryNode; + } } diff --git a/tests/metagen/typegraphs/sample/ts/main.ts b/tests/metagen/typegraphs/sample/ts/main.ts index e8abfc47e1..987577de1d 100644 --- a/tests/metagen/typegraphs/sample/ts/main.ts +++ b/tests/metagen/typegraphs/sample/ts/main.ts @@ -82,4 +82,34 @@ const res4 = await gqlClient.mutation({ }), }); -console.log(JSON.stringify([res1, res1a, res2, res3, res4])); +const res5 = await gqlClient.query({ + scalarUnion: api1.scalarUnion({ + id: "94be5420-8c4a-4e67-b4f4-e1b2b54832a2", + }), + compositeUnion1: api1.compositeUnion({ + id: "94be5420-8c4a-4e67-b4f4-e1b2b54832a2", + }, { + post: { + "_": "selectAll", + }, + }), + compositeUnion2: api1.compositeUnion({ + id: "94be5420-8c4a-4e67-b4f4-e1b2b54832a2", + }, { + user: { + "_": "selectAll", + }, + }), + mixedUnion: api1.mixedUnion({ + id: "94be5420-8c4a-4e67-b4f4-e1b2b54832a2", + }, { + post: { + "_": "selectAll", + }, + user: { + "_": "selectAll", + }, + }), +}); + +console.log(JSON.stringify([res1, res1a, res2, res3, res4, res5]));