@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.
@@ -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
+ }
@@ -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
+ }