metal-orm 1.0.58 → 1.0.59
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/README.md +34 -31
- package/dist/index.cjs +1463 -1003
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +148 -129
- package/dist/index.d.ts +148 -129
- package/dist/index.js +1459 -1003
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/core/ddl/schema-generator.ts +44 -1
- package/src/decorators/bootstrap.ts +183 -146
- package/src/decorators/column-decorator.ts +8 -49
- package/src/decorators/decorator-metadata.ts +10 -46
- package/src/decorators/entity.ts +30 -40
- package/src/decorators/relations.ts +30 -56
- package/src/orm/entity-hydration.ts +72 -0
- package/src/orm/entity-meta.ts +13 -11
- package/src/orm/entity-metadata.ts +240 -238
- package/src/orm/entity-relation-cache.ts +39 -0
- package/src/orm/entity-relations.ts +207 -0
- package/src/orm/entity.ts +124 -410
- package/src/orm/execute.ts +4 -4
- package/src/orm/lazy-batch/belongs-to-many.ts +134 -0
- package/src/orm/lazy-batch/belongs-to.ts +108 -0
- package/src/orm/lazy-batch/has-many.ts +69 -0
- package/src/orm/lazy-batch/has-one.ts +68 -0
- package/src/orm/lazy-batch/shared.ts +125 -0
- package/src/orm/lazy-batch.ts +4 -492
- package/src/orm/relations/many-to-many.ts +2 -1
- package/src/query-builder/relation-cte-builder.ts +63 -0
- package/src/query-builder/relation-filter-utils.ts +159 -0
- package/src/query-builder/relation-include-strategies.ts +177 -0
- package/src/query-builder/relation-join-planner.ts +80 -0
- package/src/query-builder/relation-service.ts +119 -479
- package/src/query-builder/relation-types.ts +41 -10
- package/src/query-builder/select/projection-facet.ts +23 -23
- package/src/query-builder/select/select-operations.ts +145 -0
- package/src/query-builder/select.ts +351 -422
- package/src/schema/relation.ts +22 -18
- package/src/schema/table.ts +22 -9
- package/src/schema/types.ts +14 -12
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { ExpressionNode, OperandNode, isOperandNode } from '../core/ast/expression.js';
|
|
2
|
+
import { OrderingTerm } from '../core/ast/query.js';
|
|
3
|
+
|
|
4
|
+
type FilterTableCollector = {
|
|
5
|
+
tables: Set<string>;
|
|
6
|
+
hasSubquery: boolean;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export type SplitFilterExpressionsResult = {
|
|
10
|
+
selfFilters: ExpressionNode[];
|
|
11
|
+
crossFilters: ExpressionNode[];
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export const splitFilterExpressions = (
|
|
15
|
+
filter: ExpressionNode | undefined,
|
|
16
|
+
allowedTables: Set<string>
|
|
17
|
+
): SplitFilterExpressionsResult => {
|
|
18
|
+
const terms = flattenAnd(filter);
|
|
19
|
+
const selfFilters: ExpressionNode[] = [];
|
|
20
|
+
const crossFilters: ExpressionNode[] = [];
|
|
21
|
+
|
|
22
|
+
for (const term of terms) {
|
|
23
|
+
if (isExpressionSelfContained(term, allowedTables)) {
|
|
24
|
+
selfFilters.push(term);
|
|
25
|
+
} else {
|
|
26
|
+
crossFilters.push(term);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return { selfFilters, crossFilters };
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const flattenAnd = (node?: ExpressionNode): ExpressionNode[] => {
|
|
34
|
+
if (!node) return [];
|
|
35
|
+
if (node.type === 'LogicalExpression' && node.operator === 'AND') {
|
|
36
|
+
return node.operands.flatMap(operand => flattenAnd(operand));
|
|
37
|
+
}
|
|
38
|
+
return [node];
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const isExpressionSelfContained = (expr: ExpressionNode, allowedTables: Set<string>): boolean => {
|
|
42
|
+
const collector = collectReferencedTables(expr);
|
|
43
|
+
if (collector.hasSubquery) return false;
|
|
44
|
+
if (collector.tables.size === 0) return true;
|
|
45
|
+
for (const table of collector.tables) {
|
|
46
|
+
if (!allowedTables.has(table)) {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return true;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const collectReferencedTables = (expr: ExpressionNode): FilterTableCollector => {
|
|
54
|
+
const collector: FilterTableCollector = {
|
|
55
|
+
tables: new Set(),
|
|
56
|
+
hasSubquery: false
|
|
57
|
+
};
|
|
58
|
+
collectFromExpression(expr, collector);
|
|
59
|
+
return collector;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const collectFromExpression = (expr: ExpressionNode, collector: FilterTableCollector): void => {
|
|
63
|
+
switch (expr.type) {
|
|
64
|
+
case 'BinaryExpression':
|
|
65
|
+
collectFromOperand(expr.left, collector);
|
|
66
|
+
collectFromOperand(expr.right, collector);
|
|
67
|
+
break;
|
|
68
|
+
case 'LogicalExpression':
|
|
69
|
+
expr.operands.forEach(operand => collectFromExpression(operand, collector));
|
|
70
|
+
break;
|
|
71
|
+
case 'NullExpression':
|
|
72
|
+
collectFromOperand(expr.left, collector);
|
|
73
|
+
break;
|
|
74
|
+
case 'InExpression':
|
|
75
|
+
collectFromOperand(expr.left, collector);
|
|
76
|
+
if (Array.isArray(expr.right)) {
|
|
77
|
+
expr.right.forEach(value => collectFromOperand(value, collector));
|
|
78
|
+
} else {
|
|
79
|
+
collector.hasSubquery = true;
|
|
80
|
+
}
|
|
81
|
+
break;
|
|
82
|
+
case 'ExistsExpression':
|
|
83
|
+
collector.hasSubquery = true;
|
|
84
|
+
break;
|
|
85
|
+
case 'BetweenExpression':
|
|
86
|
+
collectFromOperand(expr.left, collector);
|
|
87
|
+
collectFromOperand(expr.lower, collector);
|
|
88
|
+
collectFromOperand(expr.upper, collector);
|
|
89
|
+
break;
|
|
90
|
+
case 'ArithmeticExpression':
|
|
91
|
+
case 'BitwiseExpression':
|
|
92
|
+
collectFromOperand(expr.left, collector);
|
|
93
|
+
collectFromOperand(expr.right, collector);
|
|
94
|
+
break;
|
|
95
|
+
default:
|
|
96
|
+
break;
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const collectFromOperand = (node: OperandNode, collector: FilterTableCollector): void => {
|
|
101
|
+
switch (node.type) {
|
|
102
|
+
case 'Column':
|
|
103
|
+
collector.tables.add(node.table);
|
|
104
|
+
break;
|
|
105
|
+
case 'Function':
|
|
106
|
+
node.args.forEach(arg => collectFromOperand(arg, collector));
|
|
107
|
+
if (node.separator) {
|
|
108
|
+
collectFromOperand(node.separator, collector);
|
|
109
|
+
}
|
|
110
|
+
if (node.orderBy) {
|
|
111
|
+
node.orderBy.forEach(order => collectFromOrderingTerm(order.term, collector));
|
|
112
|
+
}
|
|
113
|
+
break;
|
|
114
|
+
case 'JsonPath':
|
|
115
|
+
collectFromOperand(node.column, collector);
|
|
116
|
+
break;
|
|
117
|
+
case 'ScalarSubquery':
|
|
118
|
+
collector.hasSubquery = true;
|
|
119
|
+
break;
|
|
120
|
+
case 'CaseExpression':
|
|
121
|
+
node.conditions.forEach(({ when, then }) => {
|
|
122
|
+
collectFromExpression(when, collector);
|
|
123
|
+
collectFromOperand(then, collector);
|
|
124
|
+
});
|
|
125
|
+
if (node.else) {
|
|
126
|
+
collectFromOperand(node.else, collector);
|
|
127
|
+
}
|
|
128
|
+
break;
|
|
129
|
+
case 'Cast':
|
|
130
|
+
collectFromOperand(node.expression, collector);
|
|
131
|
+
break;
|
|
132
|
+
case 'WindowFunction':
|
|
133
|
+
node.args.forEach(arg => collectFromOperand(arg, collector));
|
|
134
|
+
node.partitionBy?.forEach(part => collectFromOperand(part, collector));
|
|
135
|
+
node.orderBy?.forEach(order => collectFromOrderingTerm(order.term, collector));
|
|
136
|
+
break;
|
|
137
|
+
case 'Collate':
|
|
138
|
+
collectFromOperand(node.expression, collector);
|
|
139
|
+
break;
|
|
140
|
+
case 'ArithmeticExpression':
|
|
141
|
+
case 'BitwiseExpression':
|
|
142
|
+
collectFromOperand(node.left, collector);
|
|
143
|
+
collectFromOperand(node.right, collector);
|
|
144
|
+
break;
|
|
145
|
+
case 'Literal':
|
|
146
|
+
case 'AliasRef':
|
|
147
|
+
break;
|
|
148
|
+
default:
|
|
149
|
+
break;
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const collectFromOrderingTerm = (term: OrderingTerm, collector: FilterTableCollector): void => {
|
|
154
|
+
if (isOperandNode(term)) {
|
|
155
|
+
collectFromOperand(term, collector);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
collectFromExpression(term, collector);
|
|
159
|
+
};
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { TableDef } from '../schema/table.js';
|
|
2
|
+
import { ColumnDef } from '../schema/column-types.js';
|
|
3
|
+
import {
|
|
4
|
+
RelationDef,
|
|
5
|
+
RelationKinds,
|
|
6
|
+
BelongsToManyRelation,
|
|
7
|
+
HasManyRelation,
|
|
8
|
+
HasOneRelation,
|
|
9
|
+
BelongsToRelation
|
|
10
|
+
} from '../schema/relation.js';
|
|
11
|
+
import { ColumnNode } from '../core/ast/expression.js';
|
|
12
|
+
import { SelectQueryState } from './select-query-state.js';
|
|
13
|
+
import { HydrationManager } from './hydration-manager.js';
|
|
14
|
+
import type { RelationResult } from './relation-projection-helper.js';
|
|
15
|
+
import { RelationIncludeOptions } from './relation-types.js';
|
|
16
|
+
import { makeRelationAlias } from './relation-alias.js';
|
|
17
|
+
import { buildDefaultPivotColumns } from './relation-utils.js';
|
|
18
|
+
import { findPrimaryKey } from './hydration-planner.js';
|
|
19
|
+
|
|
20
|
+
type RelationWithForeignKey =
|
|
21
|
+
| HasManyRelation
|
|
22
|
+
| HasOneRelation
|
|
23
|
+
| BelongsToRelation;
|
|
24
|
+
|
|
25
|
+
type IncludeStrategyContext = {
|
|
26
|
+
rootTable: TableDef;
|
|
27
|
+
state: SelectQueryState;
|
|
28
|
+
hydration: HydrationManager;
|
|
29
|
+
relation: RelationDef;
|
|
30
|
+
relationName: string;
|
|
31
|
+
aliasPrefix: string;
|
|
32
|
+
options?: RelationIncludeOptions;
|
|
33
|
+
selectColumns: (
|
|
34
|
+
state: SelectQueryState,
|
|
35
|
+
hydration: HydrationManager,
|
|
36
|
+
columns: Record<string, ColumnDef>
|
|
37
|
+
) => RelationResult;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
type IncludeStrategy = (context: IncludeStrategyContext) => RelationResult;
|
|
41
|
+
|
|
42
|
+
const buildTypedSelection = (
|
|
43
|
+
columns: Record<string, ColumnDef>,
|
|
44
|
+
prefix: string,
|
|
45
|
+
keys: string[],
|
|
46
|
+
missingMsg: (col: string) => string
|
|
47
|
+
): Record<string, ColumnDef> => {
|
|
48
|
+
return keys.reduce((acc, key) => {
|
|
49
|
+
const def = columns[key];
|
|
50
|
+
if (!def) {
|
|
51
|
+
throw new Error(missingMsg(key));
|
|
52
|
+
}
|
|
53
|
+
acc[makeRelationAlias(prefix, key)] = def;
|
|
54
|
+
return acc;
|
|
55
|
+
}, {} as Record<string, ColumnDef>);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const resolveTargetColumns = (relation: RelationDef, options?: RelationIncludeOptions): string[] => {
|
|
59
|
+
const requestedColumns = options?.columns?.length
|
|
60
|
+
? [...options.columns]
|
|
61
|
+
: Object.keys(relation.target.columns);
|
|
62
|
+
const targetPrimaryKey = findPrimaryKey(relation.target);
|
|
63
|
+
if (!requestedColumns.includes(targetPrimaryKey)) {
|
|
64
|
+
requestedColumns.push(targetPrimaryKey);
|
|
65
|
+
}
|
|
66
|
+
return requestedColumns;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const ensureRootForeignKeySelected = (
|
|
70
|
+
context: IncludeStrategyContext,
|
|
71
|
+
relation: RelationWithForeignKey
|
|
72
|
+
): RelationResult => {
|
|
73
|
+
const fkColumn = context.rootTable.columns[relation.foreignKey];
|
|
74
|
+
if (!fkColumn) {
|
|
75
|
+
return { state: context.state, hydration: context.hydration };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const hasForeignKeySelected = context.state.ast.columns.some(col => {
|
|
79
|
+
if ((col as ColumnNode).type !== 'Column') return false;
|
|
80
|
+
const node = col as ColumnNode;
|
|
81
|
+
const alias = node.alias ?? node.name;
|
|
82
|
+
return alias === relation.foreignKey;
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
if (hasForeignKeySelected) {
|
|
86
|
+
return { state: context.state, hydration: context.hydration };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return context.selectColumns(context.state, context.hydration, {
|
|
90
|
+
[relation.foreignKey]: fkColumn
|
|
91
|
+
});
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const standardIncludeStrategy: IncludeStrategy = context => {
|
|
95
|
+
const relation = context.relation as RelationWithForeignKey;
|
|
96
|
+
let { state, hydration } = context;
|
|
97
|
+
|
|
98
|
+
const fkSelectionResult = ensureRootForeignKeySelected(context, relation);
|
|
99
|
+
state = fkSelectionResult.state;
|
|
100
|
+
hydration = fkSelectionResult.hydration;
|
|
101
|
+
|
|
102
|
+
const targetColumns = resolveTargetColumns(relation, context.options);
|
|
103
|
+
const targetSelection = buildTypedSelection(
|
|
104
|
+
relation.target.columns as Record<string, ColumnDef>,
|
|
105
|
+
context.aliasPrefix,
|
|
106
|
+
targetColumns,
|
|
107
|
+
key => `Column '${key}' not found on relation '${context.relationName}'`
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
const relationSelectionResult = context.selectColumns(state, hydration, targetSelection);
|
|
111
|
+
state = relationSelectionResult.state;
|
|
112
|
+
hydration = relationSelectionResult.hydration;
|
|
113
|
+
|
|
114
|
+
hydration = hydration.onRelationIncluded(
|
|
115
|
+
state,
|
|
116
|
+
relation,
|
|
117
|
+
context.relationName,
|
|
118
|
+
context.aliasPrefix,
|
|
119
|
+
targetColumns
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
return { state, hydration };
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const belongsToManyStrategy: IncludeStrategy = context => {
|
|
126
|
+
const relation = context.relation as BelongsToManyRelation;
|
|
127
|
+
let { state, hydration } = context;
|
|
128
|
+
|
|
129
|
+
const targetColumns = resolveTargetColumns(relation, context.options);
|
|
130
|
+
const targetSelection = buildTypedSelection(
|
|
131
|
+
relation.target.columns as Record<string, ColumnDef>,
|
|
132
|
+
context.aliasPrefix,
|
|
133
|
+
targetColumns,
|
|
134
|
+
key => `Column '${key}' not found on relation '${context.relationName}'`
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
const pivotAliasPrefix = context.options?.pivot?.aliasPrefix ?? `${context.aliasPrefix}_pivot`;
|
|
138
|
+
const pivotPk = relation.pivotPrimaryKey || findPrimaryKey(relation.pivotTable);
|
|
139
|
+
const defaultPivotColumns = relation.defaultPivotColumns ?? buildDefaultPivotColumns(relation, pivotPk);
|
|
140
|
+
const pivotColumns = context.options?.pivot?.columns
|
|
141
|
+
? [...context.options.pivot.columns]
|
|
142
|
+
: [...defaultPivotColumns];
|
|
143
|
+
|
|
144
|
+
const pivotSelection = buildTypedSelection(
|
|
145
|
+
relation.pivotTable.columns as Record<string, ColumnDef>,
|
|
146
|
+
pivotAliasPrefix,
|
|
147
|
+
pivotColumns,
|
|
148
|
+
key => `Column '${key}' not found on pivot table '${relation.pivotTable.name}'`
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
const combinedSelection = {
|
|
152
|
+
...targetSelection,
|
|
153
|
+
...pivotSelection
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const relationSelectionResult = context.selectColumns(state, hydration, combinedSelection);
|
|
157
|
+
state = relationSelectionResult.state;
|
|
158
|
+
hydration = relationSelectionResult.hydration;
|
|
159
|
+
|
|
160
|
+
hydration = hydration.onRelationIncluded(
|
|
161
|
+
state,
|
|
162
|
+
relation,
|
|
163
|
+
context.relationName,
|
|
164
|
+
context.aliasPrefix,
|
|
165
|
+
targetColumns,
|
|
166
|
+
{ aliasPrefix: pivotAliasPrefix, columns: pivotColumns }
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
return { state, hydration };
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
export const relationIncludeStrategies: Record<RelationDef['type'], IncludeStrategy> = {
|
|
173
|
+
[RelationKinds.HasMany]: standardIncludeStrategy,
|
|
174
|
+
[RelationKinds.HasOne]: standardIncludeStrategy,
|
|
175
|
+
[RelationKinds.BelongsTo]: standardIncludeStrategy,
|
|
176
|
+
[RelationKinds.BelongsToMany]: belongsToManyStrategy
|
|
177
|
+
};
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { TableDef } from '../schema/table.js';
|
|
2
|
+
import { RelationDef, RelationKinds, BelongsToManyRelation } from '../schema/relation.js';
|
|
3
|
+
import { SelectQueryState } from './select-query-state.js';
|
|
4
|
+
import { QueryAstService } from './query-ast-service.js';
|
|
5
|
+
import { TableSourceNode } from '../core/ast/query.js';
|
|
6
|
+
import { ExpressionNode } from '../core/ast/expression.js';
|
|
7
|
+
import { JoinKind } from '../core/sql/sql.js';
|
|
8
|
+
import { buildRelationJoinCondition, buildBelongsToManyJoins } from './relation-conditions.js';
|
|
9
|
+
import { createJoinNode } from '../core/ast/join-node.js';
|
|
10
|
+
|
|
11
|
+
export class RelationJoinPlanner {
|
|
12
|
+
constructor(
|
|
13
|
+
private readonly table: TableDef,
|
|
14
|
+
private readonly createQueryAstService: (table: TableDef, state: SelectQueryState) => QueryAstService
|
|
15
|
+
) {}
|
|
16
|
+
|
|
17
|
+
withJoin(
|
|
18
|
+
state: SelectQueryState,
|
|
19
|
+
relationName: string,
|
|
20
|
+
relation: RelationDef,
|
|
21
|
+
joinKind: JoinKind,
|
|
22
|
+
extraCondition?: ExpressionNode,
|
|
23
|
+
tableSource?: TableSourceNode
|
|
24
|
+
): SelectQueryState {
|
|
25
|
+
const rootAlias = state.ast.from.type === 'Table' ? state.ast.from.alias : undefined;
|
|
26
|
+
if (relation.type === RelationKinds.BelongsToMany) {
|
|
27
|
+
const targetTableSource: TableSourceNode = tableSource ?? {
|
|
28
|
+
type: 'Table',
|
|
29
|
+
name: relation.target.name,
|
|
30
|
+
schema: relation.target.schema
|
|
31
|
+
};
|
|
32
|
+
const targetName = this.resolveTargetTableName(targetTableSource, relation);
|
|
33
|
+
const joins = buildBelongsToManyJoins(
|
|
34
|
+
this.table,
|
|
35
|
+
relationName,
|
|
36
|
+
relation as BelongsToManyRelation,
|
|
37
|
+
joinKind,
|
|
38
|
+
extraCondition,
|
|
39
|
+
rootAlias,
|
|
40
|
+
targetTableSource,
|
|
41
|
+
targetName
|
|
42
|
+
);
|
|
43
|
+
return joins.reduce((current, join) => this.astService(current).withJoin(join), state);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const targetTable: TableSourceNode = tableSource ?? {
|
|
47
|
+
type: 'Table',
|
|
48
|
+
name: relation.target.name,
|
|
49
|
+
schema: relation.target.schema
|
|
50
|
+
};
|
|
51
|
+
const targetName = this.resolveTargetTableName(targetTable, relation);
|
|
52
|
+
const condition = buildRelationJoinCondition(
|
|
53
|
+
this.table,
|
|
54
|
+
relation,
|
|
55
|
+
extraCondition,
|
|
56
|
+
rootAlias,
|
|
57
|
+
targetName
|
|
58
|
+
);
|
|
59
|
+
const joinNode = createJoinNode(joinKind, targetTable, condition, relationName);
|
|
60
|
+
|
|
61
|
+
return this.astService(state).withJoin(joinNode);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
private astService(state: SelectQueryState): QueryAstService {
|
|
65
|
+
return this.createQueryAstService(this.table, state);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
private resolveTargetTableName(target: TableSourceNode, relation: RelationDef): string {
|
|
69
|
+
if (target.type === 'Table') {
|
|
70
|
+
return target.alias ?? target.name;
|
|
71
|
+
}
|
|
72
|
+
if (target.type === 'DerivedTable') {
|
|
73
|
+
return target.alias;
|
|
74
|
+
}
|
|
75
|
+
if (target.type === 'FunctionTable') {
|
|
76
|
+
return target.alias ?? relation.target.name;
|
|
77
|
+
}
|
|
78
|
+
return relation.target.name;
|
|
79
|
+
}
|
|
80
|
+
}
|