@checkdigit/eslint-plugin 7.17.1 → 7.18.0-PR.143-8290
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 +30 -0
- package/dist-mjs/athena/api-matcher.mjs +108 -0
- package/dist-mjs/athena/athena.mjs +331 -0
- package/dist-mjs/athena/column.mjs +1 -0
- package/dist-mjs/athena/context.mjs +21 -0
- package/dist-mjs/athena/index.mjs +1 -0
- package/dist-mjs/athena/service-table.mjs +32 -0
- package/dist-mjs/athena/types.mjs +1 -0
- package/dist-mjs/athena/visitor.mjs +258 -0
- package/dist-mjs/index.mjs +8 -4
- package/dist-mjs/no-status-code-assert.mjs +1 -1
- package/dist-mjs/openapi/deref-schema.mjs +14 -0
- package/dist-mjs/openapi/generate-schema.mjs +273 -0
- package/dist-mjs/openapi/service-schema-generator.mjs +147 -0
- package/dist-mjs/peggy/athena-peggy.mjs +20629 -0
- package/dist-types/athena/api-locator.d.ts +2 -0
- package/dist-types/athena/api-matcher.d.ts +14 -0
- package/dist-types/athena/athena.d.ts +6 -0
- package/dist-types/athena/column.d.ts +1 -0
- package/dist-types/athena/context.d.ts +21 -0
- package/dist-types/athena/index.d.ts +8 -0
- package/dist-types/athena/service-table.d.ts +8 -0
- package/dist-types/athena/types.d.ts +474 -0
- package/dist-types/athena/visitor.d.ts +63 -0
- package/dist-types/no-status-code-assert.d.ts +1 -1
- package/dist-types/openapi/deref-schema.d.ts +1 -0
- package/dist-types/openapi/generate-schema.d.ts +33 -0
- package/dist-types/openapi/service-schema-generator.d.ts +5 -0
- package/dist-types/peggy/athena-peggy.d.ts +13 -0
- package/package.json +1 -96
- package/src/athena/ATHENA.md +387 -0
- package/src/athena/PLAN.md +355 -0
- package/src/athena/api-locator.ts +39 -0
- package/src/athena/api-matcher.ts +169 -0
- package/src/athena/athena.ts +491 -0
- package/src/athena/column.ts +2 -0
- package/src/athena/context.ts +47 -0
- package/src/athena/index.ts +11 -0
- package/src/athena/service-table.ts +55 -0
- package/src/athena/types.ts +526 -0
- package/src/athena/visitor.ts +365 -0
- package/src/index.ts +4 -0
- package/src/no-side-effects.ts +1 -1
- package/src/no-status-code-assert.ts +2 -2
- package/src/openapi/deref-schema.ts +14 -0
- package/src/openapi/generate-schema.ts +422 -0
- package/src/openapi/service-schema-generator.ts +189 -0
- package/src/peggy/athena-chat.peggy +608 -0
- package/src/peggy/athena-peggy.ts +22078 -0
- package/src/peggy/athena.peggy +2967 -0
- package/src/require-service-call-response-declaration.ts +2 -2
- package/src/services/interchange/v1/swagger.schema.deref.json +849 -0
- package/src/services/interchange/v1/swagger.schema.json +473 -0
- package/src/services/interchange/v1/swagger.yml +414 -0
- package/src/services/ledger/v1/swagger.schema.deref.json +6694 -0
- package/src/services/ledger/v1/swagger.schema.json +1820 -0
- package/src/services/ledger/v1/swagger.yml +1094 -0
- package/src/services/link/v1/swagger.schema.deref.json +648 -0
- package/src/services/link/v1/swagger.schema.json +444 -0
- package/src/services/link/v1/swagger.yml +343 -0
- package/src/services/message/v1/swagger.schema.deref.json +22049 -0
- package/src/services/message/v1/swagger.schema.json +3470 -0
- package/src/services/message/v1/swagger.yml +2798 -0
- package/src/services/message/v2/swagger.schema.deref.json +72221 -0
- package/src/services/message/v2/swagger.schema.json +3558 -0
- package/src/services/message/v2/swagger.yml +3009 -0
- package/src/services/paymentCard/v1/swagger.schema.deref.json +4346 -0
- package/src/services/paymentCard/v1/swagger.schema.json +2181 -0
- package/src/services/paymentCard/v1/swagger.yml +1161 -0
- package/src/services/paymentCard/v2/swagger.schema.deref.json +4336 -0
- package/src/services/paymentCard/v2/swagger.schema.json +2155 -0
- package/src/services/paymentCard/v2/swagger.yml +1149 -0
- package/src/services/person/v1/swagger.schema.deref.json +6786 -0
- package/src/services/person/v1/swagger.schema.json +1445 -0
- package/src/services/person/v1/swagger.yml +1157 -0
- package/src/services/teampayApproval/v1/swagger.schema.deref.json +9898 -0
- package/src/services/teampayCardManagement/v1/swagger.schema.deref.json +6187 -0
- package/src/services/teampayClientManagement/v1/swagger.schema.deref.json +4914 -0
- package/src/services/teampayClientManagement/v1/swagger.schema.json +1964 -0
- package/src/services/teampayClientManagement/v1/swagger.yml +1376 -0
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
# Athena Rule — Visitor Pattern Refactor Plan
|
|
2
|
+
|
|
3
|
+
## Current State
|
|
4
|
+
|
|
5
|
+
`athena.ts` implements `checkAthenaAst` / `checkSelect` as a single monolithic recursive function (~270 lines, flagged with `sonarjs/cognitive-complexity`). It processes the SQL AST imperatively using JSONPath queries scattered throughout:
|
|
6
|
+
|
|
7
|
+
- `$.from..[?(@ && @.table && !@.column)]` — extract table refs
|
|
8
|
+
- `"$..from[?(@ && @.type === 'unnest')]"` — extract UNNEST
|
|
9
|
+
- `"$..[?(@ && @.type === 'column_ref' && @.column)]"` — find column refs per selected column
|
|
10
|
+
- `"$..[?(@ && @.type === 'function' && …)]…"` — find `json_extract_scalar` / `json_extract`
|
|
11
|
+
- `"$..[?(@ && @.type === 'column_ref' && @.array_index)]…"` — find JSON-bracket accessors
|
|
12
|
+
|
|
13
|
+
These ad-hoc JSONPath walks make the control flow hard to follow and will only get harder as new SQL constructs are added (JOINs, subqueries, CASE expressions, etc.).
|
|
14
|
+
|
|
15
|
+
## What the Visitor Pattern Should Do
|
|
16
|
+
|
|
17
|
+
A visitor walks the typed AST once, dispatching to typed handlers per node kind. The goal is:
|
|
18
|
+
|
|
19
|
+
1. **Separation of concerns** — each handler knows exactly one AST node shape.
|
|
20
|
+
2. **Typed dispatch** — no `as unknown`, no runtime `@.type === 'foo'` predicates.
|
|
21
|
+
3. **Composability** — multiple passes can reuse the same visitor infrastructure.
|
|
22
|
+
4. **Testability** — individual visitors can be unit-tested against small AST fixtures.
|
|
23
|
+
|
|
24
|
+
## AST Node Taxonomy (from `types.ts`)
|
|
25
|
+
|
|
26
|
+
The PEG grammar produces nodes that fall into these families:
|
|
27
|
+
|
|
28
|
+
| Node type (`.type` field) | Interface | Visitor hook needed |
|
|
29
|
+
| --------------------------------- | ----------------------- | ------------------- |
|
|
30
|
+
| `"select"` | `Select` | `visitSelect` |
|
|
31
|
+
| `"binary_expr"` | `Binary` | `visitBinary` |
|
|
32
|
+
| `"column_ref"` | `ColumnRefItem` | `visitColumnRef` |
|
|
33
|
+
| `"function"` | `Function` | `visitFunction` |
|
|
34
|
+
| `"aggr_func"` | `AggrFunc` | `visitAggrFunc` |
|
|
35
|
+
| `"cast"` | `Cast` | `visitCast` |
|
|
36
|
+
| `"case"` | `Case` | `visitCase` |
|
|
37
|
+
| `"expr_list"` | `ExprList` | `visitExprList` |
|
|
38
|
+
| `"unnest"` (virtual, in `from`) | `ColumnRefItem` subtype | `visitUnnest` |
|
|
39
|
+
| literal / value nodes | `ValueExpr` | `visitValue` |
|
|
40
|
+
| `BaseFrom` / `Join` / `TableExpr` | `From` union | `visitFrom` |
|
|
41
|
+
| `With` item | `With` | `visitWith` |
|
|
42
|
+
|
|
43
|
+
`Select._next` links UNION branches; those also need traversal via `visitSelect`.
|
|
44
|
+
|
|
45
|
+
## What Is Missing in `visitor.ts`
|
|
46
|
+
|
|
47
|
+
The file currently has 5 lines:
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
export default function (ast: unknown, context: Context) {
|
|
51
|
+
if (typeof ast !== 'object' || ast === null) {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Everything is missing:
|
|
58
|
+
|
|
59
|
+
### 1. Visitor interface / type map
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
// Minimal shape needed
|
|
63
|
+
type VisitorMap = {
|
|
64
|
+
visitSelect?(node: Select, ctx: VisitContext): void;
|
|
65
|
+
visitWith?(node: With, ctx: VisitContext): void;
|
|
66
|
+
visitFrom?(node: From, ctx: VisitContext): void;
|
|
67
|
+
visitColumn?(node: Column, ctx: VisitContext): void;
|
|
68
|
+
visitColumnRef?(node: ColumnRefItem, ctx: VisitContext): void;
|
|
69
|
+
visitFunction?(node: Function, ctx: VisitContext): void;
|
|
70
|
+
visitBinary?(node: Binary, ctx: VisitContext): void;
|
|
71
|
+
visitValue?(node: ValueExpr, ctx: VisitContext): void;
|
|
72
|
+
// … etc.
|
|
73
|
+
};
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
All hooks should be optional so callers only implement what they care about.
|
|
77
|
+
|
|
78
|
+
### 2. Typed `VisitContext`
|
|
79
|
+
|
|
80
|
+
`context.ts` currently defines a `Table` interface (not a context class). We need a mutable context that accumulates resolution state **per SELECT scope**:
|
|
81
|
+
|
|
82
|
+
```ts
|
|
83
|
+
interface VisitContext {
|
|
84
|
+
// resolved service tables keyed by name/alias
|
|
85
|
+
tables: Map<string, ResolvedTable>;
|
|
86
|
+
// columns resolved so far in this SELECT
|
|
87
|
+
columns: Map<string, ResolvedColumn[]>;
|
|
88
|
+
// alias → real-name mapping
|
|
89
|
+
aliases: Map<string, string>;
|
|
90
|
+
// schemas loaded from disk, shared across the whole query
|
|
91
|
+
apiSchemas: Map<string, ApiSchemas[]>;
|
|
92
|
+
// parent context (for CTE / subquery scoping)
|
|
93
|
+
parent?: VisitContext;
|
|
94
|
+
}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
`context.ts` and `column.ts` have started down this road but are incomplete and have incorrect imports (`.ts` extensions in import paths, broken `Context` base class usage).
|
|
98
|
+
|
|
99
|
+
### 3. `visit(node, ctx)` dispatcher
|
|
100
|
+
|
|
101
|
+
The core dispatcher needs to:
|
|
102
|
+
|
|
103
|
+
- Accept `unknown` input (PEG output is not statically typed at boundaries)
|
|
104
|
+
- Read `node.type` and call the appropriate handler
|
|
105
|
+
- Recursively descend into child nodes
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
function visit(node: unknown, ctx: VisitContext, visitor: VisitorMap): void {
|
|
109
|
+
if (typeof node !== 'object' || node === null) return;
|
|
110
|
+
const typed = node as { type?: string };
|
|
111
|
+
switch (typed.type) {
|
|
112
|
+
case 'select':
|
|
113
|
+
return visitor.visitSelect?.(node as Select, ctx);
|
|
114
|
+
case 'binary_expr':
|
|
115
|
+
return visitor.visitBinary?.(node as Binary, ctx);
|
|
116
|
+
case 'column_ref':
|
|
117
|
+
return visitor.visitColumnRef?.(node as ColumnRefItem, ctx);
|
|
118
|
+
case 'function':
|
|
119
|
+
return visitor.visitFunction?.(node as Function, ctx);
|
|
120
|
+
// … etc.
|
|
121
|
+
}
|
|
122
|
+
// fallback: recurse into all object values
|
|
123
|
+
}
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
### 4. Per-node child traversal helpers
|
|
127
|
+
|
|
128
|
+
Each handler should call `visitChildren` (or per-kind helpers) to recurse. Missing helpers:
|
|
129
|
+
|
|
130
|
+
- `visitSelectChildren(node: Select, ctx, visitor)` — visits `from[]`, `columns[]`, `where`, `groupby`, `_next`
|
|
131
|
+
- `visitFromChildren(node: From, ctx, visitor)` — handles `BaseFrom`, `Join` (recurse into `.on`), `TableExpr` (recurse into subquery `.expr.ast`)
|
|
132
|
+
- `visitBinaryChildren(node: Binary, ctx, visitor)` — visits `.left` and `.right`
|
|
133
|
+
- `visitFunctionChildren(node: Function, ctx, visitor)` — visits `.args.value[]`
|
|
134
|
+
|
|
135
|
+
### 5. Concrete resolution visitor for `checkSelect` logic
|
|
136
|
+
|
|
137
|
+
The current `checkSelect` logic should be decomposed into a `ResolutionVisitor` that implements `VisitorMap`:
|
|
138
|
+
|
|
139
|
+
- `visitWith` → calls `checkSelect` on the CTE body, registers the result in `ctx.tables`
|
|
140
|
+
- `visitFrom` → resolves service table → calls `locateApi` + `matchApi` → populates `ctx.tables`
|
|
141
|
+
- `visitUnnest` → maps unnested array columns
|
|
142
|
+
- `visitColumnRef` inside a `SELECT` column → looks up the column in `ctx.tables`, applies property accessor, records in `ctx.columns`
|
|
143
|
+
- `visitFunction` → detects `json_extract_scalar` / `json_extract`, extracts the path argument
|
|
144
|
+
|
|
145
|
+
### 6. `service-table.ts` needs to be completed
|
|
146
|
+
|
|
147
|
+
The file duplicates large chunks of `athena.ts` but is incomplete (references `MatchedOperation`, `ResolvedColumn`, `AthenaContext`, `log`, etc. that are not imported). It was apparently started as the extraction destination for service-table logic. Missing:
|
|
148
|
+
|
|
149
|
+
- Import `MatchedOperation` from `api-matcher`
|
|
150
|
+
- Import `ResolvedColumn` (define locally or re-export from `athena.ts`)
|
|
151
|
+
- Import `log`, `SCHEMA_STRING`, `SCHEMA_OBJECT`
|
|
152
|
+
- Export `buildServiceTable(tableAST, selectAST, apiSchemas): ServiceTable` as the single factory function
|
|
153
|
+
- Remove the duplicate `checkSelect` / `checkAthenaAst` body currently pasted in
|
|
154
|
+
|
|
155
|
+
### 7. `index.ts` needs its commented-out code resolved
|
|
156
|
+
|
|
157
|
+
`index.ts` has the full `ServiceEndpoint` / `Service` types but all the context/resolver interfaces are commented out. Once `visitor.ts` and `service-table.ts` are solid, these should either be deleted or promoted to the proper types.
|
|
158
|
+
|
|
159
|
+
### 8. `zzz.spec.ts` is a broken scratch file
|
|
160
|
+
|
|
161
|
+
`zzz.spec.ts` has syntax errors (`type Table =` with no RHS, calling `visit` instead of `visitSelect`, missing `context` arg). It was clearly a scratch experiment for the visitor idea. It should either become a proper unit test of the visitor once it exists, or be deleted.
|
|
162
|
+
|
|
163
|
+
## Suggested File Structure After Refactor
|
|
164
|
+
|
|
165
|
+
```
|
|
166
|
+
src/athena/
|
|
167
|
+
types.ts — unchanged (PEG AST types)
|
|
168
|
+
visitor.ts — IMPLEMENT: VisitorMap, VisitContext, visit(), visitChildren helpers
|
|
169
|
+
service-table.ts — COMPLETE: buildServiceTable() factory, ServiceTable type
|
|
170
|
+
context.ts — REWRITE: VisitContext (replace broken Table/Column class tree)
|
|
171
|
+
column.ts — REWRITE: ResolvedColumn value type (not a class extending Context)
|
|
172
|
+
index.ts — CLEAN UP: remove commented-out code once visitor types exist
|
|
173
|
+
athena.ts — REFACTOR: replace checkSelect monolith with visitor composition
|
|
174
|
+
athena.spec.ts — unchanged (integration tests)
|
|
175
|
+
zzz.spec.ts — FIX or DELETE: make it a real visitor unit test
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
## Recommended Implementation Order
|
|
179
|
+
|
|
180
|
+
1. **Define `VisitContext` in `context.ts`** — the mutable scope object passed through the tree.
|
|
181
|
+
2. **Define `VisitorMap` and `visit()` dispatcher in `visitor.ts`** — untyped input → typed dispatch.
|
|
182
|
+
3. **Add `visitSelectChildren` and other traversal helpers** — recursive descent wiring.
|
|
183
|
+
4. **Refactor `service-table.ts`** — `buildServiceTable()` extracts the FROM-resolution block out of `checkSelect`.
|
|
184
|
+
5. **Build `ResolutionVisitor`** — implements `VisitorMap`, accumulates `ctx.columns`, replaces current `checkSelect` body.
|
|
185
|
+
6. **Wire into `athena.ts`** — `checkAthenaAst` creates a root `VisitContext`, runs `visit(ast, ctx, resolutionVisitor)`.
|
|
186
|
+
7. **Fix `zzz.spec.ts`** into a real test of the visitor dispatch.
|
|
187
|
+
|
|
188
|
+
## Key Design Decisions To Resolve
|
|
189
|
+
|
|
190
|
+
- **One visitor pass or two?** The current code does a single forward pass (resolves tables, then processes columns). A visitor architecture could do two passes (first pass builds the table map, second pass resolves columns), which would simplify handling forward references between CTEs — but is not required yet.
|
|
191
|
+
- **Array of tables vs. Map** — the current `allResolvedTables: Record<string, Table[]>` uses arrays to represent ambiguity (multiple matching API endpoints). The new `VisitContext` should preserve this, but consider whether `Map<string, ResolvedTable[]>` is cleaner.
|
|
192
|
+
- **Error handling** — `AthenaError` is currently thrown and caught at the top level. With a visitor, errors should either propagate the same way, or be collected in `ctx.errors[]` so all errors in a query are reported at once.
|
|
193
|
+
|
|
194
|
+
---
|
|
195
|
+
|
|
196
|
+
## Future Improvements — Supporting More Complex SQL
|
|
197
|
+
|
|
198
|
+
The following gaps exist in the current implementation. Each item describes what SQL pattern is not yet handled, why it matters, and what work is needed.
|
|
199
|
+
|
|
200
|
+
### 1. JOIN Resolution
|
|
201
|
+
|
|
202
|
+
**What fails today:** `JOIN` tables (`isJoin`) are silently skipped in `resolveFromClause`. Any column from a joined table that is not also reachable through a plain `BaseFrom` causes a "can't found column" error or silently produces a wrong result.
|
|
203
|
+
|
|
204
|
+
**Affected patterns:**
|
|
205
|
+
|
|
206
|
+
```sql
|
|
207
|
+
SELECT req.message, res.message
|
|
208
|
+
FROM request_message AS req
|
|
209
|
+
JOIN response_message AS res ON json_extract_scalar(res.message, '$.id') = json_extract_scalar(req.message, '$.id')
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
**Work needed:**
|
|
213
|
+
|
|
214
|
+
- In `resolveFromClause`, handle `isJoin` nodes: the `Join` interface extends `BaseFrom`, so the `table` / `as` fields are available. Register the joined table in `ctx.tables` the same way as a plain `BaseFrom`.
|
|
215
|
+
- Add `visitJoin` hook in `visitor.ts`'s resolution path.
|
|
216
|
+
- The `ON` clause is an expression and cannot be used to narrow the API match (unlike WHERE). The join condition should be walked for column reference validation only.
|
|
217
|
+
|
|
218
|
+
### 2. Subquery / Inline View Resolution (`TableExpr`)
|
|
219
|
+
|
|
220
|
+
**What fails today:** `TableExpr` nodes (subqueries in the FROM clause) are skipped. Columns from subqueries cannot be resolved.
|
|
221
|
+
|
|
222
|
+
**Affected patterns:**
|
|
223
|
+
|
|
224
|
+
```sql
|
|
225
|
+
SELECT t.url FROM (SELECT url, method FROM link) AS t
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
**Work needed:**
|
|
229
|
+
|
|
230
|
+
- In `resolveFromClause`, detect `isTableExpr` items and recursively call `checkSelect` on `item.expr.ast`, capturing the resulting columns as a new `ResolvedTable` in `ctx.tables[alias]`.
|
|
231
|
+
- The child SELECT needs its own `createChildContext(ctx)` so its own alias map doesn't pollute the outer scope.
|
|
232
|
+
|
|
233
|
+
### 3. CAST Type Propagation
|
|
234
|
+
|
|
235
|
+
**What fails today:** `CAST(expr AS type)` is walked but the target type is ignored. The column receives the schema of the inner expression, not the cast type.
|
|
236
|
+
|
|
237
|
+
**Affected patterns:**
|
|
238
|
+
|
|
239
|
+
```sql
|
|
240
|
+
SELECT CAST(json_extract(responsebody, '$.links') AS ARRAY<VARCHAR>) AS linkages
|
|
241
|
+
FROM link CROSS JOIN UNNEST(linkages) AS t(linkage)
|
|
242
|
+
-- linkages has schema { type:'object' } instead of { type:'array', items:{ type:'string' } }
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
This means UNNEST pre-pass correctly finds `linkages` is `array` only when the JSON schema already says so, not when it is produced by a CAST. Two tests in `athena.spec.ts` carry a `[TODO:] handle array item type extraction using schema dereferencing` comment on exactly this case.
|
|
246
|
+
|
|
247
|
+
**Work needed:**
|
|
248
|
+
|
|
249
|
+
- In `resolveSelectColumns`, detect that `hasFunctionCalls` found a `cast` node.
|
|
250
|
+
- Use `extractCastTarget(expr)` (new helper) to get `target.dataType` (e.g. `'ARRAY'`, `'MAP'`, `'VARCHAR'`, `'JSON'`).
|
|
251
|
+
- Map Athena/Trino type names to JSON Schema equivalents and substitute the schema before storing the column.
|
|
252
|
+
- Extend `ColumnRefWithIndex` or add a new `CastExpr` shape to make the type accessible without `unknown` casts.
|
|
253
|
+
|
|
254
|
+
### 4. CASE Expression Schema
|
|
255
|
+
|
|
256
|
+
**What fails today:** A column whose expression is a `case` node has 0 `column_ref` items at the top level (each arm has refs, but the rule counts refs at the outer level). It falls into the "multiple refs → string" bucket even when all arms resolve to the same type.
|
|
257
|
+
|
|
258
|
+
**Affected patterns:**
|
|
259
|
+
|
|
260
|
+
```sql
|
|
261
|
+
SELECT CASE WHEN method = 'GET' THEN responsebody ELSE requestbody END AS body FROM link
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
**Work needed:**
|
|
265
|
+
|
|
266
|
+
- Special-case `case` nodes in pass 2: collect refs from each arm's `result`, intersect the resolved schemas, and emit that as the output schema.
|
|
267
|
+
- Add `extractCaseArms(expr)` helper in `visitor.ts`.
|
|
268
|
+
|
|
269
|
+
### 5. OR Conditions in API Matcher
|
|
270
|
+
|
|
271
|
+
**What fails today:** `api-matcher.ts` extracts path/method conditions only from AND-chains in the WHERE clause. SQL that uses `OR` to express version-specific URL patterns is handled by the rule passing multiple matched operations — but only when the condition can be matched by each alternative independently. A disjunction like `(split(url,'/')[3]='v1' AND ...) OR (split(url,'/')[3]='v2' AND ...)` produces no match for either branch individually, so `matchApi` throws.
|
|
272
|
+
|
|
273
|
+
**Affected patterns:**
|
|
274
|
+
|
|
275
|
+
```sql
|
|
276
|
+
WHERE method = 'PUT'
|
|
277
|
+
AND (
|
|
278
|
+
(split(url, '/')[4] = 'card' AND cardinality(split(url, '/')) = 5)
|
|
279
|
+
OR
|
|
280
|
+
(split(url, '/')[4] = 'card' AND split(url, '/')[6] = 'number' AND cardinality(split(url, '/')) = 6)
|
|
281
|
+
)
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
The test case `'AND/OR'` in `athena.spec.ts` is currently in the `invalid` set with a misleading error message — it is expected to be valid, but fails today.
|
|
285
|
+
|
|
286
|
+
**Work needed:**
|
|
287
|
+
|
|
288
|
+
- Teach `api-matcher.ts` to recognise OR-of-AND patterns: extract each AND branch as a separate matcher set, union all matched operations across branches.
|
|
289
|
+
- Alternatively, fall back to "no path constraint" when an OR is detected at the top level of the WHERE, accepting a potentially larger set of matched operations.
|
|
290
|
+
|
|
291
|
+
### 6. UNION Column Compatibility Check
|
|
292
|
+
|
|
293
|
+
**What fails today:** UNION ALL branches are resolved independently; their column count and schemas are never compared. A UNION where one branch has a different column name or an incompatible type silently passes.
|
|
294
|
+
|
|
295
|
+
**Work needed:**
|
|
296
|
+
|
|
297
|
+
- After resolving `select._next`, compare the column maps (names and schemas) between the current SELECT and the next SELECT.
|
|
298
|
+
- Report `AthenaError` if column counts differ or if a column in the next branch does not exist in the current branch.
|
|
299
|
+
- Consider whether schema compatibility checking (e.g. `string` vs `object`) should also be enforced, or just name-level matching.
|
|
300
|
+
|
|
301
|
+
### 7. Multiple Matched Operations — Conflict Reporting
|
|
302
|
+
|
|
303
|
+
**What fails today:** When multiple API operations match for the same service table (e.g. two different response status codes, or GET and POST), `buildServiceTables` silently creates multiple `ResolvedTable` entries. Column resolution then collects schemas from all of them. Conflicting schemas (e.g. different response body shapes for 200 vs 204) are silently merged.
|
|
304
|
+
|
|
305
|
+
**Work needed:**
|
|
306
|
+
|
|
307
|
+
- After `buildServiceTables`, if `operations.length > 1`, emit a warning (not an error, since it may be intentional) listing the matched operations.
|
|
308
|
+
- In `resolveSelectColumns`, when `resolvedColumns.length > 1`, check whether all schemas are identical. If not, either report a warning or fall back to `{ type: 'object' }`.
|
|
309
|
+
|
|
310
|
+
### 8. Error Collection (All Errors Per Query)
|
|
311
|
+
|
|
312
|
+
**What fails today:** The first `AthenaError` thrown immediately exits resolution, so only one error per SQL string is reported even if the query has multiple bad property accesses.
|
|
313
|
+
|
|
314
|
+
**Work needed:**
|
|
315
|
+
|
|
316
|
+
- Add `errors: AthenaError[]` to `VisitContext`.
|
|
317
|
+
- Replace `throw new AthenaError(...)` with `ctx.errors.push(...); continue` in pass 2.
|
|
318
|
+
- After `checkAthenaAst` returns, report each collected error as a separate `context.report(...)` call (or a single report with all messages joined).
|
|
319
|
+
|
|
320
|
+
### 9. Remove `ast.json` Debug Write
|
|
321
|
+
|
|
322
|
+
**What fails today (non-functional):** `athena.ts` writes `ast.json` to disk on every linted SQL string:
|
|
323
|
+
|
|
324
|
+
```typescript
|
|
325
|
+
fs.writeFileSync('ast.json', JSON.stringify(ast, undefined, 2));
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
This is a development artifact. In production it creates a file in the working directory on every lint run and makes the rule non-deterministic in parallel lint runs.
|
|
329
|
+
|
|
330
|
+
**Work needed:** Delete the `fs.writeFileSync` call and the `import fs from 'node:fs'` import. If debug AST output is needed, gate it behind `DEBUG=eslint-plugin:athena` using the `log` function already available.
|
|
331
|
+
|
|
332
|
+
### 10. Error Location in Reports
|
|
333
|
+
|
|
334
|
+
**What fails today:** The rule reports errors at the template literal node level (the whole `` `...` `` span). `ResolvedColumn.ast` stores the originating column AST node with `loc` information, but this is never used.
|
|
335
|
+
|
|
336
|
+
**Work needed:**
|
|
337
|
+
|
|
338
|
+
- The PEG parser already attaches `loc: { start: { line, column, offset }, end: {...} }` to AST nodes (when `includeLocations: true`).
|
|
339
|
+
- Map `loc` back to ESLint's `TSESTree.SourceLocation` and use `context.report({ loc: ... })` to pinpoint the exact column expression that failed rather than the whole template literal.
|
|
340
|
+
- The helper `getErrorLocation(ast)` previously existed in the old `athena.ts` (removed in the refactor) as a reference implementation.
|
|
341
|
+
|
|
342
|
+
### 11. `parameters` CTE Pattern — FROM-less SELECT
|
|
343
|
+
|
|
344
|
+
**What fails today:** CTEs with no FROM clause (e.g. `SELECT '' AS p_from, '' AS p_to`) produce a `Select` with `from: null`. These currently pass through `resolveFromClause` without registering any table, and then `resolveSelectColumns` tries to iterate zero tables. Because the columns are literal strings (0 `column_ref`s), they fall into the "no column references → use string default" branch and pass. However if someone writes `SELECT p_from FROM parameters` in a later CTE, the `parameters` table is looked up and resolved correctly because `checkSelect` registers its output in `ctx.tables` regardless. **This case already works**, but it is worth documenting explicitly and adding a dedicated test case to guard against regressions.
|
|
345
|
+
|
|
346
|
+
### 12. Quoted Table Names
|
|
347
|
+
|
|
348
|
+
**What fails today (partially):** Table names enclosed in double quotes (e.g. `FROM "payment-card"`) are parsed by the PEG grammar but the quotes are preserved in the `table` field: `table: '"payment-card"'`. `locateApi` then looks for a service named `"payment-card"` including the quotes, which fails to match the camelCase folder name `paymentCard`.
|
|
349
|
+
|
|
350
|
+
The test case `'SELECT with FROM - table name with double quotes'` is currently in the valid set, which suggests the quoted-table fallback path (no matching service → no error) is silently accepted. If schemas exist for `payment-card`, the rule would fail to find them.
|
|
351
|
+
|
|
352
|
+
**Work needed:**
|
|
353
|
+
|
|
354
|
+
- Strip leading/trailing `"` or `'` from `tableName` before calling `locateApi` in `resolveFromClause`.
|
|
355
|
+
- Add a test case that actually has schemas for a quoted-name service and verifies column resolution succeeds.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// athena/api-locator.ts
|
|
2
|
+
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
|
|
5
|
+
import debug from 'debug';
|
|
6
|
+
|
|
7
|
+
import { type ApiSchemas, generateSchemasForService } from '../openapi/generate-schema.ts';
|
|
8
|
+
|
|
9
|
+
const log = debug('eslint-plugin:athena:api-locator');
|
|
10
|
+
|
|
11
|
+
const SERVICES_ROOT_FOLDER = 'src/services';
|
|
12
|
+
|
|
13
|
+
function upperCaseFirstCharacter(value: string): string {
|
|
14
|
+
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
|
|
15
|
+
return `${value[0]?.toUpperCase()}${value.slice(1)}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function locateApi(serviceName: string): ApiSchemas[] {
|
|
19
|
+
log('locating API for service', serviceName);
|
|
20
|
+
|
|
21
|
+
const serviceNameParts = serviceName.split('-');
|
|
22
|
+
const camelCaseServiceName = [serviceNameParts[0], ...serviceNameParts.slice(1).map(upperCaseFirstCharacter)].join(
|
|
23
|
+
'',
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
const allSchemaFilenames = fs.globSync(`${SERVICES_ROOT_FOLDER}/${camelCaseServiceName}/*/swagger.schema.deref.json`);
|
|
27
|
+
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
|
|
28
|
+
log(`${allSchemaFilenames.length} versions of API schemas located for service ${serviceName}`, allSchemaFilenames);
|
|
29
|
+
|
|
30
|
+
if (allSchemaFilenames.length > 0) {
|
|
31
|
+
return allSchemaFilenames.map(
|
|
32
|
+
(schemaFilename) => JSON.parse(fs.readFileSync(schemaFilename, 'utf-8')) as ApiSchemas,
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
log('no pre-generated schemas found, attempting on-demand generation for service', serviceName);
|
|
37
|
+
const outputDir = `${SERVICES_ROOT_FOLDER}/${camelCaseServiceName}`;
|
|
38
|
+
return generateSchemasForService(serviceName, outputDir).map(({ schema }) => schema);
|
|
39
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// athena/api-matcher.ts
|
|
2
|
+
|
|
3
|
+
import debug from 'debug';
|
|
4
|
+
import { JSONPath } from 'jsonpath-plus';
|
|
5
|
+
import type { SchemaObject } from 'ajv/dist/2020';
|
|
6
|
+
|
|
7
|
+
import type { ApiSchemas, OperationSchemas } from '../openapi/generate-schema';
|
|
8
|
+
|
|
9
|
+
const log = debug('eslint-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
|
+
type Matcher = (path: string, method: string) => boolean;
|
|
25
|
+
|
|
26
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
27
|
+
function getVersionMatcher(_selectAST: object, _tableAST: object): Matcher | undefined {
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
32
|
+
function getPathPartMatcher(selectAST: object, _tableAST: object): Matcher | undefined {
|
|
33
|
+
const [pathPartCondition]: object[] = JSONPath({
|
|
34
|
+
json: selectAST,
|
|
35
|
+
path: "$.where..[?(@ && @.type === 'binary_expr' && @.operator === '=' && @.left && @.left.type === 'function' && @.left.name && @.left.name.name && @.left.name.name[0] && @.left.name.name[0].value === 'split' && @.left.args && @.left.args.value && @.left.args.value[0] && @.left.args.value[0].type === 'column_ref' && @.left.args.value[0].column === 'url' && @.left.args.value[1] && @.left.args.value[1].type === 'single_quote_string' && @.left.args.value[1].value === '/' && @.left.array_index && @.left.array_index[0] && @.left.array_index[0].brackets === true && @.left.array_index[0].index && @.left.array_index[0].index.type === 'number')]",
|
|
36
|
+
}); /*?*/
|
|
37
|
+
// log('pathPartCondition', pathPartCondition);
|
|
38
|
+
|
|
39
|
+
if (pathPartCondition !== undefined) {
|
|
40
|
+
const [pathPartIndex]: [number] = JSONPath({
|
|
41
|
+
json: pathPartCondition,
|
|
42
|
+
path: '$.left.array_index[0].index.value',
|
|
43
|
+
}); /*?*/
|
|
44
|
+
const [pathPartMatch]: [string] = JSONPath({
|
|
45
|
+
json: pathPartCondition,
|
|
46
|
+
path: '$.right.value',
|
|
47
|
+
}); /*?*/
|
|
48
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
49
|
+
return (path: string, _method: string) => {
|
|
50
|
+
const parts = path.split('/'); /*?*/
|
|
51
|
+
const part = parts[pathPartIndex - 1]; //athena index is larger than js index by one /*?*/
|
|
52
|
+
log(`checking path part`, { path, pathPartIndex, part, pathPartMatch });
|
|
53
|
+
return part?.startsWith(':') === true
|
|
54
|
+
? true // ignore path part if it presents a dynamic input parameter
|
|
55
|
+
: parts[pathPartIndex - 1] === pathPartMatch; // try to match with static path part
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
63
|
+
function getPathPartsCountMatcher(selectAST: object, _tableAST: object): Matcher | undefined {
|
|
64
|
+
// log('getPathPartsCountMatcher', JSON.stringify(selectAST, undefined, 2));
|
|
65
|
+
const [pathPartCount]: number[] = JSONPath({
|
|
66
|
+
json: selectAST,
|
|
67
|
+
path: "$.where..[?(@ && @.type === 'binary_expr' && @.operator === '=' && @.left && @.left.type === 'function' && @.left.name && @.left.name.name && @.left.name.name[0] && @.left.name.name[0].value === 'cardinality' && @.right && @.right.type === 'number' && @.left.args && @.left.args.value && @.left.args.value[0] && @.left.args.value[0].type === 'function' && @.left.args.value[0].name && @.left.args.value[0].name.name && @.left.args.value[0].name.name[0] && @.left.args.value[0].name.name[0].value === 'split' && @.left.args.value[0].args && @.left.args.value[0].args.value && @.left.args.value[0].args.value[0] && @.left.args.value[0].args.value[0].type === 'column_ref' && @.left.args.value[0].args.value[0].column === 'url' && @.left.args.value[0].args.value[1] && @.left.args.value[0].args.value[1].type === 'single_quote_string' && @.left.args.value[0].args.value[1].value === '/')].right.value",
|
|
68
|
+
}); /*?*/
|
|
69
|
+
|
|
70
|
+
if (pathPartCount !== undefined) {
|
|
71
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
72
|
+
return (path: string, _method: string) => {
|
|
73
|
+
const parts = path.split('/');
|
|
74
|
+
return parts.length === pathPartCount;
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function getPathMatchers(selectAST: object, tableAST: object) {
|
|
82
|
+
return [getPathPartMatcher(selectAST, tableAST), getPathPartsCountMatcher(selectAST, tableAST)];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
86
|
+
function getMethodMatcher(selectAST: object, _tableAST: object): Matcher | undefined {
|
|
87
|
+
const [methodToMatch]: string[] = JSONPath({
|
|
88
|
+
json: selectAST,
|
|
89
|
+
path: "$.where..[?(@ && @.type === 'binary_expr' && @.operator === '=' && @.left && @.left.type === 'column_ref' && @.left.column === 'method' && @.right && @.right.type === 'single_quote_string')].right.value",
|
|
90
|
+
}); /*?*/
|
|
91
|
+
|
|
92
|
+
if (methodToMatch !== undefined) {
|
|
93
|
+
return (_path: string, method: string) => method === methodToMatch;
|
|
94
|
+
}
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
99
|
+
function getResponseStatusToMatch(selectAST: object, _tableAST: object): string | undefined {
|
|
100
|
+
const [responseStatus]: string[] = JSONPath({
|
|
101
|
+
json: selectAST,
|
|
102
|
+
path: "$.where..[?(@ && @.type === 'binary_expr' && @.operator === '=' && @.left && @.left.type === 'column_ref' && @.left.column === 'responsestatus' && @.right && @.right.type === 'single_quote_string')].right.value",
|
|
103
|
+
}); /*?*/
|
|
104
|
+
|
|
105
|
+
return responseStatus;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// [TODO:] match only relevent table in case multiple tables are joined
|
|
109
|
+
export function matchApi(
|
|
110
|
+
selectAST: object,
|
|
111
|
+
tableAST: object,
|
|
112
|
+
apiSchemas: ApiSchemas[],
|
|
113
|
+
): MatchedOperation[] | undefined {
|
|
114
|
+
const schemaMatchers: Matcher[] = [
|
|
115
|
+
getVersionMatcher(selectAST, tableAST),
|
|
116
|
+
getPathMatchers(selectAST, tableAST),
|
|
117
|
+
getMethodMatcher(selectAST, tableAST),
|
|
118
|
+
]
|
|
119
|
+
.flat()
|
|
120
|
+
.filter<Matcher>((matcher) => matcher !== undefined);
|
|
121
|
+
|
|
122
|
+
const allOperationSchemas: OperationToMatch[] = apiSchemas
|
|
123
|
+
.flatMap((apiSchema) => Object.entries(apiSchema.apis))
|
|
124
|
+
.flatMap(([path, operations]) =>
|
|
125
|
+
Object.entries(operations).map(([method, operationSchemas]) => ({
|
|
126
|
+
path,
|
|
127
|
+
method: method.toUpperCase(),
|
|
128
|
+
operationSchemas,
|
|
129
|
+
})),
|
|
130
|
+
);
|
|
131
|
+
log('total operation schemas', allOperationSchemas.length);
|
|
132
|
+
|
|
133
|
+
const matchedOperationSchemas = allOperationSchemas.filter(({ path, method }) =>
|
|
134
|
+
schemaMatchers.every((matcher) => matcher(path, method)),
|
|
135
|
+
);
|
|
136
|
+
log('matched operation schemas', matchedOperationSchemas.length);
|
|
137
|
+
|
|
138
|
+
if (matchedOperationSchemas.length === 0) {
|
|
139
|
+
log('no matched operation schema');
|
|
140
|
+
throw new Error('no matched operation schema');
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const matchedResponseStatus = getResponseStatusToMatch(selectAST, tableAST);
|
|
144
|
+
// [TODO:] should we allow multiple response status?
|
|
145
|
+
// assert.ok(matchedResponseStatus !== undefined);
|
|
146
|
+
log('matchedResponseStatus', matchedResponseStatus);
|
|
147
|
+
|
|
148
|
+
const matchedApis = matchedOperationSchemas
|
|
149
|
+
.flatMap((operation) =>
|
|
150
|
+
Object.entries(operation.operationSchemas.responses).map(([responseCode, responseSchema]) => {
|
|
151
|
+
const matchedResponseSchema =
|
|
152
|
+
matchedResponseStatus === undefined || responseCode === matchedResponseStatus ? responseSchema : undefined;
|
|
153
|
+
return matchedResponseSchema === undefined
|
|
154
|
+
? undefined
|
|
155
|
+
: {
|
|
156
|
+
path: operation.path,
|
|
157
|
+
method: operation.method,
|
|
158
|
+
request: operation.operationSchemas.request,
|
|
159
|
+
response: matchedResponseSchema,
|
|
160
|
+
};
|
|
161
|
+
}),
|
|
162
|
+
)
|
|
163
|
+
.filter((api) => api !== undefined);
|
|
164
|
+
if (matchedApis.length === 0) {
|
|
165
|
+
log('no api satisfy both request and response matchers');
|
|
166
|
+
throw new Error('no matched api');
|
|
167
|
+
}
|
|
168
|
+
return matchedApis;
|
|
169
|
+
}
|