@checkdigit/eslint-plugin 7.18.0-PR.143-b6d4 → 7.18.0-PR.143-c73c
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/LICENSE.txt +1 -1
- package/dist-mjs/athena/athena.mjs +3 -473
- package/dist-mjs/athena/sql-file.mjs +100 -0
- package/dist-mjs/athena/validate.mjs +480 -0
- package/dist-mjs/index.mjs +18 -5
- package/dist-mjs/sql-parser.mjs +25 -0
- package/dist-types/athena/athena.d.ts +1 -2
- package/dist-types/athena/sql-file.d.ts +5 -0
- package/dist-types/athena/validate.d.ts +14 -0
- package/dist-types/index.d.ts +1 -0
- package/dist-types/sql-parser.d.ts +25 -0
- package/package.json +1 -1
- package/src/athena/athena.ts +3 -654
- package/src/athena/sql-file.ts +116 -0
- package/src/athena/validate.ts +625 -0
- package/src/index.ts +12 -0
- package/src/sql-parser.ts +46 -0
- package/src/athena/ATHENA.md +0 -387
- package/src/athena/PLAN.md +0 -355
|
@@ -0,0 +1,625 @@
|
|
|
1
|
+
// athena/validate.ts
|
|
2
|
+
|
|
3
|
+
/* eslint-disable max-lines */
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
* Copyright (c) 2021-2026 Check Digit, LLC
|
|
7
|
+
*
|
|
8
|
+
* This code is licensed under the MIT license (see LICENSE.txt for details).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { strict as assert } from 'node:assert';
|
|
12
|
+
|
|
13
|
+
import debug from 'debug';
|
|
14
|
+
import { JSONPath } from 'jsonpath-plus';
|
|
15
|
+
import type { SchemaObject } from 'ajv/dist/2020';
|
|
16
|
+
|
|
17
|
+
import type { AST, BaseFrom, From, Select, With } from './types';
|
|
18
|
+
import { matchApi } from './api-matcher.ts';
|
|
19
|
+
import { locateApi } from './api-locator.ts';
|
|
20
|
+
import { createChildContext, type ResolvedColumn, type ResolvedTable, type VisitContext } from './context.ts';
|
|
21
|
+
import { buildServiceTables } from './service-table.ts';
|
|
22
|
+
import {
|
|
23
|
+
containsCastToArray,
|
|
24
|
+
containsCastToMap,
|
|
25
|
+
containsLambda,
|
|
26
|
+
extractBracketAccessorPath,
|
|
27
|
+
extractColumnRefs,
|
|
28
|
+
extractJsonExtractCalls,
|
|
29
|
+
extractJsonExtractPath,
|
|
30
|
+
hasFunctionCalls,
|
|
31
|
+
isBaseFrom,
|
|
32
|
+
isJoin,
|
|
33
|
+
isTableExpr,
|
|
34
|
+
isUnnestFrom,
|
|
35
|
+
} from './visitor.ts';
|
|
36
|
+
|
|
37
|
+
export const SYNTEXT_ERROR = 'SyntextError';
|
|
38
|
+
export const ATHENA_ERROR = 'AthenaError';
|
|
39
|
+
|
|
40
|
+
export class AthenaError extends Error {
|
|
41
|
+
public code: string;
|
|
42
|
+
public ast?: object;
|
|
43
|
+
constructor(code: string, message: string, ast?: object) {
|
|
44
|
+
super(message);
|
|
45
|
+
this.code = code;
|
|
46
|
+
this.name = 'AthenaError';
|
|
47
|
+
if (ast !== undefined) {
|
|
48
|
+
this.ast = ast;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Convert a 0-based character offset in `text` to a 1-based line / 0-based column ESLint location.
|
|
54
|
+
export function offsetToLoc(text: string, offset: number): { line: number; column: number } {
|
|
55
|
+
const prefix = text.slice(0, offset);
|
|
56
|
+
const lines = prefix.split('\n');
|
|
57
|
+
return { line: lines.length, column: lines[lines.length - 1]?.length ?? 0 };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const log = debug('eslint-plugin:athena');
|
|
61
|
+
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
// Helpers
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
function resolvedCol(name: string, schema: SchemaObject, ast?: object): ResolvedColumn {
|
|
67
|
+
return ast !== undefined ? { name, schema, ast } : { name, schema };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function getApiSchemas(serviceName: string, ctx: VisitContext) {
|
|
71
|
+
let schemas = ctx.apiSchemas.get(serviceName);
|
|
72
|
+
if (schemas === undefined) {
|
|
73
|
+
schemas = locateApi(serviceName);
|
|
74
|
+
ctx.apiSchemas.set(serviceName, schemas);
|
|
75
|
+
}
|
|
76
|
+
return schemas;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function lookupTables(nameOrAlias: string, ctx: VisitContext): ResolvedTable[] {
|
|
80
|
+
const canonical = ctx.aliases.get(nameOrAlias) ?? nameOrAlias;
|
|
81
|
+
return ctx.tables.get(canonical) ?? [];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function fromClauseItems(select: Select): From[] {
|
|
85
|
+
if (Array.isArray(select.from)) {
|
|
86
|
+
return select.from;
|
|
87
|
+
}
|
|
88
|
+
if (select.from !== null) {
|
|
89
|
+
return [select.from];
|
|
90
|
+
}
|
|
91
|
+
return [];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
// Pass 1 — Resolve FROM clause: service tables → ctx.tables + ctx.aliases
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
function resolveServiceTable(select: Select, item: BaseFrom, ctx: VisitContext): void {
|
|
99
|
+
const { table: tableName } = item;
|
|
100
|
+
try {
|
|
101
|
+
const apiSchemas = getApiSchemas(tableName, ctx);
|
|
102
|
+
if (apiSchemas.length === 0) {
|
|
103
|
+
throw new AthenaError(ATHENA_ERROR, `service not found: "${tableName}" (no swagger schema located)`, item);
|
|
104
|
+
}
|
|
105
|
+
const operations = matchApi(select, item, apiSchemas) ?? [];
|
|
106
|
+
ctx.tables.set(tableName, buildServiceTables(tableName, operations));
|
|
107
|
+
} catch (error) {
|
|
108
|
+
if (error instanceof AthenaError) {
|
|
109
|
+
throw error;
|
|
110
|
+
}
|
|
111
|
+
throw new AthenaError(ATHENA_ERROR, error instanceof Error ? error.message : String(error), item);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function restrictToFromClause(select: Select, ctx: VisitContext): void {
|
|
116
|
+
const fromNames = new Set<string>();
|
|
117
|
+
for (const item of fromClauseItems(select)) {
|
|
118
|
+
if (isBaseFrom(item) || isJoin(item)) {
|
|
119
|
+
fromNames.add(item.table);
|
|
120
|
+
} else if (isTableExpr(item)) {
|
|
121
|
+
fromNames.add(typeof item.as === 'string' ? item.as : '<subquery>');
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
for (const name of [...ctx.tables.keys()]) {
|
|
125
|
+
if (!fromNames.has(name)) {
|
|
126
|
+
ctx.tables.delete(name);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function resolveFromClause(select: Select, ctx: VisitContext): void {
|
|
132
|
+
for (const item of fromClauseItems(select)) {
|
|
133
|
+
if (isUnnestFrom(item)) {
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (isTableExpr(item)) {
|
|
138
|
+
const alias = typeof item.as === 'string' ? item.as : '<subquery>';
|
|
139
|
+
// eslint-disable-next-line no-use-before-define
|
|
140
|
+
checkSelect(item.expr.ast, ctx, alias);
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (isJoin(item)) {
|
|
145
|
+
const { table: tableName, as: alias } = item;
|
|
146
|
+
if (alias !== null) {
|
|
147
|
+
ctx.aliases.set(alias, tableName);
|
|
148
|
+
}
|
|
149
|
+
if (!ctx.tables.has(tableName)) {
|
|
150
|
+
resolveServiceTable(select, item, ctx);
|
|
151
|
+
}
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (!isBaseFrom(item)) {
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const { table: tableName, as: alias } = item;
|
|
160
|
+
|
|
161
|
+
if (alias !== null) {
|
|
162
|
+
ctx.aliases.set(alias, tableName);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (ctx.tables.has(tableName)) {
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
resolveServiceTable(select, item, ctx);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
// UNNEST handling
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
interface UnnestMapping {
|
|
178
|
+
fromColumn: string;
|
|
179
|
+
toColumns: string[];
|
|
180
|
+
ast: object;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function extractUnnestMappings(select: Select): UnnestMapping[] {
|
|
184
|
+
const mappings: UnnestMapping[] = [];
|
|
185
|
+
|
|
186
|
+
for (const item of fromClauseItems(select)) {
|
|
187
|
+
if (!isUnnestFrom(item)) {
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const fromColumn = typeof item.expr.column === 'string' ? item.expr.column : undefined;
|
|
192
|
+
assert.ok(fromColumn !== undefined, 'UNNEST expr must be a column_ref with a string column name');
|
|
193
|
+
|
|
194
|
+
const aliasArgs = item.as?.args.value ?? [];
|
|
195
|
+
const toColumns: string[] = [];
|
|
196
|
+
for (const col of aliasArgs) {
|
|
197
|
+
if (typeof (col as { column?: unknown }).column === 'string') {
|
|
198
|
+
toColumns.push((col as { column: string }).column);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
assert.ok(toColumns.length > 0, 'UNNEST alias must have at least one column name');
|
|
202
|
+
|
|
203
|
+
mappings.push({ fromColumn, toColumns, ast: item.expr });
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return mappings;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function applyUnnestPre(mappings: UnnestMapping[], ctx: VisitContext): UnnestMapping[] {
|
|
210
|
+
const deferred: UnnestMapping[] = [];
|
|
211
|
+
|
|
212
|
+
for (const { fromColumn, toColumns, ast } of mappings) {
|
|
213
|
+
const ownerTable = [...ctx.tables.values()].flat().find((table) => table.columns.has(fromColumn));
|
|
214
|
+
|
|
215
|
+
if (ownerTable === undefined) {
|
|
216
|
+
deferred.push({ fromColumn, toColumns, ast });
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const sourceColumns = ownerTable.columns.get(fromColumn) ?? [];
|
|
221
|
+
const sourceSchema = sourceColumns[0]?.schema;
|
|
222
|
+
const unnestTableName = `${ownerTable.name ?? '<anonymous>'}:<unnested>`;
|
|
223
|
+
const apiOperation = ownerTable.apiOperation !== undefined ? { apiOperation: ownerTable.apiOperation } : {};
|
|
224
|
+
|
|
225
|
+
if (sourceSchema?.type === 'array') {
|
|
226
|
+
const [toColumn] = toColumns;
|
|
227
|
+
assert.ok(toColumn !== undefined);
|
|
228
|
+
ctx.tables.set(unnestTableName, [
|
|
229
|
+
{
|
|
230
|
+
name: unnestTableName,
|
|
231
|
+
...apiOperation,
|
|
232
|
+
columns: new Map([[toColumn, [resolvedCol(toColumn, sourceSchema.items, sourceColumns[0]?.ast)]]]),
|
|
233
|
+
},
|
|
234
|
+
]);
|
|
235
|
+
} else if (sourceSchema?.type === 'object') {
|
|
236
|
+
const [keyColumn, valueColumn] = toColumns;
|
|
237
|
+
assert.ok(
|
|
238
|
+
keyColumn !== undefined && valueColumn !== undefined,
|
|
239
|
+
`UNNEST of map column '${fromColumn}' requires exactly two alias columns (key, value)`,
|
|
240
|
+
);
|
|
241
|
+
const addlProps = (sourceSchema as SchemaObject)['additionalProperties'] as unknown;
|
|
242
|
+
const valueSchema: SchemaObject =
|
|
243
|
+
typeof addlProps === 'object' && addlProps !== null ? addlProps : { type: 'string' };
|
|
244
|
+
ctx.tables.set(unnestTableName, [
|
|
245
|
+
{
|
|
246
|
+
name: unnestTableName,
|
|
247
|
+
...apiOperation,
|
|
248
|
+
columns: new Map([
|
|
249
|
+
[keyColumn, [resolvedCol(keyColumn, { type: 'string' }, sourceColumns[0]?.ast)]],
|
|
250
|
+
[valueColumn, [resolvedCol(valueColumn, valueSchema, sourceColumns[0]?.ast)]],
|
|
251
|
+
]),
|
|
252
|
+
},
|
|
253
|
+
]);
|
|
254
|
+
} else {
|
|
255
|
+
throw new AthenaError(
|
|
256
|
+
ATHENA_ERROR,
|
|
257
|
+
`UNNEST source column '${fromColumn}' must resolve to an array or map schema`,
|
|
258
|
+
ast,
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return deferred;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function applyUnnestPost(mappings: UnnestMapping[], columns: Map<string, ResolvedColumn[]>): void {
|
|
267
|
+
for (const { fromColumn, toColumns, ast } of mappings) {
|
|
268
|
+
const sourceColumns = columns.get(fromColumn);
|
|
269
|
+
if (sourceColumns === undefined) {
|
|
270
|
+
throw new AthenaError(ATHENA_ERROR, `UNNEST source column '${fromColumn}' not found in SELECT`, ast);
|
|
271
|
+
}
|
|
272
|
+
const sourceSchema = sourceColumns[0]?.schema;
|
|
273
|
+
|
|
274
|
+
if (sourceSchema?.type === 'array') {
|
|
275
|
+
const [toColumn] = toColumns;
|
|
276
|
+
assert.ok(toColumn !== undefined);
|
|
277
|
+
columns.set(toColumn, [resolvedCol(toColumn, sourceSchema.items, sourceColumns[0]?.ast)]);
|
|
278
|
+
} else if (sourceSchema?.type === 'object') {
|
|
279
|
+
const [keyColumn, valueColumn] = toColumns;
|
|
280
|
+
assert.ok(
|
|
281
|
+
keyColumn !== undefined && valueColumn !== undefined,
|
|
282
|
+
`UNNEST of map column '${fromColumn}' requires exactly two alias columns (key, value)`,
|
|
283
|
+
);
|
|
284
|
+
const addlProps = (sourceSchema as SchemaObject)['additionalProperties'] as unknown;
|
|
285
|
+
const valueSchema: SchemaObject =
|
|
286
|
+
typeof addlProps === 'object' && addlProps !== null ? addlProps : { type: 'string' };
|
|
287
|
+
columns.set(keyColumn, [resolvedCol(keyColumn, { type: 'string' }, sourceColumns[0]?.ast)]);
|
|
288
|
+
columns.set(valueColumn, [resolvedCol(valueColumn, valueSchema, sourceColumns[0]?.ast)]);
|
|
289
|
+
} else {
|
|
290
|
+
throw new AthenaError(
|
|
291
|
+
ATHENA_ERROR,
|
|
292
|
+
`UNNEST source column '${fromColumn}' must resolve to an array or map schema`,
|
|
293
|
+
ast,
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// ---------------------------------------------------------------------------
|
|
300
|
+
// Pass 2 — Resolve SELECT columns helpers
|
|
301
|
+
// ---------------------------------------------------------------------------
|
|
302
|
+
|
|
303
|
+
function resolveDefaultSchemaColumn(
|
|
304
|
+
columnAlias: string | null,
|
|
305
|
+
indexedName: string,
|
|
306
|
+
columnAST: unknown,
|
|
307
|
+
columns: Map<string, ResolvedColumn[]>,
|
|
308
|
+
): void {
|
|
309
|
+
const name = columnAlias ?? indexedName;
|
|
310
|
+
columns.set(name, [resolvedCol(name, { type: 'string' }, columnAST as object)]);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function expandWildcard(referencedTables: ResolvedTable[], columns: Map<string, ResolvedColumn[]>): void {
|
|
314
|
+
for (const table of referencedTables) {
|
|
315
|
+
for (const [colName, cols] of table.columns) {
|
|
316
|
+
columns.set(colName, cols);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function schemaPropertyHint(resolvedColumns: ResolvedColumn[]): string {
|
|
322
|
+
if (resolvedColumns.length === 0) {
|
|
323
|
+
return '';
|
|
324
|
+
}
|
|
325
|
+
const first = JSON.stringify(resolvedColumns[0]?.schema);
|
|
326
|
+
if (!resolvedColumns.every((col) => JSON.stringify(col.schema) === first)) {
|
|
327
|
+
return '';
|
|
328
|
+
}
|
|
329
|
+
const schema = resolvedColumns[0]?.schema;
|
|
330
|
+
if (schema?.type !== 'object') {
|
|
331
|
+
return '';
|
|
332
|
+
}
|
|
333
|
+
const properties = schema.properties;
|
|
334
|
+
if (properties === undefined) {
|
|
335
|
+
return '';
|
|
336
|
+
}
|
|
337
|
+
const propNames = Object.keys(properties);
|
|
338
|
+
return propNames.length > 0 ? `; available properties: ${propNames.join(', ')}` : '';
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function resolveSchemaAtPath(
|
|
342
|
+
colRef: string,
|
|
343
|
+
propertyAccessor: string,
|
|
344
|
+
resolvedColumns: ResolvedColumn[],
|
|
345
|
+
ast?: object,
|
|
346
|
+
): SchemaObject[] {
|
|
347
|
+
// Double-dot handles allOf / anyOf / oneOf wrappers that may appear in the schema.
|
|
348
|
+
// eslint-disable-next-line prefer-named-capture-group
|
|
349
|
+
const adjustedPath = `$.${propertyAccessor.substring(1).replace(/(\.|\[)/gu, '..properties$1')}`;
|
|
350
|
+
log('adjusted path', adjustedPath);
|
|
351
|
+
|
|
352
|
+
const extractedSchemas = resolvedColumns.flatMap((col) =>
|
|
353
|
+
JSONPath<SchemaObject[]>({ json: col.schema, path: adjustedPath }),
|
|
354
|
+
);
|
|
355
|
+
log('extracted schemas', extractedSchemas);
|
|
356
|
+
|
|
357
|
+
if (extractedSchemas.length === 0) {
|
|
358
|
+
throw new AthenaError(
|
|
359
|
+
ATHENA_ERROR,
|
|
360
|
+
`property not found ${colRef} - ${propertyAccessor}${schemaPropertyHint(resolvedColumns)}`,
|
|
361
|
+
ast,
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
return extractedSchemas;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function navigateSchemaPath(
|
|
368
|
+
colRef: string,
|
|
369
|
+
propertyAccessor: string,
|
|
370
|
+
resolvedColumns: ResolvedColumn[],
|
|
371
|
+
colName: string,
|
|
372
|
+
columnAST: unknown,
|
|
373
|
+
columns: Map<string, ResolvedColumn[]>,
|
|
374
|
+
): void {
|
|
375
|
+
const errorAst = (extractJsonExtractCalls(columnAST)[0]?.fnNode ?? columnAST) as object;
|
|
376
|
+
const extractedSchemas = resolveSchemaAtPath(colRef, propertyAccessor, resolvedColumns, errorAst);
|
|
377
|
+
columns.set(
|
|
378
|
+
colName,
|
|
379
|
+
extractedSchemas.map((schema) => resolvedCol(colName, schema, columnAST as object)),
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// ---------------------------------------------------------------------------
|
|
384
|
+
// Pass 2 — Resolve SELECT columns → Map<name, ResolvedColumn[]>
|
|
385
|
+
// ---------------------------------------------------------------------------
|
|
386
|
+
|
|
387
|
+
function lookupColumnOrThrow(colRef: string, ref: object, referencedTables: ResolvedTable[]): ResolvedColumn[] {
|
|
388
|
+
const resolvedColumns = referencedTables.flatMap((table) => table.columns.get(colRef) ?? []);
|
|
389
|
+
if (resolvedColumns.length === 0) {
|
|
390
|
+
const tableNames = [
|
|
391
|
+
...new Set(referencedTables.map((referenceTable) => referenceTable.name ?? '<anonymous>')),
|
|
392
|
+
].join(', ');
|
|
393
|
+
const availableCols = [...new Set(referencedTables.flatMap((table) => [...table.columns.keys()]))].join(', ');
|
|
394
|
+
throw new AthenaError(
|
|
395
|
+
ATHENA_ERROR,
|
|
396
|
+
`can't found column ${colRef} in tables: ${tableNames}; available columns: ${availableCols}`,
|
|
397
|
+
ref,
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
return resolvedColumns;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function checkColumnRefsExist(
|
|
404
|
+
ast: unknown,
|
|
405
|
+
allTables: ResolvedTable[],
|
|
406
|
+
ctx: VisitContext,
|
|
407
|
+
selectColumns?: Map<string, ResolvedColumn[]>,
|
|
408
|
+
): void {
|
|
409
|
+
if (containsLambda(ast)) {
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
for (const ref of extractColumnRefs(ast)) {
|
|
413
|
+
const colRef = typeof ref.column === 'string' ? ref.column : undefined;
|
|
414
|
+
if (colRef === undefined || colRef === '*') {
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
417
|
+
const tableRef = ref.table ?? undefined;
|
|
418
|
+
if (tableRef === undefined && selectColumns?.has(colRef) === true) {
|
|
419
|
+
continue;
|
|
420
|
+
}
|
|
421
|
+
const referencedTables = tableRef !== undefined ? lookupTables(tableRef, ctx) : allTables;
|
|
422
|
+
if (referencedTables.length === 0) {
|
|
423
|
+
throw new AthenaError(
|
|
424
|
+
ATHENA_ERROR,
|
|
425
|
+
`unknown table or alias '${tableRef ?? colRef}'; known tables: ${[...ctx.tables.keys()].join(', ')}`,
|
|
426
|
+
ref,
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
lookupColumnOrThrow(colRef, ref, referencedTables);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function validateComplexColumnExpression(columnAST: unknown, allTables: ResolvedTable[], ctx: VisitContext): void {
|
|
434
|
+
for (const { ref, path, fnNode } of extractJsonExtractCalls(columnAST)) {
|
|
435
|
+
const tableRef = ref.table ?? undefined;
|
|
436
|
+
const colRef = typeof ref.column === 'string' ? ref.column : undefined;
|
|
437
|
+
if (colRef === undefined) {
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
const referencedTables = tableRef !== undefined ? lookupTables(tableRef, ctx) : allTables;
|
|
441
|
+
const resolvedColumns = referencedTables.flatMap((table) => table.columns.get(colRef) ?? []);
|
|
442
|
+
if (resolvedColumns.length > 0) {
|
|
443
|
+
resolveSchemaAtPath(colRef, path, resolvedColumns, fnNode);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function resolveSingleColumnRef(
|
|
449
|
+
columnAST: unknown,
|
|
450
|
+
columnAlias: string | null,
|
|
451
|
+
indexedName: string,
|
|
452
|
+
ref: NonNullable<ReturnType<typeof extractColumnRefs>[number]>,
|
|
453
|
+
allTables: ResolvedTable[],
|
|
454
|
+
ctx: VisitContext,
|
|
455
|
+
columns: Map<string, ResolvedColumn[]>,
|
|
456
|
+
): void {
|
|
457
|
+
const tableRef = ref.table ?? undefined;
|
|
458
|
+
const colRef = typeof ref.column === 'string' ? ref.column : undefined;
|
|
459
|
+
assert.ok(colRef !== undefined, 'column_ref must have a string column name');
|
|
460
|
+
|
|
461
|
+
const referencedTables = tableRef !== undefined ? lookupTables(tableRef, ctx) : allTables;
|
|
462
|
+
if (referencedTables.length === 0) {
|
|
463
|
+
const tableNames = [...ctx.tables.keys()].join(', ');
|
|
464
|
+
throw new AthenaError(
|
|
465
|
+
ATHENA_ERROR,
|
|
466
|
+
`unknown table or alias '${tableRef ?? colRef}'; known tables: ${tableNames}`,
|
|
467
|
+
ref,
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
if (colRef === '*') {
|
|
472
|
+
expandWildcard(referencedTables, columns);
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const withFunctions = hasFunctionCalls(columnAST);
|
|
477
|
+
const colName = columnAlias ?? (withFunctions ? indexedName : colRef);
|
|
478
|
+
const resolvedColumns = lookupColumnOrThrow(colRef, ref, referencedTables);
|
|
479
|
+
|
|
480
|
+
const propertyAccessor = extractJsonExtractPath(columnAST) ?? extractBracketAccessorPath(columnAST);
|
|
481
|
+
if (propertyAccessor !== undefined) {
|
|
482
|
+
navigateSchemaPath(colRef, propertyAccessor, resolvedColumns, colName, columnAST, columns);
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
columns.set(
|
|
487
|
+
colName,
|
|
488
|
+
resolvedColumns.map((col) => resolvedCol(colName, col.schema, columnAST as object)),
|
|
489
|
+
);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function resolveSelectColumns(select: Select, ctx: VisitContext): Map<string, ResolvedColumn[]> {
|
|
493
|
+
const allTables = [...ctx.tables.values()].flat();
|
|
494
|
+
const columns = new Map<string, ResolvedColumn[]>();
|
|
495
|
+
|
|
496
|
+
for (const [index, columnAST] of select.columns.entries()) {
|
|
497
|
+
log('resolving column', columnAST);
|
|
498
|
+
|
|
499
|
+
const columnAlias = (columnAST as { as?: string | null }).as ?? null;
|
|
500
|
+
const indexedName = `_col${String(index)}`;
|
|
501
|
+
const columnRefs = extractColumnRefs(columnAST);
|
|
502
|
+
|
|
503
|
+
if (columnRefs.length !== 1) {
|
|
504
|
+
checkColumnRefsExist(columnAST, allTables, ctx);
|
|
505
|
+
validateComplexColumnExpression(columnAST, allTables, ctx);
|
|
506
|
+
resolveDefaultSchemaColumn(columnAlias, indexedName, columnAST, columns);
|
|
507
|
+
} else {
|
|
508
|
+
const [ref] = columnRefs;
|
|
509
|
+
assert.ok(ref !== undefined);
|
|
510
|
+
resolveSingleColumnRef(columnAST, columnAlias, indexedName, ref, allTables, ctx, columns);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
if (containsCastToArray(columnAST)) {
|
|
514
|
+
const colName = columnAlias ?? indexedName;
|
|
515
|
+
const existing = columns.get(colName);
|
|
516
|
+
if (existing !== undefined && existing[0]?.schema.type !== 'array') {
|
|
517
|
+
columns.set(
|
|
518
|
+
colName,
|
|
519
|
+
existing.map((col) => resolvedCol(col.name, { type: 'array' }, col.ast)),
|
|
520
|
+
);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
if (containsCastToMap(columnAST)) {
|
|
525
|
+
const colName = columnAlias ?? indexedName;
|
|
526
|
+
const existing = columns.get(colName);
|
|
527
|
+
if (existing !== undefined && existing[0]?.schema.type !== 'object') {
|
|
528
|
+
columns.set(
|
|
529
|
+
colName,
|
|
530
|
+
existing.map((col) => resolvedCol(col.name, { type: 'object' }, col.ast)),
|
|
531
|
+
);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
return columns;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// ---------------------------------------------------------------------------
|
|
540
|
+
// Top-level SELECT resolution
|
|
541
|
+
// ---------------------------------------------------------------------------
|
|
542
|
+
|
|
543
|
+
function checkSelect(
|
|
544
|
+
selectAST: Select | With,
|
|
545
|
+
ctx: VisitContext,
|
|
546
|
+
withTableName?: string,
|
|
547
|
+
): Map<string, ResolvedColumn[]> {
|
|
548
|
+
const select = 'stmt' in selectAST ? selectAST.stmt.ast : selectAST;
|
|
549
|
+
const selectCtx = createChildContext(ctx);
|
|
550
|
+
|
|
551
|
+
resolveFromClause(select, selectCtx);
|
|
552
|
+
restrictToFromClause(select, selectCtx);
|
|
553
|
+
|
|
554
|
+
const unnestMappings = extractUnnestMappings(select);
|
|
555
|
+
const deferredUnnest = applyUnnestPre(unnestMappings, selectCtx);
|
|
556
|
+
|
|
557
|
+
const columns = resolveSelectColumns(select, selectCtx);
|
|
558
|
+
|
|
559
|
+
applyUnnestPost(deferredUnnest, columns);
|
|
560
|
+
|
|
561
|
+
log('resolved columns', [...columns.keys()]);
|
|
562
|
+
|
|
563
|
+
const allTables = [...selectCtx.tables.values()].flat();
|
|
564
|
+
for (const item of fromClauseItems(select)) {
|
|
565
|
+
const onExpr = (item as { on?: unknown }).on;
|
|
566
|
+
if (onExpr !== undefined) {
|
|
567
|
+
checkColumnRefsExist(onExpr, allTables, selectCtx);
|
|
568
|
+
validateComplexColumnExpression(onExpr, allTables, selectCtx);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
if (select.where !== null) {
|
|
572
|
+
checkColumnRefsExist(select.where, allTables, selectCtx);
|
|
573
|
+
validateComplexColumnExpression(select.where, allTables, selectCtx);
|
|
574
|
+
}
|
|
575
|
+
if (select.having !== null) {
|
|
576
|
+
checkColumnRefsExist(select.having, allTables, selectCtx, columns);
|
|
577
|
+
validateComplexColumnExpression(select.having, allTables, selectCtx);
|
|
578
|
+
}
|
|
579
|
+
for (const orderItem of select.orderby ?? []) {
|
|
580
|
+
checkColumnRefsExist(orderItem.expr, allTables, selectCtx, columns);
|
|
581
|
+
validateComplexColumnExpression(orderItem.expr, allTables, selectCtx);
|
|
582
|
+
}
|
|
583
|
+
if (select.groupby?.columns !== undefined) {
|
|
584
|
+
for (const groupCol of select.groupby.columns) {
|
|
585
|
+
checkColumnRefsExist(groupCol, allTables, selectCtx, columns);
|
|
586
|
+
validateComplexColumnExpression(groupCol, allTables, selectCtx);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
if (select._next !== undefined) {
|
|
591
|
+
const nextColumns = checkSelect(select._next, ctx, withTableName);
|
|
592
|
+
const currentNumberOfKeys = columns.size;
|
|
593
|
+
const nextNumberOfKeys = nextColumns.size;
|
|
594
|
+
if (currentNumberOfKeys !== nextNumberOfKeys) {
|
|
595
|
+
throw new AthenaError(
|
|
596
|
+
ATHENA_ERROR,
|
|
597
|
+
`UNION ALL parts have different number of columns: ${currentNumberOfKeys.toString()} vs ${nextNumberOfKeys.toString()}`,
|
|
598
|
+
select._next,
|
|
599
|
+
);
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
if (withTableName !== undefined) {
|
|
604
|
+
const resolvedTable: ResolvedTable = { name: withTableName, columns };
|
|
605
|
+
ctx.tables.set(withTableName, [resolvedTable]);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
return columns;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
export function checkAthenaAst(ast: AST, ctx: VisitContext): void {
|
|
612
|
+
assert.ok(ast.type === 'select');
|
|
613
|
+
const select = ast;
|
|
614
|
+
|
|
615
|
+
if (select.with !== null) {
|
|
616
|
+
for (const withItem of select.with) {
|
|
617
|
+
checkSelect(withItem.stmt.ast, ctx, withItem.name.value);
|
|
618
|
+
}
|
|
619
|
+
select.with = null;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
checkSelect(select, ctx);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/* eslint-enable max-lines */
|
package/src/index.ts
CHANGED
|
@@ -30,6 +30,8 @@ import requireAwsConfig, { ruleId as requireAwsConfigRuleId } from './aws/requir
|
|
|
30
30
|
import requireAWSBareBones, { ruleId as requireAWSBareBonesRuleId } from './aws/require-aws-bare-bones.ts';
|
|
31
31
|
import requireConsistentRead, { ruleId as requireConsistentReadRuleId } from './aws/require-consistent-read.ts';
|
|
32
32
|
import athena, { ruleId as athenaRuleId } from './athena/athena.ts';
|
|
33
|
+
import sqlFile, { ruleId as sqlFileRuleId } from './athena/sql-file.ts';
|
|
34
|
+
export { parseForESLint } from './sql-parser.ts';
|
|
33
35
|
import filePathComment from './file-path-comment.ts';
|
|
34
36
|
import noCardNumbers from './no-card-numbers.ts';
|
|
35
37
|
import noEnum from './no-enum.ts';
|
|
@@ -45,6 +47,7 @@ import requireAssertPredicateRejectsThrows from './require-assert-predicate-reje
|
|
|
45
47
|
import requireStrictAssert from './require-strict-assert.ts';
|
|
46
48
|
import requireAssertMessage from './require-assert-message';
|
|
47
49
|
import requireTsExtensionImportsExports from './require-ts-extension-imports-exports.ts';
|
|
50
|
+
import { parseForESLint } from './sql-parser.ts';
|
|
48
51
|
|
|
49
52
|
export { default as isAwsSdkV3Used } from './aws/is-aws-sdk-v3-used.ts';
|
|
50
53
|
|
|
@@ -78,6 +81,7 @@ const rules: Record<string, TSESLint.LooseRuleDefinition> = {
|
|
|
78
81
|
[requireAWSBareBonesRuleId]: requireAWSBareBones,
|
|
79
82
|
[requireConsistentReadRuleId]: requireConsistentRead,
|
|
80
83
|
[athenaRuleId]: athena,
|
|
84
|
+
[sqlFileRuleId]: sqlFile,
|
|
81
85
|
};
|
|
82
86
|
|
|
83
87
|
const plugin: TSESLint.FlatConfig.Plugin = {
|
|
@@ -121,8 +125,15 @@ const configs: Record<string, TSESLint.FlatConfig.Config[]> = {
|
|
|
121
125
|
[`@checkdigit/${requireAwsConfigRuleId}`]: 'error',
|
|
122
126
|
[`@checkdigit/${requireAWSBareBonesRuleId}`]: 'error',
|
|
123
127
|
[`@checkdigit/${athenaRuleId}`]: 'error',
|
|
128
|
+
[`@checkdigit/${sqlFileRuleId}`]: 'error',
|
|
124
129
|
},
|
|
125
130
|
},
|
|
131
|
+
{
|
|
132
|
+
files: ['**/*.sql'],
|
|
133
|
+
plugins: { '@checkdigit': plugin },
|
|
134
|
+
languageOptions: { parser: { parseForESLint } },
|
|
135
|
+
rules: { [`@checkdigit/${sqlFileRuleId}`]: 'error' },
|
|
136
|
+
},
|
|
126
137
|
],
|
|
127
138
|
recommended: [
|
|
128
139
|
{
|
|
@@ -160,6 +171,7 @@ const configs: Record<string, TSESLint.FlatConfig.Config[]> = {
|
|
|
160
171
|
[`@checkdigit/${requireAwsConfigRuleId}`]: 'off',
|
|
161
172
|
[`@checkdigit/${requireAWSBareBonesRuleId}`]: 'off',
|
|
162
173
|
[`@checkdigit/${athenaRuleId}`]: 'off',
|
|
174
|
+
[`@checkdigit/${sqlFileRuleId}`]: 'off',
|
|
163
175
|
},
|
|
164
176
|
},
|
|
165
177
|
],
|