@starci/eslint-canon-be 1.8.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +95 -5
- package/README.md +41 -52
- package/async-discipline.mjs +2 -2
- package/authorization.mjs +38 -88
- package/builders.mjs +305 -0
- package/comments.mjs +19 -34
- package/config-owner.mjs +127 -125
- package/connections.mjs +254 -0
- package/cqrs.mjs +426 -135
- package/data-access.mjs +320 -391
- package/default-deny.mjs +14 -10
- package/e2e-flow.mjs +208 -180
- package/error-handling.mjs +254 -128
- package/idempotency.mjs +98 -67
- package/index.mjs +32 -9
- package/infra-owner.mjs +96 -0
- package/injection.mjs +602 -0
- package/input-bounds.mjs +166 -66
- package/lib/config.mjs +70 -33
- package/lib/db-writes.mjs +58 -0
- package/lib/declared.mjs +154 -0
- package/lib/hfs.mjs +61 -0
- package/lib/import-source.mjs +104 -0
- package/lib/persistence.mjs +160 -0
- package/lib/ports.mjs +138 -0
- package/lib/transport-slots.mjs +14 -0
- package/lib/types.mjs +133 -0
- package/lib/unit-spec.mjs +33 -0
- package/log-safety.mjs +94 -26
- package/module-layering.mjs +180 -297
- package/module-shape.mjs +296 -59
- package/naming.mjs +44 -12
- package/observability.mjs +57 -91
- package/operations.mjs +201 -0
- package/package.json +10 -7
- package/query-safety.mjs +189 -223
- package/resilience.mjs +126 -31
- package/runtime/engine/plain-object.mjs +5 -0
- package/runtime/engine/runtime-root.mjs +32 -0
- package/runtime/engine/yaml.mjs +161 -0
- package/runtime/knowledge/hfs/slots.yaml +1111 -0
- package/runtime/scripts/lib/git.mjs +53 -0
- package/runtime/scripts/lib/glob.mjs +23 -0
- package/runtime/scripts/lib/hfs-allows.mjs +57 -0
- package/runtime/scripts/lib/hfs-slots.mjs +729 -0
- package/runtime/scripts/lib/hfs-view.mjs +68 -0
- package/runtime/scripts/lib/language.mjs +107 -0
- package/runtime/scripts/lib/path-key.mjs +17 -0
- package/runtime/scripts/lib/recorded-lines.mjs +27 -0
- package/schema-authority.mjs +273 -91
- package/size-budget.mjs +19 -61
- package/spec-no-skip.mjs +84 -0
- package/spec-quality.mjs +76 -35
- package/suppression.mjs +34 -16
- package/temporal.mjs +101 -36
- package/test-world.mjs +204 -0
- package/testing.mjs +99 -251
- package/transport.mjs +433 -142
- package/type-safety.mjs +50 -278
- package/unit-spec.mjs +452 -0
- package/user-copy.mjs +56 -37
- package/lib/slots.mjs +0 -83
package/query-safety.mjs
CHANGED
|
@@ -1,274 +1,240 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The rules that keep a database call safe to run in production.
|
|
2
|
+
* The rules that keep a database call safe to run in production (R36, R68, R69, R77).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* SQL text is a `SqlText` constant built with the `sql` tag in a persistence `<name>.sql.ts`; a dynamic identifier is a
|
|
5
|
+
* `SqlIdent` from `ident()`; a value is a numbered parameter. Every receiver is identified by its TypeScript type
|
|
6
|
+
* (`EntityManager`, `QueryRunner`, `DataSource` from `typeorm`), never by its name, and every path question goes to the
|
|
7
|
+
* HFS slot view. No rule here parses SQL text: what a statement reads and writes is judged by the architecture
|
|
8
|
+
* machine (R86).
|
|
8
9
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
10
|
+
* - `sql-text-only` (R36 `BE_SQL_OUTSIDE_PERSISTENCE`) - the first argument of `.query(` is a `SqlText`, and the `sql`
|
|
11
|
+
* tag is called only in a persistence `<name>.sql.ts`.
|
|
12
|
+
* - `no-query-builder` (R36) - no `createQueryBuilder(` call.
|
|
13
|
+
* - `no-interpolated-sql` (R68 `BE_SQL_INTERPOLATED`) - a substitution inside the `sql` tag is a `SqlIdent`; a `.query(`
|
|
14
|
+
* string built by template or concatenation is refused.
|
|
15
|
+
* - `query-needs-limit` (R69 `BE_QUERY_UNBOUNDED`) - `find`, `findBy` and `findAndCount` on an `EntityManager` carry `take`.
|
|
16
|
+
* - `no-query-in-loop` (R77 `BE_QUERY_IN_LOOP`) - an `EntityManager` read does not run once per element of a loop.
|
|
17
|
+
*
|
|
18
|
+
* A migration is hand-written DDL run once by `apps/migrate`; its `queryRunner.query(...)` takes plain text.
|
|
12
19
|
*/
|
|
13
|
-
import { keyName
|
|
14
|
-
import {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
/** A migration writes DDL once, by hand, with no caller input: interpolation there is a build step, not a hole. */
|
|
19
|
-
const isMigrationPath = (filename) => /\/migrations\/[^/]+\.[cm]?ts$/.test(normalizePath(filename))
|
|
20
|
+
import { keyName } from "./lib/ast.mjs"
|
|
21
|
+
import { hfsOf } from "./lib/hfs.mjs"
|
|
22
|
+
import { baseNameOf, infraTypeOf, isMigrationFile } from "./lib/persistence.mjs"
|
|
23
|
+
import { isPackageType, typeOrigins } from "./lib/types.mjs"
|
|
20
24
|
|
|
21
25
|
/** The property a call goes through: `manager.query(...)` gives `query`. */
|
|
22
26
|
const calleeProperty = (node) =>
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
if (object.type === "MemberExpression" && !object.computed && object.property.type === "Identifier") return object.property.name
|
|
32
|
-
return null
|
|
27
|
+
node.callee.type === "MemberExpression" && !node.callee.computed && node.callee.property.type === "Identifier"
|
|
28
|
+
? node.callee.property.name
|
|
29
|
+
: null
|
|
30
|
+
|
|
31
|
+
/** Whether a type origin is declared by the platform database capability (where `SqlText`, `SqlIdent` and `sql` live). */
|
|
32
|
+
const fromDatabaseCapability = (hfs, origin) => {
|
|
33
|
+
const found = hfs.classify(origin.file)
|
|
34
|
+
return found.slot === "be.platform" && found.bindings?.capability === "database"
|
|
33
35
|
}
|
|
34
36
|
|
|
35
|
-
/**
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
const
|
|
37
|
+
/** Whether the value at `node` has the branded type `name` exported by the platform database capability. */
|
|
38
|
+
const hasBrand = (context, hfs, node, name) => typeOrigins(context, node).some((origin) => origin.name === name && fromDatabaseCapability(hfs, origin))
|
|
39
|
+
|
|
40
|
+
/** Whether a call goes through `.query` on a manager, runner or data source. */
|
|
41
|
+
const isQueryCall = (context, node) => calleeProperty(node) === "query" && infraTypeOf(context, node.callee.object) !== null
|
|
42
|
+
|
|
43
|
+
// -- R36 -----------------------------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
/** SQL text is `SqlText`, and only a persistence `.sql.ts` builds it. */
|
|
46
|
+
export const sqlTextOnly = {
|
|
47
|
+
meta: {
|
|
48
|
+
type: "problem",
|
|
49
|
+
docs: { description: "`.query(...)` takes a `SqlText`, and the `sql` tag is called only in a persistence `<name>.sql.ts`." },
|
|
50
|
+
schema: [],
|
|
51
|
+
messages: {
|
|
52
|
+
notSqlText:
|
|
53
|
+
"The first argument of `.query(...)` is not a `SqlText`. SQL text is a constant built with the `sql` tag in the `persistence/<name>.sql.ts` of the owning capability and imported here; the row shape and its mapper live in `<name>.rows.ts`.",
|
|
54
|
+
tagOutsideSqlFile:
|
|
55
|
+
"The `sql` tag is called outside a persistence `<name>.sql.ts`. Write the statement as an exported constant in the `persistence/<name>.sql.ts` of the capability that owns the tables, and import it.",
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
create(context) {
|
|
59
|
+
const hfs = hfsOf(context)
|
|
60
|
+
const filename = context.filename || context.getFilename()
|
|
61
|
+
const migration = isMigrationFile(hfs, filename)
|
|
62
|
+
const sqlFile = hfs.slotOf(filename) === "be.persistence" && baseNameOf(filename).endsWith(".sql.ts")
|
|
63
|
+
const isSqlTag = (node) => typeOrigins(context, node).some((origin) => origin.name === "sql" && fromDatabaseCapability(hfs, origin))
|
|
64
|
+
return {
|
|
65
|
+
CallExpression(node) {
|
|
66
|
+
if (isSqlTag(node.callee) && !sqlFile) context.report({ node, messageId: "tagOutsideSqlFile" })
|
|
67
|
+
if (migration || !isQueryCall(context, node)) return
|
|
68
|
+
const first = node.arguments[0]
|
|
69
|
+
if (!first || !hasBrand(context, hfs, first, "SqlText")) context.report({ node: first ?? node, messageId: "notSqlText" })
|
|
70
|
+
},
|
|
71
|
+
TaggedTemplateExpression(node) {
|
|
72
|
+
if (isSqlTag(node.tag) && !sqlFile) context.report({ node, messageId: "tagOutsideSqlFile" })
|
|
73
|
+
},
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
}
|
|
40
77
|
|
|
41
|
-
/**
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
78
|
+
/** There is one way to write a complex read, and it is SQL. */
|
|
79
|
+
export const noQueryBuilder = {
|
|
80
|
+
meta: {
|
|
81
|
+
type: "problem",
|
|
82
|
+
docs: { description: "No `createQueryBuilder(...)` call." },
|
|
83
|
+
schema: [],
|
|
84
|
+
messages: {
|
|
85
|
+
builder:
|
|
86
|
+
"`createQueryBuilder(...)` is banned. A read is `manager.find(Entity, { where, take })`, or a `SqlText` constant in the capability's `persistence/<name>.sql.ts` run with `manager.query(...)`.",
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
create(context) {
|
|
90
|
+
return {
|
|
91
|
+
CallExpression(node) {
|
|
92
|
+
const callee = node.callee
|
|
93
|
+
const name = callee.type === "Identifier" ? callee.name : calleeProperty(node)
|
|
94
|
+
if (name === "createQueryBuilder") context.report({ node, messageId: "builder" })
|
|
95
|
+
},
|
|
96
|
+
}
|
|
97
|
+
},
|
|
55
98
|
}
|
|
56
99
|
|
|
57
100
|
// -- R68 -----------------------------------------------------------------------------------------------
|
|
58
101
|
|
|
59
102
|
/** A statement is text plus parameters; a value never becomes part of the text. */
|
|
60
103
|
export const noInterpolatedSql = {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
104
|
+
meta: {
|
|
105
|
+
type: "problem",
|
|
106
|
+
docs: { description: "Inside the `sql` tag every substitution is a `SqlIdent`; a `.query(...)` string is never built by template or concatenation." },
|
|
107
|
+
schema: [],
|
|
108
|
+
messages: {
|
|
109
|
+
substitution:
|
|
110
|
+
"This substitution (`{{expression}}`) is not a `SqlIdent`. A value spliced into a statement is an injection hole and defeats the plan cache. Pass a value as a numbered parameter (`$1`) in the array argument; a dynamic identifier goes through `ident(name, allowed)`, which checks it against a fixed list and is the only substitution the `sql` tag accepts.",
|
|
111
|
+
built:
|
|
112
|
+
"This SQL text is built with a template or a `+`. A value spliced into a statement is an injection hole. Write the statement as a `SqlText` constant with the `sql` tag in `persistence/<name>.sql.ts` and pass values as numbered parameters (`$1`) in the array argument.",
|
|
113
|
+
},
|
|
70
114
|
},
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
walk(first, (child) => {
|
|
92
|
-
if (child.type === "Literal" && typeof child.value === "string" && SQL_VERB.test(child.value)) sql = true
|
|
93
|
-
})
|
|
94
|
-
if (sql) context.report({ node: first, messageId: "concatenated" })
|
|
115
|
+
create(context) {
|
|
116
|
+
const hfs = hfsOf(context)
|
|
117
|
+
const sourceCode = context.sourceCode || context.getSourceCode()
|
|
118
|
+
const filename = context.filename || context.getFilename()
|
|
119
|
+
const migration = isMigrationFile(hfs, filename)
|
|
120
|
+
return {
|
|
121
|
+
TaggedTemplateExpression(node) {
|
|
122
|
+
if (!typeOrigins(context, node.tag).some((origin) => origin.name === "sql" && fromDatabaseCapability(hfs, origin))) return
|
|
123
|
+
for (const expression of node.quasi.expressions) {
|
|
124
|
+
if (!hasBrand(context, hfs, expression, "SqlIdent")) {
|
|
125
|
+
context.report({ node: expression, messageId: "substitution", data: { expression: sourceCode.getText(expression).slice(0, 60) } })
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
CallExpression(node) {
|
|
130
|
+
if (migration || !isQueryCall(context, node)) return
|
|
131
|
+
const first = node.arguments[0]
|
|
132
|
+
if (first?.type === "TemplateLiteral" && first.expressions.length > 0) context.report({ node: first, messageId: "built" })
|
|
133
|
+
else if (first?.type === "BinaryExpression" && first.operator === "+") context.report({ node: first, messageId: "built" })
|
|
134
|
+
},
|
|
95
135
|
}
|
|
96
|
-
|
|
97
|
-
}
|
|
98
|
-
},
|
|
136
|
+
},
|
|
99
137
|
}
|
|
100
138
|
|
|
101
139
|
// -- R69 -----------------------------------------------------------------------------------------------
|
|
102
140
|
|
|
103
|
-
const
|
|
104
|
-
const HAS_BOUND = /\bLIMIT\b|\bFETCH\s+FIRST\b/i
|
|
105
|
-
/** A lookup by the primary key returns one row, so it needs no bound. */
|
|
106
|
-
const BY_PRIMARY_KEY = /\bWHERE\s+"?(?:[a-z_]+\.)?id"?\s*=\s*\$\d+\s*(?:FOR\s+UPDATE\s*)?(?:RETURNING[^;]*)?$/i
|
|
107
|
-
|
|
108
|
-
const QUERY_BUILDER_READS = new Set(["getMany", "getRawMany", "getManyAndCount", "getRawAndEntities"])
|
|
109
|
-
const REPOSITORY_READS = new Set(["find", "findBy", "findAndCount", "findAndCountBy"])
|
|
110
|
-
const REPOSITORY_RECEIVER = /^(?:manager|entityManager|em|repo|repository|[a-z]\w*Repository|[a-z]\w*Repo)$/
|
|
111
|
-
|
|
112
|
-
/** The chain of calls a query builder read hangs off, as method names, innermost first. */
|
|
113
|
-
const chainNames = (callee) => {
|
|
114
|
-
const names = []
|
|
115
|
-
let current = callee
|
|
116
|
-
while (current) {
|
|
117
|
-
if (current.type === "MemberExpression" && !current.computed && current.property.type === "Identifier") {
|
|
118
|
-
names.push(current.property.name)
|
|
119
|
-
current = current.object
|
|
120
|
-
} else if (current.type === "CallExpression") {
|
|
121
|
-
current = current.callee
|
|
122
|
-
} else if (current.type === "AwaitExpression") {
|
|
123
|
-
current = current.argument
|
|
124
|
-
} else break
|
|
125
|
-
}
|
|
126
|
-
return names
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
/** Whether an object literal names a property, by key. */
|
|
130
|
-
const objectHasKey = (node, names) =>
|
|
131
|
-
node?.type === "ObjectExpression" && node.properties.some((property) => property.type === "Property" && names.includes(keyName(property.key)))
|
|
132
|
-
|
|
133
|
-
/** Whether a `where` object states the primary key, which bounds the read to one row. */
|
|
134
|
-
const whereHasPrimaryKey = (node) => {
|
|
135
|
-
if (node?.type !== "ObjectExpression") return false
|
|
136
|
-
if (objectHasKey(node, ["id"])) return true
|
|
137
|
-
const where = node.properties.find((property) => property.type === "Property" && keyName(property.key) === "where")
|
|
138
|
-
return where ? whereHasPrimaryKey(where.value) : false
|
|
139
|
-
}
|
|
141
|
+
const BOUNDED_READS = new Set(["find", "findBy", "findAndCount", "findAndCountBy"])
|
|
140
142
|
|
|
141
143
|
/** A list read states its bound. */
|
|
142
144
|
export const queryNeedsLimit = {
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
"`.findBy(...)` cannot take a bound, so it reads every matching row. Use `.find({ where, take })` with a named bound, or page with a cursor. A lookup by `id` is exempt.",
|
|
154
|
-
raw:
|
|
155
|
-
"This `SELECT` has no `LIMIT`. Add `LIMIT $n` with a named bound, or page by keyset. An aggregate (`COUNT`, `EXISTS`, `SUM`) and a lookup by `id` are exempt.",
|
|
145
|
+
meta: {
|
|
146
|
+
type: "problem",
|
|
147
|
+
docs: { description: "`find`, `findBy` and `findAndCount` on an `EntityManager` state `take`." },
|
|
148
|
+
schema: [],
|
|
149
|
+
messages: {
|
|
150
|
+
find:
|
|
151
|
+
"`.{{method}}(...)` reads every matching row and states no `take` in its options object. Pass `take` with a named bound (`manager.find(Entity, { where, take: LIST_ROWS_MAX })`), or page by keyset.",
|
|
152
|
+
findBy:
|
|
153
|
+
"`.{{method}}(...)` cannot take a bound, so it reads every matching row. Use `manager.find(Entity, { where, take })` with a named bound, or page by keyset.",
|
|
154
|
+
},
|
|
156
155
|
},
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
if (REPOSITORY_READS.has(method) && REPOSITORY_RECEIVER.test(receiverName(node.callee) ?? "")) {
|
|
173
|
-
const args = node.arguments
|
|
174
|
-
// manager.find(Entity, options) has the entity first; repository.find(options) has the options first
|
|
175
|
-
const options = method === "find" || method === "findAndCount" ? args.find((arg) => arg.type === "ObjectExpression") : undefined
|
|
176
|
-
const where = method === "findBy" || method === "findAndCountBy" ? args.find((arg) => arg.type === "ObjectExpression") : undefined
|
|
177
|
-
if (method === "find" || method === "findAndCount") {
|
|
178
|
-
const argument = args[args.length - 1]
|
|
179
|
-
if (!argument || argument.type !== "ObjectExpression") return // no static options to judge
|
|
180
|
-
if (objectHasKey(options, ["take", "limit"]) || whereHasPrimaryKey(options)) return
|
|
181
|
-
context.report({ node: node.callee.property, messageId: "find", data: { method } })
|
|
182
|
-
} else if (where) {
|
|
183
|
-
if (whereHasPrimaryKey(where)) return
|
|
184
|
-
context.report({ node: node.callee.property, messageId: "findBy", data: { method } })
|
|
185
|
-
}
|
|
186
|
-
return
|
|
156
|
+
create(context) {
|
|
157
|
+
return {
|
|
158
|
+
CallExpression(node) {
|
|
159
|
+
const method = calleeProperty(node)
|
|
160
|
+
if (!method || !BOUNDED_READS.has(method) || !isPackageType(context, node.callee.object, "EntityManager", "typeorm")) return
|
|
161
|
+
if (method === "findBy" || method === "findAndCountBy") {
|
|
162
|
+
context.report({ node: node.callee.property, messageId: "findBy", data: { method } })
|
|
163
|
+
return
|
|
164
|
+
}
|
|
165
|
+
const options = node.arguments[node.arguments.length - 1]
|
|
166
|
+
const hasTake =
|
|
167
|
+
options?.type === "ObjectExpression"
|
|
168
|
+
&& options.properties.some((property) => property.type === "Property" && keyName(property.key) === "take")
|
|
169
|
+
if (!hasTake) context.report({ node: node.callee.property, messageId: "find", data: { method } })
|
|
170
|
+
},
|
|
187
171
|
}
|
|
188
|
-
|
|
189
|
-
const text = staticText(node.arguments[0])
|
|
190
|
-
const sql = text ?? (node.arguments[0]?.type === "TemplateLiteral" ? node.arguments[0].quasis.map((quasi) => quasi.value.cooked ?? "").join(" ") : null)
|
|
191
|
-
if (sql === null) return
|
|
192
|
-
if (!/^\s*(?:SELECT|WITH)\b/i.test(sql)) return
|
|
193
|
-
if (HAS_BOUND.test(sql) || AGGREGATE_ONLY.test(sql) || BY_PRIMARY_KEY.test(sql.trim().replace(/;$/, ""))) return
|
|
194
|
-
context.report({ node: node.arguments[0], messageId: "raw" })
|
|
195
|
-
}
|
|
196
|
-
},
|
|
197
|
-
}
|
|
198
|
-
},
|
|
172
|
+
},
|
|
199
173
|
}
|
|
200
174
|
|
|
201
175
|
// -- R77 -----------------------------------------------------------------------------------------------
|
|
202
176
|
|
|
203
|
-
const LOOP_READS = new Set(["find", "findBy", "findOne", "findOneBy", "findOneOrFail", "findOneByOrFail", "findAndCount", "count", "countBy", "exists", "existsBy"
|
|
204
|
-
const LOOP_RECEIVER = /^(?:manager|entityManager|em|repo|repository|dataSource|queryRunner|[a-z]\w*Repository|[a-z]\w*Repo|[a-z]\w*Store)$/
|
|
177
|
+
const LOOP_READS = new Set(["find", "findBy", "findOne", "findOneBy", "findOneOrFail", "findOneByOrFail", "findAndCount", "findAndCountBy", "count", "countBy", "exists", "existsBy"])
|
|
205
178
|
const ITERATION_METHODS = new Set(["map", "forEach", "flatMap", "filter", "reduce", "some", "every"])
|
|
206
179
|
const LOOP_STATEMENTS = new Set(["ForStatement", "ForOfStatement", "ForInStatement"])
|
|
207
180
|
|
|
208
181
|
/** Whether `node` runs once per element: inside a `for` loop, or inside a callback of an array iteration method. */
|
|
209
182
|
const insideIteration = (node) => {
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
183
|
+
let current = node.parent
|
|
184
|
+
while (current) {
|
|
185
|
+
if (LOOP_STATEMENTS.has(current.type)) return true
|
|
186
|
+
if (current.type === "FunctionDeclaration" || current.type === "FunctionExpression" || current.type === "ArrowFunctionExpression") {
|
|
187
|
+
const call = current.parent
|
|
188
|
+
return (
|
|
189
|
+
call?.type === "CallExpression"
|
|
190
|
+
&& call.callee.type === "MemberExpression"
|
|
191
|
+
&& !call.callee.computed
|
|
192
|
+
&& call.callee.property.type === "Identifier"
|
|
193
|
+
&& ITERATION_METHODS.has(call.callee.property.name)
|
|
194
|
+
&& call.arguments.includes(current)
|
|
195
|
+
)
|
|
196
|
+
}
|
|
197
|
+
current = current.parent
|
|
223
198
|
}
|
|
224
|
-
|
|
225
|
-
}
|
|
226
|
-
return false
|
|
199
|
+
return false
|
|
227
200
|
}
|
|
228
201
|
|
|
229
202
|
/** A read inside an iteration is N+1: one round trip per element. */
|
|
230
203
|
export const noQueryInLoop = {
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
204
|
+
meta: {
|
|
205
|
+
type: "problem",
|
|
206
|
+
docs: { description: "An `EntityManager` read does not run once per element of a loop." },
|
|
207
|
+
schema: [],
|
|
208
|
+
messages: {
|
|
209
|
+
inLoop:
|
|
210
|
+
"`.{{method}}(...)` runs once per element, so the number of round trips grows with the input (N+1) and one large request holds a connection for as long as it takes. Read the rows once before the loop with `find(Entity, { where: { <key>: In(keys) }, take })` or a `SqlText` with `WHERE <key> = ANY($1)`, index them in a `Map`, and look up inside the loop. A polling `while` loop is not this rule's business.",
|
|
211
|
+
},
|
|
238
212
|
},
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
if (!method || !LOOP_READS.has(method)) return
|
|
247
|
-
const chain = chainNames(node.callee)
|
|
248
|
-
const builder = chain.includes("createQueryBuilder")
|
|
249
|
-
if (!builder && !LOOP_RECEIVER.test(receiverName(node.callee) ?? "")) return
|
|
250
|
-
if (method === "query") {
|
|
251
|
-
const first = node.arguments[0]
|
|
252
|
-
const sql = staticText(first) ?? (first?.type === "TemplateLiteral" ? first.quasis.map((quasi) => quasi.value.cooked ?? "").join(" ") : null)
|
|
253
|
-
if (sql === null || !/^\s*(?:SELECT|WITH)\b/i.test(sql)) return
|
|
213
|
+
create(context) {
|
|
214
|
+
return {
|
|
215
|
+
CallExpression(node) {
|
|
216
|
+
const method = calleeProperty(node)
|
|
217
|
+
if (!method || !LOOP_READS.has(method) || !insideIteration(node)) return
|
|
218
|
+
if (infraTypeOf(context, node.callee.object) === "EntityManager") context.report({ node: node.callee.property, messageId: "inLoop", data: { method } })
|
|
219
|
+
},
|
|
254
220
|
}
|
|
255
|
-
|
|
256
|
-
context.report({ node: node.callee.property, messageId: "inLoop", data: { method } })
|
|
257
|
-
},
|
|
258
|
-
}
|
|
259
|
-
},
|
|
221
|
+
},
|
|
260
222
|
}
|
|
261
223
|
|
|
262
224
|
/** The rules this law contributes to the plugin. */
|
|
263
225
|
export const rules = {
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
226
|
+
"sql-text-only": sqlTextOnly,
|
|
227
|
+
"no-query-builder": noQueryBuilder,
|
|
228
|
+
"no-interpolated-sql": noInterpolatedSql,
|
|
229
|
+
"query-needs-limit": queryNeedsLimit,
|
|
230
|
+
"no-query-in-loop": noQueryInLoop,
|
|
267
231
|
}
|
|
268
232
|
|
|
269
|
-
/**
|
|
233
|
+
/** Every rule of this law ships at `error`; no baseline exists. */
|
|
270
234
|
export const recommended = {
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
235
|
+
"starci-be/sql-text-only": "error",
|
|
236
|
+
"starci-be/no-query-builder": "error",
|
|
237
|
+
"starci-be/no-interpolated-sql": "error",
|
|
238
|
+
"starci-be/query-needs-limit": "error",
|
|
239
|
+
"starci-be/no-query-in-loop": "error",
|
|
274
240
|
}
|