vona-module-a-orm 5.1.39 → 5.1.41

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.
Files changed (61) hide show
  1. package/dist/.metadata/index.d.ts +18 -18
  2. package/dist/.metadata/index.d.ts.map +1 -1
  3. package/dist/index.js +459 -58
  4. package/dist/index.js.map +1 -1
  5. package/dist/lib/aggregateResult.d.ts +7 -0
  6. package/dist/lib/aggregateResult.d.ts.map +1 -0
  7. package/dist/lib/analysis/controls.d.ts +27 -0
  8. package/dist/lib/analysis/controls.d.ts.map +1 -0
  9. package/dist/lib/analysis/execute.d.ts +6 -0
  10. package/dist/lib/analysis/execute.d.ts.map +1 -0
  11. package/dist/lib/analysis/index.d.ts +4 -0
  12. package/dist/lib/analysis/index.d.ts.map +1 -0
  13. package/dist/lib/analysis/policy.d.ts +6 -0
  14. package/dist/lib/analysis/policy.d.ts.map +1 -0
  15. package/dist/lib/bean.model/bean.model_cache.d.ts +4 -1
  16. package/dist/lib/bean.model/bean.model_cache.d.ts.map +1 -1
  17. package/dist/lib/bean.model/bean.model_crud_inner.d.ts +1 -1
  18. package/dist/lib/bean.model/bean.model_crud_inner.d.ts.map +1 -1
  19. package/dist/lib/dto/dto.d.ts +3 -0
  20. package/dist/lib/dto/dto.d.ts.map +1 -1
  21. package/dist/lib/dto/dtoAggregate.d.ts.map +1 -1
  22. package/dist/lib/dto/dtoAnalysis.d.ts +6 -0
  23. package/dist/lib/dto/dtoAnalysis.d.ts.map +1 -0
  24. package/dist/lib/dto/dtoCreate.d.ts +2 -2
  25. package/dist/lib/dto/dtoCreate.d.ts.map +1 -1
  26. package/dist/lib/dto/dtoGet.d.ts.map +1 -1
  27. package/dist/lib/dto/dtoMutate.d.ts +2 -2
  28. package/dist/lib/dto/dtoMutate.d.ts.map +1 -1
  29. package/dist/lib/dto/dtoUpdate.d.ts +2 -2
  30. package/dist/lib/dto/dtoUpdate.d.ts.map +1 -1
  31. package/dist/lib/index.d.ts +1 -0
  32. package/dist/lib/index.d.ts.map +1 -1
  33. package/dist/types/analysis.d.ts +105 -0
  34. package/dist/types/analysis.d.ts.map +1 -0
  35. package/dist/types/dto/dtoMutate.d.ts +28 -11
  36. package/dist/types/dto/dtoMutate.d.ts.map +1 -1
  37. package/dist/types/index.d.ts +1 -0
  38. package/dist/types/index.d.ts.map +1 -1
  39. package/dist/types/relationsAggr.d.ts +1 -1
  40. package/dist/types/relationsAggr.d.ts.map +1 -1
  41. package/package.json +2 -2
  42. package/src/.metadata/index.ts +26 -26
  43. package/src/lib/aggregateResult.ts +53 -0
  44. package/src/lib/analysis/controls.ts +163 -0
  45. package/src/lib/analysis/execute.ts +49 -0
  46. package/src/lib/analysis/index.ts +3 -0
  47. package/src/lib/analysis/policy.ts +140 -0
  48. package/src/lib/bean.model/bean.model_cache.ts +96 -12
  49. package/src/lib/bean.model/bean.model_crud_inner.ts +8 -3
  50. package/src/lib/dto/dto.ts +3 -0
  51. package/src/lib/dto/dtoAggregate.ts +4 -1
  52. package/src/lib/dto/dtoAnalysis.ts +34 -0
  53. package/src/lib/dto/dtoCreate.ts +7 -9
  54. package/src/lib/dto/dtoGet.ts +27 -3
  55. package/src/lib/dto/dtoMutate.ts +2 -2
  56. package/src/lib/dto/dtoUpdate.ts +7 -9
  57. package/src/lib/index.ts +1 -0
  58. package/src/types/analysis.ts +137 -0
  59. package/src/types/dto/dtoMutate.ts +124 -87
  60. package/src/types/index.ts +1 -0
  61. package/src/types/relationsAggr.ts +1 -1
@@ -1,4 +1,30 @@
1
1
  // eslint-disable
