dsh-lh-data 0.1.0
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.
- package/README.md +332 -0
- package/cordis.patch.yml +7 -0
- package/lib/admin-contract.d.ts +274 -0
- package/lib/admin-http.d.ts +31 -0
- package/lib/admin-validate.d.ts +87 -0
- package/lib/admin.d.ts +60 -0
- package/lib/client.js +2969 -0
- package/lib/client.js.map +1 -0
- package/lib/datasource/columns.d.ts +13 -0
- package/lib/datasource/connection.d.ts +33 -0
- package/lib/datasource/connector/base.d.ts +21 -0
- package/lib/datasource/connector/mysql.d.ts +23 -0
- package/lib/datasource/connector/postgresql.d.ts +23 -0
- package/lib/datasource/crypto.d.ts +13 -0
- package/lib/datasource/driver.d.ts +39 -0
- package/lib/datasource/errors.d.ts +25 -0
- package/lib/datasource/importer.d.ts +38 -0
- package/lib/datasource/source-sql.d.ts +11 -0
- package/lib/datasource/source-store.d.ts +28 -0
- package/lib/datasource/types.d.ts +119 -0
- package/lib/db.d.ts +91 -0
- package/lib/http-common.d.ts +31 -0
- package/lib/http.d.ts +13 -0
- package/lib/index.d.ts +50 -0
- package/lib/index.js +5735 -0
- package/lib/parse.d.ts +41 -0
- package/lib/preview.d.ts +75 -0
- package/lib/render.d.ts +100 -0
- package/lib/scope-registry.d.ts +47 -0
- package/lib/scope.d.ts +56 -0
- package/lib/sql.d.ts +133 -0
- package/lib/store.d.ts +174 -0
- package/lib/table.d.ts +38 -0
- package/lib/tooling.d.ts +267 -0
- package/lib/tools/datasource.d.ts +16 -0
- package/lib/tools/import.d.ts +21 -0
- package/lib/tools/read.d.ts +77 -0
- package/lib/tools/registry.d.ts +12 -0
- package/lib/tools/write.d.ts +37 -0
- package/lib/view.d.ts +138 -0
- package/package.json +61 -0
- package/src/admin-contract.ts +351 -0
- package/src/admin-http.ts +254 -0
- package/src/admin-validate.ts +393 -0
- package/src/admin.ts +558 -0
- package/src/client/index.ts +403 -0
- package/src/client/settings/CreateForm.tsx +186 -0
- package/src/client/settings/DataSourceForm.tsx +278 -0
- package/src/client/settings/DataSourcesPanel.tsx +301 -0
- package/src/client/settings/DatasetEditor.tsx +245 -0
- package/src/client/settings/DatasetTable.tsx +110 -0
- package/src/client/settings/DatasetsPanel.tsx +207 -0
- package/src/client/settings/RowsPanel.tsx +196 -0
- package/src/client/settings/Section.tsx +41 -0
- package/src/client/settings/SourceTablesPanel.tsx +226 -0
- package/src/client/settings/api.ts +154 -0
- package/src/client/settings/styles.ts +125 -0
- package/src/datasource/columns.ts +56 -0
- package/src/datasource/connection.ts +124 -0
- package/src/datasource/connector/base.ts +54 -0
- package/src/datasource/connector/mysql.ts +187 -0
- package/src/datasource/connector/postgresql.ts +212 -0
- package/src/datasource/crypto.ts +61 -0
- package/src/datasource/driver.ts +108 -0
- package/src/datasource/errors.ts +69 -0
- package/src/datasource/importer.ts +262 -0
- package/src/datasource/index.ts +62 -0
- package/src/datasource/source-sql.ts +64 -0
- package/src/datasource/source-store.ts +152 -0
- package/src/datasource/types.ts +133 -0
- package/src/db.ts +277 -0
- package/src/http-common.ts +91 -0
- package/src/http.ts +130 -0
- package/src/index.ts +486 -0
- package/src/parse.ts +213 -0
- package/src/preview.ts +198 -0
- package/src/render.ts +294 -0
- package/src/scope-registry.ts +111 -0
- package/src/scope.ts +159 -0
- package/src/sql.ts +491 -0
- package/src/store.ts +412 -0
- package/src/table.ts +160 -0
- package/src/tooling.ts +551 -0
- package/src/tools/datasource.ts +378 -0
- package/src/tools/import.ts +282 -0
- package/src/tools/read.ts +536 -0
- package/src/tools/registry.ts +56 -0
- package/src/tools/write.ts +241 -0
- package/src/view.ts +371 -0
package/src/sql.ts
ADDED
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 只读校验器 + 结构化查询拼装 + 标识符引号化(设计文档 §7.1 / §7.3)。
|
|
3
|
+
*
|
|
4
|
+
* 参考实现的「禁词表」会误伤字符串字面量(如 `WHERE name='DROP'`),这里改为五步校验:
|
|
5
|
+
* 1. 按引号状态机剥离 `--` 与 `/* *\/` 注释(不动字符串字面量);
|
|
6
|
+
* 2. 把字符串字面量内容置空后再扫分号,拒绝多语句;
|
|
7
|
+
* 3. 首关键字白名单 `{ SELECT, WITH, EXPLAIN }`;
|
|
8
|
+
* 4. 顶层关键字扫描(命中写/DDL/事务关键字即拒绝,因此 `WITH … DELETE` 也会被拦下);
|
|
9
|
+
* 5. 表引用必须落在当前 scope 已登记的表名集合内;
|
|
10
|
+
* 6. 未显式声明 `LIMIT` 时补 `maxQueryRows`(有 `OFFSET` 无 `LIMIT` 直接拒绝)。
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export class SqlError extends Error {
|
|
14
|
+
readonly code = 'INVALID_SQL'
|
|
15
|
+
|
|
16
|
+
constructor(message: string) {
|
|
17
|
+
super(message)
|
|
18
|
+
this.name = 'SqlError'
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const ALLOWED_LEADING_KEYWORDS = ['SELECT', 'WITH', 'EXPLAIN']
|
|
23
|
+
const FORBIDDEN_KEYWORDS = [
|
|
24
|
+
'INSERT', 'UPDATE', 'DELETE', 'REPLACE', 'DROP', 'ALTER', 'CREATE',
|
|
25
|
+
'ATTACH', 'DETACH', 'PRAGMA', 'VACUUM', 'REINDEX', 'BEGIN', 'COMMIT',
|
|
26
|
+
]
|
|
27
|
+
const FORBIDDEN_IN_WHERE = [
|
|
28
|
+
'SELECT', 'INSERT', 'UPDATE', 'DELETE', 'REPLACE', 'DROP', 'ALTER', 'CREATE',
|
|
29
|
+
'ATTACH', 'DETACH', 'PRAGMA', 'VACUUM', 'FROM', 'UNION',
|
|
30
|
+
]
|
|
31
|
+
const MAX_SQL_LENGTH = 8000
|
|
32
|
+
|
|
33
|
+
/** 标识符双引号包裹,内部 `"` 转义为 `""`(列名保留原名/中文)。 */
|
|
34
|
+
export function quoteIdentifier(name: string): string {
|
|
35
|
+
return `"${name.replace(/"/g, '""')}"`
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function unquote(name: string): string {
|
|
39
|
+
const trimmed = name.trim()
|
|
40
|
+
if (trimmed.startsWith('"') && trimmed.endsWith('"') && trimmed.length >= 2) {
|
|
41
|
+
return trimmed.slice(1, -1).replace(/""/g, '"')
|
|
42
|
+
}
|
|
43
|
+
if (trimmed.startsWith('[') && trimmed.endsWith(']')) return trimmed.slice(1, -1)
|
|
44
|
+
if (trimmed.startsWith('`') && trimmed.endsWith('`')) return trimmed.slice(1, -1)
|
|
45
|
+
return trimmed
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 剥离注释,保留字符串字面量与引号标识符内的原样内容。
|
|
50
|
+
* 逐个扫描字符,按 `'` / `"` / `` ` `` 的状态机跳过引号区域。
|
|
51
|
+
*/
|
|
52
|
+
export function stripComments(sql: string): string {
|
|
53
|
+
let out = ''
|
|
54
|
+
let i = 0
|
|
55
|
+
while (i < sql.length) {
|
|
56
|
+
const ch = sql[i]!
|
|
57
|
+
if (ch === '\'' || ch === '"' || ch === '`') {
|
|
58
|
+
const quote = ch
|
|
59
|
+
out += ch
|
|
60
|
+
i += 1
|
|
61
|
+
while (i < sql.length) {
|
|
62
|
+
const current = sql[i]!
|
|
63
|
+
out += current
|
|
64
|
+
i += 1
|
|
65
|
+
if (current === quote) {
|
|
66
|
+
// SQL 的转义是引号翻倍('' / "")。
|
|
67
|
+
if (sql[i] === quote) {
|
|
68
|
+
out += sql[i]!
|
|
69
|
+
i += 1
|
|
70
|
+
continue
|
|
71
|
+
}
|
|
72
|
+
break
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
continue
|
|
76
|
+
}
|
|
77
|
+
if (ch === '-' && sql[i + 1] === '-') {
|
|
78
|
+
while (i < sql.length && sql[i] !== '\n') i += 1
|
|
79
|
+
continue
|
|
80
|
+
}
|
|
81
|
+
if (ch === '/' && sql[i + 1] === '*') {
|
|
82
|
+
i += 2
|
|
83
|
+
while (i < sql.length && !(sql[i] === '*' && sql[i + 1] === '/')) i += 1
|
|
84
|
+
i += 2
|
|
85
|
+
out += ' '
|
|
86
|
+
continue
|
|
87
|
+
}
|
|
88
|
+
out += ch
|
|
89
|
+
i += 1
|
|
90
|
+
}
|
|
91
|
+
return out
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** 把单引号字符串字面量的内容替换为空格(保留引号与长度),供关键字扫描使用。 */
|
|
95
|
+
export function blankStringLiterals(sql: string): string {
|
|
96
|
+
let out = ''
|
|
97
|
+
let i = 0
|
|
98
|
+
while (i < sql.length) {
|
|
99
|
+
const ch = sql[i]!
|
|
100
|
+
if (ch !== '\'') {
|
|
101
|
+
out += ch
|
|
102
|
+
i += 1
|
|
103
|
+
continue
|
|
104
|
+
}
|
|
105
|
+
out += '\''
|
|
106
|
+
i += 1
|
|
107
|
+
while (i < sql.length) {
|
|
108
|
+
const current = sql[i]!
|
|
109
|
+
i += 1
|
|
110
|
+
if (current === '\'') {
|
|
111
|
+
if (sql[i] === '\'') {
|
|
112
|
+
out += '\''
|
|
113
|
+
i += 1
|
|
114
|
+
continue
|
|
115
|
+
}
|
|
116
|
+
out += '\''
|
|
117
|
+
break
|
|
118
|
+
}
|
|
119
|
+
out += ' '
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return out
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* 把模型书写的 `ds` 别名替换为数据集的物理表名。
|
|
127
|
+
*
|
|
128
|
+
* 模型拿不到(也不该拿到)物理表名,因此原始 SQL 用保留别名 `ds` 指代本数据集;
|
|
129
|
+
* 只替换裸标识符,引号与字符串字面量内部不受影响。
|
|
130
|
+
*/
|
|
131
|
+
export function substituteDatasetAlias(sql: string, tableName: string): string {
|
|
132
|
+
const replacement = quoteIdentifier(tableName)
|
|
133
|
+
let out = ''
|
|
134
|
+
let i = 0
|
|
135
|
+
while (i < sql.length) {
|
|
136
|
+
const ch = sql[i]!
|
|
137
|
+
if (ch === '\'' || ch === '"' || ch === '`') {
|
|
138
|
+
const quote = ch
|
|
139
|
+
out += ch
|
|
140
|
+
i += 1
|
|
141
|
+
while (i < sql.length) {
|
|
142
|
+
const current = sql[i]!
|
|
143
|
+
out += current
|
|
144
|
+
i += 1
|
|
145
|
+
if (current === quote) {
|
|
146
|
+
if (sql[i] === quote) {
|
|
147
|
+
out += sql[i]!
|
|
148
|
+
i += 1
|
|
149
|
+
continue
|
|
150
|
+
}
|
|
151
|
+
break
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
continue
|
|
155
|
+
}
|
|
156
|
+
if (/[A-Za-z_\u4e00-\u9fa5]/.test(ch)) {
|
|
157
|
+
let identifier = ''
|
|
158
|
+
while (i < sql.length && /[\w\u4e00-\u9fa5$]/.test(sql[i]!)) {
|
|
159
|
+
identifier += sql[i]!
|
|
160
|
+
i += 1
|
|
161
|
+
}
|
|
162
|
+
out += identifier.toLowerCase() === 'ds' ? replacement : identifier
|
|
163
|
+
continue
|
|
164
|
+
}
|
|
165
|
+
out += ch
|
|
166
|
+
i += 1
|
|
167
|
+
}
|
|
168
|
+
return out
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** 提取 `FROM` / `JOIN` 之后的表名(跳过子查询 `( … )`)。 */
|
|
172
|
+
export function extractTableReferences(sql: string): string[] {
|
|
173
|
+
const identifier = '(?:"[^"]+"|\\[[^\\]]+\\]|`[^`]+`|[A-Za-z_\\u4e00-\\u9fa5][\\w\\u4e00-\\u9fa5$]*)'
|
|
174
|
+
const pattern = new RegExp(`\\b(?:FROM|JOIN)\\s+(${identifier}(?:\\s*\\.\\s*${identifier})?)`, 'gi')
|
|
175
|
+
const names: string[] = []
|
|
176
|
+
for (const match of sql.matchAll(pattern)) {
|
|
177
|
+
const raw = match[1] ?? ''
|
|
178
|
+
const first = raw.split('.')[0] ?? ''
|
|
179
|
+
const trimmed = first.trim()
|
|
180
|
+
if (trimmed.length === 0 || trimmed.startsWith('(')) continue
|
|
181
|
+
names.push(unquote(trimmed))
|
|
182
|
+
}
|
|
183
|
+
return names
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export interface ReadOnlyQueryOptions {
|
|
187
|
+
/** 当前 scope 已登记的物理表名集合。 */
|
|
188
|
+
allowedTables: Iterable<string>
|
|
189
|
+
/** 未显式声明 LIMIT 时补上的行数上限。 */
|
|
190
|
+
maxRows: number
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** 一条语句解析出的 `LIMIT` / `OFFSET`(只认字面量整数,参数化写法交给调用方)。 */
|
|
194
|
+
export interface LimitClause {
|
|
195
|
+
limit: number
|
|
196
|
+
offset: number
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* 从等长的 `blanked` 里定位 `LIMIT`,用于在 `stripped` 上切掉尾部限制子句。
|
|
201
|
+
* 返回值是 `LIMIT` 关键字在串中的下标;没有则 -1。
|
|
202
|
+
*/
|
|
203
|
+
function locateLimit(blanked: string): number {
|
|
204
|
+
const match = /\bLIMIT\b/i.exec(blanked)
|
|
205
|
+
return match === null ? -1 : match.index
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** 解析 `LIMIT n [OFFSET m | , m]`;只有 OFFSET 没有 LIMIT 时抛错(SQLite 要求 LIMIT 在前)。 */
|
|
209
|
+
export function parseLimitClause(blanked: string): LimitClause | undefined {
|
|
210
|
+
const hasLimit = /\bLIMIT\b/i.test(blanked)
|
|
211
|
+
const hasOffset = /\bOFFSET\b/i.test(blanked)
|
|
212
|
+
if (!hasLimit) {
|
|
213
|
+
if (hasOffset) throw new SqlError('使用 OFFSET 时必须同时显式声明 LIMIT')
|
|
214
|
+
return undefined
|
|
215
|
+
}
|
|
216
|
+
const match = /\bLIMIT\s+(\d+)(?:\s+OFFSET\s+(\d+)|\s*,\s*(\d+))?/i.exec(blanked)
|
|
217
|
+
if (match === null) throw new SqlError('LIMIT 只支持字面量整数(分页由视图接管)')
|
|
218
|
+
const limit = Number(match[1])
|
|
219
|
+
const offset = Number(match[2] ?? match[3] ?? 0)
|
|
220
|
+
if (!Number.isFinite(limit) || limit < 1) throw new SqlError('LIMIT 必须是正整数')
|
|
221
|
+
return { limit, offset }
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** 校验后的只读语句:分页/计数都从 `baseSql` 派生。 */
|
|
225
|
+
export interface NormalizedQuery {
|
|
226
|
+
/** 去掉注释与尾部分号、已通过关键字与表引用校验的语句(仍可能带模型声明的 LIMIT)。 */
|
|
227
|
+
stripped: string
|
|
228
|
+
/** 与 `stripped` **等长**、字符串字面量置空的版本(关键字扫描用)。 */
|
|
229
|
+
blanked: string
|
|
230
|
+
/** 首关键字:`SELECT` / `WITH` / `EXPLAIN`。 */
|
|
231
|
+
leading: string
|
|
232
|
+
/** 模型显式声明的 LIMIT/OFFSET。 */
|
|
233
|
+
limit?: LimitClause
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* 只读校验的前五步(设计文档 §5 的 1–5),不做 LIMIT 补全。
|
|
238
|
+
* 校验器只在这里收紧一次,派生的 count/page 语句都建立在它的输出上。
|
|
239
|
+
*/
|
|
240
|
+
export function normalizeReadOnlyQuery(sql: string, options: ReadOnlyQueryOptions): NormalizedQuery {
|
|
241
|
+
const trimmed = typeof sql === 'string' ? sql.trim() : ''
|
|
242
|
+
if (trimmed.length === 0) throw new SqlError('sql 不能为空')
|
|
243
|
+
if (trimmed.length > MAX_SQL_LENGTH) throw new SqlError(`sql 过长(上限 ${MAX_SQL_LENGTH} 字符)`)
|
|
244
|
+
|
|
245
|
+
const stripped = stripComments(trimmed).trim().replace(/;\s*$/, '')
|
|
246
|
+
const blanked = blankStringLiterals(stripped)
|
|
247
|
+
if (blanked.includes(';')) throw new SqlError('只允许单条语句(禁止分号拼接)')
|
|
248
|
+
|
|
249
|
+
const leading = /^([A-Za-z_]+)/.exec(stripped)?.[1]?.toUpperCase()
|
|
250
|
+
if (leading === undefined || !ALLOWED_LEADING_KEYWORDS.includes(leading)) {
|
|
251
|
+
throw new SqlError(`只允许 ${ALLOWED_LEADING_KEYWORDS.join(' / ')} 开头的只读语句`)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
for (const keyword of FORBIDDEN_KEYWORDS) {
|
|
255
|
+
if (new RegExp(`\\b${keyword}\\b`, 'i').test(blanked)) {
|
|
256
|
+
throw new SqlError(`只读查询不允许出现关键字 ${keyword}`)
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const allowed = new Set<string>()
|
|
261
|
+
for (const table of options.allowedTables) allowed.add(table.toLowerCase())
|
|
262
|
+
for (const reference of extractTableReferences(stripped)) {
|
|
263
|
+
if (!allowed.has(reference.toLowerCase())) throw new SqlError(`不允许引用未登记的表:${reference}`)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const limit = parseLimitClause(blanked)
|
|
267
|
+
return { stripped, blanked, leading, ...limit === undefined ? {} : { limit } }
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* 校验并规范化一条只读 SQL。返回值是可直接执行的单语句(已补 LIMIT)。
|
|
272
|
+
* 任何不合规都抛 `SqlError`。
|
|
273
|
+
*/
|
|
274
|
+
export function validateReadOnlyQuery(sql: string, options: ReadOnlyQueryOptions): string {
|
|
275
|
+
const normalized = normalizeReadOnlyQuery(sql, options)
|
|
276
|
+
if (normalized.limit !== undefined) return normalized.stripped
|
|
277
|
+
return `${normalized.stripped} LIMIT ${Math.max(1, Math.floor(options.maxRows))}`
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** `where` 是模型提供的布尔表达式片段(不含 WHERE 关键字),做同样的收紧校验。 */
|
|
281
|
+
export function validateWhereExpression(expression: string): string {
|
|
282
|
+
const trimmed = typeof expression === 'string' ? expression.trim() : ''
|
|
283
|
+
if (trimmed.length === 0) throw new SqlError('where 不能为空')
|
|
284
|
+
const stripped = stripComments(trimmed).trim()
|
|
285
|
+
const blanked = blankStringLiterals(stripped)
|
|
286
|
+
if (blanked.includes(';')) throw new SqlError('where 不允许分号')
|
|
287
|
+
for (const keyword of FORBIDDEN_IN_WHERE) {
|
|
288
|
+
if (new RegExp(`\\b${keyword}\\b`, 'i').test(blanked)) {
|
|
289
|
+
throw new SqlError(`where 只允许布尔表达式(出现关键字 ${keyword})`)
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return stripped
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** `orderBy` 形如 `单价 DESC, 名称`,逐项校验并引号化。 */
|
|
296
|
+
export function validateOrderBy(expression: string): string {
|
|
297
|
+
const trimmed = typeof expression === 'string' ? expression.trim() : ''
|
|
298
|
+
if (trimmed.length === 0) throw new SqlError('orderBy 不能为空')
|
|
299
|
+
const terms = trimmed.split(',').map(part => part.trim()).filter(part => part.length > 0)
|
|
300
|
+
if (terms.length === 0) throw new SqlError('orderBy 不能为空')
|
|
301
|
+
return terms.map((term) => {
|
|
302
|
+
const match = /^(.+?)(?:\s+(ASC|DESC))?$/i.exec(term)
|
|
303
|
+
const raw = match?.[1]?.trim() ?? ''
|
|
304
|
+
if (raw.length === 0) throw new SqlError(`orderBy 无法解析:${term}`)
|
|
305
|
+
const direction = match?.[2]?.toUpperCase()
|
|
306
|
+
const quoted = quoteIdentifier(unquote(raw))
|
|
307
|
+
return direction === undefined ? quoted : `${quoted} ${direction}`
|
|
308
|
+
}).join(', ')
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export interface StructuredQuery {
|
|
312
|
+
table: string
|
|
313
|
+
columns?: string[]
|
|
314
|
+
where?: string
|
|
315
|
+
orderBy?: string
|
|
316
|
+
limit?: number
|
|
317
|
+
offset?: number
|
|
318
|
+
/** 该数据集已登记的列名(不含系统列之外的任意标识符)。 */
|
|
319
|
+
allowedColumns: string[]
|
|
320
|
+
maxRows: number
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** 结构化查询的骨架:不带 LIMIT 的基础语句 + 稳定排序(总是以 `_row_id` 兜底)。 */
|
|
324
|
+
export interface StructuredPlan {
|
|
325
|
+
baseSql: string
|
|
326
|
+
orderBy: string
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* 拼装结构化查询的骨架(插件自己拼 SQL,天然安全,作为推荐路径)。
|
|
331
|
+
* 排序恒定追加 `_row_id` 兜底键,保证翻页不重不漏(设计文档 §6)。
|
|
332
|
+
*/
|
|
333
|
+
export function buildStructuredPlan(query: StructuredQuery): StructuredPlan {
|
|
334
|
+
const allowed = new Set(query.allowedColumns)
|
|
335
|
+
const selected: string[] = []
|
|
336
|
+
for (const column of query.columns ?? []) {
|
|
337
|
+
if (column === '*') {
|
|
338
|
+
selected.push('*')
|
|
339
|
+
continue
|
|
340
|
+
}
|
|
341
|
+
if (!allowed.has(column)) throw new SqlError(`未知列:${column}`)
|
|
342
|
+
selected.push(quoteIdentifier(column))
|
|
343
|
+
}
|
|
344
|
+
const projection = selected.length === 0
|
|
345
|
+
? '_row_id, *'
|
|
346
|
+
: ['_row_id', ...selected].join(', ')
|
|
347
|
+
|
|
348
|
+
const parts = [`SELECT ${projection} FROM ${quoteIdentifier(query.table)}`]
|
|
349
|
+
if (query.where !== undefined) parts.push(`WHERE (${validateWhereExpression(query.where)})`)
|
|
350
|
+
|
|
351
|
+
const tie = `${quoteIdentifier(query.table)}._row_id`
|
|
352
|
+
const orderBy = query.orderBy === undefined
|
|
353
|
+
? tie
|
|
354
|
+
: `${validateOrderBy(query.orderBy)}, ${tie}`
|
|
355
|
+
return { baseSql: parts.join(' '), orderBy }
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** 拼装结构化查询(插件自己拼 SQL,天然安全,作为推荐路径)。 */
|
|
359
|
+
export function buildStructuredQuery(query: StructuredQuery): { sql: string; params: unknown[] } {
|
|
360
|
+
const { baseSql, orderBy } = buildStructuredPlan(query)
|
|
361
|
+
const maxRows = Math.max(1, Math.floor(query.maxRows))
|
|
362
|
+
const limit = query.limit === undefined ? maxRows : Math.min(Math.max(1, Math.floor(query.limit)), maxRows)
|
|
363
|
+
const offset = query.offset === undefined ? 0 : Math.max(0, Math.floor(query.offset))
|
|
364
|
+
return { sql: `${baseSql} ORDER BY ${orderBy} LIMIT ? OFFSET ?`, params: [limit, offset] }
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// ── 查询计划:count / page 派生(设计文档 §5.3 / §6) ─────────────────────
|
|
368
|
+
|
|
369
|
+
/** 一条可分页的只读查询计划。三条语句都建立在**已校验**的基础语句之上。 */
|
|
370
|
+
export interface QueryPlan {
|
|
371
|
+
/** 已校验、已切掉模型声明的 LIMIT/OFFSET 与 ORDER BY 的基础语句。 */
|
|
372
|
+
baseSql: string
|
|
373
|
+
/** 模型(或结构化参数)给出的排序项,已引号化;可空。 */
|
|
374
|
+
baseOrder?: string
|
|
375
|
+
/** 兜底排序键(通常 `_row_id`);拿不到时为空,此时不保证翻页稳定。 */
|
|
376
|
+
tie?: string
|
|
377
|
+
/** `SELECT COUNT(*) … FROM (<baseSql>)`。 */
|
|
378
|
+
countSql: string
|
|
379
|
+
/** 默认排序下的分页语句:`… ORDER BY … LIMIT ? OFFSET ?`。 */
|
|
380
|
+
pageSql: string
|
|
381
|
+
/** 该计划能服务的最大行数(模型声明的 LIMIT 与 maxRows 取小)。 */
|
|
382
|
+
rowCap: number
|
|
383
|
+
/** 模型是否显式声明了 LIMIT(未声明时视图上限另按 maxViewRows 计算)。 */
|
|
384
|
+
declaredLimit?: number
|
|
385
|
+
/** 模型声明的 OFFSET:只影响它看到的片段,视图始终从第一行开始。 */
|
|
386
|
+
previewOffset: number
|
|
387
|
+
/** 排序是否稳定:false 时前端禁止深翻页。 */
|
|
388
|
+
stable: boolean
|
|
389
|
+
/** EXPLAIN 语句:不产生视图,也不分页。 */
|
|
390
|
+
explain: boolean
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function countOf(baseSql: string): string {
|
|
394
|
+
return `SELECT COUNT(*) AS _total FROM (${baseSql})`
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** 按「基础语句 + 排序项 + 兜底键」拼分页语句。 */
|
|
398
|
+
export function composePagedSql(plan: Pick<QueryPlan, 'baseSql' | 'baseOrder' | 'tie'>): string {
|
|
399
|
+
const terms = [plan.baseOrder, plan.tie].filter((term): term is string => typeof term === 'string' && term.length > 0)
|
|
400
|
+
const orderBy = terms.length > 0 ? ` ORDER BY ${terms.join(', ')}` : ''
|
|
401
|
+
return `${plan.baseSql}${orderBy} LIMIT ? OFFSET ?`
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** 由结构化参数派生查询计划(排序恒定以 `_row_id` 兜底)。 */
|
|
405
|
+
export function planStructuredQuery(query: StructuredQuery): QueryPlan {
|
|
406
|
+
const { baseSql, orderBy } = buildStructuredPlan(query)
|
|
407
|
+
const maxRows = Math.max(1, Math.floor(query.maxRows))
|
|
408
|
+
const declared = query.limit === undefined ? undefined : Math.max(1, Math.floor(query.limit))
|
|
409
|
+
const tie = `${quoteIdentifier(query.table)}._row_id`
|
|
410
|
+
const baseOrder = query.orderBy === undefined ? undefined : validateOrderBy(query.orderBy)
|
|
411
|
+
const plan: QueryPlan = {
|
|
412
|
+
baseSql,
|
|
413
|
+
countSql: countOf(baseSql),
|
|
414
|
+
pageSql: '',
|
|
415
|
+
rowCap: Math.min(declared ?? maxRows, maxRows),
|
|
416
|
+
previewOffset: Math.max(0, Math.floor(query.offset ?? 0)),
|
|
417
|
+
stable: true,
|
|
418
|
+
explain: false,
|
|
419
|
+
tie,
|
|
420
|
+
...declared === undefined ? {} : { declaredLimit: declared },
|
|
421
|
+
}
|
|
422
|
+
return { ...plan, ...baseOrder === undefined ? {} : { baseOrder }, pageSql: composePagedSql({ baseSql, baseOrder, tie }) }
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* 由模型书写的原始 SQL 派生查询计划。
|
|
427
|
+
*
|
|
428
|
+
* 基础语句先过 `normalizeReadOnlyQuery`(含表引用白名单),随后:
|
|
429
|
+
* 1. 切掉尾部 `LIMIT/OFFSET` —— 分页由视图接管,模型声明的 LIMIT 降级为行数上限;
|
|
430
|
+
* 2. 只有「单表 + 无 GROUP BY/DISTINCT + 非复合查询」时才补 `_row_id` 兜底排序,
|
|
431
|
+
* 其余情况保留模型自己的 ORDER BY,并把 `stable` 记为 false。
|
|
432
|
+
*/
|
|
433
|
+
export function planRawQuery(sql: string, options: ReadOnlyQueryOptions): QueryPlan {
|
|
434
|
+
const normalized = normalizeReadOnlyQuery(sql, options)
|
|
435
|
+
const maxRows = Math.max(1, Math.floor(options.maxRows))
|
|
436
|
+
|
|
437
|
+
// 1) 切掉尾部 LIMIT/OFFSET:分页由视图接管,声明的 LIMIT 降级为行数上限。
|
|
438
|
+
const limitAt = locateLimit(normalized.blanked)
|
|
439
|
+
let stripped = limitAt < 0 ? normalized.stripped : normalized.stripped.slice(0, limitAt)
|
|
440
|
+
let blanked = limitAt < 0 ? normalized.blanked : normalized.blanked.slice(0, limitAt)
|
|
441
|
+
|
|
442
|
+
// 2) 切出顶层 ORDER BY(blanked 与 stripped 等长,下标可直接复用)。
|
|
443
|
+
// 括号内的 ORDER BY(窗口函数)深度不为 0,必须整段留在基础语句里。
|
|
444
|
+
let baseOrder: string | undefined
|
|
445
|
+
const orderMatch = /\bORDER\s+BY\b/i.exec(blanked)
|
|
446
|
+
if (orderMatch !== null && parenDepth(blanked, orderMatch.index) === 0) {
|
|
447
|
+
baseOrder = stripped.slice(orderMatch.index + orderMatch[0].length).trim()
|
|
448
|
+
stripped = stripped.slice(0, orderMatch.index)
|
|
449
|
+
blanked = blanked.slice(0, orderMatch.index)
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const baseSql = stripped.trim()
|
|
453
|
+
if (baseSql.length === 0) throw new SqlError('sql 缺少可执行的查询语句')
|
|
454
|
+
|
|
455
|
+
const compound = /\bUNION\b|\bINTERSECT\b|\bEXCEPT\b/i.test(blanked)
|
|
456
|
+
const grouped = /\bGROUP\s+BY\b/i.test(blanked) || /\bDISTINCT\b/i.test(blanked)
|
|
457
|
+
const aggregated = /\b(?:COUNT|SUM|AVG|MIN|MAX|TOTAL|GROUP_CONCAT)\s*\(/i.test(blanked)
|
|
458
|
+
const tables = extractTableReferences(baseSql)
|
|
459
|
+
// 只有「单表 + 非分组 + 非聚合 + 非复合」时才补 `_row_id` 兜底排序。
|
|
460
|
+
const canTiebreak = !compound && !grouped && !aggregated && tables.length === 1 && normalized.leading !== 'EXPLAIN'
|
|
461
|
+
const tie = canTiebreak ? `${quoteIdentifier(tables[0]!)}._row_id` : undefined
|
|
462
|
+
const stable = canTiebreak || (baseOrder !== undefined && !compound)
|
|
463
|
+
|
|
464
|
+
const plan: QueryPlan = {
|
|
465
|
+
baseSql,
|
|
466
|
+
countSql: countOf(baseSql),
|
|
467
|
+
pageSql: '',
|
|
468
|
+
rowCap: Math.min(normalized.limit?.limit ?? maxRows, maxRows),
|
|
469
|
+
previewOffset: Math.max(0, Math.floor(normalized.limit?.offset ?? 0)),
|
|
470
|
+
stable,
|
|
471
|
+
explain: normalized.leading === 'EXPLAIN',
|
|
472
|
+
...normalized.limit === undefined ? {} : { declaredLimit: normalized.limit.limit },
|
|
473
|
+
}
|
|
474
|
+
return {
|
|
475
|
+
...plan,
|
|
476
|
+
...baseOrder === undefined ? {} : { baseOrder },
|
|
477
|
+
...tie === undefined ? {} : { tie },
|
|
478
|
+
pageSql: composePagedSql({ baseSql, baseOrder, tie }),
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/** 下标 `index` 之前的括号深度(用于区分顶层与子查询内的关键字)。 */
|
|
483
|
+
function parenDepth(blanked: string, index: number): number {
|
|
484
|
+
let depth = 0
|
|
485
|
+
for (let i = 0; i < index; i += 1) {
|
|
486
|
+
const ch = blanked[i]
|
|
487
|
+
if (ch === '(') depth += 1
|
|
488
|
+
else if (ch === ')') depth = Math.max(0, depth - 1)
|
|
489
|
+
}
|
|
490
|
+
return depth
|
|
491
|
+
}
|