ddlkit 0.1.0__py3-none-any.whl

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.
ddlkit/__init__.py ADDED
@@ -0,0 +1,24 @@
1
+ """ddlkit —— 多方言 DDL 文本解析器(无损保真)。
2
+
3
+ 只做一件事:**DDL 文本 -> 结构化数据**。不含规则引擎。
4
+ """
5
+ from __future__ import annotations
6
+
7
+ from .api import DIALECT_ALIASES, parse_ddl, parse_file, resolve_dialect
8
+ from .model import Column, Constraint, Index, ParseResult, SourceRef, Table
9
+
10
+ __version__ = "0.1.0"
11
+
12
+ __all__ = [
13
+ "Column",
14
+ "Constraint",
15
+ "DIALECT_ALIASES",
16
+ "Index",
17
+ "ParseResult",
18
+ "SourceRef",
19
+ "Table",
20
+ "__version__",
21
+ "parse_ddl",
22
+ "parse_file",
23
+ "resolve_dialect",
24
+ ]
ddlkit/api.py ADDED
@@ -0,0 +1,110 @@
1
+ """对外入口。
2
+
3
+ 调用方只需要知道两件事:**文本从哪来** 和 **它是哪个方言**。
4
+ 包内部负责:编码探测 -> 导出格式识别 -> 语句切分 -> 词法 -> 结构提取 -> 注释回填。
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from pathlib import Path
9
+
10
+ from . import comments as _comments
11
+ from . import encoding as _encoding
12
+ from . import source as _source
13
+ from .extract import extract_index, extract_table
14
+ from .lexer import lex
15
+ from .model import ParseResult, SourceRef, Table
16
+
17
+ #: 业务库名 -> sqlglot 方言名。新增库只需在这里加一行。
18
+ DIALECT_ALIASES: dict[str, str] = {
19
+ "dm": "oracle",
20
+ "dameng": "oracle",
21
+ "达梦": "oracle",
22
+ "ck": "clickhouse",
23
+ "clickhouse": "clickhouse",
24
+ "ob": "mysql",
25
+ "ob_mysql": "mysql",
26
+ "oceanbase": "mysql",
27
+ "oceanbase_mysql": "mysql",
28
+ "hive": "hive",
29
+ "hive2": "hive",
30
+ }
31
+
32
+
33
+ def resolve_dialect(name: str) -> str:
34
+ """把业务库名映射成 sqlglot 方言名;已是方言名则原样返回。"""
35
+ key = name.strip().lower()
36
+ return DIALECT_ALIASES.get(key, key)
37
+
38
+
39
+ def parse_ddl(
40
+ text: str | bytes,
41
+ dialect: str,
42
+ *,
43
+ filename: str | None = None,
44
+ encoding: str | None = None,
45
+ fmt: str | None = None,
46
+ ) -> ParseResult:
47
+ """解析一段 DDL 文本(可含多库/多语句)。"""
48
+ if isinstance(text, bytes):
49
+ text, used_encoding = _encoding.decode(text, encoding)
50
+ else:
51
+ used_encoding = encoding or "utf-8"
52
+
53
+ resolved = resolve_dialect(dialect)
54
+ fmt = fmt or _source.detect(text)
55
+
56
+ result = ParseResult()
57
+ statements = _source.split(text, fmt)
58
+
59
+ tables: list[Table] = []
60
+ by_name: dict[str, Table] = {}
61
+ pending_indexes: list[tuple[str, object, int]] = []
62
+ comment_on_texts: list[str] = []
63
+
64
+ for stmt in statements:
65
+ if stmt.kind == "other":
66
+ continue
67
+ lexed = lex(stmt.sql, resolved)
68
+ src = SourceRef(path=filename, line=stmt.line, encoding=used_encoding, fmt=fmt)
69
+
70
+ if stmt.kind == "create_table":
71
+ table = extract_table(lexed, resolved, src)
72
+ if table is None:
73
+ result.unsupported.append(f"line {stmt.line}: {stmt.sql[:80]}")
74
+ continue
75
+ tables.append(table)
76
+ if table.schema:
77
+ by_name[f"{table.schema}.{table.name}".upper()] = table
78
+ by_name.setdefault(table.name.upper(), table)
79
+ elif stmt.kind == "create_index":
80
+ parsed = extract_index(lexed, src)
81
+ if parsed is not None:
82
+ pending_indexes.append((parsed[0], parsed[1], stmt.line))
83
+ elif stmt.kind == "comment_on":
84
+ comment_on_texts.append(stmt.sql)
85
+
86
+ for target, index, line in pending_indexes:
87
+ table = by_name.get(target.upper())
88
+ if table is None:
89
+ result.warnings.append(f"line {line}: 索引 {index.name!r} 找不到目标表 {target!r}")
90
+ continue
91
+ table.indexes.append(index)
92
+
93
+ if comment_on_texts:
94
+ _comments.backfill(comment_on_texts, tables)
95
+
96
+ result.tables = tables
97
+ return result
98
+
99
+
100
+ def parse_file(
101
+ path: str | Path,
102
+ dialect: str,
103
+ *,
104
+ encoding: str | None = None,
105
+ fmt: str | None = None,
106
+ ) -> ParseResult:
107
+ """解析一个 DDL 导出文件。"""
108
+ p = Path(path)
109
+ text, used = _encoding.decode(p.read_bytes(), encoding)
110
+ return parse_ddl(text, dialect, filename=p.name, encoding=used, fmt=fmt)
ddlkit/comments.py ADDED
@@ -0,0 +1,72 @@
1
+ """``COMMENT ON`` 语句回填。
2
+
3
+ Oracle 系的库(达梦)把注释写成**独立语句**:
4
+
5
+ COMMENT ON TABLE "S"."T" IS '表注释'
6
+ COMMENT ON COLUMN "S"."T"."C" IS '列注释'
7
+
8
+ 只解析单条 CREATE TABLE 会漏掉全部注释,导致"注释必填"类规则大面积误报。
9
+ 该语法极规整,正则可 100% 覆盖,比赌解析器对 COMMENT 语句的支持更稳。
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import re
14
+
15
+ from .model import Table
16
+
17
+ COMMENT_ON = re.compile(
18
+ r"""COMMENT\s+ON\s+(?P<kind>TABLE|COLUMN)\s+
19
+ (?P<target>
20
+ "[^"]+"(?:\s*\.\s*"[^"]+")*
21
+ | `[^`]+`(?:\s*\.\s*`[^`]+`)*
22
+ | [A-Za-z_][\w$#]*(?:\s*\.\s*[A-Za-z_][\w$#]*)*
23
+ )
24
+ \s+IS\s+'(?P<text>(?:[^']|'')*)'""",
25
+ re.IGNORECASE | re.VERBOSE,
26
+ )
27
+
28
+ _NAME_SPLIT = re.compile(r"\s*\.\s*")
29
+
30
+
31
+ def _parts(target: str) -> list[str]:
32
+ return [p.strip('"`').upper() for p in _NAME_SPLIT.split(target.strip())]
33
+
34
+
35
+ def _build_index(tables: list[Table]) -> dict[tuple[str, ...], Table]:
36
+ index: dict[tuple[str, ...], Table] = {}
37
+ for t in tables:
38
+ if t.schema:
39
+ index[(t.schema.upper(), t.name.upper())] = t
40
+ index[(t.name.upper(),)] = t
41
+ return index
42
+
43
+
44
+ def backfill(statements: list[str], tables: list[Table]) -> int:
45
+ """把 COMMENT ON 回填到表/列上,返回成功回填的条数。"""
46
+ index = _build_index(tables)
47
+ hits = 0
48
+ for stmt in statements:
49
+ for m in COMMENT_ON.finditer(stmt):
50
+ kind = m.group("kind").upper()
51
+ segs = _parts(m.group("target"))
52
+ text = m.group("text").replace("''", "'")
53
+ if not segs:
54
+ continue
55
+
56
+ if kind == "TABLE":
57
+ table = index.get(tuple(segs[-2:])) or index.get((segs[-1],))
58
+ if table is not None:
59
+ table.comment = text
60
+ hits += 1
61
+ else:
62
+ if len(segs) < 2:
63
+ continue
64
+ table = index.get(tuple(segs[-3:-1])) or index.get((segs[-2],))
65
+ if table is None:
66
+ continue
67
+ for col in table.columns:
68
+ if col.name.upper() == segs[-1]:
69
+ col.comment = text
70
+ hits += 1
71
+ break
72
+ return hits
ddlkit/encoding.py ADDED
@@ -0,0 +1,34 @@
1
+ """编码探测。
2
+
3
+ 实测:同一批导出文件里 UTF-8 与 GB18030 混存(OB 的 test.sql 是 GB18030,
4
+ 其余 88 个文件是 UTF-8)。按单一编码读取会让中文注释整体乱码,
5
+ 而"注释必填"类规则会因此大面积误报。
6
+ """
7
+ from __future__ import annotations
8
+
9
+ BOM_UTF8 = b"\xef\xbb\xbf"
10
+ CANDIDATES: tuple[str, ...] = ("utf-8", "gb18030")
11
+
12
+
13
+ def sniff(raw: bytes) -> str:
14
+ """返回最可能的编码名。
15
+
16
+ 顺序敏感:UTF-8 先试,失败再试 GB18030。
17
+ 局限:字节序列恰好同时合法于两种编码时会误判(中文文本极少见)。
18
+ 需要更高准确率时可换 charset-normalizer,本包默认零依赖。
19
+ """
20
+ if raw.startswith(BOM_UTF8):
21
+ return "utf-8-sig"
22
+ for enc in CANDIDATES:
23
+ try:
24
+ raw.decode(enc)
25
+ return enc
26
+ except UnicodeDecodeError:
27
+ continue
28
+ return "utf-8" # 兜底,配合 errors="replace"
29
+
30
+
31
+ def decode(raw: bytes, encoding: str | None = None) -> tuple[str, str]:
32
+ """返回 (文本, 实际使用的编码名)。"""
33
+ enc = encoding or sniff(raw)
34
+ return raw.decode(enc, errors="strict" if encoding else "replace"), enc
ddlkit/extract.py ADDED
@@ -0,0 +1,398 @@
1
+ """结构提取:token 流 -> 无损 UIM。
2
+
3
+ 不调用 sqlglot 的 parser,因此:
4
+ * 不会挂死(达梦 ``NOT CLUSTER PRIMARY KEY`` 的无限回溯不存在);
5
+ * 不会改写(``TINYINT`` 不会被变成 ``SMALLINT``)。
6
+ 所有取值优先用 ``lex.raw()`` / ``lex.span()`` 从原串切片,保证原文可追溯。
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import re
11
+
12
+ from .keywords import (
13
+ CLAUSE_STARTERS,
14
+ COLUMN_ATTRS,
15
+ CONSTRAINT_STARTERS,
16
+ DECLARATION_MODIFIERS,
17
+ DEFAULT_TERMINATORS,
18
+ TYPE_TRAILERS,
19
+ )
20
+ from .lexer import Lexed, find_matching, split_top, unquote
21
+ from .model import Column, Constraint, Index, SourceRef, Table
22
+ from .source import Statement
23
+
24
+ _STRING_LITERAL = re.compile(r"'((?:[^']|'')*)'")
25
+ _QUOTE_CHARS = ('"', "`", "'", "[")
26
+
27
+ _PARTITION_HEADS = frozenset({"PARTITION", "PARTITION BY", "SUBPARTITION"})
28
+
29
+
30
+ def _string_literal(raw: str) -> str | None:
31
+ m = _STRING_LITERAL.search(raw)
32
+ return m.group(1).replace("''", "'") if m else None
33
+
34
+
35
+ def _is_quoted(raw: str) -> bool:
36
+ return len(raw) >= 2 and raw[0] in _QUOTE_CHARS and raw[-1] in ('"', "`", "'", "]")
37
+
38
+
39
+ # ------------------------------------------------------------------ 表名
40
+
41
+ def _resolve_name(lex: Lexed, stop: int) -> tuple[list[tuple[str, str]], bool]:
42
+ """解析 ``CREATE ... TABLE`` 之后、表体 ``(`` 之前的名字。
43
+
44
+ 返回 ([(原文, 去引号)], 是否含临时表修饰)。
45
+ """
46
+ idx = [i for i in range(stop) if lex.norm(i) not in DECLARATION_MODIFIERS]
47
+ segs: list[list[int]] = []
48
+ cur: list[int] = []
49
+ for i in idx:
50
+ if lex.text(i) == ".":
51
+ if cur:
52
+ segs.append(cur)
53
+ cur = []
54
+ else:
55
+ cur.append(i)
56
+ if cur:
57
+ segs.append(cur)
58
+
59
+ parts: list[tuple[str, str]] = []
60
+ for s in segs:
61
+ raw = lex.span(s[0], s[-1])
62
+ parts.append((raw, unquote(raw)))
63
+
64
+ # Hive beeline 里 ``ods.tbl`` 整体被一对反引号包住,需要再拆一层
65
+ if len(parts) == 1 and "." in parts[0][1]:
66
+ parts = [(x, x) for x in parts[0][1].split(".")]
67
+ return parts, False
68
+
69
+
70
+ # ------------------------------------------------------------------ 列
71
+
72
+ def _scan_default(lex: Lexed, k: int, end: int) -> tuple[str, int]:
73
+ """``DEFAULT`` 之后的值原文。
74
+
75
+ 终止标记 **不含 NULL**:``DEFAULT NULL`` 必须保留成 ``"NULL"``。
76
+ 括号内的逗号/关键字不终止(depth 感知)。
77
+ """
78
+ if k + 1 > end:
79
+ return "", k + 1
80
+ j = k + 1
81
+ depth = 0
82
+ while j <= end:
83
+ txt = lex.text(j)
84
+ up = lex.norm(j)
85
+ if txt == "(":
86
+ depth += 1
87
+ elif txt == ")":
88
+ depth -= 1
89
+ elif depth == 0 and j > k + 1 and up in DEFAULT_TERMINATORS:
90
+ break
91
+ j += 1
92
+ return lex.span(k + 1, j - 1).strip(), j
93
+
94
+
95
+ def _parse_column(lex: Lexed, start: int, end: int) -> Column:
96
+ col = Column(line=lex.line_of(start), col=lex.col_of(start))
97
+
98
+ name_raw = lex.raw(start)
99
+ col.name_raw = name_raw
100
+ col.name = unquote(name_raw)
101
+ col.quoted = name_raw != col.name
102
+
103
+ i = start + 1
104
+ if i <= end:
105
+ type_start = i
106
+ i += 1
107
+ while i <= end:
108
+ if lex.text(i) == "(":
109
+ close = find_matching(lex.tokens, i, end)
110
+ if close < 0:
111
+ break
112
+ i = close + 1
113
+ continue
114
+ if lex.norm(i) in TYPE_TRAILERS:
115
+ i += 1
116
+ continue
117
+ break
118
+ col.type_raw = lex.span(type_start, i - 1)
119
+ col.type_name = lex.norm(type_start)
120
+ args = re.search(r"\(.*\)", col.type_raw, re.S)
121
+ if args:
122
+ col.type_args_raw = args.group(0)
123
+
124
+ k = i
125
+ while k <= end:
126
+ up = lex.norm(k)
127
+ if up == "NOT" and k + 1 <= end and lex.norm(k + 1) == "NULL":
128
+ col.nullable = False
129
+ k += 2
130
+ continue
131
+ if up == "NULL":
132
+ col.nullable = True
133
+ k += 1
134
+ continue
135
+ if up == "DEFAULT":
136
+ col.default_raw, k = _scan_default(lex, k, end)
137
+ continue
138
+ if up == "COMMENT" and k + 1 <= end:
139
+ col.comment = unquote(lex.raw(k + 1))
140
+ k += 2
141
+ continue
142
+ if up in ("IDENTITY", "AUTO_INCREMENT", "AUTOINCREMENT"):
143
+ key = up.lower()
144
+ if k + 1 <= end and lex.text(k + 1) == "(":
145
+ close = find_matching(lex.tokens, k + 1, end)
146
+ if close > 0:
147
+ col.extras[key] = lex.span(k + 1, close)
148
+ k = close + 1
149
+ continue
150
+ col.extras[key] = "true"
151
+ k += 1
152
+ continue
153
+ if up == "PRIMARY KEY" or up == "PRIMARY":
154
+ col.extras["primary_key"] = "true"
155
+ k += 1 if up == "PRIMARY KEY" else 2
156
+ continue
157
+ if up == "UNIQUE":
158
+ col.extras["unique"] = "true"
159
+ k += 1
160
+ continue
161
+ if up == "COLLATE" and k + 1 <= end:
162
+ col.extras["collate"] = lex.raw(k + 1)
163
+ k += 2
164
+ continue
165
+ if up in ("ENCODE", "CODEC") and k + 1 <= end:
166
+ col.extras[up.lower()] = lex.raw(k + 1)
167
+ k += 2
168
+ continue
169
+ if up == "ON":
170
+ col.extras["on"] = lex.span(k, end)
171
+ break
172
+ k += 1
173
+
174
+ # 派生:DEFAULT NULL 蕴含可空(仅在没有显式 NOT NULL 时)
175
+ if col.nullable is None and col.default_raw and col.default_raw.strip().upper() == "NULL":
176
+ col.nullable = True
177
+ return col
178
+
179
+
180
+ # ------------------------------------------------------------------ 约束
181
+
182
+ def _parse_constraint(lex: Lexed, start: int, end: int) -> Constraint:
183
+ c = Constraint(line=lex.line_of(start), raw=lex.span(start, end))
184
+ i = start
185
+
186
+ def eat_cluster(pos: int) -> int:
187
+ if pos <= end and lex.norm(pos) == "NOT" and pos + 1 <= end and lex.norm(pos + 1) == "CLUSTER":
188
+ c.clustered = False
189
+ return pos + 2
190
+ if pos <= end and lex.norm(pos) == "CLUSTER":
191
+ c.clustered = True
192
+ return pos + 1
193
+ return pos
194
+
195
+ i = eat_cluster(i)
196
+ if i <= end and lex.norm(i) == "CONSTRAINT":
197
+ if i + 1 <= end:
198
+ c.name_raw = lex.raw(i + 1)
199
+ c.name = unquote(c.name_raw)
200
+ i += 2
201
+ i = eat_cluster(i)
202
+
203
+ head = lex.norm(i) if i <= end else ""
204
+ if head in ("PRIMARY KEY", "PRIMARY"):
205
+ c.kind = "PRIMARY KEY"
206
+ i += 1 if head == "PRIMARY KEY" else 2
207
+ elif head in ("FOREIGN KEY", "FOREIGN"):
208
+ c.kind = "FOREIGN KEY"
209
+ i += 1 if head == "FOREIGN KEY" else 2
210
+ elif head == "UNIQUE":
211
+ c.kind = "UNIQUE"
212
+ i += 1
213
+ if i <= end and lex.norm(i) == "KEY":
214
+ i += 1
215
+ elif head == "CHECK":
216
+ c.kind = "CHECK"
217
+ i += 1
218
+ elif head in ("KEY", "INDEX"):
219
+ c.kind = "INDEX"
220
+ i += 1
221
+ if i <= end and lex.text(i) != "(":
222
+ c.name_raw = lex.raw(i)
223
+ c.name = unquote(c.name_raw)
224
+ i += 1
225
+ else:
226
+ c.kind = head or "UNKNOWN"
227
+ i += 1
228
+
229
+ while i <= end and lex.text(i) != "(":
230
+ if c.name is None:
231
+ c.name_raw = lex.raw(i)
232
+ c.name = unquote(c.name_raw)
233
+ i += 1
234
+
235
+ if i <= end and lex.text(i) == "(":
236
+ close = find_matching(lex.tokens, i, end)
237
+ if close > 0:
238
+ if c.kind == "CHECK":
239
+ c.extras["expression"] = lex.span(i, close)
240
+ else:
241
+ for s, t in split_top(lex.tokens, i + 1, close - 1):
242
+ c.columns.append(unquote(lex.span(s, t)))
243
+ i = close + 1
244
+
245
+ if i <= end:
246
+ c.extras["tail"] = lex.span(i, end)
247
+ return c
248
+
249
+
250
+ # ------------------------------------------------------------------ 尾部子句
251
+
252
+ def _is_boundary(lex: Lexed, i: int, start: int, end: int) -> bool:
253
+ """判断下标 i 处的关键字是否真的开启一个新子句,而不是子句内部的值。
254
+
255
+ 注意 ``)`` **不在**排除列表里:``ENGINE = MergeTree() ORDER BY (a, b)``
256
+ 这种写法里,子句正是紧跟在右括号之后的。
257
+ """
258
+ if i == start:
259
+ return True
260
+ prev = lex.text(i - 1)
261
+ if prev in (",", "(", "=", "+", "-", "*", "/", "|"):
262
+ return False
263
+ # ``DEFAULT CHARSET = utf8mb4`` 应作为一个子句,不要把 DEFAULT 单独切出来
264
+ if lex.norm(i) == "DEFAULT" and i + 1 <= end and lex.norm(i + 1) in ("CHARSET", "CHARACTER SET"):
265
+ return False
266
+ return True
267
+
268
+
269
+ def _scan_clauses(lex: Lexed, start: int, end: int) -> dict[str, str]:
270
+ """把表体括号之后的尾部子句原样归集到 extras。
271
+
272
+ 只在 depth == 0 处判定边界——``STORAGE(ON "X", CLUSTERBTR)`` 里的
273
+ ``ON`` 在括号内,不会被误判成新子句。
274
+ """
275
+ extras: dict[str, str] = {}
276
+ cur_key: str | None = None
277
+ cur_start = 0
278
+ depth = 0
279
+ for i in range(start, end + 1):
280
+ txt = lex.text(i)
281
+ if txt == "(":
282
+ depth += 1
283
+ continue
284
+ if txt == ")":
285
+ depth -= 1
286
+ continue
287
+ if depth == 0:
288
+ up = lex.norm(i)
289
+ if up in CLAUSE_STARTERS and _is_boundary(lex, i, start, end):
290
+ if cur_key is not None:
291
+ extras[cur_key] = lex.span(cur_start, i - 1).strip()
292
+ cur_key = up
293
+ cur_start = i
294
+ if cur_key is not None:
295
+ extras[cur_key] = lex.span(cur_start, end).strip()
296
+ return extras
297
+
298
+
299
+ # ------------------------------------------------------------------ 入口
300
+
301
+ def extract_table(lex: Lexed, dialect: str, src: SourceRef) -> Table | None:
302
+ paren = next((i for i in range(len(lex)) if lex.text(i) == "("), -1)
303
+ if paren <= 0:
304
+ return None
305
+ # CTAS(CREATE TABLE ... AS SELECT ...)没有列定义区
306
+ if any(lex.norm(i) == "AS" for i in range(paren)):
307
+ return None
308
+
309
+ tbl = Table(dialect=dialect, source=src)
310
+ tbl.temporary = any(
311
+ lex.norm(i) in ("GLOBAL", "LOCAL", "TEMPORARY", "TEMP", "VOLATILE", "TRANSIENT")
312
+ for i in range(min(paren, 8))
313
+ )
314
+
315
+ parts, _ = _resolve_name(lex, paren)
316
+ # parts 元素是 (原文, 去引号后) 二元组
317
+ if len(parts) >= 3:
318
+ _, tbl.catalog = parts[-3]
319
+ _, tbl.schema = parts[-2]
320
+ tbl.name_raw, tbl.name = parts[-1]
321
+ elif len(parts) == 2:
322
+ _, tbl.schema = parts[0]
323
+ tbl.name_raw, tbl.name = parts[1]
324
+ elif len(parts) == 1:
325
+ tbl.name_raw, tbl.name = parts[0]
326
+ tbl.quoted = _is_quoted(tbl.name_raw)
327
+
328
+ close = find_matching(lex.tokens, paren, len(lex) - 1)
329
+ if close < 0:
330
+ tbl.warnings.append("unclosed column list")
331
+ return tbl
332
+
333
+ for s, e in split_top(lex.tokens, paren + 1, close - 1):
334
+ head = lex.norm(s)
335
+ if head in _PARTITION_HEADS:
336
+ key = head if head in ("PARTITION BY",) else "PARTITION BY"
337
+ tbl.extras[key] = lex.span(s, e)
338
+ elif head in CONSTRAINT_STARTERS:
339
+ tbl.constraints.append(_parse_constraint(lex, s, e))
340
+ else:
341
+ tbl.columns.append(_parse_column(lex, s, e))
342
+
343
+ if close + 1 < len(lex):
344
+ tbl.extras.update(_scan_clauses(lex, close + 1, len(lex) - 1))
345
+
346
+ raw_comment = tbl.extras.get("COMMENT")
347
+ if raw_comment:
348
+ tbl.comment = _string_literal(raw_comment)
349
+ return tbl
350
+
351
+
352
+ def extract_index(lex: Lexed, src: SourceRef) -> tuple[str, Index] | None:
353
+ """``CREATE [UNIQUE] INDEX name ON table (cols) [clauses]``"""
354
+ idx = Index(line=lex.line_of(0), raw=lex.span(0, len(lex) - 1))
355
+ i = 0
356
+ while i < len(lex) and lex.norm(i) in ("CREATE", "OR", "REPLACE", "GLOBAL", "LOCAL"):
357
+ i += 1
358
+ kinds: list[str] = []
359
+ while i < len(lex) and lex.norm(i) in (
360
+ "UNIQUE", "BITMAP", "CLUSTERED", "NONCLUSTERED", "FULLTEXT", "SPATIAL"
361
+ ):
362
+ kinds.append(lex.norm(i))
363
+ i += 1
364
+ if i >= len(lex) or lex.norm(i) not in ("INDEX", "KEY"):
365
+ return None
366
+ i += 1
367
+ idx.kind = (" ".join(kinds) + " INDEX").strip() if kinds else "INDEX"
368
+
369
+ if i < len(lex) and lex.text(i) != "ON":
370
+ idx.name_raw = lex.raw(i)
371
+ idx.name = unquote(idx.name_raw)
372
+ i += 1
373
+
374
+ target = ""
375
+ if i < len(lex) and lex.norm(i) == "ON":
376
+ i += 1
377
+ seg: list[int] = []
378
+ while i < len(lex) and lex.text(i) != "(":
379
+ seg.append(i)
380
+ i += 1
381
+ if seg:
382
+ target = unquote(lex.span(seg[0], seg[-1])).split(".")[-1].strip('"`')
383
+
384
+ if i < len(lex) and lex.text(i) == "(":
385
+ close = find_matching(lex.tokens, i, len(lex) - 1)
386
+ if close > 0:
387
+ for s, t in split_top(lex.tokens, i + 1, close - 1):
388
+ names = [lex.raw(x) for x in range(s, t + 1)
389
+ if lex.norm(x) not in ("ASC", "DESC", "NULLS")]
390
+ if names:
391
+ idx.columns.append(unquote(names[0]))
392
+ i = close + 1
393
+ if i < len(lex):
394
+ idx.extras["tail"] = lex.span(i, len(lex) - 1)
395
+ return target, idx
396
+
397
+
398
+ __all__ = ["Statement", "extract_index", "extract_table"]