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,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 设置页的内联样式常量。沿用插件既有风格:中性灰底、细边框、6px 圆角、12–13px 字号,
|
|
3
|
+
* 全部走 `var(--dsw-*)` 设计变量并带字面量兜底,自动跟随平台明暗主题。
|
|
4
|
+
* 不引入任何 CSS 管线(与浏览器半身既有卡片一致)。
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export const c = {
|
|
8
|
+
bg: 'var(--dsw-bg, #ffffff)',
|
|
9
|
+
subtle: 'var(--dsw-bg-subtle, #fafafa)',
|
|
10
|
+
fg: 'var(--dsw-fg, #1f2328)',
|
|
11
|
+
fg2: 'var(--dsw-fg-secondary, #656d76)',
|
|
12
|
+
border: 'var(--dsw-border, #e5e5e5)',
|
|
13
|
+
primary: 'var(--dsw-primary, #2f6feb)',
|
|
14
|
+
danger: 'var(--dsw-danger, #c0392b)',
|
|
15
|
+
ok: 'var(--dsw-ok, #1a7f37)',
|
|
16
|
+
warn: 'var(--dsw-warn, #9a6700)',
|
|
17
|
+
} as const
|
|
18
|
+
|
|
19
|
+
export const s = {
|
|
20
|
+
page: { fontSize: 12, color: c.fg, lineHeight: 1.6 },
|
|
21
|
+
toolbar: {
|
|
22
|
+
display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10,
|
|
23
|
+
paddingBottom: 10, borderBottom: `1px solid ${c.border}`,
|
|
24
|
+
},
|
|
25
|
+
search: {
|
|
26
|
+
flex: 1, minWidth: 0, padding: '5px 8px', fontSize: 12,
|
|
27
|
+
border: `1px solid ${c.border}`, borderRadius: 6, background: c.bg, color: c.fg,
|
|
28
|
+
outline: 'none',
|
|
29
|
+
},
|
|
30
|
+
button: {
|
|
31
|
+
border: `1px solid ${c.border}`, background: c.bg, color: c.fg,
|
|
32
|
+
borderRadius: 6, padding: '5px 10px', cursor: 'pointer', fontSize: 12, whiteSpace: 'nowrap',
|
|
33
|
+
},
|
|
34
|
+
primaryButton: {
|
|
35
|
+
border: `1px solid ${c.primary}`, background: c.primary, color: '#fff',
|
|
36
|
+
borderRadius: 6, padding: '5px 12px', cursor: 'pointer', fontSize: 12, whiteSpace: 'nowrap',
|
|
37
|
+
},
|
|
38
|
+
dangerButton: {
|
|
39
|
+
border: `1px solid ${c.danger}`, background: 'transparent', color: c.danger,
|
|
40
|
+
borderRadius: 6, padding: '5px 10px', cursor: 'pointer', fontSize: 12,
|
|
41
|
+
},
|
|
42
|
+
disabledButton: { opacity: 0.5, cursor: 'not-allowed' },
|
|
43
|
+
banner: {
|
|
44
|
+
marginBottom: 10, padding: '6px 10px', borderRadius: 6,
|
|
45
|
+
border: `1px solid ${c.danger}`, color: c.danger, fontSize: 12,
|
|
46
|
+
display: 'flex', alignItems: 'center', gap: 8,
|
|
47
|
+
},
|
|
48
|
+
scroll: {
|
|
49
|
+
maxHeight: 360, overflow: 'auto', border: `1px solid ${c.border}`, borderRadius: 6,
|
|
50
|
+
},
|
|
51
|
+
table: { borderCollapse: 'collapse', width: '100%', fontSize: 12 },
|
|
52
|
+
th: {
|
|
53
|
+
position: 'sticky', top: 0, background: c.subtle, textAlign: 'left',
|
|
54
|
+
padding: '6px 8px', borderBottom: `1px solid ${c.border}`, color: c.fg2, fontWeight: 600,
|
|
55
|
+
whiteSpace: 'nowrap',
|
|
56
|
+
},
|
|
57
|
+
td: {
|
|
58
|
+
padding: '5px 8px', borderBottom: `1px solid ${c.border}`, maxWidth: 280,
|
|
59
|
+
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
|
60
|
+
},
|
|
61
|
+
row: { cursor: 'pointer' },
|
|
62
|
+
selectedRow: { boxShadow: `inset 2px 0 0 ${c.primary}` },
|
|
63
|
+
rowHover: { background: c.subtle },
|
|
64
|
+
dot: { display: 'inline-block', width: 7, height: 7, borderRadius: '50%', marginRight: 5 },
|
|
65
|
+
panel: {
|
|
66
|
+
marginTop: 12, padding: 12, border: `1px solid ${c.border}`, borderRadius: 6, background: c.subtle,
|
|
67
|
+
},
|
|
68
|
+
field: { marginBottom: 10 },
|
|
69
|
+
label: { display: 'block', fontSize: 12, color: c.fg2, marginBottom: 4 },
|
|
70
|
+
input: {
|
|
71
|
+
width: '100%', boxSizing: 'border-box', padding: '5px 8px', fontSize: 12,
|
|
72
|
+
border: `1px solid ${c.border}`, borderRadius: 6, background: c.bg, color: c.fg, outline: 'none',
|
|
73
|
+
},
|
|
74
|
+
select: {
|
|
75
|
+
padding: '4px 6px', fontSize: 12, border: `1px solid ${c.border}`, borderRadius: 6,
|
|
76
|
+
background: c.bg, color: c.fg, outline: 'none',
|
|
77
|
+
},
|
|
78
|
+
textarea: {
|
|
79
|
+
width: '100%', boxSizing: 'border-box', padding: '5px 8px', fontSize: 12,
|
|
80
|
+
border: `1px solid ${c.border}`, borderRadius: 6, background: c.bg, color: c.fg,
|
|
81
|
+
outline: 'none', resize: 'vertical', minHeight: 54, fontFamily: 'inherit',
|
|
82
|
+
},
|
|
83
|
+
grid2: { display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 },
|
|
84
|
+
grid3: { display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 8, alignItems: 'end' },
|
|
85
|
+
head: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 },
|
|
86
|
+
bar: { display: 'flex', alignItems: 'center', gap: 8, marginTop: 8, flexWrap: 'wrap' },
|
|
87
|
+
muted: { color: c.fg2 },
|
|
88
|
+
link: { color: c.primary, cursor: 'pointer' },
|
|
89
|
+
status: { fontWeight: 600 },
|
|
90
|
+
tabs: {
|
|
91
|
+
display: 'flex', gap: 18, marginBottom: 12, borderBottom: `1px solid ${c.border}`,
|
|
92
|
+
},
|
|
93
|
+
tab: {
|
|
94
|
+
padding: '0 0 6px', fontSize: 13, fontWeight: 600, color: c.fg2,
|
|
95
|
+
background: 'none', border: 'none', borderBottom: '2px solid transparent',
|
|
96
|
+
cursor: 'pointer', transition: 'color 120ms ease, border-color 120ms ease',
|
|
97
|
+
},
|
|
98
|
+
activeTab: { color: c.fg, borderBottom: `2px solid ${c.primary}` },
|
|
99
|
+
} as const
|
|
100
|
+
|
|
101
|
+
/** 状态圆点配色(数据集)。 */
|
|
102
|
+
export const STATUS_COLOR: Record<string, string> = {
|
|
103
|
+
ready: c.ok,
|
|
104
|
+
importing: c.warn,
|
|
105
|
+
failed: c.danger,
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** 数据源连通状态圆点配色。 */
|
|
109
|
+
export const SOURCE_STATUS_COLOR: Record<string, string> = {
|
|
110
|
+
connected: c.ok,
|
|
111
|
+
error: c.danger,
|
|
112
|
+
unknown: c.fg2,
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export const SOURCE_STATUS_TEXT: Record<string, string> = {
|
|
116
|
+
connected: '已连通',
|
|
117
|
+
error: '连接失败',
|
|
118
|
+
unknown: '未检测',
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** 类型徽标底色(低饱和,跟随主题的浅底细边框)。 */
|
|
122
|
+
export const TYPE_BADGE: Record<string, string> = {
|
|
123
|
+
mysql: c.primary,
|
|
124
|
+
postgresql: c.ok,
|
|
125
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 远端列 → 本地 `ColumnInfo` 的映射,以及行键改写。
|
|
3
|
+
*
|
|
4
|
+
* 与文件导入的区别:远端已有权威类型声明,所以**不做采样推断**(`parse.ts` 的
|
|
5
|
+
* `inferColumnType` 只在解析 Excel/CSV 时用),直接采用连接器映射出的类型。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { deduplicateColumnNames, sanitizeColumnName, type ColumnInfo } from '../parse'
|
|
9
|
+
import type { RemoteColumn } from './types'
|
|
10
|
+
|
|
11
|
+
/** 设置页展示用的样例值个数。 */
|
|
12
|
+
const MAX_SAMPLE_VALUES = 5
|
|
13
|
+
|
|
14
|
+
export function buildColumns(remote: readonly RemoteColumn[]): ColumnInfo[] {
|
|
15
|
+
const columns: ColumnInfo[] = remote.map(column => {
|
|
16
|
+
const info: ColumnInfo = {
|
|
17
|
+
name: column.name,
|
|
18
|
+
sanitizedName: sanitizeColumnName(column.name),
|
|
19
|
+
type: column.nativeType,
|
|
20
|
+
nullable: column.nullable,
|
|
21
|
+
sample: [],
|
|
22
|
+
}
|
|
23
|
+
if (column.comment !== null && column.comment.length > 0) info.description = column.comment
|
|
24
|
+
return info
|
|
25
|
+
})
|
|
26
|
+
deduplicateColumnNames(columns)
|
|
27
|
+
return columns
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** `insertRows` 按 `sanitizedName` 取值,远端行的原始列键必须改写一次。 */
|
|
31
|
+
export function mapRowKeys(
|
|
32
|
+
rows: readonly Record<string, unknown>[],
|
|
33
|
+
columns: readonly ColumnInfo[],
|
|
34
|
+
): Record<string, unknown>[] {
|
|
35
|
+
return rows.map(row => {
|
|
36
|
+
const mapped: Record<string, unknown> = {}
|
|
37
|
+
for (const column of columns) {
|
|
38
|
+
mapped[column.sanitizedName] = row[column.name] ?? null
|
|
39
|
+
}
|
|
40
|
+
return mapped
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** 从首批行里取几个非空值填进 `sample`(原地修改)。 */
|
|
45
|
+
export function fillSamples(columns: readonly ColumnInfo[], rows: readonly Record<string, unknown>[]): void {
|
|
46
|
+
for (const column of columns) {
|
|
47
|
+
const sample: unknown[] = []
|
|
48
|
+
for (const row of rows) {
|
|
49
|
+
const value = row[column.name]
|
|
50
|
+
if (value === null || value === undefined) continue
|
|
51
|
+
sample.push(value)
|
|
52
|
+
if (sample.length >= MAX_SAMPLE_VALUES) break
|
|
53
|
+
}
|
|
54
|
+
if (sample.length > 0) column.sample = sample
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 连接器的工厂与缓存。
|
|
3
|
+
*
|
|
4
|
+
* key 是 `sourceId`:同一数据源在一次插件生命周期内复用同一个连接池。
|
|
5
|
+
* 数据源被改 / 删时必须先 `closeConnector`,否则缓存里会残留旧凭据的连接。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { MySQLConnector } from './connector/mysql'
|
|
9
|
+
import { PostgreSQLConnector } from './connector/postgresql'
|
|
10
|
+
import type { DatabaseConnector } from './connector/base'
|
|
11
|
+
import { decryptPassword, resolveEncryptKey } from './crypto'
|
|
12
|
+
import { wrapConnectionError } from './errors'
|
|
13
|
+
import type {
|
|
14
|
+
ConnectionConfig,
|
|
15
|
+
ConnectionTestResult,
|
|
16
|
+
DataSourceConfig,
|
|
17
|
+
DataSourceRecord,
|
|
18
|
+
DataSourceType,
|
|
19
|
+
} from './types'
|
|
20
|
+
|
|
21
|
+
export const DEFAULT_PORTS: Readonly<Record<DataSourceType, number>> = {
|
|
22
|
+
mysql: 3306,
|
|
23
|
+
postgresql: 5432,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** 未保存的连接参数(设置页「先测试再保存」用)。 */
|
|
27
|
+
export interface ConnectionDraft {
|
|
28
|
+
type: DataSourceType
|
|
29
|
+
host: string
|
|
30
|
+
port: number | null
|
|
31
|
+
database: string
|
|
32
|
+
username: string
|
|
33
|
+
password: string
|
|
34
|
+
sslMode?: string | null
|
|
35
|
+
poolMax?: number | null
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const connectors = new Map<string, DatabaseConnector>()
|
|
39
|
+
|
|
40
|
+
export function createConnector(type: DataSourceType, config: ConnectionConfig): DatabaseConnector {
|
|
41
|
+
if (type === 'mysql') return new MySQLConnector(config)
|
|
42
|
+
return new PostgreSQLConnector(config)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** 已登记数据源 → 明文连接参数(解密只发生在这一刻,结果不出本模块)。 */
|
|
46
|
+
export function connectionOf(cfg: DataSourceConfig, record: DataSourceRecord): ConnectionConfig {
|
|
47
|
+
return {
|
|
48
|
+
host: record.host,
|
|
49
|
+
port: record.port,
|
|
50
|
+
database: record.database,
|
|
51
|
+
username: record.username,
|
|
52
|
+
password: decryptPassword(record.passwordEnc, resolveEncryptKey(cfg.datasourceEncryptKey)),
|
|
53
|
+
sslMode: record.sslMode,
|
|
54
|
+
poolMax: record.poolMax,
|
|
55
|
+
connectTimeoutMs: cfg.datasourceConnectTimeoutMs,
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** 未保存草稿 → 明文连接参数。 */
|
|
60
|
+
export function draftToConfig(cfg: DataSourceConfig, draft: ConnectionDraft): ConnectionConfig {
|
|
61
|
+
return {
|
|
62
|
+
host: draft.host,
|
|
63
|
+
port: draft.port ?? DEFAULT_PORTS[draft.type],
|
|
64
|
+
database: draft.database,
|
|
65
|
+
username: draft.username,
|
|
66
|
+
password: draft.password,
|
|
67
|
+
sslMode: draft.sslMode ?? null,
|
|
68
|
+
poolMax: draft.poolMax ?? null,
|
|
69
|
+
connectTimeoutMs: cfg.datasourceConnectTimeoutMs,
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** 取(或建)一个已连通的连接器;连接失败会抛出,并把失败状态留给调用方记录。 */
|
|
74
|
+
export async function connectSource(cfg: DataSourceConfig, record: DataSourceRecord): Promise<DatabaseConnector> {
|
|
75
|
+
const cached = connectors.get(record.id)
|
|
76
|
+
if (cached !== undefined) return cached
|
|
77
|
+
const connector = createConnector(record.type, connectionOf(cfg, record))
|
|
78
|
+
try {
|
|
79
|
+
await connector.connect()
|
|
80
|
+
} catch (error: unknown) {
|
|
81
|
+
throw wrapConnectionError(error,record.host, record.port)
|
|
82
|
+
}
|
|
83
|
+
connectors.set(record.id, connector)
|
|
84
|
+
return connector
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** 用一次性连接器测连接:测完立刻关闭,不进缓存。 */
|
|
88
|
+
export async function testDraft(cfg: DataSourceConfig, draft: ConnectionDraft): Promise<ConnectionTestResult> {
|
|
89
|
+
const connector = createConnector(draft.type, draftToConfig(cfg, draft))
|
|
90
|
+
try {
|
|
91
|
+
return await connector.testConnection()
|
|
92
|
+
} catch (error: unknown) {
|
|
93
|
+
throw wrapConnectionError(error,draft.host, draft.port ?? DEFAULT_PORTS[draft.type])
|
|
94
|
+
} finally {
|
|
95
|
+
await connector.disconnect()
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** 测一条已登记的数据源:命中缓存就复用,否则建一次性连接。 */
|
|
100
|
+
export async function testSource(cfg: DataSourceConfig, record: DataSourceRecord): Promise<ConnectionTestResult> {
|
|
101
|
+
const cached = connectors.get(record.id)
|
|
102
|
+
if (cached !== undefined) return await cached.testConnection()
|
|
103
|
+
const connector = createConnector(record.type, connectionOf(cfg, record))
|
|
104
|
+
try {
|
|
105
|
+
return await connector.testConnection()
|
|
106
|
+
} catch (error: unknown) {
|
|
107
|
+
throw wrapConnectionError(error,record.host, record.port)
|
|
108
|
+
} finally {
|
|
109
|
+
await connector.disconnect()
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export async function closeConnector(sourceId: string): Promise<void> {
|
|
114
|
+
const connector = connectors.get(sourceId)
|
|
115
|
+
if (connector === undefined) return
|
|
116
|
+
connectors.delete(sourceId)
|
|
117
|
+
await connector.disconnect().catch(() => undefined)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export async function closeAllConnectors(): Promise<void> {
|
|
121
|
+
const entries = [...connectors.entries()]
|
|
122
|
+
connectors.clear()
|
|
123
|
+
await Promise.all(entries.map(([, connector]) => connector.disconnect().catch(() => undefined)))
|
|
124
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 数据库连接器抽象基类。
|
|
3
|
+
*
|
|
4
|
+
* 只定义方言无关的能力:连通性测试、schema / 表 / 列元数据、分块取数据。
|
|
5
|
+
* 标识符引号化与类型映射由各子类实现,基类不写任何方言分支。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type {
|
|
9
|
+
ConnectionConfig,
|
|
10
|
+
ConnectionTestResult,
|
|
11
|
+
FetchRange,
|
|
12
|
+
RemoteColumn,
|
|
13
|
+
RemoteTable,
|
|
14
|
+
SchemaSummary,
|
|
15
|
+
} from '../types'
|
|
16
|
+
|
|
17
|
+
export abstract class DatabaseConnector {
|
|
18
|
+
protected readonly config: ConnectionConfig
|
|
19
|
+
|
|
20
|
+
constructor(config: ConnectionConfig) {
|
|
21
|
+
this.config = config
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// ── 连接 ───────────────────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
abstract connect(): Promise<void>
|
|
27
|
+
abstract disconnect(): Promise<void>
|
|
28
|
+
abstract testConnection(): Promise<ConnectionTestResult>
|
|
29
|
+
|
|
30
|
+
// ── 元数据 ─────────────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
abstract getSchemas(): Promise<SchemaSummary[]>
|
|
33
|
+
abstract getTables(schemaName?: string): Promise<RemoteTable[]>
|
|
34
|
+
abstract getTableColumns(tableName: string, schemaName?: string): Promise<RemoteColumn[]>
|
|
35
|
+
abstract getTableData(tableName: string, schemaName?: string, range?: FetchRange): Promise<Record<string, unknown>[]>
|
|
36
|
+
|
|
37
|
+
// ── 方言相关的标识符引号化 ─────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
protected abstract quoteIdent(name: string): string
|
|
40
|
+
|
|
41
|
+
protected tableRef(tableName: string, schemaName?: string | null): string {
|
|
42
|
+
const schema = schemaName === undefined || schemaName === null ? '' : schemaName.trim()
|
|
43
|
+
return schema.length > 0
|
|
44
|
+
? `${this.quoteIdent(schema)}.${this.quoteIdent(tableName)}`
|
|
45
|
+
: this.quoteIdent(tableName)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
protected buildSelectQuery(tableName: string, schemaName?: string | null, range?: FetchRange): string {
|
|
49
|
+
let sql = `SELECT * FROM ${this.tableRef(tableName, schemaName)}`
|
|
50
|
+
if (range?.limit !== undefined) sql += ` LIMIT ${Math.max(0, Math.trunc(range.limit))}`
|
|
51
|
+
if (range?.offset !== undefined && range.offset > 0) sql += ` OFFSET ${Math.trunc(range.offset)}`
|
|
52
|
+
return sql
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MySQL 连接器。
|
|
3
|
+
*
|
|
4
|
+
* 元数据一次查完(`tables` LEFT JOIN `columns`),避免逐表 N+1 查询。
|
|
5
|
+
* 类型映射依据 `information_schema.COLUMN_TYPE`:远端已有权威声明,不再采样推断。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { loadMysqlDriver, type MysqlPoolLike } from '../driver'
|
|
9
|
+
import { DataSourceError, wrapConnectionError } from '../errors'
|
|
10
|
+
import type { ColumnType } from '../../parse'
|
|
11
|
+
import type {
|
|
12
|
+
ConnectionConfig,
|
|
13
|
+
ConnectionTestResult,
|
|
14
|
+
RemoteColumn,
|
|
15
|
+
RemoteTable,
|
|
16
|
+
SchemaSummary,
|
|
17
|
+
} from '../types'
|
|
18
|
+
import { DatabaseConnector } from './base'
|
|
19
|
+
|
|
20
|
+
interface ColumnRow {
|
|
21
|
+
TABLE_NAME: string
|
|
22
|
+
TABLE_ROWS: number | string | null
|
|
23
|
+
COLUMN_NAME: string | null
|
|
24
|
+
COLUMN_TYPE: string | null
|
|
25
|
+
IS_NULLABLE: string | null
|
|
26
|
+
COLUMN_COMMENT: string | null
|
|
27
|
+
COLUMN_KEY: string | null
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const TABLE_COLUMNS_SQL = `
|
|
31
|
+
SELECT t.TABLE_NAME, t.TABLE_ROWS,
|
|
32
|
+
c.COLUMN_NAME, c.COLUMN_TYPE, c.IS_NULLABLE, c.COLUMN_COMMENT, c.COLUMN_KEY
|
|
33
|
+
FROM information_schema.tables t
|
|
34
|
+
LEFT JOIN information_schema.columns c
|
|
35
|
+
ON c.TABLE_SCHEMA = t.TABLE_SCHEMA AND c.TABLE_NAME = t.TABLE_NAME
|
|
36
|
+
WHERE t.TABLE_SCHEMA = ? AND t.TABLE_TYPE = 'BASE TABLE'
|
|
37
|
+
ORDER BY t.TABLE_NAME, c.ORDINAL_POSITION`
|
|
38
|
+
|
|
39
|
+
const ONE_TABLE_COLUMNS_SQL = `
|
|
40
|
+
SELECT c.COLUMN_NAME, c.COLUMN_TYPE, c.IS_NULLABLE, c.COLUMN_COMMENT, c.COLUMN_KEY
|
|
41
|
+
FROM information_schema.columns c
|
|
42
|
+
WHERE c.TABLE_SCHEMA = ? AND c.TABLE_NAME = ?
|
|
43
|
+
ORDER BY c.ORDINAL_POSITION`
|
|
44
|
+
|
|
45
|
+
function poolOptions(config: ConnectionConfig): Record<string, unknown> {
|
|
46
|
+
const options: Record<string, unknown> = {
|
|
47
|
+
host: config.host,
|
|
48
|
+
port: config.port,
|
|
49
|
+
database: config.database,
|
|
50
|
+
user: config.username,
|
|
51
|
+
password: config.password,
|
|
52
|
+
connectionLimit: config.poolMax ?? 5,
|
|
53
|
+
waitForConnections: true,
|
|
54
|
+
queueLimit: 0,
|
|
55
|
+
connectTimeout: config.connectTimeoutMs,
|
|
56
|
+
enableKeepAlive: true,
|
|
57
|
+
keepAliveInitialDelay: 30000,
|
|
58
|
+
}
|
|
59
|
+
if (config.sslMode !== null && config.sslMode !== 'disabled') {
|
|
60
|
+
options.ssl = { rejectUnauthorized: config.sslMode === 'verify_ca' || config.sslMode === 'verify_identity' }
|
|
61
|
+
}
|
|
62
|
+
return options
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function mapMySQLType(columnType: string): ColumnType {
|
|
66
|
+
const type = columnType.toLowerCase()
|
|
67
|
+
if (type === 'tinyint(1)' || type === 'bool' || type === 'boolean') return 'boolean'
|
|
68
|
+
if (/^(tinyint|smallint|mediumint|int|integer|bigint|decimal|dec|numeric|float|double|real|fixed)/.test(type)) {
|
|
69
|
+
return 'numeric'
|
|
70
|
+
}
|
|
71
|
+
if (type.startsWith('date') || type.startsWith('time') || type.startsWith('year')) return 'date'
|
|
72
|
+
return 'text'
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function toRemoteColumn(row: ColumnRow): RemoteColumn {
|
|
76
|
+
const comment = row.COLUMN_COMMENT === null ? null : String(row.COLUMN_COMMENT)
|
|
77
|
+
return {
|
|
78
|
+
name: String(row.COLUMN_NAME ?? ''),
|
|
79
|
+
nativeType: mapMySQLType(String(row.COLUMN_TYPE ?? '')),
|
|
80
|
+
nullable: row.IS_NULLABLE === 'YES',
|
|
81
|
+
comment: comment === null || comment.length === 0 ? null : comment,
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function groupTables(rows: readonly ColumnRow[], schemaName: string): RemoteTable[] {
|
|
86
|
+
const byName = new Map<string, RemoteTable>()
|
|
87
|
+
for (const row of rows) {
|
|
88
|
+
const tableName = row.TABLE_NAME
|
|
89
|
+
let table = byName.get(tableName)
|
|
90
|
+
if (table === undefined) {
|
|
91
|
+
table = {
|
|
92
|
+
tableName,
|
|
93
|
+
schemaName,
|
|
94
|
+
rowCount: Number(row.TABLE_ROWS ?? -1),
|
|
95
|
+
primaryKey: null,
|
|
96
|
+
columns: [],
|
|
97
|
+
}
|
|
98
|
+
byName.set(tableName, table)
|
|
99
|
+
}
|
|
100
|
+
if (row.COLUMN_NAME !== null) {
|
|
101
|
+
const column = toRemoteColumn(row)
|
|
102
|
+
table.columns.push(column)
|
|
103
|
+
if (row.COLUMN_KEY === 'PRI' && table.primaryKey === null) table.primaryKey = column.name
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return [...byName.values()]
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export class MySQLConnector extends DatabaseConnector {
|
|
110
|
+
private pool: MysqlPoolLike | null = null
|
|
111
|
+
|
|
112
|
+
async connect(): Promise<void> {
|
|
113
|
+
if (this.pool !== null) return
|
|
114
|
+
const mysql = await loadMysqlDriver()
|
|
115
|
+
this.pool = mysql.createPool(poolOptions(this.config))
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async disconnect(): Promise<void> {
|
|
119
|
+
const pool = this.pool
|
|
120
|
+
this.pool = null
|
|
121
|
+
if (pool !== null) await pool.end()
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async testConnection(): Promise<ConnectionTestResult> {
|
|
125
|
+
const startedAt = Date.now()
|
|
126
|
+
try {
|
|
127
|
+
const mysql = await loadMysqlDriver()
|
|
128
|
+
const conn = await mysql.createConnection({
|
|
129
|
+
...poolOptions(this.config),
|
|
130
|
+
connectionLimit: undefined,
|
|
131
|
+
connectTimeout: this.config.connectTimeoutMs,
|
|
132
|
+
})
|
|
133
|
+
const [rows] = await conn.execute('SELECT VERSION() AS version')
|
|
134
|
+
await conn.end()
|
|
135
|
+
const version = (rows as { version?: unknown }[])[0]?.version
|
|
136
|
+
return { success: true, latency: Date.now() - startedAt, version: version === undefined ? null : String(version), error: null }
|
|
137
|
+
} catch (error) {
|
|
138
|
+
return { success: false, latency: Date.now() - startedAt, version: null, error: describe(error) }
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async getSchemas(): Promise<SchemaSummary[]> {
|
|
143
|
+
const rows = await this.query(
|
|
144
|
+
'SELECT COUNT(*) AS total FROM information_schema.tables WHERE table_schema = ? AND table_type = \'BASE TABLE\'',
|
|
145
|
+
[this.config.database],
|
|
146
|
+
)
|
|
147
|
+
const total = Number((rows[0] as { total?: unknown } | undefined)?.total ?? 0)
|
|
148
|
+
return [{ schemaName: this.config.database, tableCount: total }]
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async getTables(schemaName?: string): Promise<RemoteTable[]> {
|
|
152
|
+
void schemaName
|
|
153
|
+
const rows = await this.query(TABLE_COLUMNS_SQL, [this.config.database])
|
|
154
|
+
return groupTables(rows as ColumnRow[], this.config.database)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async getTableColumns(tableName: string, schemaName?: string): Promise<RemoteColumn[]> {
|
|
158
|
+
void schemaName
|
|
159
|
+
const rows = await this.query(ONE_TABLE_COLUMNS_SQL, [this.config.database, tableName])
|
|
160
|
+
return (rows as ColumnRow[]).map(toRemoteColumn)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async getTableData(tableName: string, schemaName?: string, range?: { limit?: number; offset?: number }): Promise<Record<string, unknown>[]> {
|
|
164
|
+
return await this.query(this.buildSelectQuery(tableName, schemaName, range)) as Record<string, unknown>[]
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
protected quoteIdent(name: string): string {
|
|
168
|
+
return `\`${name.replace(/`/g, '``')}\``
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
private async query(sql: string, params: unknown[] = []): Promise<unknown[]> {
|
|
172
|
+
const pool = this.pool
|
|
173
|
+
if (pool === null) throw new DataSourceError('UNREACHABLE', 'MySQL 连接尚未建立,请先调用 connect()')
|
|
174
|
+
try {
|
|
175
|
+
const [rows] = await pool.execute(sql, params)
|
|
176
|
+
return (rows ?? []) as unknown[]
|
|
177
|
+
} catch (error: unknown) {
|
|
178
|
+
throw wrapConnectionError(error, this.config.host, this.config.port)
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function describe(error: unknown): string {
|
|
184
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
185
|
+
// 去掉驱动可能附带的连接串(含密码)。
|
|
186
|
+
return message.replace(/(mysql:\/\/)[^\s'"]*/gi, '$1***').slice(0, 300)
|
|
187
|
+
}
|