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 +24 -0
- ddlkit/api.py +110 -0
- ddlkit/comments.py +72 -0
- ddlkit/encoding.py +34 -0
- ddlkit/extract.py +398 -0
- ddlkit/keywords.py +82 -0
- ddlkit/lexer.py +113 -0
- ddlkit/model.py +95 -0
- ddlkit/source.py +274 -0
- ddlkit-0.1.0.dist-info/METADATA +147 -0
- ddlkit-0.1.0.dist-info/RECORD +13 -0
- ddlkit-0.1.0.dist-info/WHEEL +4 -0
- ddlkit-0.1.0.dist-info/licenses/LICENSE +21 -0
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"]
|