@hedeqiang/dsh-nl2sql 0.0.0-stage → 0.11.1
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/CHANGELOG.md +52 -0
- package/LICENSE +21 -0
- package/README.en.md +63 -0
- package/README.md +88 -2
- package/analysis.js +888 -0
- package/channels/dingtalk.js +574 -0
- package/channels/http.js +63 -0
- package/channels/index.js +138 -0
- package/channels/lark.js +1180 -0
- package/channels/message.js +267 -0
- package/channels/telegram.js +663 -0
- package/channels/wecom.js +585 -0
- package/cordis.patch.yml +68 -0
- package/docs/CHANNELS.md +184 -0
- package/drivers.js +694 -0
- package/filters.js +237 -0
- package/icon.svg +13 -0
- package/im.js +8 -0
- package/index.js +658 -0
- package/lark-bot.js +9 -0
- package/lib/client.echarts.js +57 -0
- package/lib/client.js +5757 -0
- package/locale/en.json +1 -0
- package/locale/zh.json +1 -0
- package/package.json +95 -4
- package/policy.js +362 -0
- package/service/access.js +239 -0
- package/service/admin.js +555 -0
- package/service/alerts.js +233 -0
- package/service/bots.js +665 -0
- package/service/channels.js +363 -0
- package/service/dashboards.js +359 -0
- package/service/datasources.js +415 -0
- package/service/evaluation.js +121 -0
- package/service/push.js +163 -0
- package/service/query.js +411 -0
- package/service/schema.js +507 -0
- package/service/shared.js +424 -0
- package/service/suggestions.js +772 -0
- package/service/uploads.js +258 -0
- package/sql-guard.js +222 -0
- package/sqlite-driver.js +677 -0
- package/store.js +225 -0
- package/xlsx.js +1001 -0
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared helpers, constants and record storage used by the service modules.
|
|
3
|
+
*/
|
|
4
|
+
import { createHash, randomBytes } from 'node:crypto'
|
|
5
|
+
import { DIALECTS, describeError } from '../drivers.js'
|
|
6
|
+
import { sqliteDialectHints } from '../sqlite-driver.js'
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
export const SUPPORTED_TYPES = Object.keys(DIALECTS)
|
|
10
|
+
|
|
11
|
+
export const RPC_PATH = '/api/nl2sql.rpc'
|
|
12
|
+
export const ROLE_RANK = { viewer: 0, analyst: 1, admin: 2 }
|
|
13
|
+
export const PRESET_ID = 'nl2sql'
|
|
14
|
+
export const CREDENTIAL_SCOPE = 'nl2sql'
|
|
15
|
+
export const RESULT_CACHE_LIMIT = 300
|
|
16
|
+
|
|
17
|
+
export const DEFAULT_SETTINGS = {
|
|
18
|
+
maxRows: 500,
|
|
19
|
+
timeoutMs: 15000,
|
|
20
|
+
sampleMode: 'enum', // off | enum | all
|
|
21
|
+
resultToModel: 'summary', // none | summary | rows
|
|
22
|
+
modelRows: 20,
|
|
23
|
+
maskSensitive: true,
|
|
24
|
+
scanGuardRows: 1000000, // EXPLAIN rows above which a full scan needs confirmation (0 = off)
|
|
25
|
+
cacheTtlSec: 300, // identical query reuse window for dashboards / reruns (0 = off)
|
|
26
|
+
schemaWatch: true, // detect schema changes on re-analysis
|
|
27
|
+
botDefaultRole: 'none', // role for unknown IM bot users: none | viewer | analyst (legacy name: larkDefaultRole)
|
|
28
|
+
schemaOverviewMax: 80, // above this many tables the schema overview lists names only, plus the most relevant tables in full
|
|
29
|
+
schemaOverviewTop: 15, // tables described in full in a large-schema overview
|
|
30
|
+
retention: { history: 20000, feedback: 5000, delivery: 5000, alertEvent: 5000, admin: 5000, evalRun: 200, schemaChange: 1000 }, // records kept per log kind
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Clamp bounds for settings.retention values. */
|
|
34
|
+
export const RETENTION_MIN = 100
|
|
35
|
+
export const RETENTION_MAX = 200000
|
|
36
|
+
|
|
37
|
+
// ---------------------------------------------------------------- utilities
|
|
38
|
+
|
|
39
|
+
export const newId = (prefix) => `${prefix}-${randomBytes(5).toString('hex')}`
|
|
40
|
+
|
|
41
|
+
export class UserError extends Error {
|
|
42
|
+
constructor(code, message) {
|
|
43
|
+
super(message)
|
|
44
|
+
this.code = code
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function pick(obj, keys) {
|
|
49
|
+
const out = {}
|
|
50
|
+
for (const k of keys) if (obj[k] !== undefined) out[k] = obj[k]
|
|
51
|
+
return out
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// "token" alone is ambiguous (crypto token_code / token_price are business data):
|
|
55
|
+
// only credential-like token names count as sensitive.
|
|
56
|
+
export const SENSITIVE_NAME = /(phone|mobile|tel|email|mail|id_?card|idno|identity|passport|password|passwd|pwd|secret|(?:access|auth|api|refresh|session|bearer|csrf|jwt|push|device)_?token|^token$|token_?(?:hash|secret|key)$|bank_?card|card_?no|身份证|手机|电话|邮箱|密码)/i
|
|
57
|
+
|
|
58
|
+
export function maskValue(column, value) {
|
|
59
|
+
if (value === null || value === undefined) return value
|
|
60
|
+
const s = String(value)
|
|
61
|
+
if (/(password|passwd|pwd|secret|token|密码)/i.test(column)) return '******'
|
|
62
|
+
if (/(email|mail|邮箱)/i.test(column) && s.includes('@')) {
|
|
63
|
+
const [u, d] = s.split('@')
|
|
64
|
+
return `${u.slice(0, 1)}***@${d}`
|
|
65
|
+
}
|
|
66
|
+
if (s.length <= 4) return '****'
|
|
67
|
+
const keep = Math.min(3, Math.floor(s.length / 4))
|
|
68
|
+
return `${s.slice(0, keep)}${'*'.repeat(Math.max(4, s.length - keep * 2))}${s.slice(-keep)}`
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ---------------------------------------------------------- schema analysis
|
|
72
|
+
|
|
73
|
+
export const NUMERIC_TYPES = new Set(['tinyint', 'smallint', 'mediumint', 'int', 'integer', 'bigint', 'decimal', 'numeric', 'float', 'double', 'real'])
|
|
74
|
+
export const TIME_TYPES = new Set(['date', 'datetime', 'timestamp', 'time', 'year'])
|
|
75
|
+
export const TEXT_TYPES = new Set(['char', 'varchar', 'tinytext', 'text', 'mediumtext', 'longtext', 'enum', 'set'])
|
|
76
|
+
export const ENUM_NAME = /(status|state|type|kind|level|category|gender|sex|channel|source|platform|stage|grade|flag|mode|tag|role|class|状态|类型)/i
|
|
77
|
+
export const GEO_NAME = /^(province|city|district|region|country|area|county|town|street|prov|省|市|区|县|地区)(_?(name|code))?$|(_province|_city|_district|_region|_country)$/i
|
|
78
|
+
|
|
79
|
+
export function classifyColumn(col, table) {
|
|
80
|
+
const name = col.name.toLowerCase()
|
|
81
|
+
const t = col.dataType
|
|
82
|
+
const roles = []
|
|
83
|
+
if (col.key === 'PRI' || name === 'id' || /_id$|^id_/.test(name)) roles.push('id')
|
|
84
|
+
if (TIME_TYPES.has(t) || /(_at|_time|_date|^date|^time|^dt$|_dt$|日期|时间)/.test(name) && (TIME_TYPES.has(t) || t === 'int' || t === 'bigint' || t === 'varchar')) roles.push('time')
|
|
85
|
+
if (t === 'enum' || t === 'set' || (ENUM_NAME.test(name) && (t === 'tinyint' || t === 'smallint' || t === 'char' || t === 'varchar' || t === 'int'))) roles.push('enum')
|
|
86
|
+
if (NUMERIC_TYPES.has(t) && !roles.includes('id') && !roles.includes('enum') && !roles.includes('time') && !/^is_|^has_/.test(name)) roles.push('measure')
|
|
87
|
+
if (TEXT_TYPES.has(t) && GEO_NAME.test(name)) roles.push('geo')
|
|
88
|
+
if (SENSITIVE_NAME.test(name) || SENSITIVE_NAME.test(col.comment ?? '')) roles.push('sensitive')
|
|
89
|
+
if (/^is_|^has_/.test(name) || col.columnType === 'tinyint(1)' || t === 'bit') roles.push('flag')
|
|
90
|
+
if (roles.length === 0 && TEXT_TYPES.has(t)) roles.push('text')
|
|
91
|
+
void table
|
|
92
|
+
return roles
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function singular(word) {
|
|
96
|
+
if (word.endsWith('ies')) return `${word.slice(0, -3)}y`
|
|
97
|
+
if (word.endsWith('ses')) return word.slice(0, -2)
|
|
98
|
+
if (word.endsWith('s')) return word.slice(0, -1)
|
|
99
|
+
return word
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function inferRelations(tables, declared) {
|
|
103
|
+
const byName = new Map(tables.map(t => [t.name.toLowerCase(), t]))
|
|
104
|
+
const declaredKey = new Set(declared.map(r => `${r.from.table}.${r.from.column}`))
|
|
105
|
+
const out = []
|
|
106
|
+
for (const table of tables) {
|
|
107
|
+
for (const col of table.columns) {
|
|
108
|
+
const m = col.name.toLowerCase().match(/^(.*)_id$/)
|
|
109
|
+
if (!m || declaredKey.has(`${table.name}.${col.name}`)) continue
|
|
110
|
+
const base = m[1]
|
|
111
|
+
const candidates = [base, `${base}s`, `${base}es`, base.replace(/y$/, 'ies'), singular(base)]
|
|
112
|
+
const prefixes = ['', ...new Set(tables.map(t => t.name.toLowerCase().match(/^([a-z0-9]+_)/)?.[1]).filter(Boolean))]
|
|
113
|
+
let target
|
|
114
|
+
for (const p of prefixes) {
|
|
115
|
+
for (const c of candidates) {
|
|
116
|
+
const hit = byName.get(`${p}${c}`)
|
|
117
|
+
if (hit && hit !== table) { target = hit; break }
|
|
118
|
+
}
|
|
119
|
+
if (target) break
|
|
120
|
+
}
|
|
121
|
+
if (!target) continue
|
|
122
|
+
const pk = target.columns.find(c => c.key === 'PRI') ?? target.columns.find(c => c.name.toLowerCase() === 'id')
|
|
123
|
+
if (!pk) continue
|
|
124
|
+
const sameType = pk.dataType === col.dataType
|
|
125
|
+
out.push({
|
|
126
|
+
from: { table: table.name, column: col.name },
|
|
127
|
+
to: { table: target.name, column: pk.name },
|
|
128
|
+
kind: 'inferred',
|
|
129
|
+
confidence: sameType ? 0.8 : 0.55,
|
|
130
|
+
})
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return out
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function classifyTable(table, relations) {
|
|
137
|
+
const name = table.name.toLowerCase()
|
|
138
|
+
const roles = table.columns.flatMap(c => c.roles)
|
|
139
|
+
const referencedBy = relations.filter(r => r.to.table === table.name).length
|
|
140
|
+
const references = relations.filter(r => r.from.table === table.name).length
|
|
141
|
+
if (table.type === 'VIEW') return 'view'
|
|
142
|
+
if (/(log|logs|history|audit|trace|record)s?$|^log_/.test(name)) return 'log'
|
|
143
|
+
if (/(config|setting|dict|dictionary|option|param|enum)s?$/.test(name)) return 'config'
|
|
144
|
+
const hasTime = roles.includes('time')
|
|
145
|
+
const measures = roles.filter(r => r === 'measure').length
|
|
146
|
+
if (hasTime && measures >= 1 && (references >= 1 || (table.rows ?? 0) > 10000)) return 'fact'
|
|
147
|
+
if (referencedBy >= 1) return 'dimension'
|
|
148
|
+
if (hasTime && measures >= 1) return 'fact'
|
|
149
|
+
return 'other'
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// -------------------------------------------------------------- chart advice
|
|
153
|
+
|
|
154
|
+
export function suggestChart(columns, rows) {
|
|
155
|
+
const time = columns.filter(c => c.kind === 'time')
|
|
156
|
+
const num = columns.filter(c => c.kind === 'number')
|
|
157
|
+
const cat = columns.filter(c => c.kind === 'text')
|
|
158
|
+
if (rows.length === 1 && num.length >= 1 && columns.length <= 4) return { type: 'kpi', y: num.map(c => c.name) }
|
|
159
|
+
if (time.length >= 1 && num.length >= 1) {
|
|
160
|
+
return { type: 'line', x: time[0].name, y: num.slice(0, 4).map(c => c.name), ...(cat.length >= 1 && num.length === 1 ? { series: cat[0].name } : {}) }
|
|
161
|
+
}
|
|
162
|
+
if (cat.length >= 1 && num.length >= 1) {
|
|
163
|
+
if (num.length === 1 && rows.length <= 8 && cat.length === 1) return { type: 'pie', x: cat[0].name, y: [num[0].name] }
|
|
164
|
+
if (cat.length >= 2 && num.length === 1) return { type: 'bar', x: cat[0].name, y: [num[0].name], series: cat[1].name }
|
|
165
|
+
return { type: 'bar', x: cat[0].name, y: num.slice(0, 4).map(c => c.name) }
|
|
166
|
+
}
|
|
167
|
+
if (num.length >= 2) return { type: 'scatter', x: num[0].name, y: [num[1].name] }
|
|
168
|
+
return { type: 'table' }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function columnKind(field, rows, index) {
|
|
172
|
+
// Numeric codes such as status/type/level are categories, not measures.
|
|
173
|
+
if (field.kind === 'number' && ENUM_NAME.test(field.name) && new Set(rows.map(r => r[index])).size <= 20) return 'text'
|
|
174
|
+
if (field.kind && field.kind !== 'auto') return field.kind
|
|
175
|
+
const values = rows.map(r => r[index]).filter(v => v !== null && v !== undefined).slice(0, 50)
|
|
176
|
+
if (values.length && values.every(v => typeof v === 'string' && /^\d{4}-\d{2}(-\d{2})?([ T]\d{2}:\d{2}(:\d{2})?)?/.test(v))) return 'time'
|
|
177
|
+
if (values.length && values.every(v => typeof v === 'number' || (typeof v === 'string' && /^-?\d+(\.\d+)?$/.test(v) && v.length < 16)) && !/(^id$|_id$|code$|no$)/i.test(field.name)) return 'number'
|
|
178
|
+
return 'text'
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export const DIALECT_HINTS = {
|
|
182
|
+
mysql: 'MySQL: quote identifiers with backticks; MySQL 5.7 has no CTE or window functions; respect ONLY_FULL_GROUP_BY; DATE_FORMAT/DATE() for time buckets.',
|
|
183
|
+
postgres: 'PostgreSQL: quote identifiers with double quotes when they contain upper case or special characters; schema-qualified names appear as schema.table; use date_trunc(), to_char(), ::date casts, FILTER (WHERE ...), CTEs and window functions freely; ILIKE for case-insensitive match.',
|
|
184
|
+
elasticsearch: 'Elasticsearch SQL: each index is a table (quote names containing - or * with double quotes); nested object fields use dot paths; text fields are not aggregatable — group/filter on keyword fields or the .keyword sub-field; no JOINs and no sub-selects in FROM; use HISTOGRAM(ts, INTERVAL 1 DAY) or DATE_TRUNC for time buckets; LIMIT is required for large indices; MATCH(field, \'text\') for full-text search.',
|
|
185
|
+
sqlite: sqliteDialectHints,
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function summarize(columns, rows) {
|
|
189
|
+
return columns.map((c, i) => {
|
|
190
|
+
const values = rows.map(r => r[i]).filter(v => v !== null && v !== undefined)
|
|
191
|
+
const s = { name: c.name, kind: c.kind, nonNull: values.length }
|
|
192
|
+
if (c.kind === 'number') {
|
|
193
|
+
const nums = values.map(Number).filter(Number.isFinite)
|
|
194
|
+
if (nums.length) {
|
|
195
|
+
s.min = Math.min(...nums)
|
|
196
|
+
s.max = Math.max(...nums)
|
|
197
|
+
s.sum = Number(nums.reduce((a, b) => a + b, 0).toFixed(4))
|
|
198
|
+
s.avg = Number((s.sum / nums.length).toFixed(4))
|
|
199
|
+
}
|
|
200
|
+
} else if (c.kind === 'time') {
|
|
201
|
+
const sorted = values.map(String).sort()
|
|
202
|
+
if (sorted.length) { s.min = sorted[0]; s.max = sorted[sorted.length - 1] }
|
|
203
|
+
} else {
|
|
204
|
+
const counts = new Map()
|
|
205
|
+
for (const v of values) counts.set(String(v), (counts.get(String(v)) ?? 0) + 1)
|
|
206
|
+
s.distinct = counts.size
|
|
207
|
+
s.top = [...counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5).map(([value, count]) => ({ value: value.slice(0, 60), count }))
|
|
208
|
+
}
|
|
209
|
+
return s
|
|
210
|
+
})
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ------------------------------------------------------------------ service
|
|
214
|
+
|
|
215
|
+
/** state key → record kind, for the one-time migration out of state.json. */
|
|
216
|
+
export const RECORD_KINDS = [['history', 'history'], ['feedback', 'feedback'], ['delivery', 'deliveries'], ['alertEvent', 'alertEvents'], ['admin', 'adminLog'], ['evalRun', 'evalRuns'], ['schemaChange', 'schemaChanges']]
|
|
217
|
+
export const RECORD_LIMITS = { history: 20000, feedback: 5000, delivery: 5000, alertEvent: 5000, admin: 5000, evalRun: 200, schemaChange: 1000 }
|
|
218
|
+
/** Record kinds and the field used as their filter key. */
|
|
219
|
+
export const RECORD_REF = { history: 'datasourceId', feedback: 'datasourceId', delivery: 'channelId', alertEvent: 'alertId', admin: 'action', evalRun: 'datasourceId', schemaChange: 'datasourceId' }
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Growing logs (audit history, feedback, push deliveries, alert events, admin
|
|
223
|
+
* log, evaluation runs, schema changes) live in SQLite, newest first. Falls
|
|
224
|
+
* back to memory when SQLite is unavailable.
|
|
225
|
+
*/
|
|
226
|
+
export class Records {
|
|
227
|
+
/** @param {object} store ResultStore or undefined; @param {() => object} [limits] current retention per kind. */
|
|
228
|
+
constructor(store, limits) {
|
|
229
|
+
this.store = store
|
|
230
|
+
this.mem = new Map()
|
|
231
|
+
this.puts = 0
|
|
232
|
+
this.limitsOf = typeof limits === 'function' ? limits : () => limits ?? RECORD_LIMITS
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
limits() {
|
|
236
|
+
return { ...RECORD_LIMITS, ...(this.limitsOf() ?? {}) }
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Apply the retention limits now (all kinds, or one). */
|
|
240
|
+
trim(kind) {
|
|
241
|
+
const limits = this.limits()
|
|
242
|
+
for (const [k, n] of Object.entries(limits)) {
|
|
243
|
+
if (kind && k !== kind) continue
|
|
244
|
+
if (this.store) this.store.trimRecords(k, n)
|
|
245
|
+
else if (this.mem.has(k)) this.mem.set(k, this.mem.get(k).slice(0, n))
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
put(kind, rec) {
|
|
250
|
+
const ref = rec[RECORD_REF[kind]] ?? null
|
|
251
|
+
if (this.store) {
|
|
252
|
+
this.store.putRecord(kind, rec, { ref: ref === null ? null : String(ref), status: rec.status ?? null })
|
|
253
|
+
if (++this.puts % 500 === 0) this.trim()
|
|
254
|
+
return
|
|
255
|
+
}
|
|
256
|
+
const list = this.mem.get(kind) ?? []
|
|
257
|
+
const i = list.findIndex(x => x.id === rec.id)
|
|
258
|
+
if (i >= 0) list[i] = rec
|
|
259
|
+
else list.unshift(rec)
|
|
260
|
+
this.mem.set(kind, list.slice(0, this.limits()[kind] ?? 1000))
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Record count per kind. */
|
|
264
|
+
counts() {
|
|
265
|
+
const out = {}
|
|
266
|
+
for (const kind of Object.keys(RECORD_LIMITS)) out[kind] = this.count(kind)
|
|
267
|
+
return out
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
get(kind, id) {
|
|
271
|
+
return this.store ? this.store.getRecord(kind, id) : (this.mem.get(kind) ?? []).find(x => x.id === id)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
list(kind, { ref, status, limit = 100, since, filter } = {}) {
|
|
275
|
+
if (this.store) return this.store.listRecords(kind, { ref: ref === undefined || ref === null || ref === '' ? undefined : String(ref), status, limit, since, filter })
|
|
276
|
+
return (this.mem.get(kind) ?? []).filter(r => (!ref || String(r[RECORD_REF[kind]]) === String(ref)) && (!status || r.status === status) && (!since || (r.at ?? 0) >= since) && (!filter || filter(r))).slice(0, limit)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
count(kind, opts = {}) {
|
|
280
|
+
return this.store ? this.store.countRecords(kind, opts) : this.list(kind, { ...opts, limit: 1e9 }).length
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
delete(kind, id) {
|
|
284
|
+
if (this.store) this.store.deleteRecord(kind, id)
|
|
285
|
+
else this.mem.set(kind, (this.mem.get(kind) ?? []).filter(x => x.id !== id))
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
clear(kind) {
|
|
289
|
+
if (this.store) this.store.clearRecords(kind)
|
|
290
|
+
else this.mem.delete(kind)
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
/** Pick representative charts for one table: trend, composition, top N, distribution, KPI. */
|
|
296
|
+
export function dayExpr(dialect, C) {
|
|
297
|
+
return dialect === 'mysql' ? `DATE(${C})` : dialect === 'postgres' ? `date_trunc('day', ${C})::date` : dialect === 'sqlite' ? `date(${C})` : `HISTOGRAM(${C}, INTERVAL 1 DAY)`
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export function overviewPlans(dialect, t, knowledge) {
|
|
301
|
+
const q = (n) => dialect === 'mysql' ? `\`${n.replace(/`/g, '``')}\`` : dialect === 'postgres' ? n.split('.').map(p => `"${p.replace(/"/g, '""')}"`).join('.') : `"${n.replace(/"/g, '""')}"`
|
|
302
|
+
// Aliases: one identifier even when the column name contains dots.
|
|
303
|
+
const a = (n) => dialect === 'mysql' ? q(n) : `"${n.replace(/"/g, '""')}"`
|
|
304
|
+
const T = q(t.name)
|
|
305
|
+
const time = t.columns.find(c => c.roles.includes('time') && /date|time|stamp/.test(c.dataType) && !/update|modif/i.test(c.name)) ?? t.columns.find(c => c.roles.includes('time') && /date|time|stamp/.test(c.dataType))
|
|
306
|
+
const measures = t.columns.filter(c => c.roles.includes('measure') && !c.roles.includes('flag')).slice(0, 2)
|
|
307
|
+
const enums = t.columns.filter(c => c.roles.includes('enum') && !c.roles.includes('sensitive')).slice(0, 2)
|
|
308
|
+
const label = t.comment ? t.comment.replace(/表.*$/, '') || t.name : t.name
|
|
309
|
+
const metric = knowledge.metrics.find(m => m.table === t.name)
|
|
310
|
+
const plans = []
|
|
311
|
+
const agg = measures.map(m => `, SUM(${q(m.name)}) AS ${q(`${m.name}_sum`)}`).join('')
|
|
312
|
+
const kpiAgg = measures.map(m => `, SUM(${q(m.name)}) AS ${q(`${m.name}_sum`)}, AVG(${q(m.name)}) AS ${q(`${m.name}_avg`)}`).join('')
|
|
313
|
+
const days = 30
|
|
314
|
+
if (time) {
|
|
315
|
+
const C = q(time.name)
|
|
316
|
+
const day = dayExpr(dialect, C)
|
|
317
|
+
const since = dialect === 'mysql' ? `${C} >= DATE_SUB(CURDATE(), INTERVAL ${days} DAY)` : dialect === 'postgres' ? `${C} >= current_date - interval '${days} days'` : dialect === 'sqlite' ? `${C} >= date('now', '-${days} days')` : `${C} >= NOW() - INTERVAL ${days} DAYS`
|
|
318
|
+
plans.push({ title: `${label} · 近 ${days} 天总览`, sql: `SELECT COUNT(*) AS ${q('total')}${kpiAgg} FROM ${T} WHERE ${since}`, chart: { type: 'kpi' }, width: 'full' })
|
|
319
|
+
plans.push({ title: `${label} · 每日趋势`, sql: `SELECT ${day} AS ${q('day')}, COUNT(*) AS ${q('cnt')}${agg} FROM ${T} WHERE ${since} GROUP BY ${dialect === 'elasticsearch' ? q('day') : day} ORDER BY ${q('day')}`, chart: { type: 'line' }, width: 'full' })
|
|
320
|
+
if (dialect !== 'elasticsearch') {
|
|
321
|
+
const week = dialect === 'mysql' ? `DATE_FORMAT(${C}, '%x-W%v')` : dialect === 'sqlite' ? `strftime('%Y-W%W', ${C})` : `to_char(date_trunc('week', ${C}), 'IYYY-"W"IW')`
|
|
322
|
+
const since84 = dialect === 'mysql' ? `${C} >= DATE_SUB(CURDATE(), INTERVAL 84 DAY)` : dialect === 'sqlite' ? `${C} >= date('now', '-84 days')` : `${C} >= current_date - interval '84 days'`
|
|
323
|
+
plans.push({ title: `${label} · 近 12 周(周环比)`, sql: `SELECT ${week} AS ${q('week')}, COUNT(*) AS ${q('cnt')}${agg} FROM ${T} WHERE ${since84} GROUP BY ${week} ORDER BY ${q('week')}`, chart: { type: 'bar' }, width: 'half' })
|
|
324
|
+
}
|
|
325
|
+
} else {
|
|
326
|
+
plans.push({ title: `${label} · 总览`, sql: `SELECT COUNT(*) AS ${q('total')}${kpiAgg} FROM ${T}`, chart: { type: 'kpi' }, width: 'full' })
|
|
327
|
+
}
|
|
328
|
+
for (const e of enums) {
|
|
329
|
+
const E = q(e.name)
|
|
330
|
+
plans.push({ title: `${label} · 按${e.comment ? e.comment.split(/[\s::((]/)[0] : e.name}分布`, sql: `SELECT ${E} AS ${a(e.name)}, COUNT(*) AS ${q('cnt')}${measures[0] ? `, SUM(${q(measures[0].name)}) AS ${q(`${measures[0].name}_sum`)}` : ''} FROM ${T} GROUP BY ${E} ORDER BY ${q('cnt')} DESC LIMIT 12`, chart: { type: 'pie' }, width: 'half' })
|
|
331
|
+
}
|
|
332
|
+
const dim = t.columns.find(c => (c.roles.includes('geo') || (c.roles.includes('text') && /name|title|city|region|channel|source|category/i.test(c.name))) && !c.roles.includes('sensitive'))
|
|
333
|
+
if (dim && measures[0]) {
|
|
334
|
+
plans.push({ title: `${label} · ${dim.comment || dim.name} Top 10`, sql: `SELECT ${q(dim.name)} AS ${a(dim.name)}, SUM(${q(measures[0].name)}) AS ${a(`${measures[0].name}_sum`)} FROM ${T} GROUP BY ${q(dim.name)} ORDER BY ${a(`${measures[0].name}_sum`)} DESC LIMIT 10`, chart: { type: 'barh' }, width: 'half' })
|
|
335
|
+
}
|
|
336
|
+
if (metric && time) {
|
|
337
|
+
const C = q(metric.timeColumn || time.name)
|
|
338
|
+
const day = dayExpr(dialect, C)
|
|
339
|
+
plans.push({ title: `${metric.name} · 每日`, sql: `SELECT ${day} AS ${q('day')}, ${metric.expression} AS ${q(metric.name)} FROM ${T}${metric.filter ? ` WHERE ${metric.filter}` : ''} GROUP BY ${dialect === 'elasticsearch' ? q('day') : day} ORDER BY ${q('day')} DESC LIMIT 30`, chart: { type: 'line' }, width: 'half' })
|
|
340
|
+
}
|
|
341
|
+
return plans.slice(0, 6)
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
export function upsert(list, entry) {
|
|
345
|
+
const i = list.findIndex(x => x.id === entry.id)
|
|
346
|
+
if (i < 0) return [...list, entry]
|
|
347
|
+
const next = [...list]
|
|
348
|
+
next[i] = entry
|
|
349
|
+
return next
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// ------------------------------------------------------------------ helpers
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
export function clampInt(v, min, max, field) {
|
|
356
|
+
const n = Number(v)
|
|
357
|
+
if (!Number.isInteger(n) || n < min || n > max) throw new UserError('nl2sql/invalid', `${field} 必须是 ${min}–${max} 之间的整数`)
|
|
358
|
+
return n
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
export function oneOf(v, values, field) {
|
|
362
|
+
if (!values.includes(v)) throw new UserError('nl2sql/invalid', `${field} 必须是 ${values.join(' / ')} 之一`)
|
|
363
|
+
return v
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
export function requireText(v, field, max) {
|
|
367
|
+
const s = String(v ?? '').trim()
|
|
368
|
+
if (s === '' || s.length > max) throw new UserError('nl2sql/invalid', `${field} 不能为空且不超过 ${max} 个字符`)
|
|
369
|
+
return s
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
export function toUserError(error) {
|
|
373
|
+
if (error instanceof UserError) return error
|
|
374
|
+
const e = describeError(error)
|
|
375
|
+
return new UserError(e.code, e.message)
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
export const CHART_TYPES = ['line', 'bar', 'barh', 'stacked', 'area', 'pie', 'scatter', 'kpi', 'table', 'funnel']
|
|
379
|
+
|
|
380
|
+
export function normalizeChart(chart, columns) {
|
|
381
|
+
if (!chart || typeof chart !== 'object') return undefined
|
|
382
|
+
const type = CHART_TYPES.includes(chart.type) ? chart.type : undefined
|
|
383
|
+
if (!type) return undefined
|
|
384
|
+
const names = new Set(columns.map(c => c.name))
|
|
385
|
+
const y = (Array.isArray(chart.y) ? chart.y : chart.y ? [chart.y] : []).filter(n => names.has(n))
|
|
386
|
+
const out = { type }
|
|
387
|
+
if (chart.x && names.has(chart.x)) out.x = chart.x
|
|
388
|
+
if (y.length) out.y = y
|
|
389
|
+
if (chart.series && names.has(chart.series)) out.series = chart.series
|
|
390
|
+
if (type !== 'table' && type !== 'kpi' && (!out.x || !out.y)) return undefined
|
|
391
|
+
if (type === 'kpi' && !out.y) return undefined
|
|
392
|
+
return out
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** Complete a type-only chart hint (e.g. { type: 'pie' }) from the column kinds. */
|
|
396
|
+
export function fillChartHint(chart, columns, suggested) {
|
|
397
|
+
if (!chart?.type || !CHART_TYPES.includes(chart.type)) return undefined
|
|
398
|
+
const nums = columns.filter(c => c.kind === 'number').map(c => c.name)
|
|
399
|
+
const cats = columns.filter(c => c.kind !== 'number').map(c => c.name)
|
|
400
|
+
if (chart.type === 'table') return { type: 'table' }
|
|
401
|
+
if (chart.type === 'kpi') return nums.length ? { type: 'kpi', y: nums.slice(0, 6) } : undefined
|
|
402
|
+
if (chart.type === 'scatter') return nums.length >= 2 ? { type: 'scatter', x: nums[0], y: [nums[1]] } : undefined
|
|
403
|
+
const x = (suggested?.x && suggested.type !== 'scatter' ? suggested.x : undefined) ?? cats[0]
|
|
404
|
+
if (!x || !nums.length) return undefined
|
|
405
|
+
if (chart.type === 'pie' || chart.type === 'funnel') return { type: chart.type, x, y: [nums.filter(n => n !== x)[0] ?? nums[0]] }
|
|
406
|
+
return { type: chart.type, x, y: nums.filter(n => n !== x).slice(0, 4) }
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// -------------------------------------------------------------------- tools
|
|
410
|
+
|
|
411
|
+
// -------------------------------------------------------------------- apply
|
|
412
|
+
|
|
413
|
+
export function hashText(text) {
|
|
414
|
+
return createHash('sha1').update(String(text)).digest('hex').slice(0, 10)
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
export function fmtNum(v) {
|
|
418
|
+
const n = Number(v)
|
|
419
|
+
if (!Number.isFinite(n)) return String(v)
|
|
420
|
+
if (Math.abs(n) >= 1e8) return `${(n / 1e8).toFixed(2)}亿`
|
|
421
|
+
if (Math.abs(n) >= 1e4) return `${(n / 1e4).toFixed(2)}万`
|
|
422
|
+
return Number.isInteger(n) ? n.toLocaleString('en-US') : n.toFixed(2)
|
|
423
|
+
}
|
|
424
|
+
|