@sqldoc/core 0.0.1
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/package.json +33 -0
- package/src/__tests__/ast/sqlparser-ts.test.ts +117 -0
- package/src/__tests__/blocks.test.ts +80 -0
- package/src/__tests__/compile.test.ts +407 -0
- package/src/__tests__/compiler/compile.test.ts +363 -0
- package/src/__tests__/lint-rules.test.ts +249 -0
- package/src/__tests__/lint.test.ts +270 -0
- package/src/__tests__/parser.test.ts +169 -0
- package/src/__tests__/tags.sql +15 -0
- package/src/__tests__/validator.test.ts +210 -0
- package/src/ast/adapter.ts +10 -0
- package/src/ast/index.ts +3 -0
- package/src/ast/sqlparser-ts.ts +218 -0
- package/src/ast/types.ts +28 -0
- package/src/blocks.ts +242 -0
- package/src/compiler/compile.ts +783 -0
- package/src/compiler/config.ts +102 -0
- package/src/compiler/index.ts +29 -0
- package/src/compiler/types.ts +320 -0
- package/src/index.ts +72 -0
- package/src/lint.ts +127 -0
- package/src/loader.ts +102 -0
- package/src/parser.ts +202 -0
- package/src/ts-import.ts +70 -0
- package/src/types.ts +111 -0
- package/src/utils.ts +31 -0
- package/src/validator.ts +324 -0
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import * as sp from '@sqldoc/sqlparser-ts'
|
|
2
|
+
import type { SqlAstAdapter } from './adapter.ts'
|
|
3
|
+
import type { SqlColumn, SqlCommentOn, SqlStatement } from './types.ts'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* AST adapter backed by @sqldoc/sqlparser-ts (WASM-based, multi-dialect).
|
|
7
|
+
* Uses real span info from the parser for line-based matching.
|
|
8
|
+
*/
|
|
9
|
+
export class SqlparserTsAdapter implements SqlAstAdapter {
|
|
10
|
+
private initialized = false
|
|
11
|
+
private parseFn!: (sql: string, dialect?: any) => any[]
|
|
12
|
+
|
|
13
|
+
async init(): Promise<void> {
|
|
14
|
+
if (this.initialized) return
|
|
15
|
+
await sp.init()
|
|
16
|
+
this.parseFn = sp.parse
|
|
17
|
+
this.initialized = true
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
parseStatements(sql: string): SqlStatement[] {
|
|
21
|
+
if (!this.initialized) {
|
|
22
|
+
throw new Error('SqlparserTsAdapter not initialized. Call init() first.')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
let ast: any[]
|
|
26
|
+
try {
|
|
27
|
+
ast = this.parseFn(sql, 'postgresql')
|
|
28
|
+
} catch {
|
|
29
|
+
// Full parse failed — try statement by statement
|
|
30
|
+
return this.parseStatementByStatement(sql)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const results: SqlStatement[] = []
|
|
34
|
+
for (const stmt of ast) {
|
|
35
|
+
const mapped = mapStatement(stmt)
|
|
36
|
+
if (mapped) results.push(mapped)
|
|
37
|
+
}
|
|
38
|
+
return results
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
private parseStatementByStatement(sql: string): SqlStatement[] {
|
|
42
|
+
const results: SqlStatement[] = []
|
|
43
|
+
const chunks = splitStatements(sql)
|
|
44
|
+
let charOffset = 0
|
|
45
|
+
for (const chunk of chunks) {
|
|
46
|
+
const trimmed = chunk.trim()
|
|
47
|
+
if (!trimmed) {
|
|
48
|
+
charOffset += chunk.length + 1 // +1 for semicolon
|
|
49
|
+
continue
|
|
50
|
+
}
|
|
51
|
+
// Count newlines before this chunk to get line offset
|
|
52
|
+
const lineOffset = sql.substring(0, charOffset + chunk.indexOf(trimmed[0])).split('\n').length - 1
|
|
53
|
+
try {
|
|
54
|
+
const ast = this.parseFn(`${trimmed};`, 'postgresql')
|
|
55
|
+
for (const stmt of ast) {
|
|
56
|
+
const mapped = mapStatement(stmt)
|
|
57
|
+
if (mapped) {
|
|
58
|
+
mapped.line += lineOffset
|
|
59
|
+
if (mapped.columns) {
|
|
60
|
+
for (const col of mapped.columns) {
|
|
61
|
+
col.line += lineOffset
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
results.push(mapped)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
} catch {
|
|
68
|
+
// Skip unparseable statements
|
|
69
|
+
}
|
|
70
|
+
charOffset += chunk.length + 1
|
|
71
|
+
}
|
|
72
|
+
return results
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
parseComments(sql: string): SqlCommentOn[] {
|
|
76
|
+
if (!this.initialized) {
|
|
77
|
+
throw new Error('SqlparserTsAdapter not initialized. Call init() first.')
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
let ast: any[]
|
|
81
|
+
try {
|
|
82
|
+
ast = this.parseFn(sql, 'postgresql')
|
|
83
|
+
} catch {
|
|
84
|
+
return []
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const results: SqlCommentOn[] = []
|
|
88
|
+
for (const stmt of ast) {
|
|
89
|
+
const key = Object.keys(stmt)[0]
|
|
90
|
+
if (key !== 'Comment') continue
|
|
91
|
+
|
|
92
|
+
const node = stmt.Comment
|
|
93
|
+
const objectType = node.object_type as string
|
|
94
|
+
const names = extractNames(node.object_name)
|
|
95
|
+
const comment = node.comment as string
|
|
96
|
+
|
|
97
|
+
let targetKey: string
|
|
98
|
+
if (objectType === 'Column' && names.length >= 2) {
|
|
99
|
+
targetKey = `COLUMN "${names[names.length - 2]}"."${names[names.length - 1]}"`
|
|
100
|
+
} else {
|
|
101
|
+
targetKey = `${objectType.toUpperCase()} "${names[names.length - 1] ?? ''}"`
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const line = getSpanLine(node.object_name?.[0])
|
|
105
|
+
results.push({ targetKey, content: comment, line })
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return results
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ── Statement splitting ─────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
/** Split SQL on semicolons, respecting $$ dollar-quoted blocks */
|
|
115
|
+
function splitStatements(sql: string): string[] {
|
|
116
|
+
const results: string[] = []
|
|
117
|
+
let current = ''
|
|
118
|
+
let inDollarQuote = false
|
|
119
|
+
for (let i = 0; i < sql.length; i++) {
|
|
120
|
+
if (sql[i] === '$' && sql[i + 1] === '$') {
|
|
121
|
+
inDollarQuote = !inDollarQuote
|
|
122
|
+
current += '$$'
|
|
123
|
+
i++
|
|
124
|
+
continue
|
|
125
|
+
}
|
|
126
|
+
if (sql[i] === ';' && !inDollarQuote) {
|
|
127
|
+
results.push(current)
|
|
128
|
+
current = ''
|
|
129
|
+
continue
|
|
130
|
+
}
|
|
131
|
+
current += sql[i]
|
|
132
|
+
}
|
|
133
|
+
if (current.trim()) results.push(current)
|
|
134
|
+
return results
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ── Helpers ──────────────────────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
/** Extract the start line (1-based) from an AST node's span */
|
|
140
|
+
function getSpanLine(node: any): number {
|
|
141
|
+
if (!node) return 1
|
|
142
|
+
const span = node.Identifier?.span ?? node.span
|
|
143
|
+
return span?.start?.line ?? 1
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Extract names from a name array */
|
|
147
|
+
function extractNames(nameArray: any[]): string[] {
|
|
148
|
+
if (!nameArray) return []
|
|
149
|
+
return nameArray.map((n: any) => n.Identifier?.value ?? n?.value).filter(Boolean)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function extractName(nameArray: any[]): string {
|
|
153
|
+
return extractNames(nameArray).join('.')
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Map an AST statement node to our SqlStatement type */
|
|
157
|
+
function mapStatement(stmt: any): SqlStatement | null {
|
|
158
|
+
const key = Object.keys(stmt)[0]
|
|
159
|
+
const node = stmt[key]
|
|
160
|
+
|
|
161
|
+
switch (key) {
|
|
162
|
+
case 'CreateTable':
|
|
163
|
+
return {
|
|
164
|
+
kind: 'table',
|
|
165
|
+
objectName: extractName(node.name),
|
|
166
|
+
line: getSpanLine(node.name?.[0]),
|
|
167
|
+
columns: mapColumns(node.columns ?? []),
|
|
168
|
+
raw: node,
|
|
169
|
+
}
|
|
170
|
+
case 'CreateView':
|
|
171
|
+
return { kind: 'view', objectName: extractName(node.name), line: getSpanLine(node.name?.[0]), raw: node }
|
|
172
|
+
case 'CreateIndex':
|
|
173
|
+
return { kind: 'index', objectName: extractName(node.name), line: getSpanLine(node.name?.[0]), raw: node }
|
|
174
|
+
case 'CreateType':
|
|
175
|
+
return { kind: 'type', objectName: extractName(node.name), line: getSpanLine(node.name?.[0]), raw: node }
|
|
176
|
+
case 'CreateFunction':
|
|
177
|
+
return { kind: 'function', objectName: extractName(node.name), line: getSpanLine(node.name?.[0]), raw: node }
|
|
178
|
+
case 'CreateTrigger':
|
|
179
|
+
return { kind: 'trigger', objectName: extractName(node.name), line: getSpanLine(node.name?.[0]), raw: node }
|
|
180
|
+
default:
|
|
181
|
+
return null
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Map columns using span info from the AST */
|
|
186
|
+
function mapColumns(columns: any[]): SqlColumn[] {
|
|
187
|
+
return columns.map((col: any) => ({
|
|
188
|
+
name: col.name?.value ?? '',
|
|
189
|
+
dataType: normalizeDataType(col.data_type),
|
|
190
|
+
line: col.name?.span?.start?.line ?? 1,
|
|
191
|
+
raw: col,
|
|
192
|
+
}))
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Normalize sqlparser-ts data type representations to lowercase strings.
|
|
197
|
+
*/
|
|
198
|
+
export function normalizeDataType(dt: any): string {
|
|
199
|
+
if (typeof dt === 'string') return dt.toLowerCase()
|
|
200
|
+
if (dt?.Varchar != null) {
|
|
201
|
+
const len = dt.Varchar?.IntegerLength?.length
|
|
202
|
+
return len != null ? `varchar(${len})` : 'varchar'
|
|
203
|
+
}
|
|
204
|
+
if (dt?.Custom) {
|
|
205
|
+
return dt.Custom[0]
|
|
206
|
+
?.map((n: any) => n.Identifier?.value)
|
|
207
|
+
.filter(Boolean)
|
|
208
|
+
.join('.')
|
|
209
|
+
.toLowerCase()
|
|
210
|
+
}
|
|
211
|
+
if (dt?.Timestamp != null) {
|
|
212
|
+
return dt.Timestamp?.With === 'Tz' ? 'timestamptz' : 'timestamp'
|
|
213
|
+
}
|
|
214
|
+
// Fallback: use first key name lowercased
|
|
215
|
+
const keys = Object.keys(dt)
|
|
216
|
+
if (keys.length === 1) return keys[0].toLowerCase()
|
|
217
|
+
return JSON.stringify(dt)
|
|
218
|
+
}
|
package/src/ast/types.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { SqlTarget } from '../types.ts'
|
|
2
|
+
|
|
3
|
+
export interface SqlColumn {
|
|
4
|
+
name: string
|
|
5
|
+
dataType: string
|
|
6
|
+
/** 1-based line number from AST span */
|
|
7
|
+
line: number
|
|
8
|
+
raw: unknown
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface SqlStatement {
|
|
12
|
+
kind: SqlTarget
|
|
13
|
+
objectName: string
|
|
14
|
+
/** 1-based line number from AST span */
|
|
15
|
+
line: number
|
|
16
|
+
columns?: SqlColumn[]
|
|
17
|
+
raw: unknown
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** A parsed COMMENT ON statement from the source SQL */
|
|
21
|
+
export interface SqlCommentOn {
|
|
22
|
+
/** e.g. 'TABLE "users"' or 'COLUMN "users"."email"' */
|
|
23
|
+
targetKey: string
|
|
24
|
+
/** The comment text */
|
|
25
|
+
content: string
|
|
26
|
+
/** 1-based line number */
|
|
27
|
+
line: number
|
|
28
|
+
}
|
package/src/blocks.ts
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared block-building logic used by both validator and compiler.
|
|
3
|
+
* Groups consecutive tag comments into blocks and resolves their SQL target
|
|
4
|
+
* using span-based line matching from the AST.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { SqlColumn, SqlStatement } from './ast/types.ts'
|
|
8
|
+
import type { ParsedTag } from './parser.ts'
|
|
9
|
+
import type { SqlTarget } from './types.ts'
|
|
10
|
+
|
|
11
|
+
// ── AST info ────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
export interface AstInfo {
|
|
14
|
+
target: SqlTarget
|
|
15
|
+
columnName?: string
|
|
16
|
+
columnType?: string
|
|
17
|
+
objectName?: string
|
|
18
|
+
astNode?: unknown
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface TagBlock {
|
|
22
|
+
tags: ParsedTag[]
|
|
23
|
+
sqlLines: string[]
|
|
24
|
+
ast: AstInfo
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Re-export for consumers that used to import from validator
|
|
28
|
+
export function detectTarget(sqlLines: string[]): SqlTarget {
|
|
29
|
+
return detectTargetFallback(sqlLines)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function detectTargetFallback(sqlLines: string[]): SqlTarget {
|
|
33
|
+
if (sqlLines.length === 0) return 'unknown'
|
|
34
|
+
const first = sqlLines[0]
|
|
35
|
+
const TARGET_PATTERNS: [RegExp, SqlTarget][] = [
|
|
36
|
+
[/^\s*CREATE\s+(OR\s+REPLACE\s+)?TABLE\b/i, 'table'],
|
|
37
|
+
[/^\s*CREATE\s+(OR\s+REPLACE\s+)?FUNCTION\b/i, 'function'],
|
|
38
|
+
[/^\s*CREATE\s+(OR\s+REPLACE\s+)?VIEW\b/i, 'view'],
|
|
39
|
+
[/^\s*CREATE\s+(UNIQUE\s+)?INDEX\b/i, 'index'],
|
|
40
|
+
[/^\s*CREATE\s+TYPE\b/i, 'type'],
|
|
41
|
+
[/^\s*CREATE\s+(OR\s+REPLACE\s+)?TRIGGER\b/i, 'trigger'],
|
|
42
|
+
]
|
|
43
|
+
for (const [re, target] of TARGET_PATTERNS) {
|
|
44
|
+
if (re.test(first)) return target
|
|
45
|
+
}
|
|
46
|
+
if (/^\s+\w+\s+\w+/.test(first) && !first.trim().startsWith('CREATE')) {
|
|
47
|
+
return 'column'
|
|
48
|
+
}
|
|
49
|
+
return 'unknown'
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ── Block building ──────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
/** Check if a tag is an inline comment (SQL code before the -- on the same line) */
|
|
55
|
+
function isInlineTag(tag: ParsedTag, docLines: string[]): boolean {
|
|
56
|
+
const line = docLines[tag.line]
|
|
57
|
+
if (!line) return false
|
|
58
|
+
const commentIdx = line.indexOf('--')
|
|
59
|
+
if (commentIdx < 0) return false
|
|
60
|
+
// If there's non-whitespace before the --, it's inline
|
|
61
|
+
return line.substring(0, commentIdx).trim().length > 0
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function buildBlocks(
|
|
65
|
+
tags: ParsedTag[],
|
|
66
|
+
_docText: string,
|
|
67
|
+
docLines: string[],
|
|
68
|
+
stmts: SqlStatement[],
|
|
69
|
+
): TagBlock[] {
|
|
70
|
+
if (tags.length === 0) return []
|
|
71
|
+
|
|
72
|
+
const blocks: TagBlock[] = []
|
|
73
|
+
let currentTags: ParsedTag[] = []
|
|
74
|
+
let lastTagLine = -2
|
|
75
|
+
|
|
76
|
+
for (const tag of tags) {
|
|
77
|
+
// Inline tags (comment after SQL on the same line) are always their own block
|
|
78
|
+
if (isInlineTag(tag, docLines)) {
|
|
79
|
+
if (currentTags.length > 0) {
|
|
80
|
+
blocks.push(finalizeBlock(currentTags, docLines, stmts))
|
|
81
|
+
currentTags = []
|
|
82
|
+
}
|
|
83
|
+
blocks.push(finalizeBlock([tag], docLines, stmts, true))
|
|
84
|
+
lastTagLine = tag.line
|
|
85
|
+
continue
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (currentTags.length > 0 && tag.line !== lastTagLine && tag.line !== lastTagLine + 1) {
|
|
89
|
+
blocks.push(finalizeBlock(currentTags, docLines, stmts))
|
|
90
|
+
currentTags = []
|
|
91
|
+
}
|
|
92
|
+
if (currentTags.length > 0 && tag.line === lastTagLine) {
|
|
93
|
+
currentTags.push(tag)
|
|
94
|
+
} else {
|
|
95
|
+
if (currentTags.length > 0 && tag.line !== lastTagLine + 1) {
|
|
96
|
+
blocks.push(finalizeBlock(currentTags, docLines, stmts))
|
|
97
|
+
currentTags = []
|
|
98
|
+
}
|
|
99
|
+
currentTags.push(tag)
|
|
100
|
+
}
|
|
101
|
+
lastTagLine = tag.line
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (currentTags.length > 0) {
|
|
105
|
+
blocks.push(finalizeBlock(currentTags, docLines, stmts))
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return blocks
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function finalizeBlock(tags: ParsedTag[], docLines: string[], stmts: SqlStatement[], inline = false): TagBlock {
|
|
112
|
+
const lastTagLine = tags[tags.length - 1].line
|
|
113
|
+
const firstTagLine = tags[0].line
|
|
114
|
+
|
|
115
|
+
// Collect SQL lines
|
|
116
|
+
const sqlLines: string[] = []
|
|
117
|
+
if (inline) {
|
|
118
|
+
// For inline tags, the SQL is the non-comment portion of the same line
|
|
119
|
+
const line = docLines[lastTagLine]
|
|
120
|
+
const commentIdx = line.indexOf('--')
|
|
121
|
+
if (commentIdx > 0) {
|
|
122
|
+
sqlLines.push(line.substring(0, commentIdx).trim())
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Also collect SQL lines after the tag block (for non-inline, or as fallback)
|
|
127
|
+
if (!inline) {
|
|
128
|
+
for (let i = lastTagLine + 1; i < docLines.length; i++) {
|
|
129
|
+
const line = docLines[i].trim()
|
|
130
|
+
if (!line) continue
|
|
131
|
+
if (line.startsWith('--')) continue
|
|
132
|
+
sqlLines.push(docLines[i])
|
|
133
|
+
if (line.endsWith(';') || line.endsWith(',') || line.endsWith(');') || line === ')') break
|
|
134
|
+
if (/\$\$\s*$/.test(line)) {
|
|
135
|
+
for (let j = i + 1; j < docLines.length; j++) {
|
|
136
|
+
sqlLines.push(docLines[j])
|
|
137
|
+
if (/\$\$/.test(docLines[j]) && j !== i) break
|
|
138
|
+
}
|
|
139
|
+
break
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Tags use 0-based lines, AST uses 1-based lines
|
|
145
|
+
// A tag on line N (0-based) associates with the AST node on line N+1 or N+2 (1-based)
|
|
146
|
+
const tagLine1Based = firstTagLine + 1
|
|
147
|
+
|
|
148
|
+
const ast = resolveAstByLine(tagLine1Based, lastTagLine + 1, stmts, sqlLines)
|
|
149
|
+
return { tags, sqlLines, ast }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Match a tag block to an AST node using line numbers.
|
|
154
|
+
*
|
|
155
|
+
* Algorithm:
|
|
156
|
+
* 1. If there's a column/statement on the same line as the tag (inline), use it
|
|
157
|
+
* 2. Otherwise scan forward from the tag line:
|
|
158
|
+
* - Hit a column → related to that column
|
|
159
|
+
* - Hit a CREATE statement → related to that statement
|
|
160
|
+
* - Hit end-of-table (no more columns, past last column) → related to the table
|
|
161
|
+
*/
|
|
162
|
+
function resolveAstByLine(
|
|
163
|
+
_firstTagLine: number, // 1-based
|
|
164
|
+
lastTagLine: number, // 1-based
|
|
165
|
+
stmts: SqlStatement[],
|
|
166
|
+
sqlLines: string[],
|
|
167
|
+
): AstInfo {
|
|
168
|
+
if (stmts.length === 0) {
|
|
169
|
+
return { target: detectTargetFallback(sqlLines) }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Build a flat list of all AST nodes (statements + columns) sorted by line
|
|
173
|
+
type AstNode = { type: 'stmt'; stmt: SqlStatement } | { type: 'col'; col: SqlColumn; parentStmt: SqlStatement }
|
|
174
|
+
|
|
175
|
+
const nodes: { line: number; node: AstNode }[] = []
|
|
176
|
+
for (const stmt of stmts) {
|
|
177
|
+
nodes.push({ line: stmt.line, node: { type: 'stmt', stmt } })
|
|
178
|
+
if (stmt.columns) {
|
|
179
|
+
for (const col of stmt.columns) {
|
|
180
|
+
nodes.push({ line: col.line, node: { type: 'col', col, parentStmt: stmt } })
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
nodes.sort((a, b) => a.line - b.line)
|
|
185
|
+
|
|
186
|
+
// 1. Check for inline: a node on the same line as the tag, before it in text
|
|
187
|
+
for (let i = nodes.length - 1; i >= 0; i--) {
|
|
188
|
+
if (nodes[i].line === lastTagLine) {
|
|
189
|
+
return astInfoFromNode(nodes[i].node)
|
|
190
|
+
}
|
|
191
|
+
if (nodes[i].line < lastTagLine) break
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// 2. Scan forward from the tag line — find the first node after the tag
|
|
195
|
+
for (const entry of nodes) {
|
|
196
|
+
if (entry.line > lastTagLine) {
|
|
197
|
+
return astInfoFromNode(entry.node)
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// 3. Tag is after all nodes — find the enclosing table (if any)
|
|
202
|
+
// Walk backwards to find the last table statement
|
|
203
|
+
for (let i = nodes.length - 1; i >= 0; i--) {
|
|
204
|
+
const n = nodes[i].node
|
|
205
|
+
if (n.type === 'stmt' && n.stmt.kind === 'table') {
|
|
206
|
+
return {
|
|
207
|
+
target: 'table',
|
|
208
|
+
objectName: n.stmt.objectName,
|
|
209
|
+
astNode: n.stmt.raw,
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
if (n.type === 'col') {
|
|
213
|
+
// We're after the last column of this table — table-level
|
|
214
|
+
return {
|
|
215
|
+
target: 'table',
|
|
216
|
+
objectName: n.parentStmt.objectName,
|
|
217
|
+
astNode: n.parentStmt.raw,
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return { target: detectTargetFallback(sqlLines) }
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function astInfoFromNode(
|
|
226
|
+
node: { type: 'stmt'; stmt: SqlStatement } | { type: 'col'; col: SqlColumn; parentStmt: SqlStatement },
|
|
227
|
+
): AstInfo {
|
|
228
|
+
if (node.type === 'col') {
|
|
229
|
+
return {
|
|
230
|
+
target: 'column',
|
|
231
|
+
columnName: node.col.name,
|
|
232
|
+
columnType: node.col.dataType,
|
|
233
|
+
objectName: node.parentStmt.objectName,
|
|
234
|
+
astNode: node.parentStmt.raw,
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return {
|
|
238
|
+
target: node.stmt.kind,
|
|
239
|
+
objectName: node.stmt.objectName,
|
|
240
|
+
astNode: node.stmt.raw,
|
|
241
|
+
}
|
|
242
|
+
}
|