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/parse.ts
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 文件解析与类型推断 —— 移植自 `agentic-data-mini` 的 `src/lib/utils/fileParser.ts`。
|
|
3
|
+
*
|
|
4
|
+
* 保留的关键行为(设计文档 §4):
|
|
5
|
+
* - XLSX:`XLSX.read({ type: 'buffer', cellDates: true })` + `sheet_to_json({ raw: true, defval: null })`,
|
|
6
|
+
* Date → `YYYY-MM-DD`;默认第一个 sheet。
|
|
7
|
+
* - CSV:`Papa.parse({ header: true, skipEmptyLines: true, dynamicTyping: true, transformHeader: trim })`。
|
|
8
|
+
* - 列名:**保留原名(含中文)**,SQL 中双引号包裹;空列名 → `column`;重名追加 `_2`。
|
|
9
|
+
* - 类型推断:编码/号码类列名强制 text;13+ 位整数或 `xE+12` 科学计数法强制 text(防精度丢失);
|
|
10
|
+
* 其余 numeric>80% / boolean>90% / date 正则>70%。
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import Papa from 'papaparse'
|
|
14
|
+
import * as XLSX from 'xlsx'
|
|
15
|
+
|
|
16
|
+
export type ColumnType = 'text' | 'numeric' | 'boolean' | 'date'
|
|
17
|
+
|
|
18
|
+
export interface ColumnInfo {
|
|
19
|
+
/** 原始表头(如「物料编码」)。 */
|
|
20
|
+
name: string
|
|
21
|
+
/** SQL 中使用的列名(与 name 相同,或去重后加 `_2`)。 */
|
|
22
|
+
sanitizedName: string
|
|
23
|
+
type: ColumnType
|
|
24
|
+
nullable: boolean
|
|
25
|
+
sample: unknown[]
|
|
26
|
+
description?: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface ParsedFile {
|
|
30
|
+
headers: string[]
|
|
31
|
+
rows: Record<string, unknown>[]
|
|
32
|
+
rowCount: number
|
|
33
|
+
columns: ColumnInfo[]
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const DEFAULT_SAMPLE_ROWS = 100
|
|
37
|
+
|
|
38
|
+
/** 列名消毒:trim,空列名回落 `column`,其余保留原名。 */
|
|
39
|
+
export function sanitizeColumnName(name: string): string {
|
|
40
|
+
const trimmed = name.trim()
|
|
41
|
+
return trimmed.length === 0 ? 'column' : trimmed
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** 重名列追加 `_2` / `_3`(原地修改)。 */
|
|
45
|
+
export function deduplicateColumnNames(columns: ColumnInfo[]): void {
|
|
46
|
+
const nameCounts = new Map<string, number>()
|
|
47
|
+
for (const column of columns) {
|
|
48
|
+
nameCounts.set(column.sanitizedName, (nameCounts.get(column.sanitizedName) ?? 0) + 1)
|
|
49
|
+
}
|
|
50
|
+
const seen = new Map<string, number>()
|
|
51
|
+
for (const column of columns) {
|
|
52
|
+
const name = column.sanitizedName
|
|
53
|
+
if ((nameCounts.get(name) ?? 0) > 1) {
|
|
54
|
+
const index = (seen.get(name) ?? 0) + 1
|
|
55
|
+
seen.set(name, index)
|
|
56
|
+
column.sanitizedName = `${name}_${index}`
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function generateColumnDescription(name: string, type: ColumnType, sample: unknown[]): string {
|
|
62
|
+
const typeDesc: Record<ColumnType, string> = {
|
|
63
|
+
text: '文本字段',
|
|
64
|
+
numeric: '数值字段',
|
|
65
|
+
boolean: '布尔字段',
|
|
66
|
+
date: '日期字段',
|
|
67
|
+
}
|
|
68
|
+
let description: string = typeDesc[type] ?? '字段'
|
|
69
|
+
const first = sample[0]
|
|
70
|
+
if (first !== null && first !== undefined) {
|
|
71
|
+
description += `,示例: "${String(first).slice(0, 30)}"`
|
|
72
|
+
}
|
|
73
|
+
void name
|
|
74
|
+
return description
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const CHINESE_CODE_PATTERNS = ['编码', '编号', '代码', '代号', '账号', '证件号', '邮编', '区号']
|
|
78
|
+
const ENGLISH_CODE_PATTERNS = ['code', 'sku', 'ean', 'upc', 'isbn', 'issn', 'postal', 'zip']
|
|
79
|
+
|
|
80
|
+
/** 编码/号码类列:即使值是数字也应存 text(无数学含义)。 */
|
|
81
|
+
export function isCodeOrIdField(columnName: string): boolean {
|
|
82
|
+
const name = columnName.toLowerCase().trim()
|
|
83
|
+
for (const pattern of CHINESE_CODE_PATTERNS) {
|
|
84
|
+
if (name.includes(pattern)) return true
|
|
85
|
+
}
|
|
86
|
+
for (const pattern of ENGLISH_CODE_PATTERNS) {
|
|
87
|
+
if (new RegExp(`(^|_)${pattern}(_|$)`, 'i').test(name) || name === pattern) return true
|
|
88
|
+
}
|
|
89
|
+
if (name.includes('phone') || name.includes('tel') || name.includes('mobile')
|
|
90
|
+
|| name.includes('电话') || name.includes('手机')) {
|
|
91
|
+
return true
|
|
92
|
+
}
|
|
93
|
+
return false
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** 大数判定:13+ 位整数或 `1.78E+12` 这类科学计数法。 */
|
|
97
|
+
export function isLargeNumber(value: unknown): boolean {
|
|
98
|
+
if (value === null || value === undefined || value === '') return false
|
|
99
|
+
if (typeof value === 'number' && Number.isInteger(value) && value >= 1e12) return true
|
|
100
|
+
const text = String(value)
|
|
101
|
+
if (/^\d{13,}$/.test(text)) return true
|
|
102
|
+
const sci = text.match(/^(\d+\.?\d*)E\+(\d+)$/i)
|
|
103
|
+
if (sci !== null && Number.parseInt(sci[2]!, 10) >= 12) return true
|
|
104
|
+
return false
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** 把行内的 Date 对象格式化为 `YYYY-MM-DD`。 */
|
|
108
|
+
function formatDateValues(rows: Record<string, unknown>[]): void {
|
|
109
|
+
for (const row of rows) {
|
|
110
|
+
for (const key of Object.keys(row)) {
|
|
111
|
+
if (row[key] instanceof Date) {
|
|
112
|
+
row[key] = (row[key] as Date).toISOString().slice(0, 10)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** 从采样值 + 列名推断列类型;列名优先级最高。 */
|
|
119
|
+
export function inferColumnType(values: unknown[], columnName?: string): ColumnType {
|
|
120
|
+
if (columnName !== undefined && isCodeOrIdField(columnName)) return 'text'
|
|
121
|
+
const nonNull = values.filter(value => value !== null && value !== undefined && value !== '')
|
|
122
|
+
if (nonNull.length === 0) return 'text'
|
|
123
|
+
if (nonNull.some(value => isLargeNumber(value))) return 'text'
|
|
124
|
+
|
|
125
|
+
const numericCount = nonNull.filter((value) => {
|
|
126
|
+
const n = Number(value)
|
|
127
|
+
return !Number.isNaN(n) && value !== '' && value !== true && value !== false
|
|
128
|
+
}).length
|
|
129
|
+
if (numericCount / nonNull.length > 0.8) return 'numeric'
|
|
130
|
+
|
|
131
|
+
const boolCount = nonNull.filter(
|
|
132
|
+
value => value === true || value === false || value === 'true' || value === 'false' || value === '1' || value === '0',
|
|
133
|
+
).length
|
|
134
|
+
if (boolCount / nonNull.length > 0.9) return 'boolean'
|
|
135
|
+
|
|
136
|
+
const datePattern = /^\d{4}[-/]\d{2}[-/]\d{2}|^\d{2}[-/]\d{2}[-/]\d{4}/
|
|
137
|
+
const dateCount = nonNull.filter(value => typeof value === 'string' && datePattern.test(value)).length
|
|
138
|
+
if (dateCount / nonNull.length > 0.7) return 'date'
|
|
139
|
+
|
|
140
|
+
return 'text'
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function buildColumns(
|
|
144
|
+
headers: string[],
|
|
145
|
+
rows: Record<string, unknown>[],
|
|
146
|
+
sampleRows: number,
|
|
147
|
+
): ColumnInfo[] {
|
|
148
|
+
const columns: ColumnInfo[] = headers.map((header) => {
|
|
149
|
+
const sample = rows.map(row => row[header]).slice(0, sampleRows)
|
|
150
|
+
const type = inferColumnType(sample, header)
|
|
151
|
+
const allValues = rows.map(row => row[header])
|
|
152
|
+
const nullCount = allValues.filter(value => value === null || value === undefined || value === '').length
|
|
153
|
+
const sanitizedName = sanitizeColumnName(header)
|
|
154
|
+
return {
|
|
155
|
+
name: header,
|
|
156
|
+
sanitizedName,
|
|
157
|
+
type,
|
|
158
|
+
// 有空值或采样不足都按可空处理。
|
|
159
|
+
nullable: nullCount > 0 || allValues.length < rows.length,
|
|
160
|
+
sample: sample.slice(0, 5),
|
|
161
|
+
description: generateColumnDescription(header, type, sample.slice(0, 5)),
|
|
162
|
+
}
|
|
163
|
+
})
|
|
164
|
+
deduplicateColumnNames(columns)
|
|
165
|
+
return columns
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** 用去重后的列名重写行键。 */
|
|
169
|
+
function renameRows(headers: string[], rows: Record<string, unknown>[], columns: ColumnInfo[]): Record<string, unknown>[] {
|
|
170
|
+
return rows.map((row) => {
|
|
171
|
+
const next: Record<string, unknown> = {}
|
|
172
|
+
headers.forEach((header, index) => {
|
|
173
|
+
next[columns[index]!.sanitizedName] = row[header]
|
|
174
|
+
})
|
|
175
|
+
return next
|
|
176
|
+
})
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function parseCSV(buffer: Buffer, sampleRows: number = DEFAULT_SAMPLE_ROWS): ParsedFile {
|
|
180
|
+
const content = buffer.toString('utf-8')
|
|
181
|
+
const result = Papa.parse<Record<string, unknown>>(content, {
|
|
182
|
+
header: true,
|
|
183
|
+
skipEmptyLines: true,
|
|
184
|
+
dynamicTyping: true,
|
|
185
|
+
transformHeader: header => header.trim(),
|
|
186
|
+
})
|
|
187
|
+
if (result.errors.length > 0 && result.data.length === 0) {
|
|
188
|
+
throw new Error(`CSV parse error: ${result.errors[0]?.message ?? 'unknown'}`)
|
|
189
|
+
}
|
|
190
|
+
const headers = result.meta.fields ?? []
|
|
191
|
+
const rows = result.data
|
|
192
|
+
if (headers.length === 0) return { headers, rows: [], rowCount: 0, columns: [] }
|
|
193
|
+
const columns = buildColumns(headers, rows, sampleRows)
|
|
194
|
+
return { headers, rows: renameRows(headers, rows, columns), rowCount: rows.length, columns }
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function parseXLSX(buffer: Buffer, sheetName?: string, sampleRows: number = DEFAULT_SAMPLE_ROWS): ParsedFile {
|
|
198
|
+
const workbook = XLSX.read(buffer, { type: 'buffer', cellDates: true })
|
|
199
|
+
const selected = sheetName === undefined || sheetName.trim().length === 0
|
|
200
|
+
? workbook.SheetNames[0]
|
|
201
|
+
: workbook.SheetNames.find(name => name === sheetName || name.toLowerCase() === sheetName.trim().toLowerCase())
|
|
202
|
+
if (selected === undefined) {
|
|
203
|
+
throw new Error(`工作表不存在:${sheetName}(可用:${workbook.SheetNames.join(', ') || '(空)'})`)
|
|
204
|
+
}
|
|
205
|
+
const worksheet = workbook.Sheets[selected]
|
|
206
|
+
if (worksheet === undefined) throw new Error(`工作表不可读取:${selected}`)
|
|
207
|
+
const rows = XLSX.utils.sheet_to_json<Record<string, unknown>>(worksheet, { raw: true, defval: null })
|
|
208
|
+
formatDateValues(rows)
|
|
209
|
+
if (rows.length === 0) return { headers: [], rows: [], rowCount: 0, columns: [] }
|
|
210
|
+
const headers = Object.keys(rows[0] as Record<string, unknown>)
|
|
211
|
+
const columns = buildColumns(headers, rows, sampleRows)
|
|
212
|
+
return { headers, rows: renameRows(headers, rows, columns), rowCount: rows.length, columns }
|
|
213
|
+
}
|
package/src/preview.ts
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 模型可见片段的构造 —— 设计文档 §5.3。
|
|
3
|
+
*
|
|
4
|
+
* 这里只做两件事:把结果裁成「前几行 + 可选尾几行」的预览,以及基于**全量数据**
|
|
5
|
+
* 生成类型化摘要(不是只看预览行)。摘要让模型在不读全表的情况下也能下结论,
|
|
6
|
+
* 从而不再需要逐页翻数据。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { Database, Row } from './db'
|
|
10
|
+
import type { ColumnType } from './parse'
|
|
11
|
+
import { quoteIdentifier } from './sql'
|
|
12
|
+
|
|
13
|
+
export type PreviewStrategy = 'head' | 'head-tail'
|
|
14
|
+
|
|
15
|
+
export interface PreviewOptions {
|
|
16
|
+
previewRows: number
|
|
17
|
+
previewStrategy: PreviewStrategy
|
|
18
|
+
previewColumns: number
|
|
19
|
+
summaryEnabled: boolean
|
|
20
|
+
/** 参与摘要的列数上限(宽表保护)。 */
|
|
21
|
+
summaryMaxColumns: number
|
|
22
|
+
/** 文本列取 top 值的列数上限。 */
|
|
23
|
+
summaryMaxTextColumns: number
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** 一列的统计摘要;字段按类型给出,缺失即不适用。 */
|
|
27
|
+
export interface ColumnSummary {
|
|
28
|
+
name: string
|
|
29
|
+
type: ColumnType
|
|
30
|
+
/** 非空值个数。 */
|
|
31
|
+
count: number
|
|
32
|
+
min?: number | string | null
|
|
33
|
+
max?: number | string | null
|
|
34
|
+
avg?: number | null
|
|
35
|
+
sum?: number | null
|
|
36
|
+
/** distinct 取值数(文本列)。 */
|
|
37
|
+
distinct?: number
|
|
38
|
+
/** 高频取值(文本列,distinct 过多时省略)。 */
|
|
39
|
+
top?: { value: string; count: number }[]
|
|
40
|
+
/** 布尔列为真的行数。 */
|
|
41
|
+
trueCount?: number
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface PreviewSlice {
|
|
45
|
+
rows: Row[]
|
|
46
|
+
columns: string[]
|
|
47
|
+
/** 是否折叠了列(结果列数超过 previewColumns)。 */
|
|
48
|
+
columnTruncated: boolean
|
|
49
|
+
/** 头尾之间是否跳过了行。 */
|
|
50
|
+
gap: boolean
|
|
51
|
+
/** 被跳过的行数。 */
|
|
52
|
+
skipped: number
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** 头/尾各取多少行;`tail > 0` 时渲染层会在中间插入省略提示。 */
|
|
56
|
+
export function previewSlicePlan(totalRows: number, options: PreviewOptions): { head: number; tail: number } {
|
|
57
|
+
const limit = Math.max(1, Math.floor(options.previewRows))
|
|
58
|
+
const rows = Math.max(0, Math.floor(totalRows))
|
|
59
|
+
if (rows <= limit || options.previewStrategy !== 'head-tail') {
|
|
60
|
+
return { head: Math.min(limit, rows), tail: 0 }
|
|
61
|
+
}
|
|
62
|
+
const tail = Math.ceil(limit / 2)
|
|
63
|
+
const head = Math.max(1, limit - tail)
|
|
64
|
+
return { head, tail }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** 裁剪预览列(超宽表只保留前 N 列)。 */
|
|
68
|
+
export function previewColumns(columns: string[], options: PreviewOptions): { columns: string[]; truncated: boolean } {
|
|
69
|
+
const limit = Math.max(1, Math.floor(options.previewColumns))
|
|
70
|
+
if (columns.length <= limit) return { columns, truncated: false }
|
|
71
|
+
return { columns: columns.slice(0, limit), truncated: true }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** 拼装一次预览切片(头行 + 可选尾行),同时报告跳过了多少行。 */
|
|
75
|
+
export function buildPreviewSlice(
|
|
76
|
+
headRows: Row[],
|
|
77
|
+
tailRows: Row[],
|
|
78
|
+
columns: string[],
|
|
79
|
+
totalRows: number,
|
|
80
|
+
options: PreviewOptions,
|
|
81
|
+
): PreviewSlice {
|
|
82
|
+
const { head, tail } = previewSlicePlan(totalRows, options)
|
|
83
|
+
const rows = tail > 0 ? [...headRows, ...tailRows] : headRows
|
|
84
|
+
const skipped = Math.max(0, Math.floor(totalRows) - rows.length)
|
|
85
|
+
const projected = previewColumns(columns, options)
|
|
86
|
+
const sliced = projected.columns.length === columns.length
|
|
87
|
+
? rows
|
|
88
|
+
: rows.map(row => {
|
|
89
|
+
const picked: Row = {}
|
|
90
|
+
for (const name of projected.columns) picked[name] = row[name]
|
|
91
|
+
return picked
|
|
92
|
+
})
|
|
93
|
+
return {
|
|
94
|
+
rows: sliced,
|
|
95
|
+
columns: projected.columns,
|
|
96
|
+
columnTruncated: projected.truncated,
|
|
97
|
+
gap: tail > 0 && skipped > 0,
|
|
98
|
+
skipped,
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** 单元格按字符数截断,返回文本与被截断标记。 */
|
|
103
|
+
export function truncateCell(value: unknown, max: number): { text: string; truncated: boolean } {
|
|
104
|
+
if (value === null || value === undefined) return { text: '', truncated: false }
|
|
105
|
+
if (value instanceof Uint8Array) return { text: `<${value.length} bytes>`, truncated: false }
|
|
106
|
+
const raw = typeof value === 'object'
|
|
107
|
+
? safeStringify(value)
|
|
108
|
+
: String(value)
|
|
109
|
+
const flat = raw.replace(/\r?\n/g, ' ')
|
|
110
|
+
if (flat.length <= max) return { text: flat, truncated: false }
|
|
111
|
+
return { text: `${flat.slice(0, Math.max(0, max - 1))}…`, truncated: true }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function safeStringify(value: unknown): string {
|
|
115
|
+
try {
|
|
116
|
+
return JSON.stringify(value) ?? ''
|
|
117
|
+
} catch {
|
|
118
|
+
return '[object]'
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function toNumber(value: unknown): number | null {
|
|
123
|
+
if (value === null || value === undefined) return null
|
|
124
|
+
const n = typeof value === 'number' ? value : Number(value)
|
|
125
|
+
return Number.isFinite(n) ? n : null
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* 基于全表生成列摘要:numeric/date/boolean 走一次聚合扫描,
|
|
130
|
+
* 文本列额外做 distinct 计数与 top 取值(受列数上限保护)。
|
|
131
|
+
*/
|
|
132
|
+
export async function summarizeColumns(
|
|
133
|
+
db: Database,
|
|
134
|
+
tableName: string,
|
|
135
|
+
columns: { name: string; type: ColumnType }[],
|
|
136
|
+
options: PreviewOptions,
|
|
137
|
+
): Promise<ColumnSummary[]> {
|
|
138
|
+
if (!options.summaryEnabled || columns.length === 0) return []
|
|
139
|
+
const targets = columns.slice(0, Math.max(1, Math.floor(options.summaryMaxColumns)))
|
|
140
|
+
const table = quoteIdentifier(tableName)
|
|
141
|
+
|
|
142
|
+
const projections: string[] = ['COUNT(*) AS _rows']
|
|
143
|
+
targets.forEach((column, index) => {
|
|
144
|
+
const col = quoteIdentifier(column.name)
|
|
145
|
+
const base = `c${index}`
|
|
146
|
+
if (column.type === 'boolean') {
|
|
147
|
+
projections.push(`COUNT(${col}) AS cnt_${base}`)
|
|
148
|
+
projections.push(`SUM(CASE WHEN ${col} = 1 THEN 1 ELSE 0 END) AS true_${base}`)
|
|
149
|
+
return
|
|
150
|
+
}
|
|
151
|
+
projections.push(`COUNT(${col}) AS cnt_${base}`)
|
|
152
|
+
projections.push(`MIN(${col}) AS min_${base}`)
|
|
153
|
+
projections.push(`MAX(${col}) AS max_${base}`)
|
|
154
|
+
if (column.type === 'numeric') {
|
|
155
|
+
projections.push(`AVG(${col}) AS avg_${base}`)
|
|
156
|
+
projections.push(`SUM(${col}) AS sum_${base}`)
|
|
157
|
+
}
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
const aggregate = await db.prepare(`SELECT ${projections.join(', ')} FROM ${table}`).get()
|
|
161
|
+
const totalRows = toNumber(aggregate?._rows) ?? 0
|
|
162
|
+
|
|
163
|
+
const summaries: ColumnSummary[] = targets.map((column, index) => {
|
|
164
|
+
const base = `c${index}`
|
|
165
|
+
const count = toNumber(aggregate?.[`cnt_${base}`]) ?? 0
|
|
166
|
+
const summary: ColumnSummary = { name: column.name, type: column.type, count }
|
|
167
|
+
if (column.type === 'boolean') {
|
|
168
|
+
summary.trueCount = toNumber(aggregate?.[`true_${base}`]) ?? 0
|
|
169
|
+
return summary
|
|
170
|
+
}
|
|
171
|
+
const min = aggregate?.[`min_${base}`]
|
|
172
|
+
const max = aggregate?.[`max_${base}`]
|
|
173
|
+
if (min !== null && min !== undefined) summary.min = min as number | string
|
|
174
|
+
if (max !== null && max !== undefined) summary.max = max as number | string
|
|
175
|
+
if (column.type === 'numeric') {
|
|
176
|
+
summary.avg = toNumber(aggregate?.[`avg_${base}`])
|
|
177
|
+
summary.sum = toNumber(aggregate?.[`sum_${base}`])
|
|
178
|
+
}
|
|
179
|
+
return summary
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
// 文本列的取值分布:每列两次查询,受 summaryMaxTextColumns 限制。
|
|
183
|
+
const textColumns = summaries.filter(summary => summary.type === 'text')
|
|
184
|
+
for (const summary of textColumns.slice(0, Math.max(0, Math.floor(options.summaryMaxTextColumns)))) {
|
|
185
|
+
const col = quoteIdentifier(summary.name)
|
|
186
|
+
const distinctRow = await db.prepare(`SELECT COUNT(DISTINCT ${col}) AS d FROM ${table}`).get()
|
|
187
|
+
const distinct = toNumber(distinctRow?.d) ?? 0
|
|
188
|
+
summary.distinct = distinct
|
|
189
|
+
// 高基数列(编码/姓名/备注)给 top 值没有信息量,还浪费上下文。
|
|
190
|
+
if (distinct === 0 || distinct > Math.max(20, totalRows * 0.3)) continue
|
|
191
|
+
const topRows = await db
|
|
192
|
+
.prepare(`SELECT ${col} AS v, COUNT(*) AS n FROM ${table} WHERE ${col} IS NOT NULL GROUP BY ${col} ORDER BY n DESC, v ASC LIMIT 5`)
|
|
193
|
+
.all()
|
|
194
|
+
summary.top = topRows.map(row => ({ value: String(row.v), count: toNumber(row.n) ?? 0 }))
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return summaries
|
|
198
|
+
}
|