2
+ /** schedule: begin */
3
+ export * from '../bean/schedule.softDeletionPrune.ts';
4
+
5
+ import { type IDecoratorScheduleOptions } from 'vona-module-a-schedule';
6
+ declare module 'vona-module-a-schedule' {
7
+
8
+ export interface IScheduleRecord {
9
+ 'a-orm:softDeletionPrune': IDecoratorScheduleOptions;
10
+ }
11
+
12
+
13
+ }
14
+ declare module 'vona-module-a-orm' {
15
+
16
+ export interface ScheduleSoftDeletionPrune {
17
+ /** @internal */
18
+ get scope(): ScopeModuleAOrm;
19
+ }
20
+
21
+ export interface ScheduleSoftDeletionPrune {
22
+ get $beanFullName(): 'a-orm.schedule.softDeletionPrune';
23
+ get $onionName(): 'a-orm:softDeletionPrune';
24
+ get $onionOptions(): IDecoratorScheduleOptions;
25
+ }
26
+ }
27
+ /** schedule: end */
2
28
  /** aopMethod: begin */
3
29
  export * from '../bean/aopMethod.transaction.ts';
4
30
  import type { IAopMethodOptionsTransaction } from '../bean/aopMethod.transaction.ts';
@@ -261,32 +287,6 @@ export interface IModuleQueue {
261
287
  'doubleDelete': QueueDoubleDelete;
262
288
  }
263
289
  /** queue: end */
264
- /** schedule: begin */
265
- export * from '../bean/schedule.softDeletionPrune.ts';
266
-
267
- import { type IDecoratorScheduleOptions } from 'vona-module-a-schedule';
268
- declare module 'vona-module-a-schedule' {
269
-
270
- export interface IScheduleRecord {
271
- 'a-orm:softDeletionPrune': IDecoratorScheduleOptions;
272
- }
273
-
274
-
275
- }
276
- declare module 'vona-module-a-orm' {
277
-
278
- export interface ScheduleSoftDeletionPrune {
279
- /** @internal */
280
- get scope(): ScopeModuleAOrm;
281
- }
282
-
283
- export interface ScheduleSoftDeletionPrune {
284
- get $beanFullName(): 'a-orm.schedule.softDeletionPrune';
285
- get $onionName(): 'a-orm:softDeletionPrune';
286
- get $onionOptions(): IDecoratorScheduleOptions;
287
- }
288
- }
289
- /** schedule: end */
290
290
  /** config: begin */
291
291
  export * from '../config/config.ts';
292
292
  import type { config } from '../config/config.ts';
@@ -0,0 +1,53 @@
1
+ type AggregateFunction = 'count' | 'sum' | 'avg' | 'min' | 'max';
2
+ type AggregateSpec = Partial<Record<AggregateFunction, PropertyKey | readonly PropertyKey[]>>;
3
+
4
+ export function normalizeAggregateValue(value: unknown, fn: 'count'): string | undefined;
5
+ export function normalizeAggregateValue(
6
+ value: unknown,
7
+ fn: AggregateFunction,
8
+ ): string | null | undefined;
9
+ export function normalizeAggregateValue(
10
+ value: unknown,
11
+ fn: AggregateFunction,
12
+ ): string | null | undefined {
13
+ if (value === undefined) return value;
14
+ if (value === null && fn !== 'count') return value;
15
+ if (typeof value === 'string') {
16
+ if (fn !== 'count' || /^(0|[1-9]\d*)$/.test(value)) return value;
17
+ } else if (typeof value === 'number') {
18
+ if (Number.isFinite(value) && (fn !== 'count' || (Number.isSafeInteger(value) && value >= 0))) {
19
+ return String(value);
20
+ }
21
+ } else if (typeof value === 'bigint' && (fn !== 'count' || value >= 0n)) {
22
+ return String(value);
23
+ }
24
+ throw new TypeError(`Invalid ORM ${fn} aggregate result`);
25
+ }
26
+
27
+ export function normalizeAggregateRows<T extends {}>(rows: T[], aggrs?: AggregateSpec): T[] {
28
+ if (!aggrs || rows.length === 0) return rows;
29
+ const aliases: [string, AggregateFunction][] = [];
30
+ for (const fn of ['count', 'sum', 'avg', 'min', 'max'] as const) {
31
+ const columns = aggrs[fn];
32
+ if (columns === undefined) continue;
33
+ for (const column of typeof columns === 'object' ? columns : [columns]) {
34
+ aliases.push([`${fn}_${column === '*' ? 'all' : String(column)}`, fn]);
35
+ }
36
+ }
37
+ let result = rows;
38
+ for (const [index, row] of rows.entries()) {
39
+ let target = row;
40
+ for (const [alias, fn] of aliases) {
41
+ if (!Object.hasOwn(row, alias)) continue;
42
+ const value = (row as Record<string, unknown>)[alias];
43
+ const normalized = normalizeAggregateValue(value, fn);
44
+ if (value === normalized) continue;
45
+ if (target === row) target = { ...row };
46
+ (target as Record<string, unknown>)[alias] = normalized;
47
+ }
48
+ if (target === row) continue;
49
+ if (result === rows) result = [...rows];
50
+ result[index] = target;
51
+ }
52
+ return result;
53
+ }
@@ -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,3 @@
1
+ export * from './controls.ts';
2
+ export * from './execute.ts';
3
+ export * from './policy.ts';
@@ -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';
@@ -152,7 +154,7 @@ export class BeanModelCache<TRecord extends {} = {}> extends BeanModelCrud<TReco
152
154
  }
