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
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 设置页管理接口的入参校验(纯函数,无 IO)。
|
|
3
|
+
*
|
|
4
|
+
* 所有校验失败都抛 `AdminValidationError`,由 `admin-http.ts` 统一转成 400。
|
|
5
|
+
* 校验目标:把浏览器传来的任意字符串收口成「类型正确、长度受控、范围合法」
|
|
6
|
+
* 的值——写库前最后一道闸门,尤其要防止凭空造工作区、列结构越界、字段溢出。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { deduplicateColumnNames, sanitizeColumnName, type ColumnInfo } from './parse'
|
|
10
|
+
import type { DataSourceInput, DataSourcePatch, DataSourceType } from './datasource/types'
|
|
11
|
+
import type { ConnectionDraft } from './datasource/connection'
|
|
12
|
+
import {
|
|
13
|
+
ADMIN_COLUMN_TYPES,
|
|
14
|
+
ADMIN_MAX_COLUMNS,
|
|
15
|
+
ADMIN_MAX_DATABASE_LENGTH,
|
|
16
|
+
ADMIN_MAX_DESCRIPTION_LENGTH,
|
|
17
|
+
ADMIN_MAX_HOST_LENGTH,
|
|
18
|
+
ADMIN_MAX_NAME_LENGTH,
|
|
19
|
+
ADMIN_MAX_PAGE_SIZE,
|
|
20
|
+
ADMIN_MAX_PASSWORD_LENGTH,
|
|
21
|
+
ADMIN_MAX_POOL_MAX,
|
|
22
|
+
ADMIN_MAX_PORT,
|
|
23
|
+
ADMIN_MAX_QUERY_LENGTH,
|
|
24
|
+
ADMIN_MAX_SAMPLE_LENGTH,
|
|
25
|
+
ADMIN_MAX_SAMPLE_VALUES,
|
|
26
|
+
ADMIN_MAX_SOURCE_LENGTH,
|
|
27
|
+
ADMIN_MAX_SOURCE_NAME_LENGTH,
|
|
28
|
+
ADMIN_MAX_USERNAME_LENGTH,
|
|
29
|
+
ADMIN_MIN_PORT,
|
|
30
|
+
ADMIN_PAGE_SIZE,
|
|
31
|
+
ADMIN_ROW_MAX_PAGE_SIZE,
|
|
32
|
+
ADMIN_ROW_PAGE_SIZE,
|
|
33
|
+
ADMIN_SOURCE_DEFAULT_PORTS,
|
|
34
|
+
ADMIN_SOURCE_TYPES,
|
|
35
|
+
type AdminColumnType,
|
|
36
|
+
type AdminErrorCode,
|
|
37
|
+
} from './admin-contract'
|
|
38
|
+
|
|
39
|
+
/** 校验失败:对应 HTTP 400。 */
|
|
40
|
+
export class AdminValidationError extends Error {
|
|
41
|
+
constructor(
|
|
42
|
+
public readonly code: AdminErrorCode,
|
|
43
|
+
message: string,
|
|
44
|
+
) {
|
|
45
|
+
super(message)
|
|
46
|
+
this.name = 'AdminValidationError'
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** 一个可空、可缺省的文本字段(描述 / 来源路径)。 */
|
|
51
|
+
function validateOptionalText(value: unknown, max: number, field: string): string | null {
|
|
52
|
+
if (value === undefined || value === null) return null
|
|
53
|
+
if (typeof value !== 'string') throw new AdminValidationError('BAD_REQUEST', `${field}必须是字符串`)
|
|
54
|
+
const trimmed = value.trim()
|
|
55
|
+
if (trimmed.length === 0) return null
|
|
56
|
+
if (trimmed.length > max) throw new AdminValidationError('BAD_REQUEST', `${field}不超过 ${max} 字`)
|
|
57
|
+
return trimmed
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function validateScopeKey(value: unknown): string {
|
|
61
|
+
if (typeof value !== 'string' || value.trim().length === 0) {
|
|
62
|
+
throw new AdminValidationError('BAD_REQUEST', '缺少工作区标识(scopeKey)')
|
|
63
|
+
}
|
|
64
|
+
return value.trim()
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function validateDatasetName(value: unknown): string {
|
|
68
|
+
if (typeof value !== 'string' || value.trim().length === 0) {
|
|
69
|
+
throw new AdminValidationError('BAD_REQUEST', '数据集名称不能为空')
|
|
70
|
+
}
|
|
71
|
+
const name = value.trim()
|
|
72
|
+
if (name.length > ADMIN_MAX_NAME_LENGTH) {
|
|
73
|
+
throw new AdminValidationError('BAD_REQUEST', `名称不超过 ${ADMIN_MAX_NAME_LENGTH} 字`)
|
|
74
|
+
}
|
|
75
|
+
return name
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function validateDescription(value: unknown): string | null {
|
|
79
|
+
return validateOptionalText(value, ADMIN_MAX_DESCRIPTION_LENGTH, '描述')
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function validateSourcePath(value: unknown): string | null {
|
|
83
|
+
return validateOptionalText(value, ADMIN_MAX_SOURCE_LENGTH, '来源路径')
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* 校验列定义:非空数组、数量受控、类型白名单、列名消毒并去重。
|
|
88
|
+
* 返回可直接交给 `createDatasetTable` 的 `ColumnInfo[]`。
|
|
89
|
+
*/
|
|
90
|
+
export function validateColumnSpecs(raw: unknown): ColumnInfo[] {
|
|
91
|
+
if (!Array.isArray(raw) || raw.length === 0) {
|
|
92
|
+
throw new AdminValidationError('INVALID_COLUMNS', '至少需要定义一列')
|
|
93
|
+
}
|
|
94
|
+
if (raw.length > ADMIN_MAX_COLUMNS) {
|
|
95
|
+
throw new AdminValidationError('INVALID_COLUMNS', `列数不超过 ${ADMIN_MAX_COLUMNS}`)
|
|
96
|
+
}
|
|
97
|
+
const columns: ColumnInfo[] = raw.map((item, index) => {
|
|
98
|
+
if (typeof item !== 'object' || item === null) {
|
|
99
|
+
throw new AdminValidationError('INVALID_COLUMNS', `第 ${index + 1} 列格式错误`)
|
|
100
|
+
}
|
|
101
|
+
const spec = item as Record<string, unknown>
|
|
102
|
+
const name = typeof spec.name === 'string' ? spec.name.trim() : ''
|
|
103
|
+
if (name.length === 0) throw new AdminValidationError('INVALID_COLUMNS', `第 ${index + 1} 列缺少列名`)
|
|
104
|
+
if (!ADMIN_COLUMN_TYPES.includes(spec.type as AdminColumnType)) {
|
|
105
|
+
throw new AdminValidationError('INVALID_COLUMNS', `第 ${index + 1} 列类型非法`)
|
|
106
|
+
}
|
|
107
|
+
const description = typeof spec.description === 'string' ? spec.description.slice(0, ADMIN_MAX_DESCRIPTION_LENGTH) : ''
|
|
108
|
+
return {
|
|
109
|
+
name,
|
|
110
|
+
sanitizedName: sanitizeColumnName(name),
|
|
111
|
+
type: spec.type as AdminColumnType,
|
|
112
|
+
nullable: spec.nullable === true,
|
|
113
|
+
sample: [],
|
|
114
|
+
description,
|
|
115
|
+
}
|
|
116
|
+
})
|
|
117
|
+
deduplicateColumnNames(columns)
|
|
118
|
+
return columns
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** 样例值只允许 JSON 标量(columns 是 TEXT 列,不能塞对象 / NaN)。 */
|
|
122
|
+
function sanitizeSampleValue(value: unknown): string | number | boolean | null {
|
|
123
|
+
if (value === null) return null
|
|
124
|
+
if (typeof value === 'string') return value.slice(0, ADMIN_MAX_SAMPLE_LENGTH)
|
|
125
|
+
if (typeof value === 'boolean') return value
|
|
126
|
+
if (typeof value === 'number') return Number.isFinite(value) ? value : null
|
|
127
|
+
return String(value)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* 校验列的「说明 / 样例」补丁,返回合并后的完整 `ColumnInfo[]`。
|
|
132
|
+
*
|
|
133
|
+
* 只从补丁里读 `description` 与 `sample` 两项,其余字段(name / sanitizedName /
|
|
134
|
+
* type / nullable)一律沿用既有列——它们对应物理表 DDL,设置页改不了。
|
|
135
|
+
* 按 `sanitizedName` 定位列:原始表头 `name` 在重名列上会重复,不能当键。
|
|
136
|
+
*/
|
|
137
|
+
export function validateColumnPatches(raw: unknown, existing: ColumnInfo[]): ColumnInfo[] {
|
|
138
|
+
if (!Array.isArray(raw)) {
|
|
139
|
+
throw new AdminValidationError('INVALID_COLUMNS', '列补丁必须是数组')
|
|
140
|
+
}
|
|
141
|
+
const merged = existing.map(column => ({ ...column }))
|
|
142
|
+
for (const entry of raw) {
|
|
143
|
+
if (typeof entry !== 'object' || entry === null) {
|
|
144
|
+
throw new AdminValidationError('INVALID_COLUMNS', '列补丁格式错误')
|
|
145
|
+
}
|
|
146
|
+
const patch = entry as Record<string, unknown>
|
|
147
|
+
const key = typeof patch.sanitizedName === 'string' ? patch.sanitizedName : ''
|
|
148
|
+
const target = merged.find(column => column.sanitizedName === key)
|
|
149
|
+
if (target === undefined) {
|
|
150
|
+
throw new AdminValidationError('INVALID_COLUMNS', `未找到列:${key}`)
|
|
151
|
+
}
|
|
152
|
+
if (patch.description !== undefined) {
|
|
153
|
+
if (typeof patch.description !== 'string') {
|
|
154
|
+
throw new AdminValidationError('INVALID_COLUMNS', `列「${key}」的说明必须是字符串`)
|
|
155
|
+
}
|
|
156
|
+
target.description = patch.description.slice(0, ADMIN_MAX_DESCRIPTION_LENGTH)
|
|
157
|
+
}
|
|
158
|
+
if (patch.sample !== undefined) {
|
|
159
|
+
if (!Array.isArray(patch.sample)) {
|
|
160
|
+
throw new AdminValidationError('INVALID_COLUMNS', `列「${key}」的样例必须是数组`)
|
|
161
|
+
}
|
|
162
|
+
target.sample = patch.sample.slice(0, ADMIN_MAX_SAMPLE_VALUES).map(sanitizeSampleValue)
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return merged
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function clampInt(raw: unknown, fallback: number, min: number, max: number): number {
|
|
169
|
+
const parsed = typeof raw === 'string' ? Number.parseInt(raw, 10) : NaN
|
|
170
|
+
if (!Number.isFinite(parsed)) return fallback
|
|
171
|
+
return Math.min(max, Math.max(min, parsed))
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export interface ParsedListQuery {
|
|
175
|
+
q: string
|
|
176
|
+
page: number
|
|
177
|
+
pageSize: number
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** 解析并夹紧列表查询参数(关键字裁剪、页码与页大小封顶)。 */
|
|
181
|
+
export function parseListQuery(params: Record<string, string | undefined>): ParsedListQuery {
|
|
182
|
+
const q = typeof params.q === 'string' ? params.q.trim().slice(0, ADMIN_MAX_QUERY_LENGTH) : ''
|
|
183
|
+
const page = clampInt(params.page, 1, 1, 100000)
|
|
184
|
+
const pageSize = clampInt(params.pageSize, ADMIN_PAGE_SIZE, 1, ADMIN_MAX_PAGE_SIZE)
|
|
185
|
+
return { q, page, pageSize }
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export interface ParsedRowsQuery {
|
|
189
|
+
page: number
|
|
190
|
+
pageSize: number
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* 解析并夹紧表数据分页参数。页码上限只是防御(真实边界由 `COUNT(*)` 决定),
|
|
195
|
+
* 页大小封顶避免一次捞走整表。
|
|
196
|
+
*/
|
|
197
|
+
export function parseRowsQuery(params: Record<string, string | undefined>): ParsedRowsQuery {
|
|
198
|
+
const page = clampInt(params.page, 1, 1, 100000)
|
|
199
|
+
const pageSize = clampInt(params.pageSize, ADMIN_ROW_PAGE_SIZE, 1, ADMIN_ROW_MAX_PAGE_SIZE)
|
|
200
|
+
return { page, pageSize }
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ── 数据源 ───────────────────────────────────────────────────────────────
|
|
204
|
+
|
|
205
|
+
/** 必填文本:trim 后非空、长度受控。 */
|
|
206
|
+
function requireText(value: unknown, max: number, field: string): string {
|
|
207
|
+
if (typeof value !== 'string' || value.trim().length === 0) {
|
|
208
|
+
throw new AdminValidationError('BAD_REQUEST', `${field}不能为空`)
|
|
209
|
+
}
|
|
210
|
+
const text = value.trim()
|
|
211
|
+
if (text.length > max) throw new AdminValidationError('BAD_REQUEST', `${field}不超过 ${max} 字`)
|
|
212
|
+
return text
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function validateSourceName(value: unknown): string {
|
|
216
|
+
return requireText(value, ADMIN_MAX_SOURCE_NAME_LENGTH, '数据源名称')
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function validateSourceType(value: unknown): DataSourceType {
|
|
220
|
+
if (typeof value !== 'string' || !ADMIN_SOURCE_TYPES.includes(value)) {
|
|
221
|
+
throw new AdminValidationError('BAD_REQUEST', '数据源类型必须是 mysql 或 postgresql')
|
|
222
|
+
}
|
|
223
|
+
return value as DataSourceType
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function validateHost(value: unknown): string {
|
|
227
|
+
return requireText(value, ADMIN_MAX_HOST_LENGTH, '主机地址')
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export function validatePort(value: unknown, type: DataSourceType): number {
|
|
231
|
+
if (value === undefined || value === null) return ADMIN_SOURCE_DEFAULT_PORTS[type]
|
|
232
|
+
const parsed = typeof value === 'string' ? Number.parseInt(value, 10) : value
|
|
233
|
+
if (typeof parsed !== 'number' || !Number.isInteger(parsed)) {
|
|
234
|
+
throw new AdminValidationError('BAD_REQUEST', '端口必须是整数')
|
|
235
|
+
}
|
|
236
|
+
if (parsed < ADMIN_MIN_PORT || parsed > ADMIN_MAX_PORT) {
|
|
237
|
+
throw new AdminValidationError('BAD_REQUEST', `端口必须在 ${ADMIN_MIN_PORT} – ${ADMIN_MAX_PORT} 之间`)
|
|
238
|
+
}
|
|
239
|
+
return parsed
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function validateDatabaseName(value: unknown): string {
|
|
243
|
+
return requireText(value, ADMIN_MAX_DATABASE_LENGTH, '数据库名')
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export function validateUsername(value: unknown): string {
|
|
247
|
+
return requireText(value, ADMIN_MAX_USERNAME_LENGTH, '用户名')
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** 密码允许为空串(部分库无口令),但不能超过上限。 */
|
|
251
|
+
export function validatePassword(value: unknown): string {
|
|
252
|
+
if (value === undefined || value === null) return ''
|
|
253
|
+
if (typeof value !== 'string') throw new AdminValidationError('BAD_REQUEST', '密码必须是字符串')
|
|
254
|
+
if (value.length > ADMIN_MAX_PASSWORD_LENGTH) {
|
|
255
|
+
throw new AdminValidationError('BAD_REQUEST', `密码不超过 ${ADMIN_MAX_PASSWORD_LENGTH} 字`)
|
|
256
|
+
}
|
|
257
|
+
return value
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function validateSslMode(value: unknown): string | null {
|
|
261
|
+
return validateOptionalText(value, 32, 'SSL 模式')
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function validatePoolMax(value: unknown): number | null {
|
|
265
|
+
if (value === undefined || value === null) return null
|
|
266
|
+
const parsed = typeof value === 'string' ? Number.parseInt(value, 10) : value
|
|
267
|
+
if (typeof parsed !== 'number' || !Number.isInteger(parsed) || parsed < 1) {
|
|
268
|
+
throw new AdminValidationError('BAD_REQUEST', '连接池上限必须是正整数')
|
|
269
|
+
}
|
|
270
|
+
return Math.min(ADMIN_MAX_POOL_MAX, parsed)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export interface ParsedCreateSource extends DataSourceInput {
|
|
274
|
+
test: boolean
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export function parseCreateDataSource(raw: unknown): ParsedCreateSource {
|
|
278
|
+
if (typeof raw !== 'object' || raw === null) {
|
|
279
|
+
throw new AdminValidationError('BAD_REQUEST', '请求体必须是对象')
|
|
280
|
+
}
|
|
281
|
+
const body = raw as Record<string, unknown>
|
|
282
|
+
const type = validateSourceType(body.type)
|
|
283
|
+
return {
|
|
284
|
+
name: validateSourceName(body.name),
|
|
285
|
+
type,
|
|
286
|
+
host: validateHost(body.host),
|
|
287
|
+
port: validatePort(body.port, type),
|
|
288
|
+
database: validateDatabaseName(body.database),
|
|
289
|
+
username: validateUsername(body.username),
|
|
290
|
+
password: validatePassword(body.password),
|
|
291
|
+
sslMode: validateSslMode(body.sslMode),
|
|
292
|
+
poolMax: validatePoolMax(body.poolMax),
|
|
293
|
+
description: validateDescription(body.description),
|
|
294
|
+
test: body.test !== false,
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export interface ParsedPatchSource extends DataSourcePatch {
|
|
299
|
+
test: boolean
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** 只校验出现的字段;`password` 给了字符串才替换。 */
|
|
303
|
+
export function parsePatchDataSource(raw: unknown): ParsedPatchSource {
|
|
304
|
+
if (typeof raw !== 'object' || raw === null) {
|
|
305
|
+
throw new AdminValidationError('BAD_REQUEST', '请求体必须是对象')
|
|
306
|
+
}
|
|
307
|
+
const body = raw as Record<string, unknown>
|
|
308
|
+
const patch: ParsedPatchSource = { test: body.test !== false }
|
|
309
|
+
if (body.name !== undefined) patch.name = validateSourceName(body.name)
|
|
310
|
+
if (body.host !== undefined) patch.host = validateHost(body.host)
|
|
311
|
+
if (body.database !== undefined) patch.database = validateDatabaseName(body.database)
|
|
312
|
+
if (body.username !== undefined) patch.username = validateUsername(body.username)
|
|
313
|
+
if (body.password !== undefined) patch.password = validatePassword(body.password)
|
|
314
|
+
if (body.sslMode !== undefined) patch.sslMode = validateSslMode(body.sslMode)
|
|
315
|
+
if (body.poolMax !== undefined) patch.poolMax = validatePoolMax(body.poolMax)
|
|
316
|
+
if (body.description !== undefined) patch.description = validateDescription(body.description)
|
|
317
|
+
const type = body.type === undefined ? undefined : validateSourceType(body.type)
|
|
318
|
+
if (type !== undefined) patch.type = type
|
|
319
|
+
if (body.port !== undefined) patch.port = validatePort(body.port, type ?? 'mysql')
|
|
320
|
+
return patch
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export interface ParsedTestSource {
|
|
324
|
+
/** 测已登记的数据源(id 或登记名)。 */
|
|
325
|
+
reference?: string
|
|
326
|
+
/** 测未保存的草稿。 */
|
|
327
|
+
draft?: ConnectionDraft
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** `source` 与「完整连接参数」二选一。 */
|
|
331
|
+
export function parseTestDataSource(raw: unknown): ParsedTestSource {
|
|
332
|
+
if (typeof raw !== 'object' || raw === null) {
|
|
333
|
+
throw new AdminValidationError('BAD_REQUEST', '请求体必须是对象')
|
|
334
|
+
}
|
|
335
|
+
const body = raw as Record<string, unknown>
|
|
336
|
+
if (typeof body.source === 'string' && body.source.trim().length > 0) {
|
|
337
|
+
return { reference: body.source.trim() }
|
|
338
|
+
}
|
|
339
|
+
const type = validateSourceType(body.type)
|
|
340
|
+
return {
|
|
341
|
+
draft: {
|
|
342
|
+
type,
|
|
343
|
+
host: validateHost(body.host),
|
|
344
|
+
port: validatePort(body.port, type),
|
|
345
|
+
database: validateDatabaseName(body.database),
|
|
346
|
+
username: validateUsername(body.username),
|
|
347
|
+
password: validatePassword(body.password),
|
|
348
|
+
sslMode: validateSslMode(body.sslMode),
|
|
349
|
+
poolMax: validatePoolMax(body.poolMax),
|
|
350
|
+
},
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
export interface ParsedTablesQuery {
|
|
355
|
+
schema: string | null
|
|
356
|
+
q: string
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
export function parseTablesQuery(params: Record<string, string | undefined>): ParsedTablesQuery {
|
|
360
|
+
const schema = typeof params.schema === 'string' && params.schema.trim().length > 0 ? params.schema.trim() : null
|
|
361
|
+
const q = typeof params.q === 'string' ? params.q.trim().slice(0, ADMIN_MAX_QUERY_LENGTH) : ''
|
|
362
|
+
return { schema, q }
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
export interface ParsedImportRequest {
|
|
366
|
+
scopeKey: string
|
|
367
|
+
tableName: string
|
|
368
|
+
schemaName: string | null
|
|
369
|
+
name: string | null
|
|
370
|
+
limit: number | null
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
export function parseImportRequest(raw: unknown): ParsedImportRequest {
|
|
374
|
+
if (typeof raw !== 'object' || raw === null) {
|
|
375
|
+
throw new AdminValidationError('BAD_REQUEST', '请求体必须是对象')
|
|
376
|
+
}
|
|
377
|
+
const body = raw as Record<string, unknown>
|
|
378
|
+
const tableName = requireText(body.tableName, ADMIN_MAX_DATABASE_LENGTH, '表名')
|
|
379
|
+
const limit = typeof body.limit === 'number' && Number.isInteger(body.limit) && body.limit > 0 ? body.limit : null
|
|
380
|
+
return {
|
|
381
|
+
scopeKey: validateScopeKey(body.scopeKey),
|
|
382
|
+
tableName,
|
|
383
|
+
schemaName: validateOptionalText(body.schemaName, ADMIN_MAX_DATABASE_LENGTH, 'schema'),
|
|
384
|
+
name: validateDatasetNameSafe(body.name),
|
|
385
|
+
limit,
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** 数据集登记名可缺省(缺省时取远端表名)。 */
|
|
390
|
+
function validateDatasetNameSafe(value: unknown): string | null {
|
|
391
|
+
if (value === undefined || value === null) return null
|
|
392
|
+
return validateDatasetName(value)
|
|
393
|
+
}
|