diff --git a/compiler/crates/graphql-syntax/src/lib.rs b/compiler/crates/graphql-syntax/src/lib.rs index 2896253d9504d..680fcdc9b0cae 100644 --- a/compiler/crates/graphql-syntax/src/lib.rs +++ b/compiler/crates/graphql-syntax/src/lib.rs @@ -103,6 +103,16 @@ pub fn parse_schema_document( parser.parse_schema_document() } +pub fn parse_field_definition( + source: &str, + source_location: SourceLocationKey, + offset: u32, +) -> DiagnosticsResult { + let features = ParserFeatures::default(); + let parser = Parser::with_offset(source, source_location, features, offset); + parser.parse_field_definition() +} + pub fn parse_field_definition_stub( source: &str, source_location: SourceLocationKey, diff --git a/compiler/crates/graphql-syntax/src/parser.rs b/compiler/crates/graphql-syntax/src/parser.rs index 5381210aceb7f..37de46d386cb8 100644 --- a/compiler/crates/graphql-syntax/src/parser.rs +++ b/compiler/crates/graphql-syntax/src/parser.rs @@ -112,6 +112,16 @@ impl<'a> Parser<'a> { /// Parses a string containing a field name with optional arguments pub fn parse_field_definition_stub(mut self) -> DiagnosticsResult { let stub = self.parse_field_definition_stub_impl(); + if self.errors.is_empty() { + Ok(stub.unwrap()) + } else { + Err(self.errors) + } + } + + /// Parses a string containing a field definition + pub fn parse_field_definition(mut self) -> DiagnosticsResult { + let stub = self.parse_field_definition_impl(); if self.errors.is_empty() { self.parse_eof()?; Ok(stub.unwrap()) @@ -875,7 +885,7 @@ impl<'a> Parser<'a> { self.parse_optional_delimited_nonempty_list( TokenKind::OpenBrace, TokenKind::CloseBrace, - Self::parse_field_definition, + Self::parse_field_definition_impl, ) } @@ -883,7 +893,7 @@ impl<'a> Parser<'a> { * FieldDefinition : * - Description? Name ArgumentsDefinition? : Type Directives? */ - fn parse_field_definition(&mut self) -> ParseResult { + fn parse_field_definition_impl(&mut self) -> ParseResult { let description = self.parse_optional_description(); let name = self.parse_identifier()?; let arguments = self.parse_argument_defs()?; diff --git a/compiler/crates/relay-docblock/src/errors.rs b/compiler/crates/relay-docblock/src/errors.rs index 123df68432d3a..e3a0076dbd186 100644 --- a/compiler/crates/relay-docblock/src/errors.rs +++ b/compiler/crates/relay-docblock/src/errors.rs @@ -75,6 +75,14 @@ pub enum ErrorMessages { type_name: StringKey, }, + #[error( + "The type specified in the fragment (`{fragment_type_condition}`) and the parent type (`{type_name}`) are different. Please make sure these are exactly the same." + )] + MismatchRootFragmentTypeConditionTerseSyntax { + fragment_type_condition: StringKey, + type_name: StringKey, + }, + #[error( "Unexpected plural server type in `@edgeTo` field. Currently Relay Resolvers only support plural `@edgeTo` if the type is defined via Client Schema Extensions." )] @@ -90,6 +98,21 @@ pub enum ErrorMessages { field_name: StringKey, interface_name: InterfaceName, }, + + #[error( + "Unexpected character `{found}`. Expected @RelayResolver field to either be a GraphQL typename, or a field definition of the form `ParentType.field_name: ReturnType`." + )] + UnexpectedNonDot { found: char }, + + #[error( + "Unexpected character `{found}`. Terse @RelayResolver syntax, where a field is defined in a single line using the `ParentType.field_name: ReturnType` shorthand, is not enabled in your project's config." + )] + UnexpectedTerseSyntax { found: char }, + + #[error( + "Unexpected docblock field `{field_name}`. This field is not allowed in combination with terse @RelayResolver syntax, where a field is defined in a single line using the `ParentType.field_name: ReturnType` shorthand." + )] + UnexpectedFieldInTerseSyntax { field_name: StringKey }, } #[derive(Clone, Debug, Error, Eq, PartialEq, Ord, PartialOrd, Hash)] diff --git a/compiler/crates/relay-docblock/src/ir.rs b/compiler/crates/relay-docblock/src/ir.rs index 4f2e129485207..04483fe7e7a83 100644 --- a/compiler/crates/relay-docblock/src/ir.rs +++ b/compiler/crates/relay-docblock/src/ir.rs @@ -84,6 +84,7 @@ lazy_static! { #[derive(Debug, PartialEq)] pub enum DocblockIr { RelayResolver(RelayResolverIr), + TerseRelayResolver(TerseRelayResolverIr), StrongObjectResolver(StrongObjectIr), WeakObjectType(WeakObjectIr), } @@ -103,6 +104,9 @@ impl DocblockIr { DocblockIr::RelayResolver(relay_resolver) => { relay_resolver.to_graphql_schema_ast(schema) } + DocblockIr::TerseRelayResolver(relay_resolver) => { + relay_resolver.to_graphql_schema_ast(schema) + } DocblockIr::StrongObjectResolver(strong_object) => { strong_object.to_graphql_schema_ast(schema) } @@ -240,11 +244,14 @@ trait ResolverIr { arguments.push(true_argument(LIVE_ARGUMENT_NAME.0, live_field.key_location)) } - if let Some(OutputType::Output(type_)) = &self.output_type() { - arguments.push(true_argument( - HAS_OUTPUT_TYPE_ARGUMENT_NAME.0, - type_.location, - )) + if let Some(output_type) = &self.output_type() { + match output_type { + OutputType::EdgeTo(_) => {} + OutputType::Output(type_) => arguments.push(true_argument( + HAS_OUTPUT_TYPE_ARGUMENT_NAME.0, + type_.location, + )), + } } if let Some(name) = self.named_import() { arguments.push(string_argument( @@ -262,6 +269,59 @@ trait ResolverIr { } } +#[derive(Debug, PartialEq)] +pub struct TerseRelayResolverIr { + pub field: FieldDefinition, + pub type_: WithLocation, + pub root_fragment: Option>, + pub deprecated: Option, + pub output_type: Option, + pub live: Option, + pub location: Location, + pub fragment_arguments: Option>, + pub named_import: Option, +} + +impl ResolverIr for TerseRelayResolverIr { + fn definitions(&self, _schema: &SDLSchema) -> DiagnosticsResult> { + Ok(vec![TypeSystemDefinition::ObjectTypeExtension( + ObjectTypeExtension { + name: as_identifier(self.type_), + interfaces: Vec::new(), + directives: self.directives(), + fields: Some(List::generated(vec![self.field.clone()])), + }, + )]) + } + + fn location(&self) -> Location { + self.location + } + + fn root_fragment(&self) -> Option { + self.root_fragment.map(|fragment| RootFragment { + fragment, + inject_fragment_data: None, + }) + } + + fn output_type(&self) -> Option<&OutputType> { + self.output_type.as_ref() + } + + fn deprecated(&self) -> Option { + self.deprecated + } + + fn live(&self) -> Option { + self.live + } + + fn named_import(&self) -> Option { + self.named_import + } +} + #[derive(Debug, PartialEq)] pub struct RelayResolverIr { pub field: FieldDefinitionStub, @@ -303,7 +363,7 @@ impl ResolverIr for RelayResolverIr { schema, &schema.object(object_id).interfaces, )?; - return Ok(self.object_definitions(value.map(ObjectName))); + return Ok(self.object_definitions(value.map(ObjectName), schema)); } Type::Interface(_) => { return Err(vec![Diagnostic::error_with_data( @@ -426,10 +486,10 @@ impl RelayResolverIr { for object_id in &schema.interface(interface_id).implementing_objects { if !seen_objects.contains(object_id) { seen_objects.insert(*object_id); - definitions.extend(self.object_definitions(WithLocation::new( - interface_name.location, - schema.object(*object_id).name.item, - ))) + definitions.extend(self.object_definitions( + WithLocation::new(interface_name.location, schema.object(*object_id).name.item), + schema, + )) } } @@ -497,7 +557,11 @@ impl RelayResolverIr { Ok(()) } - fn object_definitions(&self, on_type: WithLocation) -> Vec { + fn object_definitions( + &self, + on_type: WithLocation, + _schema: &SDLSchema, + ) -> Vec { vec![TypeSystemDefinition::ObjectTypeExtension( ObjectTypeExtension { name: obj_as_identifier(on_type), diff --git a/compiler/crates/relay-docblock/src/lib.rs b/compiler/crates/relay-docblock/src/lib.rs index 86bbb0ae1b235..46737df653dd5 100644 --- a/compiler/crates/relay-docblock/src/lib.rs +++ b/compiler/crates/relay-docblock/src/lib.rs @@ -16,12 +16,14 @@ use common::DirectiveName; use common::Location; use common::NamedItem; use common::SourceLocationKey; +use common::Span; use common::WithLocation; use docblock_syntax::DocblockAST; use docblock_syntax::DocblockField; use docblock_syntax::DocblockSection; use errors::ErrorMessagesWithData; use graphql_ir::FragmentDefinitionName; +use graphql_syntax::parse_field_definition; use graphql_syntax::parse_field_definition_stub; use graphql_syntax::parse_identifier; use graphql_syntax::parse_type; @@ -29,6 +31,8 @@ use graphql_syntax::ConstantValue; use graphql_syntax::ExecutableDefinition; use graphql_syntax::FieldDefinitionStub; use graphql_syntax::FragmentDefinition; +use graphql_syntax::InputValueDefinition; +use graphql_syntax::List; use graphql_syntax::TypeAnnotation; use intern::string_key::Intern; use intern::string_key::StringKey; @@ -41,6 +45,7 @@ use ir::OutputType; use ir::PopulatedIrField; pub use ir::RelayResolverIr; use ir::StrongObjectIr; +use ir::TerseRelayResolverIr; use ir::WeakObjectIr; use lazy_static::lazy_static; @@ -172,6 +177,7 @@ impl RelayResolverParser { key_location: relay_resolver.key_location, value: type_name, }, + definitions_in_file, ) } else { self.parse_relay_resolver(ast.location, definitions_in_file) @@ -179,32 +185,19 @@ impl RelayResolverParser { } } - fn parse_relay_resolver( + fn parse_fragment_definition( &mut self, - ast_location: Location, + root_fragment: Option, + source_location: SourceLocationKey, + field_arguments: &Option>, definitions_in_file: Option<&Vec>, - ) -> ParseResult { - let live = self.fields.get(&LIVE_FIELD).copied(); - let root_fragment = self.get_field_with_value(*ROOT_FRAGMENT_FIELD)?; + ) -> ParseResult<(Option>, Option>)> { let fragment_definition = root_fragment .map(|root_fragment| { self.assert_fragment_definition(root_fragment.value, definitions_in_file) }) .transpose()?; - let fragment_type_condition = fragment_definition.as_ref().map(|fragment_definition| { - WithLocation::from_span( - fragment_definition.location.source_location(), - fragment_definition.type_condition.span, - fragment_definition.type_condition.type_.value, - ) - }); - let on = self.assert_on(ast_location, &fragment_type_condition); - let field_string = self.assert_field_value_exists(*FIELD_NAME_FIELD, ast_location)?; - let field = self.parse_field_definition(field_string)?; - self.validate_field_arguments(&field, field_string.location.source_location()); - - let deprecated = self.fields.get(&DEPRECATED_FIELD).copied(); let fragment_arguments = fragment_definition .as_ref() .map(|fragment_definition| self.extract_fragment_arguments(fragment_definition)) @@ -213,14 +206,14 @@ impl RelayResolverParser { // Validate that the field arguments don't collide with the fragment arguments. if let (Some(field_arguments), Some(fragment_definition), Some(fragment_arguments)) = - (&field.arguments, &fragment_definition, &fragment_arguments) + (&field_arguments, &fragment_definition, &fragment_arguments) { for field_arg in &field_arguments.items { if let Some(fragment_arg) = fragment_arguments.named(field_arg.name.value) { self.errors.push( Diagnostic::error( ErrorMessages::ConflictingArguments, - field_string.location.with_span(field_arg.name.span), + Location::new(source_location, field_arg.name.span), ) .annotate( "conflicts with this fragment argument", @@ -232,6 +225,39 @@ impl RelayResolverParser { } } } + + let fragment_type_condition = fragment_definition.as_ref().map(|fragment_definition| { + WithLocation::from_span( + fragment_definition.location.source_location(), + fragment_definition.type_condition.span, + fragment_definition.type_condition.type_.value, + ) + }); + Ok((fragment_type_condition, fragment_arguments)) + } + + fn parse_relay_resolver( + &mut self, + ast_location: Location, + definitions_in_file: Option<&Vec>, + ) -> ParseResult { + let live = self.fields.get(&LIVE_FIELD).copied(); + + let field_string = self.assert_field_value_exists(*FIELD_NAME_FIELD, ast_location)?; + let field = self.parse_field_definition(field_string)?; + let root_fragment = self.get_field_with_value(*ROOT_FRAGMENT_FIELD)?; + let (fragment_type_condition, fragment_arguments) = self.parse_fragment_definition( + root_fragment, + field_string.location.source_location(), + &field.arguments, + definitions_in_file, + )?; + + let on = self.assert_on(ast_location, &fragment_type_condition); + self.validate_field_arguments(&field.arguments, field_string.location.source_location()); + + let deprecated = self.fields.get(&DEPRECATED_FIELD).copied(); + // For the initial version the name of the export have to match // the name of the resolver field. Adding JS parser capabilities will allow // us to derive the name of the export from the source. @@ -574,10 +600,10 @@ impl RelayResolverParser { fn validate_field_arguments( &mut self, - field: &FieldDefinitionStub, + arguments: &Option>, source_location: SourceLocationKey, ) { - if let Some(field_arguments) = &field.arguments { + if let Some(field_arguments) = &arguments { for argument in field_arguments.items.iter() { if let Some(default_value) = &argument.default_value { self.errors.push(Diagnostic::error( @@ -593,6 +619,7 @@ impl RelayResolverParser { &mut self, ast_location: Location, field_value: PopulatedIrField, + definitions_in_file: Option<&Vec>, ) -> ParseResult { let type_str = field_value.value; @@ -613,7 +640,14 @@ impl RelayResolverParser { value: WithLocation::new(type_str.location.with_span(type_name.span), type_name.value), }; - if self.fields.get(&WEAK_FIELD).is_some() { + if let Some(terse_resolver) = self.parse_terse_field_definition_tail( + ast_location, + type_str, + type_name, + definitions_in_file, + )? { + Ok(DocblockIr::TerseRelayResolver(terse_resolver)) + } else if self.fields.get(&WEAK_FIELD).is_some() { self.parse_weak_type(ast_location, type_) .map(DocblockIr::WeakObjectType) } else { @@ -622,6 +656,123 @@ impl RelayResolverParser { } } + // If present, parse the `.field_name(argument: String): ReturnType` + // following a `TypeName`. + fn parse_terse_field_definition_tail( + &mut self, + ast_location: Location, + type_str: WithLocation, + type_name: graphql_syntax::Identifier, + definitions_in_file: Option<&Vec>, + ) -> ParseResult> { + let (start, end) = type_name.span.as_usize(); + let offset = end - start; + let remaining_source = &type_str.item.lookup()[offset..]; + let span_start = type_str.location.span().start + offset as u32; + + match remaining_source.chars().next() { + Some(maybe_dot) => { + if !self.options.relay_resolver_enable_terse_syntax { + self.errors.push(Diagnostic::error( + ErrorMessages::UnexpectedTerseSyntax { found: maybe_dot }, + type_str.location, + )); + return Err(()); + } + if maybe_dot != '.' { + self.errors.push(Diagnostic::error( + ErrorMessages::UnexpectedNonDot { found: maybe_dot }, + type_str + .location + .with_span(Span::new(span_start, span_start + 1)), + )); + return Err(()); + } + } + None => return Ok(None), + }; + + let field = match parse_field_definition( + &remaining_source[1..], + type_str.location.source_location(), + span_start + 1, + ) { + Ok(field) => field, + Err(diagnostics) => { + self.errors.extend(diagnostics); + return Err(()); + } + }; + + self.validate_field_arguments(&field.arguments, ast_location.source_location()); + let root_fragment = self.get_field_with_value(*ROOT_FRAGMENT_FIELD)?; + + let (maybe_fragment_type_condition, fragment_arguments) = self.parse_fragment_definition( + root_fragment, + type_str.location.source_location(), + &field.arguments, + definitions_in_file, + )?; + + if let Some(fragment_type_condition) = maybe_fragment_type_condition { + if fragment_type_condition.item != type_name.value { + self.errors.push( + Diagnostic::error( + ErrorMessages::MismatchRootFragmentTypeConditionTerseSyntax { + fragment_type_condition: fragment_type_condition.item, + type_name: type_name.value, + }, + type_str.location.with_span(type_name.span), + ) + .annotate( + "with fragment type condition", + fragment_type_condition.location, + ), + ); + } + } + + let live = self.fields.get(&LIVE_FIELD).copied(); + let deprecated = self.fields.get(&DEPRECATED_FIELD).copied(); + + let location = type_str.location; + + // TODO: Provide an output type (using a new variant) to signal that + // @outputType should be inferred from the type definition. + let output_type = None; + + // These fields are subsumed by the terse syntax, and as such cannot be used with terse syntax. + for forbidden_field_name in &[ + *FIELD_NAME_FIELD, + *ON_TYPE_FIELD, + *ON_INTERFACE_FIELD, + *EDGE_TO_FIELD, + *OUTPUT_TYPE_FIELD, + *WEAK_FIELD, + ] { + if let Some(field) = self.fields.get(forbidden_field_name) { + self.errors.push(Diagnostic::error( + ErrorMessages::UnexpectedFieldInTerseSyntax { + field_name: *forbidden_field_name, + }, + field.key_location, + )); + } + } + Ok(Some(TerseRelayResolverIr { + field, + type_: WithLocation::new(type_str.location.with_span(type_name.span), type_name.value), + root_fragment: root_fragment + .map(|root_fragment| root_fragment.value.map(FragmentDefinitionName)), + location, + deprecated, + output_type, + live, + fragment_arguments, + named_import: None, + })) + } + fn parse_strong_object( &self, ast_location: Location, diff --git a/compiler/crates/relay-docblock/tests/parse/fixtures/relay-resolver-missing-multiple-fields.invalid.expected b/compiler/crates/relay-docblock/tests/parse/fixtures/relay-resolver-missing-multiple-fields.invalid.expected index bfeda514c3fba..a3ee2c4617245 100644 --- a/compiler/crates/relay-docblock/tests/parse/fixtures/relay-resolver-missing-multiple-fields.invalid.expected +++ b/compiler/crates/relay-docblock/tests/parse/fixtures/relay-resolver-missing-multiple-fields.invalid.expected @@ -18,16 +18,6 @@ graphql` } ` ==================================== ERROR ==================================== -✖︎ Expected either `onType` or `onInterface` to be defined in a @RelayResolver docblock. - - /path/to/test/fixture/relay-resolver-missing-multiple-fields.invalid.js:10:3 - 10 │ * - │ ^ - 11 │ * @RelayResolver - │ ^^^^^^^^^^^^^^^^^ - 12 │ - - ✖︎ Missing docblock field "@fieldName" /path/to/test/fixture/relay-resolver-missing-multiple-fields.invalid.js:10:3 diff --git a/compiler/crates/relay-docblock/tests/parse/fixtures/terse-relay-resolver-forbidden-fields.invalid.expected b/compiler/crates/relay-docblock/tests/parse/fixtures/terse-relay-resolver-forbidden-fields.invalid.expected new file mode 100644 index 0000000000000..f29892105bd5d --- /dev/null +++ b/compiler/crates/relay-docblock/tests/parse/fixtures/terse-relay-resolver-forbidden-fields.invalid.expected @@ -0,0 +1,83 @@ +==================================== INPUT ==================================== +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +// expected-to-throw + +/** + * @RelayResolver User.favorite_page: Page + * @rootFragment myRootFragment + * @onType User + * @edgeTo User + * @onInterface User + * @outputType User + * @fieldName my_field + * @weak + * + * The user's favorite page! They probably clicked something in the UI + * to tell us that it was their favorite page and then we put that in a + * database or something. Then we got that info out again and put it out + * again. Anyway, I'm rambling now. Its a page that the user likes. A lot. + */ + graphql` + fragment myRootFragment on User { + name + } +` +==================================== ERROR ==================================== +✖︎ Unexpected docblock field `edgeTo`. This field is not allowed in combination with terse @RelayResolver syntax, where a field is defined in a single line using the `ParentType.field_name: ReturnType` shorthand. + + /path/to/test/fixture/terse-relay-resolver-forbidden-fields.invalid.js:14:5 + 13 │ * @onType User + 14 │ * @edgeTo User + │ ^^^^^^ + 15 │ * @onInterface User + + +✖︎ Unexpected docblock field `fieldName`. This field is not allowed in combination with terse @RelayResolver syntax, where a field is defined in a single line using the `ParentType.field_name: ReturnType` shorthand. + + /path/to/test/fixture/terse-relay-resolver-forbidden-fields.invalid.js:17:5 + 16 │ * @outputType User + 17 │ * @fieldName my_field + │ ^^^^^^^^^ + 18 │ * @weak + + +✖︎ Unexpected docblock field `onInterface`. This field is not allowed in combination with terse @RelayResolver syntax, where a field is defined in a single line using the `ParentType.field_name: ReturnType` shorthand. + + /path/to/test/fixture/terse-relay-resolver-forbidden-fields.invalid.js:15:5 + 14 │ * @edgeTo User + 15 │ * @onInterface User + │ ^^^^^^^^^^^ + 16 │ * @outputType User + + +✖︎ Unexpected docblock field `onType`. This field is not allowed in combination with terse @RelayResolver syntax, where a field is defined in a single line using the `ParentType.field_name: ReturnType` shorthand. + + /path/to/test/fixture/terse-relay-resolver-forbidden-fields.invalid.js:13:5 + 12 │ * @rootFragment myRootFragment + 13 │ * @onType User + │ ^^^^^^ + 14 │ * @edgeTo User + + +✖︎ Unexpected docblock field `outputType`. This field is not allowed in combination with terse @RelayResolver syntax, where a field is defined in a single line using the `ParentType.field_name: ReturnType` shorthand. + + /path/to/test/fixture/terse-relay-resolver-forbidden-fields.invalid.js:16:5 + 15 │ * @onInterface User + 16 │ * @outputType User + │ ^^^^^^^^^^ + 17 │ * @fieldName my_field + + +✖︎ Unexpected docblock field `weak`. This field is not allowed in combination with terse @RelayResolver syntax, where a field is defined in a single line using the `ParentType.field_name: ReturnType` shorthand. + + /path/to/test/fixture/terse-relay-resolver-forbidden-fields.invalid.js:18:5 + 17 │ * @fieldName my_field + 18 │ * @weak + │ ^^^^ + 19 │ * diff --git a/compiler/crates/relay-docblock/tests/parse/fixtures/terse-relay-resolver-forbidden-fields.invalid.js b/compiler/crates/relay-docblock/tests/parse/fixtures/terse-relay-resolver-forbidden-fields.invalid.js new file mode 100644 index 0000000000000..e4e91469f3cd9 --- /dev/null +++ b/compiler/crates/relay-docblock/tests/parse/fixtures/terse-relay-resolver-forbidden-fields.invalid.js @@ -0,0 +1,29 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +// expected-to-throw + +/** + * @RelayResolver User.favorite_page: Page + * @rootFragment myRootFragment + * @onType User + * @edgeTo User + * @onInterface User + * @outputType User + * @fieldName my_field + * @weak + * + * The user's favorite page! They probably clicked something in the UI + * to tell us that it was their favorite page and then we put that in a + * database or something. Then we got that info out again and put it out + * again. Anyway, I'm rambling now. Its a page that the user likes. A lot. + */ + graphql` + fragment myRootFragment on User { + name + } +` diff --git a/compiler/crates/relay-docblock/tests/parse/fixtures/terse-relay-resolver-fragment-type-does-not-match-parent.invalid.expected b/compiler/crates/relay-docblock/tests/parse/fixtures/terse-relay-resolver-fragment-type-does-not-match-parent.invalid.expected new file mode 100644 index 0000000000000..969b933b99e16 --- /dev/null +++ b/compiler/crates/relay-docblock/tests/parse/fixtures/terse-relay-resolver-fragment-type-does-not-match-parent.invalid.expected @@ -0,0 +1,40 @@ +==================================== INPUT ==================================== +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +// expected-to-throw + +/** + * @RelayResolver User.favorite_page: Page + * @rootFragment myRootFragment + * + * The user's favorite page! They probably clicked something in the UI + * to tell us that it was their favorite page and then we put that in a + * database or something. Then we got that info out again and put it out + * again. Anyway, I'm rambling now. Its a page that the user likes. A lot. + */ + graphql` + fragment myRootFragment on Url { + name + } +` +==================================== ERROR ==================================== +✖︎ The type specified in the fragment (`Url`) and the parent type (`User`) are different. Please make sure these are exactly the same. + + /path/to/test/fixture/terse-relay-resolver-fragment-type-does-not-match-parent.invalid.js:11:19 + 10 │ * + 11 │ * @RelayResolver User.favorite_page: Page + │ ^^^^ + 12 │ * @rootFragment myRootFragment + + ℹ︎ with fragment type condition + + /path/to/test/fixture/terse-relay-resolver-fragment-type-does-not-match-parent.invalid.js:20:26 + 19 │ + 20 │ fragment myRootFragment on Url { + │ ^^^^^^ + 21 │ name diff --git a/compiler/crates/relay-docblock/tests/parse/fixtures/terse-relay-resolver-fragment-type-does-not-match-parent.invalid.js b/compiler/crates/relay-docblock/tests/parse/fixtures/terse-relay-resolver-fragment-type-does-not-match-parent.invalid.js new file mode 100644 index 0000000000000..87c1ea09dbac5 --- /dev/null +++ b/compiler/crates/relay-docblock/tests/parse/fixtures/terse-relay-resolver-fragment-type-does-not-match-parent.invalid.js @@ -0,0 +1,23 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +// expected-to-throw + +/** + * @RelayResolver User.favorite_page: Page + * @rootFragment myRootFragment + * + * The user's favorite page! They probably clicked something in the UI + * to tell us that it was their favorite page and then we put that in a + * database or something. Then we got that info out again and put it out + * again. Anyway, I'm rambling now. Its a page that the user likes. A lot. + */ + graphql` + fragment myRootFragment on Url { + name + } +` diff --git a/compiler/crates/relay-docblock/tests/parse/fixtures/terse-relay-resolver-no-dot.invalid.expected b/compiler/crates/relay-docblock/tests/parse/fixtures/terse-relay-resolver-no-dot.invalid.expected new file mode 100644 index 0000000000000..32bf6c0fc79cd --- /dev/null +++ b/compiler/crates/relay-docblock/tests/parse/fixtures/terse-relay-resolver-no-dot.invalid.expected @@ -0,0 +1,32 @@ +==================================== INPUT ==================================== +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +// expected-to-throw + +/** + * @RelayResolver User#favorite_page: Page + * @rootFragment myRootFragment + * + * The user's favorite page! They probably clicked something in the UI + * to tell us that it was their favorite page and then we put that in a + * database or something. Then we got that info out again and put it out + * again. Anyway, I'm rambling now. Its a page that the user likes. A lot. + */ + graphql` + fragment myRootFragment on User { + name + } +` +==================================== ERROR ==================================== +✖︎ Unexpected character `#`. Expected @RelayResolver field to either be a GraphQL typename, or a field definition of the form `ParentType.field_name: ReturnType`. + + /path/to/test/fixture/terse-relay-resolver-no-dot.invalid.js:11:23 + 10 │ * + 11 │ * @RelayResolver User#favorite_page: Page + │ ^ + 12 │ * @rootFragment myRootFragment diff --git a/compiler/crates/relay-docblock/tests/parse/fixtures/terse-relay-resolver-no-dot.invalid.js b/compiler/crates/relay-docblock/tests/parse/fixtures/terse-relay-resolver-no-dot.invalid.js new file mode 100644 index 0000000000000..d7a7387571e8c --- /dev/null +++ b/compiler/crates/relay-docblock/tests/parse/fixtures/terse-relay-resolver-no-dot.invalid.js @@ -0,0 +1,23 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +// expected-to-throw + +/** + * @RelayResolver User#favorite_page: Page + * @rootFragment myRootFragment + * + * The user's favorite page! They probably clicked something in the UI + * to tell us that it was their favorite page and then we put that in a + * database or something. Then we got that info out again and put it out + * again. Anyway, I'm rambling now. Its a page that the user likes. A lot. + */ + graphql` + fragment myRootFragment on User { + name + } +` diff --git a/compiler/crates/relay-docblock/tests/parse/fixtures/terse-relay-resolver-not-enabled.invalid.expected b/compiler/crates/relay-docblock/tests/parse/fixtures/terse-relay-resolver-not-enabled.invalid.expected new file mode 100644 index 0000000000000..d14c0ad5fae3e --- /dev/null +++ b/compiler/crates/relay-docblock/tests/parse/fixtures/terse-relay-resolver-not-enabled.invalid.expected @@ -0,0 +1,33 @@ +==================================== INPUT ==================================== +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +// relay:disable_relay_resolver_terse_syntax +// expected-to-throw + +/** + * @RelayResolver User.favorite_page: Page + * @rootFragment myRootFragment + * + * The user's favorite page! They probably clicked something in the UI + * to tell us that it was their favorite page and then we put that in a + * database or something. Then we got that info out again and put it out + * again. Anyway, I'm rambling now. Its a page that the user likes. A lot. + */ + graphql` + fragment myRootFragment on User { + name + } +` +==================================== ERROR ==================================== +✖︎ Unexpected character `.`. Terse @RelayResolver syntax, where a field is defined in a single line using the `ParentType.field_name: ReturnType` shorthand, is not enabled in your project's config. + + /path/to/test/fixture/terse-relay-resolver-not-enabled.invalid.js:12:19 + 11 │ * + 12 │ * @RelayResolver User.favorite_page: Page + │ ^^^^^^^^^^^^^^^^^^^^^^^^ + 13 │ * @rootFragment myRootFragment diff --git a/compiler/crates/relay-docblock/tests/parse/fixtures/terse-relay-resolver-not-enabled.invalid.js b/compiler/crates/relay-docblock/tests/parse/fixtures/terse-relay-resolver-not-enabled.invalid.js new file mode 100644 index 0000000000000..62173f063c2de --- /dev/null +++ b/compiler/crates/relay-docblock/tests/parse/fixtures/terse-relay-resolver-not-enabled.invalid.js @@ -0,0 +1,24 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +// relay:disable_relay_resolver_terse_syntax +// expected-to-throw + +/** + * @RelayResolver User.favorite_page: Page + * @rootFragment myRootFragment + * + * The user's favorite page! They probably clicked something in the UI + * to tell us that it was their favorite page and then we put that in a + * database or something. Then we got that info out again and put it out + * again. Anyway, I'm rambling now. Its a page that the user likes. A lot. + */ + graphql` + fragment myRootFragment on User { + name + } +` diff --git a/compiler/crates/relay-docblock/tests/parse/fixtures/terse-relay-resolver.expected b/compiler/crates/relay-docblock/tests/parse/fixtures/terse-relay-resolver.expected new file mode 100644 index 0000000000000..3cd2f650618cd --- /dev/null +++ b/compiler/crates/relay-docblock/tests/parse/fixtures/terse-relay-resolver.expected @@ -0,0 +1,70 @@ +==================================== INPUT ==================================== +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * @RelayResolver User.favorite_page: Page + * @rootFragment myRootFragment + * + * The user's favorite page! They probably clicked something in the UI + * to tell us that it was their favorite page and then we put that in a + * database or something. Then we got that info out again and put it out + * again. Anyway, I'm rambling now. Its a page that the user likes. A lot. + */ + graphql` + fragment myRootFragment on User { + name + } +` +==================================== OUTPUT =================================== +TerseRelayResolver( + TerseRelayResolverIr { + field: FieldDefinition { + name: Identifier { + span: 25:38, + token: Token { + span: 25:38, + kind: Identifier, + }, + value: "favorite_page", + }, + type_: Named( + NamedTypeAnnotation { + name: Identifier { + span: 40:44, + token: Token { + span: 40:44, + kind: Identifier, + }, + value: "Page", + }, + }, + ), + arguments: None, + directives: [], + description: None, + }, + type_: WithLocation { + location: /path/to/test/fixture/terse-relay-resolver.js:20:24, + item: "User", + }, + root_fragment: Some( + WithLocation { + location: /path/to/test/fixture/terse-relay-resolver.js:62:76, + item: FragmentDefinitionName( + "myRootFragment", + ), + }, + ), + deprecated: None, + output_type: None, + live: None, + location: /path/to/test/fixture/terse-relay-resolver.js:20:44, + fragment_arguments: None, + named_import: None, + }, +) diff --git a/compiler/crates/relay-docblock/tests/parse/fixtures/terse-relay-resolver.js b/compiler/crates/relay-docblock/tests/parse/fixtures/terse-relay-resolver.js new file mode 100644 index 0000000000000..b4f0ac3598fab --- /dev/null +++ b/compiler/crates/relay-docblock/tests/parse/fixtures/terse-relay-resolver.js @@ -0,0 +1,21 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * @RelayResolver User.favorite_page: Page + * @rootFragment myRootFragment + * + * The user's favorite page! They probably clicked something in the UI + * to tell us that it was their favorite page and then we put that in a + * database or something. Then we got that info out again and put it out + * again. Anyway, I'm rambling now. Its a page that the user likes. A lot. + */ + graphql` + fragment myRootFragment on User { + name + } +` diff --git a/compiler/crates/relay-docblock/tests/parse_test.rs b/compiler/crates/relay-docblock/tests/parse_test.rs index c04544814e36c..82174bf0c8051 100644 --- a/compiler/crates/relay-docblock/tests/parse_test.rs +++ b/compiler/crates/relay-docblock/tests/parse_test.rs @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<<0739dad984f51252f80b4611b0eaade7>> + * @generated SignedSource<<72de4990b5083582ce28bb5e7f3503d6>> */ mod parse; @@ -207,3 +207,38 @@ fn relay_resolver_with_output_type() { let expected = include_str!("parse/fixtures/relay-resolver-with-output-type.expected"); test_fixture(transform_fixture, "relay-resolver-with-output-type.js", "parse/fixtures/relay-resolver-with-output-type.expected", input, expected); } + +#[test] +fn terse_relay_resolver() { + let input = include_str!("parse/fixtures/terse-relay-resolver.js"); + let expected = include_str!("parse/fixtures/terse-relay-resolver.expected"); + test_fixture(transform_fixture, "terse-relay-resolver.js", "parse/fixtures/terse-relay-resolver.expected", input, expected); +} + +#[test] +fn terse_relay_resolver_forbidden_fields_invalid() { + let input = include_str!("parse/fixtures/terse-relay-resolver-forbidden-fields.invalid.js"); + let expected = include_str!("parse/fixtures/terse-relay-resolver-forbidden-fields.invalid.expected"); + test_fixture(transform_fixture, "terse-relay-resolver-forbidden-fields.invalid.js", "parse/fixtures/terse-relay-resolver-forbidden-fields.invalid.expected", input, expected); +} + +#[test] +fn terse_relay_resolver_fragment_type_does_not_match_parent_invalid() { + let input = include_str!("parse/fixtures/terse-relay-resolver-fragment-type-does-not-match-parent.invalid.js"); + let expected = include_str!("parse/fixtures/terse-relay-resolver-fragment-type-does-not-match-parent.invalid.expected"); + test_fixture(transform_fixture, "terse-relay-resolver-fragment-type-does-not-match-parent.invalid.js", "parse/fixtures/terse-relay-resolver-fragment-type-does-not-match-parent.invalid.expected", input, expected); +} + +#[test] +fn terse_relay_resolver_no_dot_invalid() { + let input = include_str!("parse/fixtures/terse-relay-resolver-no-dot.invalid.js"); + let expected = include_str!("parse/fixtures/terse-relay-resolver-no-dot.invalid.expected"); + test_fixture(transform_fixture, "terse-relay-resolver-no-dot.invalid.js", "parse/fixtures/terse-relay-resolver-no-dot.invalid.expected", input, expected); +} + +#[test] +fn terse_relay_resolver_not_enabled_invalid() { + let input = include_str!("parse/fixtures/terse-relay-resolver-not-enabled.invalid.js"); + let expected = include_str!("parse/fixtures/terse-relay-resolver-not-enabled.invalid.expected"); + test_fixture(transform_fixture, "terse-relay-resolver-not-enabled.invalid.js", "parse/fixtures/terse-relay-resolver-not-enabled.invalid.expected", input, expected); +} diff --git a/compiler/crates/relay-docblock/tests/to_schema/fixtures/terse-relay-resolver-with-output-type.expected b/compiler/crates/relay-docblock/tests/to_schema/fixtures/terse-relay-resolver-with-output-type.expected new file mode 100644 index 0000000000000..9fbbbd61da398 --- /dev/null +++ b/compiler/crates/relay-docblock/tests/to_schema/fixtures/terse-relay-resolver-with-output-type.expected @@ -0,0 +1,27 @@ +==================================== INPUT ==================================== +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * @RelayResolver User.favorite_page: ClientPage + * @rootFragment myRootFragment + * + * The user's favorite page! They probably clicked something in the UI + * to tell us that it was their favorite page and then we put that in a + * database or something. Then we got that info out again and put it out + * again. Anyway, I'm rambling now. Its a page that the user likes. A lot. + */ + +graphql` + fragment myRootFragment on User { + name + } +` +==================================== OUTPUT =================================== +extend type User @relay_resolver(import_path: "/path/to/test/fixture/terse-relay-resolver-with-output-type.js", fragment_name: "myRootFragment") { + favorite_page: ClientPage +} diff --git a/compiler/crates/relay-docblock/tests/to_schema/fixtures/terse-relay-resolver-with-output-type.js b/compiler/crates/relay-docblock/tests/to_schema/fixtures/terse-relay-resolver-with-output-type.js new file mode 100644 index 0000000000000..3f485bda44e52 --- /dev/null +++ b/compiler/crates/relay-docblock/tests/to_schema/fixtures/terse-relay-resolver-with-output-type.js @@ -0,0 +1,22 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * @RelayResolver User.favorite_page: ClientPage + * @rootFragment myRootFragment + * + * The user's favorite page! They probably clicked something in the UI + * to tell us that it was their favorite page and then we put that in a + * database or something. Then we got that info out again and put it out + * again. Anyway, I'm rambling now. Its a page that the user likes. A lot. + */ + +graphql` + fragment myRootFragment on User { + name + } +` diff --git a/compiler/crates/relay-docblock/tests/to_schema/fixtures/terse-relay-resolver.expected b/compiler/crates/relay-docblock/tests/to_schema/fixtures/terse-relay-resolver.expected new file mode 100644 index 0000000000000..e61d51f0f23e7 --- /dev/null +++ b/compiler/crates/relay-docblock/tests/to_schema/fixtures/terse-relay-resolver.expected @@ -0,0 +1,27 @@ +==================================== INPUT ==================================== +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * @RelayResolver User.favorite_page: Page + * @rootFragment myRootFragment + * + * The user's favorite page! They probably clicked something in the UI + * to tell us that it was their favorite page and then we put that in a + * database or something. Then we got that info out again and put it out + * again. Anyway, I'm rambling now. Its a page that the user likes. A lot. + */ + +graphql` + fragment myRootFragment on User { + id + } +` +==================================== OUTPUT =================================== +extend type User @relay_resolver(import_path: "/path/to/test/fixture/terse-relay-resolver.js", fragment_name: "myRootFragment") { + favorite_page: Page +} diff --git a/compiler/crates/relay-docblock/tests/to_schema/fixtures/terse-relay-resolver.js b/compiler/crates/relay-docblock/tests/to_schema/fixtures/terse-relay-resolver.js new file mode 100644 index 0000000000000..708a0798a7a0e --- /dev/null +++ b/compiler/crates/relay-docblock/tests/to_schema/fixtures/terse-relay-resolver.js @@ -0,0 +1,22 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * @RelayResolver User.favorite_page: Page + * @rootFragment myRootFragment + * + * The user's favorite page! They probably clicked something in the UI + * to tell us that it was their favorite page and then we put that in a + * database or something. Then we got that info out again and put it out + * again. Anyway, I'm rambling now. Its a page that the user likes. A lot. + */ + +graphql` + fragment myRootFragment on User { + id + } +` diff --git a/compiler/crates/relay-docblock/tests/to_schema_test.rs b/compiler/crates/relay-docblock/tests/to_schema_test.rs index d0be32cd6a820..1937d1ebd866b 100644 --- a/compiler/crates/relay-docblock/tests/to_schema_test.rs +++ b/compiler/crates/relay-docblock/tests/to_schema_test.rs @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<> + * @generated SignedSource<> */ mod to_schema; @@ -152,6 +152,20 @@ fn relay_resolver_with_output_type() { test_fixture(transform_fixture, "relay-resolver-with-output-type.js", "to_schema/fixtures/relay-resolver-with-output-type.expected", input, expected); } +#[test] +fn terse_relay_resolver() { + let input = include_str!("to_schema/fixtures/terse-relay-resolver.js"); + let expected = include_str!("to_schema/fixtures/terse-relay-resolver.expected"); + test_fixture(transform_fixture, "terse-relay-resolver.js", "to_schema/fixtures/terse-relay-resolver.expected", input, expected); +} + +#[test] +fn terse_relay_resolver_with_output_type() { + let input = include_str!("to_schema/fixtures/terse-relay-resolver-with-output-type.js"); + let expected = include_str!("to_schema/fixtures/terse-relay-resolver-with-output-type.expected"); + test_fixture(transform_fixture, "terse-relay-resolver-with-output-type.js", "to_schema/fixtures/terse-relay-resolver-with-output-type.expected", input, expected); +} + #[test] fn weak_type() { let input = include_str!("to_schema/fixtures/weak-type.js"); diff --git a/compiler/crates/relay-lsp/src/docblock_resolution_info.rs b/compiler/crates/relay-lsp/src/docblock_resolution_info.rs index 3fde91a70f425..c7c899048f903 100644 --- a/compiler/crates/relay-lsp/src/docblock_resolution_info.rs +++ b/compiler/crates/relay-lsp/src/docblock_resolution_info.rs @@ -68,6 +68,39 @@ pub fn create_docblock_resolution_info( Err(LSPRuntimeError::ExpectedError) } + DocblockIr::TerseRelayResolver(resolver_ir) => { + // Parent type + if resolver_ir.type_.location.contains(position_span) { + return Ok(DocblockResolutionInfo::Type(resolver_ir.type_.item)); + } + + let field_type_location = resolver_ir + .location + .with_span(resolver_ir.field.type_.span()); + + // Return type + if field_type_location.contains(position_span) { + return Ok(DocblockResolutionInfo::Type( + resolver_ir.field.type_.inner().name.value, + )); + } + + // Root fragment + if let Some(root_fragment) = resolver_ir.root_fragment { + if root_fragment.location.contains(position_span) { + return Ok(DocblockResolutionInfo::RootFragment(root_fragment.item)); + } + } + + // @deprecated key + if let Some(deprecated) = resolver_ir.deprecated { + if deprecated.key_location.contains(position_span) { + return Ok(DocblockResolutionInfo::Deprecated); + } + } + + Err(LSPRuntimeError::ExpectedError) + } DocblockIr::StrongObjectResolver(strong_object) => { if strong_object.type_.value.location.contains(position_span) { return Ok(DocblockResolutionInfo::Type(strong_object.type_.value.item)); diff --git a/compiler/crates/relay-lsp/src/references/mod.rs b/compiler/crates/relay-lsp/src/references/mod.rs index b63bd8669db32..246adfb674999 100644 --- a/compiler/crates/relay-lsp/src/references/mod.rs +++ b/compiler/crates/relay-lsp/src/references/mod.rs @@ -80,15 +80,17 @@ fn get_references_response( On::Type(type_) => type_.value.item, On::Interface(interface) => interface.value.item, }, + DocblockIr::TerseRelayResolver(_) => { + // TODO: Implement support for terse relay resolvers. + return Err(LSPRuntimeError::ExpectedError); + } DocblockIr::StrongObjectResolver(_) => { - return Err(LSPRuntimeError::UnexpectedError( - "TODO: Implement support for strong object.".to_owned(), - )); + // TODO: Implement support for strong object. + return Err(LSPRuntimeError::ExpectedError); } DocblockIr::WeakObjectType(_) => { - return Err(LSPRuntimeError::UnexpectedError( - "TODO: Implement support for weak object.".to_owned(), - )); + // TODO: Implement support for weak object. + return Err(LSPRuntimeError::ExpectedError); } };