oak-db 1.0.0
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/.mocharc.json +5 -0
- package/README.md +37 -0
- package/lib/MySQL/connector.d.ts +15 -0
- package/lib/MySQL/connector.js +162 -0
- package/lib/MySQL/store.d.ts +24 -0
- package/lib/MySQL/store.js +412 -0
- package/lib/MySQL/translator.d.ts +104 -0
- package/lib/MySQL/translator.js +738 -0
- package/lib/MySQL/types/Configuration.d.ts +11 -0
- package/lib/MySQL/types/Configuration.js +2 -0
- package/lib/index.d.ts +2 -0
- package/lib/index.js +17 -0
- package/lib/sqlTranslator.d.ts +51 -0
- package/lib/sqlTranslator.js +742 -0
- package/lib/types/Translator.d.ts +0 -0
- package/lib/types/Translator.js +1 -0
- package/package.json +34 -0
- package/script/makeTestDomain.ts +8 -0
- package/src/MySQL/connector.ts +137 -0
- package/src/MySQL/store.ts +276 -0
- package/src/MySQL/translator.ts +798 -0
- package/src/MySQL/types/Configuration.ts +12 -0
- package/src/index.ts +2 -0
- package/src/sqlTranslator.ts +920 -0
- package/src/types/Translator.ts +0 -0
- package/test/entities/House.ts +24 -0
- package/test/testMySQLStore.ts +771 -0
- package/test/testSqlTranslator.ts +58 -0
- package/tsconfig.json +31 -0
|
File without changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"use strict";
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "oak-db",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "oak-db",
|
|
5
|
+
"main": "src/index.ts",
|
|
6
|
+
"author": {
|
|
7
|
+
"name": "XuChang"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"test": "mocha",
|
|
11
|
+
"make:test:domain": "ts-node script/makeTestDomain.ts",
|
|
12
|
+
"build": "tsc"
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"lodash": "^4.17.21",
|
|
16
|
+
"luxon": "^2.4.0",
|
|
17
|
+
"mysql": "^2.18.1",
|
|
18
|
+
"mysql2": "^2.3.3",
|
|
19
|
+
"oak-domain": "^1.0.0",
|
|
20
|
+
"uuid": "^8.3.2"
|
|
21
|
+
},
|
|
22
|
+
"license": "ISC",
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"@types/lodash": "^4.14.182",
|
|
25
|
+
"@types/luxon": "^2.3.2",
|
|
26
|
+
"@types/mocha": "^9.1.1",
|
|
27
|
+
"@types/node": "^17.0.42",
|
|
28
|
+
"@types/uuid": "^8.3.4",
|
|
29
|
+
"mocha": "^10.0.0",
|
|
30
|
+
"oak-general-business": "^1.0.0",
|
|
31
|
+
"ts-node": "^10.8.1",
|
|
32
|
+
"typescript": "^4.7.3"
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import {
|
|
2
|
+
buildSchema,
|
|
3
|
+
analyzeEntities,
|
|
4
|
+
} from 'oak-domain/src/compiler/schemalBuilder';
|
|
5
|
+
|
|
6
|
+
analyzeEntities(`${process.cwd()}/node_modules/oak-general-business/src/entities`);
|
|
7
|
+
analyzeEntities(`${process.cwd()}/test/entities`);
|
|
8
|
+
buildSchema(`${process.cwd()}/test/test-app-domain`);
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import mysql from 'mysql2';
|
|
2
|
+
import { v4 } from 'uuid';
|
|
3
|
+
import { TxnOption } from 'oak-domain/lib/types';
|
|
4
|
+
import { MySQLConfiguration } from './types/Configuration';
|
|
5
|
+
import assert from 'assert';
|
|
6
|
+
|
|
7
|
+
export class MySqlConnector {
|
|
8
|
+
pool?: mysql.Pool;
|
|
9
|
+
configuration: MySQLConfiguration;
|
|
10
|
+
txnDict: Record<string, mysql.PoolConnection>;
|
|
11
|
+
|
|
12
|
+
constructor(configuration: MySQLConfiguration) {
|
|
13
|
+
this.configuration = configuration;
|
|
14
|
+
this.txnDict = {};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
connect() {
|
|
18
|
+
this.pool = mysql.createPool(this.configuration);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
disconnect() {
|
|
22
|
+
this.pool!.end();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
startTransaction(option?: TxnOption): Promise<string> {
|
|
26
|
+
return new Promise(
|
|
27
|
+
(resolve, reject) => {
|
|
28
|
+
this.pool!.getConnection((err, connection) => {
|
|
29
|
+
if (err) {
|
|
30
|
+
return reject(err);
|
|
31
|
+
}
|
|
32
|
+
const { isolationLevel } = option || {};
|
|
33
|
+
const startTxn = () => {
|
|
34
|
+
let sql = 'START TRANSACTION;';
|
|
35
|
+
connection.query(sql, (err2: Error) => {
|
|
36
|
+
if (err2) {
|
|
37
|
+
connection.release();
|
|
38
|
+
return reject(err2);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const id = v4();
|
|
42
|
+
Object.assign(this.txnDict, {
|
|
43
|
+
[id]: connection,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
resolve(id);
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
if (isolationLevel) {
|
|
50
|
+
connection.query(`SET TRANSACTION ISOLATION LEVEL ${isolationLevel};`, (err2: Error) => {
|
|
51
|
+
if (err2) {
|
|
52
|
+
connection.release();
|
|
53
|
+
return reject(err2);
|
|
54
|
+
}
|
|
55
|
+
startTxn();
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
startTxn();
|
|
60
|
+
}
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async exec(sql: string, txn?: string): Promise<any> {
|
|
67
|
+
if (process.env.NODE_ENV === 'development') {
|
|
68
|
+
console.log(sql);
|
|
69
|
+
}
|
|
70
|
+
if (txn) {
|
|
71
|
+
const connection = this.txnDict[txn];
|
|
72
|
+
assert(connection);
|
|
73
|
+
|
|
74
|
+
return new Promise(
|
|
75
|
+
(resolve, reject) => {
|
|
76
|
+
connection.query(sql, (err, result) => {
|
|
77
|
+
if (err) {
|
|
78
|
+
console.error(`sql exec err: ${sql}`, err);
|
|
79
|
+
return reject(err);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
resolve(result);
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
return new Promise(
|
|
89
|
+
(resolve, reject) => {
|
|
90
|
+
// if (process.env.DEBUG) {
|
|
91
|
+
// console.log(sql);
|
|
92
|
+
//}
|
|
93
|
+
this.pool!.query(sql, (err, result) => {
|
|
94
|
+
if (err) {
|
|
95
|
+
console.error(`sql exec err: ${sql}`, err);
|
|
96
|
+
return reject(err);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
resolve(result);
|
|
100
|
+
})
|
|
101
|
+
}
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
commitTransaction(txn: string): Promise<void> {
|
|
107
|
+
const connection = this.txnDict[txn];
|
|
108
|
+
assert(connection);
|
|
109
|
+
return new Promise(
|
|
110
|
+
(resolve, reject) => {
|
|
111
|
+
connection.query('COMMIT;', (err) => {
|
|
112
|
+
if (err) {
|
|
113
|
+
return reject(err);
|
|
114
|
+
}
|
|
115
|
+
connection.release();
|
|
116
|
+
resolve();
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
rollbackTransaction(txn: string): Promise<void> {
|
|
123
|
+
const connection = this.txnDict[txn];
|
|
124
|
+
assert(connection);
|
|
125
|
+
return new Promise(
|
|
126
|
+
(resolve, reject) => {
|
|
127
|
+
connection.query('ROLLBACK;', (err: Error) => {
|
|
128
|
+
if (err) {
|
|
129
|
+
return reject(err);
|
|
130
|
+
}
|
|
131
|
+
connection.release();
|
|
132
|
+
resolve();
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { EntityDict, Context, DeduceCreateSingleOperation, DeduceRemoveOperation, DeduceUpdateOperation, OperateOption, OperationResult, SelectionResult, TxnOption, SelectRowShape, StorageSchema, DeduceCreateMultipleOperation, SelectOption } from 'oak-domain/lib/types';
|
|
2
|
+
import { CascadeStore } from 'oak-domain/lib/store/CascadeStore';
|
|
3
|
+
import { MySQLConfiguration } from './types/Configuration';
|
|
4
|
+
import { MySqlConnector } from './connector';
|
|
5
|
+
import { MySqlTranslator, MySqlSelectOption, MysqlOperateOption } from './translator';
|
|
6
|
+
import { assign } from 'lodash';
|
|
7
|
+
import assert from 'assert';
|
|
8
|
+
import { judgeRelation } from 'oak-domain/lib/store/relation';
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
function convertGeoTextToObject(geoText: string): object {
|
|
12
|
+
if (geoText.startsWith('POINT')) {
|
|
13
|
+
const coord = geoText.match((/(\d|\.)+(?=\)|\s)/g)) as string[];
|
|
14
|
+
|
|
15
|
+
return {
|
|
16
|
+
type: 'Point',
|
|
17
|
+
coordinates: coord.map(
|
|
18
|
+
ele => parseFloat(ele)
|
|
19
|
+
),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
else {
|
|
23
|
+
throw new Error('only support Point now');
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class MysqlStore<ED extends EntityDict, Cxt extends Context<ED>> extends CascadeStore<ED, Cxt> {
|
|
28
|
+
connector: MySqlConnector;
|
|
29
|
+
translator: MySqlTranslator<ED>;
|
|
30
|
+
constructor(storageSchema: StorageSchema<ED>, configuration: MySQLConfiguration) {
|
|
31
|
+
super(storageSchema);
|
|
32
|
+
this.connector = new MySqlConnector(configuration);
|
|
33
|
+
this.translator = new MySqlTranslator(storageSchema);
|
|
34
|
+
}
|
|
35
|
+
protected supportManyToOneJoin(): boolean {
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
protected supportMultipleCreate(): boolean {
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
private formResult<T extends keyof ED>(entity: T, result: any): any {
|
|
42
|
+
const schema = this.getSchema();
|
|
43
|
+
function resolveAttribute<E extends keyof ED>(entity2: E, r: Record<string, any>, attr: string, value: any) {
|
|
44
|
+
const { attributes, view } = schema[entity2];
|
|
45
|
+
if (!view) {
|
|
46
|
+
const i = attr.indexOf(".");
|
|
47
|
+
if (i !== -1) {
|
|
48
|
+
const attrHead = attr.slice(0, i);
|
|
49
|
+
const attrTail = attr.slice(i + 1);
|
|
50
|
+
if (!r[attrHead]) {
|
|
51
|
+
r[attrHead] = {};
|
|
52
|
+
}
|
|
53
|
+
const rel = judgeRelation(schema, entity2, attrHead);
|
|
54
|
+
assert(rel === 2 || typeof rel === 'string');
|
|
55
|
+
resolveAttribute(typeof rel === 'string' ? rel : attrHead, r[attrHead], attrTail, value);
|
|
56
|
+
}
|
|
57
|
+
else if (attributes[attr]) {
|
|
58
|
+
const { type } = attributes[attr];
|
|
59
|
+
switch (type) {
|
|
60
|
+
case 'date':
|
|
61
|
+
case 'time': {
|
|
62
|
+
if (value instanceof Date) {
|
|
63
|
+
r[attr] = value.valueOf();
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
r[attr] = value;
|
|
67
|
+
}
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
case 'geometry': {
|
|
71
|
+
if (typeof value === 'string') {
|
|
72
|
+
r[attr] = convertGeoTextToObject(value);
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
r[attr] = value;
|
|
76
|
+
}
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
case 'object':
|
|
80
|
+
case 'array': {
|
|
81
|
+
if (typeof value === 'string') {
|
|
82
|
+
r[attr] = JSON.parse(value.replace(/[\r]/g, '\\r').replace(/[\n]/g, '\\n'));
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
r[attr] = value;
|
|
86
|
+
}
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
case 'function': {
|
|
90
|
+
if (typeof value === 'string') {
|
|
91
|
+
// 函数的执行环境需要的参数只有创建函数者知悉,只能由上层再创建Function
|
|
92
|
+
r[attr] = `return ${Buffer.from(value, 'base64').toString()}`;
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
r[attr] = value;
|
|
96
|
+
}
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
case 'bool':
|
|
100
|
+
case 'boolean': {
|
|
101
|
+
if (value === 0) {
|
|
102
|
+
r[attr] = false;
|
|
103
|
+
}
|
|
104
|
+
else if (value === 1) {
|
|
105
|
+
r[attr] = true;
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
r[attr] = value;
|
|
109
|
+
}
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
default: {
|
|
113
|
+
r[attr] = value;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
r[attr] = value;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
assign(r, {
|
|
123
|
+
[attr]: value,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
function formalizeNullObject<E extends keyof ED>(r: Record<string, any>, e: E) {
|
|
130
|
+
const { attributes: a2 } = schema[e];
|
|
131
|
+
let allowFormalize = true;
|
|
132
|
+
for (let attr in r) {
|
|
133
|
+
if (typeof r[attr] === 'object' && a2[attr] && a2[attr].type === 'ref') {
|
|
134
|
+
if (formalizeNullObject(r[attr], a2[attr].ref!)) {
|
|
135
|
+
r[attr] = null;
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
allowFormalize = false;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
else if (r[attr] !== null) {
|
|
142
|
+
allowFormalize = false;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return allowFormalize;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function formSingleRow(r: any): any {
|
|
150
|
+
let result2 = {};
|
|
151
|
+
for (let attr in r) {
|
|
152
|
+
const value = r[attr];
|
|
153
|
+
resolveAttribute(entity, result2, attr, value);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
formalizeNullObject(result2, entity);
|
|
157
|
+
return result2 as any;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (result instanceof Array) {
|
|
161
|
+
return result.map(
|
|
162
|
+
r => formSingleRow(r)
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
return formSingleRow(result);
|
|
166
|
+
}
|
|
167
|
+
protected async selectAbjointRow<T extends keyof ED, S extends ED[T]['Selection']>(
|
|
168
|
+
entity: T,
|
|
169
|
+
selection: S,
|
|
170
|
+
context: Cxt,
|
|
171
|
+
option?: MySqlSelectOption
|
|
172
|
+
): Promise<SelectRowShape<ED[T]['Schema'], S['data']>[]> {
|
|
173
|
+
const sql = this.translator.translateSelect(entity, selection, option);
|
|
174
|
+
const result = await this.connector.exec(sql, context.getCurrentTxnId());
|
|
175
|
+
|
|
176
|
+
return this.formResult(entity, result);
|
|
177
|
+
}
|
|
178
|
+
protected async updateAbjointRow<T extends keyof ED>(
|
|
179
|
+
entity: T,
|
|
180
|
+
operation: DeduceCreateMultipleOperation<ED[T]['Schema']> | DeduceCreateSingleOperation<ED[T]['Schema']> | DeduceUpdateOperation<ED[T]['Schema']> | DeduceRemoveOperation<ED[T]['Schema']>,
|
|
181
|
+
context: Cxt,
|
|
182
|
+
option?: MysqlOperateOption
|
|
183
|
+
): Promise<number> {
|
|
184
|
+
const { translator, connector } = this;
|
|
185
|
+
const { action } = operation;
|
|
186
|
+
const txn = context.getCurrentTxnId();
|
|
187
|
+
|
|
188
|
+
switch (action) {
|
|
189
|
+
case 'create': {
|
|
190
|
+
const { data } = operation as DeduceCreateMultipleOperation<ED[T]['Schema']> | DeduceCreateSingleOperation<ED[T]['Schema']>;
|
|
191
|
+
const sql = translator.translateInsert(entity, data instanceof Array ? data : [data]);
|
|
192
|
+
await connector.exec(sql, txn);
|
|
193
|
+
if (!option?.dontCollect) {
|
|
194
|
+
context.opRecords.push({
|
|
195
|
+
a: 'c',
|
|
196
|
+
d: data as any,
|
|
197
|
+
e: entity,
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
return data instanceof Array ? data.length : 1;
|
|
201
|
+
}
|
|
202
|
+
case 'remove': {
|
|
203
|
+
const sql = translator.translateRemove(entity, operation as ED[T]['Remove'], option);
|
|
204
|
+
await connector.exec(sql, txn);
|
|
205
|
+
|
|
206
|
+
// todo 这里对sorter和indexfrom/count的支持不完整
|
|
207
|
+
if (!option?.dontCollect) {
|
|
208
|
+
context.opRecords.push({
|
|
209
|
+
a: 'r',
|
|
210
|
+
e: entity,
|
|
211
|
+
f: (operation as ED[T]['Remove']).filter,
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
return 1;
|
|
215
|
+
}
|
|
216
|
+
default: {
|
|
217
|
+
assert(!['select', 'download', 'stat'].includes(action));
|
|
218
|
+
const sql = translator.translateUpdate(entity, operation as ED[T]['Update'], option);
|
|
219
|
+
await connector.exec(sql, txn);
|
|
220
|
+
|
|
221
|
+
// todo 这里对sorter和indexfrom/count的支持不完整
|
|
222
|
+
if (!option?.dontCollect) {
|
|
223
|
+
context.opRecords.push({
|
|
224
|
+
a: 'u',
|
|
225
|
+
e: entity,
|
|
226
|
+
d: (operation as ED[T]['Update']).data,
|
|
227
|
+
f: (operation as ED[T]['Update']).filter,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
return 1;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
async operate<T extends keyof ED>(entity: T, operation: ED[T]['Operation'], context: Cxt, params?: OperateOption): Promise<OperationResult<ED>> {
|
|
235
|
+
const { action } = operation;
|
|
236
|
+
assert(!['select', 'download', 'stat'].includes(action), '现在不支持使用select operation');
|
|
237
|
+
return await this.cascadeUpdate(entity, operation as any, context, params);
|
|
238
|
+
}
|
|
239
|
+
async select<T extends keyof ED, S extends ED[T]['Selection']>(entity: T, selection: S, context: Cxt, option?: SelectOption): Promise<SelectionResult<ED[T]['Schema'], S['data']>> {
|
|
240
|
+
const result = await this.cascadeSelect(entity, selection, context, option);
|
|
241
|
+
return {
|
|
242
|
+
result,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
async count<T extends keyof ED>(entity: T, selection: Pick<ED[T]['Selection'], 'filter' | 'count'>, context: Cxt, option?: SelectOption): Promise<number> {
|
|
246
|
+
const sql = this.translator.translateCount(entity, selection, option);
|
|
247
|
+
|
|
248
|
+
const result = await this.connector.exec(sql, context.getCurrentTxnId());
|
|
249
|
+
return result.count as number;
|
|
250
|
+
}
|
|
251
|
+
async begin(option?: TxnOption): Promise<string> {
|
|
252
|
+
const txn = await this.connector.startTransaction(option);
|
|
253
|
+
return txn;
|
|
254
|
+
}
|
|
255
|
+
async commit(txnId: string): Promise<void> {
|
|
256
|
+
await this.connector.commitTransaction(txnId);
|
|
257
|
+
}
|
|
258
|
+
async rollback(txnId: string): Promise<void> {
|
|
259
|
+
await this.connector.rollbackTransaction(txnId);
|
|
260
|
+
}
|
|
261
|
+
connect() {
|
|
262
|
+
this.connector.connect();
|
|
263
|
+
}
|
|
264
|
+
disconnect() {
|
|
265
|
+
this.connector.disconnect();
|
|
266
|
+
}
|
|
267
|
+
async initialize(dropIfExists?: boolean) {
|
|
268
|
+
const schema = this.getSchema();
|
|
269
|
+
for (const entity in schema) {
|
|
270
|
+
const sqls = this.translator.translateCreateEntity(entity, { replace: dropIfExists });
|
|
271
|
+
for (const sql of sqls) {
|
|
272
|
+
await this.connector.exec(sql);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|