dsh-d365-kit 0.34.1 → 0.34.4
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 +319 -308
- package/CHANGELOG.zh-CN.md +318 -307
- package/INSTALL.md +94 -94
- package/INSTALL.zh-CN.md +93 -93
- package/LICENSE +21 -21
- package/README.md +336 -327
- package/README.zh-CN.md +336 -327
- package/client.js +1818 -1818
- package/cordis.patch.yml +44 -44
- package/docs/en/architecture.md +172 -172
- package/docs/en/checks.md +184 -184
- package/docs/en/installation.md +151 -151
- package/docs/en/permissions.md +161 -161
- package/docs/en/scenarios.md +202 -202
- package/docs/en/tools.md +665 -665
- package/docs/en/troubleshooting.md +131 -131
- package/docs/zh/architecture.md +172 -172
- package/docs/zh/checks.md +184 -184
- package/docs/zh/installation.md +151 -151
- package/docs/zh/permissions.md +161 -161
- package/docs/zh/scenarios.md +202 -202
- package/docs/zh/tools.md +665 -665
- package/docs/zh/troubleshooting.md +131 -131
- package/examples/prompts.en.md +206 -206
- package/examples/prompts.zh-CN.md +206 -206
- package/examples/waivers.example.json +20 -20
- package/icon.svg +6 -6
- package/index.js +139 -139
- package/lib/auth.js +181 -181
- package/lib/client.js +496 -496
- package/lib/code-check.js +549 -549
- package/lib/config.js +323 -323
- package/lib/contract-history.js +49 -49
- package/lib/contract.js +583 -583
- package/lib/csharp-check.js +493 -493
- package/lib/data-migrate.js +1318 -1318
- package/lib/data-policy.js +78 -78
- package/lib/error-codes.js +80 -80
- package/lib/gitlab.js +161 -161
- package/lib/health.js +205 -205
- package/lib/migrate.js +732 -732
- package/lib/preflight.js +116 -116
- package/lib/refs.js +191 -191
- package/lib/resources.js +320 -320
- package/lib/retention-defaults.js +17 -17
- package/lib/retention.js +384 -384
- package/lib/runtime.js +232 -232
- package/lib/settings-http.js +512 -512
- package/lib/settings-store.js +371 -371
- package/lib/snapshot.js +149 -149
- package/lib/tool-code-check.js +133 -133
- package/lib/tool-configure.js +169 -169
- package/lib/tool-contract.js +376 -376
- package/lib/tool-data-sync.js +559 -559
- package/lib/tool-data-write.js +203 -203
- package/lib/tool-data.js +282 -282
- package/lib/tool-diff.js +484 -484
- package/lib/tool-environments.js +106 -106
- package/lib/tool-flows.js +540 -540
- package/lib/tool-health.js +127 -127
- package/lib/tool-layers.js +497 -497
- package/lib/tool-metadata.js +417 -417
- package/lib/tool-migrate.js +102 -102
- package/lib/tool-refs.js +206 -206
- package/lib/tool-reports.js +299 -299
- package/lib/tool-sync.js +430 -430
- package/lib/tool-write.js +563 -563
- package/lib/waivers.js +83 -83
- package/lib/write-actions.js +1374 -1374
- package/lib/write-policy.js +199 -199
- package/lib/write-store.js +167 -167
- package/locale/en.json +68 -68
- package/locale/zh.json +68 -68
- package/package.json +2 -2
- package/skills/d365-troubleshooting/SKILL.md +115 -115
package/lib/tool-data.js
CHANGED
|
@@ -1,282 +1,282 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `d365_data` — the data half of "who uses this".
|
|
3
|
-
*
|
|
4
|
-
* Metadata references say a field is on a form; they do not say whether 12 or
|
|
5
|
-
* 120000 records carry a value in it. Before deleting a field or an option
|
|
6
|
-
* value, the count is the part that decides. Every read here goes through the
|
|
7
|
-
* normal client, so it is refused unless the environment grants `data.read`.
|
|
8
|
-
*
|
|
9
|
-
* Aggregation uses OData `$apply`, which Dataverse supports on ordinary entity
|
|
10
|
-
* sets; when a server rejects it the tool falls back to paging and counting.
|
|
11
|
-
*/
|
|
12
|
-
import { loadConfig } from './config.js'
|
|
13
|
-
import { formatTable, makeClient, pickEnvironments, writeArtifact } from './runtime.js'
|
|
14
|
-
import { getPagedWithSelectFallback } from './client.js'
|
|
15
|
-
import { D365Error } from './errors.js'
|
|
16
|
-
import { odataString } from './resources.js'
|
|
17
|
-
|
|
18
|
-
const ACTIONS = ['count', 'group', 'sample', 'query']
|
|
19
|
-
|
|
20
|
-
export const definition = {
|
|
21
|
-
name: 'd365_data',
|
|
22
|
-
description:
|
|
23
|
-
'Read business records: count them, group them by a column, sample a few rows, or run one query with your own columns / sort / expand / id. '
|
|
24
|
-
+ 'Use it to judge impact before changing metadata, or to fetch the exact rows a later write needs. '
|
|
25
|
-
+ 'Requires the environment to grant "读取业务数据" (data.read); metadata reads have their own "读取元数据" switch, on by default.',
|
|
26
|
-
parameters: {
|
|
27
|
-
type: 'object',
|
|
28
|
-
additionalProperties: false,
|
|
29
|
-
required: ['action', 'entity'],
|
|
30
|
-
properties: {
|
|
31
|
-
action: {
|
|
32
|
-
type: 'string',
|
|
33
|
-
enum: ACTIONS,
|
|
34
|
-
description: 'count:总行数;group:按某列分组计数;sample:取几行看样例;query:按 select / orderby / expand / id 取具体行。',
|
|
35
|
-
},
|
|
36
|
-
entity: {
|
|
37
|
-
type: 'string',
|
|
38
|
-
description: '实体逻辑名(如 account)或集合名(如 accounts)。',
|
|
39
|
-
},
|
|
40
|
-
filter: {
|
|
41
|
-
type: 'string',
|
|
42
|
-
description: "OData $filter,不带 '$filter=',例如 \"statecode eq 0\"。",
|
|
43
|
-
},
|
|
44
|
-
field: {
|
|
45
|
-
type: 'string',
|
|
46
|
-
description: 'action=group 时必填:用来分组的列逻辑名,例如 contoso_taxcategory。',
|
|
47
|
-
},
|
|
48
|
-
select: {
|
|
49
|
-
type: 'string',
|
|
50
|
-
description: 'action=sample/query 时要返回的列,逗号分隔;sample 省略则取前 5 列,query 省略则取返回行实际带的列。',
|
|
51
|
-
},
|
|
52
|
-
orderby: {
|
|
53
|
-
type: 'string',
|
|
54
|
-
description: "action=query 的排序,例如 \"modifiedon desc\"。",
|
|
55
|
-
},
|
|
56
|
-
expand: {
|
|
57
|
-
type: 'string',
|
|
58
|
-
description: 'action=query 的 $expand,例如 "primarycontactid($select=fullname)"。',
|
|
59
|
-
},
|
|
60
|
-
id: {
|
|
61
|
-
type: 'string',
|
|
62
|
-
description: 'action=query:按主键取一条(GUID,或带引号的备用键值如 \'contoso_001\')。',
|
|
63
|
-
},
|
|
64
|
-
top: {
|
|
65
|
-
type: 'integer',
|
|
66
|
-
description: 'action=sample 返回行数(默认 5,最多 100);action=group 返回前 N 组(默认 20);action=query 返回行数(默认 25,最多 500)。',
|
|
67
|
-
},
|
|
68
|
-
environment: {
|
|
69
|
-
type: 'string',
|
|
70
|
-
description: '环境 key,省略则用默认环境。',
|
|
71
|
-
},
|
|
72
|
-
environments: {
|
|
73
|
-
type: 'array',
|
|
74
|
-
items: { type: 'string' },
|
|
75
|
-
description: '一次查多个环境。',
|
|
76
|
-
},
|
|
77
|
-
allEnvironments: {
|
|
78
|
-
type: 'boolean',
|
|
79
|
-
description: '对全部已配置环境执行同一查询。',
|
|
80
|
-
},
|
|
81
|
-
},
|
|
82
|
-
},
|
|
83
|
-
output: {
|
|
84
|
-
schema: {
|
|
85
|
-
type: 'object',
|
|
86
|
-
additionalProperties: false,
|
|
87
|
-
required: ['text'],
|
|
88
|
-
properties: { text: { type: 'string' } },
|
|
89
|
-
},
|
|
90
|
-
render(_args, value) {
|
|
91
|
-
return [{ type: 'text', text: value?.text ?? '' }]
|
|
92
|
-
},
|
|
93
|
-
},
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* Resolve the entity set name.
|
|
98
|
-
*
|
|
99
|
-
* EntitySetName is customisable, so guessing `${entity}s` is only a fallback:
|
|
100
|
-
* the metadata read is authoritative and cheap.
|
|
101
|
-
* @returns the collection path segment.
|
|
102
|
-
*/
|
|
103
|
-
export async function resolveEntitySet(client, entity, signal) {
|
|
104
|
-
const candidate = String(entity).trim()
|
|
105
|
-
if (!candidate) throw new D365Error('entity 不能为空', { code: 'missing-entity' })
|
|
106
|
-
// A logical name cannot contain a quote; anything that does is either a typo
|
|
107
|
-
// or an attempt to widen the filter, and either way must not be interpolated.
|
|
108
|
-
if (!/^[A-Za-z][A-Za-z0-9_]*$/.test(candidate)) {
|
|
109
|
-
throw new D365Error(`不是合法的实体名:${candidate}`, {
|
|
110
|
-
code: 'bad-entity',
|
|
111
|
-
hint: '实体逻辑名或集合名只包含字母、数字与下划线,例如 contoso_systemconfiguration。',
|
|
112
|
-
})
|
|
113
|
-
}
|
|
114
|
-
// Keyed metadata paths reject `$top`, so ask the collection form instead —
|
|
115
|
-
// the same shape `d365_metadata` already uses successfully.
|
|
116
|
-
try {
|
|
117
|
-
const result = await getPagedWithSelectFallback(client, 'EntityDefinitions', {
|
|
118
|
-
$select: 'LogicalName,EntitySetName,PrimaryIdAttribute',
|
|
119
|
-
$filter: `LogicalName eq '${odataString(candidate)}'`,
|
|
120
|
-
}, 2, signal)
|
|
121
|
-
const row = result?.page?.records?.[0]
|
|
122
|
-
if (row?.EntitySetName) {
|
|
123
|
-
return { set: row.EntitySetName, logical: row.LogicalName ?? candidate, idField: row.PrimaryIdAttribute }
|
|
124
|
-
}
|
|
125
|
-
} catch (error) {
|
|
126
|
-
// Only a rejected query means "no such entity"; auth failures, timeouts
|
|
127
|
-
// and the like must surface as themselves, not as a misleading 404.
|
|
128
|
-
if (error?.status !== 400 && error?.status !== 404 && error?.code !== 'unknown-entity') throw error
|
|
129
|
-
}
|
|
130
|
-
if (!/s$/.test(candidate)) {
|
|
131
|
-
throw new D365Error(`实体不存在:${candidate}`, {
|
|
132
|
-
code: 'unknown-entity',
|
|
133
|
-
hint: '用实体逻辑名(如 contoso_systemconfiguration)或集合名(如 contoso_systemconfigurations)。',
|
|
134
|
-
})
|
|
135
|
-
}
|
|
136
|
-
return { set: candidate, logical: candidate }
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
/** Count rows, using `$apply` first and `$count=true` as a fallback. */
|
|
140
|
-
async function countRows(client, set, filter, signal) {
|
|
141
|
-
const params = filter ? { $filter: filter } : {}
|
|
142
|
-
try {
|
|
143
|
-
const page = await client.getPaged(set, { ...params, $apply: 'aggregate($count as count)' }, 5, signal)
|
|
144
|
-
const value = page.records?.[0]?.count
|
|
145
|
-
if (typeof value === 'number') return value
|
|
146
|
-
} catch (error) {
|
|
147
|
-
// A permission refusal is not "this set rejects $apply" — surface it.
|
|
148
|
-
if (error?.code === 'data-read-forbidden') throw error
|
|
149
|
-
}
|
|
150
|
-
try {
|
|
151
|
-
const page = await client.requestJson(
|
|
152
|
-
client.buildUrl(set, { ...params, $count: 'true', $top: '1' }),
|
|
153
|
-
{ signal },
|
|
154
|
-
)
|
|
155
|
-
if (typeof page?.['@odata.count'] === 'number') return page['@odata.count']
|
|
156
|
-
} catch (error) {
|
|
157
|
-
if (error?.code === 'data-read-forbidden') throw error
|
|
158
|
-
}
|
|
159
|
-
throw new D365Error(`无法统计 ${set} 的行数`, {
|
|
160
|
-
code: 'count-unavailable',
|
|
161
|
-
hint: '该表可能不支持聚合查询;用 filter 缩小范围后重试,或改用 action=sample。',
|
|
162
|
-
})
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
export function register(ctx, state) {
|
|
166
|
-
return ctx.tools.register({
|
|
167
|
-
...definition,
|
|
168
|
-
async execute(args, exec) {
|
|
169
|
-
const config = await loadConfig(state.rawConfig)
|
|
170
|
-
const action = args.action
|
|
171
|
-
if (!ACTIONS.includes(action)) {
|
|
172
|
-
throw new D365Error(`不认识的操作:${action}`, { code: 'unknown-action', hint: `可选:${ACTIONS.join(' / ')}` })
|
|
173
|
-
}
|
|
174
|
-
if (action === 'group' && !args.field) {
|
|
175
|
-
throw new D365Error('action=group 需要 field', { code: 'missing-field' })
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
const envs = pickEnvironments(config, args)
|
|
179
|
-
const reports = []
|
|
180
|
-
const artifact = { action, entity: args.entity, filter: args.filter, environments: {} }
|
|
181
|
-
|
|
182
|
-
for (const env of envs) {
|
|
183
|
-
const client = makeClient(env, config)
|
|
184
|
-
const { set, logical } = await resolveEntitySet(client, args.entity, exec?.signal)
|
|
185
|
-
const filter = args.filter
|
|
186
|
-
|
|
187
|
-
if (action === 'count') {
|
|
188
|
-
const count = await countRows(client, set, filter, exec?.signal)
|
|
189
|
-
artifact.environments[env.key] = { entitySet: set, count }
|
|
190
|
-
reports.push(`${env.key}:${logical} 共 ${count} 行${filter ? `(筛选:${filter})` : ''}`)
|
|
191
|
-
continue
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
if (action === 'group') {
|
|
195
|
-
const apply = `groupby((${args.field}),aggregate($count as count))`
|
|
196
|
-
const page = await client.getPaged(set, {
|
|
197
|
-
...(filter ? { $filter: filter } : {}),
|
|
198
|
-
$apply: apply,
|
|
199
|
-
}, Number.isFinite(args.top) ? Math.max(1, Math.min(500, args.top)) : 20, exec?.signal)
|
|
200
|
-
const rows = page.records ?? []
|
|
201
|
-
const display = rows.map((row) => ({ ...row, [args.field]: row[args.field] ?? '(空)' }))
|
|
202
|
-
const total = rows.reduce((sum, row) => sum + (row.count ?? 0), 0)
|
|
203
|
-
artifact.environments[env.key] = { entitySet: set, field: args.field, groups: rows }
|
|
204
|
-
const table = formatTable(display, [args.field, 'count'], { width: 46 })
|
|
205
|
-
reports.push(`${env.key}:按 ${args.field} 分组,前 ${rows.length} 组合计 ${total} 行\n${table}`)
|
|
206
|
-
continue
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
if (action === 'query') {
|
|
210
|
-
const top = Number.isFinite(args.top) ? Math.max(1, Math.min(500, args.top)) : 25
|
|
211
|
-
const columns = args.select
|
|
212
|
-
? args.select.split(',').map((item) => item.trim()).filter(Boolean)
|
|
213
|
-
: null
|
|
214
|
-
if (args.id !== undefined && args.id !== null && String(args.id).trim()) {
|
|
215
|
-
const key = String(args.id).trim()
|
|
216
|
-
const bare = key.replace(/^'|'$/g, '')
|
|
217
|
-
if (!/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(bare) && !/^'.*'$/.test(key)) {
|
|
218
|
-
throw new D365Error(`id 既不是 GUID 也不是带引号的备用键:${key}`, {
|
|
219
|
-
code: 'bad-id',
|
|
220
|
-
hint: "主键传 GUID;备用键要带单引号,例如 \"contoso_001\" 写成 \"'contoso_001'\"。",
|
|
221
|
-
})
|
|
222
|
-
}
|
|
223
|
-
const params = {}
|
|
224
|
-
if (args.select) params.$select = args.select
|
|
225
|
-
if (args.expand) params.$expand = args.expand
|
|
226
|
-
let row
|
|
227
|
-
try {
|
|
228
|
-
row = await client.requestJson(client.buildUrl(`${set}(${key})`, params), { signal: exec?.signal })
|
|
229
|
-
} catch (error) {
|
|
230
|
-
if (error?.status === 404) {
|
|
231
|
-
throw new D365Error(`${env.key} 里没有 ${logical}(${key}) 这条记录`, {
|
|
232
|
-
code: 'record-not-found',
|
|
233
|
-
hint: '确认 id 属于这个环境;跨环境复制过来的 GUID 在目标不存在时也会是 404。',
|
|
234
|
-
})
|
|
235
|
-
}
|
|
236
|
-
throw error
|
|
237
|
-
}
|
|
238
|
-
if (!row || typeof row !== 'object') {
|
|
239
|
-
throw new D365Error(`${env.key} 没有返回 ${logical}(${key})`, { code: 'record-not-found' })
|
|
240
|
-
}
|
|
241
|
-
artifact.environments[env.key] = { entitySet: set, id: key, rows: [row] }
|
|
242
|
-
const shown = columns ?? Object.keys(row).filter((name) => !name.startsWith('@')).slice(0, 8)
|
|
243
|
-
reports.push(`${env.key}:${logical}(${key})\n${formatTable([row], shown, { width: 40 })}`)
|
|
244
|
-
continue
|
|
245
|
-
}
|
|
246
|
-
// Do not send `$top` here: `getPaged` asks for one row more than the
|
|
247
|
-
// cap so a full page is distinguishable from a complete one.
|
|
248
|
-
const params = {}
|
|
249
|
-
if (filter) params.$filter = filter
|
|
250
|
-
if (args.select) params.$select = args.select
|
|
251
|
-
if (args.orderby) params.$orderby = args.orderby
|
|
252
|
-
if (args.expand) params.$expand = args.expand
|
|
253
|
-
const page = await client.getPaged(set, params, top, exec?.signal)
|
|
254
|
-
const rows = page.records ?? []
|
|
255
|
-
const total = page.hasMore ? await countRows(client, set, filter, exec?.signal).catch(() => undefined) : rows.length
|
|
256
|
-
const shown = columns ?? Object.keys(rows[0] ?? {}).filter((name) => !name.startsWith('@')).slice(0, 8)
|
|
257
|
-
artifact.environments[env.key] = { entitySet: set, params, total, rows }
|
|
258
|
-
const head = total === undefined
|
|
259
|
-
? `${env.key}:${logical} 返回 ${rows.length} 行(可能还有更多)`
|
|
260
|
-
: total === rows.length
|
|
261
|
-
? `${env.key}:${logical} 共 ${total} 行`
|
|
262
|
-
: `${env.key}:已列 ${rows.length} 条 / 共 ${total} 条(未列全,加 filter 或调 top)`
|
|
263
|
-
reports.push(`${head}\n${formatTable(rows, shown, { width: 40 })}`)
|
|
264
|
-
continue
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
const top = Number.isFinite(args.top) ? Math.max(1, Math.min(100, args.top)) : 5
|
|
268
|
-
const params = { $top: top }
|
|
269
|
-
if (filter) params.$filter = filter
|
|
270
|
-
if (args.select) params.$select = args.select
|
|
271
|
-
const page = await client.getPaged(set, params, top, exec?.signal)
|
|
272
|
-
const rows = page.records ?? []
|
|
273
|
-
const columns = args.select ? args.select.split(',').map((item) => item.trim()) : Object.keys(rows[0] ?? {}).slice(0, 5)
|
|
274
|
-
artifact.environments[env.key] = { entitySet: set, rows }
|
|
275
|
-
reports.push(`${env.key}:${logical} 样例 ${rows.length} 行\n${formatTable(rows, columns, { width: 32 })}`)
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
const file = await writeArtifact(config, `data-${action}`, artifact)
|
|
279
|
-
return { text: `${reports.join('\n\n')}\n\n完整结果:${file}` }
|
|
280
|
-
},
|
|
281
|
-
})
|
|
282
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* `d365_data` — the data half of "who uses this".
|
|
3
|
+
*
|
|
4
|
+
* Metadata references say a field is on a form; they do not say whether 12 or
|
|
5
|
+
* 120000 records carry a value in it. Before deleting a field or an option
|
|
6
|
+
* value, the count is the part that decides. Every read here goes through the
|
|
7
|
+
* normal client, so it is refused unless the environment grants `data.read`.
|
|
8
|
+
*
|
|
9
|
+
* Aggregation uses OData `$apply`, which Dataverse supports on ordinary entity
|
|
10
|
+
* sets; when a server rejects it the tool falls back to paging and counting.
|
|
11
|
+
*/
|
|
12
|
+
import { loadConfig } from './config.js'
|
|
13
|
+
import { formatTable, makeClient, pickEnvironments, writeArtifact } from './runtime.js'
|
|
14
|
+
import { getPagedWithSelectFallback } from './client.js'
|
|
15
|
+
import { D365Error } from './errors.js'
|
|
16
|
+
import { odataString } from './resources.js'
|
|
17
|
+
|
|
18
|
+
const ACTIONS = ['count', 'group', 'sample', 'query']
|
|
19
|
+
|
|
20
|
+
export const definition = {
|
|
21
|
+
name: 'd365_data',
|
|
22
|
+
description:
|
|
23
|
+
'Read business records: count them, group them by a column, sample a few rows, or run one query with your own columns / sort / expand / id. '
|
|
24
|
+
+ 'Use it to judge impact before changing metadata, or to fetch the exact rows a later write needs. '
|
|
25
|
+
+ 'Requires the environment to grant "读取业务数据" (data.read); metadata reads have their own "读取元数据" switch, on by default.',
|
|
26
|
+
parameters: {
|
|
27
|
+
type: 'object',
|
|
28
|
+
additionalProperties: false,
|
|
29
|
+
required: ['action', 'entity'],
|
|
30
|
+
properties: {
|
|
31
|
+
action: {
|
|
32
|
+
type: 'string',
|
|
33
|
+
enum: ACTIONS,
|
|
34
|
+
description: 'count:总行数;group:按某列分组计数;sample:取几行看样例;query:按 select / orderby / expand / id 取具体行。',
|
|
35
|
+
},
|
|
36
|
+
entity: {
|
|
37
|
+
type: 'string',
|
|
38
|
+
description: '实体逻辑名(如 account)或集合名(如 accounts)。',
|
|
39
|
+
},
|
|
40
|
+
filter: {
|
|
41
|
+
type: 'string',
|
|
42
|
+
description: "OData $filter,不带 '$filter=',例如 \"statecode eq 0\"。",
|
|
43
|
+
},
|
|
44
|
+
field: {
|
|
45
|
+
type: 'string',
|
|
46
|
+
description: 'action=group 时必填:用来分组的列逻辑名,例如 contoso_taxcategory。',
|
|
47
|
+
},
|
|
48
|
+
select: {
|
|
49
|
+
type: 'string',
|
|
50
|
+
description: 'action=sample/query 时要返回的列,逗号分隔;sample 省略则取前 5 列,query 省略则取返回行实际带的列。',
|
|
51
|
+
},
|
|
52
|
+
orderby: {
|
|
53
|
+
type: 'string',
|
|
54
|
+
description: "action=query 的排序,例如 \"modifiedon desc\"。",
|
|
55
|
+
},
|
|
56
|
+
expand: {
|
|
57
|
+
type: 'string',
|
|
58
|
+
description: 'action=query 的 $expand,例如 "primarycontactid($select=fullname)"。',
|
|
59
|
+
},
|
|
60
|
+
id: {
|
|
61
|
+
type: 'string',
|
|
62
|
+
description: 'action=query:按主键取一条(GUID,或带引号的备用键值如 \'contoso_001\')。',
|
|
63
|
+
},
|
|
64
|
+
top: {
|
|
65
|
+
type: 'integer',
|
|
66
|
+
description: 'action=sample 返回行数(默认 5,最多 100);action=group 返回前 N 组(默认 20);action=query 返回行数(默认 25,最多 500)。',
|
|
67
|
+
},
|
|
68
|
+
environment: {
|
|
69
|
+
type: 'string',
|
|
70
|
+
description: '环境 key,省略则用默认环境。',
|
|
71
|
+
},
|
|
72
|
+
environments: {
|
|
73
|
+
type: 'array',
|
|
74
|
+
items: { type: 'string' },
|
|
75
|
+
description: '一次查多个环境。',
|
|
76
|
+
},
|
|
77
|
+
allEnvironments: {
|
|
78
|
+
type: 'boolean',
|
|
79
|
+
description: '对全部已配置环境执行同一查询。',
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
output: {
|
|
84
|
+
schema: {
|
|
85
|
+
type: 'object',
|
|
86
|
+
additionalProperties: false,
|
|
87
|
+
required: ['text'],
|
|
88
|
+
properties: { text: { type: 'string' } },
|
|
89
|
+
},
|
|
90
|
+
render(_args, value) {
|
|
91
|
+
return [{ type: 'text', text: value?.text ?? '' }]
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Resolve the entity set name.
|
|
98
|
+
*
|
|
99
|
+
* EntitySetName is customisable, so guessing `${entity}s` is only a fallback:
|
|
100
|
+
* the metadata read is authoritative and cheap.
|
|
101
|
+
* @returns the collection path segment.
|
|
102
|
+
*/
|
|
103
|
+
export async function resolveEntitySet(client, entity, signal) {
|
|
104
|
+
const candidate = String(entity).trim()
|
|
105
|
+
if (!candidate) throw new D365Error('entity 不能为空', { code: 'missing-entity' })
|
|
106
|
+
// A logical name cannot contain a quote; anything that does is either a typo
|
|
107
|
+
// or an attempt to widen the filter, and either way must not be interpolated.
|
|
108
|
+
if (!/^[A-Za-z][A-Za-z0-9_]*$/.test(candidate)) {
|
|
109
|
+
throw new D365Error(`不是合法的实体名:${candidate}`, {
|
|
110
|
+
code: 'bad-entity',
|
|
111
|
+
hint: '实体逻辑名或集合名只包含字母、数字与下划线,例如 contoso_systemconfiguration。',
|
|
112
|
+
})
|
|
113
|
+
}
|
|
114
|
+
// Keyed metadata paths reject `$top`, so ask the collection form instead —
|
|
115
|
+
// the same shape `d365_metadata` already uses successfully.
|
|
116
|
+
try {
|
|
117
|
+
const result = await getPagedWithSelectFallback(client, 'EntityDefinitions', {
|
|
118
|
+
$select: 'LogicalName,EntitySetName,PrimaryIdAttribute',
|
|
119
|
+
$filter: `LogicalName eq '${odataString(candidate)}'`,
|
|
120
|
+
}, 2, signal)
|
|
121
|
+
const row = result?.page?.records?.[0]
|
|
122
|
+
if (row?.EntitySetName) {
|
|
123
|
+
return { set: row.EntitySetName, logical: row.LogicalName ?? candidate, idField: row.PrimaryIdAttribute }
|
|
124
|
+
}
|
|
125
|
+
} catch (error) {
|
|
126
|
+
// Only a rejected query means "no such entity"; auth failures, timeouts
|
|
127
|
+
// and the like must surface as themselves, not as a misleading 404.
|
|
128
|
+
if (error?.status !== 400 && error?.status !== 404 && error?.code !== 'unknown-entity') throw error
|
|
129
|
+
}
|
|
130
|
+
if (!/s$/.test(candidate)) {
|
|
131
|
+
throw new D365Error(`实体不存在:${candidate}`, {
|
|
132
|
+
code: 'unknown-entity',
|
|
133
|
+
hint: '用实体逻辑名(如 contoso_systemconfiguration)或集合名(如 contoso_systemconfigurations)。',
|
|
134
|
+
})
|
|
135
|
+
}
|
|
136
|
+
return { set: candidate, logical: candidate }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Count rows, using `$apply` first and `$count=true` as a fallback. */
|
|
140
|
+
async function countRows(client, set, filter, signal) {
|
|
141
|
+
const params = filter ? { $filter: filter } : {}
|
|
142
|
+
try {
|
|
143
|
+
const page = await client.getPaged(set, { ...params, $apply: 'aggregate($count as count)' }, 5, signal)
|
|
144
|
+
const value = page.records?.[0]?.count
|
|
145
|
+
if (typeof value === 'number') return value
|
|
146
|
+
} catch (error) {
|
|
147
|
+
// A permission refusal is not "this set rejects $apply" — surface it.
|
|
148
|
+
if (error?.code === 'data-read-forbidden') throw error
|
|
149
|
+
}
|
|
150
|
+
try {
|
|
151
|
+
const page = await client.requestJson(
|
|
152
|
+
client.buildUrl(set, { ...params, $count: 'true', $top: '1' }),
|
|
153
|
+
{ signal },
|
|
154
|
+
)
|
|
155
|
+
if (typeof page?.['@odata.count'] === 'number') return page['@odata.count']
|
|
156
|
+
} catch (error) {
|
|
157
|
+
if (error?.code === 'data-read-forbidden') throw error
|
|
158
|
+
}
|
|
159
|
+
throw new D365Error(`无法统计 ${set} 的行数`, {
|
|
160
|
+
code: 'count-unavailable',
|
|
161
|
+
hint: '该表可能不支持聚合查询;用 filter 缩小范围后重试,或改用 action=sample。',
|
|
162
|
+
})
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function register(ctx, state) {
|
|
166
|
+
return ctx.tools.register({
|
|
167
|
+
...definition,
|
|
168
|
+
async execute(args, exec) {
|
|
169
|
+
const config = await loadConfig(state.rawConfig)
|
|
170
|
+
const action = args.action
|
|
171
|
+
if (!ACTIONS.includes(action)) {
|
|
172
|
+
throw new D365Error(`不认识的操作:${action}`, { code: 'unknown-action', hint: `可选:${ACTIONS.join(' / ')}` })
|
|
173
|
+
}
|
|
174
|
+
if (action === 'group' && !args.field) {
|
|
175
|
+
throw new D365Error('action=group 需要 field', { code: 'missing-field' })
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const envs = pickEnvironments(config, args)
|
|
179
|
+
const reports = []
|
|
180
|
+
const artifact = { action, entity: args.entity, filter: args.filter, environments: {} }
|
|
181
|
+
|
|
182
|
+
for (const env of envs) {
|
|
183
|
+
const client = makeClient(env, config)
|
|
184
|
+
const { set, logical } = await resolveEntitySet(client, args.entity, exec?.signal)
|
|
185
|
+
const filter = args.filter
|
|
186
|
+
|
|
187
|
+
if (action === 'count') {
|
|
188
|
+
const count = await countRows(client, set, filter, exec?.signal)
|
|
189
|
+
artifact.environments[env.key] = { entitySet: set, count }
|
|
190
|
+
reports.push(`${env.key}:${logical} 共 ${count} 行${filter ? `(筛选:${filter})` : ''}`)
|
|
191
|
+
continue
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (action === 'group') {
|
|
195
|
+
const apply = `groupby((${args.field}),aggregate($count as count))`
|
|
196
|
+
const page = await client.getPaged(set, {
|
|
197
|
+
...(filter ? { $filter: filter } : {}),
|
|
198
|
+
$apply: apply,
|
|
199
|
+
}, Number.isFinite(args.top) ? Math.max(1, Math.min(500, args.top)) : 20, exec?.signal)
|
|
200
|
+
const rows = page.records ?? []
|
|
201
|
+
const display = rows.map((row) => ({ ...row, [args.field]: row[args.field] ?? '(空)' }))
|
|
202
|
+
const total = rows.reduce((sum, row) => sum + (row.count ?? 0), 0)
|
|
203
|
+
artifact.environments[env.key] = { entitySet: set, field: args.field, groups: rows }
|
|
204
|
+
const table = formatTable(display, [args.field, 'count'], { width: 46 })
|
|
205
|
+
reports.push(`${env.key}:按 ${args.field} 分组,前 ${rows.length} 组合计 ${total} 行\n${table}`)
|
|
206
|
+
continue
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (action === 'query') {
|
|
210
|
+
const top = Number.isFinite(args.top) ? Math.max(1, Math.min(500, args.top)) : 25
|
|
211
|
+
const columns = args.select
|
|
212
|
+
? args.select.split(',').map((item) => item.trim()).filter(Boolean)
|
|
213
|
+
: null
|
|
214
|
+
if (args.id !== undefined && args.id !== null && String(args.id).trim()) {
|
|
215
|
+
const key = String(args.id).trim()
|
|
216
|
+
const bare = key.replace(/^'|'$/g, '')
|
|
217
|
+
if (!/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(bare) && !/^'.*'$/.test(key)) {
|
|
218
|
+
throw new D365Error(`id 既不是 GUID 也不是带引号的备用键:${key}`, {
|
|
219
|
+
code: 'bad-id',
|
|
220
|
+
hint: "主键传 GUID;备用键要带单引号,例如 \"contoso_001\" 写成 \"'contoso_001'\"。",
|
|
221
|
+
})
|
|
222
|
+
}
|
|
223
|
+
const params = {}
|
|
224
|
+
if (args.select) params.$select = args.select
|
|
225
|
+
if (args.expand) params.$expand = args.expand
|
|
226
|
+
let row
|
|
227
|
+
try {
|
|
228
|
+
row = await client.requestJson(client.buildUrl(`${set}(${key})`, params), { signal: exec?.signal })
|
|
229
|
+
} catch (error) {
|
|
230
|
+
if (error?.status === 404) {
|
|
231
|
+
throw new D365Error(`${env.key} 里没有 ${logical}(${key}) 这条记录`, {
|
|
232
|
+
code: 'record-not-found',
|
|
233
|
+
hint: '确认 id 属于这个环境;跨环境复制过来的 GUID 在目标不存在时也会是 404。',
|
|
234
|
+
})
|
|
235
|
+
}
|
|
236
|
+
throw error
|
|
237
|
+
}
|
|
238
|
+
if (!row || typeof row !== 'object') {
|
|
239
|
+
throw new D365Error(`${env.key} 没有返回 ${logical}(${key})`, { code: 'record-not-found' })
|
|
240
|
+
}
|
|
241
|
+
artifact.environments[env.key] = { entitySet: set, id: key, rows: [row] }
|
|
242
|
+
const shown = columns ?? Object.keys(row).filter((name) => !name.startsWith('@')).slice(0, 8)
|
|
243
|
+
reports.push(`${env.key}:${logical}(${key})\n${formatTable([row], shown, { width: 40 })}`)
|
|
244
|
+
continue
|
|
245
|
+
}
|
|
246
|
+
// Do not send `$top` here: `getPaged` asks for one row more than the
|
|
247
|
+
// cap so a full page is distinguishable from a complete one.
|
|
248
|
+
const params = {}
|
|
249
|
+
if (filter) params.$filter = filter
|
|
250
|
+
if (args.select) params.$select = args.select
|
|
251
|
+
if (args.orderby) params.$orderby = args.orderby
|
|
252
|
+
if (args.expand) params.$expand = args.expand
|
|
253
|
+
const page = await client.getPaged(set, params, top, exec?.signal)
|
|
254
|
+
const rows = page.records ?? []
|
|
255
|
+
const total = page.hasMore ? await countRows(client, set, filter, exec?.signal).catch(() => undefined) : rows.length
|
|
256
|
+
const shown = columns ?? Object.keys(rows[0] ?? {}).filter((name) => !name.startsWith('@')).slice(0, 8)
|
|
257
|
+
artifact.environments[env.key] = { entitySet: set, params, total, rows }
|
|
258
|
+
const head = total === undefined
|
|
259
|
+
? `${env.key}:${logical} 返回 ${rows.length} 行(可能还有更多)`
|
|
260
|
+
: total === rows.length
|
|
261
|
+
? `${env.key}:${logical} 共 ${total} 行`
|
|
262
|
+
: `${env.key}:已列 ${rows.length} 条 / 共 ${total} 条(未列全,加 filter 或调 top)`
|
|
263
|
+
reports.push(`${head}\n${formatTable(rows, shown, { width: 40 })}`)
|
|
264
|
+
continue
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const top = Number.isFinite(args.top) ? Math.max(1, Math.min(100, args.top)) : 5
|
|
268
|
+
const params = { $top: top }
|
|
269
|
+
if (filter) params.$filter = filter
|
|
270
|
+
if (args.select) params.$select = args.select
|
|
271
|
+
const page = await client.getPaged(set, params, top, exec?.signal)
|
|
272
|
+
const rows = page.records ?? []
|
|
273
|
+
const columns = args.select ? args.select.split(',').map((item) => item.trim()) : Object.keys(rows[0] ?? {}).slice(0, 5)
|
|
274
|
+
artifact.environments[env.key] = { entitySet: set, rows }
|
|
275
|
+
reports.push(`${env.key}:${logical} 样例 ${rows.length} 行\n${formatTable(rows, columns, { width: 32 })}`)
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const file = await writeArtifact(config, `data-${action}`, artifact)
|
|
279
|
+
return { text: `${reports.join('\n\n')}\n\n完整结果:${file}` }
|
|
280
|
+
},
|
|
281
|
+
})
|
|
282
|
+
}
|