diff --git a/.changeset/little-donuts-kiss.md b/.changeset/little-donuts-kiss.md new file mode 100644 index 00000000000..f2057e50792 --- /dev/null +++ b/.changeset/little-donuts-kiss.md @@ -0,0 +1,9 @@ +--- +'hive-apollo-router-plugin': patch +'@graphql-hive/core': patch +'@graphql-hive/apollo': patch +'@graphql-hive/envelop': patch +'@graphql-hive/yoga': patch +--- + +Collect custom scalars from arguments and input object fields diff --git a/packages/libraries/core/src/client/collect-schema-coordinates.ts b/packages/libraries/core/src/client/collect-schema-coordinates.ts index 9657d3cfbb4..4f22e172367 100644 --- a/packages/libraries/core/src/client/collect-schema-coordinates.ts +++ b/packages/libraries/core/src/client/collect-schema-coordinates.ts @@ -71,7 +71,12 @@ export function collectSchemaCoordinates(args: { } function collectNode(node: ObjectFieldNode | ArgumentNode) { - const inputType = args.typeInfo.getInputType()!; + const inputType = args.typeInfo.getInputType(); + + if (!inputType) { + throw new Error('Expected an Input type, got nothing'); + } + const inputTypeName = resolveTypeName(inputType); if (node.value.kind === Kind.ENUM) { @@ -279,6 +284,12 @@ export function collectSchemaCoordinates(args: { const parentInputTypeName = resolveTypeName(parentInputType); + if (isScalarType(parentInputType)) { + collectInputType(parentInputTypeName); + // Prevent the visitor into going deeper into ObjectField + return null; + } + collectNode(node); collectInputType(parentInputTypeName, node.name.value); }, diff --git a/packages/libraries/core/tests/collect-schema-coordinates.spec.ts b/packages/libraries/core/tests/collect-schema-coordinates.spec.ts index abe0f33fba5..c7f25e2b0ed 100644 --- a/packages/libraries/core/tests/collect-schema-coordinates.spec.ts +++ b/packages/libraries/core/tests/collect-schema-coordinates.spec.ts @@ -159,4 +159,85 @@ describe('collectSchemaCoordinates', () => { }); expect(Array.from(result)).toEqual(['Query.node', 'Node.id', 'User.id']); }); + + test('custom scalar as argument', () => { + const schema = buildSchema(/* GraphQL */ ` + type Query { + random(json: JSON): String + } + scalar JSON + `); + const result = collectSchemaCoordinates({ + documentNode: parse(/* GraphQL */ ` + query { + random(json: { key: { value: "value" } }) + } + `), + schema, + processVariables: false, + variables: null, + typeInfo: new TypeInfo(schema), + }); + expect(Array.from(result)).toEqual(['Query.random', 'Query.random.json', 'JSON']); + }); + + test('custom scalar in input object field', () => { + const schema = buildSchema(/* GraphQL */ ` + type Query { + random(input: I): String + } + + input I { + json: JSON + } + + scalar JSON + `); + const result = collectSchemaCoordinates({ + documentNode: parse(/* GraphQL */ ` + query { + random(input: { json: { key: { value: "value" } } }) + } + `), + schema, + processVariables: false, + variables: null, + typeInfo: new TypeInfo(schema), + }); + expect(Array.from(result)).toEqual(['Query.random', 'Query.random.input', 'I.json', 'JSON']); + }); + + test('deeply nested inputs', () => { + const schema = buildSchema(/* GraphQL */ ` + type Query { + random(a: A): String + } + + input A { + b: B + } + + input B { + c: C + } + + input C { + d: String + } + `); + const result = collectSchemaCoordinates({ + documentNode: parse(/* GraphQL */ ` + query { + random(a: { b: { c: { d: "D" } } }) + } + `), + schema, + processVariables: false, + variables: null, + typeInfo: new TypeInfo(schema), + }); + expect(Array.from(result).sort()).toEqual( + ['Query.random', 'Query.random.a', 'A.b', 'B.c', 'C.d', 'String'].sort(), + ); + }); }); diff --git a/packages/libraries/core/tests/usage-collector.spec.ts b/packages/libraries/core/tests/usage-collector.spec.ts index 307cb3e2488..0a89b78fe3d 100644 --- a/packages/libraries/core/tests/usage-collector.spec.ts +++ b/packages/libraries/core/tests/usage-collector.spec.ts @@ -7,6 +7,7 @@ const schema = buildSchema(/* GraphQL */ ` projectsByType(type: ProjectType!): [Project!]! projectsByTypes(types: [ProjectType!]!): [Project!]! projects(filter: FilterInput, and: [FilterInput!]): [Project!]! + projectsByMetadata(metadata: JSON): [Project!]! } type Mutation { @@ -22,6 +23,7 @@ const schema = buildSchema(/* GraphQL */ ` type: ProjectType pagination: PaginationInput order: [ProjectOrderByInput!] + metadata: JSON } input PaginationInput { @@ -63,6 +65,8 @@ const schema = buildSchema(/* GraphQL */ ` STITCHING SINGLE } + + scalar JSON `); const op = parse(/* GraphQL */ ` @@ -446,6 +450,8 @@ test('collect entire input object type used as variable', async () => { ProjectOrderByInput.direction, OrderDirection.ASC, OrderDirection.DESC, + FilterInput.metadata, + JSON, ] `); }); @@ -486,6 +492,8 @@ test('collect entire input object type used as variable (list)', async () => { ProjectOrderByInput.direction, OrderDirection.ASC, OrderDirection.DESC, + FilterInput.metadata, + JSON, ] `); }); @@ -550,6 +558,174 @@ test('enum values as list', async () => { `); }); +test('custom scalar as argument (inlined)', async () => { + const collect = createCollector({ + schema, + max: 1, + }); + const info$ = await collect( + parse(/* GraphQL */ ` + query getProjects { + projectsByMetadata(metadata: { key: { value: "value" } }) { + name + } + } + `), + {}, + ); + const info = await info$.value; + + expect(info.fields).toMatchInlineSnapshot(` + [ + Query.projectsByMetadata, + Query.projectsByMetadata.metadata, + Project.name, + JSON, + ] + `); +}); + +test('custom scalar as argument (variable)', async () => { + const collect = createCollector({ + schema, + max: 1, + }); + const info$ = await collect( + parse(/* GraphQL */ ` + query getProjects($metadata: JSON) { + projectsByMetadata(metadata: $metadata) { + name + } + } + `), + {}, + ); + const info = await info$.value; + + expect(info.fields).toMatchInlineSnapshot(` + [ + Query.projectsByMetadata, + Query.projectsByMetadata.metadata, + Project.name, + JSON, + ] + `); +}); + +// TODO: same but with processVariables: true +test('custom scalar as an argument (variable with default value)', async () => { + const collect = createCollector({ + schema, + max: 1, + }); + const info$ = await collect( + parse(/* GraphQL */ ` + query getProjects($metadata: JSON = { key: { value: "value" } }) { + projectsByMetadata(metadata: $metadata) { + name + } + } + `), + {}, + ); + const info = await info$.value; + + expect(info.fields).toMatchInlineSnapshot(` + [ + Query.projectsByMetadata, + Query.projectsByMetadata.metadata, + Project.name, + JSON, + ] + `); +}); + +test('custom scalar in input object field (inlined)', async () => { + const collect = createCollector({ + schema, + max: 1, + }); + const info$ = await collect( + parse(/* GraphQL */ ` + query getProjects { + projects(filter: { metadata: { key: "value" } }) { + name + } + } + `), + {}, + ); + const info = await info$.value; + + expect(info.fields).toMatchInlineSnapshot(` + [ + Query.projects, + Query.projects.filter, + Project.name, + FilterInput.metadata, + JSON, + ] + `); +}); + +test('custom scalar in input object field (variable)', async () => { + const collect = createCollector({ + schema, + max: 1, + }); + const info$ = await collect( + parse(/* GraphQL */ ` + query getProjects($metadata: JSON) { + projects(filter: { metadata: $metadata }) { + name + } + } + `), + {}, + ); + const info = await info$.value; + + expect(info.fields).toMatchInlineSnapshot(` + [ + Query.projects, + Query.projects.filter, + Project.name, + JSON, + FilterInput.metadata, + ] + `); +}); + +test('custom scalar in input object field (variable)', async () => { + const collect = createCollector({ + schema, + max: 1, + }); + const info$ = await collect( + parse(/* GraphQL */ ` + query getProjects($metadata: JSON = { key: { value: "value" } }) { + projects(filter: { metadata: $metadata }) { + name + } + } + `), + {}, + ); + const info = await info$.value; + + expect(info.fields).toMatchInlineSnapshot(` + [ + Query.projects, + Query.projects.filter, + Project.name, + JSON, + FilterInput.metadata, + ] + `); +}); + +// + test('should get a cache hit when document is the same but variables are different (by default)', async () => { const collect = createCollector({ schema, @@ -743,3 +919,138 @@ test('(processVariables: true) collect used-only input type fields from an array ] `); }); + +test('(processVariables: true) custom scalar as argument (variable)', async () => { + const collect = createCollector({ + schema, + max: 1, + processVariables: true, + }); + const info$ = await collect( + parse(/* GraphQL */ ` + query getProjects($metadata: JSON) { + projectsByMetadata(metadata: $metadata) { + name + } + } + `), + { + metadata: { + key: { + value: 'value', + }, + }, + }, + ); + const info = await info$.value; + + expect(info.fields).toMatchInlineSnapshot(` + [ + Query.projectsByMetadata, + Query.projectsByMetadata.metadata, + Project.name, + JSON, + ] + `); +}); + +test('(processVariables: true) custom scalar as an argument (variable with default value)', async () => { + const collect = createCollector({ + schema, + max: 1, + }); + const info$ = await collect( + parse(/* GraphQL */ ` + query getProjects($metadata: JSON = { key: { value: "value" } }) { + projectsByMetadata(metadata: $metadata) { + name + } + } + `), + { + metadata: { + key: { + value: 'value', + }, + }, + }, + ); + const info = await info$.value; + + expect(info.fields).toMatchInlineSnapshot(` + [ + Query.projectsByMetadata, + Query.projectsByMetadata.metadata, + Project.name, + JSON, + ] + `); +}); + +test('(processVariables: true) custom scalar in input object field (variable)', async () => { + const collect = createCollector({ + schema, + max: 1, + processVariables: true, + }); + const info$ = await collect( + parse(/* GraphQL */ ` + query getProjects($metadata: JSON) { + projects(filter: { metadata: $metadata }) { + name + } + } + `), + { + metadata: { + key: { + value: 'value', + }, + }, + }, + ); + const info = await info$.value; + + expect(info.fields).toMatchInlineSnapshot(` + [ + Query.projects, + Query.projects.filter, + Project.name, + JSON, + FilterInput.metadata, + ] + `); +}); + +test('(processVariables: true) custom scalar in input object field (variable)', async () => { + const collect = createCollector({ + schema, + max: 1, + processVariables: true, + }); + const info$ = await collect( + parse(/* GraphQL */ ` + query getProjects($metadata: JSON = { key: { value: "value" } }) { + projects(filter: { metadata: $metadata }) { + name + } + } + `), + { + metadata: { + value: 'key', + }, + }, + ); + const info = await info$.value; + + expect(info.fields).toMatchInlineSnapshot(` + [ + Query.projects, + Query.projects.filter, + Project.name, + JSON, + FilterInput.metadata, + ] + `); +}); diff --git a/packages/libraries/router/src/graphql.rs b/packages/libraries/router/src/graphql.rs index 9ef413802c5..ac20932ae9d 100644 --- a/packages/libraries/router/src/graphql.rs +++ b/packages/libraries/router/src/graphql.rs @@ -189,6 +189,16 @@ impl<'a> OperationVisitor<'a, SchemaCoordinatesContext> for SchemaCoordinatesVis } let type_name = self.resolve_type_name(var.var_type.clone()); + let type_def = info.schema.type_by_name(&type_name); + + match type_def { + Some(TypeDefinition::Scalar(scalar_def)) => { + ctx.schema_coordinates + .insert(format!("{}", scalar_def.name.as_str(),)); + return (); + } + _ => {} + } if let Some(inner_types) = self.resolve_references(&info.schema, &type_name) { for inner_type in inner_types { @@ -232,23 +242,31 @@ impl<'a> OperationVisitor<'a, SchemaCoordinatesContext> for SchemaCoordinatesVis match info.current_input_type() { Some(input_type) => { - let input_type_name = input_type.name(); - match arg_value { - Value::Enum(value) => { - let value_str = value.to_string(); - ctx.schema_coordinates - .insert(format!("{input_type_name}.{value_str}").to_string()); + match input_type { + TypeDefinition::Scalar(scalar_def) => { + ctx.schema_coordinates.insert(scalar_def.name.clone()); } - Value::List(_) => { - // handled by enter_list_value - } - Value::Object(_) => { - // handled by enter_object_field - } - Value::Variable(_) => { - // handled by enter_variable_definition + _ => { + let input_type_name = input_type.name(); + match arg_value { + Value::Enum(value) => { + let value_str = value.to_string(); + ctx.schema_coordinates.insert( + format!("{input_type_name}.{value_str}").to_string(), + ); + } + Value::List(_) => { + // handled by enter_list_value + } + Value::Object(a) => { + // handled by enter_object_field + } + Value::Variable(_) => { + // handled by enter_variable_definition + } + _ => {} + } } - _ => {} } } None => {} @@ -256,6 +274,22 @@ impl<'a> OperationVisitor<'a, SchemaCoordinatesContext> for SchemaCoordinatesVis } } + fn enter_object_field( + &mut self, + info: &mut OperationVisitorContext<'a>, + ctx: &mut SchemaCoordinatesContext, + object_field: &(String, graphql_tools::static_graphql::query::Value), + ) { + if let Some(input_type) = info.current_input_type() { + match input_type { + TypeDefinition::Scalar(scalar_def) => { + ctx.schema_coordinates.insert(scalar_def.name.clone()); + } + _ => {} + } + } + } + fn enter_list_value( &mut self, info: &mut OperationVisitorContext<'a>, @@ -270,7 +304,7 @@ impl<'a> OperationVisitor<'a, SchemaCoordinatesContext> for SchemaCoordinatesVis for value in values { match value { Value::Object(_) => { - // object fields are handled by enter_object_field + // object fields are handled by enter_object_value } Value::List(_) => { // handled by enter_list_value @@ -324,7 +358,7 @@ impl<'a> OperationVisitor<'a, SchemaCoordinatesContext> for SchemaCoordinatesVis // handled by enter_list_value } Value::Object(_) => { - // handled by enter_object_field + // handled by enter_object_value } Value::Variable(_) => { // handled by enter_variable_definition @@ -699,6 +733,7 @@ mod tests { projectsByType(type: ProjectType!): [Project!]! projectsByTypes(types: [ProjectType!]!): [Project!]! projects(filter: FilterInput, and: [FilterInput!]): [Project!]! + projectsByMetadata(metadata: JSON): [Project!]! } type Mutation { @@ -714,6 +749,7 @@ mod tests { type: ProjectType pagination: PaginationInput order: [ProjectOrderByInput!] + metadata: JSON } input PaginationInput { @@ -755,6 +791,8 @@ mod tests { STITCHING SINGLE } + + scalar JSON "; #[test] @@ -843,6 +881,7 @@ mod tests { "PaginationInput.limit", "Int", "PaginationInput.offset", + "FilterInput.metadata", "FilterInput.order", "ProjectOrderByInput.field", "String", @@ -886,6 +925,7 @@ mod tests { "ProjectType.STITCHING", "ProjectType.SINGLE", "FilterInput.pagination", + "FilterInput.metadata", "PaginationInput.limit", "Int", "PaginationInput.offset", @@ -1361,4 +1401,207 @@ mod tests { assert_eq!(extra.len(), 0, "Extra: {:?}", extra); assert_eq!(missing.len(), 0, "Missing: {:?}", missing); } + + #[test] + fn custom_scalar_as_argument_inlined() { + let schema = parse_schema::(SCHEMA_SDL).unwrap(); + let document = parse_query::( + " + query getProjects { + projectsByMetadata(metadata: { key: { value: \"value\" } }) { + name + } + } + ", + ) + .unwrap(); + + let schema_coordinates = collect_schema_coordinates(&document, &schema).unwrap(); + + let expected = vec![ + "Query.projectsByMetadata", + "Query.projectsByMetadata.metadata", + "Project.name", + "JSON", + ] + .into_iter() + .map(|s| s.to_string()) + .collect::>(); + + let extra: Vec<&String> = schema_coordinates.difference(&expected).collect(); + let missing: Vec<&String> = expected.difference(&schema_coordinates).collect(); + + assert_eq!(extra.len(), 0, "Extra: {:?}", extra); + assert_eq!(missing.len(), 0, "Missing: {:?}", missing); + } + + #[test] + fn custom_scalar_as_argument_variable() { + let schema = parse_schema::(SCHEMA_SDL).unwrap(); + let document = parse_query::( + " + query getProjects($metadata: JSON) { + projectsByMetadata(metadata: $metadata) { + name + } + } + ", + ) + .unwrap(); + + let schema_coordinates = collect_schema_coordinates(&document, &schema).unwrap(); + + let expected = vec![ + "Query.projectsByMetadata", + "Query.projectsByMetadata.metadata", + "Project.name", + "JSON", + ] + .into_iter() + .map(|s| s.to_string()) + .collect::>(); + + let extra: Vec<&String> = schema_coordinates.difference(&expected).collect(); + let missing: Vec<&String> = expected.difference(&schema_coordinates).collect(); + + assert_eq!(extra.len(), 0, "Extra: {:?}", extra); + assert_eq!(missing.len(), 0, "Missing: {:?}", missing); + } + + #[test] + fn custom_scalar_as_argument_variable_with_default() { + let schema = parse_schema::(SCHEMA_SDL).unwrap(); + let document = parse_query::( + " + query getProjects($metadata: JSON = { key: { value: \"value\" } }) { + projectsByMetadata(metadata: $metadata) { + name + } + } + ", + ) + .unwrap(); + + let schema_coordinates = collect_schema_coordinates(&document, &schema).unwrap(); + + let expected = vec![ + "Query.projectsByMetadata", + "Query.projectsByMetadata.metadata", + "Project.name", + "JSON", + ] + .into_iter() + .map(|s| s.to_string()) + .collect::>(); + + let extra: Vec<&String> = schema_coordinates.difference(&expected).collect(); + let missing: Vec<&String> = expected.difference(&schema_coordinates).collect(); + + assert_eq!(extra.len(), 0, "Extra: {:?}", extra); + assert_eq!(missing.len(), 0, "Missing: {:?}", missing); + } + + // + + #[test] + fn custom_scalar_as_input_field_inlined() { + let schema = parse_schema::(SCHEMA_SDL).unwrap(); + let document = parse_query::( + " + query getProjects { + projects(filter: { metadata: { key: \"value\" } }) { + name + } + } + ", + ) + .unwrap(); + + let schema_coordinates = collect_schema_coordinates(&document, &schema).unwrap(); + + let expected = vec![ + "Query.projects", + "Query.projects.filter", + "FilterInput.metadata", + "Project.name", + "JSON", + ] + .into_iter() + .map(|s| s.to_string()) + .collect::>(); + + let extra: Vec<&String> = schema_coordinates.difference(&expected).collect(); + let missing: Vec<&String> = expected.difference(&schema_coordinates).collect(); + + assert_eq!(extra.len(), 0, "Extra: {:?}", extra); + assert_eq!(missing.len(), 0, "Missing: {:?}", missing); + } + + #[test] + fn custom_scalar_as_input_field_variable() { + let schema = parse_schema::(SCHEMA_SDL).unwrap(); + let document = parse_query::( + " + query getProjects($metadata: JSON) { + projects(filter: { metadata: $metadata }) { + name + } + } + ", + ) + .unwrap(); + + let schema_coordinates = collect_schema_coordinates(&document, &schema).unwrap(); + + let expected = vec![ + "Query.projects", + "Query.projects.filter", + "FilterInput.metadata", + "Project.name", + "JSON", + ] + .into_iter() + .map(|s| s.to_string()) + .collect::>(); + + let extra: Vec<&String> = schema_coordinates.difference(&expected).collect(); + let missing: Vec<&String> = expected.difference(&schema_coordinates).collect(); + + assert_eq!(extra.len(), 0, "Extra: {:?}", extra); + assert_eq!(missing.len(), 0, "Missing: {:?}", missing); + } + + #[test] + fn custom_scalar_as_input_field_variable_with_default() { + let schema = parse_schema::(SCHEMA_SDL).unwrap(); + let document = parse_query::( + " + query getProjects($metadata: JSON = { key: { value: \"value\" } }) { + projects(filter: { metadata: $metadata }) { + name + } + } + ", + ) + .unwrap(); + + let schema_coordinates = collect_schema_coordinates(&document, &schema).unwrap(); + + let expected = vec![ + "Query.projects", + "Query.projects.filter", + "FilterInput.metadata", + "Project.name", + "JSON", + ] + .into_iter() + .map(|s| s.to_string()) + .collect::>(); + + let extra: Vec<&String> = schema_coordinates.difference(&expected).collect(); + let missing: Vec<&String> = expected.difference(&schema_coordinates).collect(); + + assert_eq!(extra.len(), 0, "Extra: {:?}", extra); + assert_eq!(missing.len(), 0, "Missing: {:?}", missing); + } }