Skip to content

Commit

Permalink
feat(graphql_analyze): noUnamedOperation
Browse files Browse the repository at this point in the history
Disallow operations without name, since GraphQL clients usually use
operations' name to cache.
  • Loading branch information
vohoanglong0107 committed Oct 27, 2024
1 parent 1c60340 commit 0769cd2
Show file tree
Hide file tree
Showing 16 changed files with 263 additions and 10 deletions.
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

40 changes: 31 additions & 9 deletions crates/biome_configuration/src/analyzer/linter/rules.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3432,6 +3432,10 @@ pub struct Nursery {
#[serde(skip_serializing_if = "Option::is_none")]
pub use_import_restrictions:
Option<RuleConfiguration<biome_js_analyze::options::UseImportRestrictions>>,
#[doc = "Enforce specifying the name of GraphQL operations."]
#[serde(skip_serializing_if = "Option::is_none")]
pub use_named_operation:
Option<RuleFixConfiguration<biome_graphql_analyze::options::UseNamedOperation>>,
#[doc = "Enforce the sorting of CSS utility classes."]
#[serde(skip_serializing_if = "Option::is_none")]
pub use_sorted_classes:
Expand Down Expand Up @@ -3509,6 +3513,7 @@ impl Nursery {
"useGoogleFontDisplay",
"useGuardForIn",
"useImportRestrictions",
"useNamedOperation",
"useSortedClasses",
"useStrictMode",
"useTrimStartEnd",
Expand All @@ -3528,6 +3533,7 @@ impl Nursery {
"useAriaPropsSupportedByRole",
"useConsistentMemberAccessibility",
"useDeprecatedReason",
"useNamedOperation",
"useStrictMode",
];
const RECOMMENDED_RULES_AS_FILTERS: &'static [RuleFilter<'static>] = &[
Expand All @@ -3544,7 +3550,8 @@ impl Nursery {
RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[33]),
RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[38]),
RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[39]),
RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[45]),
RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[44]),
RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[46]),
];
const ALL_RULES_AS_FILTERS: &'static [RuleFilter<'static>] = &[
RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[0]),
Expand Down Expand Up @@ -3595,6 +3602,7 @@ impl Nursery {
RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[45]),
RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[46]),
RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[47]),
RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[48]),
];
#[doc = r" Retrieves the recommended rules"]
pub(crate) fn is_recommended_true(&self) -> bool {
Expand Down Expand Up @@ -3831,26 +3839,31 @@ impl Nursery {
index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[43]));
}
}
if let Some(rule) = self.use_sorted_classes.as_ref() {
if let Some(rule) = self.use_named_operation.as_ref() {
if rule.is_enabled() {
index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[44]));
}
}
if let Some(rule) = self.use_strict_mode.as_ref() {
if let Some(rule) = self.use_sorted_classes.as_ref() {
if rule.is_enabled() {
index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[45]));
}
}
if let Some(rule) = self.use_trim_start_end.as_ref() {
if let Some(rule) = self.use_strict_mode.as_ref() {
if rule.is_enabled() {
index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[46]));
}
}
if let Some(rule) = self.use_valid_autocomplete.as_ref() {
if let Some(rule) = self.use_trim_start_end.as_ref() {
if rule.is_enabled() {
index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[47]));
}
}
if let Some(rule) = self.use_valid_autocomplete.as_ref() {
if rule.is_enabled() {
index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[48]));
}
}
index_set
}
pub(crate) fn get_disabled_rules(&self) -> FxHashSet<RuleFilter<'static>> {
Expand Down Expand Up @@ -4075,26 +4088,31 @@ impl Nursery {
index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[43]));
}
}
if let Some(rule) = self.use_sorted_classes.as_ref() {
if let Some(rule) = self.use_named_operation.as_ref() {
if rule.is_disabled() {
index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[44]));
}
}
if let Some(rule) = self.use_strict_mode.as_ref() {
if let Some(rule) = self.use_sorted_classes.as_ref() {
if rule.is_disabled() {
index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[45]));
}
}
if let Some(rule) = self.use_trim_start_end.as_ref() {
if let Some(rule) = self.use_strict_mode.as_ref() {
if rule.is_disabled() {
index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[46]));
}
}
if let Some(rule) = self.use_valid_autocomplete.as_ref() {
if let Some(rule) = self.use_trim_start_end.as_ref() {
if rule.is_disabled() {
index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[47]));
}
}
if let Some(rule) = self.use_valid_autocomplete.as_ref() {
if rule.is_disabled() {
index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[48]));
}
}
index_set
}
#[doc = r" Checks if, given a rule name, matches one of the rules contained in this category"]
Expand Down Expand Up @@ -4307,6 +4325,10 @@ impl Nursery {
.use_import_restrictions
.as_ref()
.map(|conf| (conf.level(), conf.get_options())),
"useNamedOperation" => self
.use_named_operation
.as_ref()
.map(|conf| (conf.level(), conf.get_options())),
"useSortedClasses" => self
.use_sorted_classes
.as_ref()
Expand Down
1 change: 1 addition & 0 deletions crates/biome_diagnostics_categories/src/categories.rs
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,7 @@ define_categories! {
"lint/nursery/useGuardForIn": "https://biomejs.dev/linter/rules/use-guard-for-in",
"lint/nursery/useImportRestrictions": "https://biomejs.dev/linter/rules/use-import-restrictions",
"lint/nursery/useJsxCurlyBraceConvention": "https://biomejs.dev/linter/rules/use-jsx-curly-brace-convention",
"lint/nursery/useNamedOperation": "https://biomejs.dev/linter/rules/use-named-operation",
"lint/nursery/useSortedClasses": "https://biomejs.dev/linter/rules/use-sorted-classes",
"lint/nursery/useStrictMode": "https://biomejs.dev/linter/rules/use-strict-mode",
"lint/nursery/useTrimStartEnd": "https://biomejs.dev/linter/rules/use-trim-start-end",
Expand Down
1 change: 1 addition & 0 deletions crates/biome_graphql_analyze/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ biome_console = { workspace = true }
biome_deserialize = { workspace = true }
biome_deserialize_macros = { workspace = true }
biome_diagnostics = { workspace = true }
biome_graphql_factory = { workspace = true }
biome_graphql_syntax = { workspace = true }
biome_rowan = { workspace = true }
biome_string_case = { workspace = true }
Expand Down
4 changes: 3 additions & 1 deletion crates/biome_graphql_analyze/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,16 @@ pub use crate::registry::visit_registry;
use crate::suppression_action::GraphqlSuppressionAction;
use biome_analyze::{
AnalysisFilter, AnalyzerOptions, AnalyzerSignal, ControlFlow, LanguageRoot, MatchQueryParams,
MetadataRegistry, RuleRegistry, SuppressionKind,
MetadataRegistry, RuleAction, RuleRegistry, SuppressionKind,
};
use biome_diagnostics::{category, Error};
use biome_graphql_syntax::GraphqlLanguage;
use biome_suppression::{parse_suppression_comment, SuppressionDiagnostic};
use std::ops::Deref;
use std::sync::LazyLock;

pub(crate) type GraphqlRuleAction = RuleAction<GraphqlLanguage>;

pub static METADATA: LazyLock<MetadataRegistry> = LazyLock::new(|| {
let mut metadata = MetadataRegistry::default();
visit_registry(&mut metadata);
Expand Down
2 changes: 2 additions & 0 deletions crates/biome_graphql_analyze/src/lint/nursery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@ use biome_analyze::declare_lint_group;

pub mod no_duplicated_fields;
pub mod use_deprecated_reason;
pub mod use_named_operation;

declare_lint_group! {
pub Nursery {
name : "nursery" ,
rules : [
self :: no_duplicated_fields :: NoDuplicatedFields ,
self :: use_deprecated_reason :: UseDeprecatedReason ,
self :: use_named_operation :: UseNamedOperation ,
]
}
}
117 changes: 117 additions & 0 deletions crates/biome_graphql_analyze/src/lint/nursery/use_named_operation.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
use biome_analyze::{
context::RuleContext, declare_lint_rule, ActionCategory, Ast, FixKind, Rule, RuleDiagnostic,
RuleSource, RuleSourceKind,
};
use biome_console::markup;
use biome_graphql_factory::make;
use biome_graphql_syntax::{GraphqlOperationDefinition, GraphqlOperationType};
use biome_rowan::{AstNode, BatchMutationExt};
use biome_string_case::Case;

use crate::GraphqlRuleAction;

declare_lint_rule! {
/// Enforce specifying the name of GraphQL operations.
///
/// This is useful because most GraphQL client libraries use the operation name for caching purposes.
///
/// ## Examples
///
/// ### Invalid
///
/// ```graphql,expect_diagnostic
/// query {}
/// ```
///
/// ### Valid
///
/// ```graphql
/// query Human {
/// name
/// }
/// ```
///
pub UseNamedOperation {
version: "next",
name: "useNamedOperation",
language: "graphql",
sources: &[RuleSource::EslintGraphql("no-anonymous-operations")],
source_kind: RuleSourceKind::SameLogic,
recommended: true,
fix_kind: FixKind::Unsafe,
}
}

impl Rule for UseNamedOperation {
type Query = Ast<GraphqlOperationDefinition>;
type State = GraphqlOperationType;
type Signals = Option<Self::State>;
type Options = ();

fn run(ctx: &RuleContext<Self>) -> Option<Self::State> {
let node = ctx.query();
let operation_type = node.ty().ok()?;
if node.name().is_some() {
None
} else {
Some(operation_type)
}
}

fn diagnostic(
_ctx: &RuleContext<Self>,
operation_type: &Self::State,
) -> Option<RuleDiagnostic> {
Some(
RuleDiagnostic::new(
rule_category!(),
operation_type.range(),
markup! {
"Anonymous GraphQL operations are forbidden. Make sure to name your " {operation_type.text()}"."
},
)
.note(markup! {
"Most GraphQL client libraries use the operation name for caching purposes."
}),
)
}

fn action(ctx: &RuleContext<Self>, operation_type: &Self::State) -> Option<GraphqlRuleAction> {
let mut mutation = ctx.root().begin();
let node = ctx.query().clone();
let operation_type = operation_type.text();
let suggested_name = get_suggested_name(&node, operation_type.clone());
let new_name = make::graphql_name_binding(make::ident(&suggested_name));
let new_node = node.clone().with_name(Some(new_name));
mutation.replace_node(node, new_node);

Some(GraphqlRuleAction::new(
ActionCategory::QuickFix,
ctx.metadata().applicability(),
markup! {
"Rename this "{operation_type}" to "{suggested_name}"."
},
mutation,
))
}
}

fn get_suggested_name(operation: &GraphqlOperationDefinition, operation_type: String) -> String {
let suggested_name = operation
.selection_set()
.ok()
.and_then(|selection_set| {
selection_set
.selections()
.into_iter()
.find_map(|selection| selection.as_graphql_field().cloned())
})
.and_then(|first_field| {
first_field
.alias()
.map(|alias| alias.text())
.or(first_field.name().ok().map(|name| name.text()))
})
.unwrap_or(operation_type);
Case::Pascal.convert(&suggested_name)
}
2 changes: 2 additions & 0 deletions crates/biome_graphql_analyze/src/options.rs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
query { human }
mutation { ...Type }
subscription {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
---
source: crates/biome_graphql_analyze/tests/spec_tests.rs
expression: invalid.graphql
---
# Input
```graphql
query { human }
mutation { ...Type }
subscription {}
```

# Diagnostics
```
invalid.graphql:1:1 lint/nursery/useNamedOperation FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
! Anonymous GraphQL operations are forbidden. Make sure to name your query.
> 1 │ query { human }
│ ^^^^^
2 │ mutation { ...Type }
3 │ subscription {}
i Most GraphQL client libraries use the operation name for caching purposes.
i Unsafe fix: Rename this query to Human.
1 │ query·Human{·human·}
│ +++++
```

```
invalid.graphql:2:1 lint/nursery/useNamedOperation FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
! Anonymous GraphQL operations are forbidden. Make sure to name your mutation.
1 │ query { human }
> 2 │ mutation { ...Type }
│ ^^^^^^^^
3 │ subscription {}
4 │
i Most GraphQL client libraries use the operation name for caching purposes.
i Unsafe fix: Rename this mutation to Mutation.
2 │ mutation·Mutation{·...Type·}
│ ++++++++
```

```
invalid.graphql:3:1 lint/nursery/useNamedOperation FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
! Anonymous GraphQL operations are forbidden. Make sure to name your subscription.
1 │ query { human }
2 │ mutation { ...Type }
> 3 │ subscription {}
│ ^^^^^^^^^^^^
4 │
i Most GraphQL client libraries use the operation name for caching purposes.
i Unsafe fix: Rename this subscription to Subscription.
3 │ subscription·Subscription{}
│ ++++++++++++
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/* should not generate diagnostics */
// var a = 1;
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
source: crates/biome_graphql_analyze/tests/spec_tests.rs
expression: valid.graphql
---
# Input
```graphql
/* should not generate diagnostics */
// var a = 1;
```
Loading

0 comments on commit 0769cd2

Please sign in to comment.