@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.
@@ -1,75 +1,24 @@
1
1
  // athena/athena.ts
2
2
 
3
- /* eslint-disable max-lines */
4
-
5
3
  /*
6
4
  * Copyright (c) 2021-2026 Check Digit, LLC
7
5
  *
8
6
  * This code is licensed under the MIT license (see LICENSE.txt for details).
9
7
  */
10
8
 
11
- import { strict as assert } from 'node:assert';
12
-
13
9
  import debug from 'debug';
14
- import { JSONPath } from 'jsonpath-plus';
15
10
  import { AST_NODE_TYPES, ESLintUtils, type TSESTree } from '@typescript-eslint/utils';
16
- import type { SchemaObject } from 'ajv/dist/2020';
17
11
 
18
12
  import { parse } from '../peggy/athena-peggy.ts';
19
- import type { AST, BaseFrom, From, Select, With } from './types';
20
- import { matchApi } from './api-matcher.ts';
21
- import { locateApi } from './api-locator.ts';
22
- import {
23
- createChildContext,
24
- createRootContext,
25
- type ResolvedColumn,
26
- type ResolvedTable,
27
- type VisitContext,
28
- } from './context.ts';
29
- import { buildServiceTables } from './service-table.ts';
30
- import {
31
- containsCastToArray,
32
- containsCastToMap,
33
- containsLambda,
34
- extractBracketAccessorPath,
35
- extractColumnRefs,
36
- extractJsonExtractCalls,
37
- extractJsonExtractPath,
38
- hasFunctionCalls,
39
- isBaseFrom,
40
- isJoin,
41
- isTableExpr,
42
- isUnnestFrom,
43
- } from './visitor.ts';
13
+ import type { AST } from './types';
14
+ import { createRootContext } from './context.ts';
15
+ import { ATHENA_ERROR, AthenaError, checkAthenaAst, offsetToLoc, SYNTEXT_ERROR } from './validate.ts';
44
16
 
45
17
  export const ruleId = 'athena';
46
18
 
47
19
  const log = debug('eslint-plugin:athena');
48
20
  const createRule = ESLintUtils.RuleCreator((name) => name);
49
21
 
50
- const SYNTEXT_ERROR = 'SyntextError';
51
- const ATHENA_ERROR = 'AthenaError';
52
-
53
- class AthenaError extends Error {
54
- public code: string;
55
- public ast?: object;
56
- constructor(code: string, message: string, ast?: object) {
57
- super(message);
58
- this.code = code;
59
- this.name = 'AthenaError';
60
- if (ast !== undefined) {
61
- this.ast = ast;
62
- }
63
- }
64
- }
65
-
66
- // Convert a 0-based character offset in `text` to a 1-based line / 0-based column ESLint location.
67
- function offsetToLoc(text: string, offset: number): { line: number; column: number } {
68
- const prefix = text.slice(0, offset);
69
- const lines = prefix.split('\n');
70
- return { line: lines.length, column: lines[lines.length - 1]?.length ?? 0 };
71
- }
72
-
73
22
  // Maps a SQL-string offset (as produced by the PEG parser) back to an absolute source offset.
74
23
  // Each quasi in a TemplateLiteral contributes a segment; template expressions have no SQL width
75
24
  // but do occupy source characters, so a naïve single-offset approach gives wrong results when
@@ -108,604 +57,6 @@ function sqlOffsetToSource(sqlOffset: number, segments: SqlSourceSegment[]): num
108
57
  return segments[0]?.srcStart ?? 0;
109
58
  }
110
59
 
