diff --git a/packages/eslint-plugin-query/src/rules/prefer-query-object-syntax/prefer-query-object-syntax.test.ts b/packages/eslint-plugin-query/src/rules/prefer-query-object-syntax/prefer-query-object-syntax.test.ts index ba28466eb7..c208d059da 100644 --- a/packages/eslint-plugin-query/src/rules/prefer-query-object-syntax/prefer-query-object-syntax.test.ts +++ b/packages/eslint-plugin-query/src/rules/prefer-query-object-syntax/prefer-query-object-syntax.test.ts @@ -48,6 +48,48 @@ ruleTester.run(name, rule, { const usePosts = () => useQuery(postsQuery); `, }, + { + code: normalizeIndent` + import { useQuery } from "@tanstack/react-query"; + const getQuery = () => ({ queryKey: ['foo'], queryFn: () => Promise.resolve(5) }) + useQuery(getQuery()) + `, + }, + { + code: normalizeIndent` + import { useQuery } from "@tanstack/react-query"; + const getQuery = () => { + return { queryKey: ['foo'], queryFn: () => Promise.resolve(5) }; + } + useQuery(getQuery()) + `, + }, + { + code: normalizeIndent` + import { useQuery } from "@tanstack/react-query"; + const getQuery = () => { + const queryKey = () => ['foo']; + const queryFn = () => { + return Promise.resolve(5); + } + return { queryKey, queryFn }; + } + useQuery(getQuery()) + `, + }, + { + code: normalizeIndent` + import { useQuery } from "@tanstack/react-query"; + const getQuery = () => { + try { + return { queryKey: ['foo'], queryFn: () => Promise.resolve(5) }; + } finally { + return { queryKey: ['foo'], queryFn: () => Promise.resolve(5) }; + } + } + useQuery(getQuery()) + `, + }, ], invalid: [ @@ -125,6 +167,104 @@ ruleTester.run(name, rule, { useQuery({ queryKey, queryFn, enabled }); `, }, + { + code: normalizeIndent` + import { useQuery } from "@tanstack/react-query"; + const getQuery = () => "foo"; + useQuery(getQuery()); + `, + errors: [ + { + messageId: 'returnTypeAreNotObjectSyntax', + data: { returnType: '"foo"' }, + }, + ], + }, + { + code: normalizeIndent` + import { useQuery } from "@tanstack/react-query"; + const getQuery = (x) => { + return x + ? { queryKey: "foo", queryFn: () => Promise.resolve(1) } + : null; + }; + useQuery(getQuery(x)); + `, + errors: [ + { + messageId: 'returnTypeAreNotObjectSyntax', + data: { + returnType: `x\n ? { queryKey: "foo", queryFn: () => Promise.resolve(1) }\n : null`, + }, + }, + ], + }, + { + code: normalizeIndent` + import { useQuery } from "@tanstack/react-query"; + const getQuery = (x) => { + try { + return { queryKey: "foo", queryFn: () => Promise.resolve(1) }; + } catch (e) { + if (x > 1) { + return { queryKey: "bar", queryFn: () => Promise.resolve(2) }; + } else { + return null; + } + } + }; + useQuery(getQuery(x)); + `, + errors: [ + { + messageId: 'returnTypeAreNotObjectSyntax', + data: { returnType: 'null' }, + }, + ], + }, + { + code: normalizeIndent` + import { useQuery } from "@tanstack/react-query"; + const getQuery = (x) => { + switch (x) { + case 1: + return { queryKey: "foo", queryFn: () => Promise.resolve(1) }; + default: + return null; + } + }; + useQuery(getQuery(x)); + `, + errors: [ + { + messageId: 'returnTypeAreNotObjectSyntax', + data: { returnType: 'null' }, + }, + ], + }, + { + code: normalizeIndent` + import { useQuery } from "@tanstack/react-query"; + const getQuery = (x, y) => { + if (x) { + return { queryKey: "foo", queryFn: () => Promise.resolve(1) }; + } else { + if (y) { + return { queryKey: "bar", queryFn: () => Promise.resolve(2) }; + } else { + return () => Promise.resolve(3); + } + } + }; + useQuery(getQuery(x)); + `, + errors: [ + { + messageId: 'returnTypeAreNotObjectSyntax', + data: { returnType: '() => Promise.resolve(3)' }, + }, + ], + }, { code: normalizeIndent` import { useQuery } from "@tanstack/react-query"; diff --git a/packages/eslint-plugin-query/src/rules/prefer-query-object-syntax/prefer-query-object-syntax.ts b/packages/eslint-plugin-query/src/rules/prefer-query-object-syntax/prefer-query-object-syntax.ts index f9ba4556cb..3ed86f6af9 100644 --- a/packages/eslint-plugin-query/src/rules/prefer-query-object-syntax/prefer-query-object-syntax.ts +++ b/packages/eslint-plugin-query/src/rules/prefer-query-object-syntax/prefer-query-object-syntax.ts @@ -1,129 +1,235 @@ -import type { TSESLint } from '@typescript-eslint/utils' +import type { TSESLint, TSESTree } from '@typescript-eslint/utils' +import { AST_NODE_TYPES } from '@typescript-eslint/utils' import { createRule } from '../../utils/create-rule' import { ASTUtils } from '../../utils/ast-utils' const QUERY_CALLS = ['useQuery', 'createQuery'] +const messages = { + preferObjectSyntax: `Objects syntax for query is preferred`, + returnTypeAreNotObjectSyntax: `Return type of query should be object syntax. Got {{returnType}} instead`, +} + +type MessageKey = keyof typeof messages + export const name = 'prefer-query-object-syntax' -export const rule = createRule({ - name, - meta: { - type: 'problem', - docs: { - description: 'Prefer object syntax for useQuery', - recommended: 'error', - }, - messages: { - preferObjectSyntax: `Objects syntax for useQuery is preferred`, +export const rule: TSESLint.RuleModule = + createRule({ + name, + meta: { + type: 'problem', + docs: { + description: 'Prefer object syntax for useQuery', + recommended: 'error', + }, + messages: messages, + fixable: 'code', + schema: [], }, - fixable: 'code', - schema: [], - }, - defaultOptions: [], - - create(context, _, helpers) { - const sourceCode = context.getSourceCode() - return { - CallExpression(node) { - const isUseQuery = - node.callee.type === 'Identifier' && - QUERY_CALLS.includes(node.callee.name) && - helpers.isReactQueryImport(node.callee) - if (!isUseQuery) { - return - } - - let firstArgument = node.arguments[0] - if (!firstArgument) { - return - } - - const reference = context - .getScope() - .references.find((ref) => ref.identifier === firstArgument) - - if ( - reference?.resolved?.defs[0]?.node.type === 'VariableDeclarator' && - reference.resolved.defs[0].node.init?.type === 'ObjectExpression' - ) { - firstArgument = reference.resolved.defs[0].node.init - } - - const hasFirstObjectArgument = firstArgument.type === 'ObjectExpression' - if (hasFirstObjectArgument) { - return - } - - const secondArgument = node.arguments[1] - const thirdArgument = node.arguments[2] - - const optionsObject = - secondArgument?.type === 'ObjectExpression' - ? secondArgument - : thirdArgument?.type === 'ObjectExpression' - ? thirdArgument - : undefined - - if ( - secondArgument && - !thirdArgument && - secondArgument !== optionsObject && - secondArgument.type === 'Identifier' - ) { - // Unable to determine if the secondArgument identifier is the options object or query fn. - // User has to fix the code manually. - context.report({ node, messageId: 'preferObjectSyntax' }) - return - } - - context.report({ - node, - messageId: 'preferObjectSyntax', - fix(fixer) { - const ruleFixes: TSESLint.RuleFix[] = [] - const optionsObjectProperties: string[] = [] - - // queryKey - const queryKey = sourceCode.getText(firstArgument) - const queryKeyProperty = - queryKey === 'queryKey' ? 'queryKey' : `queryKey: ${queryKey}` - optionsObjectProperties.push(queryKeyProperty) - - // queryFn - if (secondArgument && secondArgument !== optionsObject) { - const queryFn = sourceCode.getText(secondArgument) - const queryFnProperty = - queryFn === 'queryFn' ? 'queryFn' : `queryFn: ${queryFn}` - optionsObjectProperties.push(queryFnProperty) + defaultOptions: [], + + create(context, _, helpers) { + return { + CallExpression(node) { + const isTanstackQueryCall = + ASTUtils.isIdentifierWithOneOfNames(node.callee, QUERY_CALLS) && + helpers.isTanstackQueryImport(node.callee) + + if (!isTanstackQueryCall) { + return + } + + const firstArgument = node.arguments[0] + + if (!firstArgument) { + return + } + + if (firstArgument.type === AST_NODE_TYPES.CallExpression) { + const referencedCallExpression = + ASTUtils.getReferencedExpressionByIdentifier({ + context, + node: firstArgument.callee, + }) + + if ( + referencedCallExpression === null || + !ASTUtils.isNodeOfOneOf(referencedCallExpression, [ + AST_NODE_TYPES.ArrowFunctionExpression, + AST_NODE_TYPES.FunctionExpression, + ]) + ) { + return } - // options - if (optionsObject) { - const existingObjectProperties = optionsObject.properties.map( - (objectLiteral) => { - return sourceCode.getText(objectLiteral) + if ( + !ASTUtils.isNodeOfOneOf(referencedCallExpression.body, [ + AST_NODE_TYPES.BlockStatement, + AST_NODE_TYPES.ObjectExpression, + ]) + ) { + return context.report({ + node, + messageId: 'returnTypeAreNotObjectSyntax', + data: { + returnType: context + .getSourceCode() + .getText(referencedCallExpression.body), }, - ) - optionsObjectProperties.push(...existingObjectProperties) + }) } - const argumentsRange = ASTUtils.getRangeOfArguments(node) - if (argumentsRange) { - ruleFixes.push(fixer.removeRange(argumentsRange)) + const returnStmts = ASTUtils.getNestedReturnStatements( + referencedCallExpression, + ) + + for (const stmt of returnStmts) { + if (stmt.argument === null) { + return context.report({ + node, + messageId: 'returnTypeAreNotObjectSyntax', + data: { + returnType: 'void', + }, + }) + } + + runCheckOnNode({ + context: context, + callNode: node, + expression: stmt.argument, + messageId: 'returnTypeAreNotObjectSyntax', + }) } - ruleFixes.push( - fixer.insertTextAfterRange( - [node.range[0], node.range[1] - 1], - `{ ${optionsObjectProperties.join(', ')} }`, - ), + return + } + + if (firstArgument.type === AST_NODE_TYPES.Identifier) { + const referencedNode = ASTUtils.getReferencedExpressionByIdentifier( + { + context, + node: firstArgument, + }, ) - return ruleFixes - }, - }) + if (referencedNode?.type === AST_NODE_TYPES.ObjectExpression) { + return runCheckOnNode({ + context: context, + callNode: node, + expression: referencedNode, + messageId: 'preferObjectSyntax', + }) + } + } + + runCheckOnNode({ + context: context, + callNode: node, + expression: firstArgument, + messageId: 'preferObjectSyntax', + }) + }, + } + }, + }) + +function runCheckOnNode(params: { + context: Readonly> + callNode: TSESTree.CallExpression + expression: TSESTree.Node + messageId: MessageKey +}) { + const { context, expression, messageId, callNode } = params + const sourceCode = context.getSourceCode() + + if (expression.type === AST_NODE_TYPES.ObjectExpression) { + return + } + + const secondArgument = callNode.arguments[1] + const thirdArgument = callNode.arguments[2] + + const optionsObject = + secondArgument?.type === AST_NODE_TYPES.ObjectExpression + ? secondArgument + : thirdArgument?.type === AST_NODE_TYPES.ObjectExpression + ? thirdArgument + : undefined + + if ( + secondArgument && + !thirdArgument && + secondArgument !== optionsObject && + secondArgument.type === AST_NODE_TYPES.Identifier + ) { + // Unable to determine if the secondArgument identifier is the options object or query fn. + // User has to fix the code manually. + context.report({ node: callNode, messageId: messageId }) + return + } + + if (messageId === 'returnTypeAreNotObjectSyntax') { + context.report({ + node: callNode, + messageId: 'returnTypeAreNotObjectSyntax', + data: { + returnType: sourceCode.getText(expression), }, - } - }, -}) + }) + return + } + + context.report({ + node: callNode, + messageId: 'preferObjectSyntax', + fix(fixer) { + const ruleFixes: TSESLint.RuleFix[] = [] + const optionsObjectProperties: string[] = [] + + // queryKey + const firstArgument = callNode.arguments[0] + const queryKey = sourceCode.getText(firstArgument) + const queryKeyProperty = + queryKey === 'queryKey' ? 'queryKey' : `queryKey: ${queryKey}` + + optionsObjectProperties.push(queryKeyProperty) + + // queryFn + if (secondArgument && secondArgument !== optionsObject) { + const queryFn = sourceCode.getText(secondArgument) + const queryFnProperty = + queryFn === 'queryFn' ? 'queryFn' : `queryFn: ${queryFn}` + + optionsObjectProperties.push(queryFnProperty) + } + + // options + if (optionsObject) { + const existingObjectProperties = optionsObject.properties.map( + (objectLiteral) => { + return sourceCode.getText(objectLiteral) + }, + ) + + optionsObjectProperties.push(...existingObjectProperties) + } + + const argumentsRange = ASTUtils.getRangeOfArguments(callNode) + + if (argumentsRange) { + ruleFixes.push(fixer.removeRange(argumentsRange)) + } + + ruleFixes.push( + fixer.insertTextAfterRange( + [callNode.range[0], callNode.range[1] - 1], + `{ ${optionsObjectProperties.join(', ')} }`, + ), + ) + + return ruleFixes + }, + }) +} diff --git a/packages/eslint-plugin-query/src/utils/ast-utils.ts b/packages/eslint-plugin-query/src/utils/ast-utils.ts index 6badbd8a98..192718ab19 100644 --- a/packages/eslint-plugin-query/src/utils/ast-utils.ts +++ b/packages/eslint-plugin-query/src/utils/ast-utils.ts @@ -1,8 +1,15 @@ import type { TSESLint, TSESTree } from '@typescript-eslint/utils' import { AST_NODE_TYPES } from '@typescript-eslint/utils' +import type { RuleContext } from '@typescript-eslint/utils/dist/ts-eslint' import { uniqueBy } from './unique-by' export const ASTUtils = { + isNodeOfOneOf( + node: TSESTree.Node, + types: readonly T[], + ): node is TSESTree.Node & { type: T } { + return types.includes(node.type as T) + }, isIdentifier(node: TSESTree.Node): node is TSESTree.Identifier { return node.type === AST_NODE_TYPES.Identifier }, @@ -12,6 +19,12 @@ export const ASTUtils = { ): node is TSESTree.Identifier { return ASTUtils.isIdentifier(node) && node.name === name }, + isIdentifierWithOneOfNames( + node: TSESTree.Node, + name: string[], + ): node is TSESTree.Identifier { + return ASTUtils.isIdentifier(node) && name.includes(node.name) + }, isProperty(node: TSESTree.Node): node is TSESTree.Property { return node.type === AST_NODE_TYPES.Property }, @@ -156,4 +169,94 @@ export const ASTUtils = { ]), ) }, + getReferencedExpressionByIdentifier(params: { + node: TSESTree.Node + context: Readonly> + }) { + const { node, context } = params + + const resolvedNode = context + .getScope() + .references.find((ref) => ref.identifier === node)?.resolved + ?.defs[0]?.node + + if (resolvedNode?.type !== AST_NODE_TYPES.VariableDeclarator) { + return null + } + + return resolvedNode.init + }, + getNestedReturnStatements(node: TSESTree.Node): TSESTree.ReturnStatement[] { + const returnStatements: TSESTree.ReturnStatement[] = [] + + if (node.type === AST_NODE_TYPES.ReturnStatement) { + returnStatements.push(node) + } + + if ('body' in node && node.body !== undefined && node.body !== null) { + Array.isArray(node.body) + ? node.body.forEach((x) => { + returnStatements.push(...ASTUtils.getNestedReturnStatements(x)) + }) + : returnStatements.push( + ...ASTUtils.getNestedReturnStatements(node.body), + ) + } + + if ('consequent' in node) { + Array.isArray(node.consequent) + ? node.consequent.forEach((x) => { + returnStatements.push(...ASTUtils.getNestedReturnStatements(x)) + }) + : returnStatements.push( + ...ASTUtils.getNestedReturnStatements(node.consequent), + ) + } + + if ('alternate' in node && node.alternate !== null) { + Array.isArray(node.alternate) + ? node.alternate.forEach((x) => { + returnStatements.push(...ASTUtils.getNestedReturnStatements(x)) + }) + : returnStatements.push( + ...ASTUtils.getNestedReturnStatements(node.alternate), + ) + } + + if ('cases' in node) { + node.cases.forEach((x) => { + returnStatements.push(...ASTUtils.getNestedReturnStatements(x)) + }) + } + + if ('block' in node) { + returnStatements.push(...ASTUtils.getNestedReturnStatements(node.block)) + } + + if ('handler' in node && node.handler !== null) { + returnStatements.push(...ASTUtils.getNestedReturnStatements(node.handler)) + } + + if ('finalizer' in node && node.finalizer !== null) { + returnStatements.push( + ...ASTUtils.getNestedReturnStatements(node.finalizer), + ) + } + + if ( + 'expression' in node && + node.expression !== true && + node.expression !== false + ) { + returnStatements.push( + ...ASTUtils.getNestedReturnStatements(node.expression), + ) + } + + if ('test' in node && node.test !== null) { + returnStatements.push(...ASTUtils.getNestedReturnStatements(node.test)) + } + + return returnStatements + }, } diff --git a/packages/eslint-plugin-query/src/utils/create-rule.ts b/packages/eslint-plugin-query/src/utils/create-rule.ts index 358293fc63..f2aab9298b 100644 --- a/packages/eslint-plugin-query/src/utils/create-rule.ts +++ b/packages/eslint-plugin-query/src/utils/create-rule.ts @@ -1,6 +1,6 @@ import { ESLintUtils } from '@typescript-eslint/utils' import type { EnhancedCreate } from './detect-react-query-imports' -import { detectReactQueryImports } from './detect-react-query-imports' +import { detectTanstackQueryImports } from './detect-react-query-imports' const getDocsUrl = (ruleName: string): string => `https://tanstack.com/query/v4/docs/eslint/${ruleName}` @@ -15,6 +15,6 @@ type EslintRule = Omit< export function createRule({ create, ...rest }: EslintRule) { return ESLintUtils.RuleCreator(getDocsUrl)({ ...rest, - create: detectReactQueryImports(create), + create: detectTanstackQueryImports(create), }) } diff --git a/packages/eslint-plugin-query/src/utils/detect-react-query-imports.ts b/packages/eslint-plugin-query/src/utils/detect-react-query-imports.ts index a536871508..5964107c74 100644 --- a/packages/eslint-plugin-query/src/utils/detect-react-query-imports.ts +++ b/packages/eslint-plugin-query/src/utils/detect-react-query-imports.ts @@ -7,7 +7,7 @@ type Create = Parameters< type Context = Parameters[0] type Options = Parameters[1] type Helpers = { - isReactQueryImport: (node: TSESTree.Identifier) => boolean + isTanstackQueryImport: (node: TSESTree.Identifier) => boolean } export type EnhancedCreate = ( @@ -16,13 +16,13 @@ export type EnhancedCreate = ( helpers: Helpers, ) => ReturnType -export function detectReactQueryImports(create: EnhancedCreate): Create { +export function detectTanstackQueryImports(create: EnhancedCreate): Create { return (context, optionsWithDefault) => { - const reactQueryImportSpecifiers: TSESTree.ImportClause[] = [] + const tanstackQueryImportSpecifiers: TSESTree.ImportClause[] = [] const helpers: Helpers = { - isReactQueryImport(node) { - return !!reactQueryImportSpecifiers.find((specifier) => { + isTanstackQueryImport(node) { + return !!tanstackQueryImportSpecifiers.find((specifier) => { if (specifier.type === 'ImportSpecifier') { return node.name === specifier.local.name } @@ -38,7 +38,7 @@ export function detectReactQueryImports(create: EnhancedCreate): Create { node.source.value.startsWith('@tanstack/') && node.source.value.endsWith('-query') ) { - reactQueryImportSpecifiers.push(...node.specifiers) + tanstackQueryImportSpecifiers.push(...node.specifiers) } }, } diff --git a/packages/react-query/src/__tests__/useQuery.test.tsx b/packages/react-query/src/__tests__/useQuery.test.tsx index 6667f16139..40ae98a876 100644 --- a/packages/react-query/src/__tests__/useQuery.test.tsx +++ b/packages/react-query/src/__tests__/useQuery.test.tsx @@ -1024,7 +1024,7 @@ describe('useQuery', () => { return (
-
{state?.data}
+
{state.data}
)