@checkdigit/eslint-athena-plugin 1.0.0-PR.2-dcdf

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.
Files changed (66) hide show
  1. package/LICENSE.txt +21 -0
  2. package/README.md +17 -0
  3. package/SECURITY.md +13 -0
  4. package/dist-mjs/athena/api-locator.mjs +66 -0
  5. package/dist-mjs/athena/api-matcher.mjs +206 -0
  6. package/dist-mjs/athena/athena.mjs +165 -0
  7. package/dist-mjs/athena/column.mjs +1 -0
  8. package/dist-mjs/athena/context.mjs +21 -0
  9. package/dist-mjs/athena/index.mjs +1 -0
  10. package/dist-mjs/athena/service-table.mjs +45 -0
  11. package/dist-mjs/athena/sql-file.mjs +123 -0
  12. package/dist-mjs/athena/types.mjs +1 -0
  13. package/dist-mjs/athena/validate.mjs +619 -0
  14. package/dist-mjs/athena/visitor.mjs +291 -0
  15. package/dist-mjs/get-documentation-url.mjs +9 -0
  16. package/dist-mjs/index.mjs +56 -0
  17. package/dist-mjs/openapi/deref-schema.mjs +20 -0
  18. package/dist-mjs/openapi/generate-schema.mjs +375 -0
  19. package/dist-mjs/openapi/service-schema-generator.mjs +176 -0
  20. package/dist-mjs/peggy/athena-peggy.mjs +20700 -0
  21. package/dist-mjs/service.mjs +9 -0
  22. package/dist-mjs/sql-parser.mjs +28 -0
  23. package/dist-types/athena/api-locator.d.ts +2 -0
  24. package/dist-types/athena/api-matcher.d.ts +14 -0
  25. package/dist-types/athena/athena.d.ts +5 -0
  26. package/dist-types/athena/column.d.ts +1 -0
  27. package/dist-types/athena/context.d.ts +21 -0
  28. package/dist-types/athena/index.d.ts +8 -0
  29. package/dist-types/athena/service-table.d.ts +8 -0
  30. package/dist-types/athena/sql-file.d.ts +5 -0
  31. package/dist-types/athena/types.d.ts +493 -0
  32. package/dist-types/athena/validate.d.ts +14 -0
  33. package/dist-types/athena/visitor.d.ts +75 -0
  34. package/dist-types/get-documentation-url.d.ts +1 -0
  35. package/dist-types/index.d.ts +5 -0
  36. package/dist-types/openapi/deref-schema.d.ts +1 -0
  37. package/dist-types/openapi/generate-schema.d.ts +33 -0
  38. package/dist-types/openapi/service-schema-generator.d.ts +5 -0
  39. package/dist-types/peggy/athena-peggy.d.ts +13 -0
  40. package/dist-types/service.d.ts +2 -0
  41. package/dist-types/sql-parser.d.ts +25 -0
  42. package/package.json +1 -0
  43. package/src/api/v1/swagger.yml +619 -0
  44. package/src/api/v2/swagger.yml +477 -0
  45. package/src/athena/api-locator.ts +78 -0
  46. package/src/athena/api-matcher.ts +323 -0
  47. package/src/athena/athena.ts +224 -0
  48. package/src/athena/column.ts +4 -0
  49. package/src/athena/context.ts +47 -0
  50. package/src/athena/index.ts +13 -0
  51. package/src/athena/service-table.ts +78 -0
  52. package/src/athena/sql-file.ts +161 -0
  53. package/src/athena/types.ts +568 -0
  54. package/src/athena/validate.ts +902 -0
  55. package/src/athena/visitor.ts +406 -0
  56. package/src/get-documentation-url.ts +7 -0
  57. package/src/index.ts +67 -0
  58. package/src/openapi/deref-schema.ts +20 -0
  59. package/src/openapi/generate-schema.ts +553 -0
  60. package/src/openapi/service-schema-generator.ts +241 -0
  61. package/src/peggy/athena-peggy.ts +22149 -0
  62. package/src/peggy/athena.peggy +2971 -0
  63. package/src/service.ts +11 -0
  64. package/src/services/eslintAthenaPlugin/v1/swagger.schema.deref.json +1931 -0
  65. package/src/services/eslintAthenaPlugin/v2/swagger.schema.deref.json +978 -0
  66. package/src/sql-parser.ts +53 -0
