Skip to content

Commit

Permalink
feat(eslint-plugin-query): add rule to ensure property order of infin…
Browse files Browse the repository at this point in the history
…ite query functions (#8072)

* feat(eslint-plugin-query): add rule to ensure property order of infinite query functions

* remove outdated comment

---------

Co-authored-by: Dominik Dorfmeister <[email protected]>
  • Loading branch information
schiller-manuel and TkDodo authored Sep 20, 2024
1 parent ca9e3c4 commit f8d65fb
Show file tree
Hide file tree
Showing 14 changed files with 728 additions and 30 deletions.
4 changes: 4 additions & 0 deletions docs/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -796,6 +796,10 @@
{
"label": "No Unstable Deps",
"to": "eslint/no-unstable-deps"
},
{
"label": "Infinite Query Property Order",
"to": "eslint/infinite-query-property-order"
}
]
},
Expand Down
63 changes: 63 additions & 0 deletions docs/eslint/infinite-query-property-order.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
---
id: infinite-query-property-order
title: Ensure correct order of inference sensitive properties for infinite queries
---

For the following functions, the property order of the passed in object matters due to type inference:

- `useInfiniteQuery`
- `useSuspenseInfiniteQuery`
- `infiniteQueryOptions`

The correct property order is as follows:

- `queryFn`
- `getPreviousPageParam`
- `getNextPageParam`

All other properties are insensitive to the order as they do not depend on type inference.

## Rule Details

Examples of **incorrect** code for this rule:

```tsx
/* eslint "@tanstack/query/infinite-query-property-order": "warn" */
import { useInfiniteQuery } from '@tanstack/react-query'

const query = useInfiniteQuery({
queryKey: ['projects'],
getNextPageParam: (lastPage) => lastPage.nextId ?? undefined,
queryFn: async ({ pageParam }) => {
const response = await fetch(`/api/projects?cursor=${pageParam}`)
return await response.json()
},
initialPageParam: 0,
getPreviousPageParam: (firstPage) => firstPage.previousId ?? undefined,
maxPages: 3,
})
```

Examples of **correct** code for this rule:

```tsx
/* eslint "@tanstack/query/infinite-query-property-order": "warn" */
import { useInfiniteQuery } from '@tanstack/react-query'

const query = useInfiniteQuery({
queryKey: ['projects'],
queryFn: async ({ pageParam }) => {
const response = await fetch(`/api/projects?cursor=${pageParam}`)
return await response.json()
},
initialPageParam: 0,
getPreviousPageParam: (firstPage) => firstPage.previousId ?? undefined,
getNextPageParam: (lastPage) => lastPage.nextId ?? undefined,
maxPages: 3,
})
```

## Attributes

