@nest-boot/eslint-plugin 7.0.0-beta.1 → 7.0.0-beta.3
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/.turbo/turbo-build.log +2 -2
- package/CHANGELOG.md +4 -16
- package/dist/index.d.ts +3 -0
- package/dist/index.js +4 -6
- package/dist/index.js.map +1 -1
- package/dist/rules/graphql/graphql-field-config-from-types.d.ts +6 -0
- package/dist/rules/graphql/graphql-field-config-from-types.js +417 -0
- package/dist/rules/graphql/graphql-field-config-from-types.js.map +1 -0
- package/dist/rules/graphql/graphql-field-definite-assignment.d.ts +2 -0
- package/dist/rules/graphql/graphql-field-definite-assignment.js +125 -0
- package/dist/rules/graphql/graphql-field-definite-assignment.js.map +1 -0
- package/dist/rules/import/import-bullmq.d.ts +2 -0
- package/dist/rules/import/import-bullmq.js +36 -0
- package/dist/rules/import/import-bullmq.js.map +1 -0
- package/dist/rules/import/import-graphql.d.ts +2 -0
- package/dist/rules/import/import-graphql.js +36 -0
- package/dist/rules/import/import-graphql.js.map +1 -0
- package/dist/rules/import/import-mikro-orm.d.ts +2 -0
- package/dist/rules/import/import-mikro-orm.js +36 -0
- package/dist/rules/import/import-mikro-orm.js.map +1 -0
- package/dist/rules/index.d.ts +9 -0
- package/dist/rules/index.js +16 -11
- package/dist/rules/index.js.map +1 -1
- package/dist/rules/mikro-orm/entity-field-definite-assignment.d.ts +2 -0
- package/dist/rules/mikro-orm/entity-field-definite-assignment.js +125 -0
- package/dist/rules/mikro-orm/entity-field-definite-assignment.js.map +1 -0
- package/dist/rules/mikro-orm/entity-property-config-from-types.d.ts +3 -0
- package/dist/rules/mikro-orm/entity-property-config-from-types.js +881 -0
- package/dist/rules/mikro-orm/entity-property-config-from-types.js.map +1 -0
- package/dist/tsconfig.build.tsbuildinfo +1 -0
- package/dist/utils/createRule.d.ts +2 -0
- package/dist/utils/createRule.js +1 -1
- package/dist/utils/createRule.js.map +1 -1
- package/dist/utils/decorators.d.ts +29 -0
- package/dist/utils/decorators.js +74 -0
- package/dist/utils/decorators.js.map +1 -0
- package/dist/utils/tester.d.ts +2 -0
- package/dist/utils/tester.js +27 -0
- package/dist/utils/tester.js.map +1 -0
- package/eslint.config.mjs +28 -2
- package/jest.config.ts +12 -0
- package/package.json +22 -17
- package/src/index.ts +8 -2
- package/src/rules/graphql/graphql-field-config-from-types.spec.ts +242 -0
- package/src/rules/graphql/graphql-field-config-from-types.ts +557 -0
- package/src/rules/graphql/graphql-field-definite-assignment.spec.ts +135 -0
- package/src/rules/graphql/graphql-field-definite-assignment.ts +147 -0
- package/src/rules/import/import-bullmq.spec.ts +69 -0
- package/src/rules/import/import-bullmq.ts +35 -0
- package/src/rules/import/import-graphql.spec.ts +65 -0
- package/src/rules/import/import-graphql.ts +36 -0
- package/src/rules/import/import-mikro-orm.spec.ts +65 -0
- package/src/rules/import/import-mikro-orm.ts +36 -0
- package/src/rules/index.ts +15 -13
- package/src/rules/mikro-orm/entity-field-definite-assignment.spec.ts +91 -0
- package/src/rules/mikro-orm/entity-field-definite-assignment.ts +141 -0
- package/src/rules/mikro-orm/entity-property-config-from-types.spec.ts +262 -0
- package/src/rules/mikro-orm/entity-property-config-from-types.ts +1111 -0
- package/src/utils/createRule.ts +3 -1
- package/src/utils/decorators.spec.ts +214 -0
- package/src/utils/decorators.ts +93 -0
- package/src/utils/tester.ts +22 -0
- package/tsconfig.build.json +5 -0
- package/tsconfig.json +6 -7
- package/dist/rules/entity-constructor.js +0 -78
- package/dist/rules/entity-constructor.js.map +0 -1
- package/dist/rules/entity-property-no-optional-or-non-null-assertion.js +0 -63
- package/dist/rules/entity-property-no-optional-or-non-null-assertion.js.map +0 -1
- package/dist/rules/entity-property-nullable.js +0 -81
- package/dist/rules/entity-property-nullable.js.map +0 -1
- package/dist/rules/graphql-field-arguments-match-property-type.js +0 -118
- package/dist/rules/graphql-field-arguments-match-property-type.js.map +0 -1
- package/dist/rules/graphql-resolver-method-return-type.js +0 -145
- package/dist/rules/graphql-resolver-method-return-type.js.map +0 -1
- package/dist/tsconfig.tsbuildinfo +0 -1
- package/src/rules/entity-constructor.ts +0 -97
- package/src/rules/entity-property-no-optional-or-non-null-assertion.ts +0 -81
- package/src/rules/entity-property-nullable.ts +0 -112
- package/src/rules/graphql-field-arguments-match-property-type.ts +0 -186
- package/src/rules/graphql-resolver-method-return-type.ts +0 -207
|
@@ -0,0 +1,557 @@
|
|
|
1
|
+
import { AST_NODE_TYPES, TSESTree } from "@typescript-eslint/utils";
|
|
2
|
+
import type { RuleFixer } from "@typescript-eslint/utils/ts-eslint";
|
|
3
|
+
|
|
4
|
+
import { createRule } from "../../utils/createRule";
|
|
5
|
+
import {
|
|
6
|
+
getPropertyDecorator,
|
|
7
|
+
hasClassDecorator,
|
|
8
|
+
} from "../../utils/decorators";
|
|
9
|
+
|
|
10
|
+
// 自定义 Fix 对象类型,用于延迟应用修复
|
|
11
|
+
interface CustomFix {
|
|
12
|
+
type: "insert" | "replace";
|
|
13
|
+
range: readonly [number, number];
|
|
14
|
+
text: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface TypeInfo {
|
|
18
|
+
typeName: string | null;
|
|
19
|
+
isArray: boolean;
|
|
20
|
+
isNullable: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type DecoratorBehavior = "ignore" | "remove";
|
|
24
|
+
|
|
25
|
+
export interface Options {
|
|
26
|
+
decorators?: Record<string, DecoratorBehavior>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export default createRule<
|
|
30
|
+
[Options],
|
|
31
|
+
"alignFieldDecoratorWithTsType" | "removeFieldDecorator"
|
|
32
|
+
>({
|
|
33
|
+
name: "graphql-field-config-from-types",
|
|
34
|
+
meta: {
|
|
35
|
+
type: "problem",
|
|
36
|
+
docs: {
|
|
37
|
+
description:
|
|
38
|
+
"根据 TypeScript 类型自动生成或修正 @Field 装饰器的类型与 nullable 配置(支持数组)。",
|
|
39
|
+
},
|
|
40
|
+
fixable: "code",
|
|
41
|
+
schema: [
|
|
42
|
+
{
|
|
43
|
+
type: "object",
|
|
44
|
+
properties: {
|
|
45
|
+
decorators: {
|
|
46
|
+
type: "object",
|
|
47
|
+
additionalProperties: {
|
|
48
|
+
type: "string",
|
|
49
|
+
enum: ["ignore", "remove"],
|
|
50
|
+
},
|
|
51
|
+
description:
|
|
52
|
+
"配置每个装饰器的行为。ignore: 跳过检查;remove: 移除 @Field。默认:{ HideField: 'remove', OneToOne: 'remove', OneToMany: 'remove', ManyToOne: 'remove', ManyToMany: 'remove' }",
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
additionalProperties: false,
|
|
56
|
+
},
|
|
57
|
+
],
|
|
58
|
+
messages: {
|
|
59
|
+
alignFieldDecoratorWithTsType:
|
|
60
|
+
"@Field 装饰器应与 TypeScript 类型保持一致(类型与 nullable)。",
|
|
61
|
+
removeFieldDecorator:
|
|
62
|
+
"属性带有 @{{decoratorName}} 装饰器,应移除 @Field 装饰器。",
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
defaultOptions: [
|
|
66
|
+
{
|
|
67
|
+
decorators: {
|
|
68
|
+
HideField: "remove",
|
|
69
|
+
OneToOne: "remove",
|
|
70
|
+
OneToMany: "remove",
|
|
71
|
+
ManyToOne: "remove",
|
|
72
|
+
ManyToMany: "remove",
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
],
|
|
76
|
+
create(context, [options]) {
|
|
77
|
+
const source = context.sourceCode;
|
|
78
|
+
|
|
79
|
+
const scalarFromTsKeyword = (
|
|
80
|
+
typeNodeType: AST_NODE_TYPES,
|
|
81
|
+
): string | null => {
|
|
82
|
+
switch (typeNodeType) {
|
|
83
|
+
case AST_NODE_TYPES.TSBooleanKeyword:
|
|
84
|
+
return "Boolean";
|
|
85
|
+
case AST_NODE_TYPES.TSStringKeyword:
|
|
86
|
+
return "String";
|
|
87
|
+
case AST_NODE_TYPES.TSNumberKeyword:
|
|
88
|
+
// 默认 number → Float(GraphQL 默认也是 Float;Int 需显式声明)
|
|
89
|
+
return "Float";
|
|
90
|
+
default:
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const getIdentifierName = (node: TSESTree.TypeNode): string | null => {
|
|
96
|
+
if (
|
|
97
|
+
node.type === AST_NODE_TYPES.TSTypeReference &&
|
|
98
|
+
node.typeName.type === AST_NODE_TYPES.Identifier
|
|
99
|
+
) {
|
|
100
|
+
return node.typeName.name;
|
|
101
|
+
}
|
|
102
|
+
return null;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const extractArrayElementType = (
|
|
106
|
+
node: TSESTree.TypeNode,
|
|
107
|
+
): TSESTree.TypeNode | null => {
|
|
108
|
+
if (node.type === AST_NODE_TYPES.TSArrayType) {
|
|
109
|
+
return node.elementType;
|
|
110
|
+
}
|
|
111
|
+
if (
|
|
112
|
+
node.type === AST_NODE_TYPES.TSTypeReference &&
|
|
113
|
+
node.typeName.type === AST_NODE_TYPES.Identifier &&
|
|
114
|
+
node.typeName.name === "Array"
|
|
115
|
+
) {
|
|
116
|
+
return node.typeArguments?.params[0] ?? null;
|
|
117
|
+
}
|
|
118
|
+
return null;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const computeTypeInfo = (
|
|
122
|
+
property: TSESTree.PropertyDefinition,
|
|
123
|
+
): TypeInfo | null => {
|
|
124
|
+
let isNullable = false;
|
|
125
|
+
let isArray = false;
|
|
126
|
+
|
|
127
|
+
let baseTypeNode: TSESTree.TypeNode | null =
|
|
128
|
+
property.typeAnnotation?.type === AST_NODE_TYPES.TSTypeAnnotation
|
|
129
|
+
? property.typeAnnotation.typeAnnotation
|
|
130
|
+
: null;
|
|
131
|
+
|
|
132
|
+
// 可选属性(?)视为可空
|
|
133
|
+
if (property.optional) {
|
|
134
|
+
isNullable = true;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// 处理联合类型中的 null/undefined
|
|
138
|
+
if (baseTypeNode?.type === AST_NODE_TYPES.TSUnionType) {
|
|
139
|
+
const hasNullish = baseTypeNode.types.some((t: TSESTree.TypeNode) => {
|
|
140
|
+
return (
|
|
141
|
+
t.type === AST_NODE_TYPES.TSNullKeyword ||
|
|
142
|
+
t.type === AST_NODE_TYPES.TSUndefinedKeyword
|
|
143
|
+
);
|
|
144
|
+
});
|
|
145
|
+
if (hasNullish) isNullable = true;
|
|
146
|
+
|
|
147
|
+
baseTypeNode =
|
|
148
|
+
baseTypeNode.types.find((t: TSESTree.TypeNode) => {
|
|
149
|
+
return (
|
|
150
|
+
t.type !== AST_NODE_TYPES.TSNullKeyword &&
|
|
151
|
+
t.type !== AST_NODE_TYPES.TSUndefinedKeyword
|
|
152
|
+
);
|
|
153
|
+
}) ?? null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// 先解包 Ref<T> 和 Opt<T> → T,并在内部再次处理 null/undefined
|
|
157
|
+
if (
|
|
158
|
+
baseTypeNode?.type === AST_NODE_TYPES.TSTypeReference &&
|
|
159
|
+
baseTypeNode.typeName.type === AST_NODE_TYPES.Identifier &&
|
|
160
|
+
(baseTypeNode.typeName.name === "Ref" ||
|
|
161
|
+
baseTypeNode.typeName.name === "Opt")
|
|
162
|
+
) {
|
|
163
|
+
let inner = baseTypeNode.typeArguments?.params[0] ?? null;
|
|
164
|
+
if (inner && inner.type === AST_NODE_TYPES.TSUnionType) {
|
|
165
|
+
const hasNullish = inner.types.some((t: TSESTree.TypeNode) => {
|
|
166
|
+
return (
|
|
167
|
+
t.type === AST_NODE_TYPES.TSNullKeyword ||
|
|
168
|
+
t.type === AST_NODE_TYPES.TSUndefinedKeyword
|
|
169
|
+
);
|
|
170
|
+
});
|
|
171
|
+
if (hasNullish) isNullable = true;
|
|
172
|
+
inner =
|
|
173
|
+
inner.types.find((t: TSESTree.TypeNode) => {
|
|
174
|
+
return (
|
|
175
|
+
t.type !== AST_NODE_TYPES.TSNullKeyword &&
|
|
176
|
+
t.type !== AST_NODE_TYPES.TSUndefinedKeyword
|
|
177
|
+
);
|
|
178
|
+
}) ?? inner;
|
|
179
|
+
}
|
|
180
|
+
baseTypeNode = inner ?? baseTypeNode;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// 数组类型(T[] 或 Array<T>)- 在解包 Opt/Ref 之后检查
|
|
184
|
+
const elementTypeNode = baseTypeNode
|
|
185
|
+
? extractArrayElementType(baseTypeNode)
|
|
186
|
+
: null;
|
|
187
|
+
if (elementTypeNode) {
|
|
188
|
+
isArray = true;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const targetTypeNode: TSESTree.TypeNode | null =
|
|
192
|
+
elementTypeNode ?? baseTypeNode;
|
|
193
|
+
|
|
194
|
+
// 无显式类型时,尝试从字面量初始值推断
|
|
195
|
+
if (!targetTypeNode) {
|
|
196
|
+
if (property.value?.type === AST_NODE_TYPES.Literal) {
|
|
197
|
+
const value = property.value.value;
|
|
198
|
+
const typeOf = typeof value;
|
|
199
|
+
if (typeOf === "boolean")
|
|
200
|
+
return { typeName: "Boolean", isArray, isNullable };
|
|
201
|
+
if (typeOf === "number")
|
|
202
|
+
return { typeName: "Float", isArray, isNullable };
|
|
203
|
+
if (typeOf === "string")
|
|
204
|
+
return { typeName: "String", isArray, isNullable };
|
|
205
|
+
}
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// id 字段优先使用 GraphQL 标量 ID
|
|
210
|
+
const propertyName =
|
|
211
|
+
property.key.type === AST_NODE_TYPES.Identifier
|
|
212
|
+
? property.key.name
|
|
213
|
+
: null;
|
|
214
|
+
|
|
215
|
+
if (
|
|
216
|
+
propertyName === "id" ||
|
|
217
|
+
(typeof propertyName === "string" &&
|
|
218
|
+
(propertyName.endsWith("Id") || propertyName.endsWith("ID")))
|
|
219
|
+
) {
|
|
220
|
+
return { typeName: "ID", isArray, isNullable };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Record<*, *> → GraphQLJSONObject(不限制键值类型)
|
|
224
|
+
if (
|
|
225
|
+
targetTypeNode.type === AST_NODE_TYPES.TSTypeReference &&
|
|
226
|
+
targetTypeNode.typeName.type === AST_NODE_TYPES.Identifier &&
|
|
227
|
+
targetTypeNode.typeName.name === "Record"
|
|
228
|
+
) {
|
|
229
|
+
return { typeName: "GraphQLJSONObject", isArray, isNullable };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// 关键字类型 → GraphQL 标量
|
|
233
|
+
const scalar = scalarFromTsKeyword(targetTypeNode.type);
|
|
234
|
+
if (scalar) {
|
|
235
|
+
return { typeName: scalar, isArray, isNullable };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// 标识符(类/自定义类型)
|
|
239
|
+
const ident = getIdentifierName(targetTypeNode);
|
|
240
|
+
if (ident) {
|
|
241
|
+
return { typeName: ident, isArray, isNullable };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return null;
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
const ensureScalarImport = (fixes: CustomFix[], expected: string) => {
|
|
248
|
+
if (["Int", "Float", "ID"].includes(expected)) {
|
|
249
|
+
if (
|
|
250
|
+
!context.sourceCode.text.includes(
|
|
251
|
+
`import { ${expected} } from "@nestjs/graphql"`,
|
|
252
|
+
)
|
|
253
|
+
) {
|
|
254
|
+
fixes.push({
|
|
255
|
+
type: "insert",
|
|
256
|
+
range: [0, 0],
|
|
257
|
+
text: `import { ${expected} } from "@nestjs/graphql";\n`,
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
const ensureJSONObjectImport = (fixes: CustomFix[]) => {
|
|
264
|
+
const hasImport =
|
|
265
|
+
context.sourceCode.text.includes(`from "graphql-type-json"`) &&
|
|
266
|
+
context.sourceCode.text.includes("GraphQLJSONObject");
|
|
267
|
+
if (!hasImport) {
|
|
268
|
+
fixes.push({
|
|
269
|
+
type: "insert",
|
|
270
|
+
range: [0, 0],
|
|
271
|
+
text: `import { GraphQLJSONObject } from "graphql-type-json";\n`,
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
const addFieldDecorator = (
|
|
277
|
+
property: TSESTree.PropertyDefinition,
|
|
278
|
+
info: TypeInfo,
|
|
279
|
+
) => {
|
|
280
|
+
const fixes: CustomFix[] = [];
|
|
281
|
+
|
|
282
|
+
const typeName = info.typeName ?? "";
|
|
283
|
+
const typeExpr = info.isArray
|
|
284
|
+
? `() => [${typeName}]`
|
|
285
|
+
: `() => ${typeName}`;
|
|
286
|
+
const optionsExpr = info.isNullable ? ", { nullable: true }" : "";
|
|
287
|
+
|
|
288
|
+
const newDecoratorText = `@Field(${typeExpr}${optionsExpr})`;
|
|
289
|
+
|
|
290
|
+
ensureScalarImport(fixes, info.typeName ?? "");
|
|
291
|
+
if (info.typeName === "GraphQLJSONObject") {
|
|
292
|
+
ensureJSONObjectImport(fixes);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
fixes.push({
|
|
296
|
+
type: "insert",
|
|
297
|
+
range: property.range,
|
|
298
|
+
text: newDecoratorText + "\n ",
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
return fixes;
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
const fixWithTypeInfo = (
|
|
305
|
+
property: TSESTree.PropertyDefinition,
|
|
306
|
+
fieldDecorator: TSESTree.Decorator,
|
|
307
|
+
info: TypeInfo,
|
|
308
|
+
) => {
|
|
309
|
+
const fixes: CustomFix[] = [];
|
|
310
|
+
|
|
311
|
+
const typeName = info.typeName ?? "";
|
|
312
|
+
const typeExpr = info.isArray
|
|
313
|
+
? `() => [${typeName}]`
|
|
314
|
+
: `() => ${typeName}`;
|
|
315
|
+
|
|
316
|
+
// 提取现有的配置选项(除了 nullable)
|
|
317
|
+
const callExpr = fieldDecorator.expression;
|
|
318
|
+
const existingOptions: string[] = [];
|
|
319
|
+
|
|
320
|
+
// 检查是否有现有的配置对象
|
|
321
|
+
let optionsArg: TSESTree.ObjectExpression | null = null;
|
|
322
|
+
if (
|
|
323
|
+
callExpr.type === AST_NODE_TYPES.CallExpression &&
|
|
324
|
+
callExpr.arguments.length > 0
|
|
325
|
+
) {
|
|
326
|
+
// 如果第一个参数是对象表达式(没有 type function)
|
|
327
|
+
if (
|
|
328
|
+
callExpr.arguments[0] &&
|
|
329
|
+
callExpr.arguments[0].type === AST_NODE_TYPES.ObjectExpression
|
|
330
|
+
) {
|
|
331
|
+
optionsArg = callExpr.arguments[0];
|
|
332
|
+
}
|
|
333
|
+
// 如果第二个参数是对象表达式(有 type function)
|
|
334
|
+
else if (
|
|
335
|
+
callExpr.arguments[1] &&
|
|
336
|
+
callExpr.arguments[1].type === AST_NODE_TYPES.ObjectExpression
|
|
337
|
+
) {
|
|
338
|
+
optionsArg = callExpr.arguments[1];
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// 提取现有选项,同时处理 nullable
|
|
343
|
+
let hasNullableProperty = false;
|
|
344
|
+
if (optionsArg) {
|
|
345
|
+
optionsArg.properties.forEach((prop: TSESTree.ObjectLiteralElement) => {
|
|
346
|
+
// 处理展开运算符 (SpreadElement)
|
|
347
|
+
if (prop.type === AST_NODE_TYPES.SpreadElement) {
|
|
348
|
+
const propText = source.getText(prop);
|
|
349
|
+
existingOptions.push(propText);
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
// 处理普通属性 (Property)
|
|
353
|
+
if (
|
|
354
|
+
prop.key.type === AST_NODE_TYPES.Identifier &&
|
|
355
|
+
prop.key.name === "nullable"
|
|
356
|
+
) {
|
|
357
|
+
hasNullableProperty = true;
|
|
358
|
+
// 根据类型决定是否保留或更新 nullable
|
|
359
|
+
if (info.isNullable) {
|
|
360
|
+
existingOptions.push("nullable: true");
|
|
361
|
+
}
|
|
362
|
+
// 如果不需要 nullable,则跳过(不保留)
|
|
363
|
+
} else {
|
|
364
|
+
// 保留原有的其他配置
|
|
365
|
+
const propText = source.getText(prop);
|
|
366
|
+
existingOptions.push(propText);
|
|
367
|
+
}
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// 如果需要 nullable 但原配置中没有,则添加到最前面
|
|
372
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
373
|
+
if (info.isNullable && !hasNullableProperty) {
|
|
374
|
+
existingOptions.unshift("nullable: true");
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// 构建新的装饰器文本
|
|
378
|
+
const optionsExpr =
|
|
379
|
+
existingOptions.length > 0 ? `, { ${existingOptions.join(", ")} }` : "";
|
|
380
|
+
const newDecoratorText = `@Field(${typeExpr}${optionsExpr})`;
|
|
381
|
+
|
|
382
|
+
ensureScalarImport(fixes, info.typeName ?? "");
|
|
383
|
+
if (info.typeName === "GraphQLJSONObject") {
|
|
384
|
+
ensureJSONObjectImport(fixes);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
fixes.push({
|
|
388
|
+
type: "replace",
|
|
389
|
+
range: fieldDecorator.range,
|
|
390
|
+
text: newDecoratorText,
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
return fixes;
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
const applyFixes = (fixer: RuleFixer, fixes: CustomFix[]) => {
|
|
397
|
+
return fixes.map((fix) => {
|
|
398
|
+
if (fix.type === "replace") {
|
|
399
|
+
return fixer.replaceTextRange(fix.range, fix.text);
|
|
400
|
+
} else {
|
|
401
|
+
return fixer.insertTextBeforeRange(fix.range, fix.text);
|
|
402
|
+
}
|
|
403
|
+
});
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
const isGraphqlModelClass = (node: TSESTree.ClassDeclaration): boolean => {
|
|
407
|
+
return hasClassDecorator(node, ["ObjectType", "InputType", "ArgsType"]);
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
return {
|
|
411
|
+
ClassDeclaration(node) {
|
|
412
|
+
if (!isGraphqlModelClass(node)) return;
|
|
413
|
+
|
|
414
|
+
node.body.body.forEach((member: TSESTree.ClassElement) => {
|
|
415
|
+
if (member.type !== AST_NODE_TYPES.PropertyDefinition) return;
|
|
416
|
+
|
|
417
|
+
const decoratorConfig = options.decorators ?? {
|
|
418
|
+
HideField: "remove",
|
|
419
|
+
OneToOne: "remove",
|
|
420
|
+
OneToMany: "remove",
|
|
421
|
+
ManyToOne: "remove",
|
|
422
|
+
ManyToMany: "remove",
|
|
423
|
+
};
|
|
424
|
+
|
|
425
|
+
// 检查是否有配置的装饰器
|
|
426
|
+
let foundDecoratorName: string | null = null;
|
|
427
|
+
let foundBehavior: DecoratorBehavior | null = null;
|
|
428
|
+
|
|
429
|
+
for (const decorator of member.decorators) {
|
|
430
|
+
if (
|
|
431
|
+
decorator.expression.type === AST_NODE_TYPES.CallExpression &&
|
|
432
|
+
decorator.expression.callee.type === AST_NODE_TYPES.Identifier
|
|
433
|
+
) {
|
|
434
|
+
const decoratorName = decorator.expression.callee.name;
|
|
435
|
+
if (decoratorName in decoratorConfig) {
|
|
436
|
+
foundDecoratorName = decoratorName;
|
|
437
|
+
foundBehavior = decoratorConfig[decoratorName];
|
|
438
|
+
break;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// 如果是 ignore 行为,直接跳过
|
|
444
|
+
if (foundBehavior === "ignore") return;
|
|
445
|
+
|
|
446
|
+
const fieldDecorator = getPropertyDecorator(member, "Field");
|
|
447
|
+
|
|
448
|
+
// 如果是 remove 行为且有 @Field,则移除 @Field
|
|
449
|
+
if (foundBehavior === "remove" && fieldDecorator) {
|
|
450
|
+
context.report({
|
|
451
|
+
node: fieldDecorator,
|
|
452
|
+
messageId: "removeFieldDecorator",
|
|
453
|
+
data: {
|
|
454
|
+
decoratorName: foundDecoratorName ?? "unknown",
|
|
455
|
+
},
|
|
456
|
+
fix: (fixer) => {
|
|
457
|
+
// 移除整个装饰器行(包括换行)
|
|
458
|
+
const decoratorStart = fieldDecorator.range[0];
|
|
459
|
+
const decoratorEnd = fieldDecorator.range[1];
|
|
460
|
+
|
|
461
|
+
// 查找装饰器后的换行符和空格
|
|
462
|
+
const textAfter = source.text.slice(
|
|
463
|
+
decoratorEnd,
|
|
464
|
+
decoratorEnd + 10,
|
|
465
|
+
);
|
|
466
|
+
const matchNewline = /^(\r?\n\s*)/.exec(textAfter);
|
|
467
|
+
const endPos = matchNewline
|
|
468
|
+
? decoratorEnd + matchNewline[0].length
|
|
469
|
+
: decoratorEnd;
|
|
470
|
+
|
|
471
|
+
return fixer.removeRange([decoratorStart, endPos]);
|
|
472
|
+
},
|
|
473
|
+
});
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// 如果是 remove 行为但没有 @Field,跳过
|
|
478
|
+
if (foundBehavior === "remove") return;
|
|
479
|
+
|
|
480
|
+
const typeInfo = computeTypeInfo(member);
|
|
481
|
+
if (!typeInfo?.typeName) return;
|
|
482
|
+
|
|
483
|
+
// 如果没有 @Field 装饰器,添加它
|
|
484
|
+
if (!fieldDecorator) {
|
|
485
|
+
context.report({
|
|
486
|
+
node: member,
|
|
487
|
+
messageId: "alignFieldDecoratorWithTsType",
|
|
488
|
+
fix: (fixer) => {
|
|
489
|
+
const fixes = addFieldDecorator(member, typeInfo);
|
|
490
|
+
return applyFixes(fixer, fixes);
|
|
491
|
+
},
|
|
492
|
+
});
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// 若已有 ArrowFunction 但与期望不一致,也进行修正
|
|
497
|
+
const callExpr = fieldDecorator.expression;
|
|
498
|
+
if (callExpr.type !== AST_NODE_TYPES.CallExpression) return;
|
|
499
|
+
|
|
500
|
+
let needReport = true;
|
|
501
|
+
|
|
502
|
+
if (
|
|
503
|
+
callExpr.arguments.length > 0 &&
|
|
504
|
+
callExpr.arguments[0].type ===
|
|
505
|
+
AST_NODE_TYPES.ArrowFunctionExpression
|
|
506
|
+
) {
|
|
507
|
+
const firstArg = callExpr.arguments[0];
|
|
508
|
+
const calleeText = source.getText(firstArg.body);
|
|
509
|
+
const expectedTypeText = typeInfo.isArray
|
|
510
|
+
? `[${typeInfo.typeName}]`
|
|
511
|
+
: typeInfo.typeName;
|
|
512
|
+
|
|
513
|
+
const hasNullableOption =
|
|
514
|
+
callExpr.arguments.length > 1 &&
|
|
515
|
+
callExpr.arguments[1].type === AST_NODE_TYPES.ObjectExpression &&
|
|
516
|
+
callExpr.arguments[1].properties.some(
|
|
517
|
+
(prop: TSESTree.ObjectLiteralElement) => {
|
|
518
|
+
return (
|
|
519
|
+
prop.type === AST_NODE_TYPES.Property &&
|
|
520
|
+
prop.key.type === AST_NODE_TYPES.Identifier &&
|
|
521
|
+
prop.key.name === "nullable" &&
|
|
522
|
+
prop.value.type === AST_NODE_TYPES.Literal &&
|
|
523
|
+
prop.value.value === true
|
|
524
|
+
);
|
|
525
|
+
},
|
|
526
|
+
);
|
|
527
|
+
|
|
528
|
+
// 对于 number 类型,Int 和 Float 都是合法的
|
|
529
|
+
const isNumberType = typeInfo.typeName === "Float";
|
|
530
|
+
const actualTypeText = calleeText.replace(/^\[|\]$/g, ""); // 移除数组括号
|
|
531
|
+
const isValidNumberType =
|
|
532
|
+
isNumberType &&
|
|
533
|
+
(actualTypeText === "Int" || actualTypeText === "Float");
|
|
534
|
+
|
|
535
|
+
const typeMatches =
|
|
536
|
+
calleeText === expectedTypeText || isValidNumberType;
|
|
537
|
+
|
|
538
|
+
if (typeMatches && hasNullableOption === typeInfo.isNullable) {
|
|
539
|
+
needReport = false;
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
if (!needReport) return;
|
|
544
|
+
|
|
545
|
+
context.report({
|
|
546
|
+
node: member,
|
|
547
|
+
messageId: "alignFieldDecoratorWithTsType",
|
|
548
|
+
fix: (fixer) => {
|
|
549
|
+
const fixes = fixWithTypeInfo(member, fieldDecorator, typeInfo);
|
|
550
|
+
return applyFixes(fixer, fixes);
|
|
551
|
+
},
|
|
552
|
+
});
|
|
553
|
+
});
|
|
554
|
+
},
|
|
555
|
+
};
|
|
556
|
+
},
|
|
557
|
+
});
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { tester } from "../../utils/tester";
|
|
2
|
+
import rule from "./graphql-field-definite-assignment";
|
|
3
|
+
|
|
4
|
+
tester.run("graphql-field-definite-assignment", rule, {
|
|
5
|
+
valid: [
|
|
6
|
+
// 有初始化值的属性,不需要 !
|
|
7
|
+
/* typescript */ `
|
|
8
|
+
@ObjectType()
|
|
9
|
+
class User {
|
|
10
|
+
@Field()
|
|
11
|
+
createdAt: Date = new Date();
|
|
12
|
+
}
|
|
13
|
+
`,
|
|
14
|
+
// 有 ! 的属性,没有初始化值
|
|
15
|
+
/* typescript */ `
|
|
16
|
+
@ObjectType()
|
|
17
|
+
class User {
|
|
18
|
+
@Field()
|
|
19
|
+
name!: string;
|
|
20
|
+
}
|
|
21
|
+
`,
|
|
22
|
+
// 可选属性,不需要 !
|
|
23
|
+
/* typescript */ `
|
|
24
|
+
@ObjectType()
|
|
25
|
+
class User {
|
|
26
|
+
@Field()
|
|
27
|
+
age?: number;
|
|
28
|
+
}
|
|
29
|
+
`,
|
|
30
|
+
// InputType 类型
|
|
31
|
+
/* typescript */ `
|
|
32
|
+
@InputType()
|
|
33
|
+
class CreateUserInput {
|
|
34
|
+
@Field()
|
|
35
|
+
name!: string;
|
|
36
|
+
}
|
|
37
|
+
`,
|
|
38
|
+
// ArgsType 类型
|
|
39
|
+
/* typescript */ `
|
|
40
|
+
@ArgsType()
|
|
41
|
+
class GetUserArgs {
|
|
42
|
+
@Field()
|
|
43
|
+
id!: string;
|
|
44
|
+
}
|
|
45
|
+
`,
|
|
46
|
+
// 非 GraphQL 模型类不检查
|
|
47
|
+
/* typescript */ `
|
|
48
|
+
class NotAGraphQLModel {
|
|
49
|
+
@Field()
|
|
50
|
+
field: string;
|
|
51
|
+
}
|
|
52
|
+
`,
|
|
53
|
+
// 没有 @Field 装饰器不检查
|
|
54
|
+
/* typescript */ `
|
|
55
|
+
@ObjectType()
|
|
56
|
+
class User {
|
|
57
|
+
field: string;
|
|
58
|
+
}
|
|
59
|
+
`,
|
|
60
|
+
],
|
|
61
|
+
invalid: [
|
|
62
|
+
// 没有初始化值,也没有 !
|
|
63
|
+
{
|
|
64
|
+
code: /* typescript */ `
|
|
65
|
+
@ObjectType()
|
|
66
|
+
class User {
|
|
67
|
+
@Field()
|
|
68
|
+
name: string;
|
|
69
|
+
}
|
|
70
|
+
`,
|
|
71
|
+
output: /* typescript */ `
|
|
72
|
+
@ObjectType()
|
|
73
|
+
class User {
|
|
74
|
+
@Field()
|
|
75
|
+
name!: string;
|
|
76
|
+
}
|
|
77
|
+
`,
|
|
78
|
+
errors: [{ messageId: "addDefiniteAssignment" }],
|
|
79
|
+
},
|
|
80
|
+
// 有初始化值,但也有 !
|
|
81
|
+
{
|
|
82
|
+
code: /* typescript */ `
|
|
83
|
+
@ObjectType()
|
|
84
|
+
class User {
|
|
85
|
+
@Field()
|
|
86
|
+
createdAt!: Date = new Date();
|
|
87
|
+
}
|
|
88
|
+
`,
|
|
89
|
+
output: /* typescript */ `
|
|
90
|
+
@ObjectType()
|
|
91
|
+
class User {
|
|
92
|
+
@Field()
|
|
93
|
+
createdAt: Date = new Date();
|
|
94
|
+
}
|
|
95
|
+
`,
|
|
96
|
+
errors: [{ messageId: "removeDefiniteAssignment" }],
|
|
97
|
+
},
|
|
98
|
+
// InputType - 没有初始化值,也没有 !
|
|
99
|
+
{
|
|
100
|
+
code: /* typescript */ `
|
|
101
|
+
@InputType()
|
|
102
|
+
class CreateUserInput {
|
|
103
|
+
@Field()
|
|
104
|
+
name: string;
|
|
105
|
+
}
|
|
106
|
+
`,
|
|
107
|
+
output: /* typescript */ `
|
|
108
|
+
@InputType()
|
|
109
|
+
class CreateUserInput {
|
|
110
|
+
@Field()
|
|
111
|
+
name!: string;
|
|
112
|
+
}
|
|
113
|
+
`,
|
|
114
|
+
errors: [{ messageId: "addDefiniteAssignment" }],
|
|
115
|
+
},
|
|
116
|
+
// 数字类型,没有初始化值和 !
|
|
117
|
+
{
|
|
118
|
+
code: /* typescript */ `
|
|
119
|
+
@ObjectType()
|
|
120
|
+
class User {
|
|
121
|
+
@Field()
|
|
122
|
+
age: number;
|
|
123
|
+
}
|
|
124
|
+
`,
|
|
125
|
+
output: /* typescript */ `
|
|
126
|
+
@ObjectType()
|
|
127
|
+
class User {
|
|
128
|
+
@Field()
|
|
129
|
+
age!: number;
|
|
130
|
+
}
|
|
131
|
+
`,
|
|
132
|
+
errors: [{ messageId: "addDefiniteAssignment" }],
|
|
133
|
+
},
|
|
134
|
+
],
|
|
135
|
+
});
|