@hedeqiang/dsh-nl2sql 0.12.0 → 0.12.2
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 +28 -1
- package/analysis.js +4 -1
- package/channels/dingtalk.js +11 -9
- package/channels/lark.js +17 -9
- package/channels/message.js +48 -12
- package/channels/telegram.js +13 -6
- package/channels/wecom.js +10 -9
- package/filters.js +87 -33
- package/index.js +4 -2
- package/lib/client.js +450 -154
- package/package.json +1 -1
- package/service/alerts.js +40 -20
- package/service/bots.js +38 -12
- package/service/channels.js +26 -5
- package/service/dashboards.js +5 -2
- package/service/datasources.js +16 -3
- package/service/query.js +36 -8
- package/service/uploads.js +4 -1
- package/sql-guard.js +33 -2
- package/xlsx.js +12 -2
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,31 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [0.12.2] - 2026-11-12
|
|
9
|
+
|
|
10
|
+
### Security
|
|
11
|
+
- Row-level rules could be bypassed with MySQL `--` arithmetic (e.g. `WHERE 1--1) OR (1`): the filter injector now uses the dialect-aware lexer and refuses any rewrite where the injected rule is not a top-level conjunct.
|
|
12
|
+
- The SQL guard followed JavaScript's notion of whitespace after `--`; it now follows MySQL exactly, and SQL containing control characters or non-ASCII whitespace outside literals is refused (closed table-policy and system-catalog bypasses).
|
|
13
|
+
- `nl2sql_overview` ran its queries without the caller's row-level rules; it now runs as the caller.
|
|
14
|
+
- A result filtered for one member could be reused (re-charted, drilled, sent) by a member with different row-level rules; results now carry a signature of the rules applied.
|
|
15
|
+
- The bot's data source catalog listed tables hidden by the access policy.
|
|
16
|
+
- Data values could inject mentions (`<at id=all>`) or links into Lark cards and other IM messages; data-derived text is now escaped per platform.
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
- Previewing an alert while another alert was saved could delete saved alerts; an alert edited during a check lost its state and re-fired every minute.
|
|
20
|
+
- Bot commands matched any question starting with words such as 自动, 数据源, 切换 or help; commands must now be the whole message. English aliases: `help`, `sources`, `switch <name>`, `auto`, `new`.
|
|
21
|
+
- Bots no longer change the page's current data source when they switch sources.
|
|
22
|
+
- Deleting a channel left alerts, the schema-change notice, scheduled reports and bot conversations pointing at it.
|
|
23
|
+
- Sending failed for titles longer than 100 characters; NULL numbers showed as 0; line breaks inside cells broke tables; changing a channel's type asked for unchanged secrets again; Excel sheet names with emoji could exceed Excel's limit; daily alerts handled daylight-saving transitions wrongly.
|
|
24
|
+
- New file data sources had the description "上传的文件:undefined".
|
|
25
|
+
- Page: the knowledge editor could save into another data source after switching; the Ask page briefly offered the previous data source's questions; dialogs and toasts did not appear inside conversation tool views; free-text dashboard filters could not take a comma; double clicks created duplicate widgets, alerts and knowledge items; unsaved settings were lost without warning; several actions failed silently; number formatting showed 0 for empty values and `10000万` / `1000K`; history labels for Telegram / WeCom / DingTalk bots; dates follow the UI language.
|
|
26
|
+
- Accessibility: focus stays inside dialogs; policy rows work with the keyboard; Chinese punctuation no longer appears in the English UI.
|
|
27
|
+
|
|
28
|
+
## [0.12.1] - 2026-11-12
|
|
29
|
+
|
|
30
|
+
### Fixed
|
|
31
|
+
- History & audit: the per-row delete button overlapped the row count and duration columns; it now has its own column.
|
|
32
|
+
|
|
8
33
|
## [0.12.0] - 2026-11-12
|
|
9
34
|
|
|
10
35
|
### Added
|
|
@@ -81,7 +106,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
|
81
106
|
|
|
82
107
|
- Redesigned UI; Lark push (Markdown and native charts); access control, feedback loop, dashboard global filters; PostgreSQL / Elasticsearch, SSH tunnels, privacy settings, automatic overviews, scheduled reports; semantic layer, ER graph, scan guard, history and audit, dashboards; first version.
|
|
83
108
|
|
|
84
|
-
[Unreleased]: https://github.com/hedeqiang/dsh-nl2sql/compare/v0.12.
|
|
109
|
+
[Unreleased]: https://github.com/hedeqiang/dsh-nl2sql/compare/v0.12.2...HEAD
|
|
110
|
+
[0.12.2]: https://github.com/hedeqiang/dsh-nl2sql/compare/v0.12.1...v0.12.2
|
|
111
|
+
[0.12.1]: https://github.com/hedeqiang/dsh-nl2sql/compare/v0.12.0...v0.12.1
|
|
85
112
|
[0.12.0]: https://github.com/hedeqiang/dsh-nl2sql/compare/v0.11.2...v0.12.0
|
|
86
113
|
[0.11.2]: https://github.com/hedeqiang/dsh-nl2sql/compare/v0.11.1...v0.11.2
|
|
87
114
|
[0.11.1]: https://github.com/hedeqiang/dsh-nl2sql/releases/tag/v0.11.1
|
package/analysis.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { timeBounds, TIME_RANGES } from './filters.js'
|
|
12
|
+
import { dashDashCommentFollower } from './sql-guard.js'
|
|
12
13
|
|
|
13
14
|
// ------------------------------------------------------------ quoting
|
|
14
15
|
|
|
@@ -83,7 +84,9 @@ function tokenize(sql, { dialect } = {}) {
|
|
|
83
84
|
const c = sql[i]
|
|
84
85
|
const n = sql[i + 1]
|
|
85
86
|
if (/\s/.test(c)) { i++; continue }
|
|
86
|
-
|
|
87
|
+
// MySQL: `--` starts a comment only when followed by a control/space character (or end); other dialects: always.
|
|
88
|
+
const dashComment = c === '-' && n === '-' && (dialect === 'postgres' || dialect === 'sqlite' || dialect === 'elasticsearch' || dashDashCommentFollower(sql[i + 2]))
|
|
89
|
+
if (dashComment || (c === '#' && dialect !== 'postgres' && dialect !== 'sqlite' && dialect !== 'elasticsearch')) { while (i < sql.length && sql[i] !== '\n') i++; continue }
|
|
87
90
|
if (c === '/' && n === '*') { const e = sql.indexOf('*/', i + 2); i = e < 0 ? sql.length : e + 2; continue }
|
|
88
91
|
if (c === '\'' || c === '"' || c === '`') {
|
|
89
92
|
const start = i
|
package/channels/dingtalk.js
CHANGED
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
*/
|
|
49
49
|
import { createHmac } from 'node:crypto'
|
|
50
50
|
import { ChannelError, postJson, request } from './http.js'
|
|
51
|
-
import { resultMeta, isKpi, kpiText, fmtNum, cellText, toPlainMarkdown, mentionsAll, splitText, MD_TABLE, parseMdTable } from './message.js'
|
|
51
|
+
import { resultMeta, isKpi, kpiText, fmtNum, cellText, defangMd, toPlainMarkdown, mentionsAll, splitText, MD_TABLE, parseMdTable } from './message.js'
|
|
52
52
|
|
|
53
53
|
export const OAPI = 'https://oapi.dingtalk.com'
|
|
54
54
|
export const API = 'https://api.dingtalk.com'
|
|
@@ -153,11 +153,12 @@ export function dingApiError(json, status) {
|
|
|
153
153
|
// ------------------------------------------------------------- rendering
|
|
154
154
|
|
|
155
155
|
/** A result as text lines (DingTalk markdown has no tables). */
|
|
156
|
-
export function resultAsDingText(r, maxRows = 10, maxCols = 6) {
|
|
156
|
+
export function resultAsDingText(r, maxRows = 10, maxCols = 6, { escape = defangMd } = {}) {
|
|
157
157
|
const cols = r.columns.slice(0, maxCols)
|
|
158
158
|
const rows = r.rows.slice(0, maxRows)
|
|
159
|
-
|
|
160
|
-
const
|
|
159
|
+
// NULL → '—' (not 0); line breaks would split a numbered row; data must not form links / tags.
|
|
160
|
+
const cell = (c, v) => escape((c.kind === 'number' ? fmtNum(v) : cellText(v, 30)) || '—').replace(/\|/g, '|')
|
|
161
|
+
const out = [`**${cols.map(c => escape(c.name)).join(' | ')}**`, '', ...rows.map((row, i) => `${i + 1}. ${cols.map((c, j) => cell(c, row[j])).join(' | ')}`)]
|
|
161
162
|
const total = r.rowCount ?? r.rows.length
|
|
162
163
|
if (total > rows.length) out.push('', `> 共 ${total} 行,仅显示前 ${rows.length} 行;完整数据可在数据问答页面查看或导出 Excel`)
|
|
163
164
|
return out.join('\n')
|
|
@@ -167,7 +168,8 @@ export function resultAsDingText(r, maxRows = 10, maxCols = 6) {
|
|
|
167
168
|
export function stripTables(md) {
|
|
168
169
|
return String(md ?? '').replace(MD_TABLE, (m, lead, block) => {
|
|
169
170
|
const t = parseMdTable(block)
|
|
170
|
-
|
|
171
|
+
// Tables authored in the message text keep their Markdown (links etc.).
|
|
172
|
+
return `${lead}${resultAsDingText({ ...t, rowCount: t.rows.length }, 50, 8, { escape: (x) => String(x ?? '') })}\n`
|
|
171
173
|
})
|
|
172
174
|
}
|
|
173
175
|
|
|
@@ -179,18 +181,18 @@ export function toDingMarkdown(md) {
|
|
|
179
181
|
/** OutboundMessage → { title, text } for msgtype markdown / sampleMarkdown. */
|
|
180
182
|
export function renderMarkdown(message, { maxRows = 10 } = {}) {
|
|
181
183
|
const parts = []
|
|
182
|
-
if (message.title) parts.push(`### ${message.title}`)
|
|
184
|
+
if (message.title) parts.push(`### ${defangMd(message.title)}`)
|
|
183
185
|
if (message.text) parts.push(toDingMarkdown(message.text))
|
|
184
186
|
for (const r of (message.results ?? []).slice(0, 10)) {
|
|
185
|
-
const lines = [`#### ${r.title || '查询结果'}`, `> ${resultMeta(r)}`, '']
|
|
186
|
-
if (r.error) lines.push(r.error)
|
|
187
|
+
const lines = [`#### ${defangMd(r.title || '查询结果')}`, `> ${resultMeta(r)}`, '']
|
|
188
|
+
if (r.error) lines.push(defangMd(r.error))
|
|
187
189
|
else if (!r.rows?.length) lines.push('无数据')
|
|
188
190
|
else if (isKpi(r)) lines.push(kpiText(r, { separator: '\n\n' }))
|
|
189
191
|
else lines.push(resultAsDingText(r, maxRows))
|
|
190
192
|
if (r.sql && message.includeSql) lines.push('', '```sql\n' + r.sql.slice(0, 1500) + '\n```')
|
|
191
193
|
parts.push(lines.join('\n'))
|
|
192
194
|
}
|
|
193
|
-
if (message.footer) parts.push(toDingMarkdown(message.footer))
|
|
195
|
+
if (message.footer) parts.push(defangMd(toDingMarkdown(message.footer)))
|
|
194
196
|
const all = message.mentionAll || mentionsAll(message.text) || mentionsAll(message.footer)
|
|
195
197
|
if (all && !parts.some(p => p.includes('@所有人'))) parts.push('@所有人')
|
|
196
198
|
const title = String(message.title || '数据问答').slice(0, 60)
|
package/channels/lark.js
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import { createHash, createHmac } from 'node:crypto'
|
|
18
18
|
import { ChannelError, request } from './http.js'
|
|
19
|
-
import { MD_TABLE, cellText, chartOption, chartable, dualAxis, kpiText, isKpi, needsAttachment, num, omittedNote, parseMdTable, renderChartPng, resultMeta } from './message.js'
|
|
19
|
+
import { MD_TABLE, cellText, chartOption, chartable, dualAxis, escapeLarkMd, kpiText, isKpi, needsAttachment, num, oneLine, omittedNote, parseMdTable, renderChartPng, resultMeta } from './message.js'
|
|
20
20
|
|
|
21
21
|
export { ChannelError, ChannelError as ImError, chartOption, chartable, dualAxis, renderChartPng }
|
|
22
22
|
|
|
@@ -704,7 +704,13 @@ function tableElement(result, maxRows) {
|
|
|
704
704
|
row_height: 'low',
|
|
705
705
|
header_style: { background_style: 'grey', bold: true, text_size: 'normal' },
|
|
706
706
|
columns: cols.map((c, i) => ({ name: `c${i}`, display_name: c.name, data_type: c.kind === 'number' ? 'number' : 'text', ...(c.kind === 'number' ? { format: { separator: true } } : {}), width: 'auto' })),
|
|
707
|
-
|
|
707
|
+
// NULL / '' numbers stay empty (Number(null) would show 0); line breaks would break rows.
|
|
708
|
+
rows: result.rows.slice(0, maxRows).map(r => Object.fromEntries(cols.flatMap((c, i) => {
|
|
709
|
+
const v = r[i]
|
|
710
|
+
if (c.kind !== 'number') return [[`c${i}`, oneLine(cellText(v))]]
|
|
711
|
+
if (v === null || v === undefined || v === '') return [] // empty cell, not 0
|
|
712
|
+
return [[`c${i}`, Number.isFinite(Number(v)) ? Number(v) : oneLine(cellText(v))]]
|
|
713
|
+
}))),
|
|
708
714
|
}
|
|
709
715
|
}
|
|
710
716
|
|
|
@@ -840,15 +846,17 @@ export function buildCard(message, { maxRows = 50, charts = true, actions = true
|
|
|
840
846
|
for (const r of message.results ?? []) {
|
|
841
847
|
if (elements.length) elements.push({ tag: 'hr' })
|
|
842
848
|
if (r.error) {
|
|
843
|
-
elements.push({ tag: 'markdown', content: `**${r.title || '查询'}**\n<font color='red'>${r.error}</font>` })
|
|
849
|
+
elements.push({ tag: 'markdown', content: `**${escapeLarkMd(r.title || '查询')}**\n<font color='red'>${escapeLarkMd(r.error)}</font>` })
|
|
844
850
|
continue
|
|
845
851
|
}
|
|
846
|
-
|
|
852
|
+
// Everything data-derived (titles, values, errors, footer) is escaped: a cell like
|
|
853
|
+
// `<at id=all></at>` or `[x](https://…)` must show as text, not mention or link.
|
|
854
|
+
elements.push({ tag: 'markdown', content: [`**${escapeLarkMd(r.title || '查询结果')}**`, `<font color='grey'>${resultMeta(r, { escape: escapeLarkMd })}</font>`].join(' ') })
|
|
847
855
|
const nativeChart = charts && chartCount < 5 && chartable(r) ? chartElement(r) : undefined
|
|
848
856
|
if (r.rows.length === 0) {
|
|
849
857
|
elements.push({ tag: 'markdown', content: '<font color=\'grey\'>无数据</font>' })
|
|
850
858
|
} else if (isKpi(r)) {
|
|
851
|
-
elements.push({ tag: 'markdown', content: kpiText(r) })
|
|
859
|
+
elements.push({ tag: 'markdown', content: kpiText(r, { escape: escapeLarkMd }) })
|
|
852
860
|
} else if (nativeChart) {
|
|
853
861
|
elements.push(nativeChart)
|
|
854
862
|
chartCount++
|
|
@@ -860,7 +868,7 @@ export function buildCard(message, { maxRows = 50, charts = true, actions = true
|
|
|
860
868
|
const note = omittedNote(r, Math.min(maxRows, r.rows.length), { attached: Boolean(r.attached) })
|
|
861
869
|
if (note) elements.push({ tag: 'markdown', content: `<font color='grey'>${note}</font>`, text_size: 'notation' })
|
|
862
870
|
} else {
|
|
863
|
-
elements.push({ tag: 'markdown', content: r.rows.slice(0, 5).map(row => row.slice(0, 6).map(v => cellText(v)).join(' | ')).join('\n') })
|
|
871
|
+
elements.push({ tag: 'markdown', content: r.rows.slice(0, 5).map(row => row.slice(0, 6).map(v => escapeLarkMd(cellText(v))).join(' | ')).join('\n') })
|
|
864
872
|
const note = omittedNote(r, Math.min(5, r.rows.length), { attached: Boolean(r.attached) })
|
|
865
873
|
if (note) elements.push({ tag: 'markdown', content: `<font color='grey'>${note}</font>`, text_size: 'notation' })
|
|
866
874
|
}
|
|
@@ -871,7 +879,7 @@ export function buildCard(message, { maxRows = 50, charts = true, actions = true
|
|
|
871
879
|
}
|
|
872
880
|
if (r.sql && message.includeSql) elements.push({ tag: 'markdown', content: `\`\`\`sql\n${r.sql.slice(0, 1500)}\n\`\`\`` })
|
|
873
881
|
}
|
|
874
|
-
if (message.footer) elements.push({ tag: 'markdown', content: `<font color='grey'>${message.footer}</font>`, text_size: 'notation' })
|
|
882
|
+
if (message.footer) elements.push({ tag: 'markdown', content: `<font color='grey'>${escapeLarkMd(message.footer)}</font>`, text_size: 'notation' })
|
|
875
883
|
const card = {
|
|
876
884
|
schema: '2.0',
|
|
877
885
|
config: { wide_screen_mode: true, summary: { content: String(message.title).slice(0, 60) } },
|
|
@@ -894,10 +902,10 @@ export function legacyCard(card) {
|
|
|
894
902
|
const md = card.body.elements.map(e => {
|
|
895
903
|
if (e.tag === 'markdown') return e.content
|
|
896
904
|
if (e.tag === 'hr') return '---'
|
|
897
|
-
if (e.tag === 'table') return [e.columns.map(c => c.display_name).join(' | '), ...e.rows.slice(0, 10).map(r => e.columns.map(c => cellText(r[c.name])).join(' | '))].join('\n')
|
|
905
|
+
if (e.tag === 'table') return [e.columns.map(c => escapeLarkMd(c.display_name)).join(' | '), ...e.rows.slice(0, 10).map(r => e.columns.map(c => escapeLarkMd(cellText(r[c.name]))).join(' | '))].join('\n')
|
|
898
906
|
if (e.tag === 'chart') {
|
|
899
907
|
const v = e.chart_spec.data?.values ?? e.chart_spec.data?.[0]?.values ?? []
|
|
900
|
-
return v.slice(0, 10).map(x => Object.values(x).map(y => cellText(y)).join(' | ')).join('\n')
|
|
908
|
+
return v.slice(0, 10).map(x => Object.values(x).map(y => escapeLarkMd(cellText(y))).join(' | ')).join('\n')
|
|
901
909
|
}
|
|
902
910
|
return ''
|
|
903
911
|
}).filter(Boolean).join('\n\n')
|
package/channels/message.js
CHANGED
|
@@ -14,8 +14,9 @@ export function num(v) {
|
|
|
14
14
|
return v === null || v === undefined || v === '' ? null : Number(v)
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
/** Compact number: 1.23亿 / 4.56万 / integers as-is / two decimals. */
|
|
17
|
+
/** Compact number: 1.23亿 / 4.56万 / integers as-is / two decimals. NULL / '' → ''. */
|
|
18
18
|
export function fmtNum(v) {
|
|
19
|
+
if (v === null || v === undefined || v === '') return ''
|
|
19
20
|
const n = Number(v)
|
|
20
21
|
if (!Number.isFinite(n)) return String(v ?? '')
|
|
21
22
|
if (Math.abs(n) >= 1e8) return `${(n / 1e8).toFixed(2)}亿`
|
|
@@ -30,10 +31,44 @@ export function cellText(v, max = 80) {
|
|
|
30
31
|
return s.length > max ? `${s.slice(0, max - 1)}…` : s
|
|
31
32
|
}
|
|
32
33
|
|
|
33
|
-
/**
|
|
34
|
-
export function
|
|
34
|
+
/** Line breaks (CR, LF, CRLF) → one space: keeps table rows and inline Markdown on one line. */
|
|
35
|
+
export function oneLine(s) {
|
|
36
|
+
return String(s ?? '').replace(/\r\n?|\n/g, ' ')
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Lark card Markdown: data-derived text (cell values, column names, result titles,
|
|
41
|
+
* errors, footers) as literal text. Lark documents HTML numeric entities for
|
|
42
|
+
* characters that would trigger Markdown / tags (`<at>`, `<font>`, links, emphasis).
|
|
43
|
+
* https://open.larksuite.com/document/uAjLw4CM/ukzMukzMukzM/feishu-cards/card-components/content-components/rich-text
|
|
44
|
+
*/
|
|
45
|
+
export function escapeLarkMd(s) {
|
|
46
|
+
return oneLine(s)
|
|
47
|
+
.replace(/&(?=#?\w+;)/g, '&')
|
|
48
|
+
.replace(/[<>[\]*_~`\\]/g, ch => `&#${ch.charCodeAt(0)};`)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Neutralize link / tag syntax in data-derived text for platforms whose Markdown
|
|
53
|
+
* has no reliable escape (WeCom, DingTalk): `<` `>` `[` `]` become full-width look-alikes,
|
|
54
|
+
* so values cannot create links, images, `<@user>` mentions or `<font>` tags.
|
|
55
|
+
*/
|
|
56
|
+
export function defangMd(s) {
|
|
57
|
+
return oneLine(s).replace(/[<>[\]]/g, ch => ({ '<': '<', '>': '>', '[': '[', ']': ']' })[ch])
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* A KPI-style result (one row) as `**col**:value | …` Markdown. NULL shows as '—'.
|
|
62
|
+
* `escape` is applied to data-derived text (column names and values).
|
|
63
|
+
*/
|
|
64
|
+
export function kpiText(result, { separator = ' | ', bold = true, escape = defangMd } = {}) {
|
|
35
65
|
const row = result.rows?.[0] ?? []
|
|
36
|
-
return result.columns.map((c, i) =>
|
|
66
|
+
return result.columns.map((c, i) => {
|
|
67
|
+
const raw = c.kind === 'number' ? fmtNum(row[i]) : cellText(row[i])
|
|
68
|
+
const value = raw === '' ? '—' : escape(oneLine(raw))
|
|
69
|
+
const name = escape(oneLine(c.name))
|
|
70
|
+
return `${bold ? `**${name}**` : name}:${value}`
|
|
71
|
+
}).join(separator)
|
|
37
72
|
}
|
|
38
73
|
|
|
39
74
|
/** True when the result should be shown as KPI text rather than a table/chart. */
|
|
@@ -59,8 +94,8 @@ export function needsAttachment(result, shown) {
|
|
|
59
94
|
return (result.rowCount ?? result.rows.length) > shown
|
|
60
95
|
}
|
|
61
96
|
|
|
62
|
-
export function resultMeta(result) {
|
|
63
|
-
return `${result.rowCount ?? result.rows?.length ?? 0} 行${result.truncated ? '(已截断)' : ''}${result.filters?.length ? ` · 已筛选:${result.filters.map(f => f.description).join(';')}` : ''}`
|
|
97
|
+
export function resultMeta(result, { escape = defangMd } = {}) {
|
|
98
|
+
return `${result.rowCount ?? result.rows?.length ?? 0} 行${result.truncated ? '(已截断)' : ''}${result.filters?.length ? ` · 已筛选:${result.filters.map(f => escape(f.description)).join(';')}` : ''}`
|
|
64
99
|
}
|
|
65
100
|
|
|
66
101
|
// ------------------------------------------------------- chart rendering
|
|
@@ -207,7 +242,7 @@ export function toPlainText(md) {
|
|
|
207
242
|
}
|
|
208
243
|
|
|
209
244
|
function mdCell(v, max = 40) {
|
|
210
|
-
return cellText(v, max).replace(/\|/g, '\\|')
|
|
245
|
+
return defangMd(cellText(v, max)).replace(/\|/g, '\\|')
|
|
211
246
|
}
|
|
212
247
|
|
|
213
248
|
/** A result as a Markdown table (≤ maxRows rows, ≤ maxCols columns), with a "… N more" note when cut. */
|
|
@@ -229,7 +264,7 @@ export function resultAsMarkdownTable(result, maxRows = 10, { maxCols = 8 } = {}
|
|
|
229
264
|
export function resultAsText(result, maxRows = 10, { maxCols = 6 } = {}) {
|
|
230
265
|
const cols = (result.columns ?? []).slice(0, maxCols)
|
|
231
266
|
const rows = (result.rows ?? []).slice(0, maxRows)
|
|
232
|
-
const lines = [cols.map(c => c.name).join(' | '), ...rows.map(r => cols.map((c, i) => (c.kind === 'number' ? fmtNum(r[i]) : cellText(r[i], 30))).join(' | '))]
|
|
267
|
+
const lines = [cols.map(c => c.name).join(' | '), ...rows.map(r => cols.map((c, i) => oneLine(c.kind === 'number' ? fmtNum(r[i]) : cellText(r[i], 30))).join(' | '))]
|
|
233
268
|
const total = result.rowCount ?? result.rows?.length ?? 0
|
|
234
269
|
if (total > rows.length) lines.push(`…共 ${total} 行`)
|
|
235
270
|
return lines.join('\n')
|
|
@@ -243,11 +278,12 @@ export function resultAsText(result, maxRows = 10, { maxCols = 6 } = {}) {
|
|
|
243
278
|
export function messageAsMarkdown(message, { markdown = 'basic', maxRows = 10, sqlMax = 1500 } = {}) {
|
|
244
279
|
const clean = markdown === 'full' ? normalizeMarkdown : markdown === 'none' ? toPlainText : toPlainMarkdown
|
|
245
280
|
const parts = []
|
|
246
|
-
if (message.title) parts.push(markdown === 'none' ?
|
|
281
|
+
if (message.title) parts.push(markdown === 'none' ? oneLine(message.title) : `**${defangMd(message.title)}**`)
|
|
247
282
|
if (message.text) parts.push(clean(message.text))
|
|
248
283
|
for (const r of message.results ?? []) {
|
|
249
|
-
|
|
250
|
-
|
|
284
|
+
// Result titles, errors and cells are data-derived: no Markdown / tags from them.
|
|
285
|
+
const head = markdown === 'none' ? oneLine(r.title || '查询结果') : `**${defangMd(r.title || '查询结果')}**`
|
|
286
|
+
if (r.error) { parts.push(`${head}\n${markdown === 'none' ? r.error : defangMd(r.error)}`); continue }
|
|
251
287
|
const lines = [`${head} ${resultMeta(r)}`]
|
|
252
288
|
if (!r.rows?.length) lines.push('无数据')
|
|
253
289
|
else if (isKpi(r)) lines.push(kpiText(r, { bold: markdown !== 'none' }))
|
|
@@ -255,7 +291,7 @@ export function messageAsMarkdown(message, { markdown = 'basic', maxRows = 10, s
|
|
|
255
291
|
if (r.sql && message.includeSql) lines.push(markdown === 'none' ? r.sql.slice(0, sqlMax) : `\`\`\`sql\n${r.sql.slice(0, sqlMax)}\n\`\`\``)
|
|
256
292
|
parts.push(lines.join('\n'))
|
|
257
293
|
}
|
|
258
|
-
if (message.footer) parts.push(
|
|
294
|
+
if (message.footer) parts.push(markdown === 'none' ? toPlainText(message.footer) : defangMd(toPlainMarkdown(message.footer)))
|
|
259
295
|
return parts.join('\n\n')
|
|
260
296
|
}
|
|
261
297
|
|
package/channels/telegram.js
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
*/
|
|
19
19
|
import { randomBytes } from 'node:crypto'
|
|
20
20
|
import { ChannelError, request } from './http.js'
|
|
21
|
-
import { renderChartPng as defaultRenderChartPng, chartable, resultMeta, isKpi, kpiText, resultAsMarkdownTable, toPlainMarkdown } from './message.js'
|
|
21
|
+
import { renderChartPng as defaultRenderChartPng, chartable, oneLine, resultMeta, isKpi, kpiText, resultAsMarkdownTable, toPlainMarkdown } from './message.js'
|
|
22
22
|
|
|
23
23
|
export const DEFAULT_API_BASE = 'https://api.telegram.org'
|
|
24
24
|
export const TEXT_LIMIT = 4096
|
|
@@ -280,12 +280,19 @@ export function inlineKeyboard(spec) {
|
|
|
280
280
|
|
|
281
281
|
// ------------------------------------------------------------ rendering
|
|
282
282
|
|
|
283
|
+
/**
|
|
284
|
+
* Data-derived text in Telegram Markdown: HTML is escaped by the converter anyway, so only
|
|
285
|
+
* link syntax and line breaks are neutralized (`[x](url)` must not become a link).
|
|
286
|
+
*/
|
|
287
|
+
const defangMd = (s) => oneLine(s).replace(/[[\]]/g, ch => (ch === '[' ? '[' : ']'))
|
|
288
|
+
|
|
283
289
|
function resultMarkdown(r, { includeSql, maxRows = 20 } = {}) {
|
|
284
|
-
|
|
285
|
-
|
|
290
|
+
// Data-derived text (title, error, values) is defanged: no links / tags / mentions from data.
|
|
291
|
+
const head = `**${defangMd(r.title || '查询结果')}** ${resultMeta(r)}`
|
|
292
|
+
if (r.error) return `${head}\n${defangMd(r.error)}`
|
|
286
293
|
const lines = [head]
|
|
287
294
|
if (!r.rows?.length) lines.push('无数据')
|
|
288
|
-
else if (isKpi(r)) lines.push(kpiText(r, { separator: '\n' }))
|
|
295
|
+
else if (isKpi(r)) lines.push(kpiText(r, { separator: '\n', escape: defangMd }))
|
|
289
296
|
else lines.push(resultAsMarkdownTable(r, maxRows, { maxCols: 6 }))
|
|
290
297
|
if (r.sql && includeSql) lines.push('```sql\n' + r.sql.slice(0, 1500) + '\n```')
|
|
291
298
|
return lines.join('\n')
|
|
@@ -297,7 +304,7 @@ function resultMarkdown(r, { includeSql, maxRows = 20 } = {}) {
|
|
|
297
304
|
*/
|
|
298
305
|
export function planMessage(message, { buttons = true } = {}) {
|
|
299
306
|
const segs = []
|
|
300
|
-
const head = [message.title ? `**${message.title}**` : '', message.text ?? ''].filter(Boolean).join('\n\n')
|
|
307
|
+
const head = [message.title ? `**${defangMd(message.title)}**` : '', message.text ?? ''].filter(Boolean).join('\n\n')
|
|
301
308
|
let text = head
|
|
302
309
|
const flush = (keyboard) => { if (text.trim() || keyboard) segs.push({ kind: 'text', md: text, ...(keyboard ? { keyboard } : {}) }); text = '' }
|
|
303
310
|
for (const r of (message.results ?? []).slice(0, 10)) {
|
|
@@ -310,7 +317,7 @@ export function planMessage(message, { buttons = true } = {}) {
|
|
|
310
317
|
text = [text, resultMarkdown(r, { includeSql: message.includeSql })].filter(s => s.trim()).join('\n\n')
|
|
311
318
|
if (keyboard) flush(keyboard)
|
|
312
319
|
}
|
|
313
|
-
if (message.footer) text = [text, message.footer].filter(s => s.trim()).join('\n\n')
|
|
320
|
+
if (message.footer) text = [text, defangMd(toPlainMarkdown(message.footer, { mentionAll: '' }))].filter(s => s.trim()).join('\n\n')
|
|
314
321
|
flush()
|
|
315
322
|
return segs
|
|
316
323
|
}
|
package/channels/wecom.js
CHANGED
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
*/
|
|
37
37
|
import { createHash, randomBytes } from 'node:crypto'
|
|
38
38
|
import { ChannelError, postJson } from './http.js'
|
|
39
|
-
import { renderChartPng as defaultRenderChartPng, chartable, resultMeta, isKpi, kpiText, resultAsMarkdownTable, toPlainMarkdown, mentionsAll, splitText } from './message.js'
|
|
39
|
+
import { renderChartPng as defaultRenderChartPng, chartable, cellText, defangMd, fmtNum, resultMeta, isKpi, kpiText, resultAsMarkdownTable, toPlainMarkdown, mentionsAll, splitText } from './message.js'
|
|
40
40
|
|
|
41
41
|
export const WEBHOOK_HOST = 'https://qyapi.weixin.qq.com'
|
|
42
42
|
export const DEFAULT_GATEWAY = 'wss://openws.work.weixin.qq.com'
|
|
@@ -95,8 +95,9 @@ export function toWecomMarkdown(md) {
|
|
|
95
95
|
}
|
|
96
96
|
|
|
97
97
|
function resultBlock(r, { includeSql, maxRows = 10, table = true } = {}) {
|
|
98
|
-
|
|
99
|
-
|
|
98
|
+
// Data-derived text (title, error, values) is defanged: no links / tags / mentions from data.
|
|
99
|
+
const head = `**${defangMd(r.title || '查询结果')}** ${resultMeta(r)}`
|
|
100
|
+
if (r.error) return `${head}\n${defangMd(r.error)}`
|
|
100
101
|
const lines = [head]
|
|
101
102
|
if (!r.rows?.length) lines.push('无数据')
|
|
102
103
|
else if (isKpi(r)) lines.push(kpiText(r, { separator: '\n' }))
|
|
@@ -109,7 +110,7 @@ function resultBlock(r, { includeSql, maxRows = 10, table = true } = {}) {
|
|
|
109
110
|
function resultAsText(r, maxRows) {
|
|
110
111
|
const cols = r.columns.slice(0, 6)
|
|
111
112
|
const rows = r.rows.slice(0, maxRows)
|
|
112
|
-
const out = [`> ${cols.map(c => c.name).join(' | ')}`, ...rows.map(row => `> ${cols.map((c, i) =>
|
|
113
|
+
const out = [`> ${cols.map(c => defangMd(c.name)).join(' | ')}`, ...rows.map(row => `> ${cols.map((c, i) => defangMd(c.kind === 'number' ? fmtNum(row[i]) : cellText(row[i], 60))).join(' | ')}`)]
|
|
113
114
|
if ((r.rowCount ?? r.rows.length) > rows.length) out.push(`<font color="comment">…共 ${r.rowCount ?? r.rows.length} 行</font>`)
|
|
114
115
|
return out.join('\n')
|
|
115
116
|
}
|
|
@@ -121,19 +122,19 @@ function resultAsText(r, maxRows) {
|
|
|
121
122
|
export function planWebhook(message) {
|
|
122
123
|
const sections = []
|
|
123
124
|
const images = []
|
|
124
|
-
if (message.title) sections.push(`## ${message.title}`)
|
|
125
|
+
if (message.title) sections.push(`## ${defangMd(message.title)}`)
|
|
125
126
|
if (message.text) sections.push(message.text)
|
|
126
127
|
let hasTable = /(^|\n)\|[^\n]*\|\s*\n\|[\s:|-]+\|/.test(message.text ?? '')
|
|
127
128
|
for (const r of (message.results ?? []).slice(0, 10)) {
|
|
128
129
|
if (message.chartMode !== 'table' && !r.error && chartable(r)) {
|
|
129
|
-
sections.push(`**${r.title || '查询结果'}** ${resultMeta(r)}(见下方图表)${r.sql && message.includeSql ? '\n```sql\n' + r.sql.slice(0, 1500) + '\n```' : ''}`)
|
|
130
|
+
sections.push(`**${defangMd(r.title || '查询结果')}** ${resultMeta(r)}(见下方图表)${r.sql && message.includeSql ? '\n```sql\n' + r.sql.slice(0, 1500) + '\n```' : ''}`)
|
|
130
131
|
images.push({ msgtype: 'image', result: r })
|
|
131
132
|
continue
|
|
132
133
|
}
|
|
133
134
|
if (r.rows?.length && !isKpi(r) && !r.error) hasTable = true
|
|
134
135
|
sections.push(resultBlock(r, { includeSql: message.includeSql }))
|
|
135
136
|
}
|
|
136
|
-
if (message.footer) sections.push(message.footer)
|
|
137
|
+
if (message.footer) sections.push(defangMd(toPlainMarkdown(message.footer, { mentionAll: '' })))
|
|
137
138
|
const md = sections.filter(Boolean).join('\n\n')
|
|
138
139
|
const msgtype = hasTable ? 'markdown_v2' : 'markdown'
|
|
139
140
|
const content = msgtype === 'markdown_v2' ? toPlainMarkdown(md, { mentionAll: '' }) : toWecomMarkdown(md)
|
|
@@ -238,10 +239,10 @@ function inboundText(body) {
|
|
|
238
239
|
|
|
239
240
|
function aibotMarkdown(message) {
|
|
240
241
|
const parts = []
|
|
241
|
-
if (message.title) parts.push(`**${message.title}**`)
|
|
242
|
+
if (message.title) parts.push(`**${defangMd(message.title)}**`)
|
|
242
243
|
if (message.text) parts.push(toPlainMarkdown(message.text, { mentionAll: '' }))
|
|
243
244
|
for (const r of (message.results ?? []).slice(0, 10)) parts.push(resultBlock(r, { includeSql: message.includeSql }))
|
|
244
|
-
if (message.footer) parts.push(toPlainMarkdown(message.footer, { mentionAll: '' }))
|
|
245
|
+
if (message.footer) parts.push(defangMd(toPlainMarkdown(message.footer, { mentionAll: '' })))
|
|
245
246
|
return parts.join('\n\n')
|
|
246
247
|
}
|
|
247
248
|
|
package/filters.js
CHANGED
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
* place safely (top-level UNION, no FROM) run unfiltered and report so.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
+
import { lexSql } from './sql-guard.js'
|
|
15
|
+
|
|
14
16
|
export const TIME_RANGES = ['today', 'yesterday', 'last7d', 'last30d', 'last90d', 'thisMonth', 'lastMonth', 'thisYear', 'custom']
|
|
15
17
|
|
|
16
18
|
export function normalizeFilters(input) {
|
|
@@ -68,50 +70,50 @@ export function describeFilter(f) {
|
|
|
68
70
|
|
|
69
71
|
const CLAUSE_END = /^(WHERE|GROUP|HAVING|ORDER|LIMIT|WINDOW|QUALIFY|FETCH|OFFSET|UNION|INTERSECT|EXCEPT|FOR|PIVOT)$/i
|
|
70
72
|
|
|
71
|
-
/**
|
|
72
|
-
|
|
73
|
+
/**
|
|
74
|
+
* Top-level (depth 0) word tokens of the outermost SELECT, with offsets.
|
|
75
|
+
* Tokenised with the dialect-exact lexer from sql-guard.js, so comments,
|
|
76
|
+
* strings and quoted identifiers are recognised exactly as the server does
|
|
77
|
+
* (e.g. MySQL `1--1` is code, not a comment).
|
|
78
|
+
*/
|
|
79
|
+
function topLevelWords(sql, dialect = 'mysql') {
|
|
73
80
|
const words = []
|
|
74
81
|
let depth = 0
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
if (
|
|
79
|
-
|
|
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 })
|
|
82
|
+
const { segments } = lexSql(sql, { dialect })
|
|
83
|
+
for (const seg of segments) {
|
|
84
|
+
if (seg.kind === 'comment') continue
|
|
85
|
+
if (seg.kind === 'string' || seg.kind === 'ident') {
|
|
86
|
+
if (depth === 0) words.push({ kind: 'quoted', text: seg.text, start: seg.start, end: seg.end })
|
|
91
87
|
continue
|
|
92
88
|
}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
if (depth === 0) words.push({ kind: '
|
|
99
|
-
|
|
100
|
-
|
|
89
|
+
const text = seg.text
|
|
90
|
+
for (let k = 0; k < text.length;) {
|
|
91
|
+
const c = text[k]
|
|
92
|
+
const i = seg.start + k
|
|
93
|
+
if (c === '(') { depth++; if (depth === 1) words.push({ kind: 'open', start: i, end: i + 1 }); k++; continue }
|
|
94
|
+
if (c === ')') { depth--; if (depth === 0) words.push({ kind: 'close', start: i, end: i + 1 }); k++; continue }
|
|
95
|
+
if (/[A-Za-z_]/.test(c)) {
|
|
96
|
+
let j = k
|
|
97
|
+
while (j < text.length && /[\w$.\-*]/.test(text[j]) && !(text[j] === '-' && text[j + 1] === '-')) j++
|
|
98
|
+
if (depth === 0) words.push({ kind: 'word', text: text.slice(k, j), upper: text.slice(k, j).toUpperCase(), start: i, end: seg.start + j })
|
|
99
|
+
k = j
|
|
100
|
+
continue
|
|
101
|
+
}
|
|
102
|
+
if (c === ',' && depth === 0) words.push({ kind: 'comma', start: i, end: i + 1 })
|
|
103
|
+
k++
|
|
101
104
|
}
|
|
102
|
-
if (c === ',' && depth === 0) words.push({ kind: 'comma', start: i, end: i + 1 })
|
|
103
|
-
i++
|
|
104
105
|
}
|
|
105
106
|
return words
|
|
106
107
|
}
|
|
107
108
|
|
|
108
109
|
function unquote(t) {
|
|
110
|
+
if (t.startsWith('[') && t.endsWith(']')) return t.slice(1, -1)
|
|
109
111
|
return t.replace(/^[`"]|[`"]$/g, '').replace(/``/g, '`').replace(/""/g, '"')
|
|
110
112
|
}
|
|
111
113
|
|
|
112
114
|
/** Parse the outermost query: FROM items with aliases, WHERE span, insertion point. */
|
|
113
|
-
export function parseOuterQuery(sql) {
|
|
114
|
-
const words = topLevelWords(sql)
|
|
115
|
+
export function parseOuterQuery(sql, { dialect = 'mysql' } = {}) {
|
|
116
|
+
const words = topLevelWords(sql, dialect)
|
|
115
117
|
// Skip a leading WITH ... list: the main SELECT is the first top-level SELECT after the CTE bodies.
|
|
116
118
|
let selectIdx = words.findIndex(w => w.kind === 'word' && w.upper === 'SELECT')
|
|
117
119
|
if (words[0]?.upper === 'WITH') {
|
|
@@ -190,7 +192,7 @@ function literalFor(value, dialect) {
|
|
|
190
192
|
*/
|
|
191
193
|
export function applyFilters(sql, filters, { dialect, schema, now }) {
|
|
192
194
|
if (!filters.length) return { sql, applied: [], skipped: [] }
|
|
193
|
-
const outer = parseOuterQuery(sql)
|
|
195
|
+
const outer = parseOuterQuery(sql, { dialect })
|
|
194
196
|
if (!outer || !outer.tables.length) return { sql, applied: [], skipped: filters.map(f => ({ ...f, reason: 'unsupported query shape' })) }
|
|
195
197
|
const tables = schema?.tables ?? []
|
|
196
198
|
const resolve = (ref) => {
|
|
@@ -224,14 +226,66 @@ export function applyFilters(sql, filters, { dialect, schema, now }) {
|
|
|
224
226
|
}
|
|
225
227
|
if (!predicates.length) return { sql, applied, skipped }
|
|
226
228
|
const pred = predicates.join(' AND ')
|
|
229
|
+
// A trailing line comment would swallow whatever follows on the same line: break the line first.
|
|
230
|
+
const nl = (text) => (endsInLineComment(text, dialect) ? '\n' : '')
|
|
227
231
|
let out
|
|
228
232
|
if (outer.where) {
|
|
233
|
+
const head = sql.slice(0, outer.where.keywordStart)
|
|
229
234
|
const existing = sql.slice(outer.where.keywordEnd, outer.where.end).trim()
|
|
230
|
-
out = `${
|
|
235
|
+
out = `${head}${nl(head)}WHERE (${pred}) AND (${existing}${nl(existing)})${outer.where.end < sql.length ? ' ' : ''}${sql.slice(outer.where.end)}`
|
|
231
236
|
} else {
|
|
232
237
|
const before = sql.slice(0, outer.insertAt).replace(/\s+$/, '')
|
|
233
238
|
const after = sql.slice(outer.insertAt)
|
|
234
|
-
out = `${before} WHERE ${pred}${after ? ` ${after}` : ''}`
|
|
239
|
+
out = `${before}${nl(before) || ' '}WHERE ${pred}${after ? ` ${after}` : ''}`
|
|
235
240
|
}
|
|
241
|
+
verifyInjection(out, pred, Boolean(outer.where), outer.tables, dialect)
|
|
236
242
|
return { sql: out, applied, skipped }
|
|
237
243
|
}
|
|
244
|
+
|
|
245
|
+
/** Raised when an injected predicate would not be a top-level conjunct of the rewritten query. */
|
|
246
|
+
export class FilterInjectionError extends Error {
|
|
247
|
+
constructor(message) {
|
|
248
|
+
super(message)
|
|
249
|
+
this.code = 'nl2sql/filter-injection'
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function endsInLineComment(text, dialect) {
|
|
254
|
+
const segs = lexSql(text, { dialect }).segments
|
|
255
|
+
const last = segs[segs.length - 1]
|
|
256
|
+
return Boolean(last && last.kind === 'comment' && last.end === text.length && !last.text.startsWith('/*'))
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const tokenSig = (w) => (w.kind === 'word' ? `w:${w.upper}` : w.kind === 'quoted' ? `q:${w.text}` : w.kind)
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Defence in depth: re-lex the rewritten SQL with the dialect lexer and make
|
|
263
|
+
* sure the injected predicate is a top-level conjunct of the outer WHERE —
|
|
264
|
+
* balanced parentheses, the same FROM items, and a WHERE body made of exactly
|
|
265
|
+
* `(pred) AND (existing)` or exactly `pred`. Anything else (a stray `)`, a
|
|
266
|
+
* top-level OR next to the predicate, a comment swallowing it) is refused.
|
|
267
|
+
*/
|
|
268
|
+
function verifyInjection(out, pred, hadWhere, tables, dialect) {
|
|
269
|
+
const fail = () => { throw new FilterInjectionError('filter predicate could not be injected as a top-level condition; query refused') }
|
|
270
|
+
const lexed = lexSql(out, { dialect })
|
|
271
|
+
if (lexed.unterminated) fail()
|
|
272
|
+
let depth = 0
|
|
273
|
+
for (const seg of lexed.segments) {
|
|
274
|
+
if (seg.kind !== 'code') continue
|
|
275
|
+
for (const c of seg.text) {
|
|
276
|
+
if (c === '(') depth++
|
|
277
|
+
else if (c === ')' && --depth < 0) fail()
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
if (depth !== 0) fail()
|
|
281
|
+
const outer = parseOuterQuery(out, { dialect })
|
|
282
|
+
if (!outer?.where) fail()
|
|
283
|
+
const sameTables = outer.tables.length === tables.length && outer.tables.every((t, k) => t.name === tables[k].name && t.alias === tables[k].alias)
|
|
284
|
+
if (!sameTables) fail()
|
|
285
|
+
const body = topLevelWords(out, dialect).filter(w => w.start >= outer.where.keywordEnd && w.start < outer.where.end).map(tokenSig)
|
|
286
|
+
const expected = hadWhere ? ['open', 'close', 'w:AND', 'open', 'close'] : topLevelWords(pred, dialect).map(tokenSig)
|
|
287
|
+
if (body.length !== expected.length || body.some((t, k) => t !== expected[k])) fail()
|
|
288
|
+
// The predicate text must sit right after WHERE (inside the first parenthesis when ANDed with an existing condition).
|
|
289
|
+
const at = out.slice(outer.where.keywordEnd).replace(/^\s+/, '')
|
|
290
|
+
if (!at.startsWith(hadWhere ? `(${pred})` : pred)) fail()
|
|
291
|
+
}
|
package/index.js
CHANGED
|
@@ -188,7 +188,8 @@ function registerTools(ctx, service) {
|
|
|
188
188
|
if (!args.datasource) throw new Error('`datasource` is required for action=use')
|
|
189
189
|
const ds = service.requireDatasource(args.datasource)
|
|
190
190
|
service.assertAccess(actor, ds)
|
|
191
|
-
|
|
191
|
+
// The agent tool binds only this session; the page's current data source is left alone.
|
|
192
|
+
const info = await service.bindSession(sessionId, ds.id, { setCurrent: false })
|
|
192
193
|
return { bound: info.datasourceId, datasources: visible.map(d => pick(d, ['id', 'name', 'type', 'database', 'serverVersion'])) }
|
|
193
194
|
}
|
|
194
195
|
const info = sessionId ? service.sessionInfo(sessionId) : null
|
|
@@ -353,7 +354,8 @@ function registerOverviewTool(ctx, service) {
|
|
|
353
354
|
const ds = service.datasourceForSession(exec.agent?.id, args.datasource)
|
|
354
355
|
service.assertCanWriteSql(service.actorFor(exec.agent?.id), '生成自动概览')
|
|
355
356
|
if (args.pin && service.actorFor(exec.agent?.id)) throw new Error('只有本机用户可以把图表固定到看板')
|
|
356
|
-
const
|
|
357
|
+
const sessionId = exec.agent?.id
|
|
358
|
+
const { table, results } = await service.tableOverview({ datasourceId: ds.id, table: args.table, pin: Boolean(args.pin), sessionId, actor: service.actorFor(sessionId), source: 'agent' })
|
|
357
359
|
return {
|
|
358
360
|
model: { table, pinned: Boolean(args.pin), charts: results.map(r => (r.error ? { title: r.title, error: r.error } : { title: r.title, ...service.modelView(r) })) },
|
|
359
361
|
display: { overview: true, table, results: results.map(r => (r.error ? { title: r.title, sql: r.sql, error: r.error } : displayMeta(r))) },
|