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/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)