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/keywords.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""关键字表:全部在运行时从 sqlglot 方言派生,不硬编码。
|
|
2
|
+
|
|
3
|
+
为什么必须派生:
|
|
4
|
+
sqlglot 的 Tokenizer 用 trie 把部分关键字**合并成单个 token**,
|
|
5
|
+
且每个方言的合并集合不同(实测 oracle 18 / mysql 21 / clickhouse 16 / hive 23)。
|
|
6
|
+
例如 ``PRIMARY KEY (ID)`` 只产出 2 个 token:``PRIMARY KEY`` 和 ``(``。
|
|
7
|
+
若按 ``"PRIMARY"`` 去匹配,约束会被误判为普通列。
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from functools import lru_cache
|
|
12
|
+
|
|
13
|
+
from sqlglot.dialects.dialect import Dialect
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@lru_cache(maxsize=None)
|
|
17
|
+
def keyword_table(dialect: str) -> frozenset[str]:
|
|
18
|
+
"""该方言 Tokenizer 的全部关键字(大写)。"""
|
|
19
|
+
cls = Dialect.get_or_raise(dialect).tokenizer_class
|
|
20
|
+
keywords = cls.KEYWORDS if isinstance(cls, type) else type(cls).KEYWORDS
|
|
21
|
+
return frozenset(k.upper() for k in keywords)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@lru_cache(maxsize=None)
|
|
25
|
+
def multiword_keywords(dialect: str) -> frozenset[str]:
|
|
26
|
+
"""会被合并成单个 token 的多词关键字,如 ``PRIMARY KEY`` / ``ORDER BY``。"""
|
|
27
|
+
return frozenset(k for k in keyword_table(dialect) if " " in k)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# CREATE 语句里允许出现在表名之前的修饰词,全部丢弃
|
|
31
|
+
DECLARATION_MODIFIERS: frozenset[str] = frozenset({
|
|
32
|
+
"CREATE", "OR", "REPLACE", "TABLE", "IF", "NOT", "EXISTS",
|
|
33
|
+
"GLOBAL", "TEMPORARY", "TEMP", "LOCAL", "EXTERNAL", "VOLATILE",
|
|
34
|
+
"TRANSIENT", "DYNAMIC", "UNSET", "SET", "OPTIONS", "ICEBERG",
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
# 表体括号之外、会开启一个新"尾部子句"的起始标记。
|
|
38
|
+
#
|
|
39
|
+
# 注意:这里写的是 token **实际呈现的文本**(大写),因此多词关键字必须写合并形式
|
|
40
|
+
# (ORDER BY 而不是 ORDER),单词关键字写原样。
|
|
41
|
+
# 注意:**故意不收录裸 ``AS``**——Hive 的 ``STORED AS`` 是两个独立 token
|
|
42
|
+
# (STORED / AS),收录 AS 会把 "STORED AS" 拆断。
|
|
43
|
+
CLAUSE_STARTERS: frozenset[str] = frozenset({
|
|
44
|
+
# 多词(已合并)
|
|
45
|
+
"ORDER BY", "PARTITION BY", "PARTITIONED BY", "CLUSTER BY",
|
|
46
|
+
"DISTRIBUTE BY", "SORT BY", "PRIMARY KEY", "FOREIGN KEY",
|
|
47
|
+
# 单词
|
|
48
|
+
"ENGINE", "SETTINGS", "STORAGE", "TBLPROPERTIES", "SERDEPROPERTIES",
|
|
49
|
+
"COMMENT", "LOCATION", "ORGANIZATION", "REPLICA_NUM", "BLOCK_SIZE",
|
|
50
|
+
"COMPRESSION", "TABLET_SIZE", "PCTFREE", "USE_BLOOM_FILTER",
|
|
51
|
+
"ENABLE_MACRO_BLOCK_BLOOM_FILTER", "LOCALITY", "TABLEGROUP",
|
|
52
|
+
"CHARSET", "CHARACTER SET", "COLLATE", "TABLESPACE", "DATA_RETENTION",
|
|
53
|
+
"ROW", "STORED", "INPUTFORMAT", "OUTPUTFORMAT",
|
|
54
|
+
"DEFAULT", "WITH", "ON",
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
# 列定义区的表级约束起始标记
|
|
58
|
+
CONSTRAINT_STARTERS: frozenset[str] = frozenset({
|
|
59
|
+
"PRIMARY KEY", "PRIMARY", "UNIQUE", "UNIQUE KEY", "KEY", "INDEX",
|
|
60
|
+
"FOREIGN KEY", "FOREIGN", "CHECK", "CONSTRAINT",
|
|
61
|
+
"NOT", "CLUSTER", # 达梦 NOT CLUSTER PRIMARY KEY(...) / CLUSTER PRIMARY KEY(...)
|
|
62
|
+
"PARTITION", "PARTITION BY", "SUBPARTITION",
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
# 列属性关键字:出现即表示"类型已结束"或"DEFAULT 取值已结束"
|
|
66
|
+
COLUMN_ATTRS: frozenset[str] = frozenset({
|
|
67
|
+
"NOT", "NULL", "DEFAULT", "COMMENT", "IDENTITY", "AUTO_INCREMENT",
|
|
68
|
+
"AUTOINCREMENT", "PRIMARY", "PRIMARY KEY", "UNIQUE", "REFERENCES",
|
|
69
|
+
"CHECK", "COLLATE", "GENERATED", "ENCODE", "CODEC", "TTL",
|
|
70
|
+
"CLUSTER", "STORAGE", "ON", "CONSTRAINT", "VISIBLE", "INVISIBLE",
|
|
71
|
+
"SORTKEY", "DISTKEY", "MASKING", "ENCRYPT",
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
# DEFAULT 取值的终止标记。
|
|
75
|
+
# 关键修正:**排除 NULL**——否则 ``DEFAULT NULL`` 会把 NULL 当终止符,
|
|
76
|
+
# 导致 default_raw 变成空串(实测源文件里这样的写法有 4449 处)。
|
|
77
|
+
DEFAULT_TERMINATORS: frozenset[str] = COLUMN_ATTRS - {"NULL"}
|
|
78
|
+
|
|
79
|
+
# 可以跟在类型名之后、仍属于类型的一部分(非别名化的多词类型)
|
|
80
|
+
TYPE_TRAILERS: frozenset[str] = frozenset({
|
|
81
|
+
"UNSIGNED", "SIGNED", "ZEROFILL", "PRECISION", "VARYING",
|
|
82
|
+
})
|
ddlkit/lexer.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""词法层:调用 sqlglot 的 Tokenizer,并保证"原文可无损取回"。
|
|
2
|
+
|
|
3
|
+
为什么不用 parser:
|
|
4
|
+
parser 会挂死(达梦 ``NOT CLUSTER PRIMARY KEY`` 导致无限回溯 / OOM),
|
|
5
|
+
并且会主动改写它"认识"的语法(``TINYINT`` -> ``SMALLINT``)。
|
|
6
|
+
tokenizer 只做词法,不猜语义,这两类问题都不存在。
|
|
7
|
+
|
|
8
|
+
本模块解决的关键陷阱:
|
|
9
|
+
``Token.text`` **不是原文**——字符串字面量的引号和标识符的引号/反引号都会被剥掉。
|
|
10
|
+
实测 100218 列里 ``quoted=True`` 的判据因此恒为 False,32091 个字符串字面量的引号全部丢失。
|
|
11
|
+
解法:用 ``sql[token.start : token.end + 1]`` 切原串(``end`` 是**闭区间**,必须 +1)。
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
|
|
17
|
+
import sqlglot
|
|
18
|
+
from sqlglot.tokens import Token, TokenType
|
|
19
|
+
|
|
20
|
+
# 不参与结构分析、但在原文切片中"天然存在"的 token
|
|
21
|
+
SKIP_TYPES: frozenset[TokenType] = frozenset({TokenType.SPACE, TokenType.BREAK})
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(slots=True)
|
|
25
|
+
class Lexed:
|
|
26
|
+
"""一段 SQL 的词法结果,携带原文以支持无损切片。"""
|
|
27
|
+
|
|
28
|
+
sql: str
|
|
29
|
+
tokens: list[Token]
|
|
30
|
+
|
|
31
|
+
def __len__(self) -> int:
|
|
32
|
+
return len(self.tokens)
|
|
33
|
+
|
|
34
|
+
def tok(self, i: int) -> Token:
|
|
35
|
+
return self.tokens[i]
|
|
36
|
+
|
|
37
|
+
def text(self, i: int) -> str:
|
|
38
|
+
"""token 的归一化文本(引号已剥)。"""
|
|
39
|
+
return self.tokens[i].text
|
|
40
|
+
|
|
41
|
+
def norm(self, i: int) -> str:
|
|
42
|
+
"""token 文本大写,用于与关键字集合比较。"""
|
|
43
|
+
return self.tokens[i].text.upper()
|
|
44
|
+
|
|
45
|
+
def raw(self, i: int) -> str:
|
|
46
|
+
"""单个 token 的**原始文本**(含引号)。"""
|
|
47
|
+
t = self.tokens[i]
|
|
48
|
+
return self.sql[t.start : t.end + 1]
|
|
49
|
+
|
|
50
|
+
def span(self, i: int, j: int) -> str:
|
|
51
|
+
"""[i, j] 闭区间 token 覆盖的**原始片段**(保留原始空白与引号)。
|
|
52
|
+
|
|
53
|
+
直接按首尾偏移切原串,而不是把 ``text`` 拼起来——后者会丢引号和原始间距。
|
|
54
|
+
"""
|
|
55
|
+
if i > j:
|
|
56
|
+
return ""
|
|
57
|
+
return self.sql[self.tokens[i].start : self.tokens[j].end + 1]
|
|
58
|
+
|
|
59
|
+
def line_of(self, i: int) -> int:
|
|
60
|
+
return self.tokens[i].line
|
|
61
|
+
|
|
62
|
+
def col_of(self, i: int) -> int:
|
|
63
|
+
return self.tokens[i].col
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def lex(sql: str, dialect: str) -> Lexed:
|
|
67
|
+
"""词法分析。注意 ``sqlglot.tokenize`` 的签名是 ``(sql, read=None, dialect=None)``。"""
|
|
68
|
+
tokens = [t for t in sqlglot.tokenize(sql, dialect=dialect) if t.token_type not in SKIP_TYPES]
|
|
69
|
+
return Lexed(sql=sql, tokens=tokens)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def find_matching(tokens: list[Token], start: int, end: int) -> int:
|
|
73
|
+
"""给定 ``(`` 的下标,返回配对 ``)`` 的下标;找不到返回 -1。"""
|
|
74
|
+
depth = 0
|
|
75
|
+
for i in range(start, end + 1):
|
|
76
|
+
txt = tokens[i].text
|
|
77
|
+
if txt == "(":
|
|
78
|
+
depth += 1
|
|
79
|
+
elif txt == ")":
|
|
80
|
+
depth -= 1
|
|
81
|
+
if depth == 0:
|
|
82
|
+
return i
|
|
83
|
+
return -1
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def split_top(tokens: list[Token], start: int, end: int) -> list[tuple[int, int]]:
|
|
87
|
+
"""按顶层逗号切分,返回若干 (起, 止) 下标区间。"""
|
|
88
|
+
parts: list[tuple[int, int]] = []
|
|
89
|
+
cur = start
|
|
90
|
+
depth = 0
|
|
91
|
+
for i in range(start, end + 1):
|
|
92
|
+
txt = tokens[i].text
|
|
93
|
+
if txt in ("(", "["):
|
|
94
|
+
depth += 1
|
|
95
|
+
elif txt in (")", "]"):
|
|
96
|
+
depth -= 1
|
|
97
|
+
elif txt == "," and depth == 0:
|
|
98
|
+
if i > cur:
|
|
99
|
+
parts.append((cur, i - 1))
|
|
100
|
+
cur = i + 1
|
|
101
|
+
if cur <= end:
|
|
102
|
+
parts.append((cur, end))
|
|
103
|
+
return parts
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def unquote(s: str) -> str:
|
|
107
|
+
"""去掉标识符 / 字符串的包裹引号。"""
|
|
108
|
+
if len(s) >= 2 and s[0] == s[-1]:
|
|
109
|
+
if s[0] in ('"', "`", "'"):
|
|
110
|
+
return s[1:-1]
|
|
111
|
+
if s[0] == "[" and s[-1] == "]":
|
|
112
|
+
return s[1:-1]
|
|
113
|
+
return s
|
ddlkit/model.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""统一信息模型(UIM)——本包对外的唯一数据契约。
|
|
2
|
+
|
|
3
|
+
设计原则:
|
|
4
|
+
1. 双轨字段:``*_raw`` 保留原文(含引号、原始类型名),归一化字段供规则比较。
|
|
5
|
+
2. 方言特性一律进 ``extras``,**结构化保存而非丢弃**。
|
|
6
|
+
3. 绝不向外暴露 sqlglot 的 Expression / Token,避免上游升级连带破坏下游。
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(slots=True)
|
|
14
|
+
class SourceRef:
|
|
15
|
+
"""来源定位,供报告回溯到文件行。"""
|
|
16
|
+
|
|
17
|
+
path: str | None = None
|
|
18
|
+
line: int = 0
|
|
19
|
+
encoding: str = ""
|
|
20
|
+
fmt: str = ""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(slots=True)
|
|
24
|
+
class Column:
|
|
25
|
+
name: str = "" # 去引号后的列名
|
|
26
|
+
name_raw: str = "" # 原文(含引号):"ID" / `id` / ID
|
|
27
|
+
quoted: bool = False # 是否被引号包裹(书写层信息)
|
|
28
|
+
type_name: str = "" # 首个类型词,大写:VARCHAR / TINYINT / NULLABLE
|
|
29
|
+
type_raw: str = "" # 完整原始类型文本:VARCHAR2(50) / DEC(20,0) / Nullable(String)
|
|
30
|
+
type_args_raw: str | None = None # 括号部分原文:(50) / (10,2) / (String)
|
|
31
|
+
nullable: bool | None = None # 派生字段;None = 未显式声明
|
|
32
|
+
default_raw: str | None = None # DEFAULT 之后的值原文
|
|
33
|
+
comment: str | None = None
|
|
34
|
+
extras: dict[str, str] = field(default_factory=dict)
|
|
35
|
+
line: int = 0
|
|
36
|
+
col: int = 0
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(slots=True)
|
|
40
|
+
class Constraint:
|
|
41
|
+
kind: str = "" # PRIMARY KEY / UNIQUE / FOREIGN KEY / CHECK / KEY / INDEX
|
|
42
|
+
name: str | None = None
|
|
43
|
+
name_raw: str | None = None
|
|
44
|
+
columns: list[str] = field(default_factory=list)
|
|
45
|
+
clustered: bool | None = None # 达梦 CLUSTER / NOT CLUSTER;None = 未声明
|
|
46
|
+
extras: dict[str, str] = field(default_factory=dict)
|
|
47
|
+
raw: str = ""
|
|
48
|
+
line: int = 0
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(slots=True)
|
|
52
|
+
class Index:
|
|
53
|
+
name: str | None = None
|
|
54
|
+
name_raw: str | None = None
|
|
55
|
+
kind: str = "INDEX" # INDEX / UNIQUE INDEX / KEY
|
|
56
|
+
columns: list[str] = field(default_factory=list)
|
|
57
|
+
extras: dict[str, str] = field(default_factory=dict)
|
|
58
|
+
raw: str = ""
|
|
59
|
+
line: int = 0
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(slots=True)
|
|
63
|
+
class Table:
|
|
64
|
+
dialect: str = ""
|
|
65
|
+
catalog: str | None = None
|
|
66
|
+
schema: str | None = None
|
|
67
|
+
name: str = ""
|
|
68
|
+
name_raw: str = ""
|
|
69
|
+
quoted: bool = False
|
|
70
|
+
temporary: bool = False
|
|
71
|
+
columns: list[Column] = field(default_factory=list)
|
|
72
|
+
constraints: list[Constraint] = field(default_factory=list)
|
|
73
|
+
indexes: list[Index] = field(default_factory=list)
|
|
74
|
+
comment: str | None = None
|
|
75
|
+
extras: dict[str, str] = field(default_factory=dict)
|
|
76
|
+
source: SourceRef = field(default_factory=SourceRef)
|
|
77
|
+
warnings: list[str] = field(default_factory=list)
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def qualified_name(self) -> str:
|
|
81
|
+
return ".".join(x for x in (self.catalog, self.schema, self.name) if x)
|
|
82
|
+
|
|
83
|
+
def column(self, name: str) -> Column | None:
|
|
84
|
+
upper = name.upper()
|
|
85
|
+
for c in self.columns:
|
|
86
|
+
if c.name.upper() == upper or c.name_raw.upper() == upper:
|
|
87
|
+
return c
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass(slots=True)
|
|
92
|
+
class ParseResult:
|
|
93
|
+
tables: list[Table] = field(default_factory=list)
|
|
94
|
+
warnings: list[str] = field(default_factory=list)
|
|
95
|
+
unsupported: list[str] = field(default_factory=list)
|
ddlkit/source.py
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"""导出格式识别与语句切分。
|
|
2
|
+
|
|
3
|
+
实测这批导出文件**实际有 5 种格式,不是 4 种**(按库划分会漏掉第 5 种):
|
|
4
|
+
|
|
5
|
+
====== ========================== ==========================================
|
|
6
|
+
格式 特征 切分方式
|
|
7
|
+
====== ========================== ==========================================
|
|
8
|
+
beeline ``| DDL |`` 表格 + 表头 剥边框后按锚点切
|
|
9
|
+
quoted 每行一条、整行被双引号包裹 逐行,剥首尾引号(**无分号**)
|
|
10
|
+
escaped 每行一条、内部换行是字面\\n 逐行,反转义(**分号在语句内部,不能按分号切**)
|
|
11
|
+
anchor 原生脚本但**全文 0 个分号** 按 CREATE/COMMENT ON 锚点切
|
|
12
|
+
semicolon 常规脚本 按顶层分号切(注释/引号/括号感知)
|
|
13
|
+
====== ========================== ==========================================
|
|
14
|
+
|
|
15
|
+
踩过的坑:
|
|
16
|
+
* ``Hive/可靠性_*.sql``(547 表)是 **anchor** 格式,全文分号数 = 0。
|
|
17
|
+
按分号切会把整个文件并成 1 条语句。
|
|
18
|
+
* OB 文件里语句内部含 57 个分号,按分号切会误切。
|
|
19
|
+
* ClickHouse 无分号,按分号切同样并成 1 条。
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import bisect
|
|
24
|
+
import re
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
|
|
27
|
+
# ---------------------------------------------------------------- 语句头识别
|
|
28
|
+
|
|
29
|
+
_CREATE_TABLE = re.compile(
|
|
30
|
+
r"^\s*CREATE\s+(?:OR\s+REPLACE\s+)?"
|
|
31
|
+
r"(?:GLOBAL\s+TEMPORARY\s+|LOCAL\s+TEMPORARY\s+|TEMPORARY\s+|"
|
|
32
|
+
r"EXTERNAL\s+|VOLATILE\s+|TRANSIENT\s+)?TABLE\b",
|
|
33
|
+
re.IGNORECASE,
|
|
34
|
+
)
|
|
35
|
+
_CREATE_INDEX = re.compile(
|
|
36
|
+
r"^\s*CREATE\s+(?:OR\s+REPLACE\s+)?"
|
|
37
|
+
r"(?:UNIQUE\s+|BITMAP\s+|CLUSTERED\s+|NONCLUSTERED\s+|FULLTEXT\s+|SPATIAL\s+)?"
|
|
38
|
+
r"(?:GLOBAL\s+|LOCAL\s+)?(?:INDEX|KEY)\b",
|
|
39
|
+
re.IGNORECASE,
|
|
40
|
+
)
|
|
41
|
+
_COMMENT_ON = re.compile(r"^\s*COMMENT\s+ON\b", re.IGNORECASE)
|
|
42
|
+
|
|
43
|
+
# anchor 格式的语句起点。只认这三类——不能笼统地用 `^CREATE`,
|
|
44
|
+
# 否则 CREATE VIEW / CREATE SEQUENCE 会变成锚点并把后续语句吞进去。
|
|
45
|
+
_ANCHOR = re.compile(
|
|
46
|
+
r"^\s*(?:"
|
|
47
|
+
r"CREATE\s+(?:OR\s+REPLACE\s+)?"
|
|
48
|
+
r"(?:GLOBAL\s+TEMPORARY\s+|LOCAL\s+TEMPORARY\s+|TEMPORARY\s+|EXTERNAL\s+|VOLATILE\s+|TRANSIENT\s+)?TABLE\b"
|
|
49
|
+
r"|CREATE\s+(?:OR\s+REPLACE\s+)?(?:UNIQUE\s+)?(?:INDEX|KEY)\b"
|
|
50
|
+
r"|COMMENT\s+ON\b"
|
|
51
|
+
r")",
|
|
52
|
+
re.IGNORECASE,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(slots=True)
|
|
57
|
+
class Statement:
|
|
58
|
+
sql: str
|
|
59
|
+
line: int # 1-based,语句在原始文件中的起始行
|
|
60
|
+
kind: str # create_table / create_index / comment_on / other
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def classify(sql: str) -> str:
|
|
64
|
+
if _CREATE_TABLE.match(sql):
|
|
65
|
+
return "create_table"
|
|
66
|
+
if _CREATE_INDEX.match(sql):
|
|
67
|
+
return "create_index"
|
|
68
|
+
if _COMMENT_ON.match(sql):
|
|
69
|
+
return "comment_on"
|
|
70
|
+
return "other"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# ---------------------------------------------------------------- 工具
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _line_starts(text: str) -> list[int]:
|
|
77
|
+
"""每一行起始偏移,供 offset -> 行号 换算。"""
|
|
78
|
+
pos = [0]
|
|
79
|
+
i = text.find("\n")
|
|
80
|
+
while i >= 0:
|
|
81
|
+
pos.append(i + 1)
|
|
82
|
+
i = text.find("\n", i + 1)
|
|
83
|
+
return pos
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _line_no(starts: list[int], offset: int) -> int:
|
|
87
|
+
return bisect.bisect_right(starts, offset)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def strip_leading(text: str) -> tuple[str, int]:
|
|
91
|
+
"""剥掉开头的空白与整行/整块注释,返回 (剩余文本, 剥掉的字符数)。"""
|
|
92
|
+
off = 0
|
|
93
|
+
n = len(text)
|
|
94
|
+
while off < n:
|
|
95
|
+
m = re.match(r"\s*", text[off:])
|
|
96
|
+
assert m is not None
|
|
97
|
+
k = off + m.end()
|
|
98
|
+
rest = text[k:]
|
|
99
|
+
if rest.startswith("--") or rest.startswith("#"):
|
|
100
|
+
nl = rest.find("\n")
|
|
101
|
+
if nl < 0:
|
|
102
|
+
return "", n
|
|
103
|
+
off = k + nl + 1
|
|
104
|
+
continue
|
|
105
|
+
if rest.startswith("/*"):
|
|
106
|
+
end = rest.find("*/")
|
|
107
|
+
if end < 0:
|
|
108
|
+
return "", n
|
|
109
|
+
off = k + end + 2
|
|
110
|
+
continue
|
|
111
|
+
return rest, k
|
|
112
|
+
return "", n
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _scan_semicolons(text: str) -> list[tuple[int, int]]:
|
|
116
|
+
"""按顶层分号切分,返回 (起, 止) 偏移对。
|
|
117
|
+
|
|
118
|
+
状态机感知:``'`` / ``"`` / 反引号字符串(含 ``''`` 双写与 ``\\`` 转义)、
|
|
119
|
+
``--`` 行注释、``/* */`` 块注释、括号深度。
|
|
120
|
+
注释感知是必需的——注释里的括号或引号会污染状态,导致整个文件再也不能切分。
|
|
121
|
+
"""
|
|
122
|
+
out: list[tuple[int, int]] = []
|
|
123
|
+
n = len(text)
|
|
124
|
+
i = 0
|
|
125
|
+
start = 0
|
|
126
|
+
depth = 0
|
|
127
|
+
while i < n:
|
|
128
|
+
ch = text[i]
|
|
129
|
+
if ch == "-" and text.startswith("--", i):
|
|
130
|
+
j = text.find("\n", i)
|
|
131
|
+
i = n if j < 0 else j
|
|
132
|
+
continue
|
|
133
|
+
if ch == "/" and text.startswith("/*", i):
|
|
134
|
+
j = text.find("*/", i + 2)
|
|
135
|
+
i = n if j < 0 else j + 2
|
|
136
|
+
continue
|
|
137
|
+
if ch in ("'", '"', "`"):
|
|
138
|
+
quote = ch
|
|
139
|
+
i += 1
|
|
140
|
+
while i < n:
|
|
141
|
+
c = text[i]
|
|
142
|
+
if c == "\\" and i + 1 < n:
|
|
143
|
+
i += 2
|
|
144
|
+
continue
|
|
145
|
+
if c == quote:
|
|
146
|
+
if i + 1 < n and text[i + 1] == quote:
|
|
147
|
+
i += 2
|
|
148
|
+
continue
|
|
149
|
+
i += 1
|
|
150
|
+
break
|
|
151
|
+
i += 1
|
|
152
|
+
continue
|
|
153
|
+
if ch == "(":
|
|
154
|
+
depth += 1
|
|
155
|
+
elif ch == ")":
|
|
156
|
+
depth = max(0, depth - 1)
|
|
157
|
+
elif ch == ";" and depth == 0:
|
|
158
|
+
out.append((start, i))
|
|
159
|
+
start = i + 1
|
|
160
|
+
i += 1
|
|
161
|
+
if start < n:
|
|
162
|
+
out.append((start, n))
|
|
163
|
+
return out
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _emit(text: str, spans: list[tuple[int, int]], starts: list[int]) -> list[Statement]:
|
|
167
|
+
res: list[Statement] = []
|
|
168
|
+
for a, b in spans:
|
|
169
|
+
body, off = strip_leading(text[a:b])
|
|
170
|
+
if not body.strip():
|
|
171
|
+
continue
|
|
172
|
+
res.append(Statement(sql=body, line=_line_no(starts, a + off), kind=classify(body)))
|
|
173
|
+
return res
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
# ---------------------------------------------------------------- 格式识别
|
|
177
|
+
|
|
178
|
+
def detect(text: str) -> str:
|
|
179
|
+
"""判定导出格式。顺序敏感,先判特异性最强的。"""
|
|
180
|
+
if "createtab_stmt" in text:
|
|
181
|
+
return "beeline"
|
|
182
|
+
|
|
183
|
+
escaped = text.count("\\n")
|
|
184
|
+
real = text.count("\n")
|
|
185
|
+
# 字面量 \n 明显多于真实换行 -> 该导出把逻辑换行做了转义
|
|
186
|
+
if escaped >= 3 and escaped > real:
|
|
187
|
+
return "escaped_lines"
|
|
188
|
+
|
|
189
|
+
first = next((ln.strip() for ln in text.splitlines() if ln.strip()), "")
|
|
190
|
+
if len(first) > 1 and first[0] == '"' and first[-1] == '"':
|
|
191
|
+
return "quoted_lines"
|
|
192
|
+
|
|
193
|
+
if text.count(";") == 0:
|
|
194
|
+
return "anchor"
|
|
195
|
+
|
|
196
|
+
return "semicolon"
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
# ---------------------------------------------------------------- 各格式切分
|
|
200
|
+
|
|
201
|
+
def _split_anchor(text: str, starts: list[int]) -> list[Statement]:
|
|
202
|
+
lines = text.splitlines(keepends=True)
|
|
203
|
+
offsets: list[int] = []
|
|
204
|
+
acc = 0
|
|
205
|
+
for ln in lines:
|
|
206
|
+
offsets.append(acc)
|
|
207
|
+
acc += len(ln)
|
|
208
|
+
|
|
209
|
+
anchors = [i for i, ln in enumerate(lines) if _ANCHOR.match(ln)]
|
|
210
|
+
if not anchors:
|
|
211
|
+
return []
|
|
212
|
+
|
|
213
|
+
spans: list[tuple[int, int]] = []
|
|
214
|
+
for k, idx in enumerate(anchors):
|
|
215
|
+
stop = anchors[k + 1] if k + 1 < len(anchors) else len(lines)
|
|
216
|
+
a = offsets[idx]
|
|
217
|
+
b = offsets[stop] if stop < len(lines) else len(text)
|
|
218
|
+
spans.append((a, b))
|
|
219
|
+
return _emit(text, spans, starts)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _split_beeline(text: str, starts: list[int]) -> list[Statement]:
|
|
223
|
+
"""剥掉表格边框,保持行数不变(这样行号仍对应原文件),再按锚点切。"""
|
|
224
|
+
kept: list[str] = []
|
|
225
|
+
for ln in text.splitlines(keepends=True):
|
|
226
|
+
body = ln.strip()
|
|
227
|
+
if body.startswith("|"):
|
|
228
|
+
inner = body.strip("|").strip()
|
|
229
|
+
if inner.lower() == "createtab_stmt":
|
|
230
|
+
inner = ""
|
|
231
|
+
kept.append(inner + "\n")
|
|
232
|
+
else:
|
|
233
|
+
kept.append("\n")
|
|
234
|
+
return _split_anchor("".join(kept), _line_starts("".join(kept)))
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _split_lines(text: str, starts: list[int], unescape: bool) -> list[Statement]:
|
|
238
|
+
res: list[Statement] = []
|
|
239
|
+
offset = 0
|
|
240
|
+
for idx, ln in enumerate(text.splitlines(keepends=True)):
|
|
241
|
+
body = ln.strip()
|
|
242
|
+
if body:
|
|
243
|
+
if body.startswith('"') and body.endswith('"') and len(body) > 1:
|
|
244
|
+
body = body[1:-1]
|
|
245
|
+
if unescape:
|
|
246
|
+
body = body.replace("\\n", "\n")
|
|
247
|
+
body, off = strip_leading(body)
|
|
248
|
+
if body.strip():
|
|
249
|
+
res.append(
|
|
250
|
+
Statement(
|
|
251
|
+
sql=body,
|
|
252
|
+
line=_line_no(starts, offset + off),
|
|
253
|
+
kind=classify(body),
|
|
254
|
+
)
|
|
255
|
+
)
|
|
256
|
+
offset += len(ln)
|
|
257
|
+
return res
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
# ---------------------------------------------------------------- 对外入口
|
|
261
|
+
|
|
262
|
+
def split(text: str, fmt: str | None = None) -> list[Statement]:
|
|
263
|
+
"""把整份导出文本切成语句列表。``fmt=None`` 时自动识别。"""
|
|
264
|
+
fmt = fmt or detect(text)
|
|
265
|
+
starts = _line_starts(text)
|
|
266
|
+
if fmt == "beeline":
|
|
267
|
+
return _split_beeline(text, starts)
|
|
268
|
+
if fmt == "quoted_lines":
|
|
269
|
+
return _split_lines(text, starts, unescape=False)
|
|
270
|
+
if fmt == "escaped_lines":
|
|
271
|
+
return _split_lines(text, starts, unescape=True)
|
|
272
|
+
if fmt == "anchor":
|
|
273
|
+
return _split_anchor(text, starts)
|
|
274
|
+
return _emit(text, _scan_semicolons(text), starts)
|