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.
Files changed (89) hide show
  1. package/README.md +332 -0
  2. package/cordis.patch.yml +7 -0
  3. package/lib/admin-contract.d.ts +274 -0
  4. package/lib/admin-http.d.ts +31 -0
  5. package/lib/admin-validate.d.ts +87 -0
  6. package/lib/admin.d.ts +60 -0
  7. package/lib/client.js +2969 -0
  8. package/lib/client.js.map +1 -0
  9. package/lib/datasource/columns.d.ts +13 -0
  10. package/lib/datasource/connection.d.ts +33 -0
  11. package/lib/datasource/connector/base.d.ts +21 -0
  12. package/lib/datasource/connector/mysql.d.ts +23 -0
  13. package/lib/datasource/connector/postgresql.d.ts +23 -0
  14. package/lib/datasource/crypto.d.ts +13 -0
  15. package/lib/datasource/driver.d.ts +39 -0
  16. package/lib/datasource/errors.d.ts +25 -0
  17. package/lib/datasource/importer.d.ts +38 -0
  18. package/lib/datasource/source-sql.d.ts +11 -0
  19. package/lib/datasource/source-store.d.ts +28 -0
  20. package/lib/datasource/types.d.ts +119 -0
  21. package/lib/db.d.ts +91 -0
  22. package/lib/http-common.d.ts +31 -0
  23. package/lib/http.d.ts +13 -0
  24. package/lib/index.d.ts +50 -0
  25. package/lib/index.js +5735 -0
  26. package/lib/parse.d.ts +41 -0
  27. package/lib/preview.d.ts +75 -0
  28. package/lib/render.d.ts +100 -0
  29. package/lib/scope-registry.d.ts +47 -0
  30. package/lib/scope.d.ts +56 -0
  31. package/lib/sql.d.ts +133 -0
  32. package/lib/store.d.ts +174 -0
  33. package/lib/table.d.ts +38 -0
  34. package/lib/tooling.d.ts +267 -0
  35. package/lib/tools/datasource.d.ts +16 -0
  36. package/lib/tools/import.d.ts +21 -0
  37. package/lib/tools/read.d.ts +77 -0
  38. package/lib/tools/registry.d.ts +12 -0
  39. package/lib/tools/write.d.ts +37 -0
  40. package/lib/view.d.ts +138 -0
  41. package/package.json +61 -0
  42. package/src/admin-contract.ts +351 -0
  43. package/src/admin-http.ts +254 -0
  44. package/src/admin-validate.ts +393 -0
  45. package/src/admin.ts +558 -0
  46. package/src/client/index.ts +403 -0
  47. package/src/client/settings/CreateForm.tsx +186 -0
  48. package/src/client/settings/DataSourceForm.tsx +278 -0
  49. package/src/client/settings/DataSourcesPanel.tsx +301 -0
  50. package/src/client/settings/DatasetEditor.tsx +245 -0
  51. package/src/client/settings/DatasetTable.tsx +110 -0
  52. package/src/client/settings/DatasetsPanel.tsx +207 -0
  53. package/src/client/settings/RowsPanel.tsx +196 -0
  54. package/src/client/settings/Section.tsx +41 -0
  55. package/src/client/settings/SourceTablesPanel.tsx +226 -0
  56. package/src/client/settings/api.ts +154 -0
  57. package/src/client/settings/styles.ts +125 -0
  58. package/src/datasource/columns.ts +56 -0
  59. package/src/datasource/connection.ts +124 -0
  60. package/src/datasource/connector/base.ts +54 -0
  61. package/src/datasource/connector/mysql.ts +187 -0
  62. package/src/datasource/connector/postgresql.ts +212 -0
  63. package/src/datasource/crypto.ts +61 -0
  64. package/src/datasource/driver.ts +108 -0
  65. package/src/datasource/errors.ts +69 -0
  66. package/src/datasource/importer.ts +262 -0
  67. package/src/datasource/index.ts +62 -0
  68. package/src/datasource/source-sql.ts +64 -0
  69. package/src/datasource/source-store.ts +152 -0
  70. package/src/datasource/types.ts +133 -0
  71. package/src/db.ts +277 -0
  72. package/src/http-common.ts +91 -0
  73. package/src/http.ts +130 -0
  74. package/src/index.ts +486 -0
  75. package/src/parse.ts +213 -0
  76. package/src/preview.ts +198 -0
  77. package/src/render.ts +294 -0
  78. package/src/scope-registry.ts +111 -0
  79. package/src/scope.ts +159 -0
  80. package/src/sql.ts +491 -0
  81. package/src/store.ts +412 -0
  82. package/src/table.ts +160 -0
  83. package/src/tooling.ts +551 -0
  84. package/src/tools/datasource.ts +378 -0
  85. package/src/tools/import.ts +282 -0
  86. package/src/tools/read.ts +536 -0
  87. package/src/tools/registry.ts +56 -0
  88. package/src/tools/write.ts +241 -0
  89. 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
+ }