diff --git a/src/rules/typed-input.ts b/src/rules/typed-input.ts index e2bd0c5..23bccde 100644 --- a/src/rules/typed-input.ts +++ b/src/rules/typed-input.ts @@ -1,6 +1,6 @@ import { ESLintUtils, TSESTree, ASTUtils } from "@typescript-eslint/utils"; import { RuleOptions } from "../ruleOptions.js"; -import { stringifyNode } from "../utils.js"; +import { getQueryValue, stringifyNode } from "../utils.js"; import { inferQueryInput, QueryInput } from "../inferQueryInput.js"; export function createTypedInputRule(options: RuleOptions) { @@ -13,7 +13,7 @@ export function createTypedInputRule(options: RuleOptions) { callee: TSESTree.MemberExpression; }, ) { - const val = ASTUtils.getStaticValue( + const val = getQueryValue( node.arguments[0], context.sourceCode.getScope(node.arguments[0]), ); diff --git a/src/rules/typed-result.ts b/src/rules/typed-result.ts index e52dcd3..c8dcf15 100644 --- a/src/rules/typed-result.ts +++ b/src/rules/typed-result.ts @@ -5,7 +5,7 @@ import { inferQueryResult, } from "../inferQueryResult.js"; import { RuleOptions } from "../ruleOptions.js"; -import { stringifyNode } from "../utils.js"; +import { getQueryValue, stringifyNode } from "../utils.js"; type ColumnInfoWithUserType = ColumnInfo & { userTSTypeAnnotation?: string }; @@ -19,7 +19,7 @@ export function createTypedResultRule(options: RuleOptions) { callee: TSESTree.MemberExpression; }, ) { - const val = ASTUtils.getStaticValue( + const val = getQueryValue( node.arguments[0], context.sourceCode.getScope(node.arguments[0]), ); diff --git a/src/rules/valid-query.ts b/src/rules/valid-query.ts index 9bdbb63..cc64ab9 100644 --- a/src/rules/valid-query.ts +++ b/src/rules/valid-query.ts @@ -1,6 +1,6 @@ -import { ESLintUtils, TSESTree, ASTUtils } from "@typescript-eslint/utils"; +import { ESLintUtils, TSESTree } from "@typescript-eslint/utils"; import { RuleOptions } from "../ruleOptions.js"; -import { stringifyNode } from "../utils.js"; +import { getQueryValue, stringifyNode } from "../utils.js"; export function createValidQueryRule(options: RuleOptions) { return ESLintUtils.RuleCreator.withoutDocs({ @@ -14,10 +14,7 @@ export function createValidQueryRule(options: RuleOptions) { ) { const arg = node.arguments[0]; - const val = ASTUtils.getStaticValue( - arg, - context.sourceCode.getScope(arg), - ); + const val = getQueryValue(arg, context.sourceCode.getScope(arg)); if (!val) { context.report({ diff --git a/src/utils.ts b/src/utils.ts index 71ea2f7..6b44676 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,4 +1,4 @@ -import { TSESTree } from "@typescript-eslint/utils"; +import { ASTUtils, TSESLint, TSESTree } from "@typescript-eslint/utils"; export function stringifyNode( node: TSESTree.Expression | TSESTree.PrivateIdentifier, @@ -21,3 +21,103 @@ export function stringifyNode( } } } + +/** + * Attempts to get a query value from a given CallExpression argument. + */ +export function getQueryValue( + arg: TSESTree.CallExpression["arguments"][0], + scope: TSESLint.Scope.Scope, +): { value: unknown } | null { + const value = ASTUtils.getStaticValue(arg, scope); + if (value) { + return value; + } + + if (arg.type === TSESTree.AST_NODE_TYPES.TemplateLiteral) { + if (arg.expressions.every((expr) => isVariableParameterExpression(expr))) { + return { + value: arg.quasis.map((quasi) => quasi.value.cooked).join("?"), + }; + } + + return null; + } + + if (arg.type === TSESTree.AST_NODE_TYPES.Identifier) { + const variable = ASTUtils.findVariable(scope, arg); + const def = variable?.defs[0]; + if ( + variable?.defs.length === 1 && + def?.type === TSESLint.Scope.DefinitionType.Variable && + def.node.id.type === TSESTree.AST_NODE_TYPES.Identifier && + def.parent.kind === "const" && + def.node.init + ) { + return getQueryValue(def.node.init, scope); + } + return null; + } + + return null; +} + +/** + * Checks if the expression looks like `foo.map(() => '?').join(',')` + */ +function isVariableParameterExpression(expr: TSESTree.Expression) { + if (expr.type !== TSESTree.AST_NODE_TYPES.CallExpression || expr.optional) { + return false; + } + + if (expr.callee.type !== TSESTree.AST_NODE_TYPES.MemberExpression) { + return false; + } + + if (!expr.arguments[0]) { + return false; + } + + if (ASTUtils.getPropertyName(expr.callee) !== "join") { + return false; + } + + const joinValue = ASTUtils.getStaticValue(expr.arguments[0]); + if (typeof joinValue?.value !== "string" || joinValue.value.trim() !== ",") { + return false; + } + + if ( + expr.callee.object.type !== TSESTree.AST_NODE_TYPES.CallExpression || + expr.callee.object.optional + ) { + return false; + } + + const maybeMapExpr = expr.callee.object; + + if (maybeMapExpr.callee.type !== TSESTree.AST_NODE_TYPES.MemberExpression) { + return false; + } + + if (ASTUtils.getPropertyName(maybeMapExpr.callee) !== "map") { + return false; + } + + if (!maybeMapExpr.arguments[0]) { + return false; + } + + const maybeCallback = maybeMapExpr.arguments[0]; + + if (maybeCallback.type !== TSESTree.AST_NODE_TYPES.ArrowFunctionExpression) { + return false; + } + + const mapValue = ASTUtils.getStaticValue(maybeCallback.body); + if (typeof mapValue?.value !== "string" || mapValue.value.trim() !== "?") { + return false; + } + + return true; +} diff --git a/tests/rules/typed-input.test.ts b/tests/rules/typed-input.test.ts index fc4b984..00cf036 100644 --- a/tests/rules/typed-input.test.ts +++ b/tests/rules/typed-input.test.ts @@ -48,6 +48,7 @@ ruleTester.run("typed-result", rule, { 'db.prepare<[unknown, {"userID": unknown}]>("SELECT * FROM users WHERE id = :userID or name = ?")', 'db.prepare<[unknown, {"userID": string}]>("SELECT * FROM users WHERE id = :userID or name = ?")', 'db.prepare<{"userID": string}>("SELECT * FROM users WHERE id = :userID")', + 'db.prepare<[unknown]>(`SELECT * FROM users WHERE id IN (${foo.map(() => "?").join(",")})`)', ], invalid: [ // No parameters @@ -151,5 +152,12 @@ ruleTester.run("typed-result", rule, { 'db.prepare<{"userID": string}>("SELECT * FROM users WHERE id = :userID")', errors: [{ messageId: "incorrectInputType" }], }, + // Variable input parameters + { + code: 'db.prepare(`SELECT * FROM users WHERE id IN (${foo.map(() => "?").join(",")})`)', + output: + 'db.prepare<[unknown]>(`SELECT * FROM users WHERE id IN (${foo.map(() => "?").join(",")})`)', + errors: [{ messageId: "missingInputType" }], + }, ], }); diff --git a/tests/rules/typed-result.test.ts b/tests/rules/typed-result.test.ts index f38485b..9cdedc0 100644 --- a/tests/rules/typed-result.test.ts +++ b/tests/rules/typed-result.test.ts @@ -67,6 +67,7 @@ ruleTester.run("typed-result", rule, { `db.prepare<[], {"name": number | string | Buffer | null}>("SELECT name FROM test")`, `db.prepare<[]>("DELETE FROM foo")`, `db.prepare<[], {"random()": (foo | number), "id": number}>("SELECT random(), id FROM users")`, + 'db.prepare<[], {"name": string}>(`SELECT name FROM users WHERE id IN (${foo.map(() => "?").join(",")})`)', ], invalid: [ // Query as string Literal @@ -189,5 +190,12 @@ ruleTester.run("typed-result", rule, { errors: [{ messageId: "incorrectResultType" }], output: `db.prepare<[], {"random()": (foo | number), "id": number}>("SELECT random(), id FROM users")`, }, + // Variable input parameters + { + code: 'db.prepare(`SELECT name FROM users WHERE id IN (${foo.map(() => "?").join(",")})`)', + output: + 'db.prepare<[], {"name": string}>(`SELECT name FROM users WHERE id IN (${foo.map(() => "?").join(",")})`)', + errors: [{ messageId: "missingResultType" }], + }, ], }); diff --git a/tests/rules/valid-query.test.ts b/tests/rules/valid-query.test.ts index 2a58828..4a9cec1 100644 --- a/tests/rules/valid-query.test.ts +++ b/tests/rules/valid-query.test.ts @@ -34,6 +34,8 @@ ruleTester.run("valid-query", rule, { "db_users.prepare('SELECT * FROM users')", "nested.db.users.prepare('SELECT * FROM users')", "db.prepare('DELETE FROM foo')", + "db_users.prepare(`SELECT * FROM users WHERE id IN (${ids.map(() => '?').join(',')})`);", + "const query = `SELECT * FROM users WHERE id IN (${ids.map(() => '?').join(',')})`;db_users.prepare(query);", ], invalid: [ { @@ -88,5 +90,27 @@ ruleTester.run("valid-query", rule, { }, ], }, + { + code: "db_users.prepare(`SELECT * FROM user WHERE id IN (${ids.map(() => '?').join(',')})`);", + errors: [ + { + messageId: "invalidQuery", + data: { + message: `no such table: user`, + }, + }, + ], + }, + { + code: "const query = `SELECT * FROM user WHERE id IN (${ids.map(() => '?').join(',')})`;db_users.prepare(query);", + errors: [ + { + messageId: "invalidQuery", + data: { + message: `no such table: user`, + }, + }, + ], + }, ], });