Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/polite-impalas-float.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@graphql-eslint/eslint-plugin': patch
---

- allow to config `naming-convention` for Relay fragment convention `<module_name>_<property_name>`
via `requiredPattern` option

- replace `requiredPatterns: RegEx[]` by `requiredPattern: RegEx` option
12 changes: 7 additions & 5 deletions packages/plugin/src/rules/match-document-filename/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,18 +28,20 @@ const schemaOption = {
oneOf: [{ $ref: '#/definitions/asString' }, { $ref: '#/definitions/asObject' }],
} as const;

const caseSchema = {
enum: CASE_STYLES,
description: `One of: ${CASE_STYLES.map(t => `\`${t}\``).join(', ')}`,
};

const schema = {
definitions: {
asString: {
enum: CASE_STYLES,
description: `One of: ${CASE_STYLES.map(t => `\`${t}\``).join(', ')}`,
},
asString: caseSchema,
asObject: {
type: 'object',
additionalProperties: false,
minProperties: 1,
properties: {
style: { enum: CASE_STYLES },
style: caseSchema,
suffix: { type: 'string' },
prefix: { type: 'string' },
},
Expand Down
68 changes: 66 additions & 2 deletions packages/plugin/src/rules/naming-convention/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,48 @@ ruleTester.run<RuleOptions>('naming-convention', rule, {
},
],
},
{
name: 'requiredPattern with case style in prefix',
options: [
{
FragmentDefinition: {
style: 'PascalCase',
requiredPattern: /^(?<camelCase>.+?)_/,
},
},
],
code: /* GraphQL */ `
fragment myUser_UserProfileFields on User {
id
}
`,
parserOptions: {
graphQLConfig: {
schema: 'type User',
},
},
},
{
name: 'requiredPattern with case style in suffix',
options: [
{
FragmentDefinition: {
style: 'PascalCase',
requiredPattern: /_(?<snake_case>.+?)$/,
},
},
],
code: /* GraphQL */ `
fragment UserProfileFields_my_user on User {
id
}
`,
parserOptions: {
graphQLConfig: {
schema: 'type User',
},
},
},
],
invalid: [
{
Expand Down Expand Up @@ -536,17 +578,39 @@ ruleTester.run<RuleOptions>('naming-convention', rule, {
errors: 2,
},
{
name: 'requiredPatterns',
name: 'requiredPattern',
code: 'type Test { enabled: Boolean! }',
options: [
{
'FieldDefinition[gqlType.gqlType.name.value=Boolean]': {
style: 'camelCase',
requiredPatterns: [/^(is|has)/],
requiredPattern: /^(is|has)/,
},
},
],
errors: 1,
},
{
name: 'requiredPattern with case style in suffix',
options: [
{
FragmentDefinition: {
style: 'PascalCase',
requiredPattern: /_(?<camelCase>.+?)$/,
},
},
],
code: /* GraphQL */ `
fragment UserProfileFields on User {
id
}
`,
parserOptions: {
graphQLConfig: {
schema: 'type User',
},
},
errors: 1,
},
],
});
100 changes: 75 additions & 25 deletions packages/plugin/src/rules/naming-convention/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { GraphQLESTreeNode } from '../../estree-converter/index.js';
import { GraphQLESLintRule, GraphQLESLintRuleListener, ValueOf } from '../../types.js';
import {
ARRAY_DEFAULT_OPTIONS,
CaseStyle,
convertCase,
displayNodeName,
englishJoinWords,
Expand Down Expand Up @@ -47,22 +48,24 @@ const schemaOption = {
oneOf: [{ $ref: '#/definitions/asString' }, { $ref: '#/definitions/asObject' }],
} as const;

const descriptionPrefixesSuffixes = (name: 'forbiddenPatterns' | 'requiredPatterns') =>
const descriptionPrefixesSuffixes = (name: 'forbiddenPatterns' | 'requiredPattern', id: string) =>
`> [!WARNING]
>
> This option is deprecated and will be removed in the next major release. Use [\`${name}\`](#${name.toLowerCase()}-array) instead.`;
> This option is deprecated and will be removed in the next major release. Use [\`${name}\`](#${id}) instead.`;

const caseSchema = {
enum: ALLOWED_STYLES,
description: `One of: ${ALLOWED_STYLES.map(t => `\`${t}\``).join(', ')}`,
};

const schema = {
definitions: {
asString: {
enum: ALLOWED_STYLES,
description: `One of: ${ALLOWED_STYLES.map(t => `\`${t}\``).join(', ')}`,
},
asString: caseSchema,
asObject: {
type: 'object',
additionalProperties: false,
properties: {
style: { enum: ALLOWED_STYLES },
style: caseSchema,
prefix: { type: 'string' },
suffix: { type: 'string' },
forbiddenPatterns: {
Expand All @@ -72,28 +75,25 @@ const schema = {
},
description: 'Should be of instance of `RegEx`',
},
requiredPatterns: {
...ARRAY_DEFAULT_OPTIONS,
items: {
type: 'object',
},
requiredPattern: {
type: 'object',
description: 'Should be of instance of `RegEx`',
},
forbiddenPrefixes: {
...ARRAY_DEFAULT_OPTIONS,
description: descriptionPrefixesSuffixes('forbiddenPatterns'),
description: descriptionPrefixesSuffixes('forbiddenPatterns', 'forbiddenpatterns-array'),
},
forbiddenSuffixes: {
...ARRAY_DEFAULT_OPTIONS,
description: descriptionPrefixesSuffixes('forbiddenPatterns'),
description: descriptionPrefixesSuffixes('forbiddenPatterns', 'forbiddenpatterns-array'),
},
requiredPrefixes: {
...ARRAY_DEFAULT_OPTIONS,
description: descriptionPrefixesSuffixes('requiredPatterns'),
description: descriptionPrefixesSuffixes('requiredPattern', 'requiredpattern-object'),
},
requiredSuffixes: {
...ARRAY_DEFAULT_OPTIONS,
description: descriptionPrefixesSuffixes('requiredPatterns'),
description: descriptionPrefixesSuffixes('requiredPattern', 'requiredpattern-object'),
},
ignorePattern: {
type: 'string',
Expand Down Expand Up @@ -152,7 +152,7 @@ type PropertySchema = {
suffix?: string;
prefix?: string;
forbiddenPatterns?: RegExp[];
requiredPatterns?: RegExp[];
requiredPattern?: RegExp;
forbiddenPrefixes?: string[];
forbiddenSuffixes?: string[];
requiredPrefixes?: string[];
Expand Down Expand Up @@ -278,6 +278,27 @@ export const rule: GraphQLESLintRule<RuleOptions> = {
}
`,
},
{
title: 'Correct (Relay fragment convention `<module_name>_<property_name>`)',
usage: [
{
FragmentDefinition: {
style: 'PascalCase',
requiredPattern: /_(?<camelCase>.+?)$/,
},
},
],
code: /* GraphQL */ `
# schema
type User {
# ...
}
# operations
fragment UserFields_data on User {
# ...
}
`,
},
],
configOptions: {
schema: [
Expand Down Expand Up @@ -378,7 +399,7 @@ export const rule: GraphQLESLintRule<RuleOptions> = {
requiredPrefixes,
requiredSuffixes,
forbiddenPatterns,
requiredPatterns,
requiredPattern,
} = normalisePropertyOption(selector);
const nodeName = node.value;
const error = getError();
Expand All @@ -401,7 +422,9 @@ export const rule: GraphQLESLintRule<RuleOptions> = {
errorMessage: string;
renameToNames: string[];
} | void {
const name = nodeName.replace(/(^_+)|(_+$)/g, '');
let name = nodeName;
if (allowLeadingUnderscore) name = name.replace(/^_+/, '');
if (allowTrailingUnderscore) name = name.replace(/_+$/, '');
if (ignorePattern && new RegExp(ignorePattern, 'u').test(name)) {
if ('name' in n) {
ignoredNodes.add(n.name);
Expand All @@ -420,19 +443,46 @@ export const rule: GraphQLESLintRule<RuleOptions> = {
renameToNames: [name + suffix],
};
}
if (requiredPattern) {
if (requiredPattern.source.includes('(?<')) {
try {
name = name.replace(requiredPattern, (originalString, ...args) => {
const groups = args.at(-1);
// eslint-disable-next-line no-unreachable-loop -- expected
for (const [styleName, value] of Object.entries(groups)) {
if (!(styleName in StyleToRegex)) {
throw new Error('Invalid case style in `requiredPatterns` option');
}
if (value === convertCase(styleName as CaseStyle, value as string)) {
return '';
}
throw new Error(`contain the required pattern: ${requiredPattern}`);
}
return originalString;
});
if (name === nodeName) {
throw new Error(`contain the required pattern: ${requiredPattern}`);
}
} catch (error) {
return {
errorMessage: (error as Error).message,
renameToNames: [],
};
}
} else if (!requiredPattern.test(name)) {
return {
errorMessage: `contain the required pattern: ${requiredPattern}`,
renameToNames: [],
};
}
}
const forbidden = forbiddenPatterns?.find(pattern => pattern.test(name));
if (forbidden) {
return {
errorMessage: `not contain the forbidden pattern "${forbidden}"`,
renameToNames: [name.replace(forbidden, '')],
};
}
if (requiredPatterns && !requiredPatterns.some(pattern => pattern.test(name))) {
return {
errorMessage: `contain the required pattern: ${englishJoinWords(requiredPatterns.map(re => re.source))}`,
renameToNames: [],
};
}
const forbiddenPrefix = forbiddenPrefixes?.find(prefix => name.startsWith(prefix));
if (forbiddenPrefix) {
return {
Expand Down
31 changes: 26 additions & 5 deletions packages/plugin/src/rules/naming-convention/snapshot.md
Original file line number Diff line number Diff line change
Expand Up @@ -1973,7 +1973,7 @@ exports[`naming-convention > invalid > operations-recommended config 1`] = `
13 | fragment Test on Test { id }
`;

exports[`naming-convention > invalid > requiredPatterns 1`] = `
exports[`naming-convention > invalid > requiredPattern 1`] = `
#### ⌨️ Code

1 | type Test { enabled: Boolean! }
Expand All @@ -1983,16 +1983,37 @@ exports[`naming-convention > invalid > requiredPatterns 1`] = `
{
"FieldDefinition[gqlType.gqlType.name.value=Boolean]": {
"style": "camelCase",
"requiredPatterns": [
"/^(is|has)/"
]
"requiredPattern": "/^(is|has)/"
}
}

#### ❌ Error

> 1 | type Test { enabled: Boolean! }
| ^^^^^^^ Field "enabled" should contain the required pattern: ^(is|has)
| ^^^^^^^ Field "enabled" should contain the required pattern: /^(is|has)/
`;

exports[`naming-convention > invalid > requiredPattern with case style in suffix 1`] = `
#### ⌨️ Code

1 | fragment UserProfileFields on User {
2 | id
3 | }

#### ⚙️ Options

{
"FragmentDefinition": {
"style": "PascalCase",
"requiredPattern": "/_(?<camelCase>.+?)$/"
}
}

#### ❌ Error

> 1 | fragment UserProfileFields on User {
| ^^^^^^^^^^^^^^^^^ Fragment "UserProfileFields" should contain the required pattern: /_(?<camelCase>.+?)$/
2 | id
`;

exports[`naming-convention > invalid > schema-recommended config 1`] = `
Expand Down
Loading
Loading