cabloy 5.1.248 → 5.1.250
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/.cabloy-version +1 -1
- package/.github/workflows/create-cabloy.yml +50 -3
- package/.vscode/settings.json +1 -2
- package/CHANGELOG.md +31 -0
- package/package.json +1 -7
- package/repo-agent-governance/managed-assets.json +12 -12
- package/repo-agent-governance/skills/cabloy-spec-execution/references/status-and-evidence.md +1 -1
- package/repo-agent-governance/skills/cabloy-spec-generation/references/canonical-spec-input.md +1 -1
- package/repo-agent-governance/skills/cabloy-spec-generation/references/repo-specs-document-set.md +2 -2
- package/repo-agent-governance/skills/cabloy-spec-generation/references/traceability-and-status-rules.md +1 -1
- package/repo-agent-governance/tools/spec-charts/generate-implementation-charts.mjs +73 -22
- package/repo-agent-governance/tools/spec-charts/generate-implementation-charts.test.mjs +98 -7
- package/repo-docs/ai/playbook-spec-execution.md +1 -1
- package/repo-docs/backend/crud-workflow.md +92 -1
- package/repo-docs/backend/dto-guide.md +16 -1
- package/repo-docs/backend/dto-infer-generation.md +23 -5
- package/repo-docs/backend/orm-aggregate-group-guide.md +32 -0
- package/repo-docs/backend/validation-guide.md +38 -2
- package/repo-e2e/specs/cabloy-basic.spec.ts +9 -2
- package/vona/package.original.json +1 -7
- package/vona/packages-cli/cli/package.json +1 -1
- package/vona/packages-cli/cli-set-api/cli/templates/tools/crudAnalysis/aggregate/src/dto/{{resourceName}}AggregateRes.tsx_ +11 -0
- package/vona/packages-cli/cli-set-api/cli/templates/tools/crudAnalysis/common/src/lib/{{resourceName}}Analysis.ts_ +14 -0
- package/vona/packages-cli/cli-set-api/cli/templates/tools/crudAnalysis/common/test/{{resourceName}}Analysis.test.ts_ +81 -0
- package/vona/packages-cli/cli-set-api/cli/templates/tools/crudAnalysis/group/src/dto/{{resourceName}}GroupResItem.tsx_ +11 -0
- package/vona/packages-cli/cli-set-api/cli/templates/tools/crudBasic/boilerplate/src/controller/{{resourceName}}.ts_ +18 -1
- package/vona/packages-cli/cli-set-api/cli/templates/tools/crudBasic/boilerplate/src/dto/{{resourceName}}SelectReq.tsx_ +2 -0
- package/vona/packages-cli/cli-set-api/cli/templates/tools/crudBasic/boilerplate/src/service/{{resourceName}}.ts_ +14 -1
- package/vona/packages-cli/cli-set-api/cli/templates/tools/crudBasic/boilerplate/test/{{resourceName}}.test.ts_ +4 -0
- package/vona/packages-cli/cli-set-api/cli/templates/tools/crudBasic/snippets/3-en-us.ts +2 -0
- package/vona/packages-cli/cli-set-api/cli/templates/tools/crudBasic/snippets/4-zh-cn.ts +2 -0
- package/vona/packages-cli/cli-set-api/cli/templates/tools/crudStart/boilerplate/src/controller/{{resourceName}}.ts_ +18 -1
- package/vona/packages-cli/cli-set-api/cli/templates/tools/crudStart/boilerplate/src/dto/{{resourceName}}SelectReq.tsx_ +2 -0
- package/vona/packages-cli/cli-set-api/cli/templates/tools/crudStart/boilerplate/src/service/{{resourceName}}.ts_ +14 -1
- package/vona/packages-cli/cli-set-api/cli/templates/tools/crudStart/boilerplate/test/{{resourceName}}.test.ts_ +1 -1
- package/vona/packages-cli/cli-set-api/cli/templates/tools/crudStart/snippets/3-en-us.ts +2 -0
- package/vona/packages-cli/cli-set-api/cli/templates/tools/crudStart/snippets/4-zh-cn.ts +2 -0
- package/vona/packages-cli/cli-set-api/package.json +1 -1
- package/vona/packages-cli/cli-set-api/src/lib/bean/cli.tools.crudBasic.ts +2 -0
- package/vona/packages-cli/cli-set-api/src/lib/bean/cli.tools.crudStart.ts +2 -0
- package/vona/packages-cli/cli-set-api/src/lib/bean/cli.tools.masterDetail.ts +9 -1
- package/vona/packages-cli/cli-set-api/src/lib/crudAnalysis.ts +11 -0
- package/vona/packages-cli/cli-set-api/test/crudAnalysis.test.ts +385 -0
- package/vona/packages-utils/babel-plugin-vona-bean-module/package.json +1 -1
- package/vona/packages-vona/vona/package.json +1 -1
- package/vona/packages-vona/vona-core/package.json +1 -1
- package/vona/packages-vona/vona-mock/package.json +1 -1
- package/vona/pnpm-lock.yaml +76 -1008
- package/vona/src/suite/a-training/modules/training-student/src/.metadata/index.ts +18 -0
- package/vona/src/suite/a-training/modules/training-student/src/config/locale/en-us.ts +2 -0
- package/vona/src/suite/a-training/modules/training-student/src/config/locale/zh-cn.ts +2 -0
- package/vona/src/suite/a-training/modules/training-student/src/controller/student.ts +28 -1
- package/vona/src/suite/a-training/modules/training-student/src/dto/studentAggregateRes.tsx +11 -0
- package/vona/src/suite/a-training/modules/training-student/src/dto/studentGroupResItem.tsx +11 -0
- package/vona/src/suite/a-training/modules/training-student/src/dto/studentSelectReq.tsx +2 -0
- package/vona/src/suite/a-training/modules/training-student/src/lib/studentAnalysis.ts +12 -0
- package/vona/src/suite/a-training/modules/training-student/src/service/student.ts +22 -1
- package/vona/src/suite/a-training/modules/training-student/test/student.test.ts +4 -0
- package/vona/src/suite/a-training/modules/training-student/test/studentAnalysis.test.ts +422 -0
- package/vona/src/suite-vendor/a-cabloy/modules/a-rbac/package.json +1 -1
- package/vona/src/suite-vendor/a-cabloy/package.json +2 -2
- package/vona/src/suite-vendor/a-captcha/modules/a-captcha/package.json +1 -1
- package/vona/src/suite-vendor/a-captcha/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-core/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-hmr/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-locale/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-openapi/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-openapiutils/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-openapiutils/src/lib/utils.ts +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-orm/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-orm/src/.metadata/index.ts +26 -26
- package/vona/src/suite-vendor/a-vona/modules/a-orm/src/lib/aggregateResult.ts +53 -0
- package/vona/src/suite-vendor/a-vona/modules/a-orm/src/lib/analysis/controls.ts +163 -0
- package/vona/src/suite-vendor/a-vona/modules/a-orm/src/lib/analysis/execute.ts +49 -0
- package/vona/src/suite-vendor/a-vona/modules/a-orm/src/lib/analysis/index.ts +3 -0
- package/vona/src/suite-vendor/a-vona/modules/a-orm/src/lib/analysis/policy.ts +140 -0
- package/vona/src/suite-vendor/a-vona/modules/a-orm/src/lib/bean.model/bean.model_cache.ts +4 -2
- package/vona/src/suite-vendor/a-vona/modules/a-orm/src/lib/bean.model/bean.model_crud_inner.ts +8 -3
- package/vona/src/suite-vendor/a-vona/modules/a-orm/src/lib/dto/dto.ts +3 -0
- package/vona/src/suite-vendor/a-vona/modules/a-orm/src/lib/dto/dtoAggregate.ts +4 -1
- package/vona/src/suite-vendor/a-vona/modules/a-orm/src/lib/dto/dtoAnalysis.ts +34 -0
- package/vona/src/suite-vendor/a-vona/modules/a-orm/src/lib/index.ts +1 -0
- package/vona/src/suite-vendor/a-vona/modules/a-orm/src/types/analysis.ts +137 -0
- package/vona/src/suite-vendor/a-vona/modules/a-orm/src/types/index.ts +1 -0
- package/vona/src/suite-vendor/a-vona/modules/a-orm/src/types/relationsAggr.ts +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-orm/test/aggregateResult.test.ts +81 -0
- package/vona/src/suite-vendor/a-vona/modules/a-orm/test/analysis.test.ts +208 -0
- package/vona/src/suite-vendor/a-vona/modules/a-orm/test/analysis.types.test.ts +112 -0
- package/vona/src/suite-vendor/a-vona/modules/a-permission/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-queue/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-ratelimit/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-user/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-web/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-web/src/.metadata/index.ts +40 -40
- package/vona/src/suite-vendor/a-vona/modules/a-web/src/bean/pipe.filter.ts +44 -3
- package/vona/src/suite-vendor/a-vona/modules/a-web/src/lib/analysisFilter.ts +69 -0
- package/vona/src/suite-vendor/a-vona/modules/a-web/src/lib/decorator/arguments.ts +6 -4
- package/vona/src/suite-vendor/a-vona/modules/a-web/src/lib/index.ts +1 -0
- package/vona/src/suite-vendor/a-vona/modules/a-web/src/types/dto.ts +2 -1
- package/vona/src/suite-vendor/a-vona/modules/a-web/src/types/filter.ts +7 -2
- package/vona/src/suite-vendor/a-vona/package.json +1 -1
- package/zova/package.original.json +0 -6
- package/zova/packages-cli/cli/package.json +2 -2
- package/zova/packages-cli/cli-set-front/package.json +2 -2
- package/zova/packages-utils/zova-jsx/package.json +2 -2
- package/zova/packages-utils/zova-vite/package.json +1 -1
- package/zova/packages-zova/zova/package.json +3 -3
- package/zova/packages-zova/zova-core/package.json +1 -1
- package/zova/pnpm-lock.yaml +11 -963
- package/zova/src/suite/a-home/modules/home-layoutadmin/src/component/layoutAdmin/render.header.tsx +4 -1
- package/zova/src/suite/a-home/modules/home-layoutadmin/src/component/layoutAdmin/render.tabs.tsx +2 -2
- package/zova/src/suite/a-training/modules/training-student/src/api/openapi/schemas.ts +16 -2
- package/zova/src/suite/a-training/modules/training-student/src/api/openapi/types.ts +201 -7
- package/zova/src/suite/a-training/modules/training-student/src/api/trainingStudent.ts +42 -0
- package/zova/src/suite/a-training/modules/training-student/src/apiSchema/trainingStudent.ts +10 -0
- package/zova/src/suite-vendor/a-zova/modules/a-ssr/package.json +1 -1
- package/zova/src/suite-vendor/a-zova/modules/a-ssrserver/package.json +1 -1
- package/zova/src/suite-vendor/a-zova/modules/a-table/package.json +1 -1
- package/zova/src/suite-vendor/a-zova/modules/a-zova/package.json +2 -2
- package/zova/src/suite-vendor/a-zova/package.json +5 -5
- package/vona/.textlintrc.json +0 -19
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
import type { TableIdentity } from 'table-identity';
|
|
2
|
+
import type { ISchemaObjectExtensionField } from 'vona-module-a-openapi';
|
|
3
|
+
|
|
4
|
+
import { ZodMetadata } from '@cabloy/zod-openapi';
|
|
5
|
+
import assert from 'node:assert';
|
|
6
|
+
import { randomUUID } from 'node:crypto';
|
|
7
|
+
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
8
|
+
import os from 'node:os';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { describe, it } from 'node:test';
|
|
11
|
+
import { app } from 'vona-mock';
|
|
12
|
+
import { $schema } from 'vona-module-a-openapiutils';
|
|
13
|
+
|
|
14
|
+
import { DtoStudentAggregateRes } from '../src/dto/studentAggregateRes.tsx';
|
|
15
|
+
import { DtoStudentGroupResItem } from '../src/dto/studentGroupResItem.tsx';
|
|
16
|
+
import { DtoStudentSelectReq } from '../src/dto/studentSelectReq.tsx';
|
|
17
|
+
|
|
18
|
+
type StudentGroup = { level: number; count_all: string };
|
|
19
|
+
type StudentAggregate = { count_all: string; count_imageId: string };
|
|
20
|
+
|
|
21
|
+
async function dropStudents(ids: TableIdentity[]) {
|
|
22
|
+
const student = app.scope('training-student');
|
|
23
|
+
for (const id of ids.toReversed()) {
|
|
24
|
+
await student.model.studentContent.delete({ studentId: id }, { disableDeleted: true });
|
|
25
|
+
await student.model.student.deleteById(id, { disableDeleted: true });
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function readAnalysis(query: Record<string, unknown>) {
|
|
30
|
+
const groups: StudentGroup[] = await app.bean.executor.performAction(
|
|
31
|
+
'get',
|
|
32
|
+
'/training/student/group',
|
|
33
|
+
{ query },
|
|
34
|
+
);
|
|
35
|
+
const aggregate: StudentAggregate = await app.bean.executor.performAction(
|
|
36
|
+
'get',
|
|
37
|
+
'/training/student/aggregate',
|
|
38
|
+
{ query },
|
|
39
|
+
);
|
|
40
|
+
return {
|
|
41
|
+
groups: groups.sort((a, b) => a.level - b.level),
|
|
42
|
+
aggregate,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe('studentAnalysis.test.ts', { concurrency: false }, () => {
|
|
47
|
+
it('keeps select unchanged and emits optional analysis response field supersets', async () => {
|
|
48
|
+
await app.bean.executor.mockCtx(async () => {
|
|
49
|
+
const request = $schema(DtoStudentSelectReq);
|
|
50
|
+
const requestMetadata = ZodMetadata.getOpenapiMetadata(request) as
|
|
51
|
+
| ISchemaObjectExtensionField
|
|
52
|
+
| undefined;
|
|
53
|
+
assert.equal(requestMetadata?.filter?.table, 'trainingStudent');
|
|
54
|
+
assert.equal(request.safeParse({}).success, true);
|
|
55
|
+
assert.equal(
|
|
56
|
+
request.safeParse({ name: 'Student', level: 2, createdAt: '2026-03-10~2026-03-10' })
|
|
57
|
+
.success,
|
|
58
|
+
true,
|
|
59
|
+
);
|
|
60
|
+
assert.equal(
|
|
61
|
+
request.safeParse({
|
|
62
|
+
where: { level: { _gte_: 2 } },
|
|
63
|
+
columns: ['name'],
|
|
64
|
+
orders: 'level,desc',
|
|
65
|
+
pageNo: 2,
|
|
66
|
+
pageSize: 1,
|
|
67
|
+
}).success,
|
|
68
|
+
true,
|
|
69
|
+
);
|
|
70
|
+
assert.equal(request.safeParse({ pageSize: 0 }).success, false);
|
|
71
|
+
// Filter transforms belong to runtime schema metadata; public OpenAPI strips them.
|
|
72
|
+
const createdAtSchema = ZodMetadata.getFieldSchema(request, 'createdAt');
|
|
73
|
+
assert.ok(createdAtSchema);
|
|
74
|
+
const createdAtMetadata = ZodMetadata.getOpenapiMetadata(createdAtSchema) as
|
|
75
|
+
| ISchemaObjectExtensionField
|
|
76
|
+
| undefined;
|
|
77
|
+
assert.equal(createdAtMetadata?.filter?.transform?.[0], 'a-web:dateRange');
|
|
78
|
+
for (const [DtoClass, expectedFields] of [
|
|
79
|
+
[
|
|
80
|
+
DtoStudentSelectReq,
|
|
81
|
+
['columns', 'createdAt', 'level', 'name', 'orders', 'pageNo', 'pageSize', 'where'],
|
|
82
|
+
],
|
|
83
|
+
[DtoStudentGroupResItem, ['count_all', 'count_imageId', 'level']],
|
|
84
|
+
[DtoStudentAggregateRes, ['count_all', 'count_imageId']],
|
|
85
|
+
] as const) {
|
|
86
|
+
const document = await app.bean.openapi.generateJsonOfClass(DtoClass, 'V31');
|
|
87
|
+
const component = Object.values(document.components!.schemas!).find(
|
|
88
|
+
item => !('$ref' in item) && Object.hasOwn(item.properties ?? {}, expectedFields[0]),
|
|
89
|
+
);
|
|
90
|
+
assert.ok(component && !('$ref' in component));
|
|
91
|
+
assert.deepEqual(Object.keys(component.properties ?? {}).sort(), [...expectedFields]);
|
|
92
|
+
for (const field of expectedFields.filter(field => field.startsWith('count_'))) {
|
|
93
|
+
const schema = component.properties![field];
|
|
94
|
+
assert.ok(schema && !('$ref' in schema));
|
|
95
|
+
// v.optional() emits a nullable string in the V31 schema dialect.
|
|
96
|
+
assert.deepEqual(schema.type, ['string', 'null']);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
assert.deepEqual($schema(DtoStudentGroupResItem).parse({ count_imageId: '1' }), {
|
|
100
|
+
count_imageId: '1',
|
|
101
|
+
});
|
|
102
|
+
assert.deepEqual($schema(DtoStudentGroupResItem).parse({ level: 2, count_all: '3' }), {
|
|
103
|
+
level: 2,
|
|
104
|
+
count_all: '3',
|
|
105
|
+
});
|
|
106
|
+
assert.deepEqual(
|
|
107
|
+
$schema(DtoStudentAggregateRes).parse({ count_all: '3', count_imageId: '1' }),
|
|
108
|
+
{ count_all: '3', count_imageId: '1' },
|
|
109
|
+
);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('filters real API results and refreshes warmed counts after create, update and soft delete', async () => {
|
|
114
|
+
await app.bean.executor.mockCtx(async () => {
|
|
115
|
+
const jwt = await app.bean.passport.signinMock();
|
|
116
|
+
const ids: TableIdentity[] = [];
|
|
117
|
+
const name = `Analysis-${randomUUID()}`;
|
|
118
|
+
let imageId: TableIdentity | undefined;
|
|
119
|
+
let directory: string | undefined;
|
|
120
|
+
const student = app.scope('training-student');
|
|
121
|
+
try {
|
|
122
|
+
const empty = {
|
|
123
|
+
groups: [],
|
|
124
|
+
aggregate: { count_all: '0', count_imageId: '0' },
|
|
125
|
+
};
|
|
126
|
+
assert.deepEqual(await readAnalysis({ name }), empty);
|
|
127
|
+
directory = await mkdtemp(path.join(os.tmpdir(), 'student-analysis-'));
|
|
128
|
+
const file = path.join(directory, 'student.png');
|
|
129
|
+
await writeFile(
|
|
130
|
+
file,
|
|
131
|
+
Buffer.from(
|
|
132
|
+
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4z8DwHwAFAAH/e+m+7wAAAABJRU5ErkJggg==',
|
|
133
|
+
'base64',
|
|
134
|
+
),
|
|
135
|
+
);
|
|
136
|
+
const image = await app.bean.image.upload(
|
|
137
|
+
'image-native:native',
|
|
138
|
+
{ file, filename: 'student.png', contentType: 'image/png' },
|
|
139
|
+
{ imageScene: 'training-student:studentImage' },
|
|
140
|
+
);
|
|
141
|
+
imageId = image.id;
|
|
142
|
+
for (const [index, level] of [1, 1, 2].entries()) {
|
|
143
|
+
const id = await app.bean.executor.performAction('post', '/training/student', {
|
|
144
|
+
body: {
|
|
145
|
+
name: `${name}-${index}`,
|
|
146
|
+
mobile: '13812345678',
|
|
147
|
+
level,
|
|
148
|
+
...(index === 0 ? { imageId } : {}),
|
|
149
|
+
},
|
|
150
|
+
});
|
|
151
|
+
ids.push(id);
|
|
152
|
+
await student.model.student.updateById(id, {
|
|
153
|
+
createdAt: new Date(index === 2 ? '2026-03-11T12:00:00Z' : '2026-03-10T12:00:00Z'),
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
const initial = {
|
|
157
|
+
groups: [
|
|
158
|
+
{ level: 1, count_all: '2' },
|
|
159
|
+
{ level: 2, count_all: '1' },
|
|
160
|
+
],
|
|
161
|
+
aggregate: { count_all: '3', count_imageId: '1' },
|
|
162
|
+
};
|
|
163
|
+
assert.deepEqual(await readAnalysis({ name }), initial);
|
|
164
|
+
assert.deepEqual(await readAnalysis({ name }), initial);
|
|
165
|
+
assert.deepEqual(
|
|
166
|
+
await app.bean.executor.performAction('get', '/training/student/group', {
|
|
167
|
+
query: { name, aggrs: { count: ['imageId'] } },
|
|
168
|
+
}),
|
|
169
|
+
[
|
|
170
|
+
{ level: 1, count_imageId: '1' },
|
|
171
|
+
{ level: 2, count_imageId: '0' },
|
|
172
|
+
],
|
|
173
|
+
);
|
|
174
|
+
assert.deepEqual(
|
|
175
|
+
await student.service.student.group({
|
|
176
|
+
where: { name: { _startsWith_: name } },
|
|
177
|
+
groups: ['level'],
|
|
178
|
+
}),
|
|
179
|
+
initial.groups,
|
|
180
|
+
);
|
|
181
|
+
assert.deepEqual(
|
|
182
|
+
await student.service.student.aggregate({
|
|
183
|
+
where: { name: { _startsWith_: name } },
|
|
184
|
+
aggrs: { count: ['imageId'] },
|
|
185
|
+
}),
|
|
186
|
+
{ count_imageId: '1' },
|
|
187
|
+
);
|
|
188
|
+
const selected = await app.bean.executor.performAction('get', '/training/student', {
|
|
189
|
+
query: { name, pageSize: 1 },
|
|
190
|
+
});
|
|
191
|
+
assert.equal(selected.total, '3');
|
|
192
|
+
assert.equal(selected.list.length, 1);
|
|
193
|
+
assert.equal(Object.hasOwn(selected.list[0], 'count_all'), false);
|
|
194
|
+
assert.deepEqual(await readAnalysis({ name, level: 2 }), {
|
|
195
|
+
groups: [{ level: 2, count_all: '1' }],
|
|
196
|
+
aggregate: { count_all: '1', count_imageId: '0' },
|
|
197
|
+
});
|
|
198
|
+
assert.deepEqual(await readAnalysis({ name, createdAt: '2026-03-10~2026-03-10' }), {
|
|
199
|
+
groups: [{ level: 1, count_all: '2' }],
|
|
200
|
+
aggregate: { count_all: '2', count_imageId: '1' },
|
|
201
|
+
});
|
|
202
|
+
assert.deepEqual(await readAnalysis({ name: `${name}-absent` }), empty);
|
|
203
|
+
|
|
204
|
+
// Dispatch concrete URLs through the HTTP router, where /:id could otherwise win.
|
|
205
|
+
for (const [action, expected] of [
|
|
206
|
+
['group', initial.groups],
|
|
207
|
+
['aggregate', initial.aggregate],
|
|
208
|
+
] as const) {
|
|
209
|
+
const url = new URL(app.util.getAbsoluteUrlByApiPath(`/training/student/${action}`));
|
|
210
|
+
url.searchParams.set('name', name);
|
|
211
|
+
const response = await fetch(url, {
|
|
212
|
+
headers: {
|
|
213
|
+
Accept: 'application/json',
|
|
214
|
+
Authorization: `Bearer ${jwt.accessToken}`,
|
|
215
|
+
},
|
|
216
|
+
});
|
|
217
|
+
assert.equal(response.status, 200);
|
|
218
|
+
const body = await response.json();
|
|
219
|
+
assert.equal(body.code, 0, JSON.stringify(body));
|
|
220
|
+
const actual =
|
|
221
|
+
action === 'group'
|
|
222
|
+
? body.data.sort((a: StudentGroup, b: StudentGroup) => a.level - b.level)
|
|
223
|
+
: body.data;
|
|
224
|
+
assert.deepEqual(actual, expected);
|
|
225
|
+
}
|
|
226
|
+
for (const [action, controls, expected] of [
|
|
227
|
+
[
|
|
228
|
+
'group',
|
|
229
|
+
{ groups: ['level'], aggrs: { count: ['imageId'] } },
|
|
230
|
+
[
|
|
231
|
+
{ level: 1, count_imageId: '1' },
|
|
232
|
+
{ level: 2, count_imageId: '0' },
|
|
233
|
+
],
|
|
234
|
+
],
|
|
235
|
+
['aggregate', { aggrs: { count: ['*'] } }, { count_all: '3' }],
|
|
236
|
+
] as const) {
|
|
237
|
+
const url = new URL(app.util.getAbsoluteUrlByApiPath(`/training/student/${action}`));
|
|
238
|
+
url.searchParams.set('name', name);
|
|
239
|
+
for (const [key, value] of Object.entries(controls)) {
|
|
240
|
+
url.searchParams.set(key, JSON.stringify(value));
|
|
241
|
+
}
|
|
242
|
+
const response = await fetch(url, {
|
|
243
|
+
headers: {
|
|
244
|
+
Accept: 'application/json',
|
|
245
|
+
Authorization: `Bearer ${jwt.accessToken}`,
|
|
246
|
+
},
|
|
247
|
+
});
|
|
248
|
+
assert.equal(response.status, 200);
|
|
249
|
+
const body = await response.json();
|
|
250
|
+
assert.equal(body.code, 0, JSON.stringify(body));
|
|
251
|
+
assert.deepEqual(body.data, expected);
|
|
252
|
+
}
|
|
253
|
+
for (const [action, field, value] of [
|
|
254
|
+
['group', 'groups', 'level'],
|
|
255
|
+
['group', 'groups', '["level"'],
|
|
256
|
+
['group', 'aggrs', '{"count":'],
|
|
257
|
+
['aggregate', 'aggrs', '{"count":'],
|
|
258
|
+
]) {
|
|
259
|
+
const url = new URL(app.util.getAbsoluteUrlByApiPath(`/training/student/${action}`));
|
|
260
|
+
url.searchParams.set('name', name);
|
|
261
|
+
url.searchParams.set(field, value);
|
|
262
|
+
const response = await fetch(url, {
|
|
263
|
+
headers: {
|
|
264
|
+
Accept: 'application/json',
|
|
265
|
+
Authorization: `Bearer ${jwt.accessToken}`,
|
|
266
|
+
},
|
|
267
|
+
});
|
|
268
|
+
assert.equal(response.status, 422);
|
|
269
|
+
assert.equal((await response.json()).code, 422);
|
|
270
|
+
}
|
|
271
|
+
for (const [action, controls] of [
|
|
272
|
+
['', { groups: ['level'] }],
|
|
273
|
+
['', { aggrs: { count: ['*'] } }],
|
|
274
|
+
['group', { groups: [] }],
|
|
275
|
+
['group', { groups: ['level', 'level'] }],
|
|
276
|
+
['group', { groups: ['name'] }],
|
|
277
|
+
['group', { aggrs: {} }],
|
|
278
|
+
['group', { aggrs: { count: [] } }],
|
|
279
|
+
['group', { aggrs: { count: ['*', '*'] } }],
|
|
280
|
+
['group', { aggrs: { count: ['mobile'] } }],
|
|
281
|
+
['group', { aggrs: { sum: ['level'] } }],
|
|
282
|
+
['aggregate', { groups: ['level'] }],
|
|
283
|
+
['aggregate', { groups: [] }],
|
|
284
|
+
] as const) {
|
|
285
|
+
await assert.rejects(
|
|
286
|
+
() =>
|
|
287
|
+
app.bean.executor.performAction(
|
|
288
|
+
'get',
|
|
289
|
+
action === '' ? '/training/student' : `/training/student/${action}`,
|
|
290
|
+
{
|
|
291
|
+
query: { name, ...controls },
|
|
292
|
+
},
|
|
293
|
+
),
|
|
294
|
+
(error: { code?: number }) => error.code === 422,
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
await assert.rejects(
|
|
298
|
+
() => student.service.student.group({ groups: ['name'] } as never),
|
|
299
|
+
(error: { code?: number }) => error.code === 422,
|
|
300
|
+
);
|
|
301
|
+
await assert.rejects(
|
|
302
|
+
() => student.service.student.aggregate({ groups: ['level'] } as never),
|
|
303
|
+
(error: { code?: number }) => error.code === 422,
|
|
304
|
+
);
|
|
305
|
+
assert.deepEqual(
|
|
306
|
+
await readAnalysis({
|
|
307
|
+
name,
|
|
308
|
+
columns: ['name'],
|
|
309
|
+
orders: 'level,desc',
|
|
310
|
+
pageNo: 2,
|
|
311
|
+
pageSize: 1,
|
|
312
|
+
}),
|
|
313
|
+
initial,
|
|
314
|
+
);
|
|
315
|
+
// Raw predicates are shared with select and take precedence over named filters.
|
|
316
|
+
assert.deepEqual(
|
|
317
|
+
await readAnalysis({
|
|
318
|
+
name,
|
|
319
|
+
level: 1,
|
|
320
|
+
where: { level: 2 },
|
|
321
|
+
}),
|
|
322
|
+
{
|
|
323
|
+
groups: [{ level: 2, count_all: '1' }],
|
|
324
|
+
aggregate: { count_all: '1', count_imageId: '0' },
|
|
325
|
+
},
|
|
326
|
+
);
|
|
327
|
+
for (const action of ['group', 'aggregate'] as const) {
|
|
328
|
+
for (const query of [
|
|
329
|
+
{ name, pageSize: 0 },
|
|
330
|
+
{ name, pageNo: 0 },
|
|
331
|
+
]) {
|
|
332
|
+
await assert.rejects(
|
|
333
|
+
() =>
|
|
334
|
+
app.bean.executor.performAction('get', `/training/student/${action}`, { query }),
|
|
335
|
+
(error: { code?: number }) => error.code === 422,
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
await app.bean.executor.performAction('patch', '/training/student/:id', {
|
|
340
|
+
params: { id: ids[1] },
|
|
341
|
+
body: { name: `${name}-1`, mobile: '13812345678', level: 2 },
|
|
342
|
+
});
|
|
343
|
+
assert.deepEqual(await readAnalysis({ name }), {
|
|
344
|
+
groups: [
|
|
345
|
+
{ level: 1, count_all: '1' },
|
|
346
|
+
{ level: 2, count_all: '2' },
|
|
347
|
+
],
|
|
348
|
+
aggregate: { count_all: '3', count_imageId: '1' },
|
|
349
|
+
});
|
|
350
|
+
await app.bean.executor.performAction('delete', '/training/student/:id', {
|
|
351
|
+
params: { id: ids[0] },
|
|
352
|
+
});
|
|
353
|
+
assert.equal(
|
|
354
|
+
(await student.model.student.getById(ids[0], { disableDeleted: true }))?.deleted,
|
|
355
|
+
true,
|
|
356
|
+
);
|
|
357
|
+
assert.deepEqual(await readAnalysis({ name }), {
|
|
358
|
+
groups: [{ level: 2, count_all: '2' }],
|
|
359
|
+
aggregate: { count_all: '2', count_imageId: '0' },
|
|
360
|
+
});
|
|
361
|
+
} finally {
|
|
362
|
+
try {
|
|
363
|
+
await dropStudents(ids);
|
|
364
|
+
if (imageId !== undefined) await app.bean.image.delete(imageId);
|
|
365
|
+
} finally {
|
|
366
|
+
if (directory) await rm(directory, { recursive: true, force: true });
|
|
367
|
+
await app.bean.passport.signout();
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
});
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
it('excludes students owned by another instance', async () => {
|
|
374
|
+
// Keep the full UUID within the VARCHAR(50) name column.
|
|
375
|
+
const name = `Analysis-${randomUUID()}`;
|
|
376
|
+
const ids: TableIdentity[] = [];
|
|
377
|
+
try {
|
|
378
|
+
await app.bean.executor.mockCtx(async () => {
|
|
379
|
+
await app.bean.passport.signinMock();
|
|
380
|
+
try {
|
|
381
|
+
ids.push(
|
|
382
|
+
await app.bean.executor.performAction('post', '/training/student', {
|
|
383
|
+
body: { name, mobile: '13812345678', level: 3 },
|
|
384
|
+
}),
|
|
385
|
+
);
|
|
386
|
+
assert.deepEqual(await readAnalysis({ name }), {
|
|
387
|
+
groups: [{ level: 3, count_all: '1' }],
|
|
388
|
+
aggregate: { count_all: '1', count_imageId: '0' },
|
|
389
|
+
});
|
|
390
|
+
} finally {
|
|
391
|
+
await app.bean.passport.signout();
|
|
392
|
+
}
|
|
393
|
+
});
|
|
394
|
+
await app.bean.executor.mockCtx(
|
|
395
|
+
async () => {
|
|
396
|
+
await app.bean.passport.signinMock();
|
|
397
|
+
try {
|
|
398
|
+
assert.deepEqual(await readAnalysis({ name }), {
|
|
399
|
+
groups: [],
|
|
400
|
+
aggregate: { count_all: '0', count_imageId: '0' },
|
|
401
|
+
});
|
|
402
|
+
} finally {
|
|
403
|
+
await app.bean.passport.signout();
|
|
404
|
+
}
|
|
405
|
+
},
|
|
406
|
+
{ instanceName: 'shareTest' as any },
|
|
407
|
+
);
|
|
408
|
+
await app.bean.executor.mockCtx(async () => {
|
|
409
|
+
assert.deepEqual(
|
|
410
|
+
await app.scope('training-student').service.student.aggregate({
|
|
411
|
+
where: { name },
|
|
412
|
+
}),
|
|
413
|
+
{ count_all: '1', count_imageId: '0' },
|
|
414
|
+
);
|
|
415
|
+
});
|
|
416
|
+
} finally {
|
|
417
|
+
await app.bean.executor.mockCtx(async () => {
|
|
418
|
+
await dropStudents(ids);
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
});
|
|
422
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vona-suite-a-cabloy",
|
|
3
|
-
"version": "5.1.
|
|
3
|
+
"version": "5.1.72",
|
|
4
4
|
"gitHead": "a79189b882c17af5911573896a781bbb0046d37d",
|
|
5
5
|
"description": "",
|
|
6
6
|
"author": "",
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"vona-module-a-cabloy": "workspace:^",
|
|
11
11
|
"vona-module-a-layoutprofile": "^5.0.2",
|
|
12
12
|
"vona-module-a-markdown": "^5.0.3",
|
|
13
|
-
"vona-module-a-rbac": "^5.0.
|
|
13
|
+
"vona-module-a-rbac": "^5.0.20",
|
|
14
14
|
"vona-module-a-socket": "workspace:^",
|
|
15
15
|
"vona-module-a-ssr": "workspace:^",
|
|
16
16
|
"vona-module-a-ssrhmr": "workspace:^"
|
|
@@ -38,7 +38,7 @@ export function getTargetDecoratorDtoOpenapi(
|
|
|
38
38
|
replace: true,
|
|
39
39
|
});
|
|
40
40
|
}
|
|
41
|
-
return appMetadata.
|
|
41
|
+
return appMetadata.getMetadata(SymbolDecoratorDtoOpenapi, target);
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
export function setTargetDecoratorDtoOpenapi(
|
|
@@ -1,4 +1,30 @@
|
|
|
1
1
|
// eslint-disable
|
|
2
|
+
/** schedule: begin */
|
|
3
|
+
export * from '../bean/schedule.softDeletionPrune.ts';
|
|
4
|
+
|
|
5
|
+
import { type IDecoratorScheduleOptions } from 'vona-module-a-schedule';
|
|
6
|
+
declare module 'vona-module-a-schedule' {
|
|
7
|
+
|
|
8
|
+
export interface IScheduleRecord {
|
|
9
|
+
'a-orm:softDeletionPrune': IDecoratorScheduleOptions;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
}
|
|
14
|
+
declare module 'vona-module-a-orm' {
|
|
15
|
+
|
|
16
|
+
export interface ScheduleSoftDeletionPrune {
|
|
17
|
+
/** @internal */
|
|
18
|
+
get scope(): ScopeModuleAOrm;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ScheduleSoftDeletionPrune {
|
|
22
|
+
get $beanFullName(): 'a-orm.schedule.softDeletionPrune';
|
|
23
|
+
get $onionName(): 'a-orm:softDeletionPrune';
|
|
24
|
+
get $onionOptions(): IDecoratorScheduleOptions;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/** schedule: end */
|
|
2
28
|
/** aopMethod: begin */
|
|
3
29
|
export * from '../bean/aopMethod.transaction.ts';
|
|
4
30
|
import type { IAopMethodOptionsTransaction } from '../bean/aopMethod.transaction.ts';
|
|
@@ -261,32 +287,6 @@ export interface IModuleQueue {
|
|
|
261
287
|
'doubleDelete': QueueDoubleDelete;
|
|
262
288
|
}
|
|
263
289
|
/** queue: end */
|
|
264
|
-
/** schedule: begin */
|
|
265
|
-
export * from '../bean/schedule.softDeletionPrune.ts';
|
|
266
|
-
|
|
267
|
-
import { type IDecoratorScheduleOptions } from 'vona-module-a-schedule';
|
|
268
|
-
declare module 'vona-module-a-schedule' {
|
|
269
|
-
|
|
270
|
-
export interface IScheduleRecord {
|
|
271
|
-
'a-orm:softDeletionPrune': IDecoratorScheduleOptions;
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
}
|
|
276
|
-
declare module 'vona-module-a-orm' {
|
|
277
|
-
|
|
278
|
-
export interface ScheduleSoftDeletionPrune {
|
|
279
|
-
/** @internal */
|
|
280
|
-
get scope(): ScopeModuleAOrm;
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
export interface ScheduleSoftDeletionPrune {
|
|
284
|
-
get $beanFullName(): 'a-orm.schedule.softDeletionPrune';
|
|
285
|
-
get $onionName(): 'a-orm:softDeletionPrune';
|
|
286
|
-
get $onionOptions(): IDecoratorScheduleOptions;
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
|
-
/** schedule: end */
|
|
290
290
|
/** config: begin */
|
|
291
291
|
export * from '../config/config.ts';
|
|
292
292
|
import type { config } from '../config/config.ts';
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
type AggregateFunction = 'count' | 'sum' | 'avg' | 'min' | 'max';
|
|
2
|
+
type AggregateSpec = Partial<Record<AggregateFunction, PropertyKey | readonly PropertyKey[]>>;
|
|
3
|
+
|
|
4
|
+
export function normalizeAggregateValue(value: unknown, fn: 'count'): string | undefined;
|
|
5
|
+
export function normalizeAggregateValue(
|
|
6
|
+
value: unknown,
|
|
7
|
+
fn: AggregateFunction,
|
|
8
|
+
): string | null | undefined;
|
|
9
|
+
export function normalizeAggregateValue(
|
|
10
|
+
value: unknown,
|
|
11
|
+
fn: AggregateFunction,
|
|
12
|
+
): string | null | undefined {
|
|
13
|
+
if (value === undefined) return value;
|
|
14
|
+
if (value === null && fn !== 'count') return value;
|
|
15
|
+
if (typeof value === 'string') {
|
|
16
|
+
if (fn !== 'count' || /^(0|[1-9]\d*)$/.test(value)) return value;
|
|
17
|
+
} else if (typeof value === 'number') {
|
|
18
|
+
if (Number.isFinite(value) && (fn !== 'count' || (Number.isSafeInteger(value) && value >= 0))) {
|
|
19
|
+
return String(value);
|
|
20
|
+
}
|
|
21
|
+
} else if (typeof value === 'bigint' && (fn !== 'count' || value >= 0n)) {
|
|
22
|
+
return String(value);
|
|
23
|
+
}
|
|
24
|
+
throw new TypeError(`Invalid ORM ${fn} aggregate result`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function normalizeAggregateRows<T extends {}>(rows: T[], aggrs?: AggregateSpec): T[] {
|
|
28
|
+
if (!aggrs || rows.length === 0) return rows;
|
|
29
|
+
const aliases: [string, AggregateFunction][] = [];
|
|
30
|
+
for (const fn of ['count', 'sum', 'avg', 'min', 'max'] as const) {
|
|
31
|
+
const columns = aggrs[fn];
|
|
32
|
+
if (columns === undefined) continue;
|
|
33
|
+
for (const column of typeof columns === 'object' ? columns : [columns]) {
|
|
34
|
+
aliases.push([`${fn}_${column === '*' ? 'all' : String(column)}`, fn]);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
let result = rows;
|
|
38
|
+
for (const [index, row] of rows.entries()) {
|
|
39
|
+
let target = row;
|
|
40
|
+
for (const [alias, fn] of aliases) {
|
|
41
|
+
if (!Object.hasOwn(row, alias)) continue;
|
|
42
|
+
const value = (row as Record<string, unknown>)[alias];
|
|
43
|
+
const normalized = normalizeAggregateValue(value, fn);
|
|
44
|
+
if (value === normalized) continue;
|
|
45
|
+
if (target === row) target = { ...row };
|
|
46
|
+
(target as Record<string, unknown>)[alias] = normalized;
|
|
47
|
+
}
|
|
48
|
+
if (target === row) continue;
|
|
49
|
+
if (result === rows) result = [...rows];
|
|
50
|
+
result[index] = target;
|
|
51
|
+
}
|
|
52
|
+
return result;
|
|
53
|
+
}
|