@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/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>