@@ -0,0 +1,323 @@
1
+ // athena/api-matcher.ts
2
+
3
+ import debug from 'debug';
4
+ import type { SchemaObject } from 'ajv/dist/2020';
5
+
6
+ import type { ApiSchemas, OperationSchemas } from '../openapi/generate-schema';
7
+ import type { Binary, ColumnRefItem, Function as SqlFunction } from './types';
8
+
9
+ const log = debug('eslint-athena-plugin:athena:api-matcher');
10
+
11
+ export interface OperationToMatch {
12
+ path: string;
13
+ method: string;
14
+ operationSchemas: OperationSchemas;
15
+ }
16
+
17
+ export interface MatchedOperation {
18
+ path: string;
19
+ method: string;
20
+ request: SchemaObject;
21
+ response: SchemaObject;
22
+ }
23
+
24
+ // A predicate over an API operation candidate: (url path, HTTP method, HTTP response code)
25
+ type OperationPredicate = (
26
+ path: string,
27
+ method: string,
28
+ responseCode: string,
29
+ ) => boolean;
30
+
31
+ const ALWAYS_TRUE: OperationPredicate = () => true;
32
+
33
+ // --- AST accessor helpers (use Record<string,unknown> to avoid TypeScript narrowing conflicts) ---
34
+
35
+ function rec(node: unknown): Record<string, unknown> | undefined {
36
+ return typeof node === 'object' && node !== null
37
+ ? (node as Record<string, unknown>)
38
+ : undefined;
39
+ }
40
+
41
+ function getFunctionName(node: unknown): string | undefined {
42
+ const fn = rec(node);
43
+ if (fn?.['type'] !== 'function') {
44
+ return undefined;
45
+ }
46
+ const name = fn['name'] as SqlFunction['name'] | undefined;
47
+ const firstName = name?.name[0];
48
+ return firstName === undefined ? undefined : firstName.value.toLowerCase();
49
+ }
50
+
51
+ function getColumnRef(node: unknown): ColumnRefItem | undefined {
52
+ const col = rec(node);
53
+ return col?.['type'] === 'column_ref' ? (node as ColumnRefItem) : undefined;
54
+ }
55
+
56
+ function getColumnName(node: unknown): string | undefined {
57
+ const column = getColumnRef(node)?.column;
58
+ return typeof column === 'string' ? column.toLowerCase() : undefined;
59
+ }
60
+
61
+ function getColumnTable(node: unknown): string | undefined {
62
+ return getColumnRef(node)?.table ?? undefined;
63
+ }
64
+
65
+ function getStringValue(node: unknown): string | undefined {
66
+ const val = rec(node);
67
+ if (val?.['type'] !== 'single_quote_string' && val?.['type'] !== 'string') {
68
+ return undefined;
69
+ }
70
+ return typeof val['value'] === 'string' ? val['value'] : undefined;
71
+ }
72
+
73
+ function getNumberValue(node: unknown): number | undefined {
74
+ const val = rec(node);
75
+ return val?.['type'] === 'number' && typeof val['value'] === 'number'
76
+ ? val['value']
77
+ : undefined;
78
+ }
79
+
80
+ // Returns the function args when the node is split(url, '/') or split_part(url, '/'), else undefined.
81
+ function getSplitUrlFunctionArgs(
82
+ node: unknown,
83
+ functionName: 'split' | 'split_part',
84
+ ): unknown[] | undefined {
85
+ if (getFunctionName(node) !== functionName) {
86
+ return undefined;
87
+ }
88
+ const args = (rec(node)?.['args'] as { value?: unknown[] } | undefined)
89
+ ?.value;
90
+ if (!Array.isArray(args) || args.length < 2) {
91
+ return undefined;
92
+ }
93
+ if (getColumnName(args[0]) !== 'url' || getStringValue(args[1]) !== '/') {
94
+ return undefined;
95
+ }
96
+ return args;
97
+ }
98
+
99
+ // Matches: split(url, '/')[N] — a Function node carrying an array_index extension
100
+ interface SplitUrlIndexed extends SqlFunction {
101
+ array_index: { brackets: true; index: { type: string; value: unknown } }[];
102
+ }
103
+ function isSplitUrlIndexed(node: unknown): node is SplitUrlIndexed {
104
+ if (getSplitUrlFunctionArgs(node, 'split') === undefined) {
105
+ return false;
106
+ }
107
+ const fn = rec(node);
108
+ return (
109
+ Array.isArray(fn?.['array_index']) &&
110
+ (fn['array_index'] as unknown[]).length > 0
111
+ );
112
+ }
113
+
114
+ // Matches: split_part(url, '/', N) — Presto-style, index in args[2] (1-based)
115
+ function isSplitPartUrl(node: unknown): node is SqlFunction {
116
+ const args = getSplitUrlFunctionArgs(node, 'split_part');
117
+ return getNumberValue(args?.[2]) !== undefined;
118
+ }
119
+
120
+ // Matches: cardinality(split(url, '/'))
121
+ function isCardinalitySplitUrl(node: unknown): node is SqlFunction {
122
+ if (getFunctionName(node) !== 'cardinality') {
123
+ return false;
124
+ }
125
+ const outerArgs = (rec(node)?.['args'] as { value?: unknown[] } | undefined)
126
+ ?.value;
127
+ if (!Array.isArray(outerArgs) || outerArgs.length === 0) {
128
+ return false;
129
+ }
130
+ return getSplitUrlFunctionArgs(outerArgs[0], 'split') !== undefined;
131
+ }
132
+
133
+ // Returns the table qualifier of the left-hand side of a matchable binary condition.
134
+ // undefined means either unqualified (applies to every table) or indeterminate.
135
+ function getConditionTableQualifier(left: unknown): string | undefined {
136
+ const colName = getColumnName(left);
137
+ if (colName !== undefined) {
138
+ return getColumnTable(left);
139
+ }
140
+
141
+ const splitArgs =
142
+ getSplitUrlFunctionArgs(left, 'split') ??
143
+ getSplitUrlFunctionArgs(left, 'split_part');
144
+ if (splitArgs !== undefined) {
145
+ return getColumnTable(splitArgs[0]);
146
+ }
147
+ if (isCardinalitySplitUrl(left)) {
148
+ const outerArgs = (rec(left)?.['args'] as { value?: unknown[] } | undefined)
149
+ ?.value;
150
+ const splitFn = rec(outerArgs?.[0]);
151
+ const innerArgs = (splitFn?.['args'] as { value?: unknown[] } | undefined)
152
+ ?.value;
153
+ return getColumnTable(innerArgs?.[0]);
154
+ }
155
+ return undefined;
156
+ }
157
+
158
+ // --- Predicate builders ---
159
+
160
+ // Builds a predicate that checks whether the Nth path segment (1-based) equals a fixed value.
161
+ // Path parameters (`:param`) are treated as wildcards and always match.
162
+ function buildPathSegmentPredicate(
163
+ index: number,
164
+ value: string,
165
+ ): OperationPredicate {
166
+ return (path) => {
167
+ const part = path.split('/')[index - 1];
168
+ log('checking path segment', { path, index, part, value });
169
+ return part?.startsWith(':') === true ? true : part === value;
170
+ };
171
+ }
172
+
173
+ // tableAlias: the alias (or undefined if none) of the FROM-clause item we are currently matching.
174
+ // Conditions that explicitly reference a different alias are skipped (treated as ALWAYS_TRUE).
175
+ function buildLeafPredicate(
176
+ node: Binary,
177
+ tableAlias: string | undefined,
178
+ ): OperationPredicate | undefined {
179
+ if (node.operator !== '=') {
180
+ return undefined;
181
+ }
182
+ const { left, right } = node;
183
+
184
+ const conditionTable = getConditionTableQualifier(left);
185
+ // conditionTable === undefined → unqualified or indeterminate, applies to all tables
186
+ // conditionTable === string → qualified; skip if it names a different alias
187
+ if (
188
+ conditionTable !== undefined &&
189
+ tableAlias !== undefined &&
190
+ conditionTable !== tableAlias
191
+ ) {
192
+ return undefined;
193
+ }
194
+
195
+ // method = 'GET'
196
+ if (getColumnName(left) === 'method') {
197
+ const value = getStringValue(right);
198
+ if (value !== undefined) {
199
+ return (_path, method) => method === value;
200
+ }
201
+ }
202
+
203
+ // responsestatus = '200'
204
+ if (getColumnName(left) === 'responsestatus') {
205
+ const value = getStringValue(right);
206
+ if (value !== undefined) {
207
+ return (_path, _method, responseCode) => responseCode === value;
208
+ }
209
+ }
210
+
211
+ // split(url, '/')[N] = 'value'
212
+ if (isSplitUrlIndexed(left)) {
213
+ const index = left.array_index[0]?.index.value;
214
+ const value = getStringValue(right);
215
+ if (typeof index === 'number' && value !== undefined) {
216
+ return buildPathSegmentPredicate(index, value);
217
+ }
218
+ }
219
+
220
+ // split_part(url, '/', N) = 'value'
221
+ if (isSplitPartUrl(left)) {
222
+ const index = getNumberValue(
223
+ getSplitUrlFunctionArgs(left, 'split_part')?.[2],
224
+ );
225
+ const value = getStringValue(right);
226
+ if (index !== undefined && value !== undefined) {
227
+ return buildPathSegmentPredicate(index, value);
228
+ }
229
+ }
230
+
231
+ // cardinality(split(url, '/')) = N
232
+ if (isCardinalitySplitUrl(left)) {
233
+ const count = getNumberValue(right);
234
+ if (count !== undefined) {
235
+ return (path) => path.split('/').length === count;
236
+ }
237
+ }
238
+
239
+ return undefined;
240
+ }
241
+
242
+ function buildPredicate(
243
+ expr: unknown,
244
+ tableAlias: string | undefined,
245
+ ): OperationPredicate {
246
+ const node = rec(expr);
247
+ if (node?.['type'] !== 'binary_expr') {
248
+ return ALWAYS_TRUE;
249
+ }
250
+
251
+ const binary = expr as Binary;
252
+
253
+ switch (binary.operator) {
254
+ case 'AND': {
255
+ const leftPred = buildPredicate(binary.left, tableAlias);
256
+ const rightPred = buildPredicate(binary.right, tableAlias);
257
+ return (path, method, code) =>
258
+ leftPred(path, method, code) && rightPred(path, method, code);
259
+ }
260
+ case 'OR': {
261
+ const leftPred = buildPredicate(binary.left, tableAlias);
262
+ const rightPred = buildPredicate(binary.right, tableAlias);
263
+ return (path, method, code) =>
264
+ leftPred(path, method, code) || rightPred(path, method, code);
265
+ }
266
+ case 'NOT': {
267
+ const innerPred = buildPredicate(binary.left, tableAlias);
268
+ return (path, method, code) => !innerPred(path, method, code);
269
+ }
270
+ default: {
271
+ return buildLeafPredicate(binary, tableAlias) ?? ALWAYS_TRUE;
272
+ }
273
+ }
274
+ }
275
+
276
+ export function matchApi(
277
+ selectAST: object,
278
+ tableAST: object,
279
+ apiSchemas: ApiSchemas[],
280
+ ): MatchedOperation[] | undefined {
281
+ const tableAlias = (tableAST as { as?: string | null }).as ?? undefined;
282
+ const predicate = buildPredicate(
283
+ (selectAST as { where?: unknown }).where,
284
+ tableAlias,
285
+ );
286
+
287
+ const allOperations: OperationToMatch[] = apiSchemas
288
+ .flatMap((apiSchema) => Object.entries(apiSchema.apis))
289
+ .flatMap(([path, operations]) =>
290
+ Object.entries(operations).map(([method, operationSchemas]) => ({
291
+ path,
292
+ method: method.toUpperCase(),
293
+ operationSchemas,
294
+ })),
295
+ );
296
+ log('total operation schemas', allOperations.length);
297
+
298
+ const matchedApis = allOperations.flatMap(
299
+ ({ path, method, operationSchemas }) =>
300
+ Object.entries(operationSchemas.responses).flatMap(
301
+ ([responseCode, responseSchema]) =>
302
+ predicate(path, method, responseCode)
303
+ ? [
304
+ {
305
+ path,
306
+ method,
307
+ request: operationSchemas.request,
308
+ response: responseSchema,
309
+ },
310
+ ]
311
+ : [],
312
+ ),
313
+ );
314
+ log('matched apis', matchedApis.length);
315
+
316
+ if (matchedApis.length === 0) {
317
+ log('no matched api');
318
+ throw new Error(
319
+ 'No matched api, please adjust your query conditions to match with at least one API endpoints with firehose enabled.',
320
+ );
321
+ }
322
+ return matchedApis;
323
+ }
@@ -0,0 +1,224 @@
1
+ // athena/athena.ts
2
+
3
+ /*
4
+ * Copyright (c) 2021-2026 Check Digit, LLC
5
+ *
6
+ * This code is licensed under the MIT license (see LICENSE.txt for details).
7
+ */
8
+
9
+ import debug from 'debug';
10
+ import {
11
+ AST_NODE_TYPES,
12
+ ESLintUtils,
13
+ type TSESTree,
14
+ } from '@typescript-eslint/utils';
15
+
16
+ import { parse } from '../peggy/athena-peggy.ts';
17
+ import type { AST } from './types';
18
+ import { createRootContext } from './context.ts';
19
+ import {
20
+ ATHENA_ERROR,
21
+ AthenaError,
22
+ checkAthenaAst,
23
+ offsetToLoc,
24
+ SYNTAXT_ERROR,
25
+ } from './validate.ts';
26
+
27
+ export const ruleId = 'athena';
28
+
29
+ const log = debug('eslint-athena-plugin:athena');
30
+ const createRule = ESLintUtils.RuleCreator((name) => name);
31
+
32
+ // Maps a SQL-string offset (as produced by the PEG parser) back to an absolute source offset.
33
+ // Each quasi in a TemplateLiteral contributes a segment; template expressions have no SQL width
34
+ // but do occupy source characters, so a naïve single-offset approach gives wrong results when
35
+ // the error location is in a quasi that follows one or more template expressions.
36
+ interface SqlSourceSegment {
37
+ sqlStart: number; // offset in the trimmed SQL where this quasi's content begins
38
+ srcStart: number; // absolute source offset where this quasi's content begins
39
+ }
40
+
41
+ function buildSqlMapping(sqlNode: TSESTree.Node): SqlSourceSegment[] {
42
+ if (sqlNode.type !== AST_NODE_TYPES.TemplateLiteral) {
43
+ // String literals: single segment, content starts one char after the opening quote.
44
+ return [{ sqlStart: 0, srcStart: sqlNode.range[0] + 1 }];
45
+ }
46
+ const rawSql = sqlNode.quasis
47
+ .map((quasi) => quasi.value.cooked ?? '')
48
+ .join('');
49
+ const trimStart = rawSql.length - rawSql.trimStart().length;
50
+ const segments: SqlSourceSegment[] = [];
51
+ let sqlCursor = 0;
52
+ for (const [index, quasi] of sqlNode.quasis.entries()) {
53
+ const cooked = quasi.value.cooked ?? '';
54
+ const localTrim = index === 0 ? trimStart : 0;
55
+ // quasi.range[0] is the opening backtick (index 0) or the closing } of the preceding expression.
56
+ segments.push({
57
+ sqlStart: sqlCursor,
58
+ srcStart: quasi.range[0] + 1 + localTrim,
59
+ });
60
+ sqlCursor += cooked.length - localTrim;
61
+ }
62
+ return segments;
63
+ }
64
+
65
+ function sqlOffsetToSource(
66
+ sqlOffset: number,
67
+ segments: SqlSourceSegment[],
68
+ ): number {
69
+ for (let index = segments.length - 1; index >= 0; index--) {
70
+ const seg = segments[index];
71
+ if (seg !== undefined && sqlOffset >= seg.sqlStart) {
72
+ return seg.srcStart + (sqlOffset - seg.sqlStart);
73
+ }
74
+ }
75
+ return segments[0]?.srcStart ?? 0;
76
+ }
77
+
78
+ // ---------------------------------------------------------------------------
79
+ // ESLint rule
80
+ // ---------------------------------------------------------------------------
81
+
82
+ const rule: ESLintUtils.RuleModule<typeof SYNTAXT_ERROR | typeof ATHENA_ERROR> =
83
+ createRule({
84
+ name: ruleId,
85
+ meta: {
86
+ type: 'problem',
87
+ docs: {
88
+ description:
89
+ 'Validate Athena SQL strings against OpenAPI schemas at lint time',
90
+ },
91
+ schema: [],
92
+ messages: {
93
+ [SYNTAXT_ERROR]: `SyntaxError {{ errorMessage }}`,
94
+ [ATHENA_ERROR]: `AthenaError {{ errorMessage }}`,
95
+ },
96
+ },
97
+ defaultOptions: [],
98
+ create(context) {
99
+ function checkSql(sql: string, sqlNode: TSESTree.Node) {
100
+ if (
101
+ !/^\s*(?:SELECT\b[\s\S]*\bFROM\b|WITH\b[\s\S]*\bSELECT\b[\s\S]*\b)/iu.test(
102
+ sql,
103
+ )
104
+ ) {
105
+ log('skipping non-SELECT SQL string', { sql });
106
+ return;
107
+ }
108
+
109
+ const sqlMapping = buildSqlMapping(sqlNode);
110
+ let ast: AST;
111
+ try {
112
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
113
+ ({ ast } = parse(sql, { includeLocations: true }));
114
+ } catch (error) {
115
+ log('error parsing Athena SQL', { error, sql });
116
+ const pegLoc = (
117
+ error as {
118
+ location?: { start: { offset: number }; end: { offset: number } };
119
+ }
120
+ ).location;
121
+ if (pegLoc !== undefined) {
122
+ const sourceText = context.sourceCode.getText();
123
+ context.report({
124
+ loc: {
125
+ start: offsetToLoc(
126
+ sourceText,
127
+ sqlOffsetToSource(pegLoc.start.offset, sqlMapping),
128
+ ),
129
+ end: offsetToLoc(
130
+ sourceText,
131
+ sqlOffsetToSource(pegLoc.end.offset, sqlMapping),
132
+ ),
133
+ },
134
+ messageId: SYNTAXT_ERROR,
135
+ data: { errorMessage: (error as Error).message },
136
+ });
137
+ } else {
138
+ context.report({
139
+ node: sqlNode,
140
+ messageId: SYNTAXT_ERROR,
141
+ data: { errorMessage: (error as Error).message },
142
+ });
143
+ }
144
+ return;
145
+ }
146
+ const athenaCtx = createRootContext();
147
+ try {
148
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
149
+ checkAthenaAst(Array.isArray(ast) ? ast[0] : ast, athenaCtx);
150
+ } catch (error) {
151
+ log('error checking Athena AST', { error, sql });
152
+ if (error instanceof AthenaError) {
153
+ const astLoc = (
154
+ error.ast as
155
+ | {
156
+ loc?: {
157
+ start: { offset: number };
158
+ end: { offset: number };
159
+ };
160
+ }
161
+ | undefined
162
+ )?.loc;
163
+ if (astLoc !== undefined) {
164
+ const sourceText = context.sourceCode.getText();
165
+ context.report({
166
+ loc: {
167
+ start: offsetToLoc(
168
+ sourceText,
169
+ sqlOffsetToSource(astLoc.start.offset, sqlMapping),
170
+ ),
171
+ end: offsetToLoc(
172
+ sourceText,
173
+ sqlOffsetToSource(astLoc.end.offset, sqlMapping),
174
+ ),
175
+ },
176
+ messageId: ATHENA_ERROR,
177
+ data: { errorMessage: error.message },
178
+ });
179
+ } else {
180
+ context.report({
181
+ node: sqlNode,
182
+ messageId: ATHENA_ERROR,
183
+ data: { errorMessage: error.message },
184
+ });
185
+ }
186
+ } else {
187
+ // eslint-disable-next-line no-console
188
+ console.error(
189
+ `Failed to apply ${ruleId} rule for "${context.filename}":`,
190
+ error,
191
+ );
192
+ context.report({
193
+ node: sqlNode,
194
+ messageId: ATHENA_ERROR,
195
+ data: {
196
+ errorMessage:
197
+ error instanceof Error
198
+ ? String(error)
199
+ : JSON.stringify(error, undefined, 2),
200
+ },
201
+ });
202
+ }
203
+ }
204
+ }
205
+
206
+ return {
207
+ TemplateLiteral(sqlNode) {
208
+ const sql = sqlNode.quasis
209
+ .map((quasi) => quasi.value.cooked)
210
+ .join('')
211
+ .trim();
212
+ checkSql(sql, sqlNode);
213
+ },
214
+ Literal(sqlNode) {
215
+ if (typeof sqlNode.value !== 'string') {
216
+ return;
217
+ }
218
+ checkSql(sqlNode.value, sqlNode);
219
+ },
220
+ };
221
+ },
222
+ });
223
+
224
+ export default rule;
@@ -0,0 +1,4 @@
1
+ // athena/column.ts
2
+
3
+ // Re-exported for external consumers that previously imported ResolvedColumn from here.
4
+ export type { ResolvedColumn } from './context';
@@ -0,0 +1,47 @@
1
+ // athena/context.ts
2
+
3
+ import type { OpenAPIV3_1 as v3 } from 'openapi-types';
4
+
5
+ import type { ApiSchemas } from '../openapi/generate-schema';
6
+ import type { MatchedOperation } from './api-matcher';
7
+
8
+ export interface ResolvedColumn {
9
+ name: string;
10
+ schema: v3.SchemaObject;
11
+ ast?: object;
12
+ }
13
+
14
+ export interface ResolvedTable {
15
+ name?: string;
16
+ columns: Map<string, ResolvedColumn[]>;
17
+ apiOperation?: MatchedOperation[];
18
+ }
19
+
20
+ export interface VisitContext {
21
+ // Tables available in this scope, keyed by name. Each name maps to an array because
22
+ // multiple API operations can match a single service table (e.g. GET + POST on same path).
23
+ tables: Map<string, ResolvedTable[]>;
24
+ // alias → canonical table name
25
+ aliases: Map<string, string>;
26
+ // API schema disk-read cache, shared across the entire query to avoid re-reading files.
27
+ apiSchemas: Map<string, ApiSchemas[]>;
28
+ parent?: VisitContext;
29
+ }
30
+
31
+ export function createRootContext(): VisitContext {
32
+ return {
33
+ tables: new Map(),
34
+ aliases: new Map(),
35
+ apiSchemas: new Map(),
36
+ };
37
+ }
38
+
39
+ // Creates a child context that inherits CTE tables registered in the parent scope.
40
+ export function createChildContext(parent: VisitContext): VisitContext {
41
+ return {
42
+ tables: new Map(parent.tables),
43
+ aliases: new Map(),
44
+ apiSchemas: parent.apiSchemas,
45
+ parent,
46
+ };
47
+ }
@@ -0,0 +1,13 @@
1
+ // athena/index.ts
2
+
3
+ import type { OpenAPIV3_1 as v3 } from 'openapi-types';
4
+
5
+ export interface ServiceEndpoint {
6
+ path: string;
7
+ method: 'GET' | 'POST' | 'PUT' | 'DELETE';
8
+ requestBody?: v3.SchemaObject;
9
+ responses: Record<string, v3.SchemaObject>;
10
+ }
11
+
12
+ // A service may expose multiple endpoints (e.g. /sample/v1, /sample/v2).
13
+ export type Service = Record<string, ServiceEndpoint>;
@@ -0,0 +1,78 @@
1
+ // athena/service-table.ts
2
+
3
+ /*
4
+ * Copyright (c) 2021-2026 Check Digit, LLC
5
+ *
6
+ * This code is licensed under the MIT license (see LICENSE.txt for details).
7
+ */
8
+
9
+ /**
10
+ * Builds a ResolvedTable for one matched API operation against an Athena service table.
11
+ * Each Athena service table exposes a fixed set of top-level columns representing the
12
+ * Kinesis stream record fields (method, url, requestbody, responsebody, etc.).
13
+ */
14
+
15
+ import type { OpenAPIV3_1 as v3 } from 'openapi-types';
16
+ import type { SchemaObject } from 'ajv/dist/2020';
17
+
18
+ import type { MatchedOperation } from './api-matcher';
19
+ import type { ResolvedColumn, ResolvedTable } from './context';
20
+
21
+ const SCHEMA_STRING: SchemaObject = { type: 'string' };
22
+ const SCHEMA_OBJECT: SchemaObject = { type: 'object' };
23
+
24
+ function col(name: string, schema: v3.SchemaObject): ResolvedColumn {
25
+ return { name, schema };
26
+ }
27
+
28
+ function bodySchema(
29
+ envelope: SchemaObject,
30
+ field: 'body' | 'headers',
31
+ ): v3.SchemaObject {
32
+ return (
33
+ (envelope as Record<string, Record<string, v3.SchemaObject>>)[
34
+ 'properties'
35
+ ]?.[field] ?? SCHEMA_OBJECT
36
+ );
37
+ }
38
+
39
+ /**
40
+ * Create one ResolvedTable per matched API operation.
41
+ * Multiple operations may match (e.g. GET + POST for the same service), so the
42
+ * caller receives an array and stores all of them under the same table name.
43
+ */
44
+ export function buildServiceTables(
45
+ tableName: string,
46
+ operations: MatchedOperation[],
47
+ ): ResolvedTable[] {
48
+ return operations.map((operation) => ({
49
+ name: tableName,
50
+ apiOperation: [operation],
51
+ columns: new Map<string, ResolvedColumn[]>([
52
+ ['method', [col('method', SCHEMA_STRING)]],
53
+ ['started', [col('started', SCHEMA_STRING)]],
54
+ ['ended', [col('ended', SCHEMA_STRING)]],
55
+ ['url', [col('url', SCHEMA_STRING)]],
56
+ [
57
+ 'requestbody',
58
+ [col('requestbody', bodySchema(operation.request, 'body'))],
59
+ ],
60
+ [
61
+ 'requestheaders',
62
+ [col('requestheaders', bodySchema(operation.request, 'headers'))],
63
+ ],
64
+ ['responsestatus', [col('responsestatus', SCHEMA_STRING)]],
65
+ ['responsemessage', [col('responsemessage', SCHEMA_STRING)]],
66
+ ['responsetype', [col('responsetype', SCHEMA_STRING)]],
67
+ [
68
+ 'responsebody',
69
+ [col('responsebody', bodySchema(operation.response, 'body'))],
70
+ ],
71
+ [
72
+ 'responseheaders',
73
+ [col('responseheaders', bodySchema(operation.response, 'headers'))],
74
+ ],
75
+ ['partition_date', [col('partition_date', SCHEMA_STRING)]],
76
+ ]),
77
+ }));
78
+ }