cabloy 5.1.249 → 5.1.251
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 +23 -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 +143 -88
- package/vona/pnpm-workspace.yaml +1 -1
- 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
- /package/vona/patches/{zova-core@5.1.95.patch → zova-core@5.1.96.patch} +0 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import type { Constructable } from 'vona';
|
|
2
|
+
|
|
3
|
+
import type { BeanModelMeta } from '../lib/bean.model/bean.model_meta.ts';
|
|
4
|
+
import type { TypeModelWhere } from './modelWhere.ts';
|
|
5
|
+
import type { IModelClassRecord } from './onion/model.ts';
|
|
6
|
+
import type { TypeModelOfModelLike, TypeSymbolKeyEntity } from './relations.ts';
|
|
7
|
+
|
|
8
|
+
export type TypeAnalysisMode = 'group' | 'aggregate';
|
|
9
|
+
export type TypeAnalysisFunction = 'count' | 'sum' | 'avg' | 'min' | 'max';
|
|
10
|
+
export type TypeAnalysisModelLike =
|
|
11
|
+
| Constructable<BeanModelMeta>
|
|
12
|
+
| (() => Constructable<BeanModelMeta>)
|
|
13
|
+
| keyof IModelClassRecord;
|
|
14
|
+
export type TypeAnalysisEntity<ModelLike extends TypeAnalysisModelLike> =
|
|
15
|
+
TypeModelOfModelLike<ModelLike>[TypeSymbolKeyEntity];
|
|
16
|
+
export type TypeAnalysisField<Entity> = Extract<keyof Entity, string>;
|
|
17
|
+
export type TypeAnalysisNumericField<Entity> = Exclude<
|
|
18
|
+
{
|
|
19
|
+
[K in keyof Entity]-?: NonNullable<Entity[K]> extends number ? K : never;
|
|
20
|
+
}[keyof Entity],
|
|
21
|
+
'id' | 'iid'
|
|
22
|
+
> &
|
|
23
|
+
string;
|
|
24
|
+
|
|
25
|
+
export type TypeAnalysisAggrs = Partial<Record<TypeAnalysisFunction, readonly string[]>>;
|
|
26
|
+
export type TypeAnalysisAllowedAggrs<Entity> = {
|
|
27
|
+
readonly count?: readonly ('*' | TypeAnalysisField<Entity>)[];
|
|
28
|
+
} & {
|
|
29
|
+
readonly [
|
|
30
|
+
K in Exclude<TypeAnalysisFunction, 'count'>
|
|
31
|
+
]?: readonly TypeAnalysisNumericField<Entity>[];
|
|
32
|
+
};
|
|
33
|
+
export type TypeAnalysisSelectionAggrs<Aggrs extends TypeAnalysisAggrs> = {
|
|
34
|
+
readonly [K in keyof Aggrs]?: NonNullable<Aggrs[K]> extends readonly string[]
|
|
35
|
+
? readonly NonNullable<Aggrs[K]>[number][]
|
|
36
|
+
: never;
|
|
37
|
+
};
|
|
38
|
+
export interface IAnalysisLimits {
|
|
39
|
+
readonly maxGroups: number;
|
|
40
|
+
readonly maxMetrics: number;
|
|
41
|
+
readonly maxGroupRows: number;
|
|
42
|
+
}
|
|
43
|
+
export interface IAnalysisSelection {
|
|
44
|
+
readonly groups?: readonly string[];
|
|
45
|
+
readonly aggrs: TypeAnalysisAggrs;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Structural server-owned policy surface; model references must not be serialized. */
|
|
49
|
+
export interface IAnalysisPolicy {
|
|
50
|
+
readonly modelLike: TypeAnalysisModelLike;
|
|
51
|
+
readonly modelClass: Constructable<BeanModelMeta>;
|
|
52
|
+
readonly groups: readonly string[];
|
|
53
|
+
readonly aggrs: TypeAnalysisAggrs;
|
|
54
|
+
readonly defaults: {
|
|
55
|
+
readonly group?: IAnalysisSelection & { readonly groups: readonly string[] };
|
|
56
|
+
readonly aggregate?: { readonly aggrs: TypeAnalysisAggrs };
|
|
57
|
+
};
|
|
58
|
+
readonly limits: IAnalysisLimits;
|
|
59
|
+
}
|
|
60
|
+
export type TypeAnalysisPolicy<
|
|
61
|
+
ModelLike extends TypeAnalysisModelLike,
|
|
62
|
+
Groups extends readonly string[],
|
|
63
|
+
Aggrs extends TypeAnalysisAggrs,
|
|
64
|
+
> = Omit<IAnalysisPolicy, 'modelLike' | 'groups' | 'aggrs'> & {
|
|
65
|
+
readonly modelLike: ModelLike;
|
|
66
|
+
readonly groups: Groups;
|
|
67
|
+
readonly aggrs: Aggrs;
|
|
68
|
+
};
|
|
69
|
+
export interface IAnalysisConfig<
|
|
70
|
+
Groups extends readonly string[],
|
|
71
|
+
Aggrs extends TypeAnalysisAggrs,
|
|
72
|
+
> {
|
|
73
|
+
readonly groups: Groups;
|
|
74
|
+
readonly aggrs: Aggrs;
|
|
75
|
+
readonly defaults: {
|
|
76
|
+
readonly group?: {
|
|
77
|
+
readonly groups: readonly NoInfer<Groups[number]>[];
|
|
78
|
+
readonly aggrs: TypeAnalysisSelectionAggrs<NoInfer<Aggrs>>;
|
|
79
|
+
};
|
|
80
|
+
readonly aggregate?: { readonly aggrs: TypeAnalysisSelectionAggrs<NoInfer<Aggrs>> };
|
|
81
|
+
};
|
|
82
|
+
readonly limits?: Partial<IAnalysisLimits>;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
interface IAnalysisParams<Policy extends IAnalysisPolicy> {
|
|
86
|
+
where?: TypeModelWhere<TypeAnalysisEntity<Policy['modelLike']>>;
|
|
87
|
+
aggrs?: TypeAnalysisSelectionAggrs<Policy['aggrs']>;
|
|
88
|
+
// Existing list controls remain accepted but never drive analysis execution.
|
|
89
|
+
columns?: unknown;
|
|
90
|
+
orders?: unknown;
|
|
91
|
+
limit?: number;
|
|
92
|
+
offset?: number;
|
|
93
|
+
pageNo?: number;
|
|
94
|
+
pageSize?: number;
|
|
95
|
+
joins?: never;
|
|
96
|
+
having?: never;
|
|
97
|
+
distinct?: never;
|
|
98
|
+
include?: never;
|
|
99
|
+
with?: never;
|
|
100
|
+
}
|
|
101
|
+
export type TypeAnalysisGroupParams<Policy extends IAnalysisPolicy> = IAnalysisParams<Policy> & {
|
|
102
|
+
groups?: readonly Policy['groups'][number][];
|
|
103
|
+
};
|
|
104
|
+
export type TypeAnalysisAggregateParams<Policy extends IAnalysisPolicy> =
|
|
105
|
+
IAnalysisParams<Policy> & {
|
|
106
|
+
groups?: never;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
type TypeAnalysisAlias<Column extends string> = Column extends '*' ? 'all' : Column;
|
|
110
|
+
export type TypeAnalysisAggregateResult<Policy extends IAnalysisPolicy> = {
|
|
111
|
+
-readonly [
|
|
112
|
+
Fn in keyof Policy['aggrs'] &
|
|
113
|
+
TypeAnalysisFunction as Policy['aggrs'][Fn] extends readonly string[]
|
|
114
|
+
? `${Fn}_${TypeAnalysisAlias<Policy['aggrs'][Fn][number]>}`
|
|
115
|
+
: never
|
|
116
|
+
]?: Fn extends 'count' ? string : string | null;
|
|
117
|
+
};
|
|
118
|
+
export type TypeAnalysisGroupResult<Policy extends IAnalysisPolicy> = Partial<
|
|
119
|
+
Pick<
|
|
120
|
+
TypeAnalysisEntity<Policy['modelLike']>,
|
|
121
|
+
Extract<Policy['groups'][number], keyof TypeAnalysisEntity<Policy['modelLike']>>
|
|
122
|
+
>
|
|
123
|
+
> &
|
|
124
|
+
TypeAnalysisAggregateResult<Policy>;
|
|
125
|
+
|
|
126
|
+
export interface IAnalysisCapabilities {
|
|
127
|
+
readonly mode: TypeAnalysisMode;
|
|
128
|
+
readonly groups: readonly string[];
|
|
129
|
+
readonly aggrs: TypeAnalysisAggrs;
|
|
130
|
+
readonly defaults: IAnalysisSelection;
|
|
131
|
+
readonly limits: IAnalysisLimits;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export interface IResolvedAnalysis {
|
|
135
|
+
groups?: string[];
|
|
136
|
+
aggrs: Partial<Record<TypeAnalysisFunction, string[]>>;
|
|
137
|
+
}
|
|
@@ -27,7 +27,7 @@ export type TypeUtilAggrPrepareColumns<TColumns> = TColumns extends string[]
|
|
|
27
27
|
: undefined;
|
|
28
28
|
export type TypeRecordAggrsValues<TRecord extends Record<string, any>> = TRecord[keyof TRecord];
|
|
29
29
|
export type TypeRecordAggrsValuesToObject<AggrValues extends string> = {
|
|
30
|
-
[K in AggrValues]: string | undefined;
|
|
30
|
+
[K in AggrValues]: K extends `count_${string}` ? string | undefined : string | null | undefined;
|
|
31
31
|
};
|
|
32
32
|
|
|
33
33
|
export type TypeUtilGetAggrsFromRelationAndIncludeWrapper<
|
|
@@ -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;
|