diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index a6017e4..ec93169 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -1,18 +1,18 @@ name: lint run-name: Installs project and runs linting -on: [ push, pull_request ] +on: [push, pull_request] jobs: - lint: - runs-on: ubuntu-latest - strategy: - matrix: - node: [ 18, 20 ] - name: Linting on Ubuntu with Node ${{ matrix.node }} - steps: - - uses: actions/checkout@v3 - - uses: actions/setup-node@v3 - with: - node-version: ${{ matrix.node }} - cache: 'npm' - - run: npm install - - run: npm run lint \ No newline at end of file + lint: + runs-on: ubuntu-latest + strategy: + matrix: + node: [18, 20] + name: Linting on Ubuntu with Node ${{ matrix.node }} + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: ${{ matrix.node }} + cache: 'npm' + - run: npm install + - run: npm run lint diff --git a/src/rules/graphql/no-fiscal-date-filtering-supported.ts b/src/rules/graphql/no-fiscal-date-filtering-supported.ts index f2e6921..576c8a2 100644 --- a/src/rules/graphql/no-fiscal-date-filtering-supported.ts +++ b/src/rules/graphql/no-fiscal-date-filtering-supported.ts @@ -1,14 +1,11 @@ -import { Kind } from 'graphql'; +import { ASTNode, Kind, ArgumentNode } from 'graphql'; import { GraphQLESLintRule, GraphQLESLintRuleContext } from '@graphql-eslint/eslint-plugin'; import getDocUrl from '../../util/getDocUrl'; +import { getClosestAncestorByType } from '../../util/graphqlAstUtils'; +import { GraphQLESTreeNode } from './types'; export const NO_FISCAL_DATE_FILTER_SUPPORTED_RULE_ID = 'offline-graphql-no-fiscal-date-filter-supported'; -type NodeWithParent = { - kind: string; - parent: NodeWithParent; -}; - export const rule: GraphQLESLintRule = { meta: { type: 'problem', @@ -105,7 +102,7 @@ export const rule: GraphQLESLintRule = { node.name.value === 'literal' && node.value.kind === Kind.ENUM && node.value.value.indexOf('_FISCAL_') > 0 && - isInFilter(node as NodeWithParent) + isInFilter(node) ) { context.report({ messageId: NO_FISCAL_DATE_FILTER_SUPPORTED_RULE_ID, @@ -127,7 +124,7 @@ export const rule: GraphQLESLintRule = { // Checks if it is a fiscal date filter, for example 'last_n_fiscal_quarters', 'n_fiscal_years_ago'. if ( rangeObjectField.name.value.indexOf('_fiscal_') > 0 && - isInFilter(rangeObjectField as NodeWithParent) + isInFilter(rangeObjectField) ) { context.report({ messageId: NO_FISCAL_DATE_FILTER_SUPPORTED_RULE_ID, @@ -145,9 +142,11 @@ export const rule: GraphQLESLintRule = { } }; -function isInFilter(node: NodeWithParent): boolean { - if (node.kind === Kind.ARGUMENT) { - return true; +function isInFilter(node: GraphQLESTreeNode): boolean { + const argument = getClosestAncestorByType(node, Kind.ARGUMENT); + if (argument === undefined) { + return false; } - return node.parent === null ? false : isInFilter(node.parent); + + return argument.name.value === 'where'; } diff --git a/src/rules/graphql/no-more-than-3-root-entities.ts b/src/rules/graphql/no-more-than-3-root-entities.ts index 1c54cf3..2b6539c 100644 --- a/src/rules/graphql/no-more-than-3-root-entities.ts +++ b/src/rules/graphql/no-more-than-3-root-entities.ts @@ -23,8 +23,8 @@ export const rule: GraphQLESLintRule = { { title: 'Correct', code: /* GraphQL */ ` - query { - uiapi { + uiapi { + query { Contacts { edges { node { @@ -51,10 +51,55 @@ export const rule: GraphQLESLintRule = { ` }, { - title: 'Incorrect', + title: 'Correct', code: /* GraphQL */ ` - query { + query FirstQuery { uiapi { + query { + Contact(first: 1) { + edges { + node { + Id + } + } + } + ServiceAppointment(first: 1) { + edges { + node { + Id + } + } + } + Case(first: 1) { + edges { + node { + Id + } + } + } + } + } + } + query SecondQuery { + uiapi { + query { + Contact(first: 1) { + edges { + node { + Id + } + } + } + } + } + } + ` + }, + { + title: 'Incorrect', + code: /* GraphQL */ ` + uiapi { + query{ Contacts { edges { node { diff --git a/src/rules/graphql/types.ts b/src/rules/graphql/types.ts index f2d7dd3..dedb97f 100644 --- a/src/rules/graphql/types.ts +++ b/src/rules/graphql/types.ts @@ -83,7 +83,7 @@ type NodeWithType = | OperationTypeDefinitionNode | VariableDefinitionNode; -type ParentNode = T extends DocumentNode +export type ParentNode = T extends DocumentNode ? AST.Program : T extends DefinitionNode ? DocumentNode diff --git a/src/rules/graphql/unsupported-scope.ts b/src/rules/graphql/unsupported-scope.ts index 0cb8b28..aa088cc 100644 --- a/src/rules/graphql/unsupported-scope.ts +++ b/src/rules/graphql/unsupported-scope.ts @@ -5,7 +5,7 @@ * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT */ import { GraphQLESLintRule, GraphQLESLintRuleContext } from '@graphql-eslint/eslint-plugin'; -import { Kind, NameNode } from 'graphql'; +import { Kind, FieldNode } from 'graphql'; export const UNSUPPORTED_SCOPE_RULE_ID = 'offline-graphql-unsupported-scope'; @@ -14,10 +14,7 @@ export const OTHER_UNSUPPORTED_SCOPE = 'OTHER_UNSUPPORTED_SCOPE'; import getDocUrl from '../../util/getDocUrl'; -type NodeWithName = { - name: NameNode; -}; - +import { GraphQLESTreeNode } from './types'; // key is scope name, value is the array of supported entities. Empty array means that all entities are supported. const supportedScopes: Record = { MINE: [], @@ -98,7 +95,7 @@ export const rule: GraphQLESLintRule = { } else { const entities = supportedScopes[scopeName]; if (entities.length > 0) { - const entityNode = node.parent as NodeWithName; + const entityNode = node.parent as GraphQLESTreeNode; if (entityNode.name.kind === Kind.NAME) { const entityName = entityNode.name.value; if (!entities.includes(entityName)) { diff --git a/src/util/graphqlAstUtils.ts b/src/util/graphqlAstUtils.ts index e1d1ba7..3fc243b 100644 --- a/src/util/graphqlAstUtils.ts +++ b/src/util/graphqlAstUtils.ts @@ -7,8 +7,8 @@ import { Position } from 'estree'; import { AST } from 'eslint'; -import { GraphQLESTreeNode } from '../rules/graphql/types'; -import { FieldNode, Kind, DocumentNode, OperationDefinitionNode } from 'graphql'; +import { GraphQLESTreeNode, ParentNode } from '../rules/graphql/types'; +import { ASTNode, FieldNode, Kind, DocumentNode, OperationDefinitionNode } from 'graphql'; import { DEFAULT_PAGE_SIZE } from '../rules/graphql/EntityStats'; export type GraphQLESFieldNode = GraphQLESTreeNode; @@ -28,17 +28,24 @@ export function getLocation(start: Position, fieldName = ''): AST.SourceLocation } /** - * Find closest ancestor by type + * Find closest ancestor by type. T is source ASTNode type, W is the target ASTNode type. + * @param node source node + * @param type target node type. For example, Kind.Field or Kind.Argument */ -export function getClosestAncestorByType( - node: GraphQLESFieldNode, + +export function getClosestAncestorByType( + node: GraphQLESTreeNode, type: Kind -): GraphQLESFieldNode | undefined { - let parentNode: any = node.parent; - while (parentNode !== undefined && parentNode.type !== type) { - parentNode = parentNode.parent; +): GraphQLESTreeNode | undefined { + const parentNode = node.parent; + if (parentNode === null || parentNode === undefined) { + return undefined; } - return parentNode; + const astParentNode = parentNode as GraphQLESTreeNode, unknown>>; + if (astParentNode.type === type) { + return astParentNode as GraphQLESTreeNode; + } + return getClosestAncestorByType(astParentNode, type); } /** @@ -104,11 +111,12 @@ export function getPageSizeFromEntityNode(node: GraphQLESFieldNode): number { export function getParentEntityNode( entityNode: GraphQLESFieldNode ): GraphQLESFieldNode | undefined { - const node = getClosestAncestorByType(entityNode, Kind.FIELD); + const node = getClosestAncestorByType(entityNode, Kind.FIELD); + if (node === undefined || node.name.value !== 'node') { return undefined; } - const edges = getClosestAncestorByType(node, Kind.FIELD); + const edges = getClosestAncestorByType(node, Kind.FIELD); if (edges == undefined || edges.name.value !== 'edges') { return undefined; } @@ -135,7 +143,10 @@ export function getParentEntityNode( } */ export function getOperationIndex(entityNode: GraphQLESFieldNode): number { - const operation = getClosestAncestorByType(entityNode, Kind.OPERATION_DEFINITION)!; + const operation = getClosestAncestorByType( + entityNode, + Kind.OPERATION_DEFINITION + )!; const document = operation.parent.rawNode() as any as DocumentNode; return document.definitions.indexOf(operation.rawNode() as any as OperationDefinitionNode); } diff --git a/test/rules/graphql/no-more-than-3-root-entities.spec.ts b/test/rules/graphql/no-more-than-3-root-entities.spec.ts index 3a32b65..0e09aef 100644 --- a/test/rules/graphql/no-more-than-3-root-entities.spec.ts +++ b/test/rules/graphql/no-more-than-3-root-entities.spec.ts @@ -10,26 +10,72 @@ ruleTester.run(createScopedModuleRuleName(NO_MORE_THAN_3_ROOT_ENTITIES_RULE_ID), valid: [ { code: /* GraphQL */ ` - query { + { uiapi { - Contacts { - edges { - node { - Id + query { + Contacts { + edges { + node { + Id + } + } + } + Opportunities { + edges { + node { + Id + } + } + } + Cases { + edges { + node { + Id + } } } } - Opportunities { - edges { - node { - Id + } + } + ` + }, + { + code: /* GraphQL */ ` + query FirstQuery { + uiapi { + query { + Contacts { + edges { + node { + Id + } + } + } + Opportunities { + edges { + node { + Id + } + } + } + Cases { + edges { + node { + Id + } } } } - Cases { - edges { - node { - Id + } + } + query SecondQuery { + uiapi { + query { + Contact(first: 1) { + edges { + node { + Id + } } } } @@ -41,33 +87,35 @@ ruleTester.run(createScopedModuleRuleName(NO_MORE_THAN_3_ROOT_ENTITIES_RULE_ID), invalid: [ { code: /* GraphQL */ ` - query { + { uiapi { - Contacts { - edges { - node { - Id + query { + Contacts { + edges { + node { + Id + } } } - } - Opportunities { - edges { - node { - Id + Opportunities { + edges { + node { + Id + } } } - } - Cases { - edges { - node { - Id + Cases { + edges { + node { + Id + } } } - } - Documents { - edges { - node { - Id + Documents { + edges { + node { + Id + } } } }