From 0f058459d1413363191364a36ec4372951faf7b1 Mon Sep 17 00:00:00 2001 From: Natoandro Date: Tue, 16 May 2023 01:12:02 +0300 Subject: [PATCH] Query result validator (#304) * Refactoring validator * Move code_generator out in a dedicated file * Draft result validator * Fix typecheck test * Remove unwanted fields * Fix unions * Update deno.lock * Field inclusion, fix unions * Rename interface * Fix test * Integer validation * Fix tests * Move the computation into into its own module * Apply suggestions * Add some documentation comments * Test nested unions * Test selection sets * Test boolean validator * Test multilevel unions * Fix merge confusions * Fix tests * Fix tests * Fix tests --- libs/common/src/typegraph/types.rs | 25 + libs/common/src/typegraph/validator.rs | 87 +++- libs/common/src/typegraph/visitor.rs | 75 ++- meta-cli/src/codegen/deno.rs | 2 +- meta-cli/src/tests/typegraphs/union_node.py | 6 +- ...e__e2e__validator__invalid_injections.snap | 11 + meta-cli/tests/e2e/validator.rs | 4 +- typegate/deno.lock | 63 +-- typegate/src/engine.ts | 173 +------ typegate/src/engine/computation_engine.ts | 287 +++++++++++ typegate/src/planner/args.ts | 113 +---- typegate/src/planner/mod.ts | 370 +++++++++------ typegate/src/typecheck.ts | 384 --------------- typegate/src/typecheck/code_generator.ts | 294 ++++++++++++ typegate/src/typecheck/common.ts | 60 +++ typegate/src/typecheck/input.ts | 445 +++-------------- typegate/src/typecheck/matching_variant.ts | 157 ++++++ typegate/src/typecheck/result.ts | 447 ++++++++++++++++++ typegate/src/typegraph.ts | 95 +--- typegate/src/typegraph/visitor.ts | 4 + typegate/src/types.ts | 2 + .../__snapshots__/union_either_test.ts.snap | 4 - typegate/tests/introspection/union_either.py | 2 +- typegate/tests/planner/planner_test.ts | 34 +- typegate/tests/schema_validation/circular.py | 3 +- .../tests/schema_validation/circular_test.ts | 2 +- .../__snapshots__/class_syntax_test.ts.snap | 4 +- typegate/tests/simple/class_syntax.py | 2 +- typegate/tests/simple/class_syntax_test.ts | 2 +- .../__snapshots__/either_test.ts.snap | 8 +- .../__snapshots__/union_test.ts.snap | 312 +++++++++++- typegate/tests/type_nodes/either_node.py | 6 +- typegate/tests/type_nodes/either_test.ts | 53 ++- .../type_nodes/ts/union/color_converter.ts | 94 ++-- typegate/tests/type_nodes/union_node.py | 114 ++++- typegate/tests/type_nodes/union_node_attr.py | 14 +- .../tests/type_nodes/union_node_attr_test.ts | 39 +- .../tests/type_nodes/union_node_quantifier.py | 2 +- .../type_nodes/union_node_quantifier_test.ts | 24 +- typegate/tests/type_nodes/union_test.ts | 151 +++++- .../__snapshots__/typecheck_test.ts.snap | 272 +++++++++++ typegate/tests/typecheck/typecheck_test.ts | 124 ++--- typegate/tests/utils.ts | 1 + typegate/tests/utils/metatest.ts | 14 + typegate/tests/vars/vars_test.ts | 8 +- 45 files changed, 2839 insertions(+), 1554 deletions(-) create mode 100644 typegate/src/engine/computation_engine.ts delete mode 100644 typegate/src/typecheck.ts create mode 100644 typegate/src/typecheck/code_generator.ts create mode 100644 typegate/src/typecheck/common.ts create mode 100644 typegate/src/typecheck/matching_variant.ts create mode 100644 typegate/src/typecheck/result.ts create mode 100644 typegate/tests/typecheck/__snapshots__/typecheck_test.ts.snap diff --git a/libs/common/src/typegraph/types.rs b/libs/common/src/typegraph/types.rs index c49bb7f8fa..199c8d3b64 100644 --- a/libs/common/src/typegraph/types.rs +++ b/libs/common/src/typegraph/types.rs @@ -264,4 +264,29 @@ impl TypeNode { | Any { base, .. } => base, } } + + pub fn type_name(&self) -> &'static str { + use TypeNode::*; + match self { + Optional { .. } => "optional", + Boolean { .. } => "booleal", + Number { .. } => "number", + Integer { .. } => "integer", + String { .. } => "string", + Object { .. } => "object", + Array { .. } => "array", + Function { .. } => "function", + Union { .. } => "union", + Either { .. } => "either", + Any { .. } => "any", + } + } + + pub fn is_scalar(&self) -> bool { + use TypeNode::*; + matches!( + self, + Boolean { .. } | Number { .. } | Integer { .. } | String { .. } + ) + } } diff --git a/libs/common/src/typegraph/validator.rs b/libs/common/src/typegraph/validator.rs index f0366001a8..c54b19b5a1 100644 --- a/libs/common/src/typegraph/validator.rs +++ b/libs/common/src/typegraph/validator.rs @@ -28,10 +28,10 @@ struct Validator { } impl Validator { - fn push_error(&mut self, path: &[PathSegment], message: String) { + fn push_error(&mut self, path: &[PathSegment], message: impl Into) { self.errors.push(ValidatorError { path: Path(path).to_string(), - message, + message: message.into(), }); } } @@ -40,6 +40,25 @@ fn to_string(value: &Value) -> String { serde_json::to_string(value).unwrap() } +impl Typegraph { + fn collect_nested_variants_into(&self, out: &mut Vec, variants: &[u32]) { + for idx in variants { + let node = self.types.get(*idx as usize).unwrap(); + match node { + TypeNode::Union { + data: UnionTypeData { any_of: variants }, + .. + } + | TypeNode::Either { + data: EitherTypeData { one_of: variants }, + .. + } => self.collect_nested_variants_into(out, variants), + _ => out.push(*idx), + } + } + } +} + impl TypeVisitor for Validator { type Return = Vec; @@ -48,9 +67,73 @@ impl TypeVisitor for Validator { type_idx: u32, path: &[PathSegment], tg: &Typegraph, + as_input: bool, ) -> VisitResult { let node = &tg.types[type_idx as usize]; + if as_input { + // do not allow t.func in input types + if let TypeNode::Function { .. } = node { + self.push_error(path, "function is not allowed in input types"); + return VisitResult::Continue(false); + } + } else { + match node { + TypeNode::Union { .. } | TypeNode::Either { .. } => { + let mut variants = vec![]; + tg.collect_nested_variants_into(&mut variants, &[type_idx]); + let mut object_count = 0; + + for variant_type in variants + .iter() + .map(|idx| tg.types.get(*idx as usize).unwrap()) + { + match variant_type { + TypeNode::Object { .. } => object_count += 1, + TypeNode::Boolean { .. } + | TypeNode::Number { .. } + | TypeNode::Integer { .. } + | TypeNode::String { .. } => { + // scalar + } + TypeNode::Array { data, .. } => { + let item_type = tg.types.get(data.items as usize).unwrap(); + if !item_type.is_scalar() { + self.push_error( + path, + format!( + "array of '{}' not allowed as union/either variant", + item_type.type_name() + ), + ); + return VisitResult::Continue(false); + } + } + _ => { + self.push_error( + path, + format!( + "type '{}' not allowed as union/either variant", + variant_type.type_name() + ), + ); + return VisitResult::Continue(false); + } + } + } + + if object_count != 0 && object_count != variants.len() { + self.push_error( + path, + "union variants must either be all scalars or all objects", + ); + return VisitResult::Continue(false); + } + } + _ => {} + } + } + if let Some(enumeration) = &node.base().enumeration { if matches!(node, TypeNode::Optional { .. }) { self.push_error( diff --git a/libs/common/src/typegraph/visitor.rs b/libs/common/src/typegraph/visitor.rs index b5265a6648..2135c062be 100644 --- a/libs/common/src/typegraph/visitor.rs +++ b/libs/common/src/typegraph/visitor.rs @@ -11,7 +11,9 @@ impl Typegraph { let mut traversal = TypegraphTraversal { tg: self, path: vec![], + as_input: false, visited_types: HashSet::new(), + visited_input_types: HashSet::new(), visitor, }; traversal @@ -28,18 +30,31 @@ impl Typegraph { struct TypegraphTraversal<'a, V: TypeVisitor + Sized> { tg: &'a Typegraph, path: Vec>, - visited_types: HashSet, + as_input: bool, + visited_types: HashSet, // non input types + visited_input_types: HashSet, visitor: V, } impl<'a, V: TypeVisitor + Sized> TypegraphTraversal<'a, V> { fn visit_type(&mut self, type_idx: u32) -> Option { - if self.visited_types.contains(&type_idx) { - return None; + if self.as_input { + if self.visited_input_types.contains(&type_idx) { + return None; + } + self.visited_input_types.insert(type_idx); + } else { + if self.visited_types.contains(&type_idx) { + return None; + } + self.visited_types.insert(type_idx); } - self.visited_types.insert(type_idx); - let res = self.visitor.visit(type_idx, &self.path, self.tg); + + let res = self + .visitor + .visit(type_idx, &self.path, self.tg, self.as_input); let type_node = &self.tg.types[type_idx as usize]; + match res { VisitResult::Continue(deeper) if deeper => match type_node { TypeNode::Optional { data, .. } => self.visit_optional(type_idx, data.item), @@ -71,6 +86,7 @@ impl<'a, V: TypeVisitor + Sized> TypegraphTraversal<'a, V> { edge: Edge::OptionalItem, }, item_type_idx, + false, ) } @@ -81,6 +97,7 @@ impl<'a, V: TypeVisitor + Sized> TypegraphTraversal<'a, V> { edge: Edge::ArrayItem, }, item_type_idx, + false, ) } @@ -96,6 +113,7 @@ impl<'a, V: TypeVisitor + Sized> TypegraphTraversal<'a, V> { edge: Edge::ObjectProp(prop_name), }, *prop_type, + false, ); if let Some(res) = res { return Some(res); @@ -112,6 +130,7 @@ impl<'a, V: TypeVisitor + Sized> TypegraphTraversal<'a, V> { edge: Edge::UnionVariant(i), }, *variant_type, + false, ); if let Some(ret) = res { return Some(ret); @@ -128,28 +147,47 @@ impl<'a, V: TypeVisitor + Sized> TypegraphTraversal<'a, V> { edge: Edge::EitherVariant(i), }, *t, + false, ) }) } fn visit_function(&mut self, type_idx: u32, input: u32, output: u32) -> Option { - [(Edge::FunctionInput, input), (Edge::FunctionOutput, output)] - .into_iter() - .find_map(|(edge, t)| { - self.visit_child( - PathSegment { - from: type_idx, - edge, - }, - t, - ) - }) + [ + (Edge::FunctionInput, input, true), + (Edge::FunctionOutput, output, false), + ] + .into_iter() + .find_map(|(edge, t, as_input)| { + self.visit_child( + PathSegment { + from: type_idx, + edge, + }, + t, + as_input, + ) + }) } - fn visit_child(&mut self, segment: PathSegment<'a>, type_idx: u32) -> Option { + fn visit_child( + &mut self, + segment: PathSegment<'a>, + type_idx: u32, + as_input: bool, + ) -> Option { + let root_input = as_input && !self.as_input; + if root_input { + self.as_input = as_input; + } + self.path.push(segment); let res = self.visit_type(type_idx); self.path.pop().unwrap(); + + if root_input { + self.as_input = false; + } res } } @@ -204,13 +242,16 @@ pub enum VisitResult { pub trait TypeVisitor { type Return: Sized; + /// return true to continue the traversal on the subgraph fn visit( &mut self, type_idx: u32, path: &[PathSegment], tg: &Typegraph, + as_input: bool, ) -> VisitResult; + fn get_result(self) -> Option where Self: Sized, diff --git a/meta-cli/src/codegen/deno.rs b/meta-cli/src/codegen/deno.rs index 83245d103c..e76c5dea7f 100644 --- a/meta-cli/src/codegen/deno.rs +++ b/meta-cli/src/codegen/deno.rs @@ -483,7 +483,7 @@ mod tests { ensure_venv()?; let test_folder = Path::new("./src/tests/typegraphs").normalize()?; std::env::set_current_dir(&test_folder)?; - trace!("Test folder: {test_folder:?}"); + trace!("Test folder: {:?}", test_folder.as_path()); let tests = fs::read_dir(&test_folder).unwrap(); let config = Config::default_in("."); let config = Arc::new(config); diff --git a/meta-cli/src/tests/typegraphs/union_node.py b/meta-cli/src/tests/typegraphs/union_node.py index fc33f0c89f..20fc5c6459 100644 --- a/meta-cli/src/tests/typegraphs/union_node.py +++ b/meta-cli/src/tests/typegraphs/union_node.py @@ -3,8 +3,8 @@ from typegraph.runtimes.deno import ModuleMat with TypeGraph("union") as g: - rgb = t.array(t.integer().min(0).max(255)).min(3).max(3).named("RGB") - hex = t.string().pattern("^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$").named("HEX") + rgb = t.array(t.integer().min(0).max(255)).min(3).max(3).named("RGBArray") + hex = t.string().pattern("^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$").named("HexColor") colorName = ( t.string() .enum( @@ -18,7 +18,7 @@ "yellow", ] ) - .named("ColorName") + .named("NamedColor") ) color = t.union((rgb, hex, colorName)).named("Color") diff --git a/meta-cli/tests/e2e/snapshots/e2e__e2e__validator__invalid_injections.snap b/meta-cli/tests/e2e/snapshots/e2e__e2e__validator__invalid_injections.snap index f2e189d0ed..2e4f43a543 100644 --- a/meta-cli/tests/e2e/snapshots/e2e__e2e__validator__invalid_injections.snap +++ b/meta-cli/tests/e2e/snapshots/e2e__e2e__validator__invalid_injections.snap @@ -9,9 +9,20 @@ expression: "String::from_utf8(output.stderr).expect(\"failed to decode output\" [ERROR] at validator:/test/[in]/e: Required field "a" not found in object '{}' [ERROR] at validator:/test/[in]/f: Required field "a" not found in object '{"b":1}' [ERROR] at validator:/test/[in]/g: Unexpected fields "b" in object "{\"a\":2,\"b\":1}" +[ERROR] at validator:/test/[out]/a: Expected number got '"1"' +[ERROR] at validator:/test/[out]/b: Expected a string, got '["h","e","l","l","o"]' +[ERROR] at validator:/test/[out]/c: Expected a minimum value of 2, got 0 +[ERROR] at validator:/test/[out]/d: Expected a maximun length of 4, got "hello" (len=5) +[ERROR] at validator:/test/[out]/e: Required field "a" not found in object '{}' +[ERROR] at validator:/test/[out]/f: Required field "a" not found in object '{"b":1}' +[ERROR] at validator:/test/[out]/g: Unexpected fields "b" in object "{\"a\":2,\"b\":1}" [ERROR] at validator:/testEnums/[in]/a: Expected a minimum length of 4, got "hi" (len=2) [ERROR] at validator:/testEnums/[in]/a: Expected a string, got '12' [ERROR] at validator:/testEnums/[in]/b: Expected number got '"13"' [ERROR] at validator:/testEnums/[in]/c: optional not cannot have enumerated values +[ERROR] at validator:/testEnums/[out]/a: Expected a minimum length of 4, got "hi" (len=2) +[ERROR] at validator:/testEnums/[out]/a: Expected a string, got '12' +[ERROR] at validator:/testEnums/[out]/b: Expected number got '"13"' +[ERROR] at validator:/testEnums/[out]/c: optional not cannot have enumerated values Error: Error while post processing typegraph validator from "tests/e2e/typegraphs/validator.py": Typegraph validator failed validation diff --git a/meta-cli/tests/e2e/validator.rs b/meta-cli/tests/e2e/validator.rs index 1080944600..d67d3800c4 100644 --- a/meta-cli/tests/e2e/validator.rs +++ b/meta-cli/tests/e2e/validator.rs @@ -5,7 +5,7 @@ use std::process::Command; #[test] fn test_invalid_injections() { let status = Command::new("cargo") - .args(&["build", "-p", "meta-cli", "--quiet"]) + .args(["build", "-p", "meta-cli", "--quiet"]) .status() .expect("failed to execute build for meta-cli"); assert!(status.success(), "`cargo build -p meta-cli` failed"); @@ -13,7 +13,7 @@ fn test_invalid_injections() { let root = project_root::get_project_root().unwrap(); let bin = root.join("target/debug/meta"); let output = Command::new(bin) - .args(&["serialize", "-f", "tests/e2e/typegraphs/validator.py"]) + .args(["serialize", "-f", "tests/e2e/typegraphs/validator.py"]) .env("RUST_LOG", "error") .output() .expect("failed to execute process"); diff --git a/typegate/deno.lock b/typegate/deno.lock index 693c03037e..aa40b4aadc 100644 --- a/typegate/deno.lock +++ b/typegate/deno.lock @@ -271,6 +271,7 @@ "https://deno.land/std@0.184.0/bytes/copy.ts": "939d89e302a9761dcf1d9c937c7711174ed74c59eef40a1e4569a05c9de88219", "https://deno.land/std@0.184.0/collections/_utils.ts": "5114abc026ddef71207a79609b984614e66a63a4bda17d819d56b0e72c51527e", "https://deno.land/std@0.184.0/collections/deep_merge.ts": "5a8ed29030f4471a5272785c57c3455fa79697b9a8f306013a8feae12bafc99a", + "https://deno.land/std@0.184.0/collections/distinct.ts": "5f9671702ace8bc0ca59237554e6d7509b08899338ce6f990d146d26538339ea", "https://deno.land/std@0.184.0/collections/distinct_by.ts": "30bd7af8895ac4289d2df1f7c8eb0628b4391cd310aadb861be285840af93b43", "https://deno.land/std@0.184.0/collections/filter_keys.ts": "3b99bd98d347f23b8fe8183b108077ae6e467707e80798e768c11eb86fff49c9", "https://deno.land/std@0.184.0/collections/filter_values.ts": "5b9feaf17b9a6e5ffccdd36cf6f38fa4ffa94cff2602d381c2ad0c2a97929652", @@ -419,8 +420,6 @@ "https://deno.land/x/djwt@v2.7/deps.ts": "a5d7952aaf7fad421717c9a2db0b2e736b409632cb70f3f7f9e68f8e96e04f45", "https://deno.land/x/djwt@v2.7/mod.ts": "08cb2c745c9bc33883c2d027fc4af5c157f0a30564c3ba503a56fe0ab6959c8e", "https://deno.land/x/djwt@v2.7/signature.ts": "f79b4e521cd6a6dff28cd2779b1d9f2059f9e0822fb99c9f747ff34ae26532e4", - "https://deno.land/x/json_schema_typed@v8.0.0/draft_2020_12.ts": "126783a400a891fce19c88533874b41073eb898725cb2a86a878e3b18e4e71f8", - "https://deno.land/x/json_schema_typed@v8.0.0/draft_latest.ts": "bdaa8b3aab99d2e44294b2c3a7c9ff233d628fdfe9ecf8a78294465469b85493", "https://deno.land/x/levenshtein@v1.0.1/mod.ts": "6b632d4a9bb11ba6d5d02a770c7fc9b0a4125f30bd9c668632ff85e7f05ff4f6", "https://deno.land/x/math@v1.1.0/abs.ts": "d64fe603ae4bb57037f06a1a5004285b99ed016dab6bfcded07c8155efce24b7", "https://deno.land/x/math@v1.1.0/big/big.d.ts": "34794a7d39c9c6e1ff28f395b4c9c1e0ca741e90463263ce6ae503aa39014ed2", @@ -497,65 +496,7 @@ "https://deno.land/x/zod@v3.21.4/index.ts": "d27aabd973613985574bc31f39e45cb5d856aa122ef094a9f38a463b8ef1a268", "https://deno.land/x/zod@v3.21.4/locales/en.ts": "a7a25cd23563ccb5e0eed214d9b31846305ddbcdb9c5c8f508b108943366ab4c", "https://deno.land/x/zod@v3.21.4/mod.ts": "64e55237cb4410e17d968cd08975566059f27638ebb0b86048031b987ba251c4", - "https://deno.land/x/zod@v3.21.4/types.ts": "b5d061babea250de14fc63764df5b3afa24f2b088a1d797fc060ba49a0ddff28", - "https://esm.sh/ajv-formats@2.1.1?pin=v117": "a064c0d0fc7aa47eeaf5b3ceaf464dc9a3dfd937b5ed0ff924a206b0c111601e", - "https://esm.sh/ajv@8.12.0?pin=v117": "8cf2e15c9c9d1716f5dd8e230e6763ab176ecff614d3ca881b0f726a36016ffe", - "https://esm.sh/v117/ajv-formats@2.1.1/deno/ajv-formats.mjs": "764c590b89ad898c70983d52c760e2635556ea37f78c4366840ff4f339630956", - "https://esm.sh/v117/ajv-formats@2.1.1/dist/formats.d.ts": "596db80d4c13f1673754b6373f90da4c880fbb910caa427569cef2d7674fcefd", - "https://esm.sh/v117/ajv-formats@2.1.1/dist/index.d.ts": "953601108d5153e17a4286f60404b2081aa668bc7c3bc545826104f2d4e37112", - "https://esm.sh/v117/ajv-formats@2.1.1/dist/limit.d.ts": "34bb38fc668245f2d1dc0ccd9090d226da9070f29d3529556f1f4565147ee9fc", - "https://esm.sh/v117/ajv@8.12.0/deno/ajv.mjs": "ff86a1749c7462cb1268b0b2611924996b3c9844183b0ea91b5a9c154fd4b9a1", - "https://esm.sh/v117/ajv@8.12.0/deno/dist/compile/codegen.js": "dacfb32bd32a598dea5f8bd34d4f888a349f6bb946acc3c0f244bba163992a83", - "https://esm.sh/v117/ajv@8.12.0/deno/dist/compile/codegen/code.js": "aa52161908b26a1d66a4c08d651844f5473e3cc4b89b8708de29b35dd816a4a8", - "https://esm.sh/v117/ajv@8.12.0/deno/dist/compile/codegen/scope.js": "cb2d5f163cf4039255706a55c61e2ba85df8942a4ac352b9ce58c29378581f24", - "https://esm.sh/v117/ajv@8.12.0/dist/ajv.d.ts": "e2efa41fff63778572ae1d04dc9d62048684aa8aad6edd0218e562402ea9e0ec", - "https://esm.sh/v117/ajv@8.12.0/dist/compile/codegen/code.d.ts": "2d225e7bda2871c066a7079c88174340950fb604f624f2586d3ea27bb9e5f4ff", - "https://esm.sh/v117/ajv@8.12.0/dist/compile/codegen/index.d.ts": "9785e1199bae7389a92831c1f732fa72fc6f34a9c3f8948a5acfc8451e72f833", - "https://esm.sh/v117/ajv@8.12.0/dist/compile/codegen/scope.d.ts": "4ad65126bd23e1b8fe40f5375b812676694dccc232060a53bff52ac416aef059", - "https://esm.sh/v117/ajv@8.12.0/dist/compile/errors.d.ts": "65c56e41c3ad2867c1c179e7cb8fd4fcbb4cdaa172e2cc5242fbbfab4be089c0", - "https://esm.sh/v117/ajv@8.12.0/dist/compile/index.d.ts": "dc63e8445111329e56e443e63cc892632015ddbfa76170e4959b4d876535285e", - "https://esm.sh/v117/ajv@8.12.0/dist/compile/ref_error.d.ts": "b5e33c3fb8302173827cb3ee68d45c9ff45d2e8ec2a9b347ede1f0b5e00ccdf3", - "https://esm.sh/v117/ajv@8.12.0/dist/compile/resolve.d.ts": "634cf07489f2ee4f529cbf5bf97f93128d40babcf6fccebeefe63d27dd7f4af1", - "https://esm.sh/v117/ajv@8.12.0/dist/compile/rules.d.ts": "18e085a67ae0ea81f7d4539e8f682f0b9b150b12af9e66f7760a03356aec507f", - "https://esm.sh/v117/ajv@8.12.0/dist/compile/util.d.ts": "3acaa483c7cf47fc5ced1473b830ec37a8f0d40238f3d96246657c970e13e6c7", - "https://esm.sh/v117/ajv@8.12.0/dist/compile/validate/dataType.d.ts": "4f56c2ba7307d8d5749dd4b60fadab04dce7d69838e129d04c3837cb397ce5bf", - "https://esm.sh/v117/ajv@8.12.0/dist/compile/validate/index.d.ts": "3e1bff49fa21ad6c11d06b5074fd623443dbd5db8fed3bd78411232401a0313e", - "https://esm.sh/v117/ajv@8.12.0/dist/compile/validate/subschema.d.ts": "79ae0a30e9f503b57d8c698e40f1141de1ff05d36dc9b08bc9b3ed6fe1074f9f", - "https://esm.sh/v117/ajv@8.12.0/dist/core.d.ts": "c0d24ae17225116e4895c1961a957acb2158c3ea3f2164ede7b726fc1112edf7", - "https://esm.sh/v117/ajv@8.12.0/dist/runtime/validation_error.d.ts": "515ba5900e0017f5512f442a31826d4210ac782b9b6fad6dbfcab61b5f8c881f", - "https://esm.sh/v117/ajv@8.12.0/dist/types/index.d.ts": "487babf4ebcb268dd90be76d6a2ad779978ee46b957030e8aba4e3f0f548b4d3", - "https://esm.sh/v117/ajv@8.12.0/dist/types/json-schema.d.ts": "6b6ed4aa017eb6867cef27257379cfe3e16caf628aceae3f0163dbafcaf891ff", - "https://esm.sh/v117/ajv@8.12.0/dist/types/jtd-schema.d.ts": "25f1159094dc0bf3a71313a74e0885426af21c5d6564a254004f2cadf9c5b052", - "https://esm.sh/v117/ajv@8.12.0/dist/vocabularies/applicator/additionalItems.d.ts": "810dc8272ea4023b647017ee4ff3ababce2a186c48a1e371e0fb631ed5bf9499", - "https://esm.sh/v117/ajv@8.12.0/dist/vocabularies/applicator/additionalProperties.d.ts": "93eafd639403aca489d2a2bd26fb89b46a855a271fa3685df23d3f3cfd6c7781", - "https://esm.sh/v117/ajv@8.12.0/dist/vocabularies/applicator/anyOf.d.ts": "3bcf9e93173ce018235392adf8ac6b9d4173b632d2fbd74e083f58c5e8dda50d", - "https://esm.sh/v117/ajv@8.12.0/dist/vocabularies/applicator/contains.d.ts": "e271ef23f0eecaa3bc3241429c25694aab4e47d96b8bd564d68e077ca2155251", - "https://esm.sh/v117/ajv@8.12.0/dist/vocabularies/applicator/dependencies.d.ts": "4da778139fb84dce56f1bb4ef757c17d891a9e3f0f5cff5b835ebde09ac1e956", - "https://esm.sh/v117/ajv@8.12.0/dist/vocabularies/applicator/if.d.ts": "48955ffe23bd06877c1bbc7f364a9e80f2dae56783fbfb79c16520375c376230", - "https://esm.sh/v117/ajv@8.12.0/dist/vocabularies/applicator/index.d.ts": "16bea08752cd2ca1eabbeb11ebddd265f4150cf171c1041e11a0501f35a49d4e", - "https://esm.sh/v117/ajv@8.12.0/dist/vocabularies/applicator/items2020.d.ts": "263fb5e5115873391f99a45bab2e975b0483e8a45da95bdecdbec6760c127aa4", - "https://esm.sh/v117/ajv@8.12.0/dist/vocabularies/applicator/not.d.ts": "869d7db4971ca299ff238ca0ea8b3d5cd752b449ccb6559790db0c8ad0c5dfbe", - "https://esm.sh/v117/ajv@8.12.0/dist/vocabularies/applicator/oneOf.d.ts": "385baa5077f5878b7506ec9192fd7ab686f7fda6276b71b877ecc0424c24badb", - "https://esm.sh/v117/ajv@8.12.0/dist/vocabularies/applicator/propertyNames.d.ts": "2b379d5bc77776abe065fab1e47f84929f6274e0e380c0c1c27a6965e7f4aabc", - "https://esm.sh/v117/ajv@8.12.0/dist/vocabularies/discriminator/index.d.ts": "d6f1fa129cf004b83f2913b3b3094d510e77b421f87ed6ee69f5f89b493fe91b", - "https://esm.sh/v117/ajv@8.12.0/dist/vocabularies/discriminator/types.d.ts": "cdcdcd676aa93fb9822b91569f06178c6cdcbb216f6dab289cc193ac99638838", - "https://esm.sh/v117/ajv@8.12.0/dist/vocabularies/errors.d.ts": "673f52fa2cc7f047ab6f369d12ed82088358174fd58474329d79fa999e3ce635", - "https://esm.sh/v117/ajv@8.12.0/dist/vocabularies/format/format.d.ts": "1fbffe88e41ab623cdd71ef300206298a6780ed9fd1abfd7d63f1892379c7e9a", - "https://esm.sh/v117/ajv@8.12.0/dist/vocabularies/unevaluated/unevaluatedItems.d.ts": "8b9b8c5189417cda763245be14d202e1a199091f1b0b3a0f4c957bdb85ffd044", - "https://esm.sh/v117/ajv@8.12.0/dist/vocabularies/unevaluated/unevaluatedProperties.d.ts": "4e10c976131382776b03ab8cee68e7764c9bf7db1ab9132066bb0d1f640c03fe", - "https://esm.sh/v117/ajv@8.12.0/dist/vocabularies/validation/const.d.ts": "90985bc6fed39fd595acab2f8f91ce2274b46c9ec7f68d9624d4a677fb206ae8", - "https://esm.sh/v117/ajv@8.12.0/dist/vocabularies/validation/dependentRequired.d.ts": "632b0af8992f86056e5571fbb148835d582332bb613c4e80fdf6719c501483c5", - "https://esm.sh/v117/ajv@8.12.0/dist/vocabularies/validation/enum.d.ts": "15663b84b4d935e1a282269a9ac1072c96d9351c941fa959469cd1ba2419b148", - "https://esm.sh/v117/ajv@8.12.0/dist/vocabularies/validation/index.d.ts": "d05ad2c8e4613c06e314105aaa7b43caa7a373e5f2671c51053bc9160803fbd5", - "https://esm.sh/v117/ajv@8.12.0/dist/vocabularies/validation/limitNumber.d.ts": "3b4220c15eacf28d3732f9df3a2ad0908b9a388dc9d13c35311233f11c28dd36", - "https://esm.sh/v117/ajv@8.12.0/dist/vocabularies/validation/multipleOf.d.ts": "a62b50d44d9c8d0e26b3b42eb90fe87ad1fad683122aa076e6f8d30fb056ad5f", - "https://esm.sh/v117/ajv@8.12.0/dist/vocabularies/validation/pattern.d.ts": "bc7f68367bbd6bdf9ed47aae8590770c7ec7ad6d44c0de11df696162038224b1", - "https://esm.sh/v117/ajv@8.12.0/dist/vocabularies/validation/required.d.ts": "7a14df7f7a58dd22ca4ca449f39cc0168eec566c95d9d7950e5b91da50bf4415", - "https://esm.sh/v117/ajv@8.12.0/dist/vocabularies/validation/uniqueItems.d.ts": "9a9056c3705215ba3289f7ad913e92babeca6e53091aa8095ee85e66ee2bd089", - "https://esm.sh/v117/fast-deep-equal@3.1.3/deno/fast-deep-equal.mjs": "64f660f95839c8707928b30f235061c623f279ba4ad4b4e739c767e84478e446", - "https://esm.sh/v117/json-schema-traverse@1.0.0/deno/json-schema-traverse.mjs": "8efd283bc9e47460b1629905d2666102312334a073cd3ebbff19bc4ac88a0341", - "https://esm.sh/v117/uri-js@4.4.1/deno/uri-js.mjs": "7b886b08ee81e016ce2f4d4c9e4e9759cf2c4a5d0539c4e6da14b3364312a95a", - "https://esm.sh/v117/uri-js@4.4.1/dist/es5/uri.all.d.ts": "9f3c5498245c38c9016a369795ec5ef1768d09db63643c8dba9656e5ab294825" + "https://deno.land/x/zod@v3.21.4/types.ts": "b5d061babea250de14fc63764df5b3afa24f2b088a1d797fc060ba49a0ddff28" }, "npm": { "specifiers": { diff --git a/typegate/src/engine.ts b/typegate/src/engine.ts index a8ed8f563b..6b9a83f6be 100644 --- a/typegate/src/engine.ts +++ b/typegate/src/engine.ts @@ -1,6 +1,6 @@ // Copyright Metatype OÜ under the Elastic License 2.0 (ELv2). See LICENSE.md for usage. -import { Kind, parse } from "graphql"; +import { parse } from "graphql"; import * as ast from "graphql/ast"; import { RuntimeResolver, @@ -24,13 +24,15 @@ import { Resolver, Variables, } from "./types.ts"; -import { TypeCheck } from "./typecheck.ts"; import { parseGraphQLTypeGraph } from "./graphql/graphql.ts"; import { Planner } from "./planner/mod.ts"; import { OperationPolicies } from "./planner/policies.ts"; import { Option } from "monads"; import { getLogger } from "./log.ts"; import { handleOnInitHooks, handleOnPushHooks, PushResponse } from "./hooks.ts"; +import { Validator } from "./typecheck/common.ts"; +import { generateValidator } from "./typecheck/result.ts"; +import { ComputationEngine } from "./engine/computation_engine.ts"; const logger = getLogger(import.meta); @@ -89,15 +91,6 @@ export class ComputeStage { } } -// typechecks for scalar types -const typeChecks: Record boolean> = { - Int: (value) => typeof value === "number", - Float: (value) => typeof value === "number", - String: (value) => typeof value === "string", - ID: (value) => typeof value === "string", - Boolean: (value) => typeof value === "boolean", -}; - function isIntrospectionQuery( operation: ast.OperationDefinitionNode, _fragments: FragmentDefs, @@ -105,10 +98,12 @@ function isIntrospectionQuery( return operation.name?.value === "IntrospectionQuery"; } +// See `planner/mod.ts` on how the graphql is processed to build the plan interface Plan { + // matching to each selection in the graphql query stages: ComputeStage[]; policies: OperationPolicies; - validator: TypeCheck; + validator: Validator; } class QueryCache { @@ -201,105 +196,6 @@ export class Engine { return await this.tg.deinit(); } - /** - * Note: - * Each `ComputeStage` relates to a specific type/node generated from the graphql - * 1. `plan: ComputeStage` should be of the same cardinality as the types enumerated in the graphql - * 2. values are computed depending on the Runtime - * - * See `planner/mod.ts` on how the graphql is processed to build the plan - */ - async compute( - plan: ComputeStage[], - policies: OperationPolicies, - context: Context, - info: Info, - variables: Record, - limit: RateLimit | null, - verbose: boolean, - ): Promise { - const ret = {}; - const cache: Record = {}; - const lenses: Record = {}; - - await policies.authorize(context, info, verbose); - - for await (const stage of plan) { - const { - dependencies, - args, - effect, - resolver, - path, - parent, - batcher, - node, - rateCalls, - rateWeight, - } = stage.props; - - const deps = dependencies - .filter((dep) => dep !== parent?.id()) - .filter((dep) => !(parent && dep.startsWith(`${parent.id()}.`))) - .reduce((agg, dep) => ({ ...agg, [dep]: cache[dep] }), {}); - - //verbose && console.log("dep", stage.id(), deps); - const previousValues = parent ? cache[parent.id()] : ([{}] as any); - const lens = parent ? lenses[parent.id()] : ([ret] as any); - - if (limit && rateCalls) { - limit.consume(rateWeight ?? 1); - } - - const computeArgs = args ?? (() => ({})); - - const res = await Promise.all( - previousValues.map((parent: any) => - resolver!({ - ...computeArgs({ variables, context, parent, effect }), - _: { - parent: parent ?? {}, - context, - info, - variables, - effect, - ...deps, - }, - }) - ), - ); - - if (limit && !rateCalls) { - limit.consume(res.length * (rateWeight ?? 1)); - } - - // or no cache if no further usage - cache[stage.id()] = batcher(res); - - if ( - lens.length !== res.length - ) { - throw new Error( - `cannot align array results ${lens.length} != ${res.length} at stage ${stage.id()}: ${ - JSON.stringify(lens) - }, ${JSON.stringify(res)}`, - ); - } - const field = path[path.length - 1] as any; - if (node !== "") { - lens.forEach((l: any, i: number) => { - l[field] = res[i]; - }); - - lenses[stage.id()] = batcher(lens).flatMap((l: any) => { - return l[field] ?? []; - }); - } - } - - return ret; - } - materialize( stages: ComputeStage[], verbose: boolean, @@ -358,10 +254,10 @@ export class Engine { // when const optimizedStages = this.optimize(stagesMat, verbose); - const validator = TypeCheck.init( + const validator = generateValidator( isIntrospectionQuery(operation, fragments) - ? this.tg.introspection!.tg.types - : this.tg.tg.types, + ? this.tg.introspection! + : this.tg, operation, fragments, ); @@ -424,7 +320,7 @@ export class Engine { const { stages, policies, validator } = plan; //logger.info("dag:", stages); - const res = await this.compute( + const res = await ComputationEngine.compute( stages, policies, context, @@ -436,7 +332,7 @@ export class Engine { const computeTime = performance.now(); //console.log("value computed", res); - validator.validate(res); + validator(res); const endTime = performance.now(); if (verbose) { @@ -514,51 +410,8 @@ export class Engine { if (value === undefined) { throw Error(`missing variable "${varName}" value`); } - this.validateVariable(varDef.type, value, varName); - } - } - - validateVariable(type: ast.TypeNode, value: unknown, label: string) { - if (type.kind === Kind.NON_NULL_TYPE) { - if (value == null) { - throw new Error(`variable ${label} cannot be null`); - } - type = type.type; - } - if (value == null) { - return; - } - switch (type.kind) { - case Kind.LIST_TYPE: - if (!Array.isArray(value)) { - throw new Error(`variable ${label} must be an array`); - } - value.forEach((item, idx) => { - this.validateVariable( - (type as ast.ListTypeNode).type, - item, - `${label}[${idx}]`, - ); - }); - break; - case Kind.NAMED_TYPE: - this.validateValueType(type.name.value, value, label); - } - } - - validateValueType(typeName: string, value: unknown, label: string) { - const check = typeChecks[typeName]; - if (check != null) { - // scalar type - if (!check(value)) { - console.error( - `expected type ${typeName}, got value ${JSON.stringify(value)}`, - ); - throw new Error(`variable ${label} must be a ${typeName}`); - } - return; + // variable values are validated with the argument validator } - this.tg.validateValueType(typeName, value, label); } async ensureJWT( diff --git a/typegate/src/engine/computation_engine.ts b/typegate/src/engine/computation_engine.ts new file mode 100644 index 0000000000..4fd0c4de94 --- /dev/null +++ b/typegate/src/engine/computation_engine.ts @@ -0,0 +1,287 @@ +// Copyright Metatype OÜ under the Elastic License 2.0 (ELv2). See LICENSE.md for usage. + +import { distinct } from "std/collections/distinct.ts"; +import { ComputeStage } from "../engine.ts"; +import { OperationPolicies } from "../planner/policies.ts"; +import { RateLimit } from "../rate_limiter.ts"; +import { Context, Info, Parents } from "../types.ts"; +import { JSONValue } from "../utils.ts"; + +// character marking the starting of the branch name in the path segment +export const BRANCH_NAME_SEPARATOR = "$"; + +function withEmptyObjects(res: unknown): unknown { + if (Array.isArray(res)) { + return res.map(withEmptyObjects); + } + return typeof res === "object" && res != null ? {} : res; +} + +function getParentId(stageId: string): string | null { + const lastSeparatorIndex = stageId.lastIndexOf("."); + if (lastSeparatorIndex < 0) { + return null; + } + return stageId.slice(0, lastSeparatorIndex); +} + +function getBranchNameFromParentId(parentId: string) { + const separatorIndex = parentId.lastIndexOf(BRANCH_NAME_SEPARATOR); + if (separatorIndex < 0) { + throw new Error("expected parentId to end with a branch name"); + } + const branch = parentId.slice(separatorIndex); + if (branch.indexOf(".") >= 0) { + // branch name is not on the last path segment + throw new Error("expected parentId to end with a branch name"); + } + return branch; +} + +interface BranchSelection { + level: number; + // name of active branch in the selection + // a compute stage belonging the branch will be skipped if there are no matching value + // from the parent (based on `childSelection`) + select: string | null; +} + +// Compute the result according to the given computation plan and additionnal +// contexts +export class ComputationEngine { + ret: Record = {}; + + // Array of the raw values returned by the resolver of each stage. + // Note: Arrays are flattened and null values are filtered out by the batcher + cache: Record = {}; + + // Array of the values corresponding to the result of each stage. + // Objects are references to objects nested within `ret`; only containing + // fields that are to be included in the final result. + // Note: Arrays are flattened and null values are filtered out by the batcher + lenses: Record = {}; + + // stack of active selections + activeSelections: BranchSelection[] = []; + + // execute the plan to compute the result + public static async compute( + plan: ComputeStage[], + policies: OperationPolicies, + context: Context, + info: Info, + variables: Record, + limit: RateLimit | null, + verbose: boolean, + ): Promise> { + await policies.authorize(context, info, verbose); + + const computationEngine = new ComputationEngine( + context, + info, + variables, + limit, + // verbose, + ); + + for await (const stage of plan) { + await computationEngine.executeStage(stage); + } + + return computationEngine.ret as Record; + } + + private constructor( + private context: Context, + private info: Info, + private variables: Record, + private limit: RateLimit | null, + // private verbose: boolean, + ) {} + + async executeStage(stage: ComputeStage) { + const { path, resolver, effect, rateCalls, rateWeight } = stage.props; + const level = path.length; + const stageId = stage.id(); + const parentId = getParentId(stageId); + + this.updateSelectedBranch(level, parentId); + const deps = this.getDeps(stage, parentId); + + // parent values ( full values ) + const previousValues = parentId ? this.cache[parentId] : [{}]; + if (previousValues == null) { + // no matching value for the brach + return; + } + + if (rateCalls) { + this.consumeLimit(rateWeight ?? 1); + } + + const res = await Promise.all( + previousValues.map((parent) => + resolver!({ + ...this.computeArgs(stage, parent as Parents), + _: { + // parent: parent ?? {}, + parent: parent as Parents, + context: this.context, + info: this.info, + variables: this.variables, + effect, + ...deps, + }, + }) + ), + ); + + if (!rateCalls) { + this.consumeLimit(res.length * (rateWeight ?? 1)); + } + + this.registerResult(stage, stageId, parentId, res); + } + + private registerResult( + stage: ComputeStage, + stageId: string, + parentId: string | null, + res: unknown[], + ) { + // parent values (collected fields) + const lens = parentId ? this.lenses[parentId] : [this.ret]; + + if (lens.length !== res.length) { + const lengths = `${lens.length} != ${res.length}`; + const details = `${JSON.stringify(lens)}, ${JSON.stringify(res)}`; + throw new Error( + `at stage ${stageId}: cannot align array results ${lengths}: ${details}`, + ); + } + + const { path, node, batcher } = stage.props; + + this.cache[stageId] = batcher(res); + + const field = path[path.length - 1]; + // only root?? + if (node !== "") { + (lens as Array>).forEach((l, i) => { + // Objects are replaced by empty objects `{}`. + // It will be populated by child compute stages using values in `cache`. + l[field] = withEmptyObjects(res[i]); + }); + + // TODO + this.lenses[stageId] = lens.flatMap((l) => + batcher([(l as Record)[field]]) ?? [] + ); + } + + this.pushChildSelection(stage, stageId); + } + + private getDeps(stage: ComputeStage, parentId: string | null) { + // TODO what if the dependency has not been computed yet??? (not in cache) + return stage.props.dependencies.filter((dep) => dep != parentId) + .filter((dep) => !(parentId && dep.startsWith(`${parentId}`))) + .reduce((agg, dep) => ({ ...agg, [dep]: this.cache[dep] }), {}); + } + + private updateSelectedBranch(currentLevel: number, parentId: string | null) { + while ( + this.topSelection != null && currentLevel < this.topSelection.level + ) { + // top branch selections exhausted + this.activeSelections.pop(); + } + + const selection = this.topSelection; + if (selection == null) { + return; + } + + if (selection.level === currentLevel) { + // potential branch selection transition + selection.select = getBranchNameFromParentId(parentId!); + } + } + + private consumeLimit(n: number) { + if (this.limit != null) { + this.limit.consume(n); + } + } + + private pushChildSelection(stage: ComputeStage, stageId: string) { + const { childSelection, path } = stage.props; + if (childSelection == null) { + return; + } + + const currentLevel = path.length; + + if ( + this.topSelection != null && this.topSelection.level + 1 === currentLevel + ) { + // nested union/either should have been flattened + throw new Error(); + } + + const branches = this.cache[stageId].map((res) => { + const branch = childSelection(res); + if (branch == null) { + throw new Error( + `at stage ${stageId}: No matching branch for the result`, + ); + } + return branch; + }); + + for (const branch of distinct(branches)) { + const p = `${stageId}${BRANCH_NAME_SEPARATOR}${branch}`; + this.cache[p] = []; + this.lenses[p] = []; + } + + for (const [i, branch] of branches.entries()) { + const p = `${stageId}${BRANCH_NAME_SEPARATOR}${branch}`; + if (this.cache[p] == null) { + this.cache[p] = []; + this.lenses[p] = []; + } + this.cache[p].push(this.cache[stageId][i]); + this.lenses[p].push(this.lenses[stageId][i]); + } + + this.activeSelections.push({ + level: currentLevel + 1, + select: null, + }); + } + + private computeArgs( + stage: ComputeStage, + parent: Parents, + ): Record { + const { args: compute, effect } = stage.props; + if (compute == null) { + return {}; + } + + return compute({ + variables: this.variables, + context: this.context, + parent, + effect, + }); + } + + // static + private get topSelection(): BranchSelection | null { + return this.activeSelections.length > 0 + ? this.activeSelections[this.activeSelections.length - 1] + : null; + } +} diff --git a/typegate/src/planner/args.ts b/typegate/src/planner/args.ts index 51ad9ca400..8b42cc8885 100644 --- a/typegate/src/planner/args.ts +++ b/typegate/src/planner/args.ts @@ -23,7 +23,6 @@ import { import { mapValues } from "std/collections/map_values.ts"; import { filterValues } from "std/collections/filter_values.ts"; -import { JSONSchema, SchemaValidatorError, trimType } from "../typecheck.ts"; import { EffectType, EitherNode } from "../types/typegraph.ts"; import { getChildTypes, visitTypes } from "../typegraph/visitor.ts"; @@ -37,12 +36,6 @@ class MandatoryArgumentError extends Error { } } -interface ArgumentObjectSchema { - type: string; - properties: Record; - required?: string[]; -} - export interface ComputeArgParams { variables: Variables; parent: Parents; @@ -389,107 +382,6 @@ class ArgumentCollector { return this.getJsonValueFromRoot(astNode.value, astNode.name.value); } - /** - * Returns the JSON Schema of an argument type node. - * - * The JSON Schema returned is useful to check non-primitive values such as - * objects or unions. - */ - private getArgumentSchema(typenode: TypeNode): JSONSchema { - switch (typenode.type) { - case Type.OPTIONAL: { - // Note: - // The field `item` does not exist in JSON Schema - // we must get its schema and make the type nullable - const itemTypeNode = this.tg.type(typenode.item); - const nullableType = [itemTypeNode.type, "null"]; - const itemSchema = this.getArgumentSchema(itemTypeNode); - const schema = { - ...itemSchema, - type: nullableType, - }; - return schema; - } - case Type.ARRAY: { - const itemsTypeNode = this.tg.type(typenode.items); - const itemsSchema = this.getArgumentSchema(itemsTypeNode); - const schema = { - ...trimType(typenode), - items: itemsSchema, - }; - return schema; - } - - case Type.UNION: { - const schemes = typenode.anyOf - .map((variantTypeIndex) => this.tg.type(variantTypeIndex)) - .map((variant) => this.getArgumentSchema(variant)); - - const argumentSchema = { - anyOf: schemes as JSONSchema[], - }; - - return argumentSchema; - } - - case Type.EITHER: { - const schemes = typenode.oneOf - .map((variantTypeIndex) => this.tg.type(variantTypeIndex)) - .map((variant) => this.getArgumentSchema(variant)); - - const argumentSchema = { - oneOf: schemes as JSONSchema[], - }; - - return argumentSchema; - } - - case Type.STRING: - case Type.BOOLEAN: - case Type.NUMBER: - case Type.INTEGER: { - const schema = trimType(typenode); - return schema; - } - - case Type.OBJECT: { - const schema: ArgumentObjectSchema = {} as ArgumentObjectSchema; - - schema.type = Type.OBJECT; - schema.required = []; - schema.properties = {}; - - for ( - const [propertyName, propertyTypeIndex] of Object.entries( - typenode.properties, - ) - ) { - const propertyNode = this.tg.type(propertyTypeIndex); - if (propertyNode.type !== Type.OPTIONAL) { - schema.required.push(propertyName); - schema.properties[propertyName] = this.getArgumentSchema( - propertyNode, - ); - } else { - schema.properties[propertyName] = this.getArgumentSchema( - this.tg.type(propertyNode.item), - ); - } - } - - return schema; - } - - default: - throw new Error( - [ - `unsupported type node '${typenode.type}'`, - `to generate its argument schema`, - ].join(" "), - ); - } - } - /** * Collect the value of a parameter of type 'union' or 'either'. */ @@ -511,8 +403,7 @@ class ArgumentCollector { } catch (error) { if ( error instanceof TypeMismatchError || - error instanceof MandatoryArgumentError || - error instanceof SchemaValidatorError + error instanceof MandatoryArgumentError ) { continue; } @@ -737,7 +628,7 @@ class ArgumentCollector { ); } - return value; + return typeof value === "function" ? value() : value; }; } diff --git a/typegate/src/planner/mod.ts b/typegate/src/planner/mod.ts index 38cd742664..4ed4f9c499 100644 --- a/typegate/src/planner/mod.ts +++ b/typegate/src/planner/mod.ts @@ -1,25 +1,21 @@ // Copyright Metatype OÜ under the Elastic License 2.0 (ELv2). See LICENSE.md for usage. import * as ast from "graphql/ast"; -import { Kind } from "graphql"; +import { FieldNode, Kind } from "graphql"; import { ComputeStage } from "../engine.ts"; import { FragmentDefs, resolveSelection } from "../graphql.ts"; import { TypeGraph } from "../typegraph.ts"; import { ComputeStageProps } from "../types.ts"; import { getReverseMapNameToQuery } from "../utils.ts"; -import { - getWrappedType, - isArray, - isObject, - isQuantifier, - Type, -} from "../type_node.ts"; +import { getWrappedType, isQuantifier, Type, UnionNode } from "../type_node.ts"; import { DenoRuntime } from "../runtimes/deno/deno.ts"; import { closestWord, ensure, unparse } from "../utils.ts"; import { collectArgs, ComputeArg } from "./args.ts"; import { OperationPolicies, OperationPoliciesBuilder } from "./policies.ts"; import { getLogger } from "../log.ts"; +import { EitherNode } from "../types/typegraph.ts"; const logger = getLogger(import.meta); +import { generateVariantMatcher } from "../typecheck/matching_variant.ts"; interface Node { name: string; @@ -109,90 +105,140 @@ export class Planner { ): ComputeStage[] { const { name, selectionSet, args, typeIdx } = node; const typ = this.tg.type(typeIdx); - const stages: ComputeStage[] = []; - const selection = selectionSet - ? resolveSelection(selectionSet, this.fragments) - : []; - const props = (typ.type === Type.OBJECT && typ.properties) || {}; - - this.verbose && - logger.debug( - this.tg.root.title, - name, - args.map((n) => n.name?.value), - selection.map((n) => n.name?.value), - typ.type, - Object.entries(props).reduce( - (agg, [k, v]) => ({ ...agg, [k]: this.tg.type(v).type }), - {}, - ), - ); + if (selectionSet == null) { + if (this.isSelectionSetExpectedFor(typeIdx)) { + const path = this.formatPath(node.path); + throw new Error( + `at ${path}: selection set is expected for object type`, + ); + } + return []; + } - if (typ.type === Type.OBJECT && selection.length < 1) { - throw new Error(`struct '${name}' must be a field selection`); + if (typ.type === Type.OBJECT) { + const selection = resolveSelection(selectionSet, this.fragments); + const props = (typ.type === Type.OBJECT && typ.properties) || {}; + const stages: ComputeStage[] = []; + + this.verbose && + logger.debug( + this.tg.root.title, + name, + args.map((n) => n.name?.value), + selection.map((n) => n.name?.value), + typ.type, + Object.entries(props).reduce( + (agg, [k, v]) => ({ ...agg, [k]: this.tg.type(v).type }), + {}, + ), + ); + + for (const field of selection) { + stages.push( + ...this.traverseField( + this.getChildNodeForField(field, node, props, stage), + field, + ), + ); + } + + return stages; } - for (const field of selection) { - const { - name: { value: name }, - alias: { value: alias } = {}, - arguments: args, - } = field; - // name: used to fetch the value - // canonicalName: field name on the expected output - - const canonicalName = alias ?? name; - const path = [...node.path, canonicalName]; - const fieldIdx = props[name]; - if ( - fieldIdx == undefined && - !(name === "__schema" || name === "__type" || name === "__typename") - ) { - const allProps = Object.keys(props); - const formattedPath = this.formatPath(node.path); - if (typ.title === "Mutation" || typ.title === "Query") { - // propose which root type has that name - const nameToPaths = getReverseMapNameToQuery(this.tg, [ - "mutation", - "query", - ]); - if (nameToPaths.has(name)) { - const rootPaths = [...nameToPaths.get(name)!]; - // Mutation or Query but never both - if (rootPaths.length == 1) { - const [suggestion] = rootPaths; - throw new Error( - `'${name}' not found at '${formattedPath}', did you mean using '${name}' from '${suggestion}'?`, - ); - } - } + if (typ.type === Type.EITHER || typ.type === Type.UNION) { + const stages: ComputeStage[] = []; + const variants = this.getNestedVariantsByName(typ); + const unselectedVariants = new Set(Object.keys(variants)); + // expect selections to be inline fragments with type conditions + const selections = selectionSet.selections.map((sel) => { + if (sel.kind !== Kind.INLINE_FRAGMENT || sel.typeCondition == null) { + throw new Error("Expected inline fragment with type condition"); } - // if the above fails, tell the user in case they made a typo - const suggestion = closestWord(name, allProps); - if (suggestion) { + const typeName = sel.typeCondition.name.value; + if (!unselectedVariants.has(typeName)) { + const path = this.formatPath(node.path); + const suggestions = [...unselectedVariants].join(", "); throw new Error( - `'${name}' not found at '${formattedPath}', did you mean '${suggestion}'?`, + `at: ${path}: Unknown type condition '${typeName}'; available types are: ${suggestions}`, ); } - const suggestions = allProps.join(", "); + unselectedVariants.delete(typeName); + return [typeName, sel.selectionSet] as const; + }); + + if (unselectedVariants.size > 0) { + const path = this.formatPath; + const s = unselectedVariants.size > 0 ? "s" : ""; + const variants = [...unselectedVariants].join(", "); throw new Error( - `'${name}' not found at '${formattedPath}', available names are: ${suggestions}`, + `at ${path}: Unselected union variant${s}: ${variants}`, ); } - const childNode = { - parent: node, - name: canonicalName, - path, - selectionSet: field.selectionSet, - args: args ?? [], - typeIdx: props[name], - parentStage: stage, - }; - stages.push(...this.traverseField(childNode, field)); + + stage!.props.childSelection = generateVariantMatcher(this.tg, typeIdx); + + for (const [typeName, selectionSet] of selections) { + const selection = resolveSelection(selectionSet, this.fragments); + const props = this.tg.type(variants[typeName], Type.OBJECT).properties; + const parentPath = node.path.slice(); + parentPath[parentPath.length - 1] += `$${typeName}`; + for (const field of selection) { + stages.push( + ...this.traverseField( + this.getChildNodeForField( + field, + { ...node, path: parentPath }, + props, + stage, + ), + field, + ), + ); + } + } + + return stages; } - return stages; + const path = this.formatPath(node.path); + throw new Error( + `at ${path}: Unexpected selection set for type '${typ.type}'`, + ); + } + + private getChildNodeForField( + field: FieldNode, + node: Node, + props: Record, + parentStage?: ComputeStage, + ) { + const { + name: { value: name }, + alias: { value: alias } = {}, + arguments: args, + } = field; + // name: used to fetch the value + // canonicalName: field name on the expected output + + const canonicalName = alias ?? name; + const path = [...node.path, canonicalName]; + const fieldIdx = props[name]; + if ( + fieldIdx == undefined && + !(name === "__schema" || name === "__type" || name === "__typename") + ) { + throw this.unexpectedFieldError(node, name); + } + return { + parent: node, + name: canonicalName, + path, + selectionSet: field.selectionSet, + args: args ?? [], + typeIdx: props[name], + parentStage, + }; } /** @@ -267,14 +313,14 @@ export class Planner { const fieldType = this.tg.type(node.typeIdx); - if (fieldType.type !== Type.FUNCTION) { - return this.traverseValueField(node); - } + const stages = fieldType.type !== Type.FUNCTION + ? this.traverseValueField(node) + : this.traverseFuncField( + node, + this.tg.type(parent.typeIdx, Type.OBJECT).properties, + ); - return this.traverseFuncField( - node, - this.tg.type(parent.typeIdx, Type.OBJECT).properties, - ); + return stages; } /** @@ -313,41 +359,16 @@ export class Planner { stages.push(stage); - if (schema.type === Type.OBJECT) { - stages.push(...this.traverse(node, stage)); - return stages; + // nested quantifiers + let nestedTypeIdx = node.typeIdx; + let nestedSchema = this.tg.type(nestedTypeIdx); + while (isQuantifier(nestedSchema)) { + nestedTypeIdx = getWrappedType(nestedSchema); + nestedSchema = this.tg.type(nestedTypeIdx); + types.push(nestedTypeIdx); } - // TODO support for nested quantifiers - // What nested quantifiers should be supported: t.optional(t.optional(...)), ... - if (isQuantifier(schema)) { - const itemTypeIdx = getWrappedType(schema); - types.push(itemTypeIdx); - const itemSchema = this.tg.type(itemTypeIdx); - - if (itemSchema.type === Type.OBJECT) { - stages.push(...this.traverse({ ...node, typeIdx: itemTypeIdx }, stage)); - } - - // support for nested quantifier `t.array(t.struct()).optional()`, - // which is necessary to compute some introspection fields - if (isArray(itemSchema)) { - const nestedItemTypeIndex = getWrappedType(itemSchema); - types.push(nestedItemTypeIndex); - const nestedItemNode = this.tg.type(nestedItemTypeIndex); - - if (isObject(nestedItemNode)) { - stages.push( - ...this.traverse( - { ...node, typeIdx: nestedItemTypeIndex }, - stage, - ), - ); - } - } - - return stages; - } + stages.push(...this.traverse({ ...node, typeIdx: nestedTypeIdx }, stage)); return stages; } @@ -426,38 +447,22 @@ export class Planner { inputIdx, ); - if (outputType.type === Type.OBJECT) { - stages.push( - ...this.traverse( - { ...node, typeIdx: outputIdx, parentStage: stage }, - stage, - ), - ); - this.policiesBuilder.pop(stage.id()); - return stages; - } - - if (isQuantifier(outputType)) { - let wrappedTypeIdx: number = getWrappedType(outputType); + // nested quantifiers + let wrappedTypeIdx = outputIdx; + let wrappedType = this.tg.type(wrappedTypeIdx); + while (isQuantifier(wrappedType)) { + wrappedTypeIdx = getWrappedType(wrappedType); + wrappedType = this.tg.type(wrappedTypeIdx); types.push(wrappedTypeIdx); - let wrappedType = this.tg.type(wrappedTypeIdx); - while (isQuantifier(wrappedType)) { - wrappedTypeIdx = getWrappedType(wrappedType); - types.push(wrappedTypeIdx); - wrappedType = this.tg.type(wrappedTypeIdx); - } - - if (wrappedType.type === Type.OBJECT) { - stages.push( - ...this.traverse({ - ...node, - typeIdx: wrappedTypeIdx, - parentStage: stage, - }, stage), - ); - } } + stages.push( + ...this.traverse( + { ...node, typeIdx: wrappedTypeIdx, parentStage: stage }, + stage, + ), + ); + this.policiesBuilder.pop(stage.id()); return stages; } @@ -501,4 +506,73 @@ export class Planner { private formatPath(path: string[]) { return [this.operationName, ...path].join("."); } + + private isSelectionSetExpectedFor(typeIdx: number): boolean { + const typ = this.tg.type(typeIdx); + if (typ.type === Type.OBJECT) { + return true; + } + + if (typ.type === Type.UNION) { + // only check for first variant + // typegraph validation ensure that all the (nested) variants are all either objects or scalars + return this.isSelectionSetExpectedFor(typ.anyOf[0]); + } + if (typ.type === Type.EITHER) { + return this.isSelectionSetExpectedFor(typ.oneOf[0]); + } + return false; + } + + private unexpectedFieldError(node: Node, name: string): Error { + const typ = this.tg.type(node.typeIdx, Type.OBJECT); + const allProps = Object.keys(typ.properties); + const formattedPath = this.formatPath(node.path); + if (typ.title === "Mutation" || typ.title === "Query") { + // propose which root type has that name + const nameToPaths = getReverseMapNameToQuery(this.tg, [ + "mutation", + "query", + ]); + if (nameToPaths.has(name)) { + const rootPaths = [...nameToPaths.get(name)!]; + // Mutation or Query but never both + if (rootPaths.length == 1) { + const [suggestion] = rootPaths; + return new Error( + `'${name}' not found at '${formattedPath}', did you mean using '${name}' from '${suggestion}'?`, + ); + } + } + } + // if the above fails, tell the user in case they made a typo + const suggestion = closestWord(name, allProps); + if (suggestion) { + return new Error( + `'${name}' not found at '${formattedPath}', did you mean '${suggestion}'?`, + ); + } + const suggestions = allProps.join(", "); + return new Error( + `'${name}' not found at '${formattedPath}', available names are: ${suggestions}`, + ); + } + + private getNestedVariantsByName( + typ: UnionNode | EitherNode, + ): Record { + const getEntries = ( + typ: UnionNode | EitherNode, + ): Array<[string, number]> => { + const variants = typ.type === Type.UNION ? typ.anyOf : typ.oneOf; + return variants.flatMap((idx) => { + const typeNode = this.tg.type(idx); + if (typeNode.type === Type.EITHER || typeNode.type === Type.UNION) { + return getEntries(typeNode); + } + return [[typeNode.title, idx]]; + }); + }; + return Object.fromEntries(getEntries(typ)); + } } diff --git a/typegate/src/typecheck.ts b/typegate/src/typecheck.ts deleted file mode 100644 index b96bc14f7b..0000000000 --- a/typegate/src/typecheck.ts +++ /dev/null @@ -1,384 +0,0 @@ -// Copyright Metatype OÜ under the Elastic License 2.0 (ELv2). See LICENSE.md for usage. - -// deno-lint-ignore-file no-unused-vars -import type * as jst from "json_schema_typed"; -import { Kind } from "graphql"; -import Ajv, { ErrorObject, ValidateFunction } from "ajv"; - -import addFormats from "ajv-formats"; -import { - OperationDefinitionNode, - SelectionNode, - SelectionSetNode, -} from "graphql/ast"; -import { FragmentDefs } from "./graphql.ts"; -import { - getVariantTypesIndexes, - isEither, - isOptional, - isUnion, - ObjectNode, - TypeNode, -} from "./type_node.ts"; -import { EitherNode, UnionNode } from "./types/typegraph.ts"; -import { toPrettyJSON } from "./utils.ts"; -import { getLogger } from "./log.ts"; -const logger = getLogger("sync"); - -// we will use this jsonschema jit compiler: https://github.com/sinclairzx81/typebox -// and the types format will become a superset of the jsonschema https://json-schema.org/understanding-json-schema/reference/index.html -// & https://json-schema.org/understanding-json-schema/structuring.html -// especially we will use json pointer to encode the typegraph https://json-schema.org/understanding-json-schema/structuring.html#json-pointer -// it will allow to extend some type later using wasi "typechecking" https://github.com/chiefbiiko/json-schm-wasm -// for now but we will add directely the following new jsonschema "type" -// - optional -// - func - -export type JSONSchema = Exclude; - -export function trimType(node: TypeNode): JSONSchema { - const { runtime, policies, config, injection, ...ret } = node; - return ret as unknown as JSONSchema; -} - -class InvalidNodePathsError extends Error { - constructor( - public invalidNodePaths: string[], - public generatedSchema: JSONSchema, - ) { - const nodePaths = invalidNodePaths.join(", "); - const quantifier = invalidNodePaths.length <= 1 ? "is" : "are"; - - super(`${nodePaths} ${quantifier} undefined`); - } -} - -export class SchemaValidatorError extends Error { - constructor(value: unknown, schemaErrors: ErrorObject[], schema: JSONSchema) { - let errorMessage = ""; - - if (schemaErrors.length > 1) { - errorMessage = [ - `value: ${toPrettyJSON(value)}`, - `errors: ${toPrettyJSON(schemaErrors)}`, - ].join("\n\n"); - } else { - // if there is only one error, return it instead of the whole error, - // as it may be redundant - const error = schemaErrors[0]; - errorMessage = `${error.message} at ${error.instancePath}`; - } - - super(errorMessage); - } -} - -// Build a jsonschema for a query result -export class ValidationSchemaBuilder { - constructor( - private types: Array, - private operation: OperationDefinitionNode, - private fragments: FragmentDefs, - ) {} - - public build(): JSONSchema { - const { name, operation } = this.operation; - const rootPath = name?.value ?? operation[0].toUpperCase(); - if (operation !== "query" && operation !== "mutation") { - throw new Error(`unsupported operation type: ${operation}`); - } - const rootTypeIdx = (this.types[0] as ObjectNode).properties[operation]; - - return this.get( - rootPath, - this.types[rootTypeIdx], - this.operation.selectionSet, - ); - } - - private get( - path: string, - type: TypeNode, - selectionSet: SelectionSetNode | undefined, - ): JSONSchema { - const enumOverride = type.enum == null - ? {} - : { enum: type.enum.map((v) => JSON.parse(v)) }; - switch (type.type) { - case "object": { - const properties = {} as Record; - const required = [] as string[]; - const baseProperties = type.properties ?? {}; - if (selectionSet == undefined) { - throw new Error(`Path ${path} must be a field selection`); - } - - // variable helper to bundle all the errors found instead of throwing - // on the first error found - const invalidNodePaths: string[] = []; - - const addProperty = (node: SelectionNode) => { - switch (node.kind) { - case Kind.FIELD: { - const { name, selectionSet, alias } = node; - const canonicalName = (alias ?? name).value; - const nameValue = name.value; - - if (name.value === "__typename") { - properties[canonicalName] = { type: "string" }; - return; - } - - if (Object.hasOwnProperty.call(baseProperties, nameValue)) { - const prop = this.types[baseProperties[nameValue]]; - if (!isOptional(prop)) { - required.push(canonicalName); - } - properties[canonicalName] = this.get( - `${path}.${name.value}`, - prop, - selectionSet, - ); - } else { - const nodePath = `${path}.${name.value}`; - invalidNodePaths.push(nodePath); - } - break; - } - - case Kind.FRAGMENT_SPREAD: { - const fragment = this.fragments[node.name.value]; - for (const selectionNode of fragment.selectionSet.selections) { - addProperty(selectionNode); - } - break; - } - - case Kind.INLINE_FRAGMENT: { - for (const selectionNode of node.selectionSet.selections) { - addProperty(selectionNode); - } - break; - } - } - }; - - for (const node of selectionSet.selections) { - addProperty(node); - } - - const generatedSchema = { - ...trimType(type), - properties, - required, - additionalProperties: false, - ...enumOverride, - }; - - if (invalidNodePaths.length > 0) { - throw new InvalidNodePathsError(invalidNodePaths, generatedSchema); - } - - return generatedSchema; - } - - case "union": { - return this.getGeneralUnionSchema(type, path, selectionSet); - } - - case "either": { - return this.getGeneralUnionSchema(type, path, selectionSet); - } - - case "array": { - const currentType = this.types[type.items]; - if (isOptional(currentType)) { - const item = this.types[currentType.item]; - if (isEither(item) || isUnion(item)) { - // optional requires a list of all variant types ([undefined, "null"] does not work) - // TODO: - // 1. enumerate all types properly for union/either - // (see: args.ts: JsonSchemaBuilder.listUnionEitherTypes) - // 2. Or.. make it so that `array` ignores the optional wrapper - // array(optional(x)) => array(x) - - // this fix partially implements 2 - return { - ...trimType(type), - items: this.get(path, item, selectionSet), - ...enumOverride, - }; - } - } - - return { - ...trimType(type), - items: this.get(path, currentType, selectionSet), - ...enumOverride, - }; - } - - case "function": { - return this.get(path, this.types[type.output], selectionSet); - } - - case "optional": { - const itemSchema = this.get(path, this.types[type.item], selectionSet); - const nullableType = Array.isArray(itemSchema.type) - ? [...itemSchema.type, "null"] - : [itemSchema.type, "null"]; - return { ...itemSchema, type: nullableType }; - } - - default: - if (selectionSet != undefined) { - throw new Error( - `Path ${path} cannot be a field selection on value of type ${type.type}`, - ); - } - return { ...trimType(type), ...enumOverride }; - } - } - - /** - * Returns the JSON Schema for a node of type `union` or `either`. - */ - private getGeneralUnionSchema( - typeNode: UnionNode | EitherNode, - path: string, - selectionSet?: SelectionSetNode, - ): JSONSchema { - const variantTypesIndexes: number[] = getVariantTypesIndexes(typeNode); - - const variants = variantTypesIndexes.map( - (typeIndex) => this.types[typeIndex], - ); - const variantsSchema: JSONSchema[] = []; - const undefinedNodePaths = new Map(); - const errorsCounter = new Map(); - - for (const variant of variants) { - try { - const variantSchema = this.get(path, variant, selectionSet); - - variantsSchema.push(variantSchema); - } catch (error) { - if (error instanceof InvalidNodePathsError) { - for (const invalidPath of error.invalidNodePaths) { - let count = undefinedNodePaths.get(invalidPath) || 0; - count += 1; - undefinedNodePaths.set(invalidPath, count); - } - - variantsSchema.push(error.generatedSchema); - } else { - let count = errorsCounter.get(error.message) || 0; - count += 1; - errorsCounter.set(error.message, count); - } - } - } - - // only throw that a node path is undefined if it doesn't exist on any - // of the subschemes - const invalidPaths = []; - for (const [nodePath, count] of undefinedNodePaths.entries()) { - if (count === variants.length) { - invalidPaths.push(nodePath); - } - } - if (invalidPaths.length > 0) { - throw new InvalidNodePathsError(invalidPaths, {}); - } - - // only throw errors that appear on all the subschemes - for (const [message, count] of errorsCounter) { - if (count === variants.length) { - throw new Error(message); - } - } - - const trimmedType = trimType(typeNode); - - // remove `type` field as is ruled by the subschemes - delete trimmedType.type; - - const filteredVariantsSchema = variantsSchema.filter( - (variant) => Object.keys(variant.properties || {}).length > 0, - ); - - // only return the schema if there is at least a variant, - // since `anyOf` and `oneOf` fields cannot be empty arrays - if (filteredVariantsSchema.length < 1) { - return {}; - } - - const schema = { - ...trimmedType, - ...(typeNode.enum == null - ? {} - : { enum: typeNode.enum.map((v) => JSON.parse(v)) }), - }; - - if (isUnion(typeNode)) { - schema.anyOf = filteredVariantsSchema; - } else { - schema.oneOf = filteredVariantsSchema; - } - - return schema; - } -} - -export function addJsonFormat(ajv: Ajv) { - ajv.addFormat("json", (data: string) => { - try { - JSON.parse(data); - return true; - } catch (e) { - return false; - } - }); -} - -const ajv = new Ajv({ removeAdditional: true }); -addFormats(ajv); -addJsonFormat(ajv); - -// Validator of query response -export class TypeCheck { - validator: ValidateFunction; - // serializer: any; - - constructor(private readonly schema: JSONSchema) { - this.validator = ajv.compile(schema); - // this.serializer = ajv.compileSerializer(schema); - } - - public static init( - types: Array, - operation: OperationDefinitionNode, - fragments: FragmentDefs, - ) { - const schema = new ValidationSchemaBuilder( - types, - operation, - fragments, - ).build(); - return new TypeCheck(schema); - } - - public check(value: unknown): boolean { - return this.validator(value); - } - - public validate(value: unknown) { - this.check(value); - - if (this.validator.errors) { - console.error("Some errors occurred while validating: ", value); - console.error({ errors: this.validator.errors }); - throw new SchemaValidatorError(value, this.validator.errors, this.schema); - } - } -} diff --git a/typegate/src/typecheck/code_generator.ts b/typegate/src/typecheck/code_generator.ts new file mode 100644 index 0000000000..6e9bfee075 --- /dev/null +++ b/typegate/src/typecheck/code_generator.ts @@ -0,0 +1,294 @@ +// Copyright Metatype OÜ under the Elastic License 2.0 (ELv2). See LICENSE.md for usage. + +import { + ArrayNode, + BooleanNode, + IntegerNode, + NumberNode, + ObjectNode, + OptionalNode, + StringNode, + Type, + TypeNode, + UnionNode, +} from "../type_node.ts"; +import { EitherNode } from "../types/typegraph.ts"; + +export class CodeGenerator { + lines: string[] = []; + + generateEnumValidator(typeNode: TypeNode) { + const comparisons = []; + + if ( + ([ + Type.BOOLEAN, + Type.NUMBER, + Type.INTEGER, + Type.STRING, + ] as TypeNode["type"][]) + .includes(typeNode.type) + ) { + // shallow comparison + comparisons.push( + ...typeNode.enum!.map((val) => `value !== ${val}`), + ); + } else { + // deep comparison + comparisons.push( + ...typeNode.enum!.map((val) => `!context.deepEqual(value, ${val})`), + ); + } + + this.validation( + comparisons.join(" && "), + '"value did not match to any of the enum values"', + ); + } + + generateBooleanValidator(_typeNode: BooleanNode) { + this.validation( + 'typeof value !== "boolean"', + "`expected boolean, got ${typeof value}`", + ); + } + + generateNumberValidator(typeNode: NumberNode | IntegerNode) { + this.validation( + 'typeof value !== "number"', + "`expected number, got ${typeof value}`", + ["return"], + ); + + if (typeNode.type === "integer") { + this.validation( + `parseInt(value) !== value`, + `\`expected an integer, got \${value}\``, + ["return"], + ); + } + + const constraints = [ + ["minimum", "<"], + ["maximum", ">"], + ["exclusiveMinimum", "<=", "exclusive minimum"], + ["exclusiveMaximum", ">=", "exclusive maximum"], + ] as const; + for (const c of constraints) { + const [prop, comp, name = null] = c; + const constraint = typeNode[prop]; + if (constraint != null) { + this.line("else"); + this.validation( + `value ${comp} ${constraint}`, + `\`expected ${name ?? prop} value: ${constraint}, got \${value}\``, + ); + } + } + } + + generateStringValidator(typeNode: StringNode) { + this.validation( + 'typeof value !== "string"', + "`expected a string, got ${typeof value}`", + ); + const constraints = [ + ["minLength", "<", "minimum length"], + ["maxLength", ">", "maximum length"], + ] as const; + for (const c of constraints) { + const [prop, comp, name] = c; + const constraint = typeNode[prop]; + if (constraint != null) { + this.line("else"); + this.validation( + `value.length ${comp} ${constraint}`, + `\`expected ${name}: ${constraint}, got \${value.length}\``, + ); + } + } + if (typeNode.pattern != null) { + this.line("else {"); + this.validation( + `!new RegExp("${typeNode.pattern}").test(value)`, + `"string does not match to the pattern /${typeNode.pattern}/"`, + ); + this.line("}"); + } + if (typeNode.format != null) { + this.line("else {"); + this.line( + `const formatValidator = context.formatValidators["${typeNode.format}"]`, + ); + this.validation( + "formatValidator == null", + `"unknown format '${typeNode.format}'"`, + ); + this.line("else"); + this.validation( + "!formatValidator(value)", + `"string does not statisfy the required format '${typeNode.format}'"`, + ); + this.line("}"); + } + } + + generateOptionalValidator( + _typeNode: OptionalNode, + itemValidatorName: string, + ) { + this.line(`if (value != null) {`); + this.line( + `${itemValidatorName}(value, path, errors, context)`, + ); + this.line("}"); + } + + generateArrayValidator( + typeNode: ArrayNode, + itemValidatorName: string, + ) { + this.validation( + `!Array.isArray(value)`, + "`expected an array, got ${typeof value}`", + ); + const constraints = [ + ["minItems", "<", "minimum items"], + ["maxItems", ">", "maximum items"], + ] as const; + for (const c of constraints) { + const [prop, comp, name] = c; + const constraint = typeNode[prop]; + if (constraint != null) { + this.line("else"); + this.validation( + `value.length ${comp} ${typeNode[prop]}`, + `\`expected ${name}: ${constraint}, got \${value.length}\``, + ); + } + } + + const itemType = typeNode.items; + + this.line("else {"); + this.line("for (let i = 0; i < value.length; ++i) {"); + this.line("const item = value[i]"); + this.line( + `${itemValidatorName}(value[i], path + \`[\${i}]\`, errors, context)`, + ); + this.line("}"); + this.line("}"); + + return [itemType]; + } + + generateObjectValidator( + typeNode: ObjectNode, + propValidatorNames: Record, + ) { + this.validation( + `typeof value !== "object"`, + "`expected an object, got ${typeof value}`", + ); + this.line("else"); + this.validation( + `value == null`, + '"exptected a non-null object, got null"', + ); + + this.line("else {"); + this.line("const keys = new Set(Object.keys(value))"); + + for (const [name, validator] of Object.entries(propValidatorNames)) { + this.line(`keys.delete("${name}")`); + this.line( + `${validator}(value["${name}"], path + ".${name}", errors, context)`, + ); + } + + this.validation( + "keys.size > 0", + `\`unexpected fields: \${[...keys].join(', ')}\``, + ); + this.line("}"); + return Object.values(typeNode.properties); + } + + generateUnionValidator( + typeNode: UnionNode, + variantValidatorNames: string[], + ) { + this.line("let errs;"); + + const variantCount = typeNode.anyOf.length; + if (variantValidatorNames.length !== variantCount) { + throw new Error( + "The length of variantValidatorNames does not match to the variant count", + ); + } + for (let i = 0; i < variantCount; ++i) { + this.line(`errs = []`); + const validator = variantValidatorNames[i]; + this.line(`${validator}(value, path, errs, context)`); + this.line("if (errs.length === 0) { return }"); + } + + // TODO display variant errors + this.line( + 'errors.push([path, "Value does not match to any variant of the union type"])', + ); + } + + generateEitherValidator( + typeNode: EitherNode, + variantValidatorNames: string[], + ) { + this.line("let matchCount = 0;"); + this.line("let errs;"); + + const variantCount = typeNode.oneOf.length; + if (variantValidatorNames.length !== variantCount) { + throw new Error( + "The length of variantValidatorNames does not match to the variant count", + ); + } + for (let i = 0; i < variantCount; ++i) { + this.line(`errs = []`); + const validator = variantValidatorNames[i]; + this.line(`${validator}(value, path, errs, context);`); + this.line("if (errs.length === 0) { matchCount += 1 }"); + } + + this.line("if (matchCount === 0) {"); + this.line( + 'errors.push([path, "Value does not match to any variant of the either type"])', + ); + this.line("}"); + this.line("else if (matchCount > 1) {"); + this.line( + 'errors.push([path, "Value match to more than one variant of the either type"])', + ); + this.line("}"); + } + + reset(): string[] { + const lines = this.lines; + this.lines = []; + return lines; + } + + private validation( + errorCond: string, + errorMsg: string, + onError: string[] = [], + ) { + this.line(`if (${errorCond}) {`); + this.line(`errors.push([path, ${errorMsg}]);`); + this.lines.push(...onError); + this.line("}"); + } + + line(l: string) { + this.lines.push(l); + } +} diff --git a/typegate/src/typecheck/common.ts b/typegate/src/typecheck/common.ts new file mode 100644 index 0000000000..bd42859330 --- /dev/null +++ b/typegate/src/typecheck/common.ts @@ -0,0 +1,60 @@ +// Copyright Metatype OÜ under the Elastic License 2.0 (ELv2). See LICENSE.md for usage. + +import { StringFormat } from "../types/typegraph.ts"; +import * as uuid from "std/uuid/mod.ts"; +import validator from "npm:validator"; +import lodash from "npm:lodash"; + +export type ErrorEntry = [path: string, message: string]; + +export type FormatValidator = (value: string) => boolean; + +export interface Validator { + (value: unknown): void; +} + +export interface ValidationContext { + formatValidators: Record; + deepEqual: (left: T, right: T) => boolean; +} + +const formatValidators: Record = { + uuid: uuid.validate, + json: (value: string) => { + try { + JSON.parse(value); + return true; + } catch (_e) { + return false; + } + }, + email: validator.isEmail, + // TODO validatorjs does not have a URI validator, so this is stricter than expected + uri: (value: string) => { + return validator.isDataURI(value) || validator.isURL(value, { + require_protocol: true, + require_valid_protocol: false, + require_host: true, + }); + }, + // TODO + hostname: validator.isFQDN, + ean: validator.isEAN, + phone: validator.isMobilePhone, // ?? + date: validator.isDate, + // datetime: ?? +}; + +export const validationContext: ValidationContext = { + formatValidators, + deepEqual: lodash.isEqual, +}; + +export interface ValidatorFn { + ( + value: unknown, + path: string, + errors: Array, + context: ValidationContext, + ): void; +} diff --git a/typegate/src/typecheck/input.ts b/typegate/src/typecheck/input.ts index d5fd82ba1c..a0128f25d9 100644 --- a/typegate/src/typecheck/input.ts +++ b/typegate/src/typecheck/input.ts @@ -1,73 +1,25 @@ // Copyright Metatype OÜ under the Elastic License 2.0 (ELv2). See LICENSE.md for usage. -import { - ArrayNode, - BooleanNode, - IntegerNode, - NumberNode, - ObjectNode, - OptionalNode, - StringNode, - Type, - UnionNode, -} from "../type_node.ts"; import { TypeGraph } from "../typegraph.ts"; -import { EitherNode, StringFormat, TypeNode } from "../types/typegraph.ts"; -import * as uuid from "std/uuid/mod.ts"; -import validator from "npm:validator"; -import lodash from "npm:lodash"; - -type ErrorEntry = [path: string, message: string]; - -type FormatValidator = (value: string) => boolean; - -interface ValidationContext { - formatValidators: Record; - deepEqual: (left: T, right: T) => boolean; -} - -const formatValidators: Record = { - uuid: uuid.validate, - json: (value: string) => { - try { - JSON.parse(value); - return true; - } catch (_e) { - return false; - } - }, - email: validator.isEmail, - // TODO validatorjs does not have a URI validator, so this is stricter than expected - uri: (value: string) => - validator.isURL(value, { - require_valid_protocol: false, - require_host: false, - }), - // TODO - hostname: validator.isFQDN, - ean: validator.isEAN, - phone: validator.isMobilePhone, // ?? - date: validator.isDate, - // datetime: ?? -}; +import { CodeGenerator } from "./code_generator.ts"; +import { mapValues } from "std/collections/map_values.ts"; +import { + ErrorEntry, + validationContext, + Validator, + ValidatorFn, +} from "./common.ts"; -export function generateValidator(tg: TypeGraph, typeIdx: number) { +export function generateValidator(tg: TypeGraph, typeIdx: number): Validator { const validator = new Function( new InputValidationCompiler(tg).generate(typeIdx), - )() as ( - value: unknown, - path: string, - errors: Array, - context: ValidationContext, - ) => void; - + )() as ValidatorFn; return (value: unknown) => { const errors: ErrorEntry[] = []; - validator(value, "", errors, { - formatValidators, - deepEqual: lodash.isEqual, - }); + validator(value, "", errors, validationContext); + // console.log("validating input", value); if (errors.length > 0) { + console.log(errors); const messages = errors.map(([path, msg]) => ` - at ${path}: ${msg}\n`) .join(""); throw new Error(`Validation errors:\n${messages}`); @@ -75,15 +27,17 @@ export function generateValidator(tg: TypeGraph, typeIdx: number) { }; } +function functionName(typeIdx: number) { + return `validate_${typeIdx}`; +} + export class InputValidationCompiler { codes: Map = new Map(); - codegen: CodeGenerator; - constructor(tg: TypeGraph) { - this.codegen = new CodeGenerator(tg); - } + constructor(private tg: TypeGraph) {} generate(rootTypeIdx: number): string { + const cg = new CodeGenerator(); const queue = [rootTypeIdx]; const refs = new Set([rootTypeIdx]); for ( @@ -95,315 +49,68 @@ export class InputValidationCompiler { if (this.codes.has(typeIdx)) { continue; } - const { code, deps } = this.codegen.generate(typeIdx); - this.codes.set(typeIdx, code); - queue.push(...deps); - } - - const rootValidatorName = CodeGenerator.functionName(rootTypeIdx); - const rootValidator = `\nreturn ${rootValidatorName}`; - - return [...refs].map((idx) => this.codes.get(idx)) - .join("\n") + rootValidator; - } -} - -interface GeneratedCode { - code: string; - deps: number[]; -} - -class CodeGenerator { - lines: string[] = []; - - constructor(private tg: TypeGraph) {} - - public generate(typeIdx: number): GeneratedCode { - this.lines = []; - let deps: number[] = []; - const typeNode = this.tg.type(typeIdx); - - if (typeNode.enum != null) { - this.generateEnumValidator(typeNode); - } else { - switch (typeNode.type) { - case "boolean": - this.generateBooleanValidator(typeNode); - break; - case "number": - case "integer": - this.generateNumberValidator(typeNode); - break; - case "string": - this.generateStringValidator(typeNode); - break; - case "optional": - deps = this.generateOptionalValidator(typeNode); - break; - case "array": - deps = this.generateArrayValidator(typeNode); - break; - case "object": - deps = this.generateObjectValidator(typeNode); - break; - case "union": - deps = this.generateUnionValidator(typeNode); - break; - case "either": - deps = this.generateEitherValidator(typeNode); - break; - default: - throw new Error(`Unsupported type: ${typeNode.type}`); - } - } - - return { code: this.end(typeIdx), deps }; - } - - generateEnumValidator(typeNode: TypeNode) { - const comparisons = []; - - if ( - ([ - Type.BOOLEAN, - Type.NUMBER, - Type.INTEGER, - Type.STRING, - ] as TypeNode["type"][]) - .includes(typeNode.type) - ) { - // shallow comparison - comparisons.push( - ...typeNode.enum!.map((val) => `value !== ${val}`), - ); - } else { - // deep comparison - comparisons.push( - ...typeNode.enum!.map((val) => `!context.deepEqual(value, ${val})`), - ); - } - - this.validation( - comparisons.join(" && "), - '"value did not match to any of the enum values"', - ); - } - - generateBooleanValidator(_typeNode: BooleanNode) { - this.validation( - 'typeof value !== "boolean"', - "`expected boolean, got ${typeof value}`", - ); - } - - generateNumberValidator(typeNode: NumberNode | IntegerNode) { - this.validation( - 'typeof value !== "number"', - "`expected number, got ${typeof value}`", - ); - const constraints = [ - ["minimum", "<"], - ["maximum", ">"], - ["exclusiveMinimum", "<=", "exclusive minimum"], - ["exclusiveMaximum", ">=", "exclusive maximum"], - ] as const; - for (const c of constraints) { - const [prop, comp, name = null] = c; - const constraint = typeNode[prop]; - if (constraint != null) { - this.line("else"); - this.validation( - `value ${comp} ${constraint}`, - `\`expected ${name ?? prop} value: ${constraint}, got \${value}\``, - ); - } - } - } - - private generateStringValidator(typeNode: StringNode) { - this.validation( - 'typeof value !== "string"', - "`expected a string, got ${typeof value}`", - ); - const constraints = [ - ["minLength", "<", "minimum length"], - ["maxLength", ">", "maximum length"], - ] as const; - for (const c of constraints) { - const [prop, comp, name] = c; - const constraint = typeNode[prop]; - if (constraint != null) { - this.line("else"); - this.validation( - `value.length ${comp} ${constraint}`, - `\`expected ${name}: ${constraint}, got \${value.length}\``, - ); + const typeNode = this.tg.type(typeIdx); + + if (typeNode.enum != null) { + cg.generateEnumValidator(typeNode); + } else { + switch (typeNode.type) { + case "boolean": + cg.generateBooleanValidator(typeNode); + break; + case "number": + case "integer": + cg.generateNumberValidator(typeNode); + break; + case "string": + cg.generateStringValidator(typeNode); + break; + case "optional": + cg.generateOptionalValidator(typeNode, functionName(typeNode.item)); + queue.push(typeNode.item); + break; + case "array": + cg.generateArrayValidator(typeNode, functionName(typeNode.items)); + queue.push(typeNode.items); + break; + case "object": + cg.generateObjectValidator( + typeNode, + mapValues(typeNode.properties, functionName), + ); + queue.push(...Object.values(typeNode.properties)); + break; + case "union": + cg.generateUnionValidator( + typeNode, + typeNode.anyOf.map(functionName), + ); + queue.push(...typeNode.anyOf); + break; + case "either": + cg.generateEitherValidator( + typeNode, + typeNode.oneOf.map(functionName), + ); + queue.push(...typeNode.oneOf); + break; + default: + throw new Error(`Unsupported type: ${typeNode.type}`); + } } - } - if (typeNode.pattern != null) { - this.line("else {"); - this.validation( - `!new RegExp("${typeNode.pattern}").test(value)`, - `"string does not match to the pattern /${typeNode.pattern}/"`, - ); - this.line("}"); - } - if (typeNode.format != null) { - this.line("else {"); - this.line( - `const formatValidator = context.formatValidators["${typeNode.format}"]`, - ); - this.validation( - "formatValidator == null", - `"unknown format '${typeNode.format}'"`, - ); - this.line("else"); - this.validation( - "!formatValidator(value)", - `"string does not statisfy the required format '${typeNode.format}'"`, - ); - this.line("}"); - } - } - - private generateOptionalValidator(typeNode: OptionalNode): number[] { - this.line(`if (value != null) {`); - this.line( - `${ - CodeGenerator.functionName(typeNode.item) - }(value, path, errors, context)`, - ); - this.line("}"); - return [typeNode.item]; - } - - private generateArrayValidator(typeNode: ArrayNode): number[] { - this.validation( - `!Array.isArray(value)`, - "`expected an array, got ${typeof value}`", - ); - const constraints = [ - ["minItems", "<", "minimum items"], - ["maxItems", ">", "maximum items"], - ] as const; - for (const c of constraints) { - const [prop, comp, name] = c; - const constraint = typeNode[prop]; - if (constraint != null) { - this.line("else"); - this.validation( - `value.length ${comp} ${typeNode[prop]}`, - `\`expected ${name}: ${constraint}, got \${value.length}\``, - ); - } - } - - const itemType = typeNode.items; - const itemValidator = CodeGenerator.functionName(itemType); - - this.line("else {"); - this.line("for (let i = 0; i < value.length; ++i) {"); - this.line("const item = value[i]"); - this.line( - `${itemValidator}(value[i], path + \`[\${i}]\`, errors, context)`, - ); - this.line("}"); - this.line("}"); - - return [itemType]; - } - - private generateObjectValidator(typeNode: ObjectNode): number[] { - this.validation( - `typeof value !== "object"`, - "`expected an object, got ${typeof value}`", - ); - this.line("else"); - this.validation( - `value == null`, - '"exptected a non-null object, got null"', - ); - this.line("else {"); - this.line("const keys = new Set(Object.keys(value))"); - for (const [name, typeIdx] of Object.entries(typeNode.properties)) { - this.line(`keys.delete("${name}")`); - const validator = CodeGenerator.functionName(typeIdx); - this.line( - `${validator}(value["${name}"], path + ".${name}", errors, context)`, + const fnName = functionName(typeIdx); + const fnBody = cg.reset().join("\n"); + this.codes.set( + typeIdx, + `function ${fnName}(value, path, errors, context) {\n${fnBody}\n}`, ); } - this.validation( - "keys.size > 0", - `\`unexpected fields: \${[...keys].join(', ')}\``, - ); - this.line("}"); - return Object.values(typeNode.properties); - } - - private generateUnionValidator(typeNode: UnionNode): number[] { - this.line("let errs;"); - for (const variantIdx of typeNode.anyOf) { - this.line(`errs = []`); - const validator = CodeGenerator.functionName(variantIdx); - this.line(`${validator}(value, path, errs, context)`); - this.line("if (errs.length === 0) { return }"); - } - // TODO display variant errors - this.line( - 'errors.push([path, "Value does not match to any variant of the union type"])', - ); - return typeNode.anyOf; - } - - private generateEitherValidator(typeNode: EitherNode): number[] { - this.line("let matchCount = 0;"); - this.line("let errs;"); - - for (const variantIdx of typeNode.oneOf) { - this.line(`errs = []`); - const validator = CodeGenerator.functionName(variantIdx); - this.line(`${validator}(value, path, errs, context);`); - this.line("if (errs.length === 0) { matchCount += 1 }"); - } - - this.line("if (matchCount === 0) {"); - this.line( - 'errors.push([path, "Value does not match to any variant of the either type"])', - ); - this.line("}"); - this.line("else if (matchCount > 1) {"); - this.line( - 'errors.push([path, "Value match to more than one variant of the either type"])', - ); - this.line("}"); - - return typeNode.oneOf; - } - - private end(typeIdx: number): string { - const fnName = CodeGenerator.functionName(typeIdx); - const fnBody = this.lines.join("\n"); - return `function ${fnName}(value, path, errors, context) {\n${fnBody}\n}`; - } - - private validation( - errorCond: string, - errorMsg: string, - onError: string[] = [], - ) { - this.line(`if (${errorCond}) {`); - this.line(`errors.push([path, ${errorMsg}]);`); - this.lines.push(...onError); - this.line("}"); - } - - private line(l: string) { - this.lines.push(l); - } + const rootValidatorName = functionName(rootTypeIdx); + const rootValidator = `\nreturn ${rootValidatorName}`; - static functionName(typeIdx: number) { - return `validate_${typeIdx}`; + return [...refs].map((idx) => this.codes.get(idx)) + .join("\n") + rootValidator; } } diff --git a/typegate/src/typecheck/matching_variant.ts b/typegate/src/typecheck/matching_variant.ts new file mode 100644 index 0000000000..b833343e96 --- /dev/null +++ b/typegate/src/typecheck/matching_variant.ts @@ -0,0 +1,157 @@ +// Copyright Metatype OÜ under the Elastic License 2.0 (ELv2). See LICENSE.md for usage. + +import { Type, TypeNode } from "../type_node.ts"; +import { TypeGraph } from "../typegraph.ts"; +import { CodeGenerator } from "./code_generator.ts"; +import { mapValues } from "std/collections/map_values.ts"; +import { ErrorEntry, validationContext, ValidatorFn } from "./common.ts"; + +export type VariantMatcher = (value: unknown) => string | null; + +export function flattenUnionVariants( + tg: TypeGraph, + variants: number[], +): number[] { + return variants.flatMap((idx) => { + const typeNode = tg.type(idx); + switch (typeNode.type) { + case Type.UNION: + return flattenUnionVariants(tg, typeNode.anyOf); + case Type.EITHER: + return flattenUnionVariants(tg, typeNode.oneOf); + default: + return [idx]; + } + }); +} + +// get the all the variants in a multilevel union/either +export function getNestedUnionVariants( + tg: TypeGraph, + typeNode: TypeNode, +): number[] { + switch (typeNode.type) { + case Type.UNION: + return flattenUnionVariants(tg, typeNode.anyOf); + case Type.EITHER: + return flattenUnionVariants(tg, typeNode.oneOf); + default: + throw new Error(`Expected either or union, got '${typeNode.type}'`); + } +} +// optimized variant matcher for union of objects +export function generateVariantMatcher( + tg: TypeGraph, + typeIdx: number, +): VariantMatcher { + // all variants must be objects + const variantIndices = getNestedUnionVariants(tg, tg.type(typeIdx)); + const variants = variantIndices.map((idx) => tg.type(idx, Type.OBJECT)); + + const validators = new Function( + new VariantMatcherCompiler(tg).generate(variantIndices), + )() as ValidatorFn[]; + + return (value: unknown) => { + let errors: ErrorEntry[] = []; + for (let i = 0; i < variants.length; ++i) { + const validator = validators[i]; + validator(value, "", errors, validationContext); + if (errors.length === 0) { + return variants[i].title; + } + errors = []; + } + return null; + }; +} + +function functionName(typeIdx: number) { + return `validate_${typeIdx}`; +} + +class VariantMatcherCompiler { + codes: Map = new Map(); + + constructor(private tg: TypeGraph) {} + + generate(variants: number[]): string { + // TODO onError: return + const cg = new CodeGenerator(); + const queue = [...variants]; + const refs = new Set(variants); + + for ( + let typeIdx = queue.shift(); + typeIdx != null; + typeIdx = queue.shift() + ) { + refs.add(typeIdx); + if (this.codes.has(typeIdx)) { + continue; + } + const typeNode = this.tg.type(typeIdx); + + if (typeNode.enum != null) { + cg.generateEnumValidator(typeNode); + } else { + switch (typeNode.type) { + case "boolean": + cg.generateBooleanValidator(typeNode); + break; + case "number": + case "integer": + cg.generateNumberValidator(typeNode); + break; + case "string": + cg.generateStringValidator(typeNode); + break; + case "optional": + cg.generateOptionalValidator(typeNode, functionName(typeNode.item)); + queue.push(typeNode.item); + break; + case "array": + cg.generateArrayValidator(typeNode, functionName(typeNode.items)); + queue.push(typeNode.items); + break; + case "object": + cg.generateObjectValidator( + typeNode, + mapValues(typeNode.properties, functionName), + ); + queue.push(...Object.values(typeNode.properties)); + break; + case "union": + cg.generateUnionValidator( + typeNode, + typeNode.anyOf.map(functionName), + ); + queue.push(...typeNode.anyOf); + break; + case "either": + cg.generateEitherValidator( + typeNode, + typeNode.oneOf.map(functionName), + ); + queue.push(...typeNode.oneOf); + break; + default: + throw new Error(`Unsupported type: ${typeNode.type}`); + } + } + + const fnName = functionName(typeIdx); + const fnBody = cg.reset().join("\n"); + this.codes.set( + typeIdx, + `function ${fnName}(value, path, errors, context) {\n${fnBody}\n}`, + ); + } + + const validatorNames = variants.map((idx) => functionName(idx)); + const variantValidators = `\nreturn [${validatorNames.join(", ")}]`; + + return [...refs].map((idx) => this.codes.get(idx)).join("\n") + + variantValidators; + } +} diff --git a/typegate/src/typecheck/result.ts b/typegate/src/typecheck/result.ts new file mode 100644 index 0000000000..999919b2ac --- /dev/null +++ b/typegate/src/typecheck/result.ts @@ -0,0 +1,447 @@ +// Copyright Metatype OÜ under the Elastic License 2.0 (ELv2). See LICENSE.md for usage. + +import { FragmentDefs } from "../graphql.ts"; +import { + OperationDefinitionNode, + SelectionNode, + SelectionSetNode, +} from "graphql/ast"; +import { FieldNode, Kind } from "graphql"; +import { isScalar, ObjectNode, Type } from "../type_node.ts"; +import { TypeGraph } from "../typegraph.ts"; +import { CodeGenerator } from "./code_generator.ts"; +import { getChildTypes } from "../typegraph/visitor.ts"; +import { mapValues } from "std/collections/map_values.ts"; +import { + ErrorEntry, + validationContext, + Validator, + ValidatorFn, +} from "./common.ts"; +import { + flattenUnionVariants, + getNestedUnionVariants, +} from "./matching_variant.ts"; + +export function generateValidator( + tg: TypeGraph, + operation: OperationDefinitionNode, + fragments: FragmentDefs, +): Validator { + const code = new ResultValidationCompiler(tg, fragments).generate(operation); + const validator = new Function(code)() as ValidatorFn; + + return (value: unknown) => { + // console.log("validating", value); + const errors: ErrorEntry[] = []; + validator(value, "", errors, validationContext); + if (errors.length > 0) { + const messages = errors.map(([path, msg]) => ` - at ${path}: ${msg}\n`) + .join(""); + throw new Error(`Validation errors:\n${messages}`); + } + }; +} + +interface QueueEntry { + name: string; + typeIdx: number; + selectionSet?: SelectionSetNode | undefined; + path: string; +} + +export class ResultValidationCompiler { + codes: Map = new Map(); + counter = 0; + + constructor(private tg: TypeGraph, private fragments: FragmentDefs) {} + + private validatorName(idx: number, additionalSuffix = false) { + if (!additionalSuffix) { + return `validate_${idx}`; + } + + this.counter += 1; + return `validate_${idx}_${this.counter}`; + } + + private getRootQueueEntry(opDef: OperationDefinitionNode): QueueEntry { + const { name, operation, selectionSet } = opDef; + const rootTypeIdx = this.tg.type(0, Type.OBJECT).properties[operation]; + if (rootTypeIdx == null) { + throw new Error(`Unsupported operation '${operation}'`); + } + const rootPath = name?.value ?? operation[0].toUpperCase(); + // TODO check if selection set is required or prohibited + return { + name: this.validatorName(rootTypeIdx, true), + typeIdx: rootTypeIdx, + selectionSet, + path: rootPath, + }; + } + + generate(opDef: OperationDefinitionNode) { + const cg = new CodeGenerator(); + const rootEntry = this.getRootQueueEntry(opDef); + const queue: QueueEntry[] = [rootEntry]; + const refs = new Set([rootEntry.name]); + + for ( + let entry = queue.shift(); + entry != null; + entry = queue.shift() + ) { + refs.add(entry.name); + if (this.codes.has(entry.name)) { + continue; + } + + const typeNode = this.tg.type(entry.typeIdx); + + if (entry.name === "validate_typename") { + cg.generateStringValidator({ + type: "string", + title: "__TypeName", + runtime: -1, + policies: [], + }); + } else if (isScalar(typeNode)) { + if (entry.selectionSet != null) { + throw new Error( + `Unexpected selection set for scalar type '${typeNode.type}' at '${entry.path}'`, + ); + } + + if (typeNode.enum != null) { + cg.generateEnumValidator(typeNode); + } else { + switch (typeNode.type) { + case "boolean": + cg.generateBooleanValidator(typeNode); + break; + case "number": + case "integer": + cg.generateNumberValidator(typeNode); + break; + case "string": + cg.generateStringValidator(typeNode); + break; + } + } + } else { + // TODO: cannot check enum - perhaps we should disable enums for non-scalar types?? + + switch (typeNode.type) { + case "optional": { + const itemValidatorName = this.validatorName( + typeNode.item, + entry.selectionSet != null, + ); + cg.generateOptionalValidator(typeNode, itemValidatorName); + queue.push({ + name: itemValidatorName, + typeIdx: typeNode.item, + selectionSet: entry.selectionSet, + path: entry.path, + }); + break; + } + + case "array": { + const itemValidatorName = this.validatorName( + typeNode.items, + entry.selectionSet != null, + ); + cg.generateArrayValidator(typeNode, itemValidatorName); + queue.push({ + name: itemValidatorName, + typeIdx: typeNode.items, + selectionSet: entry.selectionSet, + path: entry.path, + }); + break; + } + + case "object": { + const childEntries: Record = this + .getChildEntries(typeNode, entry); + cg.generateObjectValidator( + typeNode, + mapValues(childEntries, (e) => e.name), + ); + queue.push(...Object.values(childEntries)); + break; + } + + case "union": { + const childEntries = this.getVariantEntries(typeNode.anyOf, entry); + cg.generateUnionValidator( + typeNode, + childEntries.map((e) => e.name), + ); + queue.push(...childEntries); + break; + } + + case "either": { + const childEntries = this.getVariantEntries(typeNode.oneOf, entry); + cg.generateEitherValidator( + typeNode, + childEntries.map((e) => e.name), + ); + queue.push(...childEntries); + break; + } + + case "function": { + const outputValidator = this.validatorName( + typeNode.output, + entry.selectionSet != null, + ); + cg.line(`${outputValidator}(value, path, errors, context)`); + queue.push({ + name: outputValidator, + path: entry.path, + typeIdx: typeNode.output, + selectionSet: entry.selectionSet, + }); + break; + } + + default: + throw new Error(`Unsupported type ${typeNode.type}`); + } + } + + const fnName = entry.name; + const fnBody = cg.reset().join("\n"); + this.codes.set( + fnName, + `function ${fnName}(value, path, errors, context) {\n${fnBody}\n}`, + ); + } + + const rootValidator = `\nreturn ${rootEntry.name}`; + + return [...refs].map((name) => this.codes.get(name)).join("\n") + + rootValidator; + } + + private getChildEntryFromFieldNode( + typeNode: ObjectNode, + entry: QueueEntry, + node: FieldNode, + ): [string, QueueEntry] { + const { name, selectionSet, alias } = node; + const propName = alias?.value ?? name.value; + if (name.value === "__typename") { + return [propName, { + name: "validate_typename", + typeIdx: -1, + path: entry.path + ".__typename", + } as QueueEntry]; + } + + if (!Object.hasOwn(typeNode.properties, name.value)) { + throw new Error( + `Unexpected property '${name.value}' at '${entry.path}'`, + ); + } + + const propTypeIdx = typeNode.properties[name.value]; + const path = `${entry.path}.${name.value}`; + let validator: string; + if (this.hasNestedObjectResult(propTypeIdx)) { + if (selectionSet == null) { + throw new Error( + `Selection set required at '${path}'`, + ); + } + validator = this.validatorName(propTypeIdx, true); + } else { + if (selectionSet != null) { + throw new Error( + `Unexpected selection set at '${path}'`, + ); + } + validator = this.validatorName(propTypeIdx, false); + } + + return [propName, { + name: validator, + path, + typeIdx: propTypeIdx, + selectionSet, + } as QueueEntry]; + } + + private getChildEntriesFromSelectionNode( + typeNode: ObjectNode, + entry: QueueEntry, + node: SelectionNode, + ): Array<[string, QueueEntry]> { + switch (node.kind) { + case Kind.FIELD: + return [this.getChildEntryFromFieldNode(typeNode, entry, node)]; + + case Kind.FRAGMENT_SPREAD: { + const fragment = this.fragments[node.name.value]; + return fragment.selectionSet.selections.flatMap((selectionNode) => + this.getChildEntriesFromSelectionNode(typeNode, entry, selectionNode) + ); + } + + case Kind.INLINE_FRAGMENT: { + if (node.typeCondition != null) { + throw new Error("Unexpected type condition on non-union type"); + } + return node.selectionSet.selections.flatMap((selectionNode) => + this.getChildEntriesFromSelectionNode( + typeNode, + entry, + selectionNode, + ) + ); + } + + default: + throw new Error("Not implemented"); + } + } + + private getChildEntries( + typeNode: ObjectNode, + entry: QueueEntry, + ): Record { + return Object.fromEntries( + entry.selectionSet! + .selections.flatMap((node) => + this.getChildEntriesFromSelectionNode(typeNode, entry, node) + ), + ); + } + + private getVariantEntries( + variants: number[], + entry: QueueEntry, + ): QueueEntry[] { + const multilevelVariants = flattenUnionVariants(this.tg, variants); + if (entry.selectionSet == null) { + if ( + multilevelVariants.some((variantIdx) => + !isScalar(this.tg.type(variantIdx)) + ) + ) { + const hasScalar = variants.some((idx) => isScalar(this.tg.type(idx))); + if (hasScalar) { + // TODO: AOT validation for the typegraph + throw new Error( + `Either/union variants must be either all scalars or all objects at '${entry.path}'`, + ); + } + throw new Error(`Selection set required at '${entry.path}'`); + } + return variants.map((variantIdx) => ({ + name: this.validatorName(variantIdx, false), + path: entry.path, + typeIdx: variantIdx, + })); + } + + if ( + multilevelVariants.some((idx) => this.tg.type(idx).type !== Type.OBJECT) + ) { + throw new Error( + `Either/union variants must be either all scalars or all objects at '${entry.path}'`, + ); + } + + const variantSelections = new Map( + entry.selectionSet.selections.map((node) => { + if (node.kind !== Kind.INLINE_FRAGMENT || node.typeCondition == null) { + throw new Error( + `at '${entry.path}': selection nodes must be inline fragments with type condition`, + ); + } + return [node.typeCondition.name.value, node]; + }), + ); + + const entries: QueueEntry[] = variants.map((variantIdx) => { + const variantType = this.tg.type(variantIdx); + switch (variantType.type) { + case Type.OBJECT: { + const typeName = variantType.title; + const selectionSet = variantSelections.get(typeName)?.selectionSet; + if (selectionSet == null) { + throw new Error( + `at '${entry.path}': variant type '${typeName}' must be selected with type condition inline fragment`, + ); + } + variantSelections.delete(typeName); + return { + name: this.validatorName(variantIdx, true), + path: entry.path, + typeIdx: variantIdx, + selectionSet, + }; + } + + case Type.UNION: + case Type.EITHER: { + const variantIndices = getNestedUnionVariants(this.tg, variantType); + return { + name: this.validatorName(variantIdx, true), + path: entry.path, + typeIdx: variantIdx, + selectionSet: { + kind: Kind.SELECTION_SET, + selections: variantIndices.map((idx) => { + const typeNode = this.tg.type(idx); + const typeName = typeNode.title; + const node = variantSelections.get(typeName); + if (node == null) { + throw new Error( + `at '${entry.path}': variant type '${typeName}' must be selected with type condition inline fragment`, + ); + } + variantSelections.delete(typeName); + return node; + }), + }, + }; + } + + default: + throw new Error(); + } + }); + + if (variantSelections.size > 0) { + const names = [...variantSelections.keys()].join(", "); + throw new Error( + `at '${entry.path}': Unexpected type conditions: ${names}`, + ); + } + return entries; + } + + private hasNestedObjectResult(typeIdx: number): boolean { + const queue = [typeIdx]; + + for (let idx = queue.shift(); idx != null; idx = queue.shift()) { + const typeNode = this.tg.type(idx); + switch (typeNode.type) { + case Type.OBJECT: + return true; + case Type.FUNCTION: + queue.push(typeNode.output); + break; + default: + queue.push(...getChildTypes(typeNode)); + } + } + return false; + } +} diff --git a/typegate/src/typegraph.ts b/typegate/src/typegraph.ts index 7e8362e663..59b15b48e0 100644 --- a/typegate/src/typegraph.ts +++ b/typegate/src/typegraph.ts @@ -311,25 +311,34 @@ export class TypeGraph { if (isArray(type)) { if (isOptional(this.type(type.items))) { - return (x: any) => x.flat().filter((c: any) => !!c); + return (x: any) => { + return x.flat().filter((c: any) => !!c); + }; } - return (x: any) => ensureArray(x).flat(); + return (x: any) => { + return ensureArray(x).flat(); + }; } if (isOptional(type)) { if (isArray(this.type(type.item))) { - return (x: any) => - ensureArray(x) - .filter((c: any) => !!c) - .flat(); + return (x: any) => { + return ensureArray(x) + .flat() + .filter((c: any) => !!c); + }; } - return (x: any) => ensureArray(x).filter((c: any) => !!c); + return (x: any) => { + return ensureArray(x).filter((c: any) => !!c); + }; } ensure( isObject(type) || isInteger(type) || isNumber(type) || isBoolean(type) || isFunction(type) || isString(type) || isUnion(type) || isEither(type), `object expected but got ${type.type}`, ); - return (x: any) => ensureArray(x); + return (x: any) => { + return ensureArray(x); + }; }; typeByNameOrIndex(nameOrIndex: string | number): TypeNode { @@ -346,74 +355,4 @@ export class TypeGraph { } return tpe; } - - validateValueType( - nameOrIndex: string | number, - value: unknown, - label: string, - ) { - const tpe = this.typeByNameOrIndex(nameOrIndex); - - if (isOptional(tpe)) { - if (value == null) return; - this.validateValueType(tpe.item as number, value, label); - return; - } - - if (value == null) { - throw new Error(`variable ${label} cannot be null`); - } - - switch (tpe.type) { - case "object": - if (typeof value !== "object") { - throw new Error(`variable ${label} must be an object`); - } - Object.entries(tpe.properties).forEach( - ([key, typeIdx]) => { - this.validateValueType( - typeIdx, - (value as Record)[key], - `${label}.${key}`, - ); - }, - ); - return; - case "array": - if (!Array.isArray(value)) { - throw new Error(`variable ${label} must be an array`); - } - value.forEach((item, idx) => { - this.validateValueType( - tpe.items, - item, - `${label}[${idx}]`, - ); - }); - return; - case "integer": - case "number": - if (typeof value !== "number") { - throw new Error(`variable ${label} must be a number`); - } - return; - case "boolean": - if (typeof value !== "boolean") { - throw new Error(`variable ${label} must be a boolean`); - } - return; - case "string": - if (typeof value !== "string") { - throw new Error(`variable ${label} must be a string`); - } - return; - // case "uuid": - // if (!uuid.validate(value as string)) { - // throw new Error(`variable ${label} must be a valid UUID`); - // } - // return; - default: - throw new Error(`unsupported type ${tpe.type}`); - } - } } diff --git a/typegate/src/typegraph/visitor.ts b/typegate/src/typegraph/visitor.ts index 96d26872bf..14927e91f0 100644 --- a/typegate/src/typegraph/visitor.ts +++ b/typegate/src/typegraph/visitor.ts @@ -48,6 +48,10 @@ export function getChildTypes(type: TypeNode): number[] { return Object.values(type.properties); case Type.FUNCTION: return [type.input, type.output]; + case Type.UNION: + return type.anyOf; + case Type.EITHER: + return type.oneOf; default: return []; diff --git a/typegate/src/types.ts b/typegate/src/types.ts index 64bdb0d508..be24e9766e 100644 --- a/typegate/src/types.ts +++ b/typegate/src/types.ts @@ -11,6 +11,7 @@ import { ObjectNode, TypeNode } from "./type_node.ts"; import * as ast from "graphql/ast"; import { ComputeArg } from "./planner/args.ts"; import { EffectType, PolicyIndices } from "./types/typegraph.ts"; +import { VariantMatcher } from "./typecheck/matching_variant.ts"; export interface Parents { [key: string]: (() => Promise | unknown) | unknown; @@ -78,6 +79,7 @@ export interface ComputeStageProps { path: string[]; rateCalls: boolean; rateWeight: number; + childSelection?: VariantMatcher; } export type StageId = string; diff --git a/typegate/tests/introspection/__snapshots__/union_either_test.ts.snap b/typegate/tests/introspection/__snapshots__/union_either_test.ts.snap index 97b6aa6905..4d8105b778 100644 --- a/typegate/tests/introspection/__snapshots__/union_either_test.ts.snap +++ b/typegate/tests/introspection/__snapshots__/union_either_test.ts.snap @@ -76,10 +76,6 @@ snapshot[`Basic introspection 1`] = ` kind: "OBJECT", name: "Gunpla", }, - { - kind: "OBJECT", - name: "_String", - }, ], }, { diff --git a/typegate/tests/introspection/union_either.py b/typegate/tests/introspection/union_either.py index 2a57c37140..9799e2080b 100644 --- a/typegate/tests/introspection/union_either.py +++ b/typegate/tests/introspection/union_either.py @@ -7,7 +7,7 @@ gunpla = t.struct( {"model": t.string(), "ref": t.union([t.string(), t.integer()])} ).named("Gunpla") - toy = t.either([rubix_cube, toygun, gunpla, t.string()]) + toy = t.either([rubix_cube, toygun, gunpla]) user = t.struct( { diff --git a/typegate/tests/planner/planner_test.ts b/typegate/tests/planner/planner_test.ts index 823ee2d7f4..112b5bb093 100644 --- a/typegate/tests/planner/planner_test.ts +++ b/typegate/tests/planner/planner_test.ts @@ -1,7 +1,7 @@ // Copyright Metatype OÜ under the Elastic License 2.0 (ELv2). See LICENSE.md for usage. import { findOperation } from "../../src/graphql.ts"; -import { test } from "../utils.ts"; +import { gql, test } from "../utils.ts"; import { None } from "monads"; import { parse } from "graphql"; import { mapValues } from "std/collections/map_values.ts"; @@ -56,4 +56,36 @@ test("planner", async (t) => { }; })); }); + + await t.should("fail when required selections are missing", async () => { + await gql` + query { + one + } + ` + .expectErrorContains("at Q.one: selection set is expected for object") + .on(e); + + await gql` + query { + one { + nested + } + } + ` + .expectErrorContains("at Q.one.nested: selection set is expected") + .on(e); + }); + + await t.should("fail for unexpected selections", async () => { + await gql` + query { + one { + id { id } + } + } + ` + .expectErrorContains("at Q.one.id: Unexpected selection set") + .on(e); + }); }); diff --git a/typegate/tests/schema_validation/circular.py b/typegate/tests/schema_validation/circular.py index 7483de9ddd..d314abe7ce 100644 --- a/typegate/tests/schema_validation/circular.py +++ b/typegate/tests/schema_validation/circular.py @@ -32,8 +32,7 @@ # Edgecase #6: nested union/either "award": t.either( [ - t.string(), - t.integer(), + t.struct({"name": t.string()}).named("NamedAward"), t.union([medals, stars]), ] ).optional(), diff --git a/typegate/tests/schema_validation/circular_test.ts b/typegate/tests/schema_validation/circular_test.ts index 9ead3a275e..96e1da2c95 100644 --- a/typegate/tests/schema_validation/circular_test.ts +++ b/typegate/tests/schema_validation/circular_test.ts @@ -12,7 +12,7 @@ test("circular test", async (t) => { registerUser( user: { name: "John", - professor: {name: "Kramer", parents: [], award: 6}, + professor: {name: "Kramer", parents: [], award: {count: 6}}, parents: [], paper: {title: "Some paper", author: {name: "John", parents: []}}, friends: [ diff --git a/typegate/tests/simple/__snapshots__/class_syntax_test.ts.snap b/typegate/tests/simple/__snapshots__/class_syntax_test.ts.snap index 4523dc4386..2c5ce93962 100644 --- a/typegate/tests/simple/__snapshots__/class_syntax_test.ts.snap +++ b/typegate/tests/simple/__snapshots__/class_syntax_test.ts.snap @@ -111,7 +111,7 @@ snapshot[`Class Syntax 1`] = ` }, ], kind: "OBJECT", - name: "object_6", + name: "Info", }, { fields: [ @@ -176,7 +176,7 @@ snapshot[`Class Syntax 1`] = ` { fields: null, kind: "INPUT_OBJECT", - name: "object_6Inp", + name: "InfoInp", }, { fields: null, diff --git a/typegate/tests/simple/class_syntax.py b/typegate/tests/simple/class_syntax.py index 595b66f2a2..ce0a04bbc0 100644 --- a/typegate/tests/simple/class_syntax.py +++ b/typegate/tests/simple/class_syntax.py @@ -13,7 +13,7 @@ class TitledEntity(t.struct): class Info(TitledEntity): content = t.string() - metadata = t.either([tag, Info()]) + metadata = t.either([tag, Info().named("Info")]) class Comment(TitledEntity): content = t.string() diff --git a/typegate/tests/simple/class_syntax_test.ts b/typegate/tests/simple/class_syntax_test.ts index 59b0d22e2f..8c72b99ebb 100644 --- a/typegate/tests/simple/class_syntax_test.ts +++ b/typegate/tests/simple/class_syntax_test.ts @@ -72,7 +72,7 @@ test("Class Syntax", async (t) => { } ` .expectErrorContains( - "expected minimum length: 2, got 0", + ".comments[0].title: expected minimum length: 2", ) .on(e); }); diff --git a/typegate/tests/type_nodes/__snapshots__/either_test.ts.snap b/typegate/tests/type_nodes/__snapshots__/either_test.ts.snap index 57a985dd8c..f79f83bba4 100644 --- a/typegate/tests/type_nodes/__snapshots__/either_test.ts.snap +++ b/typegate/tests/type_nodes/__snapshots__/either_test.ts.snap @@ -44,22 +44,22 @@ snapshot[`Either type 2`] = ` possibleTypes: [ { kind: "OBJECT", - name: "object_16", + name: "SuccessTransaction", }, { kind: "OBJECT", - name: "object_18", + name: "FailedTransaction", }, ], }, { kind: "OBJECT", - name: "object_16", + name: "SuccessTransaction", possibleTypes: null, }, { kind: "OBJECT", - name: "object_18", + name: "FailedTransaction", possibleTypes: null, }, { diff --git a/typegate/tests/type_nodes/__snapshots__/union_test.ts.snap b/typegate/tests/type_nodes/__snapshots__/union_test.ts.snap index 4732a43d02..727ac713e0 100644 --- a/typegate/tests/type_nodes/__snapshots__/union_test.ts.snap +++ b/typegate/tests/type_nodes/__snapshots__/union_test.ts.snap @@ -2,10 +2,7 @@ export const snapshot = {}; snapshot[`Union type 1`] = ` [ - "Validation errors: -" + - " - at .color: Value does not match to any variant of the union type -", + "Type mismatch: got 'StringValue' but expected 'OBJECT' for argument 'color' of type 'union' ('Color') at convert", ] `; @@ -23,6 +20,11 @@ snapshot[`Union type 2`] = ` name: "String", possibleTypes: null, }, + { + kind: "SCALAR", + name: "Boolean", + possibleTypes: null, + }, { kind: "OBJECT", name: "_Integer", @@ -33,6 +35,11 @@ snapshot[`Union type 2`] = ` name: "_String", possibleTypes: null, }, + { + kind: "OBJECT", + name: "_Boolean", + possibleTypes: null, + }, { kind: "OBJECT", name: "Query", @@ -43,16 +50,121 @@ snapshot[`Union type 2`] = ` name: "ColorOut", possibleTypes: [ { - kind: "LIST", - name: null, + kind: "OBJECT", + name: "RGBArray", }, { kind: "OBJECT", - name: "RGB_struct", + name: "RGBStruct", }, { kind: "OBJECT", - name: "_String", + name: "HexColor", + }, + { + kind: "OBJECT", + name: "NamedColor", + }, + ], + }, + { + kind: "OBJECT", + name: "RGBArray", + possibleTypes: null, + }, + { + kind: "OBJECT", + name: "RGBStruct", + possibleTypes: null, + }, + { + kind: "OBJECT", + name: "HexColor", + possibleTypes: null, + }, + { + kind: "OBJECT", + name: "NamedColor", + possibleTypes: null, + }, + { + kind: "UNION", + name: "NestedUnionsOut", + possibleTypes: [ + { + kind: "OBJECT", + name: "A1", + }, + { + kind: "OBJECT", + name: "B", + }, + ], + }, + { + kind: "OBJECT", + name: "A1", + possibleTypes: null, + }, + { + kind: "UNION", + name: "union_20Out", + possibleTypes: [ + { + kind: "OBJECT", + name: "A2", + }, + { + kind: "OBJECT", + name: "B", + }, + ], + }, + { + kind: "OBJECT", + name: "A2", + possibleTypes: null, + }, + { + kind: "UNION", + name: "union_18Out", + possibleTypes: [ + { + kind: "OBJECT", + name: "A3", + }, + { + kind: "OBJECT", + name: "A4", + }, + ], + }, + { + kind: "OBJECT", + name: "A3", + possibleTypes: null, + }, + { + kind: "OBJECT", + name: "A4", + possibleTypes: null, + }, + { + kind: "OBJECT", + name: "B", + possibleTypes: null, + }, + { + kind: "UNION", + name: "ScalarUnionOut", + possibleTypes: [ + { + kind: "OBJECT", + name: "_Boolean", + }, + { + kind: "OBJECT", + name: "_Integer", }, { kind: "OBJECT", @@ -60,9 +172,84 @@ snapshot[`Union type 2`] = ` }, ], }, + { + kind: "UNION", + name: "MultilevelUnionOut", + possibleTypes: [ + { + kind: "OBJECT", + name: "Ua", + }, + { + kind: "OBJECT", + name: "Ub", + }, + { + kind: "UNION", + name: "union_38Out", + }, + ], + }, + { + kind: "OBJECT", + name: "Ua", + possibleTypes: null, + }, + { + kind: "OBJECT", + name: "Ub", + possibleTypes: null, + }, + { + kind: "UNION", + name: "union_38Out", + possibleTypes: [ + { + kind: "OBJECT", + name: "Uc", + }, + { + kind: "OBJECT", + name: "Ud", + }, + { + kind: "UNION", + name: "either_37Out", + }, + ], + }, + { + kind: "OBJECT", + name: "Uc", + possibleTypes: null, + }, + { + kind: "OBJECT", + name: "Ud", + possibleTypes: null, + }, + { + kind: "UNION", + name: "either_37Out", + possibleTypes: [ + { + kind: "OBJECT", + name: "Ue", + }, + { + kind: "OBJECT", + name: "Uf", + }, + ], + }, { kind: "OBJECT", - name: "RGB_struct", + name: "Ue", + possibleTypes: null, + }, + { + kind: "OBJECT", + name: "Uf", possibleTypes: null, }, { @@ -72,7 +259,112 @@ snapshot[`Union type 2`] = ` }, { kind: "INPUT_OBJECT", - name: "RGB_structInp", + name: "RGBArrayInp", + possibleTypes: null, + }, + { + kind: "INPUT_OBJECT", + name: "RGBStructInp", + possibleTypes: null, + }, + { + kind: "INPUT_OBJECT", + name: "HexColorInp", + possibleTypes: null, + }, + { + kind: "INPUT_OBJECT", + name: "NamedColorInp", + possibleTypes: null, + }, + { + kind: "SCALAR", + name: "NestedUnionsIn", + possibleTypes: null, + }, + { + kind: "INPUT_OBJECT", + name: "A1Inp", + possibleTypes: null, + }, + { + kind: "SCALAR", + name: "union_20In", + possibleTypes: null, + }, + { + kind: "INPUT_OBJECT", + name: "A2Inp", + possibleTypes: null, + }, + { + kind: "SCALAR", + name: "union_18In", + possibleTypes: null, + }, + { + kind: "INPUT_OBJECT", + name: "A3Inp", + possibleTypes: null, + }, + { + kind: "INPUT_OBJECT", + name: "A4Inp", + possibleTypes: null, + }, + { + kind: "INPUT_OBJECT", + name: "BInp", + possibleTypes: null, + }, + { + kind: "SCALAR", + name: "ScalarUnionIn", + possibleTypes: null, + }, + { + kind: "SCALAR", + name: "MultilevelUnionIn", + possibleTypes: null, + }, + { + kind: "INPUT_OBJECT", + name: "UaInp", + possibleTypes: null, + }, + { + kind: "INPUT_OBJECT", + name: "UbInp", + possibleTypes: null, + }, + { + kind: "SCALAR", + name: "union_38In", + possibleTypes: null, + }, + { + kind: "INPUT_OBJECT", + name: "UcInp", + possibleTypes: null, + }, + { + kind: "INPUT_OBJECT", + name: "UdInp", + possibleTypes: null, + }, + { + kind: "SCALAR", + name: "either_37In", + possibleTypes: null, + }, + { + kind: "INPUT_OBJECT", + name: "UeInp", + possibleTypes: null, + }, + { + kind: "INPUT_OBJECT", + name: "UfInp", possibleTypes: null, }, ], diff --git a/typegate/tests/type_nodes/either_node.py b/typegate/tests/type_nodes/either_node.py index 9ef688fdc7..869fdf10ec 100644 --- a/typegate/tests/type_nodes/either_node.py +++ b/typegate/tests/type_nodes/either_node.py @@ -22,9 +22,11 @@ # transaction models - success_transaction = t.struct({"user_id": t.string(), "date": t.date()}) + success_transaction = t.struct({"user_id": t.string(), "date": t.date()}).named( + "SuccessTransaction" + ) - failed_transaction = t.struct({"reason": t.string()}) + failed_transaction = t.struct({"reason": t.string()}).named("FailedTransaction") response = t.either([success_transaction, failed_transaction]).named("Response") diff --git a/typegate/tests/type_nodes/either_test.ts b/typegate/tests/type_nodes/either_test.ts index e92b635d17..5ed8d1b0c8 100644 --- a/typegate/tests/type_nodes/either_test.ts +++ b/typegate/tests/type_nodes/either_test.ts @@ -11,8 +11,13 @@ test( await gql` query { regist_user(user: { age: 11, name: "Bob", school: "The school" }) { - user_id - date + ... on SuccessTransaction { + user_id + date + } + ... on FailedTransaction { + reason + } } } ` @@ -29,8 +34,13 @@ test( await gql` query { regist_user(user: { age: 20, name: "Dave", college: "The college" }) { - user_id - date + ... on SuccessTransaction { + user_id + date + } + ... on FailedTransaction { + reason + } } } ` @@ -47,8 +57,13 @@ test( await gql` query { regist_user(user: { age: 32, name: "John", company: "The company" }) { - user_id - date + ... on SuccessTransaction { + user_id + date + } + ... on FailedTransaction { + reason + } } } ` @@ -69,7 +84,12 @@ test( regist_user( user: { age: 32, name: "John", company: "The company" } ) { - date + ... on SuccessTransaction { + date + } + ... on FailedTransaction { + reason + } } } ` @@ -96,8 +116,13 @@ test( company: "The company" } ) { - user_id - date + ... on SuccessTransaction { + user_id + date + } + ... on FailedTransaction { + reason + } } } ` @@ -118,8 +143,13 @@ test( school: "The school" } ) { - user_id - date + ... on SuccessTransaction { + user_id + date + } + ... on FailedTransaction { + reason + } } } ` @@ -127,6 +157,7 @@ test( .on(e); }, ); + await t.should("allow to introspect the either type", async () => { await gql` query IntrospectionQuery { diff --git a/typegate/tests/type_nodes/ts/union/color_converter.ts b/typegate/tests/type_nodes/ts/union/color_converter.ts index b1bfe376f4..5d6e998cfe 100644 --- a/typegate/tests/type_nodes/ts/union/color_converter.ts +++ b/typegate/tests/type_nodes/ts/union/color_converter.ts @@ -5,37 +5,29 @@ import { RGBColor, } from "https://deno.land/x/color_util@1.0.1/mod.ts"; -interface ConvertInput { - color: - | Array - | { - b: number; - r: number; - g: number; - } - | string - | "red" - | "green" - | "blue" - | "black" - | "white"; - to: "rgb_array" | "rgb_struct" | "hex" | "colorName"; -} +type RGBArray = { rgb: [number, number, number] }; -type ConvertOutput = - | Array - | { - g: number; - r: number; - b: number; - } - | string +type RGBStruct = { + r: number; + g: number; + b: number; +}; + +type ColorName = | "red" | "green" | "blue" | "black" | "white"; +type Color = RGBArray | RGBStruct | { hex: string } | { name: ColorName }; +interface ConvertInput { + color: Color; + to: "rgb_array" | "rgb_struct" | "hex" | "colorName"; +} + +type ConvertOutput = Color; + const colorNameToRGB = { red: [255, 0, 0], green: [0, 255, 0], @@ -44,37 +36,31 @@ const colorNameToRGB = { white: [255, 255, 255], } as Record; -type colorFormat = "rgb_array" | "rgb_struct" | "hex" | "colorName"; - -type RGB_ARRAY = [number, number, number]; -type RGB_STRUCT = { - r: number; - g: number; - b: number; -}; - -type Color = RGB_ARRAY | RGB_STRUCT | string; +type ColorFormat = "rgb_array" | "rgb_struct" | "hex" | "named_color"; -function getColorFormat(color: Color): colorFormat { - if (Array.isArray(color)) { +function getColorFormat(color: Color): ColorFormat { + if ("rgb" in color && Array.isArray(color.rgb)) { return "rgb_array"; - } else if (typeof color == "string" && color.startsWith("#")) { + } + if ("name" in color && typeof color.name === "string") { + return "named_color"; + } + if ( + "hex" in color && typeof color.hex === "string" && color.hex.startsWith("#") + ) { return "hex"; - } else if (typeof color == "object") { - return "rgb_struct"; - } else { - return "colorName"; } + return "rgb_struct"; } -function rgbToArray(rgb: RGBColor): Array { +function rgbToArray(rgb: RGBColor): RGBArray["rgb"] { return [rgb.red, rgb.green, rgb.blue]; } -function parseRGB(color: ConvertInput["color"]) { - if (Array.isArray(color)) { - return new RGBColor(...(color as [number, number, number])); - } else if (typeof color == "object") { +function parseRGB(color: Color) { + if ("rgb" in color) { + return new RGBColor(...color.rgb); + } else if ("r" in color) { return new RGBColor(color.r, color.g, color.b); } else { throw new Error(`cannot parse RGB from ${color}`); @@ -106,17 +92,17 @@ export function convert( if (to == "hex") { const colorHex = rgb.toHex(); - return colorHex.toString(); + return { hex: colorHex.toString() }; } throw new Error("RGB to color name not supported"); } case "hex": { - const hex = new HexColor(color as string); + const hex = new HexColor((color as { hex: string }).hex); if (to == "rgb_array" || to == "rgb_struct") { - return rgbToArray(hex.toRGB()); + return { rgb: rgbToArray(hex.toRGB()) }; } if (to == "hex") { @@ -126,17 +112,17 @@ export function convert( throw new Error("HEX to color name not supported"); } - case "colorName": { - const rgbValues = colorNameToRGB[color as string]; - const rgb = parseRGB(rgbValues); + case "named_color": { + const rgbValues = colorNameToRGB[(color as { name: string }).name]; + const rgb = parseRGB({ rgb: rgbValues }); if (to == "rgb_array" || to == "rgb_struct") { - return rgbToArray(rgb); + return { rgb: rgbToArray(rgb) }; } if (to == "hex") { const colorHex = rgb.toHex(); - return colorHex.toString(); + return { hex: colorHex.toString() }; } return color; diff --git a/typegate/tests/type_nodes/union_node.py b/typegate/tests/type_nodes/union_node.py index 30d90d9851..6795bcb636 100644 --- a/typegate/tests/type_nodes/union_node.py +++ b/typegate/tests/type_nodes/union_node.py @@ -1,36 +1,42 @@ from typegraph import policies from typegraph import t from typegraph import TypeGraph -from typegraph.runtimes.deno import ModuleMat +from typegraph.runtimes.deno import ModuleMat, PureFunMat with TypeGraph("union") as g: channel_of_8_bits = t.integer().min(0).max(255).named("8BitsChannel") - rgb_array = t.array(channel_of_8_bits).min(3).max(3).named("RGB_array") + rgb_array = t.struct({"rgb": t.array(channel_of_8_bits).min(3).max(3)}).named( + "RGBArray" + ) + rgb_struct = t.struct( { "r": channel_of_8_bits, "g": channel_of_8_bits, "b": channel_of_8_bits, } - ).named("RGB_struct") - - hex = t.string().pattern("^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$").named("HEX") - colorName = ( - t.string() - .enum( - [ - "red", - "green", - "blue", - "black", - "white", - ] - ) - .named("ColorName") - ) + ).named("RGBStruct") + + hex = t.struct( + {"hex": t.string().pattern("^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$")} + ).named("HexColor") + + named_color = t.struct( + { + "name": t.string().enum( + [ + "red", + "green", + "blue", + "black", + "white", + ] + ) + } + ).named("NamedColor") - color = t.union([rgb_array, rgb_struct, hex, colorName]).named("Color") + color = t.union([rgb_array, rgb_struct, hex, named_color]).named("Color") colorFormat = t.string().enum(["rgb_array", "rgb_struct", "hex", "colorName"]) @@ -42,8 +48,76 @@ colorMaterializer.imp("convert"), ) + nested_unions = t.union( + [ + t.struct( + { + "a": t.union( + [ + t.struct( + { + "a": t.union( + [ + t.struct({"s": t.string()}).named("A3"), + t.struct( + {"i": t.integer(), "j": t.integer()} + ).named("A4"), + ] + ) + } + ).named("A2"), + g("B"), + ], + ), + } + ).named("A1"), + t.struct( + { + "b": t.string(), + } + ).named("B"), + ] + ).named("NestedUnions") + + multilevel_union = t.union( + [ + t.struct({"a": t.string()}).named("Ua"), + t.struct({"b": t.string()}).named("Ub"), + t.union( + [ + t.struct({"c": t.string()}).named("Uc"), + t.struct({"d": t.string()}).named("Ud"), + t.either( + [ + t.struct({"e": t.string()}).named("Ue"), + t.struct({"f": t.string()}).named("Uf"), + ] + ), + ] + ), + ] + ).named("MultilevelUnion") + + scalar_union = t.union([t.boolean(), t.integer(), t.string()]).named("ScalarUnion") + public = policies.public() g.expose( - convert=convert.add_policy(public), + convert=convert, + nested=t.func( + t.struct({"inp": t.array(nested_unions)}), + t.array(nested_unions), + PureFunMat("({ inp }) => inp"), + ), + scalar=t.func( + t.struct({"inp": t.array(scalar_union)}), + t.array(scalar_union), + PureFunMat("({ inp }) => inp"), + ), + multilevel=t.func( + t.struct({"inp": t.array(multilevel_union)}), + t.array(multilevel_union), + PureFunMat("({ inp }) => inp"), + ), + default_policy=public, ) diff --git a/typegate/tests/type_nodes/union_node_attr.py b/typegate/tests/type_nodes/union_node_attr.py index 10228c4b50..8ab028dcc2 100644 --- a/typegate/tests/type_nodes/union_node_attr.py +++ b/typegate/tests/type_nodes/union_node_attr.py @@ -2,16 +2,14 @@ from typegraph.runtimes.deno import ModuleMat with TypeGraph("union_attr") as g: - rgb = t.struct({"R": t.float(), "G": t.float(), "B": t.float()}).named("rgb") - vec = t.struct({"x": t.float(), "y": t.float(), "z": t.float()}).named("vec") - pair_or_list = t.union( - [t.struct({"first": t.float(), "second": t.float()}), t.array(t.float())] - ) + rgb = t.struct({"R": t.float(), "G": t.float(), "B": t.float()}).named("Rgb") + vec = t.struct({"x": t.float(), "y": t.float(), "z": t.float()}).named("Vec") + pair = t.struct({"first": t.float(), "second": t.float()}) axis_pairs = t.struct( { - "xy": pair_or_list.named("xy"), - "xz": pair_or_list.named("xz"), - "yz": pair_or_list.named("yz"), + "xy": pair.named("xy"), + "xz": pair.named("xz"), + "yz": pair.named("yz"), } ).named("AxisPair") public = policies.public() diff --git a/typegate/tests/type_nodes/union_node_attr_test.ts b/typegate/tests/type_nodes/union_node_attr_test.ts index 8c9c682d2f..2557f0e009 100644 --- a/typegate/tests/type_nodes/union_node_attr_test.ts +++ b/typegate/tests/type_nodes/union_node_attr_test.ts @@ -11,9 +11,13 @@ test( await gql` query { normalize(x: 8, y: 0, z: 6, as: "color") { - R - G - B + ... on Rgb { R G B } + ... on Vec { x y z } + ... on AxisPair { + xy { first second } + xz { first second } + yz { first second } + } } } ` @@ -27,9 +31,13 @@ test( await gql` query { normalize(x: 8, y: 0, z: 6, as: "vec") { - x - y - z + ... on Rgb { R G B } + ... on Vec { x y z } + ... on AxisPair { + xy { first second } + xz { first second } + yz { first second } + } } } ` @@ -45,7 +53,13 @@ test( await gql` query { normalize(x: 8, y: 0, z: 6, as: "vec") { - x + ... on Rgb { R } + ... on Vec { x } + ... on AxisPair { + xy { first } + xz { first } + yz { first } + } } } ` @@ -60,12 +74,11 @@ test( await gql` query { normalize(x: 8, y: 0, z: 6, as: "pair") { - xz { - first - second - } - xy { - first + ... on Rgb { R G B } + ... on Vec { x y z } + ... on AxisPair { + xy { first } + xz { first second } } } } diff --git a/typegate/tests/type_nodes/union_node_quantifier.py b/typegate/tests/type_nodes/union_node_quantifier.py index 6df0e0083a..5ca3517a59 100644 --- a/typegate/tests/type_nodes/union_node_quantifier.py +++ b/typegate/tests/type_nodes/union_node_quantifier.py @@ -18,7 +18,7 @@ "os": t.enum(["Android", "iOS"]), "metadatas": t.array(metadata).optional(), } - ).named("Smartphone") + ).named("SmartPhone") basic_phone = t.struct( { diff --git a/typegate/tests/type_nodes/union_node_quantifier_test.ts b/typegate/tests/type_nodes/union_node_quantifier_test.ts index b29bf96859..58ffd93605 100644 --- a/typegate/tests/type_nodes/union_node_quantifier_test.ts +++ b/typegate/tests/type_nodes/union_node_quantifier_test.ts @@ -23,11 +23,21 @@ test( message type phone { - name - metadatas { - label - content - source + ... on BasicPhone { + name + metadatas { + label + content + source + } + } + ... on SmartPhone { + name + metadatas { + label + content + source + } } } } @@ -70,8 +80,8 @@ test( message type phone { - name - os + ... on SmartPhone { name os } + ... on BasicPhone { name os } } } } diff --git a/typegate/tests/type_nodes/union_test.ts b/typegate/tests/type_nodes/union_test.ts index 60e9081c56..ed52dd5646 100644 --- a/typegate/tests/type_nodes/union_test.ts +++ b/typegate/tests/type_nodes/union_test.ts @@ -1,5 +1,6 @@ // Copyright Metatype OÜ under the Elastic License 2.0 (ELv2). See LICENSE.md for usage. +import { JSONValue } from "../../src/utils.ts"; import { gql, test } from "../utils.ts"; test( @@ -12,11 +13,16 @@ test( async () => { await gql` query { - convert(color: "blue", to: "rgb_array") + convert(color: { name: "blue" }, to: "rgb_array") { + ... on RGBArray { rgb } + ... on RGBStruct { r g b } + ... on HexColor { hex } + ... on NamedColor { name } + } } ` .expectData({ - convert: [0, 0, 255], + convert: { rgb: [0, 0, 255] }, }) .on(e); }, @@ -27,11 +33,16 @@ test( async () => { await gql` query { - convert(color: "#ffffff", to: "rgb_array") + convert(color: { hex: "#ffffff" }, to: "rgb_array") { + ... on RGBArray { rgb } + ... on RGBStruct { r g b } + ... on HexColor { hex } + ... on NamedColor { name } + } } ` .expectData({ - convert: [255, 255, 255], + convert: { rgb: [255, 255, 255] }, }) .on(e); }, @@ -42,11 +53,16 @@ test( async () => { await gql` query { - convert(color: [220, 20, 60], to: "hex") + convert(color: { rgb: [220, 20, 60] }, to: "hex") { + ... on RGBArray { rgb } + ... on RGBStruct { r g b } + ... on HexColor { hex } + ... on NamedColor { name } + } } ` .expectData({ - convert: "#dc143c", + convert: { hex: "#dc143c" }, }) .on(e); }, @@ -58,9 +74,10 @@ test( await gql` query { convert(color: { r: 155, g: 38, b: 182 }, to: "rgb_struct") { - r - g - b + ... on RGBArray { rgb } + ... on RGBStruct { r g b } + ... on HexColor { hex } + ... on NamedColor { name } } } ` @@ -81,7 +98,10 @@ test( await gql` query { convert(color: { r: 155, g: 38, b: 182 }, to: "rgb_struct") { - r + ... on RGBArray { rgb } + ... on RGBStruct { r } + ... on HexColor { hex } + ... on NamedColor { name } } } ` @@ -99,7 +119,12 @@ test( async () => { await gql` query { - convert(color: 100, to: "rgb_array") + convert(color: 100, to: "rgb_array") { + ... on RGBArray { rgb } + ... on RGBStruct { r g b } + ... on HexColor { hex } + ... on NamedColor { name } + } } ` .expectErrorContains("Type mismatch: got 'IntValue'") @@ -112,7 +137,12 @@ test( async () => { await gql` query { - convert(color: "hello world", to: "rgb_array") + convert(color: "hello world", to: "rgb_array") { + ... on RGBArray { rgb } + ... on RGBStruct { r g b } + ... on HexColor { hex } + ... on NamedColor { name } + } } ` .matchErrorSnapshot(t) @@ -140,3 +170,100 @@ test( }, { introspection: true }, ); + +test("nested unions", async (t) => { + const e = await t.pythonFile("type_nodes/union_node.py"); + + await t.should("support nested unions", async () => { + const data: JSONValue = [ + { b: "Hello" }, + { a: { b: "Hello" } }, + { a: { a: { s: "World" } } }, + { a: { a: { i: 12, j: 15 } } }, + ]; + await gql` + query Q($inp: [NestedUnionsIn]) { + nested(inp: $inp) { + ... on A1 { + a { + ... on A2 { + a { + ... on A3 { s } + ... on A4 { i j } + } + } + ... on B { + b + } + } + } + ... on B { + b + } + } + } + ` + .withVars({ + inp: data, + }) + .expectData({ + nested: data, + }) + .on(e); + }); +}); + +test("multilevel unions", async (t) => { + const e = await t.pythonFile("type_nodes/union_node.py"); + + await t.should("success", async () => { + const data: JSONValue = [ + { a: "a" }, + { b: "b" }, + { c: "c" }, + { d: "d" }, + { e: "e" }, + { f: "f" }, + ]; + + await gql` + query Q($inp: [MultilevelUnionIn]!) { + multilevel(inp: $inp) { + ... on Ua { a } + ... on Ub { b } + ... on Uc { c } + ... on Ud { d } + ... on Ue { e } + ... on Uf { f } + } + } + ` + .withVars({ + inp: data, + }) + .expectData({ + multilevel: data, + }) + .on(e); + }); +}); + +test("scalar unions", async (t) => { + const e = await t.pythonFile("type_nodes/union_node.py"); + + await t.should("succeed", async () => { + const data: JSONValue = [1, "hello", 12, false]; + await gql` + query Q($inp: [MultilevelUnionIn]) { + scalar(inp: $inp) + } + ` + .withVars({ + inp: data, + }) + .expectData({ + scalar: data, + }) + .on(e); + }); +}); diff --git a/typegate/tests/typecheck/__snapshots__/typecheck_test.ts.snap b/typegate/tests/typecheck/__snapshots__/typecheck_test.ts.snap new file mode 100644 index 0000000000..c531df3929 --- /dev/null +++ b/typegate/tests/typecheck/__snapshots__/typecheck_test.ts.snap @@ -0,0 +1,272 @@ +export const snapshot = {}; + +snapshot[`typecheck 1`] = ` +"function validate_53_1(value, path, errors, context) { +" + + ' if (typeof value !== "object") { +' + + " errors.push([path, \`expected an object, got \${typeof value}\`]); +" + + " } else if (value == null) { +" + + ' errors.push([path, "exptected a non-null object, got null"]); +' + + " } else { +" + + " const keys = new Set(Object.keys(value)); +" + + ' keys.delete("posts"); +' + + ' validate_8_2(value["posts"], path + ".posts", errors, context); +' + + " if (keys.size > 0) { +" + + ' errors.push([path, \`unexpected fields: \${[...keys].join(", ")}\`]); +' + + " } +" + + " } +" + + "} +" + + "function validate_8_2(value, path, errors, context) { +" + + " validate_27_3(value, path, errors, context); +" + + "} +" + + "function validate_27_3(value, path, errors, context) { +" + + " if (!Array.isArray(value)) { +" + + " errors.push([path, \`expected an array, got \${typeof value}\`]); +" + + " } else if (value.length > 20) { +" + + " errors.push([path, \`expected maximum items: 20, got \${value.length}\`]); +" + + " } else { +" + + " for (let i = 0; i < value.length; ++i) { +" + + " const item = value[i]; +" + + " validate_28_4(value[i], path + \`[\${i}]\`, errors, context); +" + + " } +" + + " } +" + + "} +" + + "function validate_28_4(value, path, errors, context) { +" + + ' if (typeof value !== "object") { +' + + " errors.push([path, \`expected an object, got \${typeof value}\`]); +" + + " } else if (value == null) { +" + + ' errors.push([path, "exptected a non-null object, got null"]); +' + + " } else { +" + + " const keys = new Set(Object.keys(value)); +" + + ' keys.delete("id"); +' + + ' validate_29(value["id"], path + ".id", errors, context); +' + + ' keys.delete("title"); +' + + ' validate_30(value["title"], path + ".title", errors, context); +' + + ' keys.delete("author"); +' + + ' validate_2_5(value["author"], path + ".author", errors, context); +' + + " if (keys.size > 0) { +" + + ' errors.push([path, \`unexpected fields: \${[...keys].join(", ")}\`]); +' + + " } +" + + " } +" + + "} +" + + "function validate_29(value, path, errors, context) { +" + + ' if (typeof value !== "string") { +' + + " errors.push([path, \`expected a string, got \${typeof value}\`]); +" + + " } else { +" + + ' const formatValidator = context.formatValidators["uuid"]; +' + + " if (formatValidator == null) { +" + + \` errors.push([path, "unknown format 'uuid'"]); +\` + + " } else if (!formatValidator(value)) { +" + + " errors.push([path, +" + + \` "string does not statisfy the required format 'uuid'"]); +\` + + " } +" + + " } +" + + "} +" + + "function validate_30(value, path, errors, context) { +" + + ' if (typeof value !== "string") { +' + + " errors.push([path, \`expected a string, got \${typeof value}\`]); +" + + " } else if (value.length < 10) { +" + + " errors.push([path, \`expected minimum length: 10, got \${value.length}\`]); +" + + " } else if (value.length > 200) { +" + + " errors.push([path, \`expected maximum length: 200, got \${value.length}\`]); +" + + " } +" + + "} +" + + "function validate_2_5(value, path, errors, context) { +" + + ' if (typeof value !== "object") { +' + + " errors.push([path, \`expected an object, got \${typeof value}\`]); +" + + " } else if (value == null) { +" + + ' errors.push([path, "exptected a non-null object, got null"]); +' + + " } else { +" + + " const keys = new Set(Object.keys(value)); +" + + ' keys.delete("id"); +' + + ' validate_3(value["id"], path + ".id", errors, context); +' + + ' keys.delete("username"); +' + + ' validate_4(value["username"], path + ".username", errors, context); +' + + " if (keys.size > 0) { +" + + ' errors.push([path, \`unexpected fields: \${[...keys].join(", ")}\`]); +' + + " } +" + + " } +" + + "} +" + + "function validate_3(value, path, errors, context) { +" + + ' if (typeof value !== "string") { +' + + " errors.push([path, \`expected a string, got \${typeof value}\`]); +" + + " } else { +" + + ' const formatValidator = context.formatValidators["uuid"]; +' + + " if (formatValidator == null) { +" + + \` errors.push([path, "unknown format 'uuid'"]); +\` + + " } else if (!formatValidator(value)) { +" + + " errors.push([path, +" + + \` "string does not statisfy the required format 'uuid'"]); +\` + + " } +" + + " } +" + + "} +" + + "function validate_4(value, path, errors, context) { +" + + ' if (typeof value !== "string") { +' + + " errors.push([path, \`expected a string, got \${typeof value}\`]); +" + + " } else if (value.length < 4) { +" + + " errors.push([path, \`expected minimum length: 4, got \${value.length}\`]); +" + + " } else if (value.length > 63) { +" + + " errors.push([path, \`expected maximum length: 63, got \${value.length}\`]); +" + + " } else { +" + + ' if (!new RegExp("^[a-z]+\$").test(value)) { +' + + ' errors.push([path, "string does not match to the pattern /^[a-z]+\$/"]); +' + + " } +" + + " } +" + + "} +" + + "return validate_53_1; +" +`; + +snapshot[`typecheck 2`] = ` +"Validation errors: +" + + " - at .posts[0].author: expected an object, got undefined +" +`; + +snapshot[`typecheck 3`] = ` +"Validation errors: +" + + " - at .posts[0].author.username: expected a string, got undefined +" +`; + +snapshot[`typecheck 4`] = ` +"Validation errors: +" + + " - at .posts[1].author.username: expected a string, got undefined +" +`; + +snapshot[`typecheck 5`] = ` +"Validation errors: +" + + " - at .posts[0].author.id: string does not statisfy the required format 'uuid' +" + + " - at .posts[0].author.email: string does not statisfy the required format 'email' +" +`; + +snapshot[`typecheck 6`] = ` +"Validation errors: +" + + " - at .posts[0].author.email: string does not statisfy the required format 'email' +" +`; + +snapshot[`typecheck 7`] = ` +"Validation errors: +" + + " - at .posts[0].author.website: string does not statisfy the required format 'uri' +" +`; diff --git a/typegate/tests/typecheck/typecheck_test.ts b/typegate/tests/typecheck/typecheck_test.ts index ef4de5dc04..1fc266b842 100644 --- a/typegate/tests/typecheck/typecheck_test.ts +++ b/typegate/tests/typecheck/typecheck_test.ts @@ -1,11 +1,16 @@ // Copyright Metatype OÜ under the Elastic License 2.0 (ELv2). See LICENSE.md for usage. import { test } from "../utils.ts"; -import { assert, assertThrows } from "std/testing/asserts.ts"; -import { TypeCheck, ValidationSchemaBuilder } from "../../src/typecheck.ts"; +import { assertThrows } from "std/testing/asserts.ts"; import { findOperation } from "../../src/graphql.ts"; import { parse } from "graphql"; import { None } from "monads"; +import { + generateValidator, + ResultValidationCompiler, +} from "../../src/typecheck/result.ts"; +import * as native from "native"; +import { nativeResult } from "../../src/utils.ts"; test("typecheck", async (t) => { const e = await t.pythonFile("typecheck/typecheck.py"); @@ -14,38 +19,46 @@ test("typecheck", async (t) => { // for syntax highlighting const graphql = String.raw; - const typecheck = (query: string) => { + const getValidationCode = (query: string) => { const [operation, fragments] = findOperation(parse(query), None); if (operation.isNone()) { throw new Error("No operation found in the query"); } - const validationSchema = new ValidationSchemaBuilder( - tg.tg.types, + + return new ResultValidationCompiler(tg, fragments).generate( operation.unwrap(), - fragments, - ).build(); + ); + }; - return new TypeCheck(validationSchema); + const getValidator = (query: string) => { + const [operation, fragments] = findOperation(parse(query), None); + if (operation.isNone()) { + throw new Error("No operation found in the query"); + } + + return generateValidator(tg, operation.unwrap(), fragments); }; - await t.should("invalid queries", () => { + await t.should("fail for invalid queries", () => { assertThrows( () => - typecheck(graphql` - query Q { + getValidationCode(graphql` + query Query1 { postis { id } } `), Error, - "Q.postis is undefined", + "Unexpected property 'postis' at 'Query1'", ); + console.log("Q2"); + assertThrows( () => - typecheck(graphql` - query Q { + getValidationCode(graphql` + query Query2 { posts { id title @@ -54,33 +67,39 @@ test("typecheck", async (t) => { } `), Error, - "Q.posts.text is undefined", + "Unexpected property 'text' at 'Query2.posts'", ); }); - let query1: TypeCheck; - - await t.should("valid query", () => { - query1 = typecheck(graphql` - query GetPosts { - posts { + const queryGetPosts = graphql` + query GetPosts { + posts { + id + title + author { id - title - author { - id - username - } + username } } - `); - - assert(query1 instanceof TypeCheck); + } + `; + + await t.should("generate validation code for valid queries", () => { + const code = getValidationCode(queryGetPosts); + + const formattedCode = nativeResult(native.typescript_format_code({ + source: code, + })).formatted_code; + console.log("-- START code --"); + console.log(formattedCode); + console.log("-- END code --"); + t.assertSnapshot(formattedCode); }); const post1 = { id: crypto.randomUUID(), title: "Hello World of Metatype", - content: "Hello, World!", + // content: "Hello, World!", }; const user1 = { id: crypto.randomUUID() }; @@ -90,27 +109,20 @@ test("typecheck", async (t) => { const post3 = { ...post1, author: user2 }; await t.should("data type validation", () => { - assertThrows( - () => query1.validate({ posts: [post1] }), - Error, - "must have required property 'author' at /posts/0", - ); + const validate = getValidator(queryGetPosts); + t.assertThrowsSnapshot(() => validate({ posts: [post1] })); - assertThrows( - () => query1.validate({ posts: [post2] }), - Error, - "must have required property 'username' at /posts/0/author", + t.assertThrowsSnapshot( + () => validate({ posts: [post2] }), ); - query1.validate({ posts: [post3] }); + validate({ posts: [post3] }); - assertThrows( - () => query1.validate({ posts: [post3, post2] }), - Error, - "must have required property 'username' at /posts/1/author", + t.assertThrowsSnapshot( + () => validate({ posts: [post3, post2] }), ); - const query2 = typecheck(graphql` + const validate2 = getValidator(graphql` query GetPosts { posts { id @@ -131,9 +143,9 @@ test("typecheck", async (t) => { email: "my email", }; - assertThrows( + t.assertThrowsSnapshot( () => - query2.validate({ + validate2({ posts: [ { ...post1, @@ -141,25 +153,21 @@ test("typecheck", async (t) => { }, ], }), - Error, - 'must match format "uuid" at /posts/0/author/id', ); - assertThrows( + t.assertThrowsSnapshot( () => - query2.validate({ + validate2({ posts: [{ ...post1, author: { ...user, id: crypto.randomUUID() }, }], }), - Error, - 'must match format "email" at /posts/0/author/email', ); - assertThrows( + t.assertThrowsSnapshot( () => - query2.validate({ + validate2({ posts: [{ ...post1, author: { @@ -170,11 +178,9 @@ test("typecheck", async (t) => { }, }], }), - Error, - 'must match format "uri" at /posts/0/author/website', ); - query2.validate({ + validate2({ posts: [{ ...post1, author: { @@ -185,7 +191,7 @@ test("typecheck", async (t) => { }], }); - query2.validate({ + validate2({ posts: [{ ...post1, author: { diff --git a/typegate/tests/utils.ts b/typegate/tests/utils.ts index 2ce29303d9..aa0db67deb 100644 --- a/typegate/tests/utils.ts +++ b/typegate/tests/utils.ts @@ -54,6 +54,7 @@ export async function shell( cmd: string[], options: MetaOptions = {}, ): Promise { + console.log("shell", cmd); const { stdin = null } = options; const p = new Deno.Command(cmd[0], { cwd: testDir, diff --git a/typegate/tests/utils/metatest.ts b/typegate/tests/utils/metatest.ts index e6f4f8d935..1fe68e0260 100644 --- a/typegate/tests/utils/metatest.ts +++ b/typegate/tests/utils/metatest.ts @@ -171,4 +171,18 @@ export class MetaTest { assertSnapshot(...params: AssertSnapshotParams): Promise { return assertSnapshot(this.t, ...params); } + + assertThrowsSnapshot(fn: () => void) { + let err: Error | null = null; + try { + fn(); + } catch (e) { + err = e; + } + + if (err == null) { + throw new Error("Assertion failure: function did not throw"); + } + this.assertSnapshot(err.message); + } } diff --git a/typegate/tests/vars/vars_test.ts b/typegate/tests/vars/vars_test.ts index bba0aaefe8..ef3da4d2f8 100644 --- a/typegate/tests/vars/vars_test.ts +++ b/typegate/tests/vars/vars_test.ts @@ -64,7 +64,7 @@ test("GraphQL variable types", async (t) => { first: 2, second: "3", }) - .expectErrorContains("variable second") + .expectErrorContains("at .second: expected number, got string") .on(e); }); @@ -77,7 +77,7 @@ test("GraphQL variable types", async (t) => { .withVars({ numbers: [1, 4, "5"], }) - .expectErrorContains("variable numbers[2]") + .expectErrorContains("at .numbers[2]: expected number") .on(e); await gql` @@ -90,7 +90,7 @@ test("GraphQL variable types", async (t) => { level2: 2, }, }) - .expectErrorContains("variable val.level2") + .expectErrorContains("at .level1.level2: expected an array") .on(e); await gql` @@ -103,7 +103,7 @@ test("GraphQL variable types", async (t) => { level2: ["hello", ["world"]], }, }) - .expectErrorContains("variable val.level2[1]") + .expectErrorContains("at .level1.level2[1]: expected a string") .on(e); }); });