111
- // ---------------------------------------------------------------------------
112
- // Helpers
113
- // ---------------------------------------------------------------------------
114
-
115
- function resolvedCol(name: string, schema: SchemaObject, ast?: object): ResolvedColumn {
116
- return ast !== undefined ? { name, schema, ast } : { name, schema };
117
- }
118
-
119
- /** Look up (and cache) API schemas for a service table name. */
120
- function getApiSchemas(serviceName: string, ctx: VisitContext) {
121
- let schemas = ctx.apiSchemas.get(serviceName);
122
- if (schemas === undefined) {
123
- schemas = locateApi(serviceName);
124
- ctx.apiSchemas.set(serviceName, schemas);
125
- }
126
- return schemas;
127
- }
128
-
129
- /** Resolve a (possibly aliased) table name to the tables registered in ctx. */
130
- function lookupTables(nameOrAlias: string, ctx: VisitContext): ResolvedTable[] {
131
- const canonical = ctx.aliases.get(nameOrAlias) ?? nameOrAlias;
132
- return ctx.tables.get(canonical) ?? [];
133
- }
134
-
135
- /** Normalize the FROM clause into a flat array. */
136
- function fromClauseItems(select: Select): From[] {
137
- if (Array.isArray(select.from)) {
138
- return select.from;
139
- }
140
- if (select.from !== null) {
141
- return [select.from];
142
- }
143
- return [];
144
- }
145
-
146
- // ---------------------------------------------------------------------------
147
- // Pass 1 — Resolve FROM clause: service tables → ctx.tables + ctx.aliases
148
- // ---------------------------------------------------------------------------
149
-
150
- function resolveServiceTable(select: Select, item: BaseFrom, ctx: VisitContext): void {
151
- const { table: tableName } = item;
152
- try {
153
- const apiSchemas = getApiSchemas(tableName, ctx);
154
- if (apiSchemas.length === 0) {
155
- throw new AthenaError(ATHENA_ERROR, `service not found: "${tableName}" (no swagger schema located)`, item);
156
- }
157
- const operations = matchApi(select, item, apiSchemas) ?? [];
158
- ctx.tables.set(tableName, buildServiceTables(tableName, operations));
159
- } catch (error) {
160
- if (error instanceof AthenaError) {
161
- throw error;
162
- }
163
- throw new AthenaError(ATHENA_ERROR, error instanceof Error ? error.message : String(error), item);
164
- }
165
- }
166
-
167
- /** Remove inherited CTE tables that are not referenced in this SELECT's FROM clause. */
168
- function restrictToFromClause(select: Select, ctx: VisitContext): void {
169
- const fromNames = new Set<string>();
170
- for (const item of fromClauseItems(select)) {
171
- if (isBaseFrom(item) || isJoin(item)) {
172
- fromNames.add(item.table);
173
- } else if (isTableExpr(item)) {
174
- fromNames.add(typeof item.as === 'string' ? item.as : '<subquery>');
175
- }
176
- }
177
- for (const name of [...ctx.tables.keys()]) {
178
- if (!fromNames.has(name)) {
179
- ctx.tables.delete(name);
180
- }
181
- }
182
- }
183
-
184
- function resolveFromClause(select: Select, ctx: VisitContext): void {
185
- for (const item of fromClauseItems(select)) {
186
- if (isUnnestFrom(item)) {
187
- continue; // UNNEST handled separately
188
- }
189
-
190
- if (isTableExpr(item)) {
191
- const alias = typeof item.as === 'string' ? item.as : '<subquery>';
192
- // eslint-disable-next-line no-use-before-define
193
- checkSelect(item.expr.ast, ctx, alias);
194
- continue;
195
- }
196
-
197
- if (isJoin(item)) {
198
- const { table: tableName, as: alias } = item;
199
- if (alias !== null) {
200
- ctx.aliases.set(alias, tableName);
201
- }
202
- if (!ctx.tables.has(tableName)) {
203
- resolveServiceTable(select, item, ctx);
204
- }
205
- continue;
206
- }
207
-
208
- if (!isBaseFrom(item)) {
209
- continue; // skip subqueries and DUAL
210
- }
211
-
212
- const { table: tableName, as: alias } = item;
213
-
214
- if (alias !== null) {
215
- ctx.aliases.set(alias, tableName);
216
- }
217
-
218
- if (ctx.tables.has(tableName)) {
219
- continue; // already resolved (CTE or duplicate)
220
- }
221
-
222
- resolveServiceTable(select, item, ctx);
223
- }
224
- }
225
-
226
- // ---------------------------------------------------------------------------
227
- // UNNEST handling (may run before or after column selection, depending on
228
- // whether the source column is a service-table column or a computed column).
229
- // ---------------------------------------------------------------------------
230
-
231
- interface UnnestMapping {
232
- fromColumn: string;
233
- toColumns: string[]; // 1 item for array UNNEST, 2 items for map UNNEST (key, value)
234
- ast: object;
235
- }
236
-
237
- function extractUnnestMappings(select: Select): UnnestMapping[] {
238
- const mappings: UnnestMapping[] = [];
239
-
240
- for (const item of fromClauseItems(select)) {
241
- if (!isUnnestFrom(item)) {
242
- continue;
243
- }
244
-
245
- const fromColumn = typeof item.expr.column === 'string' ? item.expr.column : undefined;
246
- assert.ok(fromColumn !== undefined, 'UNNEST expr must be a column_ref with a string column name');
247
-
248
- // The alias is stored as a func_call node: UNNEST(col) AS t(alias1[, alias2])
249
- const aliasArgs = item.as?.args.value ?? [];
250
- const toColumns: string[] = [];
251
- for (const col of aliasArgs) {
252
- if (typeof (col as { column?: unknown }).column === 'string') {
253
- toColumns.push((col as { column: string }).column);
254
- }
255
- }
256
- assert.ok(toColumns.length > 0, 'UNNEST alias must have at least one column name');
257
-
258
- mappings.push({ fromColumn, toColumns, ast: item.expr });
259
- }
260
-
261
- return mappings;
262
- }
263
-
264
- function applyUnnestPre(mappings: UnnestMapping[], ctx: VisitContext): UnnestMapping[] {
265
- const deferred: UnnestMapping[] = [];
266
-
267
- for (const { fromColumn, toColumns, ast } of mappings) {
268
- // Find the table that owns the source column
269
- const ownerTable = [...ctx.tables.values()].flat().find((table) => table.columns.has(fromColumn));
270
-
271
- if (ownerTable === undefined) {
272
- deferred.push({ fromColumn, toColumns, ast });
273
- continue;
274
- }
275
-
276
- const sourceColumns = ownerTable.columns.get(fromColumn) ?? [];
277
- const sourceSchema = sourceColumns[0]?.schema;
278
- const unnestTableName = `${ownerTable.name ?? '<anonymous>'}:<unnested>`;
279
- const apiOperation = ownerTable.apiOperation !== undefined ? { apiOperation: ownerTable.apiOperation } : {};
280
-
281
- if (sourceSchema?.type === 'array') {
282
- const [toColumn] = toColumns;
283
- assert.ok(toColumn !== undefined);
284
- ctx.tables.set(unnestTableName, [
285
- {
286
- name: unnestTableName,
287
- ...apiOperation,
288
- columns: new Map([[toColumn, [resolvedCol(toColumn, sourceSchema.items, sourceColumns[0]?.ast)]]]),
289
- },
290
- ]);
291
- } else if (sourceSchema?.type === 'object') {
292
- // MAP UNNEST: UNNEST(map_col) AS t(key_col, value_col)
293
- const [keyColumn, valueColumn] = toColumns;
294
- assert.ok(
295
- keyColumn !== undefined && valueColumn !== undefined,
296
- `UNNEST of map column '${fromColumn}' requires exactly two alias columns (key, value)`,
297
- );
298
- const addlProps = (sourceSchema as SchemaObject)['additionalProperties'] as unknown;
299
- const valueSchema: SchemaObject =
300
- typeof addlProps === 'object' && addlProps !== null ? addlProps : { type: 'string' };
301
- ctx.tables.set(unnestTableName, [
302
- {
303
- name: unnestTableName,
304
- ...apiOperation,
305
- columns: new Map([
306
- [keyColumn, [resolvedCol(keyColumn, { type: 'string' }, sourceColumns[0]?.ast)]],
307
- [valueColumn, [resolvedCol(valueColumn, valueSchema, sourceColumns[0]?.ast)]],
308
- ]),
309
- },
310
- ]);
311
- } else {
312
- throw new AthenaError(
313
- ATHENA_ERROR,
314
- `UNNEST source column '${fromColumn}' must resolve to an array or map schema`,
315
- ast,
316
- );
317
- }
318
- }
319
-
320
- return deferred;
321
- }
322
-
323
- function applyUnnestPost(mappings: UnnestMapping[], columns: Map<string, ResolvedColumn[]>): void {
324
- for (const { fromColumn, toColumns, ast } of mappings) {
325
- const sourceColumns = columns.get(fromColumn);
326
- if (sourceColumns === undefined) {
327
- throw new AthenaError(ATHENA_ERROR, `UNNEST source column '${fromColumn}' not found in SELECT`, ast);
328
- }
329
- const sourceSchema = sourceColumns[0]?.schema;
330
-
331
- if (sourceSchema?.type === 'array') {
332
- const [toColumn] = toColumns;
333
- assert.ok(toColumn !== undefined);
334
- columns.set(toColumn, [resolvedCol(toColumn, sourceSchema.items, sourceColumns[0]?.ast)]);
335
- } else if (sourceSchema?.type === 'object') {
336
- // MAP UNNEST: produces key column and value column
337
- const [keyColumn, valueColumn] = toColumns;
338
- assert.ok(
339
- keyColumn !== undefined && valueColumn !== undefined,
340
- `UNNEST of map column '${fromColumn}' requires exactly two alias columns (key, value)`,
341
- );
342
- const addlProps = (sourceSchema as SchemaObject)['additionalProperties'] as unknown;
343
- const valueSchema: SchemaObject =
344
- typeof addlProps === 'object' && addlProps !== null ? addlProps : { type: 'string' };
345
- columns.set(keyColumn, [resolvedCol(keyColumn, { type: 'string' }, sourceColumns[0]?.ast)]);
346
- columns.set(valueColumn, [resolvedCol(valueColumn, valueSchema, sourceColumns[0]?.ast)]);
347
- } else {
348
- throw new AthenaError(
349
- ATHENA_ERROR,
350
- `UNNEST source column '${fromColumn}' must resolve to an array or map schema`,
351
- ast,
352
- );
353
- }
354
- }
355
- }
356
-
357
- // ---------------------------------------------------------------------------
358
- // Pass 2 — Resolve SELECT columns helpers
359
- // ---------------------------------------------------------------------------
360
-
361
- function resolveDefaultSchemaColumn(
362
- columnAlias: string | null,
363
- indexedName: string,
364
- columnAST: unknown,
365
- columns: Map<string, ResolvedColumn[]>,
366
- ): void {
367
- const name = columnAlias ?? indexedName;
368
- columns.set(name, [resolvedCol(name, { type: 'string' }, columnAST as object)]);
369
- }
370
-
371
- function expandWildcard(referencedTables: ResolvedTable[], columns: Map<string, ResolvedColumn[]>): void {
372
- for (const table of referencedTables) {
373
- for (const [colName, cols] of table.columns) {
374
- columns.set(colName, cols);
375
- }
376
- }
377
- }
378
-
379
- // If every resolved column shares the same object schema, return a hint listing its top-level
380
- // properties. Returns '' when schemas differ across matched operations or the schema isn't an object.
381
- function schemaPropertyHint(resolvedColumns: ResolvedColumn[]): string {
382
- if (resolvedColumns.length === 0) {
383
- return '';
384
- }
385
- const first = JSON.stringify(resolvedColumns[0]?.schema);
386
- if (!resolvedColumns.every((col) => JSON.stringify(col.schema) === first)) {
387
- return '';
388
- }
389
- const schema = resolvedColumns[0]?.schema;
390
- if (schema?.type !== 'object') {
391
- return '';
392
- }
393
- const properties = schema.properties;
394
- if (properties === undefined) {
395
- return '';
396
- }
397
- const propNames = Object.keys(properties);
398
- return propNames.length > 0 ? `; available properties: ${propNames.join(', ')}` : '';
399
- }
400
-
401
- function resolveSchemaAtPath(
402
- colRef: string,
403
- propertyAccessor: string,
404
- resolvedColumns: ResolvedColumn[],
405
- ast?: object,
406
- ): SchemaObject[] {
407
- // Double-dot handles allOf / anyOf / oneOf wrappers that may appear in the schema.
408
- // eslint-disable-next-line prefer-named-capture-group
409
- const adjustedPath = `$.${propertyAccessor.substring(1).replace(/(\.|\[)/gu, '..properties$1')}`;
410
- log('adjusted path', adjustedPath);
411
-
412
- const extractedSchemas = resolvedColumns.flatMap((col) =>
413
- JSONPath<SchemaObject[]>({ json: col.schema, path: adjustedPath }),
414
- );
415
- log('extracted schemas', extractedSchemas);
416
-
417
- if (extractedSchemas.length === 0) {
418
- throw new AthenaError(
419
- ATHENA_ERROR,
420
- `property not found ${colRef} - ${propertyAccessor}${schemaPropertyHint(resolvedColumns)}`,
421
- ast,
422
- );
423
- }
424
- return extractedSchemas;
425
- }
426
-
427
- function navigateSchemaPath(
428
- colRef: string,
429
- propertyAccessor: string,
430
- resolvedColumns: ResolvedColumn[],
431
- colName: string,
432
- columnAST: unknown,
433
- columns: Map<string, ResolvedColumn[]>,
434
- ): void {
435
- // Prefer the inner function/expression node for location: the Column wrapper rarely carries loc.
436
- const errorAst = (extractJsonExtractCalls(columnAST)[0]?.fnNode ?? columnAST) as object;
437
- const extractedSchemas = resolveSchemaAtPath(colRef, propertyAccessor, resolvedColumns, errorAst);
438
- columns.set(
439
- colName,
440
- extractedSchemas.map((schema) => resolvedCol(colName, schema, columnAST as object)),
441
- );
442
- }
443
-
444
- // ---------------------------------------------------------------------------
445
- // Pass 2 — Resolve SELECT columns → Map<name, ResolvedColumn[]>
446
- // ---------------------------------------------------------------------------
447
-
448
- /** Resolve a column by name against `referencedTables`, throwing if not found. */
449
- function lookupColumnOrThrow(colRef: string, ref: object, referencedTables: ResolvedTable[]): ResolvedColumn[] {
450
- const resolvedColumns = referencedTables.flatMap((table) => table.columns.get(colRef) ?? []);
451
- if (resolvedColumns.length === 0) {
452
- const tableNames = [
453
- ...new Set(referencedTables.map((referenceTable) => referenceTable.name ?? '<anonymous>')),
454
- ].join(', ');
455
- const availableCols = [...new Set(referencedTables.flatMap((table) => [...table.columns.keys()]))].join(', ');
456
- throw new AthenaError(
457
- ATHENA_ERROR,
458
- `can't found column ${colRef} in tables: ${tableNames}; available columns: ${availableCols}`,
459
- ref,
460
- );
461
- }
462
- return resolvedColumns;
463
- }
464
-
465
- /** Check all column_refs in `ast` exist in scope. Skips lambda expressions (lambda params look like column_refs). */
466
- function checkColumnRefsExist(
467
- ast: unknown,
468
- allTables: ResolvedTable[],
469
- ctx: VisitContext,
470
- selectColumns?: Map<string, ResolvedColumn[]>,
471
- ): void {
472
- if (containsLambda(ast)) {
473
- return;
474
- }
475
- for (const ref of extractColumnRefs(ast)) {
476
- const colRef = typeof ref.column === 'string' ? ref.column : undefined;
477
- if (colRef === undefined || colRef === '*') {
478
- continue;
479
- }
480
- const tableRef = ref.table ?? undefined;
481
- if (tableRef === undefined && selectColumns?.has(colRef) === true) {
482
- continue; // SELECT alias used in GROUP BY / ORDER BY / HAVING — valid
483
- }
484
- const referencedTables = tableRef !== undefined ? lookupTables(tableRef, ctx) : allTables;
485
- if (referencedTables.length === 0) {
486
- throw new AthenaError(
487
- ATHENA_ERROR,
488
- `unknown table or alias '${tableRef ?? colRef}'; known tables: ${[...ctx.tables.keys()].join(', ')}`,
489
- ref,
490
- );
491
- }
492
- lookupColumnOrThrow(colRef, ref, referencedTables);
493
- }
494
- }
495
-
496
- /** Validate all json_extract / json_extract_scalar paths in a complex column expression. */
497
- function validateComplexColumnExpression(columnAST: unknown, allTables: ResolvedTable[], ctx: VisitContext): void {
498
- for (const { ref, path, fnNode } of extractJsonExtractCalls(columnAST)) {
499
- const tableRef = ref.table ?? undefined;
500
- const colRef = typeof ref.column === 'string' ? ref.column : undefined;
501
- if (colRef === undefined) {
502
- continue;
503
- }
504
- const referencedTables = tableRef !== undefined ? lookupTables(tableRef, ctx) : allTables;
505
- const resolvedColumns = referencedTables.flatMap((table) => table.columns.get(colRef) ?? []);
506
- if (resolvedColumns.length > 0) {
507
- resolveSchemaAtPath(colRef, path, resolvedColumns, fnNode); // throws if path not found
508
- }
509
- }
510
- }
511
-
512
- /** Resolve a column expression that has exactly one column_ref. */
513
- function resolveSingleColumnRef(
514
- columnAST: unknown,
515
- columnAlias: string | null,
516
- indexedName: string,
517
- ref: NonNullable<ReturnType<typeof extractColumnRefs>[number]>,
518
- allTables: ResolvedTable[],
519
- ctx: VisitContext,
520
- columns: Map<string, ResolvedColumn[]>,
521
- ): void {
522
- const tableRef = ref.table ?? undefined;
523
- const colRef = typeof ref.column === 'string' ? ref.column : undefined;
524
- assert.ok(colRef !== undefined, 'column_ref must have a string column name');
525
-
526
- const referencedTables = tableRef !== undefined ? lookupTables(tableRef, ctx) : allTables;
527
- if (referencedTables.length === 0) {
528
- const tableNames = [...ctx.tables.keys()].join(', ');
529
- throw new AthenaError(
530
- ATHENA_ERROR,
531
- `unknown table or alias '${tableRef ?? colRef}'; known tables: ${tableNames}`,
532
- ref,
533
- );
534
- }
535
-
536
- if (colRef === '*') {
537
- expandWildcard(referencedTables, columns);
538
- return;
539
- }
540
-
541
- const withFunctions = hasFunctionCalls(columnAST);
542
- const colName = columnAlias ?? (withFunctions ? indexedName : colRef);
543
- const resolvedColumns = lookupColumnOrThrow(colRef, ref, referencedTables);
544
-
545
- const propertyAccessor = extractJsonExtractPath(columnAST) ?? extractBracketAccessorPath(columnAST);
546
- if (propertyAccessor !== undefined) {
547
- navigateSchemaPath(colRef, propertyAccessor, resolvedColumns, colName, columnAST, columns);
548
- return;
549
- }
550
-
551
- columns.set(
552
- colName,
553
- resolvedColumns.map((col) => resolvedCol(colName, col.schema, columnAST as object)),
554
- );
555
- }
556
-
557
- function resolveSelectColumns(select: Select, ctx: VisitContext): Map<string, ResolvedColumn[]> {
558
- const allTables = [...ctx.tables.values()].flat();
559
- const columns = new Map<string, ResolvedColumn[]>();
560
-
561
- for (const [index, columnAST] of select.columns.entries()) {
562
- log('resolving column', columnAST);
563
-
564
- const columnAlias = (columnAST as { as?: string | null }).as ?? null;
565
- const indexedName = `_col${String(index)}`;
566
- const columnRefs = extractColumnRefs(columnAST);
567
-
568
- if (columnRefs.length !== 1) {
569
- checkColumnRefsExist(columnAST, allTables, ctx);
570
- validateComplexColumnExpression(columnAST, allTables, ctx);
571
- resolveDefaultSchemaColumn(columnAlias, indexedName, columnAST, columns);
572
- } else {
573
- const [ref] = columnRefs;
574
- assert.ok(ref !== undefined);
575
- resolveSingleColumnRef(columnAST, columnAlias, indexedName, ref, allTables, ctx, columns);
576
- }
577
-
578
- // When the outermost expression is a CAST/TRY_CAST to ARRAY<…>, honour the declared
579
- // array type regardless of what the inner expression resolves to. This is needed when
580
- // the inner expression (e.g. json_parse) has no schema-aware path to navigate.
581
- // Skip when the inner expression already resolved to an array (e.g. json_extract on an
582
- // array-typed property): overriding would strip the items schema and break UNNEST typing.
583
- if (containsCastToArray(columnAST)) {
584
- const colName = columnAlias ?? indexedName;
585
- const existing = columns.get(colName);
586
- if (existing !== undefined && existing[0]?.schema.type !== 'array') {
587
- columns.set(
588
- colName,
589
- existing.map((col) => resolvedCol(col.name, { type: 'array' }, col.ast)),
590
- );
591
- }
592
- }
593
-
594
- // Same for CAST/TRY_CAST to MAP<…>: honour the declared map type when the inner
595
- // expression (e.g. split_to_map) has no schema-aware navigation available.
596
- // Skip when the schema is already an object to preserve additionalProperties.
597
- if (containsCastToMap(columnAST)) {
598
- const colName = columnAlias ?? indexedName;
599
- const existing = columns.get(colName);
600
- if (existing !== undefined && existing[0]?.schema.type !== 'object') {
601
- columns.set(
602
- colName,
603
- existing.map((col) => resolvedCol(col.name, { type: 'object' }, col.ast)),
604
- );
605
- }
606
- }
607
- }
608
-
609
- return columns;
610
- }
611
-
612
- // ---------------------------------------------------------------------------
613
- // Top-level SELECT resolution
614
- // ---------------------------------------------------------------------------
615
-
616
- function checkSelect(
617
- selectAST: Select | With,
618
- ctx: VisitContext,
619
- withTableName?: string,
620
- ): Map<string, ResolvedColumn[]> {
621
- // Unwrap CTE wrapper (With → Select)
622
- const select = 'stmt' in selectAST ? selectAST.stmt.ast : selectAST;
623
-
624
- // Each SELECT gets a child context that inherits CTE tables from the parent.
625
- const selectCtx = createChildContext(ctx);
626
-
627
- // Pass 1: resolve FROM clause → populate selectCtx.tables + selectCtx.aliases
628
- resolveFromClause(select, selectCtx);
629
- // Drop inherited CTE tables not referenced in FROM so allTables stays scoped to this SELECT.
630
- restrictToFromClause(select, selectCtx);
631
-
632
- // UNNEST pre-pass: mappings whose source is a service-table column
633
- const unnestMappings = extractUnnestMappings(select);
634
- const deferredUnnest = applyUnnestPre(unnestMappings, selectCtx);
635
-
636
- // Pass 2: resolve SELECT columns
637
- const columns = resolveSelectColumns(select, selectCtx);
638
-
639
- // UNNEST post-pass: mappings whose source is a computed SELECT column
640
- applyUnnestPost(deferredUnnest, columns);
641
-
642
- log('resolved columns', [...columns.keys()]);
643
-
644
- // Pass 3: validate column refs and JSON paths in JOIN ON / WHERE / HAVING / GROUP BY / ORDER BY
645
- const allTables = [...selectCtx.tables.values()].flat();
646
- for (const item of fromClauseItems(select)) {
647
- const onExpr = (item as { on?: unknown }).on;
648
- if (onExpr !== undefined) {
649
- checkColumnRefsExist(onExpr, allTables, selectCtx);
650
- validateComplexColumnExpression(onExpr, allTables, selectCtx);
651
- }
652
- }
653
- if (select.where !== null) {
654
- checkColumnRefsExist(select.where, allTables, selectCtx);
655
- validateComplexColumnExpression(select.where, allTables, selectCtx);
656
- }
657
- if (select.having !== null) {
658
- checkColumnRefsExist(select.having, allTables, selectCtx, columns);
659
- validateComplexColumnExpression(select.having, allTables, selectCtx);
660
- }
661
- for (const orderItem of select.orderby ?? []) {
662
- checkColumnRefsExist(orderItem.expr, allTables, selectCtx, columns);
663
- validateComplexColumnExpression(orderItem.expr, allTables, selectCtx);
664
- }
665
- if (select.groupby?.columns !== undefined) {
666
- for (const groupCol of select.groupby.columns) {
667
- checkColumnRefsExist(groupCol, allTables, selectCtx, columns);
668
- validateComplexColumnExpression(groupCol, allTables, selectCtx);
669
- }
670
- }
671
-
672
- // UNION ALL — next SELECT in the chain
673
- if (select._next !== undefined) {
674
- const nextColumns = checkSelect(select._next, ctx, withTableName);
675
- const currentNumberOfKeys = columns.size;
676
- const nextNumberOfKeys = nextColumns.size;
677
- if (currentNumberOfKeys !== nextNumberOfKeys) {
678
- throw new AthenaError(
679
- ATHENA_ERROR,
680
- `UNION ALL parts have different number of columns: ${currentNumberOfKeys.toString()} vs ${nextNumberOfKeys.toString()}`,
681
- select._next,
682
- );
683
- }
684
- }
685
-
686
- // Register CTE result so subsequent SELECTs in the same WITH can reference it
687
- if (withTableName !== undefined) {
688
- const resolvedTable: ResolvedTable = { name: withTableName, columns };
689
- ctx.tables.set(withTableName, [resolvedTable]);
690
- }
691
-
692
- return columns;
693
- }
694
-
695
- function checkAthenaAst(ast: AST, ctx: VisitContext): void {
696
- assert.ok(ast.type === 'select');
697
- const select = ast;
698
-
699
- if (select.with !== null) {
700
- for (const withItem of select.with) {
701
- checkSelect(withItem.stmt.ast, ctx, withItem.name.value);
702
- }
703
- select.with = null;
704
- }
705
-
706
- checkSelect(select, ctx);
707
- }
708
-
709
60
  // ---------------------------------------------------------------------------
710
61
  // ESLint rule
711
62
  // ---------------------------------------------------------------------------
@@ -817,5 +168,3 @@ const rule: ESLintUtils.RuleModule<typeof SYNTEXT_ERROR | typeof ATHENA_ERROR> =
817
168
  });
818
169
 
819
170
  export default rule;
820
-
821
- /* eslint-enable max-lines */