- [x] ✅ Recommended
- [x] 🔧 Fixable
1 change: 1 addition & 0 deletions packages/eslint-plugin-query/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
},
"devDependencies": {
"@typescript-eslint/rule-tester": "^8.3.0",
"combinate": "^1.1.11",
"eslint": "^9.9.1"
},
"peerDependencies": {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { RuleTester } from '@typescript-eslint/rule-tester'
import combinate from 'combinate'

import {
checkedProperties,
infiniteQueryFunctions,
} from '../rules/infinite-query-property-order/constants'
import {
name,
rule,
} from '../rules/infinite-query-property-order/infinite-query-property-order.rule'
import {
generateInterleavedCombinations,
generatePartialCombinations,
generatePermutations,
} from './test-utils'
import type { InfiniteQueryFunctions } from '../rules/infinite-query-property-order/constants'

const ruleTester = new RuleTester()

type CheckedProperties = (typeof checkedProperties)[number]
const orderIndependentProps = ['queryKey', '...foo'] as const
type OrderIndependentProps = (typeof orderIndependentProps)[number]

interface TestCase {
infiniteQueryFunction: InfiniteQueryFunctions
properties: Array<CheckedProperties | OrderIndependentProps>
}

const validTestMatrix = combinate({
infiniteQueryFunction: [...infiniteQueryFunctions],
properties: generatePartialCombinations(checkedProperties, 2),
})

export function generateInvalidPermutations<T>(
arr: ReadonlyArray<T>,
): Array<{ invalid: Array<T>; valid: Array<T> }> {
const combinations = generatePartialCombinations(arr, 2)
const allPermutations: Array<{ invalid: Array<T>; valid: Array<T> }> = []

for (const combination of combinations) {
const permutations = generatePermutations(combination)
// skip the first permutation as it matches the original combination
const invalidPermutations = permutations.slice(1)
allPermutations.push(
...invalidPermutations.map((p) => ({ invalid: p, valid: combination })),
)
}

return allPermutations
}

const invalidPermutations = generateInvalidPermutations(checkedProperties)

type Interleaved = CheckedProperties | OrderIndependentProps
const interleavedInvalidPermutations: Array<{
invalid: Array<Interleaved>
valid: Array<Interleaved>
}> = []
for (const invalidPermutation of invalidPermutations) {
const invalid = generateInterleavedCombinations(
invalidPermutation.invalid,
orderIndependentProps,
)
const valid = generateInterleavedCombinations(
invalidPermutation.valid,
orderIndependentProps,
)

for (let i = 0; i < invalid.length; i++) {
interleavedInvalidPermutations.push({
invalid: invalid[i]!,
valid: valid[i]!,
})
}
}

const invalidTestMatrix = combinate({
infiniteQueryFunction: [...infiniteQueryFunctions],
properties: interleavedInvalidPermutations,
})

function getCode({
infiniteQueryFunction: infiniteQueryFunction,
properties,
}: TestCase) {
function getPropertyCode(
property: CheckedProperties | OrderIndependentProps,
) {
if (property.startsWith('...')) {
return property
}
switch (property) {
case 'queryKey':
return `queryKey: ['projects']`
case 'queryFn':
return 'queryFn: async ({ pageParam }) => { \n await fetch(`/api/projects?cursor=${pageParam}`) \n return await response.json() \n }'
case 'getPreviousPageParam':
return 'getPreviousPageParam: (firstPage) => firstPage.previousId ?? undefined'
case 'getNextPageParam':
return 'getNextPageParam: (lastPage) => lastPage.nextId ?? undefined'
}

return `${property}: () => null`
}
return `
import { ${infiniteQueryFunction} } from '@tanstack/react-query'
${infiniteQueryFunction}({
${properties.map(getPropertyCode).join(',\n ')}
})
`
}

const validTestCases = validTestMatrix.map(
({ infiniteQueryFunction, properties }) => ({
name: `should pass when order is correct for ${infiniteQueryFunction} with order: ${properties.join(', ')}`,
code: getCode({ infiniteQueryFunction, properties }),
}),
)

const invalidTestCases = invalidTestMatrix.map(
({ infiniteQueryFunction, properties }) => ({
name: `incorrect property order is detected for ${infiniteQueryFunction} with order: ${properties.invalid.join(', ')}`,
code: getCode({
infiniteQueryFunction: infiniteQueryFunction,
properties: properties.invalid,
}),
errors: [{ messageId: 'invalidOrder' }],
output: getCode({
infiniteQueryFunction: infiniteQueryFunction,
properties: properties.valid,
}),
}),
)

ruleTester.run(name, rule, {
valid: validTestCases,
invalid: invalidTestCases,
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { describe, expect, test } from 'vitest'
import { sortDataByOrder } from '../rules/infinite-query-property-order/infinite-query-property-order.utils'

describe('create-route-property-order utils', () => {
describe('sortDataByOrder', () => {
const testCases = [
{
data: [{ key: 'a' }, { key: 'c' }, { key: 'b' }],
orderArray: ['a', 'b', 'c'],
key: 'key',
expected: [{ key: 'a' }, { key: 'b' }, { key: 'c' }],
},
{
data: [{ key: 'b' }, { key: 'a' }, { key: 'c' }],
orderArray: ['a', 'b', 'c'],
key: 'key',
expected: [{ key: 'a' }, { key: 'b' }, { key: 'c' }],
},
{
data: [{ key: 'a' }, { key: 'b' }, { key: 'c' }],
orderArray: ['a', 'b', 'c'],
key: 'key',
expected: null,
},
{
data: [{ key: 'a' }, { key: 'b' }, { key: 'c' }, { key: 'd' }],
orderArray: ['a', 'b', 'c'],
key: 'key',
expected: null,
},
{
data: [{ key: 'a' }, { key: 'b' }, { key: 'd' }, { key: 'c' }],
orderArray: ['a', 'b', 'c'],
key: 'key',
expected: null,
},
{
data: [{ key: 'd' }, { key: 'a' }, { key: 'b' }, { key: 'c' }],
orderArray: ['a', 'b', 'c'],
key: 'key',
expected: null,
},
{
data: [{ key: 'd' }, { key: 'b' }, { key: 'a' }, { key: 'c' }],
orderArray: ['a', 'b', 'c'],
key: 'key',
expected: [{ key: 'd' }, { key: 'a' }, { key: 'b' }, { key: 'c' }],
},
] as const
test.each(testCases)(
'$data $orderArray $key $expected',
({ data, orderArray, key, expected }) => {
const sortedData = sortDataByOrder(data, orderArray, key)
expect(sortedData).toEqual(expected)
},
)
})
})
104 changes: 104 additions & 0 deletions packages/eslint-plugin-query/src/__tests__/test-utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { describe, expect, test } from 'vitest'
import {
expectArrayEqualIgnoreOrder,
generateInterleavedCombinations,
generatePartialCombinations,
generatePermutations,
} from './test-utils'

describe('test-utils', () => {
describe('generatePermutations', () => {
const testCases = [
{
input: ['a', 'b', 'c'],
expected: [
['a', 'b', 'c'],
['a', 'c', 'b'],
['b', 'a', 'c'],
['b', 'c', 'a'],
['c', 'a', 'b'],
['c', 'b', 'a'],
],
},
{
input: ['a', 'b'],
expected: [
['a', 'b'],
['b', 'a'],
],
},
{
input: ['a'],
expected: [['a']],
},
]
test.each(testCases)('$input $expected', ({ input, expected }) => {
const permutations = generatePermutations(input)
expect(permutations).toEqual(expected)
})
})

describe('generatePartialCombinations', () => {
const testCases = [
{
input: ['a', 'b', 'c'],
minLength: 2,
expected: [
['a', 'b'],
['a', 'c'],
['b', 'c'],
['a', 'b', 'c'],
],
},
{
input: ['a', 'b'],
expected: [['a', 'b']],
minLength: 2,
},
{
input: ['a'],
expected: [],
minLength: 2,
},
{
input: ['a'],
expected: [['a']],
minLength: 1,
},
{
input: ['a'],
expected: [[], ['a']],
minLength: 0,
},
]
test.each(testCases)(
'$input $minLength $expected',
({ input, minLength, expected }) => {
const combinations = generatePartialCombinations(input, minLength)
expectArrayEqualIgnoreOrder(combinations, expected)
},
)
})

describe('generateInterleavedCombinations', () => {
const testCases = [
{
data: ['a', 'b'],
additional: ['x'],
expected: [
['a', 'b'],
['x', 'a', 'b'],
['a', 'x', 'b'],
['a', 'b', 'x'],
],
},
]
test.each(testCases)(
'$input $expected',
({ data, additional, expected }) => {
const combinations = generateInterleavedCombinations(data, additional)
expectArrayEqualIgnoreOrder(combinations, expected)
},
)
})
})
Loading

0 comments on commit f8d65fb

Please sign in to comment.