153
155
  // insert/update
154
156
  const itemsInsertNew = await this.__insertBulk_raw(table, itemsInsert, options);
155
- await this.__updateBulk_raw(table, itemsUpdate, options);
157
+ const idsUpdate = await this.__updateBulk_raw(table, itemsUpdate, options);
156
158
  const itemsMutate = itemsInsert
157
159
  .map((item, index) => {
158
160
  return Object.assign({}, item, { id: cast(itemsInsertNew[index]).id });
@@ -160,10 +162,10 @@ export class BeanModelCache<TRecord extends {} = {}> extends BeanModelCrud<TReco
160
162
  .concat(itemsUpdate as any);
161
163
  let itemsMutateResult = itemsInsertNew.concat(itemsUpdate as any);
162
164
  const indexesMutate = indexesInsert.concat(indexesUpdate);
163
- itemsMutateResult = await this.relations.handleRelationsMutate(
165
+ itemsMutateResult = await this.__handleRelationsMutateAdmitted(
164
166
  itemsMutateResult,
165
167
  itemsMutate as any,
166
- options as any,
168
+ [...itemsInsertNew.map(item => [cast(item).id]), ...idsUpdate],
167
169
  options,
168
170
  );
169
171
  let result: TRecord[] = [];
@@ -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>>(
@@ -586,19 +588,65 @@ export class BeanModelCache<TRecord extends {} = {}> extends BeanModelCrud<TReco
586
588
  items: Partial<TRecord>[],
587
589
  options?: T,
588
590
  ): Promise<Partial<TRecord>[]> {
589
- await this.__updateBulk_raw(undefined, items, options);
590
- return await this.relations.handleRelationsMutate(items, items, options as any, options);
591
+ const ids = await this.__updateBulk_raw(undefined, items, options);
592
+ return await this.__handleRelationsMutateAdmitted(items, items, ids, options);
591
593
  }
592
594
 
593
595
  async __updateBulk_raw<T extends IModelUpdateOptions<TRecord>>(
594
596
  table: keyof ITableRecord | undefined,
595
597
  items: Partial<TRecord>[],
596
598
  options?: T,
597
- ): Promise<void> {
598
- if (items.length === 0) return;
599
+ ): Promise<(TableIdentity[] | void)[]> {
600
+ const ids: (TableIdentity[] | void)[] = [];
599
601
  for (const item of items) {
600
- await this.__update_raw(table, item, options);
602
+ ids.push(await this.__update_raw(table, item, options));
601
603
  }
604
+ return ids;
605
+ }
606
+
607
+ private async __handleRelationsMutateAdmitted<TResult extends {}>(
608
+ results: TResult[],
609
+ items: Partial<TRecord>[],
610
+ ids: (TableIdentity[] | void)[],
611
+ options?: IModelMutateOptions<TRecord>,
612
+ ): Promise<TResult[]> {
613
+ const indexes: number[] = [];
614
+ const itemsAdmitted: Partial<TRecord>[] = [];
615
+ const resultsAdmitted: TResult[] = [];
616
+ for (let index = 0; index < items.length; index++) {
617
+ const idsItem = ids[index];
618
+ if (!idsItem || idsItem.length !== 1) continue;
619
+ indexes.push(index);
620
+ itemsAdmitted.push(Object.assign({}, items[index], { id: idsItem[0] }));
621
+ resultsAdmitted.push(results[index]);
622
+ }
623
+ const resultsMutated = await this.relations.handleRelationsMutate(
624
+ resultsAdmitted,
625
+ itemsAdmitted as any,
626
+ options as any,
627
+ options,
628
+ );
629
+ const result = results.concat();
630
+ for (let index = 0; index < indexes.length; index++) {
631
+ result[indexes[index]] = resultsMutated[index];
632
+ }
633
+ return result;
634
+ }
635
+
636
+ private __hasWritableRelations(options?: IModelMutateOptions<TRecord>) {
637
+ return this.relations.handleRelationsCollection(options as any).some(([, relation]) => {
638
+ return relation !== false && relation.type !== 'belongsTo';
639
+ });
640
+ }
641
+
642
+ private async __selectMutationIds(
643
+ table: keyof ITableRecord,
644
+ where: TypeModelWhere<TRecord> | undefined,
645
+ options?: IModelMethodOptionsGeneral,
646
+ ): Promise<TableIdentity[]> {
647
+ // Admission must use current scoped rows, not cached entities or changed-row counts.
648
+ const items = await super._select(table, { where, columns: ['id'] as any }, options);
649
+ return items.map(item => cast(item).id);
602
650
  }
603
651
 
604
652
  async __update_raw(
@@ -609,6 +657,31 @@ export class BeanModelCache<TRecord extends {} = {}> extends BeanModelCrud<TReco
609
657
  // table
610
658
  table = table || this.getTable(data);
611
659
  if (!table) return this.scopeOrm.error.ShouldSpecifyTable.throw();
660
+ if (this.__hasWritableRelations(options)) {
661
+ const where = Object.assign({}, options?.where);
662
+ const dataNew = Object.assign({}, data);
663
+ let hasId = false;
664
+ for (const key of ['id', `${table}.id`]) {
665
+ if (!isNil(dataNew[key])) {
666
+ cast(where).id = dataNew[key];
667
+ delete dataNew[key];
668
+ hasId = true;
669
+ }
670
+ }
671
+ if (!hasId && !options?.where) {
672
+ throw new Error('id should be specified for update method');
673
+ }
674
+ const ids = await this.__selectMutationIds(table, where, options);
675
+ if (ids.length === 0) return [];
676
+ await super._update(table, dataNew, {
677
+ ...options,
678
+ where: Object.assign({}, where, { id: ids }),
679
+ });
680
+ if (!this._checkDisableCacheEntityByOptions(options)) {
681
+ await this.cacheEntityDel(ids, table);
682
+ }
683
+ return ids;
684
+ }
612
685
  // check if cache
613
686
  if (this._checkDisableCacheEntityByOptions(options)) {
614
687
  await super._update(table, data, options);
@@ -677,8 +750,10 @@ export class BeanModelCache<TRecord extends {} = {}> extends BeanModelCrud<TReco
677
750
  options?: T,
678
751
  ): Promise<void> {
679
752
  if (ids.length === 0) return;
680
- await this.__delete_raw(table, { id: ids } as any, options);
681
- await this.relations.handleRelationsDelete(ids, options as any, options);
753
+ const idsDeleted = await this.__delete_raw(table, { id: ids } as any, options);
754
+ if (idsDeleted) {
755
+ await this.relations.handleRelationsDelete(idsDeleted, options as any, options);
756
+ }
682
757
  }
683
758
 
684
759
  async __delete_raw(
@@ -689,6 +764,15 @@ export class BeanModelCache<TRecord extends {} = {}> extends BeanModelCrud<TReco
689
764
  // table
690
765
  table = table || this.getTable(where);
691
766
  if (!table) return this.scopeOrm.error.ShouldSpecifyTable.throw();
767
+ if (this.__hasWritableRelations(options)) {
768
+ const ids = await this.__selectMutationIds(table, where, options);
769
+ if (ids.length === 0) return [];
770
+ await super._delete(table, Object.assign({}, where, { id: ids }), options);
771
+ if (!this._checkDisableCacheEntityByOptions(options)) {
772
+ await this.cacheEntityDel(ids, table);
773
+ }
774
+ return ids;
775
+ }
692
776
  // check if cache
693
777
  if (this._checkDisableCacheEntityByOptions(options)) {
694
778
  return await super._delete(table, where, options);