Skip to content

Commit 0c0edd4

Browse files
authored
feat(graphql_analyze): add useUniqueGraphqlOperationName (#8013)
1 parent c266319 commit 0c0edd4

File tree

15 files changed

+453
-18
lines changed

15 files changed

+453
-18
lines changed

.changeset/rich-memes-sleep.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
---
2+
"@biomejs/biome": patch
3+
---
4+
5+
Added the GraphQL nursery rule [`useUniqueGraphqlOperationName`](https://biomejs.dev/linter/rules/use-unique-graphql-operation-name). This rule ensures that all GraphQL operations within a document have unique names.
6+
7+
**Invalid:**
8+
```graphql
9+
query user {
10+
user {
11+
id
12+
}
13+
}
14+
15+
query user {
16+
user {
17+
id
18+
email
19+
}
20+
}
21+
```
22+
23+
**Valid:**
24+
```graphql
25+
query user {
26+
user {
27+
id
28+
}
29+
}
30+
31+
query userWithEmail {
32+
user {
33+
id
34+
email
35+
}
36+
}
37+
```

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/biome_configuration/src/analyzer/linter/rules.rs

Lines changed: 38 additions & 17 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/biome_diagnostics_categories/src/categories.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,7 @@ define_categories! {
209209
"lint/nursery/useQwikMethodUsage": "https://biomejs.dev/linter/rules/use-qwik-method-usage",
210210
"lint/nursery/useQwikValidLexicalScope": "https://biomejs.dev/linter/rules/use-qwik-valid-lexical-scope",
211211
"lint/nursery/useSortedClasses": "https://biomejs.dev/linter/rules/use-sorted-classes",
212+
"lint/nursery/useUniqueGraphqlOperationName": "https://biomejs.dev/linter/rules/use-unique-graphql-operation-name",
212213
"lint/nursery/useVueDefineMacrosOrder": "https://biomejs.dev/linter/rules/use-vue-define-macros-order",
213214
"lint/nursery/useVueMultiWordComponentNames": "https://biomejs.dev/linter/rules/use-vue-multi-word-component-names",
214215
"lint/nursery/useVueValidVBind": "https://biomejs.dev/linter/rules/use-vue-valid-v-bind",

crates/biome_graphql_analyze/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ biome_rule_options = { workspace = true }
2424
biome_string_case = { workspace = true }
2525
biome_suppression = { workspace = true }
2626
jiff = { workspace = true }
27+
rustc-hash = { workspace = true }
2728
schemars = { workspace = true, optional = true }
2829
serde = { workspace = true, features = ["derive"] }
2930

crates/biome_graphql_analyze/src/lint/nursery.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,5 @@ use biome_analyze::declare_lint_group;
66
pub mod no_empty_source;
77
pub mod use_consistent_graphql_descriptions;
88
pub mod use_deprecated_date;
9-
declare_lint_group! { pub Nursery { name : "nursery" , rules : [self :: no_empty_source :: NoEmptySource , self :: use_consistent_graphql_descriptions :: UseConsistentGraphqlDescriptions , self :: use_deprecated_date :: UseDeprecatedDate ,] } }
9+
pub mod use_unique_graphql_operation_name;
10+
declare_lint_group! { pub Nursery { name : "nursery" , rules : [self :: no_empty_source :: NoEmptySource , self :: use_consistent_graphql_descriptions :: UseConsistentGraphqlDescriptions , self :: use_deprecated_date :: UseDeprecatedDate , self :: use_unique_graphql_operation_name :: UseUniqueGraphqlOperationName ,] } }
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
use biome_analyze::{
2+
Ast, Rule, RuleDiagnostic, RuleSource, context::RuleContext, declare_lint_rule,
3+
};
4+
use biome_console::markup;
5+
use biome_graphql_syntax::GraphqlRoot;
6+
use biome_rowan::{AstNode, TextRange, TokenText};
7+
use biome_rule_options::use_unique_graphql_operation_name::UseUniqueGraphqlOperationNameOptions;
8+
use rustc_hash::FxHashMap;
9+
10+
declare_lint_rule! {
11+
/// Enforce unique operation names across a GraphQL document.
12+
///
13+
/// This rule ensures that all GraphQL operations (queries, mutations, subscriptions) have unique names.
14+
/// Using unique operation names is essential for proper identification and reducing confusion.
15+
///
16+
/// :::note
17+
/// This rule currently does not work across multiple files.
18+
/// :::
19+
///
20+
/// ## Examples
21+
///
22+
/// ### Invalid
23+
///
24+
/// ```graphql,expect_diagnostic
25+
/// query user {
26+
/// user {
27+
/// id
28+
/// }
29+
/// }
30+
///
31+
/// query user {
32+
/// me {
33+
/// id
34+
/// }
35+
/// }
36+
/// ```
37+
///
38+
/// ### Valid
39+
///
40+
/// ```graphql
41+
/// query user {
42+
/// user {
43+
/// id
44+
/// }
45+
/// }
46+
///
47+
/// query me {
48+
/// me {
49+
/// id
50+
/// }
51+
/// }
52+
/// ```
53+
///
54+
pub UseUniqueGraphqlOperationName {
55+
version: "next",
56+
name: "useUniqueGraphqlOperationName",
57+
language: "graphql",
58+
recommended: false,
59+
sources: &[RuleSource::EslintGraphql("unique-operation-name").inspired()],
60+
}
61+
}
62+
63+
pub struct DuplicateOperationName {
64+
name: TokenText,
65+
text_range: TextRange,
66+
}
67+
68+
impl Rule for UseUniqueGraphqlOperationName {
69+
type Query = Ast<GraphqlRoot>;
70+
type State = DuplicateOperationName;
71+
type Signals = Box<[Self::State]>;
72+
type Options = UseUniqueGraphqlOperationNameOptions;
73+
74+
fn run(ctx: &RuleContext<Self>) -> Self::Signals {
75+
let root = ctx.query();
76+
let mut operation_names: FxHashMap<TokenText, TextRange> = FxHashMap::default();
77+
let mut duplicates = vec![];
78+
79+
for definition in root.definitions() {
80+
if let Some(operation) = definition.as_graphql_operation_definition()
81+
&& let Some(name_token) = operation.name()
82+
&& let Ok(token) = name_token.value_token()
83+
{
84+
let name = token.token_text_trimmed();
85+
let text_range = operation.range();
86+
87+
if let Some(_existing_range) = operation_names.insert(name.clone(), text_range) {
88+
duplicates.push(DuplicateOperationName { name, text_range });
89+
}
90+
}
91+
}
92+
93+
duplicates.into_boxed_slice()
94+
}
95+
96+
fn diagnostic(_ctx: &RuleContext<Self>, state: &Self::State) -> Option<RuleDiagnostic> {
97+
let DuplicateOperationName { name, text_range } = state;
98+
99+
Some(
100+
RuleDiagnostic::new(
101+
rule_category!(),
102+
text_range,
103+
markup! {
104+
"Operation named \""{ name.text() }"\" is already defined."
105+
},
106+
)
107+
.note(markup! {
108+
"GraphQL operation names must be unique to ensure proper identification."
109+
})
110+
.note(markup! {
111+
"Rename the operation to have a unique name."
112+
}),
113+
)
114+
}
115+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
query user {
2+
user {
3+
id
4+
}
5+
}
6+
7+
query user {
8+
me {
9+
id
10+
}
11+
}
12+
13+
mutation updateUser {
14+
updateUser {
15+
id
16+
}
17+
}
18+
19+
mutation updateUser {
20+
updateProfile {
21+
name
22+
}
23+
}
24+
25+
mutation updateUser {
26+
updateSettings {
27+
theme
28+
}
29+
}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
---
2+
source: crates/biome_graphql_analyze/tests/spec_tests.rs
3+
expression: invalid.graphql
4+
---
5+
# Input
6+
```graphql
7+
query user {
8+
user {
9+
id
10+
}
11+
}
12+
13+
query user {
14+
me {
15+
id
16+
}
17+
}
18+
19+
mutation updateUser {
20+
updateUser {
21+
id
22+
}
23+
}
24+
25+
mutation updateUser {
26+
updateProfile {
27+
name
28+
}
29+
}
30+
31+
mutation updateUser {
32+
updateSettings {
33+
theme
34+
}
35+
}
36+
37+
```
38+
39+
# Diagnostics
40+
```
41+
invalid.graphql:7:1 lint/nursery/useUniqueGraphqlOperationName ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
42+
43+
i Operation named "user" is already defined.
44+
45+
5 │ }
46+
6 │
47+
> 7 │ query user {
48+
^^^^^^^^^^^^
49+
> 8me {
50+
> 9 │ id
51+
> 10 │ }
52+
> 11}
53+
│ ^
54+
12 │
55+
13 │ mutation updateUser {
56+
57+
i GraphQL operation names must be unique to ensure proper identification.
58+
59+
i Rename the operation to have a unique name.
60+
61+
62+
```
63+
64+
```
65+
invalid.graphql:19:1 lint/nursery/useUniqueGraphqlOperationName ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
66+
67+
i Operation named "updateUser" is already defined.
68+
69+
17}
70+
18 │
71+
> 19 │ mutation updateUser {
72+
^^^^^^^^^^^^^^^^^^^^^
73+
> 20updateProfile {
74+
> 21 │ name
75+
> 22 │ }
76+
> 23}
77+
│ ^
78+
24 │
79+
25 │ mutation updateUser {
80+
81+
i GraphQL operation names must be unique to ensure proper identification.
82+
83+
i Rename the operation to have a unique name.
84+
85+
86+
```
87+
88+
```
89+
invalid.graphql:25:1 lint/nursery/useUniqueGraphqlOperationName ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
90+
91+
i Operation named "updateUser" is already defined.
92+
93+
23}
94+
24 │
95+
> 25 │ mutation updateUser {
96+
^^^^^^^^^^^^^^^^^^^^^
97+
> 26updateSettings {
98+
> 27 │ theme
99+
> 28 │ }
100+
> 29}
101+
│ ^
102+
30 │
103+
104+
i GraphQL operation names must be unique to ensure proper identification.
105+
106+
i Rename the operation to have a unique name.
107+
108+
109+
```
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# should not generate diagnostics
2+
3+
query user {
4+
user {
5+
id
6+
}
7+
}
8+
9+
query me {
10+
me {
11+
id
12+
}
13+
}
14+
15+
mutation updateUser {
16+
updateUser {
17+
id
18+
}
19+
}
20+
21+
mutation updateProfile {
22+
updateProfile {
23+
name
24+
}
25+
}
26+
27+
query {
28+
field
29+
}
30+
31+
subscription {
32+
newMessage
33+
}

0 commit comments

Comments
 (0)