@checkdigit/eslint-plugin 7.18.0-PR.143-b47c → 7.18.0-PR.143-cf9d
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/dist-mjs/athena/api-locator.mjs +6 -9
- package/dist-mjs/athena/api-matcher.mjs +37 -77
- package/dist-mjs/athena/validate.mjs +130 -161
- package/dist-mjs/athena/visitor.mjs +32 -52
- package/dist-mjs/openapi/generate-schema.mjs +17 -23
- package/dist-mjs/openapi/service-schema-generator.mjs +7 -9
- package/dist-types/athena/visitor.d.ts +5 -3
- package/package.json +1 -1
- package/src/athena/api-locator.ts +5 -11
- package/src/athena/api-matcher.ts +45 -79
- package/src/athena/validate.ts +175 -170
- package/src/athena/visitor.ts +38 -71
- package/src/openapi/generate-schema.ts +18 -34
- package/src/openapi/service-schema-generator.ts +7 -8
package/src/athena/validate.ts
CHANGED
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
extractColumnRefs,
|
|
28
28
|
extractJsonExtractCalls,
|
|
29
29
|
extractJsonExtractPath,
|
|
30
|
+
fromClauseItems,
|
|
30
31
|
hasFunctionCalls,
|
|
31
32
|
isBaseFrom,
|
|
32
33
|
isJoin,
|
|
@@ -61,6 +62,7 @@ export function offsetToLoc(text: string, offset: number): { line: number; colum
|
|
|
61
62
|
|
|
62
63
|
const log = debug('eslint-plugin:athena');
|
|
63
64
|
const ANONYMOUS_TABLE = '<anonymous>';
|
|
65
|
+
const SUBQUERY_TABLE = '<subquery>';
|
|
64
66
|
|
|
65
67
|
// ---------------------------------------------------------------------------
|
|
66
68
|
// Helpers
|
|
@@ -80,22 +82,31 @@ function getApiSchemas(serviceName: string, ctx: VisitContext) {
|
|
|
80
82
|
}
|
|
81
83
|
|
|
82
84
|
function lookupTables(nameOrAlias: string, ctx: VisitContext): ResolvedTable[] {
|
|
83
|
-
//
|
|
84
|
-
if (ctx.tables.has(nameOrAlias)) {
|
|
85
|
-
return ctx.tables.get(nameOrAlias) ?? [];
|
|
86
|
-
}
|
|
85
|
+
// Aliases are stored under their alias key in ctx.tables, so direct lookup wins.
|
|
87
86
|
const canonical = ctx.aliases.get(nameOrAlias) ?? nameOrAlias;
|
|
88
|
-
return ctx.tables.get(canonical) ?? [];
|
|
87
|
+
return ctx.tables.get(nameOrAlias) ?? ctx.tables.get(canonical) ?? [];
|
|
89
88
|
}
|
|
90
89
|
|
|
91
|
-
function
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
90
|
+
function flattenTables(ctx: VisitContext): ResolvedTable[] {
|
|
91
|
+
return [...ctx.tables.values()].flat();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function resolveReferencedTables(
|
|
95
|
+
tableRef: string | undefined,
|
|
96
|
+
allTables: ResolvedTable[],
|
|
97
|
+
ctx: VisitContext,
|
|
98
|
+
): ResolvedTable[] {
|
|
99
|
+
return tableRef !== undefined ? lookupTables(tableRef, ctx) : allTables;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function resolveColumnRefParts(ref: { table: string | null; column: unknown }): {
|
|
103
|
+
tableRef: string | undefined;
|
|
104
|
+
colRef: string | undefined;
|
|
105
|
+
} {
|
|
106
|
+
return {
|
|
107
|
+
tableRef: ref.table ?? undefined,
|
|
108
|
+
colRef: typeof ref.column === 'string' ? ref.column : undefined,
|
|
109
|
+
};
|
|
99
110
|
}
|
|
100
111
|
|
|
101
112
|
// ---------------------------------------------------------------------------
|
|
@@ -138,8 +149,7 @@ function extractColumnAliasName(arg: unknown): string | undefined {
|
|
|
138
149
|
}
|
|
139
150
|
const fnNode = arg as { type?: unknown; name?: { name?: { value?: string }[] }; args?: { value?: unknown[] } };
|
|
140
151
|
if (fnNode.type === 'function' && fnNode.args?.value?.length === 0) {
|
|
141
|
-
|
|
142
|
-
return functionName !== undefined ? functionName.toLowerCase() : undefined;
|
|
152
|
+
return fnNode.name?.name?.[0]?.value?.toLowerCase();
|
|
143
153
|
}
|
|
144
154
|
return undefined;
|
|
145
155
|
}
|
|
@@ -149,6 +159,10 @@ function isStandaloneUnnest(node: unknown): node is UnnestFrom {
|
|
|
149
159
|
return isUnnestFrom(node) && typeof (node.expr as { column?: unknown }).column !== 'string';
|
|
150
160
|
}
|
|
151
161
|
|
|
162
|
+
function aliasAsSingleton(alias: string | undefined): string[] {
|
|
163
|
+
return alias !== undefined ? [alias] : [];
|
|
164
|
+
}
|
|
165
|
+
|
|
152
166
|
// Returns every name under which this FROM item can be stored in ctx.tables.
|
|
153
167
|
// Both the alias key and the canonical table name are returned so that service
|
|
154
168
|
// tables (stored under alias) and CTEs (stored under canonical name) both survive
|
|
@@ -158,16 +172,14 @@ function fromItemTableNames(item: From): string[] {
|
|
|
158
172
|
return item.as !== null ? [item.as, item.table] : [item.table];
|
|
159
173
|
}
|
|
160
174
|
if (isTableExpr(item)) {
|
|
161
|
-
return [typeof item.as === 'string' ? item.as :
|
|
175
|
+
return [typeof item.as === 'string' ? item.as : SUBQUERY_TABLE];
|
|
162
176
|
}
|
|
163
177
|
if (isValuesFrom(item)) {
|
|
164
|
-
|
|
165
|
-
return alias !== undefined ? [alias] : [];
|
|
178
|
+
return aliasAsSingleton(getFunctionAliasName(item.as));
|
|
166
179
|
}
|
|
167
180
|
const unknownItem = item as unknown;
|
|
168
181
|
if (isStandaloneUnnest(unknownItem)) {
|
|
169
|
-
|
|
170
|
-
return alias !== undefined ? [alias] : [];
|
|
182
|
+
return aliasAsSingleton(getFunctionAliasName(unknownItem.as));
|
|
171
183
|
}
|
|
172
184
|
return [];
|
|
173
185
|
}
|
|
@@ -185,6 +197,10 @@ function tableIsResolved(tableName: string, storageKey: string, ctx: VisitContex
|
|
|
185
197
|
return ctx.tables.has(storageKey) || ctx.tables.has(tableName);
|
|
186
198
|
}
|
|
187
199
|
|
|
200
|
+
function registerAliasedTable(tableAlias: string, columns: Map<string, ResolvedColumn[]>, ctx: VisitContext): void {
|
|
201
|
+
ctx.tables.set(tableAlias, [{ name: tableAlias, columns }]);
|
|
202
|
+
}
|
|
203
|
+
|
|
188
204
|
// Shared resolution logic for BaseFrom and Join items (Join extends BaseFrom).
|
|
189
205
|
function resolveTableOrJoinItem(select: Select, item: BaseFrom, ctx: VisitContext): void {
|
|
190
206
|
const { table: tableName, as: alias } = item;
|
|
@@ -211,7 +227,7 @@ function resolveFromClause(select: Select, ctx: VisitContext): void {
|
|
|
211
227
|
return [colName, [resolvedCol(colName, {})]];
|
|
212
228
|
}),
|
|
213
229
|
);
|
|
214
|
-
|
|
230
|
+
registerAliasedTable(tableAlias, columns, ctx);
|
|
215
231
|
}
|
|
216
232
|
} else if (isUnnestFrom(unknownItem)) {
|
|
217
233
|
// CROSS JOIN UNNEST with a column ref — handled later by extractUnnestMappings.
|
|
@@ -221,10 +237,10 @@ function resolveFromClause(select: Select, ctx: VisitContext): void {
|
|
|
221
237
|
const columns = new Map(
|
|
222
238
|
item.as.args.value.map((columnRef) => [columnRef.column, [resolvedCol(columnRef.column, {})]]),
|
|
223
239
|
);
|
|
224
|
-
|
|
240
|
+
registerAliasedTable(tableAlias, columns, ctx);
|
|
225
241
|
}
|
|
226
242
|
} else if (isTableExpr(item)) {
|
|
227
|
-
const alias = typeof item.as === 'string' ? item.as :
|
|
243
|
+
const alias = typeof item.as === 'string' ? item.as : SUBQUERY_TABLE;
|
|
228
244
|
// eslint-disable-next-line no-use-before-define
|
|
229
245
|
checkSelect(item.expr.ast, ctx, alias);
|
|
230
246
|
} else if (isJoin(item) || isBaseFrom(item)) {
|
|
@@ -283,11 +299,51 @@ function extractUnnestMappings(select: Select): UnnestMapping[] {
|
|
|
283
299
|
return mappings;
|
|
284
300
|
}
|
|
285
301
|
|
|
302
|
+
// Resolves UNNEST source schema into target column entries.
|
|
303
|
+
// When hasKnownApiOperation is true, an unrecognised schema type throws; otherwise unknown columns
|
|
304
|
+
// are registered with an empty schema (the schema is an estimate for non-API sources).
|
|
305
|
+
function buildUnnestColumnMap(
|
|
306
|
+
fromColumn: string,
|
|
307
|
+
toColumns: string[],
|
|
308
|
+
sourceSchema: ResolvedColumn['schema'] | undefined,
|
|
309
|
+
sourceAst: object | undefined,
|
|
310
|
+
ast: object,
|
|
311
|
+
hasKnownApiOperation: boolean,
|
|
312
|
+
): Map<string, ResolvedColumn[]> {
|
|
313
|
+
if (sourceSchema?.type === 'array') {
|
|
314
|
+
const [toColumn] = toColumns;
|
|
315
|
+
assert.ok(toColumn !== undefined);
|
|
316
|
+
return new Map([[toColumn, [resolvedCol(toColumn, sourceSchema.items, sourceAst)]]]);
|
|
317
|
+
}
|
|
318
|
+
if (sourceSchema?.type === 'object') {
|
|
319
|
+
const [keyColumn, valueColumn] = toColumns;
|
|
320
|
+
assert.ok(
|
|
321
|
+
keyColumn !== undefined && valueColumn !== undefined,
|
|
322
|
+
`UNNEST of map column '${fromColumn}' requires exactly two alias columns (key, value)`,
|
|
323
|
+
);
|
|
324
|
+
const addlProps = (sourceSchema as SchemaObject)['additionalProperties'] as unknown;
|
|
325
|
+
const valueSchema: SchemaObject =
|
|
326
|
+
typeof addlProps === 'object' && addlProps !== null ? addlProps : { type: 'string' };
|
|
327
|
+
return new Map([
|
|
328
|
+
[keyColumn, [resolvedCol(keyColumn, { type: 'string' }, sourceAst)]],
|
|
329
|
+
[valueColumn, [resolvedCol(valueColumn, valueSchema, sourceAst)]],
|
|
330
|
+
]);
|
|
331
|
+
}
|
|
332
|
+
if (!hasKnownApiOperation) {
|
|
333
|
+
return new Map(toColumns.map((toColumn) => [toColumn, [resolvedCol(toColumn, {})]]));
|
|
334
|
+
}
|
|
335
|
+
throw new AthenaError(
|
|
336
|
+
ATHENA_ERROR,
|
|
337
|
+
`UNNEST source column '${fromColumn}' must resolve to an array or map schema`,
|
|
338
|
+
ast,
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
|
|
286
342
|
function applyUnnestPre(mappings: UnnestMapping[], ctx: VisitContext): UnnestMapping[] {
|
|
287
343
|
const deferred: UnnestMapping[] = [];
|
|
288
344
|
|
|
289
345
|
for (const { fromColumn, toColumns, tableAlias, ast } of mappings) {
|
|
290
|
-
const ownerTable =
|
|
346
|
+
const ownerTable = flattenTables(ctx).find((table) => table.columns.has(fromColumn));
|
|
291
347
|
|
|
292
348
|
if (ownerTable === undefined) {
|
|
293
349
|
deferred.push({ fromColumn, toColumns, ...(tableAlias !== undefined ? { tableAlias } : {}), ast });
|
|
@@ -295,58 +351,17 @@ function applyUnnestPre(mappings: UnnestMapping[], ctx: VisitContext): UnnestMap
|
|
|
295
351
|
}
|
|
296
352
|
|
|
297
353
|
const sourceColumns = ownerTable.columns.get(fromColumn) ?? [];
|
|
298
|
-
const sourceSchema = sourceColumns[0]?.schema;
|
|
299
354
|
const unnestTableName = `${ownerTable.name ?? ANONYMOUS_TABLE}:<unnested>`;
|
|
300
355
|
const apiOperation = ownerTable.apiOperation !== undefined ? { apiOperation: ownerTable.apiOperation } : {};
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
columns: new Map([[toColumn, [resolvedCol(toColumn, sourceSchema.items, sourceColumns[0]?.ast)]]]),
|
|
311
|
-
},
|
|
312
|
-
];
|
|
313
|
-
} else if (sourceSchema?.type === 'object') {
|
|
314
|
-
const [keyColumn, valueColumn] = toColumns;
|
|
315
|
-
assert.ok(
|
|
316
|
-
keyColumn !== undefined && valueColumn !== undefined,
|
|
317
|
-
`UNNEST of map column '${fromColumn}' requires exactly two alias columns (key, value)`,
|
|
318
|
-
);
|
|
319
|
-
const addlProps = (sourceSchema as SchemaObject)['additionalProperties'] as unknown;
|
|
320
|
-
const valueSchema: SchemaObject =
|
|
321
|
-
typeof addlProps === 'object' && addlProps !== null ? addlProps : { type: 'string' };
|
|
322
|
-
unnestEntry = [
|
|
323
|
-
{
|
|
324
|
-
name: unnestTableName,
|
|
325
|
-
...apiOperation,
|
|
326
|
-
columns: new Map([
|
|
327
|
-
[keyColumn, [resolvedCol(keyColumn, { type: 'string' }, sourceColumns[0]?.ast)]],
|
|
328
|
-
[valueColumn, [resolvedCol(valueColumn, valueSchema, sourceColumns[0]?.ast)]],
|
|
329
|
-
]),
|
|
330
|
-
},
|
|
331
|
-
];
|
|
332
|
-
} else if (ownerTable.apiOperation !== undefined) {
|
|
333
|
-
// Only raise an error when the column schema was derived from a known API spec — for
|
|
334
|
-
// computed columns (subqueries, CTEs, functions) the inferred schema may be imprecise.
|
|
335
|
-
throw new AthenaError(
|
|
336
|
-
ATHENA_ERROR,
|
|
337
|
-
`UNNEST source column '${fromColumn}' must resolve to an array or map schema`,
|
|
338
|
-
ast,
|
|
339
|
-
);
|
|
340
|
-
} else {
|
|
341
|
-
// Non-API source: schema is an estimate; register target columns with unknown schema.
|
|
342
|
-
unnestEntry = [
|
|
343
|
-
{
|
|
344
|
-
name: unnestTableName,
|
|
345
|
-
...apiOperation,
|
|
346
|
-
columns: new Map(toColumns.map((toColumn) => [toColumn, [resolvedCol(toColumn, {})]])),
|
|
347
|
-
},
|
|
348
|
-
];
|
|
349
|
-
}
|
|
356
|
+
const unnestColumns = buildUnnestColumnMap(
|
|
357
|
+
fromColumn,
|
|
358
|
+
toColumns,
|
|
359
|
+
sourceColumns[0]?.schema,
|
|
360
|
+
sourceColumns[0]?.ast,
|
|
361
|
+
ast,
|
|
362
|
+
ownerTable.apiOperation !== undefined,
|
|
363
|
+
);
|
|
364
|
+
const unnestEntry: ResolvedTable[] = [{ name: unnestTableName, ...apiOperation, columns: unnestColumns }];
|
|
350
365
|
ctx.tables.set(unnestTableName, unnestEntry);
|
|
351
366
|
if (tableAlias !== undefined) {
|
|
352
367
|
ctx.tables.set(tableAlias, unnestEntry);
|
|
@@ -362,29 +377,16 @@ function applyUnnestPost(mappings: UnnestMapping[], columns: Map<string, Resolve
|
|
|
362
377
|
if (sourceColumns === undefined) {
|
|
363
378
|
throw new AthenaError(ATHENA_ERROR, `UNNEST source column '${fromColumn}' not found in SELECT`, ast);
|
|
364
379
|
}
|
|
365
|
-
const
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
`UNNEST of map column '${fromColumn}' requires exactly two alias columns (key, value)`,
|
|
376
|
-
);
|
|
377
|
-
const addlProps = (sourceSchema as SchemaObject)['additionalProperties'] as unknown;
|
|
378
|
-
const valueSchema: SchemaObject =
|
|
379
|
-
typeof addlProps === 'object' && addlProps !== null ? addlProps : { type: 'string' };
|
|
380
|
-
columns.set(keyColumn, [resolvedCol(keyColumn, { type: 'string' }, sourceColumns[0]?.ast)]);
|
|
381
|
-
columns.set(valueColumn, [resolvedCol(valueColumn, valueSchema, sourceColumns[0]?.ast)]);
|
|
382
|
-
} else {
|
|
383
|
-
throw new AthenaError(
|
|
384
|
-
ATHENA_ERROR,
|
|
385
|
-
`UNNEST source column '${fromColumn}' must resolve to an array or map schema`,
|
|
386
|
-
ast,
|
|
387
|
-
);
|
|
380
|
+
const unnestColumns = buildUnnestColumnMap(
|
|
381
|
+
fromColumn,
|
|
382
|
+
toColumns,
|
|
383
|
+
sourceColumns[0]?.schema,
|
|
384
|
+
sourceColumns[0]?.ast,
|
|
385
|
+
ast,
|
|
386
|
+
true,
|
|
387
|
+
);
|
|
388
|
+
for (const [key, value] of unnestColumns) {
|
|
389
|
+
columns.set(key, value);
|
|
388
390
|
}
|
|
389
391
|
}
|
|
390
392
|
}
|
|
@@ -415,19 +417,14 @@ function schemaPropertyHint(resolvedColumns: ResolvedColumn[]): string {
|
|
|
415
417
|
if (resolvedColumns.length === 0) {
|
|
416
418
|
return '';
|
|
417
419
|
}
|
|
418
|
-
const
|
|
419
|
-
if (!resolvedColumns.every((col) => JSON.stringify(col.schema) ===
|
|
420
|
-
return '';
|
|
421
|
-
}
|
|
422
|
-
const schema = resolvedColumns[0]?.schema;
|
|
423
|
-
if (schema?.type !== 'object') {
|
|
420
|
+
const firstSchema = resolvedColumns[0]?.schema;
|
|
421
|
+
if (!resolvedColumns.every((col) => JSON.stringify(col.schema) === JSON.stringify(firstSchema))) {
|
|
424
422
|
return '';
|
|
425
423
|
}
|
|
426
|
-
|
|
427
|
-
if (properties === undefined) {
|
|
424
|
+
if (firstSchema?.type !== 'object' || firstSchema.properties === undefined) {
|
|
428
425
|
return '';
|
|
429
426
|
}
|
|
430
|
-
const propNames = Object.keys(properties);
|
|
427
|
+
const propNames = Object.keys(firstSchema.properties);
|
|
431
428
|
return propNames.length > 0 ? `; available properties: ${propNames.join(', ')}` : '';
|
|
432
429
|
}
|
|
433
430
|
|
|
@@ -438,8 +435,7 @@ function resolveSchemaAtPath(
|
|
|
438
435
|
ast?: object,
|
|
439
436
|
): SchemaObject[] {
|
|
440
437
|
// Double-dot handles allOf / anyOf / oneOf wrappers that may appear in the schema.
|
|
441
|
-
|
|
442
|
-
const adjustedPath = `$.${propertyAccessor.substring(1).replace(/(\.|\[)/gu, '..properties$1')}`;
|
|
438
|
+
const adjustedPath = `$.${propertyAccessor.substring(1).replace(/(?<sep>\.|\[)/gu, '..properties$<sep>')}`;
|
|
443
439
|
log('adjusted path', adjustedPath);
|
|
444
440
|
|
|
445
441
|
const extractedSchemas = resolvedColumns.flatMap((col) =>
|
|
@@ -477,6 +473,28 @@ function navigateSchemaPath(
|
|
|
477
473
|
// Pass 2 — Resolve SELECT columns → Map<name, ResolvedColumn[]>
|
|
478
474
|
// ---------------------------------------------------------------------------
|
|
479
475
|
|
|
476
|
+
function throwUnknownTableError(tableRef: string | undefined, colRef: string, ctx: VisitContext, ref: object): never {
|
|
477
|
+
throw new AthenaError(
|
|
478
|
+
ATHENA_ERROR,
|
|
479
|
+
`unknown table or alias '${tableRef ?? colRef}'; known tables: ${[...ctx.tables.keys()].join(', ')}`,
|
|
480
|
+
ref,
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function resolveReferencedTablesOrThrow(
|
|
485
|
+
tableRef: string | undefined,
|
|
486
|
+
colRef: string,
|
|
487
|
+
allTables: ResolvedTable[],
|
|
488
|
+
ctx: VisitContext,
|
|
489
|
+
ref: object,
|
|
490
|
+
): ResolvedTable[] {
|
|
491
|
+
const referencedTables = resolveReferencedTables(tableRef, allTables, ctx);
|
|
492
|
+
if (referencedTables.length === 0) {
|
|
493
|
+
throwUnknownTableError(tableRef, colRef, ctx, ref);
|
|
494
|
+
}
|
|
495
|
+
return referencedTables;
|
|
496
|
+
}
|
|
497
|
+
|
|
480
498
|
function lookupColumnOrThrow(colRef: string, ref: object, referencedTables: ResolvedTable[]): ResolvedColumn[] {
|
|
481
499
|
const resolvedColumns = referencedTables.flatMap((table) => table.columns.get(colRef) ?? []);
|
|
482
500
|
if (resolvedColumns.length === 0) {
|
|
@@ -503,34 +521,25 @@ function checkColumnRefsExist(
|
|
|
503
521
|
return;
|
|
504
522
|
}
|
|
505
523
|
for (const ref of extractColumnRefs(ast)) {
|
|
506
|
-
const
|
|
524
|
+
const { tableRef, colRef } = resolveColumnRefParts(ref);
|
|
507
525
|
if (colRef === undefined || colRef === '*') {
|
|
508
526
|
continue;
|
|
509
527
|
}
|
|
510
|
-
const tableRef = ref.table ?? undefined;
|
|
511
528
|
if (tableRef === undefined && selectColumns?.has(colRef) === true) {
|
|
512
529
|
continue;
|
|
513
530
|
}
|
|
514
|
-
const referencedTables = tableRef
|
|
515
|
-
if (referencedTables.length === 0) {
|
|
516
|
-
throw new AthenaError(
|
|
517
|
-
ATHENA_ERROR,
|
|
518
|
-
`unknown table or alias '${tableRef ?? colRef}'; known tables: ${[...ctx.tables.keys()].join(', ')}`,
|
|
519
|
-
ref,
|
|
520
|
-
);
|
|
521
|
-
}
|
|
531
|
+
const referencedTables = resolveReferencedTablesOrThrow(tableRef, colRef, allTables, ctx, ref);
|
|
522
532
|
lookupColumnOrThrow(colRef, ref, referencedTables);
|
|
523
533
|
}
|
|
524
534
|
}
|
|
525
535
|
|
|
526
536
|
function validateComplexColumnExpression(columnAST: unknown, allTables: ResolvedTable[], ctx: VisitContext): void {
|
|
527
537
|
for (const { ref, path, fnNode } of extractJsonExtractCalls(columnAST)) {
|
|
528
|
-
const tableRef = ref
|
|
529
|
-
const colRef = typeof ref.column === 'string' ? ref.column : undefined;
|
|
538
|
+
const { tableRef, colRef } = resolveColumnRefParts(ref);
|
|
530
539
|
if (colRef === undefined) {
|
|
531
540
|
continue;
|
|
532
541
|
}
|
|
533
|
-
const referencedTables =
|
|
542
|
+
const referencedTables = resolveReferencedTables(tableRef, allTables, ctx);
|
|
534
543
|
const resolvedColumns = referencedTables.flatMap((table) => table.columns.get(colRef) ?? []);
|
|
535
544
|
if (resolvedColumns.length > 0) {
|
|
536
545
|
resolveSchemaAtPath(colRef, path, resolvedColumns, fnNode);
|
|
@@ -547,18 +556,10 @@ function resolveSingleColumnRef(
|
|
|
547
556
|
ctx: VisitContext,
|
|
548
557
|
columns: Map<string, ResolvedColumn[]>,
|
|
549
558
|
): void {
|
|
550
|
-
const tableRef = ref
|
|
551
|
-
const colRef = typeof ref.column === 'string' ? ref.column : undefined;
|
|
559
|
+
const { tableRef, colRef } = resolveColumnRefParts(ref);
|
|
552
560
|
assert.ok(colRef !== undefined, 'column_ref must have a string column name');
|
|
553
561
|
|
|
554
|
-
const referencedTables = tableRef
|
|
555
|
-
if (referencedTables.length === 0) {
|
|
556
|
-
throw new AthenaError(
|
|
557
|
-
ATHENA_ERROR,
|
|
558
|
-
`unknown table or alias '${tableRef ?? colRef}'; known tables: ${[...ctx.tables.keys()].join(', ')}`,
|
|
559
|
-
ref,
|
|
560
|
-
);
|
|
561
|
-
}
|
|
562
|
+
const referencedTables = resolveReferencedTablesOrThrow(tableRef, colRef, allTables, ctx, ref);
|
|
562
563
|
|
|
563
564
|
if (colRef === '*') {
|
|
564
565
|
expandWildcard(referencedTables, columns);
|
|
@@ -581,8 +582,39 @@ function resolveSingleColumnRef(
|
|
|
581
582
|
);
|
|
582
583
|
}
|
|
583
584
|
|
|
585
|
+
function applySchemaTypeOverride(
|
|
586
|
+
columnAST: unknown,
|
|
587
|
+
columnAlias: string | null,
|
|
588
|
+
indexedName: string,
|
|
589
|
+
columns: Map<string, ResolvedColumn[]>,
|
|
590
|
+
predicate: (expression: unknown) => boolean,
|
|
591
|
+
schemaType: 'array' | 'object',
|
|
592
|
+
): void {
|
|
593
|
+
if (!predicate(columnAST)) {
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
const colName = columnAlias ?? indexedName;
|
|
597
|
+
const existing = columns.get(colName);
|
|
598
|
+
if (existing !== undefined && existing[0]?.schema.type !== schemaType) {
|
|
599
|
+
columns.set(
|
|
600
|
+
colName,
|
|
601
|
+
existing.map((col) => resolvedCol(col.name, { type: schemaType }, col.ast)),
|
|
602
|
+
);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function validateClauseExpression(
|
|
607
|
+
expression: unknown,
|
|
608
|
+
allTables: ResolvedTable[],
|
|
609
|
+
ctx: VisitContext,
|
|
610
|
+
selectColumns?: Map<string, ResolvedColumn[]>,
|
|
611
|
+
): void {
|
|
612
|
+
checkColumnRefsExist(expression, allTables, ctx, selectColumns);
|
|
613
|
+
validateComplexColumnExpression(expression, allTables, ctx);
|
|
614
|
+
}
|
|
615
|
+
|
|
584
616
|
function resolveSelectColumns(select: Select, ctx: VisitContext): Map<string, ResolvedColumn[]> {
|
|
585
|
-
const allTables =
|
|
617
|
+
const allTables = flattenTables(ctx);
|
|
586
618
|
const columns = new Map<string, ResolvedColumn[]>();
|
|
587
619
|
|
|
588
620
|
for (const [index, columnAST] of select.columns.entries()) {
|
|
@@ -593,8 +625,7 @@ function resolveSelectColumns(select: Select, ctx: VisitContext): Map<string, Re
|
|
|
593
625
|
const columnRefs = extractColumnRefs(columnAST);
|
|
594
626
|
|
|
595
627
|
if (columnRefs.length !== 1) {
|
|
596
|
-
|
|
597
|
-
validateComplexColumnExpression(columnAST, allTables, ctx);
|
|
628
|
+
validateClauseExpression(columnAST, allTables, ctx);
|
|
598
629
|
resolveDefaultSchemaColumn(columnAlias, indexedName, columnAST, columns);
|
|
599
630
|
} else {
|
|
600
631
|
const [ref] = columnRefs;
|
|
@@ -602,27 +633,8 @@ function resolveSelectColumns(select: Select, ctx: VisitContext): Map<string, Re
|
|
|
602
633
|
resolveSingleColumnRef(columnAST, columnAlias, indexedName, ref, allTables, ctx, columns);
|
|
603
634
|
}
|
|
604
635
|
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
const existing = columns.get(colName);
|
|
608
|
-
if (existing !== undefined && existing[0]?.schema.type !== 'array') {
|
|
609
|
-
columns.set(
|
|
610
|
-
colName,
|
|
611
|
-
existing.map((col) => resolvedCol(col.name, { type: 'array' }, col.ast)),
|
|
612
|
-
);
|
|
613
|
-
}
|
|
614
|
-
}
|
|
615
|
-
|
|
616
|
-
if (containsCastToMap(columnAST)) {
|
|
617
|
-
const colName = columnAlias ?? indexedName;
|
|
618
|
-
const existing = columns.get(colName);
|
|
619
|
-
if (existing !== undefined && existing[0]?.schema.type !== 'object') {
|
|
620
|
-
columns.set(
|
|
621
|
-
colName,
|
|
622
|
-
existing.map((col) => resolvedCol(col.name, { type: 'object' }, col.ast)),
|
|
623
|
-
);
|
|
624
|
-
}
|
|
625
|
-
}
|
|
636
|
+
applySchemaTypeOverride(columnAST, columnAlias, indexedName, columns, containsCastToArray, 'array');
|
|
637
|
+
applySchemaTypeOverride(columnAST, columnAlias, indexedName, columns, containsCastToMap, 'object');
|
|
626
638
|
}
|
|
627
639
|
|
|
628
640
|
return columns;
|
|
@@ -652,41 +664,34 @@ function checkSelect(
|
|
|
652
664
|
|
|
653
665
|
log('resolved columns', [...columns.keys()]);
|
|
654
666
|
|
|
655
|
-
const allTables =
|
|
667
|
+
const allTables = flattenTables(selectCtx);
|
|
656
668
|
for (const item of fromClauseItems(select)) {
|
|
657
669
|
const onExpr = (item as { on?: unknown }).on;
|
|
658
670
|
if (onExpr !== undefined) {
|
|
659
|
-
|
|
660
|
-
validateComplexColumnExpression(onExpr, allTables, selectCtx);
|
|
671
|
+
validateClauseExpression(onExpr, allTables, selectCtx);
|
|
661
672
|
}
|
|
662
673
|
}
|
|
663
674
|
if (select.where !== null) {
|
|
664
|
-
|
|
665
|
-
validateComplexColumnExpression(select.where, allTables, selectCtx);
|
|
675
|
+
validateClauseExpression(select.where, allTables, selectCtx);
|
|
666
676
|
}
|
|
667
677
|
if (select.having !== null) {
|
|
668
|
-
|
|
669
|
-
validateComplexColumnExpression(select.having, allTables, selectCtx);
|
|
678
|
+
validateClauseExpression(select.having, allTables, selectCtx, columns);
|
|
670
679
|
}
|
|
671
680
|
for (const orderItem of select.orderby ?? []) {
|
|
672
|
-
|
|
673
|
-
validateComplexColumnExpression(orderItem.expr, allTables, selectCtx);
|
|
681
|
+
validateClauseExpression(orderItem.expr, allTables, selectCtx, columns);
|
|
674
682
|
}
|
|
675
683
|
if (select.groupby?.columns !== undefined) {
|
|
676
684
|
for (const groupCol of select.groupby.columns) {
|
|
677
|
-
|
|
678
|
-
validateComplexColumnExpression(groupCol, allTables, selectCtx);
|
|
685
|
+
validateClauseExpression(groupCol, allTables, selectCtx, columns);
|
|
679
686
|
}
|
|
680
687
|
}
|
|
681
688
|
|
|
682
689
|
if (select._next !== undefined) {
|
|
683
690
|
const nextColumns = checkSelect(select._next, ctx, withTableName);
|
|
684
|
-
|
|
685
|
-
const nextNumberOfKeys = nextColumns.size;
|
|
686
|
-
if (currentNumberOfKeys !== nextNumberOfKeys) {
|
|
691
|
+
if (columns.size !== nextColumns.size) {
|
|
687
692
|
throw new AthenaError(
|
|
688
693
|
ATHENA_ERROR,
|
|
689
|
-
`UNION ALL parts have different number of columns: ${
|
|
694
|
+
`UNION ALL parts have different number of columns: ${columns.size.toString()} vs ${nextColumns.size.toString()}`,
|
|
690
695
|
select._next,
|
|
691
696
|
);
|
|
692
697
|
}
|