Skip to content

Commit adfaab4

Browse files
committed
feat: support variable input parameters
1 parent ed88d9f commit adfaab4

File tree

7 files changed

+148
-11
lines changed

7 files changed

+148
-11
lines changed

src/rules/typed-input.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { ESLintUtils, TSESTree, ASTUtils } from "@typescript-eslint/utils";
22
import { RuleOptions } from "../ruleOptions.js";
3-
import { stringifyNode } from "../utils.js";
3+
import { getQueryValue, stringifyNode } from "../utils.js";
44
import { inferQueryInput, QueryInput } from "../inferQueryInput.js";
55

66
export function createTypedInputRule(options: RuleOptions) {
@@ -13,7 +13,7 @@ export function createTypedInputRule(options: RuleOptions) {
1313
callee: TSESTree.MemberExpression;
1414
},
1515
) {
16-
const val = ASTUtils.getStaticValue(
16+
const val = getQueryValue(
1717
node.arguments[0],
1818
context.sourceCode.getScope(node.arguments[0]),
1919
);

src/rules/typed-result.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {
55
inferQueryResult,
66
} from "../inferQueryResult.js";
77
import { RuleOptions } from "../ruleOptions.js";
8-
import { stringifyNode } from "../utils.js";
8+
import { getQueryValue, stringifyNode } from "../utils.js";
99

1010
type ColumnInfoWithUserType = ColumnInfo & { userTSTypeAnnotation?: string };
1111

@@ -19,7 +19,7 @@ export function createTypedResultRule(options: RuleOptions) {
1919
callee: TSESTree.MemberExpression;
2020
},
2121
) {
22-
const val = ASTUtils.getStaticValue(
22+
const val = getQueryValue(
2323
node.arguments[0],
2424
context.sourceCode.getScope(node.arguments[0]),
2525
);

src/rules/valid-query.ts

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import { ESLintUtils, TSESTree, ASTUtils } from "@typescript-eslint/utils";
1+
import { ESLintUtils, TSESTree } from "@typescript-eslint/utils";
22
import { RuleOptions } from "../ruleOptions.js";
3-
import { stringifyNode } from "../utils.js";
3+
import { getQueryValue, stringifyNode } from "../utils.js";
44

55
export function createValidQueryRule(options: RuleOptions) {
66
return ESLintUtils.RuleCreator.withoutDocs({
@@ -14,10 +14,7 @@ export function createValidQueryRule(options: RuleOptions) {
1414
) {
1515
const arg = node.arguments[0];
1616

17-
const val = ASTUtils.getStaticValue(
18-
arg,
19-
context.sourceCode.getScope(arg),
20-
);
17+
const val = getQueryValue(arg, context.sourceCode.getScope(arg));
2118

2219
if (!val) {
2320
context.report({

src/utils.ts

Lines changed: 101 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { TSESTree } from "@typescript-eslint/utils";
1+
import { ASTUtils, TSESLint, TSESTree } from "@typescript-eslint/utils";
22

33
export function stringifyNode(
44
node: TSESTree.Expression | TSESTree.PrivateIdentifier,
@@ -21,3 +21,103 @@ export function stringifyNode(
2121
}
2222
}
2323
}
24+
25+
/**
26+
* Attempts to get a query value from a given CallExpression argument.
27+
*/
28+
export function getQueryValue(
29+
arg: TSESTree.CallExpression["arguments"][0],
30+
scope: TSESLint.Scope.Scope,
31+
): { value: unknown } | null {
32+
const value = ASTUtils.getStaticValue(arg, scope);
33+
if (value) {
34+
return value;
35+
}
36+
37+
if (arg.type === TSESTree.AST_NODE_TYPES.TemplateLiteral) {
38+
if (arg.expressions.every((expr) => isVariableParameterExpression(expr))) {
39+
return {
40+
value: arg.quasis.map((quasi) => quasi.value.cooked).join("?"),
41+
};
42+
}
43+
44+
return null;
45+
}
46+
47+
if (arg.type === TSESTree.AST_NODE_TYPES.Identifier) {
48+
const variable = ASTUtils.findVariable(scope, arg);
49+
const def = variable?.defs[0];
50+
if (
51+
variable?.defs.length === 1 &&
52+
def?.type === TSESLint.Scope.DefinitionType.Variable &&
53+
def.node.id.type === TSESTree.AST_NODE_TYPES.Identifier &&
54+
def.parent.kind === "const" &&
55+
def.node.init
56+
) {
57+
return getQueryValue(def.node.init, scope);
58+
}
59+
return null;
60+
}
61+
62+
return null;
63+
}
64+
65+
/**
66+
* Checks if the expression looks like `foo.map(() => '?').join(',')`
67+
*/
68+
function isVariableParameterExpression(expr: TSESTree.Expression) {
69+
if (expr.type !== TSESTree.AST_NODE_TYPES.CallExpression || expr.optional) {
70+
return false;
71+
}
72+
73+
if (expr.callee.type !== TSESTree.AST_NODE_TYPES.MemberExpression) {
74+
return false;
75+
}
76+
77+
if (!expr.arguments[0]) {
78+
return false;
79+
}
80+
81+
if (ASTUtils.getPropertyName(expr.callee) !== "join") {
82+
return false;
83+
}
84+
85+
const joinValue = ASTUtils.getStaticValue(expr.arguments[0]);
86+
if (typeof joinValue?.value !== "string" || joinValue.value.trim() !== ",") {
87+
return false;
88+
}
89+
90+
if (
91+
expr.callee.object.type !== TSESTree.AST_NODE_TYPES.CallExpression ||
92+
expr.callee.object.optional
93+
) {
94+
return false;
95+
}
96+
97+
const maybeMapExpr = expr.callee.object;
98+
99+
if (maybeMapExpr.callee.type !== TSESTree.AST_NODE_TYPES.MemberExpression) {
100+
return false;
101+
}
102+
103+
if (ASTUtils.getPropertyName(maybeMapExpr.callee) !== "map") {
104+
return false;
105+
}
106+
107+
if (!maybeMapExpr.arguments[0]) {
108+
return false;
109+
}
110+
111+
const maybeCallback = maybeMapExpr.arguments[0];
112+
113+
if (maybeCallback.type !== TSESTree.AST_NODE_TYPES.ArrowFunctionExpression) {
114+
return false;
115+
}
116+
117+
const mapValue = ASTUtils.getStaticValue(maybeCallback.body);
118+
if (typeof mapValue?.value !== "string" || mapValue.value.trim() !== "?") {
119+
return false;
120+
}
121+
122+
return true;
123+
}

tests/rules/typed-input.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ ruleTester.run("typed-result", rule, {
4848
'db.prepare<[unknown, {"userID": unknown}]>("SELECT * FROM users WHERE id = :userID or name = ?")',
4949
'db.prepare<[unknown, {"userID": string}]>("SELECT * FROM users WHERE id = :userID or name = ?")',
5050
'db.prepare<{"userID": string}>("SELECT * FROM users WHERE id = :userID")',
51+
'db.prepare<[unknown]>(`SELECT * FROM users WHERE id IN (${foo.map(() => "?").join(",")})`)',
5152
],
5253
invalid: [
5354
// No parameters
@@ -151,5 +152,12 @@ ruleTester.run("typed-result", rule, {
151152
'db.prepare<{"userID": string}>("SELECT * FROM users WHERE id = :userID")',
152153
errors: [{ messageId: "incorrectInputType" }],
153154
},
155+
// Variable input parameters
156+
{
157+
code: 'db.prepare(`SELECT * FROM users WHERE id IN (${foo.map(() => "?").join(",")})`)',
158+
output:
159+
'db.prepare<[unknown]>(`SELECT * FROM users WHERE id IN (${foo.map(() => "?").join(",")})`)',
160+
errors: [{ messageId: "missingInputType" }],
161+
},
154162
],
155163
});

tests/rules/typed-result.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ ruleTester.run("typed-result", rule, {
6767
`db.prepare<[], {"name": number | string | Buffer | null}>("SELECT name FROM test")`,
6868
`db.prepare<[]>("DELETE FROM foo")`,
6969
`db.prepare<[], {"random()": (foo | number), "id": number}>("SELECT random(), id FROM users")`,
70+
'db.prepare<[], {"name": string}>(`SELECT name FROM users WHERE id IN (${foo.map(() => "?").join(",")})`)',
7071
],
7172
invalid: [
7273
// Query as string Literal
@@ -189,5 +190,12 @@ ruleTester.run("typed-result", rule, {
189190
errors: [{ messageId: "incorrectResultType" }],
190191
output: `db.prepare<[], {"random()": (foo | number), "id": number}>("SELECT random(), id FROM users")`,
191192
},
193+
// Variable input parameters
194+
{
195+
code: 'db.prepare(`SELECT name FROM users WHERE id IN (${foo.map(() => "?").join(",")})`)',
196+
output:
197+
'db.prepare<[], {"name": string}>(`SELECT name FROM users WHERE id IN (${foo.map(() => "?").join(",")})`)',
198+
errors: [{ messageId: "missingResultType" }],
199+
},
192200
],
193201
});

tests/rules/valid-query.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ ruleTester.run("valid-query", rule, {
3434
"db_users.prepare('SELECT * FROM users')",
3535
"nested.db.users.prepare('SELECT * FROM users')",
3636
"db.prepare('DELETE FROM foo')",
37+
"db_users.prepare(`SELECT * FROM users WHERE id IN (${ids.map(() => '?').join(',')})`);",
38+
"const query = `SELECT * FROM users WHERE id IN (${ids.map(() => '?').join(',')})`;db_users.prepare(query);",
3739
],
3840
invalid: [
3941
{
@@ -88,5 +90,27 @@ ruleTester.run("valid-query", rule, {
8890
},
8991
],
9092
},
93+
{
94+
code: "db_users.prepare(`SELECT * FROM user WHERE id IN (${ids.map(() => '?').join(',')})`);",
95+
errors: [
96+
{
97+
messageId: "invalidQuery",
98+
data: {
99+
message: `no such table: user`,
100+
},
101+
},
102+
],
103+
},
104+
{
105+
code: "const query = `SELECT * FROM user WHERE id IN (${ids.map(() => '?').join(',')})`;db_users.prepare(query);",
106+
errors: [
107+
{
108+
messageId: "invalidQuery",
109+
data: {
110+
message: `no such table: user`,
111+
},
112+
},
113+
],
114+
},
91115
],
92116
});

0 commit comments

Comments
 (0)