@hedeqiang/dsh-nl2sql 0.0.0-stage → 0.10.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/CHANGELOG.md +32 -0
- package/LICENSE +21 -0
- package/README.en.md +62 -0
- package/README.md +88 -2
- package/analysis.js +888 -0
- package/cordis.patch.yml +68 -0
- package/drivers.js +694 -0
- package/filters.js +237 -0
- package/icon.svg +13 -0
- package/im.js +511 -0
- package/index.js +650 -0
- package/lark-bot.js +606 -0
- package/lib/client.echarts.js +57 -0
- package/lib/client.js +4845 -0
- package/locale/en.json +1 -0
- package/locale/zh.json +1 -0
- package/package.json +90 -4
- package/policy.js +362 -0
- package/service/access.js +145 -0
- package/service/admin.js +539 -0
- package/service/alerts.js +233 -0
- package/service/dashboards.js +359 -0
- package/service/datasources.js +392 -0
- package/service/evaluation.js +121 -0
- package/service/lark-actions.js +223 -0
- package/service/lark.js +250 -0
- package/service/push.js +314 -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
package/filters.js
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dashboard global filters, injected into a widget's SQL.
|
|
3
|
+
*
|
|
4
|
+
* Filter shapes:
|
|
5
|
+
* { kind: 'time', column, range: 'today'|'yesterday'|'last7d'|'last30d'|'last90d'|'thisMonth'|'lastMonth'|'thisYear'|'custom', from?, to? }
|
|
6
|
+
* { kind: 'in', column, values: string[] }
|
|
7
|
+
*
|
|
8
|
+
* A filter applies to a query when the column belongs to a table in the
|
|
9
|
+
* outermost query's FROM clause; the predicate is ANDed into that query's
|
|
10
|
+
* WHERE clause, qualified with the table's alias. Queries the injector cannot
|
|
11
|
+
* place safely (top-level UNION, no FROM) run unfiltered and report so.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export const TIME_RANGES = ['today', 'yesterday', 'last7d', 'last30d', 'last90d', 'thisMonth', 'lastMonth', 'thisYear', 'custom']
|
|
15
|
+
|
|
16
|
+
export function normalizeFilters(input) {
|
|
17
|
+
if (!Array.isArray(input)) return []
|
|
18
|
+
const out = []
|
|
19
|
+
for (const f of input.slice(0, 10)) {
|
|
20
|
+
if (!f || typeof f !== 'object' || typeof f.column !== 'string' || !f.column.trim()) continue
|
|
21
|
+
const column = f.column.trim().slice(0, 200)
|
|
22
|
+
if (f.kind === 'time') {
|
|
23
|
+
const range = TIME_RANGES.includes(f.range) ? f.range : 'last30d'
|
|
24
|
+
const date = (v) => (/^\d{4}-\d{2}-\d{2}$/.test(String(v ?? '')) ? String(v) : undefined)
|
|
25
|
+
if (range === 'custom' && !date(f.from) && !date(f.to)) continue
|
|
26
|
+
out.push({ kind: 'time', column, range, ...(range === 'custom' ? { from: date(f.from), to: date(f.to) } : {}) })
|
|
27
|
+
} else if (f.kind === 'in') {
|
|
28
|
+
const values = (Array.isArray(f.values) ? f.values : []).map(v => String(v)).filter(v => v.length <= 200).slice(0, 100)
|
|
29
|
+
if (values.length) out.push({ kind: 'in', column, values })
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return out
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const pad = (n) => String(n).padStart(2, '0')
|
|
36
|
+
const ymd = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
|
37
|
+
|
|
38
|
+
/** Half-open local date interval [from, to) for a time filter. */
|
|
39
|
+
export function timeBounds(filter, now = new Date()) {
|
|
40
|
+
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
|
41
|
+
const add = (d, days) => new Date(d.getFullYear(), d.getMonth(), d.getDate() + days)
|
|
42
|
+
switch (filter.range) {
|
|
43
|
+
case 'today': return { from: ymd(today), to: ymd(add(today, 1)) }
|
|
44
|
+
case 'yesterday': return { from: ymd(add(today, -1)), to: ymd(today) }
|
|
45
|
+
case 'last7d': return { from: ymd(add(today, -6)), to: ymd(add(today, 1)) }
|
|
46
|
+
case 'last30d': return { from: ymd(add(today, -29)), to: ymd(add(today, 1)) }
|
|
47
|
+
case 'last90d': return { from: ymd(add(today, -89)), to: ymd(add(today, 1)) }
|
|
48
|
+
case 'thisMonth': return { from: ymd(new Date(today.getFullYear(), today.getMonth(), 1)), to: ymd(add(today, 1)) }
|
|
49
|
+
case 'lastMonth': return { from: ymd(new Date(today.getFullYear(), today.getMonth() - 1, 1)), to: ymd(new Date(today.getFullYear(), today.getMonth(), 1)) }
|
|
50
|
+
case 'thisYear': return { from: ymd(new Date(today.getFullYear(), 0, 1)), to: ymd(add(today, 1)) }
|
|
51
|
+
case 'custom': {
|
|
52
|
+
const to = filter.to ? ymd(add(new Date(`${filter.to}T00:00:00`), 1)) : undefined
|
|
53
|
+
return { from: filter.from, to }
|
|
54
|
+
}
|
|
55
|
+
default: return {}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function describeFilter(f) {
|
|
60
|
+
if (f.kind === 'time') {
|
|
61
|
+
const b = timeBounds(f)
|
|
62
|
+
return `${f.column} ∈ [${b.from ?? '…'}, ${b.to ?? '…'})`
|
|
63
|
+
}
|
|
64
|
+
return `${f.column} IN (${f.values.join(', ')})`
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ------------------------------------------------------------ SQL scanning
|
|
68
|
+
|
|
69
|
+
const CLAUSE_END = /^(WHERE|GROUP|HAVING|ORDER|LIMIT|WINDOW|QUALIFY|FETCH|OFFSET|UNION|INTERSECT|EXCEPT|FOR|PIVOT)$/i
|
|
70
|
+
|
|
71
|
+
/** Top-level (depth 0) word tokens of the outermost SELECT, with offsets. */
|
|
72
|
+
function topLevelWords(sql) {
|
|
73
|
+
const words = []
|
|
74
|
+
let depth = 0
|
|
75
|
+
for (let i = 0; i < sql.length;) {
|
|
76
|
+
const c = sql[i]
|
|
77
|
+
const n = sql[i + 1]
|
|
78
|
+
if ((c === '-' && n === '-') || c === '#') { while (i < sql.length && sql[i] !== '\n') i++; continue }
|
|
79
|
+
if (c === '/' && n === '*') { const e = sql.indexOf('*/', i + 2); i = e < 0 ? sql.length : e + 2; continue }
|
|
80
|
+
if (c === '\'' || c === '"' || c === '`') {
|
|
81
|
+
const q = c
|
|
82
|
+
const start = i
|
|
83
|
+
i++
|
|
84
|
+
while (i < sql.length) {
|
|
85
|
+
if (sql[i] === '\\' && q === '\'') { i += 2; continue }
|
|
86
|
+
if (sql[i] === q) { if (sql[i + 1] === q) { i += 2; continue } break }
|
|
87
|
+
i++
|
|
88
|
+
}
|
|
89
|
+
i++
|
|
90
|
+
if (depth === 0) words.push({ kind: 'quoted', text: sql.slice(start, i), start, end: i })
|
|
91
|
+
continue
|
|
92
|
+
}
|
|
93
|
+
if (c === '(') { depth++; if (depth === 1) words.push({ kind: 'open', start: i, end: i + 1 }); i++; continue }
|
|
94
|
+
if (c === ')') { depth--; if (depth === 0) words.push({ kind: 'close', start: i, end: i + 1 }); i++; continue }
|
|
95
|
+
if (/[A-Za-z_]/.test(c)) {
|
|
96
|
+
let j = i
|
|
97
|
+
while (j < sql.length && /[\w$.\-*]/.test(sql[j]) && !(sql[j] === '-' && sql[j + 1] === '-')) j++
|
|
98
|
+
if (depth === 0) words.push({ kind: 'word', text: sql.slice(i, j), upper: sql.slice(i, j).toUpperCase(), start: i, end: j })
|
|
99
|
+
i = j
|
|
100
|
+
continue
|
|
101
|
+
}
|
|
102
|
+
if (c === ',' && depth === 0) words.push({ kind: 'comma', start: i, end: i + 1 })
|
|
103
|
+
i++
|
|
104
|
+
}
|
|
105
|
+
return words
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function unquote(t) {
|
|
109
|
+
return t.replace(/^[`"]|[`"]$/g, '').replace(/``/g, '`').replace(/""/g, '"')
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Parse the outermost query: FROM items with aliases, WHERE span, insertion point. */
|
|
113
|
+
export function parseOuterQuery(sql) {
|
|
114
|
+
const words = topLevelWords(sql)
|
|
115
|
+
// Skip a leading WITH ... list: the main SELECT is the first top-level SELECT after the CTE bodies.
|
|
116
|
+
let selectIdx = words.findIndex(w => w.kind === 'word' && w.upper === 'SELECT')
|
|
117
|
+
if (words[0]?.upper === 'WITH') {
|
|
118
|
+
selectIdx = -1
|
|
119
|
+
for (let k = 1; k < words.length; k++) if (words[k].kind === 'word' && words[k].upper === 'SELECT' && words[k - 1]?.kind === 'close') { selectIdx = k; break }
|
|
120
|
+
}
|
|
121
|
+
if (selectIdx < 0) return undefined
|
|
122
|
+
const rest = words.slice(selectIdx)
|
|
123
|
+
if (rest.some(w => w.kind === 'word' && /^(UNION|INTERSECT|EXCEPT)$/.test(w.upper))) return undefined
|
|
124
|
+
const fromIdx = rest.findIndex(w => w.kind === 'word' && w.upper === 'FROM')
|
|
125
|
+
if (fromIdx < 0) return undefined
|
|
126
|
+
let endIdx = rest.findIndex((w, k) => k > fromIdx && w.kind === 'word' && CLAUSE_END.test(w.upper))
|
|
127
|
+
if (endIdx < 0) endIdx = rest.length
|
|
128
|
+
// FROM items: split by commas and JOIN keywords; skip ON/USING conditions.
|
|
129
|
+
const items = []
|
|
130
|
+
let current = []
|
|
131
|
+
let inCondition = false
|
|
132
|
+
const flush = () => { if (current.length) items.push(current); current = [] }
|
|
133
|
+
for (let k = fromIdx + 1; k < endIdx; k++) {
|
|
134
|
+
const w = rest[k]
|
|
135
|
+
if (w.kind === 'comma' && !inCondition) { flush(); continue }
|
|
136
|
+
if (w.kind === 'word' && /^(JOIN|INNER|LEFT|RIGHT|FULL|CROSS|OUTER|NATURAL|STRAIGHT_JOIN|LATERAL)$/.test(w.upper)) { flush(); inCondition = false; continue }
|
|
137
|
+
if (w.kind === 'word' && /^(ON|USING)$/.test(w.upper)) { flush(); inCondition = true; continue }
|
|
138
|
+
if (!inCondition) current.push(w)
|
|
139
|
+
}
|
|
140
|
+
flush()
|
|
141
|
+
const tables = []
|
|
142
|
+
for (const item of items) {
|
|
143
|
+
if (item[0]?.kind === 'open') continue // derived table
|
|
144
|
+
const nameTok = item[0]
|
|
145
|
+
if (!nameTok || (nameTok.kind !== 'word' && nameTok.kind !== 'quoted')) continue
|
|
146
|
+
let name = nameTok.kind === 'quoted' ? unquote(nameTok.text) : nameTok.text
|
|
147
|
+
// Quoted dotted names: "schema"."table" arrive as one quoted token followed by a word starting with '.'.
|
|
148
|
+
let k = 1
|
|
149
|
+
while (item[k] && ((item[k].kind === 'word' && item[k].text.startsWith('.')) || (item[k].kind === 'quoted' && item[k - 1] && sql[item[k].start - 1] === '.'))) {
|
|
150
|
+
name += item[k].kind === 'quoted' ? `.${unquote(item[k].text)}` : item[k].text
|
|
151
|
+
k++
|
|
152
|
+
}
|
|
153
|
+
let alias
|
|
154
|
+
const aliasTok = item[k]?.kind === 'word' && item[k].upper === 'AS' ? item[k + 1] : item[k]
|
|
155
|
+
if (aliasTok && (aliasTok.kind === 'word' || aliasTok.kind === 'quoted') && !/^(WHERE|GROUP|ORDER|LIMIT|USE|FORCE|IGNORE|TABLESAMPLE|PARTITION)$/i.test(aliasTok.text)) {
|
|
156
|
+
alias = aliasTok.kind === 'quoted' ? unquote(aliasTok.text) : aliasTok.text
|
|
157
|
+
}
|
|
158
|
+
tables.push({ name, alias })
|
|
159
|
+
}
|
|
160
|
+
const whereIdx = rest.findIndex((w, k) => k > fromIdx && w.kind === 'word' && w.upper === 'WHERE')
|
|
161
|
+
let where
|
|
162
|
+
if (whereIdx >= 0) {
|
|
163
|
+
let whereEnd = rest.findIndex((w, k) => k > whereIdx && w.kind === 'word' && CLAUSE_END.test(w.upper) && w.upper !== 'WHERE')
|
|
164
|
+
where = { keywordStart: rest[whereIdx].start, keywordEnd: rest[whereIdx].end, end: whereEnd < 0 ? sql.length : rest[whereEnd].start }
|
|
165
|
+
}
|
|
166
|
+
const insertAt = endIdx < rest.length ? rest[endIdx].start : sql.length
|
|
167
|
+
return { tables, where, insertAt }
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function quoteIdent(name, dialect) {
|
|
171
|
+
if (dialect === 'mysql') return `\`${name.replace(/`/g, '``')}\``
|
|
172
|
+
return `"${name.replace(/"/g, '""')}"`
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function literal(value) {
|
|
176
|
+
return `'${String(value).replace(/\\/g, '\\\\').replace(/'/g, "''")}'`
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function literalFor(value, dialect) {
|
|
180
|
+
// MySQL treats backslash as an escape in string literals; PostgreSQL/ES do not.
|
|
181
|
+
return dialect === 'mysql' ? literal(value) : `'${String(value).replace(/'/g, "''")}'`
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Inject filters into a SELECT.
|
|
186
|
+
* @param {string} sql
|
|
187
|
+
* @param {object[]} filters - normalized filters.
|
|
188
|
+
* @param {{ dialect: string, schema: { tables: { name: string, role?: string, columns: { name: string }[] }[] } }} ctx
|
|
189
|
+
* @returns {{ sql: string, applied: object[], skipped: object[] }}
|
|
190
|
+
*/
|
|
191
|
+
export function applyFilters(sql, filters, { dialect, schema, now }) {
|
|
192
|
+
if (!filters.length) return { sql, applied: [], skipped: [] }
|
|
193
|
+
const outer = parseOuterQuery(sql)
|
|
194
|
+
if (!outer || !outer.tables.length) return { sql, applied: [], skipped: filters.map(f => ({ ...f, reason: 'unsupported query shape' })) }
|
|
195
|
+
const tables = schema?.tables ?? []
|
|
196
|
+
const resolve = (ref) => {
|
|
197
|
+
const n = ref.name.toLowerCase()
|
|
198
|
+
return tables.find(t => t.name.toLowerCase() === n) ?? tables.find(t => t.name.toLowerCase().split('.').pop() === n.split('.').pop())
|
|
199
|
+
}
|
|
200
|
+
const refs = outer.tables.map(ref => ({ ref, table: resolve(ref) })).filter(x => x.table)
|
|
201
|
+
const predicates = []
|
|
202
|
+
const applied = []
|
|
203
|
+
const skipped = []
|
|
204
|
+
for (const f of filters) {
|
|
205
|
+
const candidates = refs.filter(x => x.table.columns.some(c => c.name.toLowerCase() === f.column.toLowerCase()))
|
|
206
|
+
if (!candidates.length) { skipped.push({ ...f, reason: 'column not in query tables' }); continue }
|
|
207
|
+
const pick = candidates.find(x => x.table.role === 'fact') ?? candidates[0]
|
|
208
|
+
const colName = pick.table.columns.find(c => c.name.toLowerCase() === f.column.toLowerCase()).name
|
|
209
|
+
const qualifier = pick.ref.alias
|
|
210
|
+
? quoteIdent(pick.ref.alias, dialect)
|
|
211
|
+
: (dialect === 'elasticsearch' ? null : pick.ref.name.split('.').map(p => quoteIdent(p, dialect)).join('.'))
|
|
212
|
+
const col = dialect === 'elasticsearch' && !pick.ref.alias ? quoteIdent(colName, dialect) : `${qualifier}.${quoteIdent(colName, dialect)}`
|
|
213
|
+
if (f.kind === 'time') {
|
|
214
|
+
const b = timeBounds(f, now)
|
|
215
|
+
const parts = []
|
|
216
|
+
if (b.from) parts.push(`${col} >= ${literalFor(`${b.from}${dialect === 'elasticsearch' ? '' : ' 00:00:00'}`, dialect)}`)
|
|
217
|
+
if (b.to) parts.push(`${col} < ${literalFor(`${b.to}${dialect === 'elasticsearch' ? '' : ' 00:00:00'}`, dialect)}`)
|
|
218
|
+
if (!parts.length) { skipped.push({ ...f, reason: 'empty range' }); continue }
|
|
219
|
+
predicates.push(parts.join(' AND '))
|
|
220
|
+
} else {
|
|
221
|
+
predicates.push(`${col} IN (${f.values.map(v => literalFor(v, dialect)).join(', ')})`)
|
|
222
|
+
}
|
|
223
|
+
applied.push({ ...f, table: pick.table.name, description: describeFilter(f) })
|
|
224
|
+
}
|
|
225
|
+
if (!predicates.length) return { sql, applied, skipped }
|
|
226
|
+
const pred = predicates.join(' AND ')
|
|
227
|
+
let out
|
|
228
|
+
if (outer.where) {
|
|
229
|
+
const existing = sql.slice(outer.where.keywordEnd, outer.where.end).trim()
|
|
230
|
+
out = `${sql.slice(0, outer.where.keywordStart)}WHERE (${pred}) AND (${existing})${outer.where.end < sql.length ? ' ' : ''}${sql.slice(outer.where.end)}`
|
|
231
|
+
} else {
|
|
232
|
+
const before = sql.slice(0, outer.insertAt).replace(/\s+$/, '')
|
|
233
|
+
const after = sql.slice(outer.insertAt)
|
|
234
|
+
out = `${before} WHERE ${pred}${after ? ` ${after}` : ''}`
|
|
235
|
+
}
|
|
236
|
+
return { sql: out, applied, skipped }
|
|
237
|
+
}
|
package/icon.svg
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
|
|
2
|
+
<rect width="64" height="64" rx="14" fill="#2f5bd8"/>
|
|
3
|
+
<g fill="none" stroke="#fff" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round">
|
|
4
|
+
<ellipse cx="27" cy="17" rx="13" ry="5"/>
|
|
5
|
+
<path d="M14 17v10.5c0 2.8 5.8 5 13 5s13-2.2 13-5V17"/>
|
|
6
|
+
<path d="M14 27.5V38c0 2.8 5.8 5 13 5 1.5 0 2.9-.1 4.2-.3"/>
|
|
7
|
+
</g>
|
|
8
|
+
<g fill="#7fe0b2">
|
|
9
|
+
<rect x="36" y="40" width="5" height="12" rx="1.5"/>
|
|
10
|
+
<rect x="43.5" y="33" width="5" height="19" rx="1.5"/>
|
|
11
|
+
<rect x="51" y="44" width="5" height="8" rx="1.5"/>
|
|
12
|
+
</g>
|
|
13
|
+
</svg>
|