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,81 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { describe, it } from 'node:test';
|
|
3
|
+
|
|
4
|
+
import { normalizeAggregateRows, normalizeAggregateValue } from '../src/lib/aggregateResult.ts';
|
|
5
|
+
|
|
6
|
+
describe('ORM aggregate result adaptation', () => {
|
|
7
|
+
it('preserves driver strings and converts supported numeric scalars', () => {
|
|
8
|
+
for (const fn of ['count', 'sum', 'avg', 'min', 'max'] as const) {
|
|
9
|
+
for (const value of [0, 17, 17n, Number.MAX_SAFE_INTEGER, '900719925474099312345']) {
|
|
10
|
+
assert.equal(normalizeAggregateValue(value, fn), String(value));
|
|
11
|
+
}
|
|
12
|
+
assert.equal(normalizeAggregateValue(undefined, fn), undefined);
|
|
13
|
+
for (const value of [true, {}, new Date(), Number.NaN, Infinity, -Infinity]) {
|
|
14
|
+
assert.throws(() => normalizeAggregateValue(value, fn), TypeError);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
for (const fn of ['sum', 'avg', 'min', 'max'] as const) {
|
|
18
|
+
for (const value of ['001.2300', '-12345678901234567890.12345', '1e-100', -1.25, -1n]) {
|
|
19
|
+
assert.equal(normalizeAggregateValue(value, fn), String(value));
|
|
20
|
+
}
|
|
21
|
+
assert.equal(normalizeAggregateValue(null, fn), null);
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('rejects invalid and already lossy count values', () => {
|
|
26
|
+
for (const value of [
|
|
27
|
+
null,
|
|
28
|
+
-1,
|
|
29
|
+
-1n,
|
|
30
|
+
1.5,
|
|
31
|
+
Number.MAX_SAFE_INTEGER + 1,
|
|
32
|
+
'',
|
|
33
|
+
'01',
|
|
34
|
+
'-1',
|
|
35
|
+
'+1',
|
|
36
|
+
' 1',
|
|
37
|
+
'1 ',
|
|
38
|
+
'1.0',
|
|
39
|
+
'1e3',
|
|
40
|
+
]) {
|
|
41
|
+
assert.throws(() => normalizeAggregateValue(value, 'count'), TypeError);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('adapts only requested aliases without mutating cached rows', () => {
|
|
46
|
+
const unchanged = Object.freeze({ level: 2, count_all: '2', sum_age: null });
|
|
47
|
+
const legacy = Object.freeze({
|
|
48
|
+
level: 1,
|
|
49
|
+
count_all: 2,
|
|
50
|
+
avg_age: 1.25,
|
|
51
|
+
sum_age: null,
|
|
52
|
+
count_name: 7,
|
|
53
|
+
});
|
|
54
|
+
const rows = [unchanged, legacy];
|
|
55
|
+
const result = normalizeAggregateRows(rows, { count: '*', sum: 'age', avg: 'age' });
|
|
56
|
+
assert.notEqual(result, rows);
|
|
57
|
+
assert.equal(result[0], unchanged);
|
|
58
|
+
assert.notEqual(result[1], legacy);
|
|
59
|
+
assert.deepEqual(result[1], {
|
|
60
|
+
level: 1,
|
|
61
|
+
count_all: '2',
|
|
62
|
+
avg_age: '1.25',
|
|
63
|
+
sum_age: null,
|
|
64
|
+
count_name: 7,
|
|
65
|
+
});
|
|
66
|
+
assert.equal(legacy.count_all, 2);
|
|
67
|
+
assert.equal(normalizeAggregateRows(result, { count: '*', sum: 'age', avg: 'age' }), result);
|
|
68
|
+
assert.equal(normalizeAggregateRows(rows), rows);
|
|
69
|
+
assert.equal(normalizeAggregateRows(rows, {}), rows);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('preserves absent and undefined aliases and supports column lists', () => {
|
|
73
|
+
const rows = [{ count_all: 0n, count_imageId: undefined, name: 'unchanged' }];
|
|
74
|
+
assert.deepEqual(normalizeAggregateRows(rows, { count: ['*', 'imageId', 'name'] }), [
|
|
75
|
+
{ count_all: '0', count_imageId: undefined, name: 'unchanged' },
|
|
76
|
+
]);
|
|
77
|
+
assert.equal(Object.hasOwn(rows[0], 'count_name'), false);
|
|
78
|
+
const empty: object[] = [];
|
|
79
|
+
assert.equal(normalizeAggregateRows(empty, { count: '*' }), empty);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { describe, it } from 'node:test';
|
|
3
|
+
|
|
4
|
+
import type { IAnalysisPolicy } from '../src/types/analysis.ts';
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
AnalysisError,
|
|
8
|
+
analysisCapabilities,
|
|
9
|
+
analysisControlSchemas,
|
|
10
|
+
resolveAnalysis,
|
|
11
|
+
} from '../src/lib/analysis/controls.ts';
|
|
12
|
+
import { $executeAnalysisAggregate, $executeAnalysisGroup } from '../src/lib/analysis/execute.ts';
|
|
13
|
+
|
|
14
|
+
class Model {
|
|
15
|
+
calls: unknown[] = [];
|
|
16
|
+
rows: object[] = [];
|
|
17
|
+
group(params: unknown) {
|
|
18
|
+
this.calls.push(params);
|
|
19
|
+
return Promise.resolve(this.rows);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
aggregate(params: unknown) {
|
|
23
|
+
this.calls.push(params);
|
|
24
|
+
return Promise.resolve(this.rows[0]);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
const policy = {
|
|
28
|
+
modelLike: Model,
|
|
29
|
+
modelClass: Model,
|
|
30
|
+
groups: ['level', 'category'],
|
|
31
|
+
aggrs: {
|
|
32
|
+
count: ['*', 'imageId'],
|
|
33
|
+
sum: ['amount'],
|
|
34
|
+
avg: ['amount'],
|
|
35
|
+
min: ['amount'],
|
|
36
|
+
max: ['amount'],
|
|
37
|
+
},
|
|
38
|
+
defaults: {
|
|
39
|
+
group: { groups: ['level'], aggrs: { count: ['*'] } },
|
|
40
|
+
aggregate: { aggrs: { count: ['*', 'imageId'] } },
|
|
41
|
+
},
|
|
42
|
+
limits: { maxGroups: 2, maxMetrics: 5, maxGroupRows: 2 },
|
|
43
|
+
} as unknown as IAnalysisPolicy;
|
|
44
|
+
const invalid = (run: () => unknown) =>
|
|
45
|
+
assert.throws(
|
|
46
|
+
run,
|
|
47
|
+
error => error instanceof AnalysisError && error.status === 422 && error.code === 422,
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
describe('analysis controls', () => {
|
|
51
|
+
it('defaults each omitted control independently and canonicalizes policy order', () => {
|
|
52
|
+
assert.deepEqual(resolveAnalysis(policy, 'group'), {
|
|
53
|
+
groups: ['level'],
|
|
54
|
+
aggrs: { count: ['*'] },
|
|
55
|
+
});
|
|
56
|
+
assert.deepEqual(resolveAnalysis(policy, 'group', { groups: ['category'] }), {
|
|
57
|
+
groups: ['category'],
|
|
58
|
+
aggrs: { count: ['*'] },
|
|
59
|
+
});
|
|
60
|
+
assert.deepEqual(resolveAnalysis(policy, 'group', { aggrs: { sum: ['amount'] } }), {
|
|
61
|
+
groups: ['level'],
|
|
62
|
+
aggrs: { sum: ['amount'] },
|
|
63
|
+
});
|
|
64
|
+
assert.deepEqual(
|
|
65
|
+
resolveAnalysis(policy, 'group', {
|
|
66
|
+
groups: ['category', 'level'],
|
|
67
|
+
aggrs: { count: ['imageId', '*'] },
|
|
68
|
+
}),
|
|
69
|
+
{ groups: ['level', 'category'], aggrs: { count: ['*', 'imageId'] } },
|
|
70
|
+
);
|
|
71
|
+
assert.deepEqual(resolveAnalysis(policy, 'aggregate'), { aggrs: { count: ['*', 'imageId'] } });
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('rejects empty, duplicated, unknown, qualified and malformed controls', () => {
|
|
75
|
+
for (const input of [
|
|
76
|
+
{ groups: [] },
|
|
77
|
+
{ groups: ['level', 'level'] },
|
|
78
|
+
{ groups: ['unknown'] },
|
|
79
|
+
{ groups: ['table.level'] },
|
|
80
|
+
{ groups: 'level' },
|
|
81
|
+
{ groups: '["level"]' },
|
|
82
|
+
{ groups: [1] },
|
|
83
|
+
{ groups: null },
|
|
84
|
+
{ aggrs: '{"count":["*"]}' },
|
|
85
|
+
{ aggrs: { count: '["*"]' } },
|
|
86
|
+
{ aggrs: { count: '*' } },
|
|
87
|
+
{ aggrs: { count: [1] } },
|
|
88
|
+
{ aggrs: {} },
|
|
89
|
+
{ aggrs: null },
|
|
90
|
+
{ aggrs: [] },
|
|
91
|
+
{ aggrs: { count: [] } },
|
|
92
|
+
{ aggrs: { count: ['*', '*'] } },
|
|
93
|
+
{ aggrs: { count: ['table.imageId'] } },
|
|
94
|
+
{ aggrs: { count: ['unknown'] } },
|
|
95
|
+
{ aggrs: { sum: ['imageId'] } },
|
|
96
|
+
{ aggrs: { median: ['amount'] } },
|
|
97
|
+
{ aggrs: { 'sql.count': ['*'] } },
|
|
98
|
+
{ aggrs: { count: ['*'], avg: [] } },
|
|
99
|
+
{ joins: [] },
|
|
100
|
+
{ having: {} },
|
|
101
|
+
{ distinct: false },
|
|
102
|
+
{ include: {} },
|
|
103
|
+
{ with: {} },
|
|
104
|
+
'not-an-object',
|
|
105
|
+
null,
|
|
106
|
+
[],
|
|
107
|
+
])
|
|
108
|
+
invalid(() => resolveAnalysis(policy, 'group', input));
|
|
109
|
+
for (const groups of [[], ['level'], undefined, null])
|
|
110
|
+
invalid(() => resolveAnalysis(policy, 'aggregate', { groups }));
|
|
111
|
+
invalid(() => resolveAnalysis(policy, 'other' as never));
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('applies combined metric limits and refuses disabled actions', () => {
|
|
115
|
+
invalid(() =>
|
|
116
|
+
resolveAnalysis({ ...policy, limits: { ...policy.limits, maxGroups: 1 } }, 'group', {
|
|
117
|
+
groups: ['level', 'category'],
|
|
118
|
+
}),
|
|
119
|
+
);
|
|
120
|
+
invalid(() => resolveAnalysis(policy, 'aggregate', { aggrs: policy.aggrs }));
|
|
121
|
+
invalid(() =>
|
|
122
|
+
resolveAnalysis({ ...policy, defaults: { aggregate: policy.defaults.aggregate } }, 'group'),
|
|
123
|
+
);
|
|
124
|
+
assert.deepEqual(
|
|
125
|
+
resolveAnalysis(
|
|
126
|
+
{ ...policy, groups: [], defaults: { aggregate: policy.defaults.aggregate } },
|
|
127
|
+
'aggregate',
|
|
128
|
+
),
|
|
129
|
+
{ aggrs: { count: ['*', 'imageId'] } },
|
|
130
|
+
);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('exposes strict native schemas and only serializable public capabilities', () => {
|
|
134
|
+
const schemas = analysisControlSchemas(policy, 'group');
|
|
135
|
+
assert.equal(
|
|
136
|
+
schemas.selection.safeParse({ aggrs: { count: ['*'], unknown: ['amount'] } }).success,
|
|
137
|
+
false,
|
|
138
|
+
);
|
|
139
|
+
assert.equal(schemas.selection.safeParse({ extra: true }).success, false);
|
|
140
|
+
assert.equal(
|
|
141
|
+
analysisControlSchemas(policy, 'aggregate').selection.safeParse({ groups: ['level'] })
|
|
142
|
+
.success,
|
|
143
|
+
false,
|
|
144
|
+
);
|
|
145
|
+
assert.deepEqual(schemas.selection.parse({}), { groups: ['level'], aggrs: { count: ['*'] } });
|
|
146
|
+
const capabilities = analysisCapabilities(policy, 'group');
|
|
147
|
+
assert.deepEqual(JSON.parse(JSON.stringify(capabilities)), {
|
|
148
|
+
mode: 'group',
|
|
149
|
+
groups: ['level', 'category'],
|
|
150
|
+
aggrs: policy.aggrs,
|
|
151
|
+
defaults: policy.defaults.group,
|
|
152
|
+
limits: policy.limits,
|
|
153
|
+
});
|
|
154
|
+
assert.deepEqual(analysisCapabilities(policy, 'aggregate').groups, []);
|
|
155
|
+
(capabilities.groups as string[]).push('external');
|
|
156
|
+
assert.deepEqual(policy.groups, ['level', 'category']);
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
describe('analysis execution boundary', () => {
|
|
161
|
+
it('forwards only scoped where, resolved controls, canonical ordering and cap+1', async () => {
|
|
162
|
+
const model = new Model();
|
|
163
|
+
const where = { level: 1 };
|
|
164
|
+
model.rows = [
|
|
165
|
+
{ level: 1, count_all: '2' },
|
|
166
|
+
{ level: 2, count_all: '1' },
|
|
167
|
+
];
|
|
168
|
+
const rows = await $executeAnalysisGroup(model as never, policy, {
|
|
169
|
+
where,
|
|
170
|
+
limit: 999,
|
|
171
|
+
offset: 99,
|
|
172
|
+
columns: ['secret'],
|
|
173
|
+
orders: [['secret', 'desc']],
|
|
174
|
+
});
|
|
175
|
+
assert.equal(rows, model.rows);
|
|
176
|
+
assert.deepEqual(model.calls, [
|
|
177
|
+
{ where, groups: ['level'], aggrs: { count: ['*'] }, orders: [['level', 'asc']], limit: 3 },
|
|
178
|
+
]);
|
|
179
|
+
model.calls = [];
|
|
180
|
+
model.rows = [{ count_all: '0', sum_amount: null }];
|
|
181
|
+
const item = await $executeAnalysisAggregate(model as never, policy, {
|
|
182
|
+
where,
|
|
183
|
+
aggrs: { count: ['*'], sum: ['amount'] },
|
|
184
|
+
limit: 1,
|
|
185
|
+
});
|
|
186
|
+
assert.equal(item, model.rows[0]);
|
|
187
|
+
assert.deepEqual(model.calls, [{ where, aggrs: { count: ['*'], sum: ['amount'] } }]);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('rejects overflow, wrong models and invalid direct calls before execution', async () => {
|
|
191
|
+
const model = new Model();
|
|
192
|
+
model.rows = [{}, {}, {}];
|
|
193
|
+
await assert.rejects($executeAnalysisGroup(model as never, policy), AnalysisError);
|
|
194
|
+
model.calls = [];
|
|
195
|
+
await assert.rejects(
|
|
196
|
+
$executeAnalysisGroup(model as never, policy, { groups: [] }),
|
|
197
|
+
AnalysisError,
|
|
198
|
+
);
|
|
199
|
+
await assert.rejects(
|
|
200
|
+
$executeAnalysisAggregate(model as never, policy, { joins: [] } as never),
|
|
201
|
+
AnalysisError,
|
|
202
|
+
);
|
|
203
|
+
await assert.rejects($executeAnalysisAggregate({} as never, policy), AnalysisError);
|
|
204
|
+
assert.deepEqual(model.calls, []);
|
|
205
|
+
model.rows = [];
|
|
206
|
+
assert.deepEqual(await $executeAnalysisGroup(model as never, policy), []);
|
|
207
|
+
});
|
|
208
|
+
});
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import type { BeanModelMeta } from '../src/lib/bean.model/bean.model_meta.ts';
|
|
2
|
+
import type {
|
|
3
|
+
TypeAnalysisAggregateParams,
|
|
4
|
+
TypeAnalysisAggregateResult,
|
|
5
|
+
TypeAnalysisGroupParams,
|
|
6
|
+
TypeAnalysisGroupResult,
|
|
7
|
+
} from '../src/types/analysis.ts';
|
|
8
|
+
|
|
9
|
+
import { $defineAnalysis } from '../src/lib/analysis/policy.ts';
|
|
10
|
+
|
|
11
|
+
function expectType<_T extends true>() {}
|
|
12
|
+
type Equal<A, B> =
|
|
13
|
+
(<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2 ? true : false;
|
|
14
|
+
|
|
15
|
+
type Entity = {
|
|
16
|
+
id: string;
|
|
17
|
+
iid: number;
|
|
18
|
+
level: number;
|
|
19
|
+
amount?: number | null;
|
|
20
|
+
name: string;
|
|
21
|
+
description?: string | null;
|
|
22
|
+
imageId?: string;
|
|
23
|
+
};
|
|
24
|
+
declare const Model: new () => BeanModelMeta<Entity>;
|
|
25
|
+
|
|
26
|
+
// Compile-time assertions only; the factory is never executed by this file.
|
|
27
|
+
function checkAnalysisTypes() {
|
|
28
|
+
const policy = $defineAnalysis(() => Model, {
|
|
29
|
+
groups: ['level', 'description'],
|
|
30
|
+
aggrs: { count: ['*', 'imageId'], sum: ['amount'] },
|
|
31
|
+
defaults: {
|
|
32
|
+
group: { groups: ['level'], aggrs: { count: ['*'] } },
|
|
33
|
+
aggregate: { aggrs: { count: ['imageId'] } },
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
expectType<Equal<typeof policy.groups, readonly ['level', 'description']>>();
|
|
37
|
+
expectType<Equal<typeof policy.aggrs.count, readonly ['*', 'imageId']>>();
|
|
38
|
+
expectType<
|
|
39
|
+
Equal<
|
|
40
|
+
TypeAnalysisAggregateResult<typeof policy>,
|
|
41
|
+
{ count_all?: string; count_imageId?: string; sum_amount?: string | null }
|
|
42
|
+
>
|
|
43
|
+
>();
|
|
44
|
+
expectType<
|
|
45
|
+
Equal<TypeAnalysisGroupResult<typeof policy>['description'], string | null | undefined>
|
|
46
|
+
>();
|
|
47
|
+
expectType<Equal<TypeAnalysisGroupResult<typeof policy>['level'], number | undefined>>();
|
|
48
|
+
const group: TypeAnalysisGroupParams<typeof policy> = {
|
|
49
|
+
groups: ['description'],
|
|
50
|
+
aggrs: { sum: ['amount'] },
|
|
51
|
+
where: { name: 'x' },
|
|
52
|
+
};
|
|
53
|
+
const aggregate: TypeAnalysisAggregateParams<typeof policy> = { aggrs: { count: ['imageId'] } };
|
|
54
|
+
void group;
|
|
55
|
+
void aggregate;
|
|
56
|
+
// @ts-expect-error unapproved group
|
|
57
|
+
const badGroup: TypeAnalysisGroupParams<typeof policy> = { groups: ['name'] };
|
|
58
|
+
// @ts-expect-error field was allowed for count, not sum
|
|
59
|
+
const badColumn: TypeAnalysisAggregateParams<typeof policy> = { aggrs: { sum: ['imageId'] } };
|
|
60
|
+
// @ts-expect-error aggregate has no group controls
|
|
61
|
+
const badAggregate: TypeAnalysisAggregateParams<typeof policy> = { groups: ['level'] };
|
|
62
|
+
// @ts-expect-error unknown function
|
|
63
|
+
const badFunction: TypeAnalysisAggregateParams<typeof policy> = { aggrs: { median: ['amount'] } };
|
|
64
|
+
void [badGroup, badColumn, badAggregate, badFunction];
|
|
65
|
+
$defineAnalysis(Model, {
|
|
66
|
+
groups: ['level'],
|
|
67
|
+
// @ts-expect-error non-count metrics must be numeric
|
|
68
|
+
aggrs: { sum: ['name'] },
|
|
69
|
+
defaults: { aggregate: { aggrs: {} } },
|
|
70
|
+
});
|
|
71
|
+
$defineAnalysis(Model, {
|
|
72
|
+
groups: ['level'],
|
|
73
|
+
// @ts-expect-error identity columns cannot be summed
|
|
74
|
+
aggrs: { sum: ['iid'] },
|
|
75
|
+
defaults: { aggregate: { aggrs: {} } },
|
|
76
|
+
});
|
|
77
|
+
$defineAnalysis(Model, {
|
|
78
|
+
// @ts-expect-error qualified columns are not physical field names
|
|
79
|
+
groups: ['table.level'],
|
|
80
|
+
aggrs: { count: ['*'] },
|
|
81
|
+
defaults: { aggregate: { aggrs: { count: ['*'] } } },
|
|
82
|
+
});
|
|
83
|
+
$defineAnalysis(Model, {
|
|
84
|
+
groups: [],
|
|
85
|
+
// @ts-expect-error unknown function
|
|
86
|
+
aggrs: { count: ['*'], median: ['level'] },
|
|
87
|
+
defaults: { aggregate: { aggrs: { count: ['*'] } } },
|
|
88
|
+
});
|
|
89
|
+
$defineAnalysis(Model, {
|
|
90
|
+
groups: ['level'],
|
|
91
|
+
aggrs: { count: ['*'] },
|
|
92
|
+
defaults: {
|
|
93
|
+
// @ts-expect-error defaults cannot widen the group allowlist
|
|
94
|
+
group: { groups: ['name'], aggrs: { count: ['*'] } },
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
$defineAnalysis(Model, {
|
|
98
|
+
groups: ['level'],
|
|
99
|
+
aggrs: { count: ['*'] },
|
|
100
|
+
// @ts-expect-error defaults cannot widen the metric allowlist
|
|
101
|
+
defaults: { aggregate: { aggrs: { count: ['imageId'] } } },
|
|
102
|
+
});
|
|
103
|
+
const readonlyConfig = {
|
|
104
|
+
groups: ['level'],
|
|
105
|
+
aggrs: { count: ['*'] },
|
|
106
|
+
defaults: { group: { groups: ['level'], aggrs: { count: ['*'] } } },
|
|
107
|
+
} as const;
|
|
108
|
+
const readonlyPolicy = $defineAnalysis(Model, readonlyConfig);
|
|
109
|
+
expectType<Equal<typeof readonlyPolicy.aggrs.count, readonly ['*']>>();
|
|
110
|
+
void [policy, readonlyPolicy];
|
|
111
|
+
}
|
|
112
|
+
void checkAnalysisTypes;
|
|
@@ -1,4 +1,44 @@
|
|
|
1
1
|
// eslint-disable
|
|
2
|
+
/** filterTransform: begin */
|
|
3
|
+
export * from '../bean/filterTransform.base.ts';
|
|
4
|
+
export * from '../bean/filterTransform.dateRange.ts';
|
|
5
|
+
import type { IFilterTransformOptionsBase } from '../bean/filterTransform.base.ts';
|
|
6
|
+
import type { IFilterTransformOptionsDateRange } from '../bean/filterTransform.dateRange.ts';
|
|
7
|
+
import 'vona-module-a-web';
|
|
8
|
+
declare module 'vona-module-a-web' {
|
|
9
|
+
|
|
10
|
+
export interface IFilterTransformRecord {
|
|
11
|
+
'a-web:base': IFilterTransformOptionsBase;
|
|
12
|
+
'a-web:dateRange': IFilterTransformOptionsDateRange;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
}
|
|
17
|
+
declare module 'vona-module-a-web' {
|
|
18
|
+
|
|
19
|
+
export interface FilterTransformBase {
|
|
20
|
+
/** @internal */
|
|
21
|
+
get scope(): ScopeModuleAWeb;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface FilterTransformBase {
|
|
25
|
+
get $beanFullName(): 'a-web.filterTransform.base';
|
|
26
|
+
get $onionName(): 'a-web:base';
|
|
27
|
+
get $onionOptions(): IFilterTransformOptionsBase;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface FilterTransformDateRange {
|
|
31
|
+
/** @internal */
|
|
32
|
+
get scope(): ScopeModuleAWeb;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface FilterTransformDateRange {
|
|
36
|
+
get $beanFullName(): 'a-web.filterTransform.dateRange';
|
|
37
|
+
get $onionName(): 'a-web:dateRange';
|
|
38
|
+
get $onionOptions(): IFilterTransformOptionsDateRange;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/** filterTransform: end */
|
|
2
42
|
/** pipe: begin */
|
|
3
43
|
export * from '../bean/pipe.filter.ts';
|
|
4
44
|
export * from '../bean/pipe.valid.ts';
|
|
@@ -167,46 +207,6 @@ declare module 'vona-module-a-web' {
|
|
|
167
207
|
}
|
|
168
208
|
}
|
|
169
209
|
/** startup: end */
|
|
170
|
-
/** filterTransform: begin */
|
|
171
|
-
export * from '../bean/filterTransform.base.ts';
|
|
172
|
-
export * from '../bean/filterTransform.dateRange.ts';
|
|
173
|
-
import type { IFilterTransformOptionsBase } from '../bean/filterTransform.base.ts';
|
|
174
|
-
import type { IFilterTransformOptionsDateRange } from '../bean/filterTransform.dateRange.ts';
|
|
175
|
-
import 'vona-module-a-web';
|
|
176
|
-
declare module 'vona-module-a-web' {
|
|
177
|
-
|
|
178
|
-
export interface IFilterTransformRecord {
|
|
179
|
-
'a-web:base': IFilterTransformOptionsBase;
|
|
180
|
-
'a-web:dateRange': IFilterTransformOptionsDateRange;
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
}
|
|
185
|
-
declare module 'vona-module-a-web' {
|
|
186
|
-
|
|
187
|
-
export interface FilterTransformBase {
|
|
188
|
-
/** @internal */
|
|
189
|
-
get scope(): ScopeModuleAWeb;
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
export interface FilterTransformBase {
|
|
193
|
-
get $beanFullName(): 'a-web.filterTransform.base';
|
|
194
|
-
get $onionName(): 'a-web:base';
|
|
195
|
-
get $onionOptions(): IFilterTransformOptionsBase;
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
export interface FilterTransformDateRange {
|
|
199
|
-
/** @internal */
|
|
200
|
-
get scope(): ScopeModuleAWeb;
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
export interface FilterTransformDateRange {
|
|
204
|
-
get $beanFullName(): 'a-web.filterTransform.dateRange';
|
|
205
|
-
get $onionName(): 'a-web:dateRange';
|
|
206
|
-
get $onionOptions(): IFilterTransformOptionsDateRange;
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
/** filterTransform: end */
|
|
210
210
|
/** config: begin */
|
|
211
211
|
export * from '../config/config.ts';
|
|
212
212
|
import type { config } from '../config/config.ts';
|
|
@@ -8,13 +8,15 @@ import type {
|
|
|
8
8
|
ITableQuery,
|
|
9
9
|
RouteHandlerArgumentMeta,
|
|
10
10
|
} from 'vona-module-a-openapi';
|
|
11
|
+
import type { IAnalysisPolicy, TypeAnalysisMode } from 'vona-module-a-orm';
|
|
11
12
|
import type { ValidatorOptions } from 'vona-module-a-validation';
|
|
12
13
|
|
|
13
14
|
import { isNil, isNilOrEmptyString } from '@cabloy/utils';
|
|
14
15
|
import { ZodMetadata } from '@cabloy/zod-openapi';
|
|
15
16
|
import { isNullableSchema } from '@cabloy/zod-query';
|
|
16
|
-
import { BeanBase, beanFullNameFromOnionName, cast } from 'vona';
|
|
17
|
+
import { BeanBase, beanFullNameFromOnionName, cast, runWithZodQueryCoercion } from 'vona';
|
|
17
18
|
import { createArgumentPipe, Pipe } from 'vona-module-a-aspect';
|
|
19
|
+
import { analysisFailure, resolveAnalysis } from 'vona-module-a-orm';
|
|
18
20
|
|
|
19
21
|
import type {
|
|
20
22
|
IFilterTransformRecord,
|
|
@@ -28,9 +30,13 @@ export type TypePipeFilterResult = TypeQueryParamsPatch;
|
|
|
28
30
|
type TypeQueryJoin = NonNullable<TypeQueryParamsPatch['joins']>[number];
|
|
29
31
|
|
|
30
32
|
export interface IPipeOptionsFilter
|
|
31
|
-
extends IDecoratorPipeOptions, IDecoratorPipeOptionsArgument, ValidatorOptions {
|
|
33
|
+
extends IDecoratorPipeOptions, IDecoratorPipeOptionsArgument, ValidatorOptions {
|
|
34
|
+
analysis?: IAnalysisPolicy;
|
|
35
|
+
analysisMode?: TypeAnalysisMode;
|
|
36
|
+
}
|
|
32
37
|
|
|
33
38
|
const __FieldsSystem = ['columns', 'where', 'orders', 'pageNo', 'pageSize'];
|
|
39
|
+
const __AnalysisControls = ['groups', 'aggrs', 'having', 'joins', 'distinct', 'include', 'with'];
|
|
34
40
|
|
|
35
41
|
@Pipe<IPipeOptionsFilter>({
|
|
36
42
|
// ValidatorOptions
|
|
@@ -53,6 +59,7 @@ export class PipeFilter
|
|
|
53
59
|
`should specify the schema of pipeFilter: ${metadata.controller.name}.${metadata.method}#${metadata.index}`,
|
|
54
60
|
);
|
|
55
61
|
}
|
|
62
|
+
value = this._prepareAnalysisControls(value, options);
|
|
56
63
|
// validateSchema
|
|
57
64
|
value = (await this.bean.validator.validateSchema(
|
|
58
65
|
options.schema,
|
|
@@ -66,6 +73,35 @@ export class PipeFilter
|
|
|
66
73
|
return params;
|
|
67
74
|
}
|
|
68
75
|
|
|
76
|
+
private _prepareAnalysisControls(value: TypePipeFilterData, options: IPipeOptionsFilter) {
|
|
77
|
+
if (!!options.analysis !== !!options.analysisMode) {
|
|
78
|
+
throw new Error('Analysis filter requires both a policy and an action mode');
|
|
79
|
+
}
|
|
80
|
+
if (!value || typeof value !== 'object') return value;
|
|
81
|
+
const prepared = { ...value };
|
|
82
|
+
for (const key of __AnalysisControls) {
|
|
83
|
+
if (!Object.hasOwn(value, key)) continue;
|
|
84
|
+
if (!options.analysisMode) analysisFailure(`Select does not support ${key}`);
|
|
85
|
+
if (key !== 'aggrs' && !(key === 'groups' && options.analysisMode === 'group')) {
|
|
86
|
+
analysisFailure(`Analysis ${options.analysisMode} does not support ${key}`);
|
|
87
|
+
}
|
|
88
|
+
if (typeof value[key] === 'string') {
|
|
89
|
+
try {
|
|
90
|
+
prepared[key] = JSON.parse(value[key]);
|
|
91
|
+
} catch {
|
|
92
|
+
analysisFailure(`Analysis ${key} must be valid JSON`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (options.analysis && options.analysisMode) {
|
|
97
|
+
// Validate native shapes before permissive generic query coercion runs.
|
|
98
|
+
runWithZodQueryCoercion(false, () =>
|
|
99
|
+
resolveAnalysis(options.analysis!, options.analysisMode!, prepared),
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
return prepared;
|
|
103
|
+
}
|
|
104
|
+
|
|
69
105
|
private async _transform(value: TypePipeFilterData, options: IPipeOptionsFilter) {
|
|
70
106
|
// 1. system: columns/where/orders/pageNo/pageSize
|
|
71
107
|
const params = this._transformSystem(value);
|
|
@@ -76,6 +112,10 @@ export class PipeFilter
|
|
|
76
112
|
await this._transformFields(params, value, options, rootOpenapi);
|
|
77
113
|
// 3. system: orders
|
|
78
114
|
this._transformOrders(params, options, rootOpenapi);
|
|
115
|
+
if (options.analysis && options.analysisMode) {
|
|
116
|
+
if (params.joins?.length) analysisFailure('Analysis does not support joined filters');
|
|
117
|
+
Object.assign(params, resolveAnalysis(options.analysis, options.analysisMode, value));
|
|
118
|
+
}
|
|
79
119
|
// ok
|
|
80
120
|
return params;
|
|
81
121
|
}
|
|
@@ -188,7 +228,8 @@ export class PipeFilter
|
|
|
188
228
|
options: IPipeOptionsFilter,
|
|
189
229
|
rootOpenapi: ISchemaObjectExtensionField | undefined,
|
|
190
230
|
) {
|
|
191
|
-
if (__FieldsSystem.includes(key))
|
|
231
|
+
if (__FieldsSystem.includes(key) || (options.analysisMode && __AnalysisControls.includes(key)))
|
|
232
|
+
return;
|
|
192
233
|
const info = this._resolveField(key, options, rootOpenapi);
|
|
193
234
|
if (!info) return;
|
|
194
235
|
const { fieldSchema, fieldSchemaInner, openapi, originalName, fullName, joinInfo } = info;
|