@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/locale/en.json ADDED
@@ -0,0 +1 @@
1
+ {"meta": {"title": "Data Q&A (NL2SQL)", "description": "Open Data Q&A in the sidebar to connect MySQL 5.7+, PostgreSQL or Elasticsearch (optionally over SSH), or upload CSV / Excel. After schema analysis, ask in plain language: the agent writes read-only SQL, runs it and charts the result in the conversation. Includes dashboards with global filters, drill-down and change attribution, a metric library and business knowledge, accuracy evaluation, alerts and scheduled reports, Lark / Feishu push and an @-mention bot, member scopes (data sources, viewer role, row-level rules) and audit. A setting controls what result data reaches the model (column statistics by default). Passwords live in the Harness credential store; data and logs stay in ~/.dsh/nl2sql. Turning the plugin off stops its page, tools and scheduled checks; saved data is kept."}}
package/locale/zh.json ADDED
@@ -0,0 +1 @@
1
+ {"meta": {"title": "数据问答(NL2SQL)", "description": "在左侧栏「数据问答」中连接 MySQL 5.7+ / PostgreSQL / Elasticsearch(可经 SSH 隧道)或上传 CSV / Excel,分析表结构后用自然语言提问:Agent 写只读 SQL、执行并在对话中画图。支持看板与全局筛选、下钻与变化归因、指标库与业务知识、准确率评测、告警与定时报告、飞书推送与群内 @机器人问答、成员权限(数据源范围、只读角色、行级权限)和审计。查询结果按设置决定发给模型的内容(默认只发统计摘要);数据源密码保存在 Harness 凭据库;数据和日志保存在本机 ~/.dsh/nl2sql。关闭插件后页面、工具和定时检查停止,已保存的数据保留。"}}
package/package.json CHANGED
@@ -1,6 +1,97 @@
1
1
  {
2
2
  "name": "@hedeqiang/dsh-nl2sql",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.11.1",
4
+ "description": "Data Q&A (NL2SQL) for DeepSeek Harness: ask MySQL / PostgreSQL / Elasticsearch / CSV in plain language, charts, dashboards, alerts, IM push and bots (Lark/Feishu, Telegram, WeCom, DingTalk), access control.",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": "./index.js",
8
+ "./client": "./lib/client.js",
9
+ "./locale/*.json": "./locale/*.json",
10
+ "./icon": "./icon.svg"
11
+ },
12
+ "files": [
13
+ "index.js",
14
+ "service/*.js",
15
+ "channels/*.js",
16
+ "drivers.js",
17
+ "policy.js",
18
+ "filters.js",
19
+ "im.js",
20
+ "store.js",
21
+ "xlsx.js",
22
+ "analysis.js",
23
+ "lark-bot.js",
24
+ "sqlite-driver.js",
25
+ "sql-guard.js",
26
+ "lib/client.js",
27
+ "lib/client.echarts.js",
28
+ "cordis.patch.yml",
29
+ "locale/*.json",
30
+ "icon.svg",
31
+ "README.md",
32
+ "README.en.md",
33
+ "LICENSE",
34
+ "CHANGELOG.md",
35
+ "docs/CHANNELS.md"
36
+ ],
37
+ "dependencies": {
38
+ "@larksuiteoapi/node-sdk": "1.76.0",
39
+ "@resvg/resvg-js": "2.6.2",
40
+ "echarts": "6.1.0",
41
+ "mysql2": "3.24.6",
42
+ "pg": "8.23.1",
43
+ "ssh2": "1.17.0"
44
+ },
45
+ "scripts": {
46
+ "build": "node scripts/build-echarts.mjs",
47
+ "test": "node test/run.mjs",
48
+ "build:client": "node scripts/build-client.mjs",
49
+ "test:fast": "node test/run.mjs --fast",
50
+ "check": "node scripts/build-client.mjs --check && node test/run.mjs --fast"
51
+ },
52
+ "dsh": {
53
+ "bundle": {
54
+ "patch": "./cordis.patch.yml"
55
+ },
56
+ "client": {
57
+ "platform": "web",
58
+ "immediately": true,
59
+ "inject": [
60
+ "@deepseek-ai/dsh-client-connection",
61
+ "@deepseek-ai/dsh-client-locale",
62
+ "@deepseek-ai/dsh-client-ui-layout",
63
+ "@deepseek-ai/dsh-client-ui-sidebar",
64
+ "@deepseek-ai/dsh-client-ui-conversation",
65
+ "@deepseek-ai/dsh-client-ui-workspace"
66
+ ]
67
+ }
68
+ },
69
+ "license": "MIT",
70
+ "author": "hedeqiang <laravel_code@163.com>",
71
+ "keywords": [
72
+ "bi",
73
+ "deepseek-harness",
74
+ "dingtalk",
75
+ "dsh",
76
+ "dsh-bundle",
77
+ "elasticsearch",
78
+ "feishu",
79
+ "lark",
80
+ "mysql",
81
+ "nl2sql",
82
+ "postgresql",
83
+ "telegram",
84
+ "text-to-sql",
85
+ "wecom"
86
+ ],
87
+ "engines": {
88
+ "node": ">=24"
89
+ },
90
+ "repository": {
91
+ "type": "git",
92
+ "url": "git+https://github.com/hedeqiang/dsh-nl2sql.git"
93
+ },
94
+ "publishConfig": {
95
+ "access": "public"
96
+ }
97
+ }
package/policy.js ADDED
@@ -0,0 +1,362 @@
1
+ import { lexSql } from './sql-guard.js'
2
+ /**
3
+ * Table/column access policy and SQL identifier analysis.
4
+ *
5
+ * Policy shape (per data source):
6
+ * { mode: 'all' | 'allow' | 'deny', tables: string[], hiddenColumns: string[], maskedColumns: string[] }
7
+ * Patterns are case-insensitive globs: `orders`, `ods_*`, `sales.*`; column
8
+ * patterns are `table.column` (`*.salary`, `hr_*.id_card`).
9
+ *
10
+ * Enforcement is syntactic and conservative: a statement is refused when it
11
+ * names a table the policy excludes, a system catalog, or a hidden column of a
12
+ * referenced table; hidden columns that arrive anyway (SELECT *) are dropped
13
+ * from the result. Database grants remain the strict boundary.
14
+ */
15
+
16
+ export const EMPTY_POLICY = Object.freeze({ mode: 'all', tables: [], hiddenColumns: [], maskedColumns: [] })
17
+
18
+ export function normalizePolicy(input) {
19
+ if (!input || typeof input !== 'object') return { ...EMPTY_POLICY }
20
+ const list = (v) => (Array.isArray(v) ? v : String(v ?? '').split(/[\n,,]/))
21
+ .map(x => String(x).trim()).filter(Boolean).slice(0, 2000).map(x => x.slice(0, 200))
22
+ const mode = ['all', 'allow', 'deny'].includes(input.mode) ? input.mode : 'all'
23
+ return { mode, tables: [...new Set(list(input.tables))], hiddenColumns: [...new Set(list(input.hiddenColumns))], maskedColumns: [...new Set(list(input.maskedColumns))] }
24
+ }
25
+
26
+ export function policyActive(policy) {
27
+ return Boolean(policy) && (policy.mode !== 'all' || policy.hiddenColumns.length > 0 || policy.maskedColumns.length > 0)
28
+ }
29
+
30
+ const globCache = new Map()
31
+ export function glob(pattern) {
32
+ const key = pattern.toLowerCase()
33
+ let re = globCache.get(key)
34
+ if (!re) {
35
+ re = new RegExp(`^${key.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.')}$`)
36
+ globCache.set(key, re)
37
+ }
38
+ return re
39
+ }
40
+
41
+ function matchAny(patterns, name) {
42
+ const n = name.toLowerCase()
43
+ const last = n.split('.').pop()
44
+ return patterns.some(p => {
45
+ const re = glob(p)
46
+ return re.test(n) || (!p.includes('.') && re.test(last))
47
+ })
48
+ }
49
+
50
+ /** Whether the policy lets the model see / query this table. */
51
+ export function tableAllowed(policy, table) {
52
+ if (!policy || policy.mode === 'all') return true
53
+ const hit = matchAny(policy.tables, table)
54
+ return policy.mode === 'allow' ? hit : !hit
55
+ }
56
+
57
+ function columnMatches(patterns, table, column) {
58
+ const t = table.toLowerCase()
59
+ const tl = t.split('.').pop()
60
+ const c = column.toLowerCase()
61
+ return patterns.some(p => {
62
+ const i = p.lastIndexOf('.')
63
+ if (i < 0) return glob(p).test(c)
64
+ const tp = glob(p.slice(0, i))
65
+ return (tp.test(t) || tp.test(tl)) && glob(p.slice(i + 1)).test(c)
66
+ })
67
+ }
68
+
69
+ export function columnHidden(policy, table, column) {
70
+ return Boolean(policy) && columnMatches(policy.hiddenColumns, table, column)
71
+ }
72
+
73
+ export function columnMasked(policy, table, column) {
74
+ return Boolean(policy) && columnMatches(policy.maskedColumns, table, column)
75
+ }
76
+
77
+ /** Schema as the model may see it: excluded tables and hidden columns removed. */
78
+ export function filterSchema(schema, policy) {
79
+ if (!policyActive(policy)) return schema
80
+ const tables = schema.tables
81
+ .filter(t => tableAllowed(policy, t.name))
82
+ .map(t => ({
83
+ ...t,
84
+ columns: t.columns.filter(c => !columnHidden(policy, t.name, c.name)).map(c => (columnMasked(policy, t.name, c.name) ? { ...c, values: undefined, roles: [...new Set([...c.roles, 'sensitive'])] } : c)),
85
+ indexes: t.indexes.filter(i => i.columns.every(c => !columnHidden(policy, t.name, c))),
86
+ }))
87
+ const visible = new Set(tables.map(t => t.name))
88
+ const hiddenCol = (tbl, col) => columnHidden(policy, tbl, col)
89
+ const relations = schema.relations.filter(r => visible.has(r.from.table) && visible.has(r.to.table) && !hiddenCol(r.from.table, r.from.column) && !hiddenCol(r.to.table, r.to.column))
90
+ return { ...schema, tables, relations }
91
+ }
92
+
93
+ /** Annotate (not filter) a schema for the page: blocked tables and hidden/masked columns. */
94
+ export function annotateSchema(schema, policy) {
95
+ if (!policyActive(policy)) return schema
96
+ return {
97
+ ...schema,
98
+ tables: schema.tables.map(t => ({
99
+ ...t,
100
+ blocked: !tableAllowed(policy, t.name),
101
+ columns: t.columns.map(c => ({ ...c, hidden: columnHidden(policy, t.name, c.name), policyMasked: columnMasked(policy, t.name, c.name) })),
102
+ })),
103
+ }
104
+ }
105
+
106
+ // ------------------------------------------------------------ SQL analysis
107
+
108
+ /**
109
+ * Identifiers of a statement, lower-cased, with dotted parts joined
110
+ * (`db`.`t` → db.t), string literals and comments skipped. Also returns the
111
+ * raw tokens that follow FROM/JOIN, which may contain `-` and `*` in
112
+ * Elasticsearch index names.
113
+ */
114
+ export function analyzeIdentifiers(sql, { dialect = 'mysql' } = {}) {
115
+ const ids = []
116
+ const fromTargets = []
117
+ const src = normalizeForScan(String(sql), dialect)
118
+ let i = 0
119
+ let prevWord = ''
120
+ let inFromList = false // after FROM target: `t alias, next` keeps listing tables
121
+ let depth = 0
122
+ let fromDepth = -1
123
+ const pushId = (parts) => { if (parts.length) ids.push(parts.join('.').toLowerCase()) }
124
+ while (i < src.length) {
125
+ const c = src[i]
126
+ const n = src[i + 1]
127
+ void n
128
+ // Input is normalized by normalizeForScan: comments are gone and every string literal is exactly ''.
129
+ if (c === '\'') { i += 2; prevWord = ''; continue }
130
+ if (c === '`' || c === '"' || /[A-Za-z_]/.test(c)) {
131
+ // A possibly dotted identifier chain.
132
+ const parts = []
133
+ const start = i
134
+ for (;;) {
135
+ if (src[i] === '`' || src[i] === '"') {
136
+ const q = src[i]
137
+ let j = i + 1
138
+ let text = ''
139
+ while (j < src.length) {
140
+ if (src[j] === q) { if (src[j + 1] === q) { text += q; j += 2; continue } break }
141
+ text += src[j]
142
+ j++
143
+ }
144
+ parts.push(text)
145
+ i = j + 1
146
+ } else if (/[A-Za-z_]/.test(src[i] ?? '')) {
147
+ let j = i
148
+ while (j < src.length && /[\w$]/.test(src[j])) j++
149
+ parts.push(src.slice(i, j))
150
+ i = j
151
+ } else if (src[i] === '*' && parts.length) {
152
+ parts.push('*')
153
+ i++
154
+ break
155
+ } else {
156
+ break
157
+ }
158
+ if (src[i] === '.' && /[`"A-Za-z_*]/.test(src[i + 1] ?? '')) { i++; continue }
159
+ break
160
+ }
161
+ const word = parts.length === 1 ? parts[0].toUpperCase() : ''
162
+ if (/^(WHERE|GROUP|ORDER|HAVING|LIMIT|ON|USING|UNION|SELECT|WINDOW|FETCH|OFFSET|SET|RETURNING)$/.test(word) && depth === fromDepth) inFromList = false
163
+ if (/^(FROM|JOIN)$/.test(prevWord)) {
164
+ inFromList = true
165
+ fromDepth = depth
166
+ // Raw target, including hyphens/wildcards that bare identifiers stop at.
167
+ let j = i
168
+ while (j < src.length && /[\w\-*.:]/.test(src[j])) j++
169
+ const raw = (parts.length === 1 && !/[`"]/.test(src[start]) ? src.slice(start, j) : parts.join('.')).toLowerCase()
170
+ fromTargets.push(raw)
171
+ if (j > i && !/[`"]/.test(src[start])) i = j
172
+ }
173
+ pushId(parts)
174
+ prevWord = word
175
+ continue
176
+ }
177
+ if (c === '(') depth++
178
+ if (c === ')') { depth--; if (depth < fromDepth) inFromList = false }
179
+ if (c === ',' && inFromList && depth === fromDepth) { prevWord = 'FROM'; i++; continue }
180
+ if (!/\s/.test(c)) prevWord = ''
181
+ i++
182
+ }
183
+ return { ids, fromTargets }
184
+ }
185
+
186
+ /**
187
+ * Rewrite SQL for identifier scanning using the dialect's exact lexical rules:
188
+ * comments become a space, string literals become '' (so nothing inside them is
189
+ * read as code), quoted identifiers are kept as "name", and whitespace around
190
+ * dots in name chains is removed (`information_schema . tables`).
191
+ */
192
+ export function normalizeForScan(sql, dialect = 'mysql') {
193
+ const { segments } = lexSql(sql, { dialect })
194
+ const out = segments.map(sg => {
195
+ if (sg.kind === 'comment') return ' '
196
+ if (sg.kind === 'string') return "''"
197
+ if (sg.kind === 'ident') {
198
+ const t = sg.text
199
+ const inner = t[0] === '[' ? t.slice(1, -1) : t.slice(1, -1).split(t[0] + t[0]).join(t[0])
200
+ return `"${inner.replace(/"/g, '""')}"`
201
+ }
202
+ return sg.text
203
+ }).join('')
204
+ return out.replace(/\s*\.\s*(?=["A-Za-z_*])/g, '.').replace(/(["\w])\s+\./g, '$1.')
205
+ }
206
+
207
+ const SYSTEM_PREFIX = /^(information_schema|mysql|performance_schema|sys|pg_catalog|pg_toast)\./
208
+ const SYSTEM_BARE = /^pg_(class|namespace|attribute|tables|views|user|shadow|roles|authid|stat\w*|settings|proc|database|index|indexes|constraint|description|type|matviews|policies|sequence\w*)$/
209
+
210
+ /** Tables of the schema that a statement references. */
211
+ export function referencedTables(sql, tables, { dialect = 'mysql' } = {}) {
212
+ const { ids, fromTargets } = analyzeIdentifiers(sql, { dialect })
213
+ const names = tables.map(t => t.name)
214
+ const lower = new Map(names.map(n => [n.toLowerCase(), n]))
215
+ const byLast = new Map()
216
+ for (const n of names) {
217
+ const last = n.toLowerCase().split('.').pop()
218
+ if (!byLast.has(last)) byLast.set(last, [])
219
+ byLast.get(last).push(n)
220
+ }
221
+ const out = new Set()
222
+ for (const id of [...ids, ...fromTargets]) {
223
+ if (lower.has(id)) { out.add(lower.get(id)); continue }
224
+ // db.table / schema.table / alias.column: try the last two and last parts.
225
+ const parts = id.split('.')
226
+ if (parts.length >= 2) {
227
+ const two = parts.slice(-2).join('.')
228
+ if (lower.has(two)) { out.add(lower.get(two)); continue }
229
+ }
230
+ // A bare name, or db.table where the last part names a table (MySQL database prefix).
231
+ const last = parts[parts.length - 1]
232
+ if ((parts.length === 1 || fromTargets.includes(id)) && byLast.has(last)) for (const n of byLast.get(last)) out.add(n)
233
+ // Elasticsearch wildcard targets: every matching index.
234
+ if (id.includes('*') && fromTargets.includes(id)) for (const n of names) if (glob(id).test(n.toLowerCase())) out.add(n)
235
+ }
236
+ return [...out]
237
+ }
238
+
239
+ /**
240
+ * Refuse a statement the policy does not admit.
241
+ * @returns undefined when admitted, otherwise a refusal message.
242
+ */
243
+ export function checkAccess(sql, policy, schema, head, { dialect = 'mysql' } = {}) {
244
+ if (!policyActive(policy)) return undefined
245
+ const { ids, fromTargets } = analyzeIdentifiers(sql, { dialect })
246
+ const scan = normalizeForScan(String(sql), dialect)
247
+ if (policy.mode !== 'all' && head === 'SHOW') return '当前数据源启用了表访问控制,不允许执行 SHOW 语句;请使用 nl2sql_schema 查看可访问的表。'
248
+ for (const id of [...ids, ...fromTargets]) {
249
+ if (SYSTEM_PREFIX.test(id) || SYSTEM_BARE.test(id) || (fromTargets.includes(id) && id.startsWith('.'))) return `当前数据源启用了访问控制,不允许查询系统目录(${id})。`
250
+ }
251
+ const tables = schema?.tables ?? []
252
+ const refs = referencedTables(sql, tables, { dialect })
253
+ const blocked = refs.filter(t => !tableAllowed(policy, t))
254
+ if (blocked.length) return `无权查询表:${blocked.join('、')}(数据源访问控制)。`
255
+ if (policy.mode === 'deny') {
256
+ // Deny patterns also cover FROM/JOIN targets not (yet) in the analyzed schema.
257
+ const raw = fromTargets.filter(t => t && !tableAllowed(policy, t.replace(/^"|"$/g, '')))
258
+ if (raw.length) return `无权查询表:${raw.join('、')}(数据源访问控制)。`
259
+ }
260
+ if (policy.mode === 'allow') {
261
+ // FROM/JOIN targets that resolve to no analyzed table (new tables, unanalyzed views) are refused too.
262
+ const resolves = (target) => referencedTables(`SELECT 1 FROM ${/[^\w.]/.test(target) ? `"${target}"` : target}`, tables, { dialect: 'postgres' }).length > 0
263
+ const ctes = new Set([...scan.matchAll(/(?:\bWITH(?:\s+RECURSIVE)?|,)\s*[`"]?([A-Za-z_][\w$]*)[`"]?\s*(?:\([^)]*\))?\s+AS\s*(?:NOT\s+)?(?:MATERIALIZED\s*)?\(/gi)].map(m => m[1].toLowerCase()))
264
+ const unknown = fromTargets.filter(t => t && !ctes.has(t) && !/^(select|lateral|unnest|generate_series|json_table|dual)$/.test(t) && !resolves(t))
265
+ if (unknown.length) return `无权查询未授权或未分析的表:${unknown.join('、')}。如果是新表,请先在页面上重新分析表结构并加入允许列表。`
266
+ }
267
+ const hidden = []
268
+ const masked = []
269
+ for (const t of refs) {
270
+ const table = tables.find(x => x.name === t)
271
+ for (const c of table?.columns ?? []) {
272
+ if (columnHidden(policy, t, c.name)) hidden.push({ table: t, column: c.name.toLowerCase() })
273
+ else if (columnMasked(policy, t, c.name)) masked.push({ table: t, column: c.name.toLowerCase() })
274
+ }
275
+ }
276
+ if (hidden.length || masked.length) {
277
+ const hiddenNames = new Set(hidden.map(h => h.column))
278
+ const hit = ids.find(id => hiddenNames.has(id.split('.').pop().replace(/^"|"$/g, '')))
279
+ if (hit) return `字段 ${hit} 已被数据源访问控制隐藏,不能在查询中使用。`
280
+ if (/\b(row_to_json|to_jsonb?|jsonb?_agg|json_object_agg|hstore|record_out|row)\s*\(/i.test(scan)) return '引用的表含有受保护的字段,不允许使用整行转换函数(row_to_json/to_json/json_agg/ROW(...) 等)。'
281
+ // Whole-row values: alias::type, CAST(alias AS …), (alias), alias in function args, alias.* inside a call.
282
+ const aliasNames = tableAliases(scan, refs)
283
+ for (const a of aliasNames) {
284
+ const q = a.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
285
+ const asValue = new RegExp(`(?:^|[^\\w."])${q}\\s*::|\\bCAST\\s*\\(\\s*${q}\\s+AS\\b|\\(\\s*${q}\\s*[,)]|,\\s*${q}\\s*\\)|\\(\\s*${q}\\.\\*`, 'i')
286
+ if (asValue.test(scan)) return '引用的表含有受保护的字段,不允许把整行(表或别名本身)作为值使用;请明确列出需要的字段。'
287
+ }
288
+ // Masked columns may only be selected as plain columns (so results can be masked), not inside expressions.
289
+ if (masked.length) {
290
+ const maskedNames = new Set(masked.map(m => m.column))
291
+ const exprUse = maskedInExpression(scan, maskedNames)
292
+ if (exprUse) return `字段 ${exprUse} 需要脱敏显示,只能直接选取,不能用于表达式、函数或改名(AS)。`
293
+ }
294
+ }
295
+ return undefined
296
+ }
297
+
298
+ /** Table names and aliases introduced in FROM/JOIN for the referenced tables. */
299
+ function tableAliases(scan, refs) {
300
+ const out = new Set()
301
+ const lastParts = new Set(refs.map(r => r.toLowerCase().split('.').pop()))
302
+ for (const m of scan.matchAll(/\b(?:FROM|JOIN|,)\s+((?:"[^"]+"|[\w$]+)(?:\.(?:"[^"]+"|[\w$]+))*)(?:\s+(?:AS\s+)?("?[A-Za-z_][\w$]*"?))?/gi)) {
303
+ const name = m[1].split('.').pop().replace(/^"|"$/g, '').toLowerCase()
304
+ if (!lastParts.has(name)) continue
305
+ out.add(m[1].split('.').pop().replace(/^"|"$/g, ''))
306
+ const alias = m[2]?.replace(/^"|"$/g, '')
307
+ if (alias && !/^(WHERE|JOIN|ON|LEFT|RIGHT|INNER|OUTER|FULL|CROSS|GROUP|ORDER|LIMIT|UNION|USING|NATURAL|HAVING|WINDOW)$/i.test(alias)) out.add(alias)
308
+ }
309
+ return [...out]
310
+ }
311
+
312
+ /**
313
+ * Whether a masked column appears anywhere other than as a plain select item
314
+ * (`col`, `t.col`) — e.g. inside a function, an operator, or renamed with AS.
315
+ */
316
+ function maskedInExpression(scan, maskedNames) {
317
+ const m = /^\s*\(*\s*SELECT\s+(DISTINCT\s+)?([\s\S]*?)\s+FROM\s/i.exec(scan)
318
+ const items = m ? splitTop(m[2]) : []
319
+ for (const item of items) {
320
+ const text = item.trim()
321
+ const plain = /^(?:"?[\w$]+"?\.)*"?([\w$]+)"?$/.exec(text)
322
+ for (const name of maskedNames) {
323
+ const re = new RegExp(`(^|[^\\w$])"?${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}"?($|[^\\w$])`, 'i')
324
+ if (!re.test(text)) continue
325
+ if (plain && plain[1].toLowerCase() === name) continue
326
+ return name
327
+ }
328
+ }
329
+ // Outside the select list (WHERE/GROUP BY/ORDER BY) masked columns are fine: they do not reach results.
330
+ return undefined
331
+ }
332
+
333
+ function splitTop(text) {
334
+ const out = []
335
+ let depth = 0
336
+ let cur = ''
337
+ for (const ch of text) {
338
+ if (ch === '(') depth++
339
+ if (ch === ')') depth--
340
+ if (ch === ',' && depth === 0) { out.push(cur); cur = ''; continue }
341
+ cur += ch
342
+ }
343
+ if (cur.trim()) out.push(cur)
344
+ return out
345
+ }
346
+
347
+ /** Result column indexes to drop (hidden) and to mask, given the referenced tables. */
348
+ export function resultColumnPolicy(policy, columns, refs, schema) {
349
+ const drop = new Set()
350
+ const mask = new Set()
351
+ if (!policyActive(policy)) return { drop, mask }
352
+ const tables = (schema?.tables ?? []).filter(t => refs.includes(t.name))
353
+ columns.forEach((col, i) => {
354
+ const name = String(col.name).toLowerCase()
355
+ const origin = col.table && col.column ? [{ table: col.table, column: col.column }] : tables.map(t => ({ table: t.name, column: name.split('.').pop() }))
356
+ for (const o of origin) {
357
+ if (columnHidden(policy, o.table, o.column)) drop.add(i)
358
+ else if (columnMasked(policy, o.table, o.column)) mask.add(i)
359
+ }
360
+ })
361
+ return { drop, mask }
362
+ }