diff --git a/eslint-plugin-drizzle/readme.md b/eslint-plugin-drizzle/readme.md index 1396cf2a7..7b405871a 100644 --- a/eslint-plugin-drizzle/readme.md +++ b/eslint-plugin-drizzle/readme.md @@ -169,3 +169,21 @@ const db = drizzle(...) // ---> Will be triggered by ESLint Rule db.update() ``` + + +**enforce-prepare-has-unique-name**: Enforce using a unique name for the `.prepare()` statement. This is useful when you have multiple `.prepare()` statements in your codebase and want to avoid accidental runtime errors. + +```json +"rules": { + "drizzle/enforce-prepare-has-unique-name": ["error"] +} +``` + +```ts +const db = drizzle(...) + +db.select().from(table).prepare('query1'); + +// ---> Will be triggered by ESLint Rule +db.select().from(table).prepare('query1'); +``` diff --git a/eslint-plugin-drizzle/src/configs/all.ts b/eslint-plugin-drizzle/src/configs/all.ts index 18093b5bf..941e135f7 100644 --- a/eslint-plugin-drizzle/src/configs/all.ts +++ b/eslint-plugin-drizzle/src/configs/all.ts @@ -1,14 +1,15 @@ export default { - env: { - es2024: true, - }, - parserOptions: { - ecmaVersion: 'latest', - sourceType: 'module', - }, - plugins: ['drizzle'], - rules: { - 'drizzle/enforce-delete-with-where': 'error', - 'drizzle/enforce-update-with-where': 'error', - }, + env: { + es2024: true, + }, + parserOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + }, + plugins: ['drizzle'], + rules: { + 'drizzle/enforce-delete-with-where': 'error', + 'drizzle/enforce-update-with-where': 'error', + 'drizzle/enforce-prepare-has-unique-name': 'error', + }, }; diff --git a/eslint-plugin-drizzle/src/configs/recommended.ts b/eslint-plugin-drizzle/src/configs/recommended.ts index 18093b5bf..941e135f7 100644 --- a/eslint-plugin-drizzle/src/configs/recommended.ts +++ b/eslint-plugin-drizzle/src/configs/recommended.ts @@ -1,14 +1,15 @@ export default { - env: { - es2024: true, - }, - parserOptions: { - ecmaVersion: 'latest', - sourceType: 'module', - }, - plugins: ['drizzle'], - rules: { - 'drizzle/enforce-delete-with-where': 'error', - 'drizzle/enforce-update-with-where': 'error', - }, + env: { + es2024: true, + }, + parserOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + }, + plugins: ['drizzle'], + rules: { + 'drizzle/enforce-delete-with-where': 'error', + 'drizzle/enforce-update-with-where': 'error', + 'drizzle/enforce-prepare-has-unique-name': 'error', + }, }; diff --git a/eslint-plugin-drizzle/src/enforce-prepare-has-unique-name.ts b/eslint-plugin-drizzle/src/enforce-prepare-has-unique-name.ts new file mode 100644 index 000000000..8a74a9aec --- /dev/null +++ b/eslint-plugin-drizzle/src/enforce-prepare-has-unique-name.ts @@ -0,0 +1,86 @@ +import { ESLintUtils, type TSESTree } from '@typescript-eslint/utils'; +import type { Options } from './utils/options'; + +const createRule = ESLintUtils.RuleCreator( + () => 'https://github.com/drizzle-team/eslint-plugin-drizzle' +); +type MessageIds = 'enforcePrepareHasUniqueName'; + +interface QueryLocation { + preparedName: string; + node: TSESTree.CallExpression; + filePath: string; + line: number; +} + +const updateRule = createRule({ + defaultOptions: [{ drizzleObjectName: [] }], + name: 'enforce-prepare-has-unique-name', + meta: { + type: 'problem', + docs: { + description: + 'Enforce that `prepare` method is called with a unique `name` to avoid a runtime error', + }, + fixable: 'code', + messages: { + enforcePrepareHasUniqueName: + 'Prepared statements `.prepare(...)` require a unique name. The name "{{preparedName}}" is also used at {{location}}', + }, + schema: [ + { + type: 'object', + properties: { + drizzleObjectName: { + type: ['string', 'array'], + }, + }, + additionalProperties: false, + }, + ], + }, + create(context, _options) { + const preparedStatementNames = new Map(); + + return { + CallExpression(node) { + if ( + node.callee.type === 'MemberExpression' && + node.callee.property && + node.callee.property.type === 'Identifier' && + node.callee.property.name === 'prepare' && + node.arguments.length === 1 && + node.arguments[0]?.type === 'Literal' + ) { + const preparedName = node.arguments[0].value as string; + const filePath = context.getFilename(); + const line = node.loc ? node.loc.start.line : 0; + + const collidingLocation = preparedStatementNames.get(preparedName); + + if (collidingLocation) { + for (const location of collidingLocation) { + const messageData = { + location: `${location.filePath}:${location.line}`, + preparedName, + }; + context.report({ + node, + messageId: 'enforcePrepareHasUniqueName', + data: messageData, + }); + } + collidingLocation.push({ preparedName, node, filePath, line }); + } else { + preparedStatementNames.set(preparedName, [ + { preparedName, node, filePath, line }, + ]); + } + } + return; + }, + }; + }, +}); + +export default updateRule; diff --git a/eslint-plugin-drizzle/src/index.ts b/eslint-plugin-drizzle/src/index.ts index 15ded747f..1ade5f823 100644 --- a/eslint-plugin-drizzle/src/index.ts +++ b/eslint-plugin-drizzle/src/index.ts @@ -4,11 +4,13 @@ import all from './configs/all'; import recommended from './configs/recommended'; import deleteRule from './enforce-delete-with-where'; import updateRule from './enforce-update-with-where'; +import prepareRule from './enforce-prepare-has-unique-name'; import type { Options } from './utils/options'; export const rules = { - 'enforce-delete-with-where': deleteRule, - 'enforce-update-with-where': updateRule, + 'enforce-delete-with-where': deleteRule, + 'enforce-update-with-where': updateRule, + 'enforce-prepare-has-unique-name': prepareRule, } satisfies Record>; export const configs = { all, recommended }; diff --git a/eslint-plugin-drizzle/tests/prepare.test.ts b/eslint-plugin-drizzle/tests/prepare.test.ts new file mode 100644 index 000000000..78f26a0af --- /dev/null +++ b/eslint-plugin-drizzle/tests/prepare.test.ts @@ -0,0 +1,96 @@ +// @ts-ignore +import { RuleTester } from '@typescript-eslint/rule-tester'; + +import rule from '../src/enforce-prepare-has-unique-name'; + +const ruleTester = new RuleTester(); + +ruleTester.run('enforce-prepare-has-unique-name', rule, { + valid: [ + { + code: ` + drizzle.select().from(table).prepare('query1'); + drizzle.select().from(table).prepare('query2'); + `, + }, + { + code: ` + db.select().from(table).prepare('query1'); + db.select().from(table).prepare('query2'); + `, + options: [{ drizzleObjectName: ['db'] }], + }, + { + code: ` + drizzle.select().from(table).prepare('query1'); + db.select().from(table).prepare('query2'); + `, + options: [{ drizzleObjectName: ['drizzle', 'db'] }], + }, + ], + invalid: [ + { + code: ` + drizzle.select().from(table).prepare('query1'); + drizzle.select().from(table).prepare('query1'); + `, + // test without options + errors: [ + { + messageId: 'enforcePrepareHasUniqueName', + data: { + preparedName: 'query1', + location: 'file.ts:2', + }, + }, + ], + }, + { + code: ` + drizzle.select().from(table).prepare('query1'); + drizzle.select().from(table).prepare('query1'); + `, + options: [{ drizzleObjectName: ['drizzle'] }], + errors: [ + { + messageId: 'enforcePrepareHasUniqueName', + data: { + preparedName: 'query1', + location: 'file.ts:2', + }, + }, + ], + }, + { + code: ` + db.select().from(table).prepare('query1'); + db.select().from(table).prepare('query2'); + db.select().from(table).prepare('query1'); + `, + errors: [ + { + messageId: 'enforcePrepareHasUniqueName', + data: { + preparedName: 'query1', + location: 'file.ts:2', + }, + }, + ], + }, + { + code: ` + drizzle.select().from(table).prepare('query1'); + db.select().from(table).prepare('query1'); + `, + errors: [ + { + messageId: 'enforcePrepareHasUniqueName', + data: { + preparedName: 'query1', + location: 'file.ts:2', + }, + }, + ], + }, + ], +});