cabloy 5.1.249 → 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/CHANGELOG.md +21 -0
- package/package.json +1 -1
- 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/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 +7 -7
- 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/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 -11
- 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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import z from 'zod';
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
IAnalysisCapabilities,
|
|
5
|
+
IAnalysisPolicy,
|
|
6
|
+
IAnalysisSelection,
|
|
7
|
+
IResolvedAnalysis,
|
|
8
|
+
TypeAnalysisFunction,
|
|
9
|
+
TypeAnalysisMode,
|
|
10
|
+
} from '../../types/analysis.ts';
|
|
11
|
+
|
|
12
|
+
export const analysisFunctions = ['count', 'sum', 'avg', 'min', 'max'] as const;
|
|
13
|
+
export const analysisReservedFields = new Set([
|
|
14
|
+
'groups',
|
|
15
|
+
'aggrs',
|
|
16
|
+
'where',
|
|
17
|
+
'joins',
|
|
18
|
+
'having',
|
|
19
|
+
'columns',
|
|
20
|
+
'orders',
|
|
21
|
+
'limit',
|
|
22
|
+
'offset',
|
|
23
|
+
'pageNo',
|
|
24
|
+
'pageSize',
|
|
25
|
+
'distinct',
|
|
26
|
+
'include',
|
|
27
|
+
'with',
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
/** A deliberate validation/resource-bound error, also usable without an application context. */
|
|
31
|
+
export class AnalysisError extends Error {
|
|
32
|
+
readonly code = 422;
|
|
33
|
+
readonly status = 422;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function analysisFailure(message: string): never {
|
|
37
|
+
throw new AnalysisError(message);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function actionDefaults(policy: IAnalysisPolicy, mode: TypeAnalysisMode): IAnalysisSelection {
|
|
41
|
+
if (mode !== 'group' && mode !== 'aggregate') analysisFailure('Unknown analysis action');
|
|
42
|
+
const defaults = policy.defaults[mode];
|
|
43
|
+
if (!defaults) analysisFailure(`Analysis ${mode} action is not enabled`);
|
|
44
|
+
return defaults;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function columnArray(columns: readonly string[], max: number) {
|
|
48
|
+
return z
|
|
49
|
+
.array(z.enum([...columns]))
|
|
50
|
+
.min(1)
|
|
51
|
+
.max(max)
|
|
52
|
+
.superRefine((value, ctx) => {
|
|
53
|
+
if (new Set(value).size !== value.length) {
|
|
54
|
+
ctx.addIssue({ code: 'custom', message: 'Duplicate analysis columns are not allowed' });
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Native array/object schemas preserve finite enums for query coercion and OpenAPI. */
|
|
60
|
+
export function analysisControlSchemas(policy: IAnalysisPolicy, mode: TypeAnalysisMode) {
|
|
61
|
+
const defaults = actionDefaults(policy, mode);
|
|
62
|
+
const shape: Partial<Record<TypeAnalysisFunction, z.ZodType>> = {};
|
|
63
|
+
for (const fn of analysisFunctions) {
|
|
64
|
+
const columns = policy.aggrs[fn];
|
|
65
|
+
if (columns) shape[fn] = columnArray(columns, policy.limits.maxMetrics).optional();
|
|
66
|
+
}
|
|
67
|
+
const aggrs = z
|
|
68
|
+
.strictObject(shape)
|
|
69
|
+
.superRefine((value, ctx) => {
|
|
70
|
+
const count = Object.values(value).reduce<number>(
|
|
71
|
+
(total, columns) => total + (Array.isArray(columns) ? columns.length : 0),
|
|
72
|
+
0,
|
|
73
|
+
);
|
|
74
|
+
if (count === 0 || count > policy.limits.maxMetrics) {
|
|
75
|
+
ctx.addIssue({
|
|
76
|
+
code: 'custom',
|
|
77
|
+
message: `Analysis requires 1 to ${policy.limits.maxMetrics} metrics`,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
})
|
|
81
|
+
.default(() =>
|
|
82
|
+
Object.fromEntries(
|
|
83
|
+
Object.entries(defaults.aggrs).map(([fn, columns]) => [fn, [...columns!]]),
|
|
84
|
+
),
|
|
85
|
+
);
|
|
86
|
+
const groups =
|
|
87
|
+
mode === 'group'
|
|
88
|
+
? columnArray(policy.groups, policy.limits.maxGroups).default(() => [...defaults.groups!])
|
|
89
|
+
: undefined;
|
|
90
|
+
const selection = z.strictObject({ groups: groups ?? z.never().optional(), aggrs });
|
|
91
|
+
return { groups, aggrs, selection };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Validate controls independently of row filters and ignored list controls. */
|
|
95
|
+
export function resolveAnalysis(
|
|
96
|
+
policy: IAnalysisPolicy,
|
|
97
|
+
mode: TypeAnalysisMode,
|
|
98
|
+
input?: unknown,
|
|
99
|
+
): IResolvedAnalysis {
|
|
100
|
+
if (input !== undefined && (!input || typeof input !== 'object' || Array.isArray(input))) {
|
|
101
|
+
analysisFailure('Analysis parameters must be an object');
|
|
102
|
+
}
|
|
103
|
+
const params = (input ?? {}) as Record<string, unknown>;
|
|
104
|
+
for (const key of ['joins', 'having', 'distinct', 'include', 'with']) {
|
|
105
|
+
if (Object.hasOwn(params, key) && params[key] !== undefined) {
|
|
106
|
+
analysisFailure(`Analysis does not support ${key}`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (mode === 'aggregate' && Object.hasOwn(params, 'groups')) {
|
|
110
|
+
analysisFailure('Aggregate analysis does not support groups');
|
|
111
|
+
}
|
|
112
|
+
// The request context can enable generic query coercion. Direct service calls must
|
|
113
|
+
// still supply native arrays/objects, never JSON strings or coerced scalar items.
|
|
114
|
+
const assertNativeColumns = (columns: unknown) => {
|
|
115
|
+
if (!Array.isArray(columns) || columns.some(column => typeof column !== 'string')) {
|
|
116
|
+
analysisFailure('Analysis columns must be arrays of strings');
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
if (params.groups !== undefined) assertNativeColumns(params.groups);
|
|
120
|
+
if (params.aggrs !== undefined) {
|
|
121
|
+
if (!params.aggrs || typeof params.aggrs !== 'object' || Array.isArray(params.aggrs)) {
|
|
122
|
+
analysisFailure('Analysis metrics must be an object of column arrays');
|
|
123
|
+
}
|
|
124
|
+
for (const columns of Object.values(params.aggrs)) assertNativeColumns(columns);
|
|
125
|
+
}
|
|
126
|
+
const parsed = analysisControlSchemas(policy, mode).selection.safeParse({
|
|
127
|
+
...(mode === 'group' ? { groups: params.groups } : {}),
|
|
128
|
+
aggrs: params.aggrs,
|
|
129
|
+
});
|
|
130
|
+
if (!parsed.success) analysisFailure(parsed.error.message);
|
|
131
|
+
const result: IResolvedAnalysis = { aggrs: {} };
|
|
132
|
+
if (mode === 'group') {
|
|
133
|
+
// Canonical policy order also stabilizes SQL/cache keys for equivalent selections.
|
|
134
|
+
const selected = parsed.data.groups as string[];
|
|
135
|
+
result.groups = policy.groups.filter(column => selected.includes(column));
|
|
136
|
+
}
|
|
137
|
+
for (const fn of analysisFunctions) {
|
|
138
|
+
const selected = parsed.data.aggrs[fn] as string[] | undefined;
|
|
139
|
+
if (selected) result.aggrs[fn] = policy.aggrs[fn]!.filter(column => selected.includes(column));
|
|
140
|
+
}
|
|
141
|
+
return result;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function analysisCapabilities(
|
|
145
|
+
policy: IAnalysisPolicy,
|
|
146
|
+
mode: TypeAnalysisMode,
|
|
147
|
+
): IAnalysisCapabilities {
|
|
148
|
+
const defaults = actionDefaults(policy, mode);
|
|
149
|
+
return {
|
|
150
|
+
mode,
|
|
151
|
+
groups: mode === 'group' ? [...policy.groups] : [],
|
|
152
|
+
aggrs: Object.fromEntries(
|
|
153
|
+
Object.entries(policy.aggrs).map(([fn, columns]) => [fn, [...columns!]]),
|
|
154
|
+
),
|
|
155
|
+
defaults: {
|
|
156
|
+
...(mode === 'group' ? { groups: [...defaults.groups!] } : {}),
|
|
157
|
+
aggrs: Object.fromEntries(
|
|
158
|
+
Object.entries(defaults.aggrs).map(([fn, columns]) => [fn, [...columns!]]),
|
|
159
|
+
),
|
|
160
|
+
},
|
|
161
|
+
limits: { ...policy.limits },
|
|
162
|
+
};
|
|
163
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
IAnalysisPolicy,
|
|
3
|
+
TypeAnalysisAggregateParams,
|
|
4
|
+
TypeAnalysisAggregateResult,
|
|
5
|
+
TypeAnalysisGroupParams,
|
|
6
|
+
TypeAnalysisGroupResult,
|
|
7
|
+
} from '../../types/analysis.ts';
|
|
8
|
+
import type { TypeModelOfModelLike } from '../../types/relations.ts';
|
|
9
|
+
import type { BeanModelCache } from '../bean.model/bean.model_cache.ts';
|
|
10
|
+
|
|
11
|
+
import { analysisFailure, resolveAnalysis } from './controls.ts';
|
|
12
|
+
|
|
13
|
+
function assertModel(model: object, policy: IAnalysisPolicy) {
|
|
14
|
+
if (!(model instanceof policy.modelClass))
|
|
15
|
+
analysisFailure('Analysis policy does not belong to this model');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Keep instance/soft-delete scope, caching and scalar normalization owned by the ORM. */
|
|
19
|
+
export async function $executeAnalysisGroup<Policy extends IAnalysisPolicy>(
|
|
20
|
+
model: TypeModelOfModelLike<Policy['modelLike']>,
|
|
21
|
+
policy: Policy,
|
|
22
|
+
params?: TypeAnalysisGroupParams<Policy>,
|
|
23
|
+
): Promise<TypeAnalysisGroupResult<Policy>[]> {
|
|
24
|
+
assertModel(model, policy);
|
|
25
|
+
const { groups, aggrs } = resolveAnalysis(policy, 'group', params);
|
|
26
|
+
const rows = await (model as unknown as BeanModelCache<any>).group({
|
|
27
|
+
where: params?.where,
|
|
28
|
+
groups: groups!,
|
|
29
|
+
aggrs,
|
|
30
|
+
orders: groups!.map(column => [column, 'asc']),
|
|
31
|
+
limit: policy.limits.maxGroupRows + 1,
|
|
32
|
+
});
|
|
33
|
+
if (rows.length > policy.limits.maxGroupRows)
|
|
34
|
+
analysisFailure(`Analysis exceeds ${policy.limits.maxGroupRows} group rows`);
|
|
35
|
+
return rows as TypeAnalysisGroupResult<Policy>[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function $executeAnalysisAggregate<Policy extends IAnalysisPolicy>(
|
|
39
|
+
model: TypeModelOfModelLike<Policy['modelLike']>,
|
|
40
|
+
policy: Policy,
|
|
41
|
+
params?: TypeAnalysisAggregateParams<Policy>,
|
|
42
|
+
): Promise<TypeAnalysisAggregateResult<Policy>> {
|
|
43
|
+
assertModel(model, policy);
|
|
44
|
+
const { aggrs } = resolveAnalysis(policy, 'aggregate', params);
|
|
45
|
+
return (await (model as unknown as BeanModelCache<any>).aggregate({
|
|
46
|
+
where: params?.where,
|
|
47
|
+
aggrs,
|
|
48
|
+
})) as TypeAnalysisAggregateResult<Policy>;
|
|
49
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { ZodMetadata } from '@cabloy/zod-openapi';
|
|
2
|
+
import { getTargetDecoratorRules } from 'vona-module-a-openapiutils';
|
|
3
|
+
|
|
4
|
+
import type {
|
|
5
|
+
IAnalysisConfig,
|
|
6
|
+
IAnalysisPolicy,
|
|
7
|
+
TypeAnalysisAggrs,
|
|
8
|
+
TypeAnalysisAllowedAggrs,
|
|
9
|
+
TypeAnalysisEntity,
|
|
10
|
+
TypeAnalysisField,
|
|
11
|
+
TypeAnalysisFunction,
|
|
12
|
+
TypeAnalysisModelLike,
|
|
13
|
+
TypeAnalysisPolicy,
|
|
14
|
+
} from '../../types/analysis.ts';
|
|
15
|
+
|
|
16
|
+
import { getClassEntityFromClassModel, prepareClassModel } from '../../common/utils.ts';
|
|
17
|
+
import { analysisFunctions, analysisReservedFields, resolveAnalysis } from './controls.ts';
|
|
18
|
+
|
|
19
|
+
function invalid(message: string): never {
|
|
20
|
+
throw new TypeError(`Invalid analysis policy: ${message}`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function assertColumns(
|
|
24
|
+
columns: unknown,
|
|
25
|
+
label: string,
|
|
26
|
+
allowEmpty = false,
|
|
27
|
+
): asserts columns is string[] {
|
|
28
|
+
if (!Array.isArray(columns) || (!allowEmpty && columns.length === 0))
|
|
29
|
+
invalid(`${label} must be a nonempty column array`);
|
|
30
|
+
if (columns.some(column => typeof column !== 'string'))
|
|
31
|
+
invalid(`${label} columns must be strings`);
|
|
32
|
+
if (new Set(columns).size !== columns.length) invalid(`${label} contains duplicate columns`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function copyAggrs(aggrs: TypeAnalysisAggrs): TypeAnalysisAggrs {
|
|
36
|
+
return Object.freeze(
|
|
37
|
+
Object.fromEntries(
|
|
38
|
+
Object.entries(aggrs).map(([fn, columns]) => [fn, Object.freeze([...columns!])]),
|
|
39
|
+
),
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Static model/entity metadata only: safe while DTO classes are being evaluated. */
|
|
44
|
+
export function $defineAnalysis<
|
|
45
|
+
ModelLike extends TypeAnalysisModelLike,
|
|
46
|
+
const Groups extends readonly TypeAnalysisField<TypeAnalysisEntity<ModelLike>>[],
|
|
47
|
+
const Aggrs extends TypeAnalysisAllowedAggrs<TypeAnalysisEntity<ModelLike>>,
|
|
48
|
+
>(
|
|
49
|
+
modelLike: ModelLike,
|
|
50
|
+
config: IAnalysisConfig<Groups, Aggrs> & {
|
|
51
|
+
aggrs: Aggrs & Record<Exclude<keyof Aggrs, TypeAnalysisFunction>, never>;
|
|
52
|
+
},
|
|
53
|
+
): TypeAnalysisPolicy<ModelLike, Groups, Aggrs> {
|
|
54
|
+
const modelClass = prepareClassModel(modelLike as any);
|
|
55
|
+
const entityClass = getClassEntityFromClassModel(modelClass);
|
|
56
|
+
const rules = getTargetDecoratorRules(entityClass.prototype, true);
|
|
57
|
+
assertColumns(config.groups, 'groups', true);
|
|
58
|
+
const validateField = (column: string, numeric: boolean) => {
|
|
59
|
+
if (!/^[A-Z_]\w*$/i.test(column) || analysisReservedFields.has(column))
|
|
60
|
+
invalid(`unsupported column ${column}`);
|
|
61
|
+
if (!Object.hasOwn(rules, column)) invalid(`unknown entity column ${column}`);
|
|
62
|
+
const schema = ZodMetadata.unwrapChained(ZodMetadata.resolveLazySchema(rules[column]!));
|
|
63
|
+
if (numeric && (column === 'id' || column === 'iid' || schema.def.type !== 'number'))
|
|
64
|
+
invalid(`non-count metrics require numeric columns: ${column}`);
|
|
65
|
+
};
|
|
66
|
+
for (const column of config.groups) validateField(column, false);
|
|
67
|
+
if (!config.aggrs || typeof config.aggrs !== 'object' || Array.isArray(config.aggrs))
|
|
68
|
+
invalid('aggrs must be an object');
|
|
69
|
+
const aliases = new Set<string>(config.groups);
|
|
70
|
+
let metricCount = 0;
|
|
71
|
+
for (const [fn, columns] of Object.entries(config.aggrs)) {
|
|
72
|
+
if (!analysisFunctions.includes(fn as TypeAnalysisFunction))
|
|
73
|
+
invalid(`unsupported function ${fn}`);
|
|
74
|
+
assertColumns(columns, fn);
|
|
75
|
+
for (const column of columns) {
|
|
76
|
+
if (column !== '*' || fn !== 'count') validateField(column, fn !== 'count');
|
|
77
|
+
const alias = `${fn}_${column === '*' ? 'all' : column}`;
|
|
78
|
+
if (aliases.has(alias) || analysisReservedFields.has(alias))
|
|
79
|
+
invalid(`alias collision: ${alias}`);
|
|
80
|
+
aliases.add(alias);
|
|
81
|
+
metricCount++;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (metricCount === 0) invalid('at least one allowed metric is required');
|
|
85
|
+
const limits = { maxGroups: 3, maxMetrics: 10, maxGroupRows: 1000, ...config.limits };
|
|
86
|
+
for (const [key, value] of Object.entries(limits)) {
|
|
87
|
+
if (
|
|
88
|
+
!['maxGroups', 'maxMetrics', 'maxGroupRows'].includes(key) ||
|
|
89
|
+
!Number.isSafeInteger(value) ||
|
|
90
|
+
value < 1 ||
|
|
91
|
+
value >= Number.MAX_SAFE_INTEGER
|
|
92
|
+
) {
|
|
93
|
+
invalid(`invalid limit ${key}`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (
|
|
97
|
+
!config.defaults ||
|
|
98
|
+
Object.keys(config.defaults).some(key => key !== 'group' && key !== 'aggregate')
|
|
99
|
+
) {
|
|
100
|
+
invalid('unknown or missing action defaults');
|
|
101
|
+
}
|
|
102
|
+
if (!config.defaults.group && !config.defaults.aggregate)
|
|
103
|
+
invalid('at least one action default is required');
|
|
104
|
+
const policy: IAnalysisPolicy = {
|
|
105
|
+
modelLike,
|
|
106
|
+
modelClass,
|
|
107
|
+
groups: Object.freeze([...config.groups]),
|
|
108
|
+
aggrs: copyAggrs(config.aggrs),
|
|
109
|
+
defaults: {},
|
|
110
|
+
limits: Object.freeze(limits),
|
|
111
|
+
};
|
|
112
|
+
const defaults: {
|
|
113
|
+
group?: IAnalysisPolicy['defaults']['group'];
|
|
114
|
+
aggregate?: IAnalysisPolicy['defaults']['aggregate'];
|
|
115
|
+
} = {};
|
|
116
|
+
for (const mode of ['group', 'aggregate'] as const) {
|
|
117
|
+
const input = config.defaults[mode];
|
|
118
|
+
if (!input) continue;
|
|
119
|
+
// Do not allow omitted defaults to expand authorization implicitly.
|
|
120
|
+
if (!Object.hasOwn(input, 'aggrs') || (mode === 'group' && !Object.hasOwn(input, 'groups')))
|
|
121
|
+
invalid(`incomplete ${mode} defaults`);
|
|
122
|
+
if (Object.keys(input).some(key => key !== 'aggrs' && !(mode === 'group' && key === 'groups')))
|
|
123
|
+
invalid(`unsupported ${mode} default control`);
|
|
124
|
+
const forValidation = { ...policy, defaults: { [mode]: input } } as IAnalysisPolicy;
|
|
125
|
+
try {
|
|
126
|
+
const resolved = resolveAnalysis(forValidation, mode, input);
|
|
127
|
+
const aggrs = copyAggrs(resolved.aggrs);
|
|
128
|
+
if (mode === 'group')
|
|
129
|
+
defaults.group = Object.freeze({ groups: Object.freeze(resolved.groups!), aggrs });
|
|
130
|
+
else defaults.aggregate = Object.freeze({ aggrs });
|
|
131
|
+
} catch (error) {
|
|
132
|
+
invalid(`${mode} defaults: ${(error as Error).message}`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return Object.freeze({ ...policy, defaults: Object.freeze(defaults) }) as TypeAnalysisPolicy<
|
|
136
|
+
ModelLike,
|
|
137
|
+
Groups,
|
|
138
|
+
Aggrs
|
|
139
|
+
>;
|
|
140
|
+
}
|
|
@@ -20,6 +20,7 @@ import type {
|
|
|
20
20
|
IModelSelectAggrParams,
|
|
21
21
|
IModelSelectCountParams,
|
|
22
22
|
IModelSelectGroupParams,
|
|
23
|
+
IModelSelectGeneralParams,
|
|
23
24
|
IModelSelectParams,
|
|
24
25
|
IModelUpdateOptions,
|
|
25
26
|
ITableRecord,
|
|
@@ -34,6 +35,7 @@ import type {
|
|
|
34
35
|
} from '../../types/index.ts';
|
|
35
36
|
|
|
36
37
|
import { getTargetColumnName } from '../../common/utils.ts';
|
|
38
|
+
import { normalizeAggregateRows } from '../../lib/aggregateResult.ts';
|
|
37
39
|
import { getCacheModelsClear } from '../../lib/const.ts';
|
|
38
40
|
import { ServiceCacheEntity } from '../../service/cacheEntity_.ts';
|
|
39
41
|
import { ServiceCacheQuery } from '../../service/cacheQuery_.ts';
|
|
@@ -425,7 +427,7 @@ export class BeanModelCache<TRecord extends {} = {}> extends BeanModelCrud<TReco
|
|
|
425
427
|
|
|
426
428
|
private async __select_cache(
|
|
427
429
|
table: keyof ITableRecord,
|
|
428
|
-
params?: IModelSelectParams<TRecord>,
|
|
430
|
+
params?: IModelSelectParams<TRecord> & Pick<IModelSelectGeneralParams<TRecord>, 'aggrs'>,
|
|
429
431
|
options?: IModelMethodOptions,
|
|
430
432
|
): Promise<TRecord[]> {
|
|
431
433
|
// check if cache
|
|
@@ -445,7 +447,7 @@ export class BeanModelCache<TRecord extends {} = {}> extends BeanModelCrud<TReco
|
|
|
445
447
|
},
|
|
446
448
|
db: this.db,
|
|
447
449
|
});
|
|
448
|
-
return items;
|
|
450
|
+
return normalizeAggregateRows(items, params?.aggrs);
|
|
449
451
|
}
|
|
450
452
|
|
|
451
453
|
async get<T extends IModelGetOptions<TRecord>>(
|
package/vona/src/suite-vendor/a-vona/modules/a-orm/src/lib/bean.model/bean.model_crud_inner.ts
CHANGED
|
@@ -18,6 +18,7 @@ import type {
|
|
|
18
18
|
TypeModelWhere,
|
|
19
19
|
} from '../../types/index.ts';
|
|
20
20
|
|
|
21
|
+
import { normalizeAggregateRows, normalizeAggregateValue } from '../aggregateResult.ts';
|
|
21
22
|
import { BeanModelView } from './bean.model_view.ts';
|
|
22
23
|
|
|
23
24
|
export class BeanModelCrudInner<TRecord extends {}> extends BeanModelView<TRecord> {
|
|
@@ -66,7 +67,8 @@ export class BeanModelCrudInner<TRecord extends {}> extends BeanModelView<TRecor
|
|
|
66
67
|
}
|
|
67
68
|
|
|
68
69
|
protected async _select<
|
|
69
|
-
T extends IModelSelectParams<TRecord> | IBuildModelSelectParams<TRecord, undefined, never
|
|
70
|
+
T extends (IModelSelectParams<TRecord> | IBuildModelSelectParams<TRecord, undefined, never>) &
|
|
71
|
+
Pick<IModelSelectGeneralParams<TRecord>, 'aggrs'>,
|
|
70
72
|
>(
|
|
71
73
|
table?: keyof ITableRecord,
|
|
72
74
|
params?: T,
|
|
@@ -89,7 +91,10 @@ export class BeanModelCrudInner<TRecord extends {}> extends BeanModelView<TRecor
|
|
|
89
91
|
}
|
|
90
92
|
return `model.select: ${tip}, ${builder.toQuery()}`;
|
|
91
93
|
});
|
|
92
|
-
|
|
94
|
+
const rows = (await this.dialect.select(builder, datas, () =>
|
|
95
|
+
this.columns(table),
|
|
96
|
+
)) as TRecord[];
|
|
97
|
+
return normalizeAggregateRows(rows, params?.aggrs);
|
|
93
98
|
}
|
|
94
99
|
|
|
95
100
|
protected _select_buildParams<T extends IModelSelectGeneralParams<TRecord>>(
|
|
@@ -164,7 +169,7 @@ export class BeanModelCrudInner<TRecord extends {}> extends BeanModelView<TRecor
|
|
|
164
169
|
// ready
|
|
165
170
|
this.$loggerChild('model').debug(() => `model.count: ${builder.toQuery()}`);
|
|
166
171
|
const res = await builder;
|
|
167
|
-
return this.
|
|
172
|
+
return normalizeAggregateValue(this.extractFirstValue(res), 'count');
|
|
168
173
|
}
|
|
169
174
|
|
|
170
175
|
protected async _increment(
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { DtoAggregate } from './dtoAggregate.ts';
|
|
2
|
+
import { DtoAnalysisAggregate, DtoAnalysisGroup } from './dtoAnalysis.ts';
|
|
2
3
|
import { DtoCreate } from './dtoCreate.ts';
|
|
3
4
|
import { DtoGet } from './dtoGet.ts';
|
|
4
5
|
import { DtoGroup } from './dtoGroup.ts';
|
|
@@ -15,6 +16,8 @@ export const $Dto = {
|
|
|
15
16
|
mutate: DtoMutate,
|
|
16
17
|
get: DtoGet,
|
|
17
18
|
aggregate: DtoAggregate,
|
|
19
|
+
analysisGroup: DtoAnalysisGroup,
|
|
20
|
+
analysisAggregate: DtoAnalysisAggregate,
|
|
18
21
|
group: DtoGroup,
|
|
19
22
|
query: DtoQuery,
|
|
20
23
|
queryPage: DtoQueryPage,
|
|
@@ -56,7 +56,10 @@ export function _DtoAggregate_inner<
|
|
|
56
56
|
if (!columns) continue;
|
|
57
57
|
for (const column of columns) {
|
|
58
58
|
const column2 = `${key}_${column === '*' ? 'all' : column}`;
|
|
59
|
-
Api.field(v.optional(), z.string())(
|
|
59
|
+
Api.field(v.optional(), key === 'count' ? z.string() : z.string().nullable())(
|
|
60
|
+
classTarget.prototype,
|
|
61
|
+
column2,
|
|
62
|
+
);
|
|
60
63
|
}
|
|
61
64
|
}
|
|
62
65
|
return classTarget as any;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { Constructable } from 'vona';
|
|
2
|
+
|
|
3
|
+
import { $Class } from 'vona';
|
|
4
|
+
|
|
5
|
+
import type {
|
|
6
|
+
IAnalysisPolicy,
|
|
7
|
+
TypeAnalysisAggregateResult,
|
|
8
|
+
TypeAnalysisGroupResult,
|
|
9
|
+
} from '../../types/analysis.ts';
|
|
10
|
+
|
|
11
|
+
import { _DtoAggregate_inner } from './dtoAggregate.ts';
|
|
12
|
+
import { DtoGroup } from './dtoGroup.ts';
|
|
13
|
+
|
|
14
|
+
/** A finite optional superset: absent fields are unrequested, null retains SQL meaning. */
|
|
15
|
+
export function DtoAnalysisGroup<Policy extends IAnalysisPolicy>(
|
|
16
|
+
policy: Policy,
|
|
17
|
+
): Constructable<TypeAnalysisGroupResult<Policy>> {
|
|
18
|
+
if (!policy.defaults.group) throw new TypeError('Analysis group action is not enabled');
|
|
19
|
+
const base = DtoGroup(policy.modelLike as any, [...policy.groups] as any, policy.aggrs as any);
|
|
20
|
+
return $Class.partial(base) as Constructable<TypeAnalysisGroupResult<Policy>>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function DtoAnalysisAggregate<Policy extends IAnalysisPolicy>(
|
|
24
|
+
policy: Policy,
|
|
25
|
+
): Constructable<TypeAnalysisAggregateResult<Policy>> {
|
|
26
|
+
if (!policy.defaults.aggregate) throw new TypeError('Analysis aggregate action is not enabled');
|
|
27
|
+
abstract class TargetClass {}
|
|
28
|
+
// Aggregate aliases deliberately do not copy field validators/renderers from source columns.
|
|
29
|
+
return _DtoAggregate_inner(
|
|
30
|
+
TargetClass as any,
|
|
31
|
+
policy.modelLike as any,
|
|
32
|
+
policy.aggrs as any,
|
|
33
|
+
) as Constructable<TypeAnalysisAggregateResult<Policy>>;
|
|
34
|
+
}
|