@twin.org/entity-storage-connector-scylladb 0.0.1-next.2

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.
@@ -0,0 +1,864 @@
1
+ import { Guards, Is, StringHelper, GeneralError, BaseError, NotSupportedError } from '@twin.org/core';
2
+ import { EntitySchemaFactory, EntitySchemaHelper, ComparisonOperator, LogicalOperator, SortDirection, EntitySchemaPropertyType } from '@twin.org/entity';
3
+ import { LoggingConnectorFactory } from '@twin.org/logging-models';
4
+ import { Client, types } from 'cassandra-driver';
5
+
6
+ // Copyright 2024 IOTA Stiftung.
7
+ // SPDX-License-Identifier: Apache-2.0.
8
+ /**
9
+ * Store entities using ScyllaDB.
10
+ */
11
+ class AbstractScyllaDBConnector {
12
+ /**
13
+ * Limit the number of entities when finding.
14
+ * @internal
15
+ */
16
+ static PAGE_SIZE = 40;
17
+ /**
18
+ * Runtime name for the class.
19
+ * @internal
20
+ */
21
+ CLASS_NAME;
22
+ /**
23
+ * The name of the database table.
24
+ * @internal
25
+ */
26
+ _fullTableName;
27
+ /**
28
+ * Configuration to connection to ScyllaDB.
29
+ * @internal
30
+ */
31
+ _config;
32
+ /**
33
+ * The logging connector.
34
+ * @internal
35
+ */
36
+ _logging;
37
+ /**
38
+ * The schema for the entity.
39
+ * @internal
40
+ */
41
+ _entitySchema;
42
+ /**
43
+ * The primary key.
44
+ * @internal
45
+ */
46
+ _primaryKey;
47
+ /**
48
+ * Create a new instance of AbstractScyllaDBConnector.
49
+ * @param options The options for the connector.
50
+ * @param options.loggingConnectorType The type of logging connector to use, defaults to no logging.
51
+ * @param options.entitySchema The name of the entity schema.
52
+ * @param options.config The configuration for the connector.
53
+ * @param className The name of the derived class.
54
+ */
55
+ constructor(options, className) {
56
+ this.CLASS_NAME = className;
57
+ Guards.object(this.CLASS_NAME, "options", options);
58
+ Guards.stringValue(this.CLASS_NAME, "options.entitySchema", options.entitySchema);
59
+ Guards.object(this.CLASS_NAME, "options.config", options.config);
60
+ Guards.arrayValue(this.CLASS_NAME, "options.config.hosts", options.config.hosts);
61
+ Guards.stringValue(this.CLASS_NAME, "options.config.localDataCenter", options.config.localDataCenter);
62
+ Guards.stringValue(this.CLASS_NAME, "options.config.keyspace", options.config.keyspace);
63
+ if (Is.stringValue(options.loggingConnectorType)) {
64
+ this._logging = LoggingConnectorFactory.get(options.loggingConnectorType);
65
+ }
66
+ this._entitySchema = EntitySchemaFactory.get(options.entitySchema);
67
+ this._primaryKey = EntitySchemaHelper.getPrimaryKey(this._entitySchema);
68
+ this._config = options.config;
69
+ this._fullTableName = StringHelper.camelCase(Is.stringValue(options.config.tableName) ? options.config.tableName : options.entitySchema);
70
+ }
71
+ /**
72
+ * Get an entity.
73
+ * @param id The id of the entity to get.
74
+ * @param secondaryIndex Get the item using a secondary index.
75
+ * @returns The object if it can be found or undefined.
76
+ */
77
+ async get(id, secondaryIndex) {
78
+ Guards.stringValue(this.CLASS_NAME, "id", id);
79
+ let connection;
80
+ try {
81
+ const indexField = secondaryIndex ?? this._primaryKey?.property;
82
+ let sql = `SELECT * FROM "${this._fullTableName}" WHERE "${String(indexField)}"=?`;
83
+ if (secondaryIndex) {
84
+ sql += "ALLOW FILTERING";
85
+ }
86
+ await this._logging?.log({
87
+ level: "info",
88
+ source: this.CLASS_NAME,
89
+ ts: Date.now(),
90
+ message: "sql",
91
+ data: { sql }
92
+ });
93
+ connection = await this.openConnection();
94
+ const result = await this.queryDB(connection, sql, [id]);
95
+ if (result.rows.length === 1) {
96
+ return this.convertRowToObject(result.rows[0]);
97
+ }
98
+ }
99
+ catch (error) {
100
+ throw new GeneralError(this.CLASS_NAME, "getFailed", {
101
+ id
102
+ }, error);
103
+ }
104
+ finally {
105
+ await this.closeConnection(connection);
106
+ }
107
+ }
108
+ /**
109
+ * Find all the entities which match the conditions.
110
+ * @param conditions The conditions to match for the entities.
111
+ * @param sortProperties The optional sort order.
112
+ * @param properties The optional properties to return, defaults to all.
113
+ * @param cursor The cursor to request the next page of entities.
114
+ * @param pageSize The suggested number of entities to return in each chunk, in some scenarios can return a different amount.
115
+ * @returns All the entities for the storage matching the conditions,
116
+ * and a cursor which can be used to request more entities.
117
+ */
118
+ async query(conditions, sortProperties, properties, cursor, pageSize) {
119
+ let connection;
120
+ try {
121
+ let returnSize = pageSize ?? AbstractScyllaDBConnector.PAGE_SIZE;
122
+ let sql = `SELECT * FROM "${this._fullTableName}"`;
123
+ if (Is.array(properties)) {
124
+ const fields = [];
125
+ for (const property of properties) {
126
+ fields.push(property.toString());
127
+ }
128
+ const selectFields = fields.join(",");
129
+ sql = sql.replace("*", selectFields);
130
+ }
131
+ const conds = [];
132
+ let conditionQuery = "";
133
+ // The params to be used to execute the query
134
+ const params = [];
135
+ let theConditions = [];
136
+ if (!Is.undefined(conditions)) {
137
+ if ("conditions" in conditions) {
138
+ theConditions = conditions.conditions;
139
+ }
140
+ else {
141
+ theConditions.push(conditions);
142
+ }
143
+ }
144
+ // TODO: This code needs refactoring to support conditions for sub properties.
145
+ for (const cond of theConditions) {
146
+ const condition = cond;
147
+ const descriptor = this._entitySchema.properties?.find(p => p.property === condition.property);
148
+ if (condition.comparison === ComparisonOperator.Includes ||
149
+ condition.comparison === ComparisonOperator.NotIncludes) {
150
+ const propValue = `'%${condition.value}%'`;
151
+ if (condition.comparison === ComparisonOperator.Includes) {
152
+ conds.push(`"${condition.property}" LIKE ${propValue}`);
153
+ }
154
+ else if (condition.comparison === ComparisonOperator.NotIncludes) {
155
+ conds.push(`"${condition.property}" NOT LIKE ${propValue}`);
156
+ }
157
+ }
158
+ else if (condition.comparison === ComparisonOperator.In) {
159
+ let value = [];
160
+ if (!Is.arrayValue(condition.value)) {
161
+ value.push(this.propertyToDbValue(condition.value, descriptor));
162
+ }
163
+ else {
164
+ value = condition.value.map(v => this.propertyToDbValue(v, descriptor));
165
+ }
166
+ params.push(value);
167
+ conds.push(`"${condition.property}" IN ?`);
168
+ }
169
+ else {
170
+ const propValue = condition.value;
171
+ params.push(propValue);
172
+ if (condition.comparison === ComparisonOperator.Equals) {
173
+ conds.push(`"${condition.property}" = ?`);
174
+ }
175
+ else if (condition.comparison === ComparisonOperator.NotEquals) {
176
+ conds.push(`"${condition.property}" <> ?`);
177
+ }
178
+ else if (condition.comparison === ComparisonOperator.GreaterThan) {
179
+ conds.push(`"${condition.property}" > ?`);
180
+ }
181
+ else if (condition.comparison === ComparisonOperator.LessThan) {
182
+ conds.push(`"${condition.property}" < ?`);
183
+ }
184
+ else if (condition.comparison === ComparisonOperator.GreaterThanOrEqual) {
185
+ conds.push(`"${condition.property}" >= ?`);
186
+ }
187
+ else if (condition.comparison === ComparisonOperator.LessThanOrEqual) {
188
+ conds.push(`"${condition.property}" <= ?`);
189
+ }
190
+ }
191
+ const operator = conditions.logicalOperator ?? LogicalOperator.And;
192
+ conditionQuery = `${conds.join(` ${operator} `)}`;
193
+ }
194
+ if (conditionQuery.length > 0) {
195
+ sql += ` WHERE ${conditionQuery}`;
196
+ }
197
+ connection = await this.openConnection();
198
+ // TODO: Only supported one sort property at the moment. This code would need to be revised in a follow-up
199
+ if (Is.array(sortProperties) && sortProperties.length >= 1) {
200
+ const sortKey = sortProperties[0].property ?? this._primaryKey.property;
201
+ const sortDir = sortProperties[0].sortDirection ??
202
+ this._entitySchema.properties?.find(e => e.property === sortKey)?.sortDirection;
203
+ let sqlSortDir = "asc";
204
+ if (sortDir === SortDirection.Descending) {
205
+ sqlSortDir = "desc";
206
+ }
207
+ sql += ` ORDER BY "${String(sortKey)}" ${sqlSortDir.toUpperCase()}`;
208
+ // Disabling paging in order by situations
209
+ returnSize = 0;
210
+ }
211
+ await this._logging?.log({
212
+ level: "info",
213
+ source: this.CLASS_NAME,
214
+ ts: Date.now(),
215
+ message: "sql",
216
+ data: { sql }
217
+ });
218
+ const result = await this.queryDB(connection, sql, params, cursor, returnSize);
219
+ const entities = [];
220
+ for (const row of result.rows) {
221
+ entities.push(this.convertRowToObject(row));
222
+ }
223
+ return {
224
+ entities,
225
+ cursor: Is.stringValue(result.pageState) ? result.pageState : undefined
226
+ };
227
+ }
228
+ catch (error) {
229
+ throw new GeneralError(this.CLASS_NAME, "findFailed", { table: this._fullTableName }, error);
230
+ }
231
+ finally {
232
+ await this.closeConnection(connection);
233
+ }
234
+ }
235
+ /**
236
+ * Open a new database connection.
237
+ * @param config The config for the connection.
238
+ * @param skipKeySpace Don't include the keyspace in the connection.
239
+ * @returns The new connection.
240
+ * @internal
241
+ */
242
+ async openConnection(skipKeySpace = false) {
243
+ const client = new Client({
244
+ contactPoints: this._config.hosts,
245
+ localDataCenter: this._config.localDataCenter,
246
+ keyspace: skipKeySpace ? undefined : this._config.keyspace
247
+ });
248
+ await client.connect();
249
+ return client;
250
+ }
251
+ /**
252
+ * Close database connection.
253
+ * @param connection The connection to close.
254
+ * @internal
255
+ */
256
+ async closeConnection(connection) {
257
+ if (!connection) {
258
+ return;
259
+ }
260
+ return connection.shutdown();
261
+ }
262
+ /**
263
+ * Query the database.
264
+ * @param connection The connection to query.
265
+ * @param sql The sql statement to execute.
266
+ * @param params The params to use when executing the query.
267
+ * @param state The state to use when it comes to pagination.
268
+ * @returns The rows.
269
+ * @internal
270
+ */
271
+ async queryDB(connection, sql, params, pageState, pageSize) {
272
+ return new Promise((resolve, reject) => {
273
+ const rows = [];
274
+ connection.eachRow(sql, params, {
275
+ prepare: true,
276
+ autoPage: false,
277
+ fetchSize: pageSize ?? AbstractScyllaDBConnector.PAGE_SIZE,
278
+ pageState
279
+ }, (n, row) => {
280
+ rows.push(row);
281
+ }, (err, res) => {
282
+ if (err) {
283
+ reject(err);
284
+ return;
285
+ }
286
+ res.rows = rows;
287
+ resolve(res);
288
+ });
289
+ });
290
+ }
291
+ /**
292
+ * Execute on the database.
293
+ * @param connection The connection to execute.
294
+ * @param sql The sql statement to execute.
295
+ * @internal
296
+ */
297
+ async execute(connection, sql, params) {
298
+ return connection.execute(sql, params, { prepare: true });
299
+ }
300
+ /**
301
+ * Create keyspace if it doesn't exist.
302
+ * @param connection The connection to perform the query with.
303
+ * @param keyspaceName The name of the keyspace to create.
304
+ * @internal
305
+ */
306
+ async createKeyspace(connection, keyspaceName) {
307
+ return this.execute(connection, `CREATE KEYSPACE IF NOT EXISTS "${keyspaceName}"
308
+ WITH REPLICATION = { 'class' : 'SimpleStrategy', 'replication_factor' : 1}`);
309
+ }
310
+ /**
311
+ * Format a field from the DB.
312
+ * @param value The value to convert to original form.
313
+ * @param fieldDescriptor The descriptor for the field.
314
+ * @returns The value as a property for the object.
315
+ * @internal
316
+ */
317
+ dbValueToProperty(value, fieldDescriptor) {
318
+ if (fieldDescriptor.type === "object") {
319
+ if (value === "null" ||
320
+ value === "undefined" ||
321
+ value === "" ||
322
+ value === null ||
323
+ value === undefined) {
324
+ return;
325
+ }
326
+ }
327
+ else if (fieldDescriptor.type === "string" && fieldDescriptor.format === "json") {
328
+ try {
329
+ return JSON.parse(value);
330
+ }
331
+ catch {
332
+ throw new GeneralError(this.CLASS_NAME, "parseJSONFailed", {
333
+ name: fieldDescriptor.property,
334
+ value
335
+ });
336
+ }
337
+ }
338
+ else if (fieldDescriptor.format === "uuid") {
339
+ return value.toString();
340
+ }
341
+ return value;
342
+ }
343
+ /**
344
+ * Format a value for the DB. As the driver takes care of conversion from Javascript
345
+ * @param value The value to format.
346
+ * @param fieldDescriptor The descriptor for the field
347
+ * @returns The value after conversion.
348
+ * @internal
349
+ */
350
+ propertyToDbValue(value, fieldDescriptor) {
351
+ if (fieldDescriptor) {
352
+ // eslint-disable-next-line no-constant-condition
353
+ if (fieldDescriptor.type === "string" && fieldDescriptor.format === "json") {
354
+ return Is.empty(value) ? "null" : this.jsonWrap(value);
355
+ }
356
+ else if (fieldDescriptor.format === "uuid") {
357
+ if (!Is.string(value)) {
358
+ return;
359
+ }
360
+ return types.Uuid.fromString(value);
361
+ }
362
+ return value;
363
+ }
364
+ }
365
+ /**
366
+ * Convert a row back to an object.
367
+ * @param row The row to convert.
368
+ * @returns The row as an object.
369
+ * @internal
370
+ */
371
+ convertRowToObject(row) {
372
+ const obj = {};
373
+ for (const field of this._entitySchema.properties ?? []) {
374
+ const value = row[field.property];
375
+ if (value) {
376
+ obj[field.property] = this.dbValueToProperty(value, field);
377
+ }
378
+ }
379
+ return obj;
380
+ }
381
+ /**
382
+ * Wrap a string for DB format.
383
+ * @param value The value to wrap.
384
+ * @returns The wrapped string.
385
+ * @internal
386
+ */
387
+ stringWrap(value) {
388
+ if (value === undefined || value === null) {
389
+ return "''";
390
+ }
391
+ return `'${value.replace(/'/g, "''")}'`;
392
+ }
393
+ /**
394
+ * Wrap an object for json in DB format.
395
+ * @param value The value to wrap.
396
+ * @returns The wrapped string.
397
+ * @internal
398
+ */
399
+ jsonWrap(value) {
400
+ let json = JSON.stringify(value);
401
+ // eslint-disable-next-line no-control-regex
402
+ json = json.replace(/[\b\0\t\n\r\u001A\\]/g, s => {
403
+ switch (s) {
404
+ case "\0":
405
+ return String.raw `\0`;
406
+ case "\n":
407
+ return String.raw `\n`;
408
+ case "\r":
409
+ return String.raw `\r`;
410
+ case "\b":
411
+ return String.raw `\b`;
412
+ case "\t":
413
+ return String.raw `\t`;
414
+ case "\u001A":
415
+ return String.raw `\Z`;
416
+ default:
417
+ return `\\${s}`;
418
+ }
419
+ });
420
+ return json;
421
+ }
422
+ }
423
+
424
+ // Copyright 2024 IOTA Stiftung.
425
+ // SPDX-License-Identifier: Apache-2.0.
426
+ /**
427
+ * Store entities using ScyllaDB.
428
+ */
429
+ class ScyllaDBTableConnector extends AbstractScyllaDBConnector {
430
+ /**
431
+ * Runtime name for the class.
432
+ */
433
+ CLASS_NAME = "ScyllaDBTableConnector";
434
+ /**
435
+ * Create a new instance of ScyllaDBTableConnector.
436
+ * @param options The options for the connector.
437
+ * @param options.loggingConnectorType The type of logging connector to use, defaults to "logging".
438
+ * @param options.entitySchema The name of the entity schema.
439
+ * @param options.config The configuration for the connector.
440
+ */
441
+ constructor(options) {
442
+ super(options, "ScyllaDBTableConnector");
443
+ }
444
+ /**
445
+ * Bootstrap the component by creating and initializing any resources it needs.
446
+ * @param nodeLoggingConnectorType The node logging connector type, defaults to "node-logging".
447
+ * @returns True if the bootstrapping process was successful.
448
+ */
449
+ async bootstrap(nodeLoggingConnectorType) {
450
+ const nodeLogging = LoggingConnectorFactory.getIfExists(nodeLoggingConnectorType ?? "node-logging");
451
+ nodeLogging?.log({
452
+ level: "info",
453
+ source: this.CLASS_NAME,
454
+ ts: Date.now(),
455
+ message: "tableCreating",
456
+ data: { table: this._fullTableName }
457
+ });
458
+ try {
459
+ let dbConnection = await this.openConnection(true);
460
+ await this.createKeyspace(dbConnection, this._config.keyspace);
461
+ // Connection has to be closed and now open a new one with our keyspace
462
+ await this.closeConnection(dbConnection);
463
+ dbConnection = await this.openConnection();
464
+ // Need to find structured properties (declared as type: object)
465
+ const structuredProperties = this._entitySchema.properties?.filter(property => property.type === EntitySchemaPropertyType.Object ||
466
+ (property.type === EntitySchemaPropertyType.Array && property.itemTypeRef));
467
+ // Needs to support objects that may have itemRef other objects (to be done)
468
+ if (Is.array(structuredProperties)) {
469
+ for (const strProperty of structuredProperties) {
470
+ const subTypeSchemaRef = strProperty.itemTypeRef;
471
+ if (!Is.undefined(subTypeSchemaRef)) {
472
+ const objSchema = EntitySchemaFactory.get(subTypeSchemaRef);
473
+ const typeFields = [];
474
+ for (const field of objSchema.properties ?? []) {
475
+ typeFields.push(`"${String(field.property)}" ${this.toDbField(field)}`);
476
+ }
477
+ const sql = `CREATE TYPE IF NOT EXISTS
478
+ "${subTypeSchemaRef}" (${typeFields.join(",")})`;
479
+ await nodeLogging?.log({
480
+ level: "info",
481
+ source: this.CLASS_NAME,
482
+ ts: Date.now(),
483
+ message: "sql",
484
+ data: { sql }
485
+ });
486
+ await this.execute(dbConnection, sql);
487
+ await nodeLogging?.log({
488
+ level: "info",
489
+ source: this.CLASS_NAME,
490
+ ts: Date.now(),
491
+ message: "typeCreated",
492
+ data: { typeName: subTypeSchemaRef }
493
+ });
494
+ }
495
+ }
496
+ }
497
+ const fields = [];
498
+ const primaryKeys = [];
499
+ const secondaryKeys = [];
500
+ for (const field of this._entitySchema.properties ?? []) {
501
+ fields.push(`"${String(field.property)}" ${this.toDbField(field)}`);
502
+ if (field.isPrimary) {
503
+ primaryKeys.push(`"${field.property}"`);
504
+ }
505
+ if (field.isSecondary) {
506
+ secondaryKeys.push(`"${field.property}"`);
507
+ }
508
+ }
509
+ fields.push(`PRIMARY KEY ((${primaryKeys.join(",")})`);
510
+ if (secondaryKeys.length > 0) {
511
+ fields.push(`${secondaryKeys.join(",")})`);
512
+ }
513
+ else {
514
+ fields[fields.length - 1] += ")";
515
+ }
516
+ const sql = `CREATE TABLE IF NOT EXISTS "${this._fullTableName}" (${fields.join(", ")})`;
517
+ await nodeLogging?.log({
518
+ level: "info",
519
+ source: this.CLASS_NAME,
520
+ ts: Date.now(),
521
+ message: "sql",
522
+ data: { sql }
523
+ });
524
+ await this.execute(dbConnection, sql);
525
+ await nodeLogging?.log({
526
+ level: "info",
527
+ source: this.CLASS_NAME,
528
+ ts: Date.now(),
529
+ message: "tableCreated",
530
+ data: { table: this._fullTableName }
531
+ });
532
+ }
533
+ catch (err) {
534
+ if (BaseError.isErrorCode(err, "ResourceInUseException")) {
535
+ await nodeLogging?.log({
536
+ level: "info",
537
+ source: this.CLASS_NAME,
538
+ ts: Date.now(),
539
+ message: "tableExists",
540
+ data: { table: this._fullTableName }
541
+ });
542
+ }
543
+ else {
544
+ await nodeLogging?.log({
545
+ level: "error",
546
+ source: this.CLASS_NAME,
547
+ ts: Date.now(),
548
+ message: "tableCreateFailed",
549
+ error: err,
550
+ data: { table: this._fullTableName }
551
+ });
552
+ }
553
+ return false;
554
+ }
555
+ return true;
556
+ }
557
+ /**
558
+ * Set an entity.
559
+ * @param entity The entity to set.
560
+ */
561
+ async set(entity) {
562
+ Guards.object(this.CLASS_NAME, "entity", entity);
563
+ let connection;
564
+ const id = entity[this._primaryKey?.property];
565
+ try {
566
+ const propNames = this._entitySchema.properties?.map(f => `"${String(f.property)}"`) ?? [];
567
+ const propValues = [];
568
+ const preparedValues = [];
569
+ const entityAsKeyValues = entity;
570
+ for (const propDesc of this._entitySchema.properties ?? []) {
571
+ const value = entityAsKeyValues[propDesc.property];
572
+ propValues.push(this.propertyToDbValue(value, propDesc));
573
+ preparedValues.push("?");
574
+ }
575
+ const sql = `INSERT INTO "${this._fullTableName}" (${propNames.join(",")}) VALUES (${preparedValues.join(",")})`;
576
+ await this._logging?.log({
577
+ level: "info",
578
+ source: this.CLASS_NAME,
579
+ ts: Date.now(),
580
+ message: "sql",
581
+ data: { sql }
582
+ });
583
+ connection = await this.openConnection();
584
+ await this.execute(connection, sql, propValues);
585
+ }
586
+ catch (error) {
587
+ throw new GeneralError(this.CLASS_NAME, "entityStorage.setFailed", {
588
+ id
589
+ }, error);
590
+ }
591
+ finally {
592
+ await this.closeConnection(connection);
593
+ }
594
+ }
595
+ /**
596
+ * Delete the entity.
597
+ * @param id The id of the entity to remove.
598
+ */
599
+ async remove(id) {
600
+ Guards.stringValue(this.CLASS_NAME, "id", id);
601
+ let connection;
602
+ const primaryFieldValue = this.propertyToDbValue(id, this._primaryKey);
603
+ try {
604
+ const sql = `DELETE FROM "${this._fullTableName}" WHERE "${String(this._primaryKey?.property)}"=?`;
605
+ await this._logging?.log({
606
+ level: "info",
607
+ source: this.CLASS_NAME,
608
+ ts: Date.now(),
609
+ message: "entityStorage.sqlRemove",
610
+ data: { sql }
611
+ });
612
+ connection = await this.openConnection();
613
+ await this.execute(connection, sql, [primaryFieldValue]);
614
+ }
615
+ catch (error) {
616
+ throw new GeneralError(this.CLASS_NAME, "removeFailed", {
617
+ id
618
+ }, error);
619
+ }
620
+ finally {
621
+ await this.closeConnection(connection);
622
+ }
623
+ }
624
+ /**
625
+ * Drops table.
626
+ */
627
+ async dropTable() {
628
+ let connection;
629
+ try {
630
+ connection = await this.openConnection();
631
+ await connection.execute(`DROP TABLE IF EXISTS "${this._fullTableName}"`);
632
+ }
633
+ catch (error) {
634
+ throw new GeneralError(this.CLASS_NAME, "dropTableFailed", { table: this._fullTableName }, error);
635
+ }
636
+ finally {
637
+ await this.closeConnection(connection);
638
+ }
639
+ }
640
+ /**
641
+ * Truncates (clear) table.
642
+ */
643
+ async truncateTable() {
644
+ let connection;
645
+ try {
646
+ connection = await this.openConnection();
647
+ await connection.execute(`TRUNCATE TABLE "${this._fullTableName}"`);
648
+ }
649
+ catch (error) {
650
+ throw new GeneralError(this.CLASS_NAME, "truncateTableFailed", { table: this._fullTableName }, error);
651
+ }
652
+ finally {
653
+ await this.closeConnection(connection);
654
+ }
655
+ }
656
+ /**
657
+ * Transform a logical description of a field into a DB field.
658
+ * @param logicalField The logical field description.
659
+ * @returns The DB type.
660
+ * @throws GeneralException if no mapping found.
661
+ * @internal
662
+ */
663
+ toDbField(logicalField) {
664
+ let dbType;
665
+ switch (logicalField.type) {
666
+ case "string":
667
+ dbType = "TEXT";
668
+ switch (logicalField.format) {
669
+ case "uuid":
670
+ dbType = "UUID";
671
+ break;
672
+ case "date":
673
+ case "date-time":
674
+ dbType = "TIMESTAMP";
675
+ break;
676
+ }
677
+ break;
678
+ case "number":
679
+ dbType = "DOUBLE";
680
+ switch (logicalField.format) {
681
+ case "float":
682
+ dbType = "FLOAT";
683
+ break;
684
+ case "double":
685
+ dbType = "DOUBLE";
686
+ break;
687
+ }
688
+ break;
689
+ case "integer":
690
+ dbType = "INT";
691
+ switch (logicalField.format) {
692
+ case "int8":
693
+ case "uint8":
694
+ dbType = "TINYINT";
695
+ break;
696
+ case "int16":
697
+ case "uint16":
698
+ dbType = "SMALLINT";
699
+ break;
700
+ case "int32":
701
+ case "uint32":
702
+ dbType = "INT";
703
+ break;
704
+ case "int64":
705
+ case "uint64":
706
+ dbType = "BIGINT";
707
+ break;
708
+ }
709
+ break;
710
+ case "boolean":
711
+ dbType = "BOOLEAN";
712
+ break;
713
+ case "object":
714
+ if (!logicalField.itemTypeRef) {
715
+ throw new GeneralError(this.CLASS_NAME, "itemTypeNotDefined", {
716
+ type: logicalField.type,
717
+ table: this._fullTableName
718
+ });
719
+ }
720
+ dbType = `frozen<"${logicalField.itemTypeRef}">`;
721
+ break;
722
+ case "array":
723
+ if (!logicalField.itemType && !logicalField.itemTypeRef) {
724
+ throw new GeneralError(this.CLASS_NAME, "itemTypeNotDefined", {
725
+ type: logicalField.type,
726
+ table: this._fullTableName
727
+ });
728
+ }
729
+ if (logicalField.itemType) {
730
+ dbType = `SET<${this.toDbField({
731
+ property: logicalField.property,
732
+ type: logicalField.itemType
733
+ })}>`;
734
+ }
735
+ else {
736
+ dbType = `SET<frozen<"${logicalField.itemTypeRef}">>`;
737
+ }
738
+ break;
739
+ }
740
+ return dbType;
741
+ }
742
+ }
743
+
744
+ // Copyright 2024 IOTA Stiftung.
745
+ // SPDX-License-Identifier: Apache-2.0.
746
+ /**
747
+ * Manage entities using ScyllaDB Views.
748
+ */
749
+ class ScyllaDBViewConnector extends AbstractScyllaDBConnector {
750
+ /**
751
+ * Runtime name for the class.
752
+ */
753
+ CLASS_NAME = "ScyllaDBViewConnector";
754
+ /**
755
+ * The view descriptor.
756
+ * @internal
757
+ */
758
+ _viewSchema;
759
+ /**
760
+ * The name of the database table.
761
+ * @internal
762
+ */
763
+ _originalFullTableName;
764
+ /**
765
+ * Create a new instance of ScyllaDBViewConnector.
766
+ * @param options The options for the connector.
767
+ * @param options.loggingConnectorType The type of logging connector to use, defaults to "logging".
768
+ * @param options.entitySchema The name of the entity schema.
769
+ * @param options.viewSchema The name of the view schema.
770
+ * @param options.config The configuration for the connector.
771
+ */
772
+ constructor(options) {
773
+ // We need this conversion so that types can match in the superclass and reuse the get method
774
+ super({
775
+ loggingConnectorType: options.loggingConnectorType,
776
+ entitySchema: options.viewSchema,
777
+ config: options.config
778
+ }, "ScyllaDBViewConnector");
779
+ this._viewSchema = EntitySchemaHelper.getSchema(options.viewSchema);
780
+ // We need the underlying class to use the view name for lookups
781
+ // so substitute the view name for the entity name
782
+ // but store the original table name to use when bootstrapping the view
783
+ this._originalFullTableName = this._fullTableName;
784
+ this._fullTableName = StringHelper.camelCase(Is.stringValue(options.config.viewName) ? options.config.viewName : options.entitySchema);
785
+ }
786
+ /**
787
+ * Bootstrap the component by creating and initializing any resources it needs.
788
+ * @param nodeLoggingConnectorType The node logging connector type, defaults to "node-logging".
789
+ * @returns True if the bootstrapping process was successful.
790
+ */
791
+ async bootstrap(nodeLoggingConnectorType) {
792
+ const nodeLogging = LoggingConnectorFactory.getIfExists(nodeLoggingConnectorType ?? "node-logging");
793
+ nodeLogging?.log({
794
+ level: "info",
795
+ source: this.CLASS_NAME,
796
+ ts: Date.now(),
797
+ message: "viewCreating",
798
+ data: { view: this._fullTableName }
799
+ });
800
+ try {
801
+ const dbConnection = await this.openConnection(true);
802
+ await this.createKeyspace(dbConnection, this._config.keyspace);
803
+ const fields = [];
804
+ const primaryKeys = [];
805
+ for (const field of this._viewSchema.properties ?? []) {
806
+ fields.push(`"${String(field.property)}" IS NOT NULL `);
807
+ if (field.isPrimary) {
808
+ primaryKeys.push(field.property);
809
+ }
810
+ }
811
+ fields.push(`PRIMARY KEY (${primaryKeys.join(",")})`);
812
+ const sql = `CREATE MATERIALIZED VIEW IF NOT EXISTS ${this._config.keyspace}.${this._fullTableName}
813
+ AS SELECT * FROM ${this._config.keyspace}.${this._originalFullTableName} WHERE
814
+ ${this._fullTableName} (${fields.join(" AND ")})`;
815
+ await this.execute(dbConnection, sql);
816
+ nodeLogging?.log({
817
+ level: "info",
818
+ source: this.CLASS_NAME,
819
+ ts: Date.now(),
820
+ message: "viewCreated",
821
+ data: { view: this._fullTableName }
822
+ });
823
+ }
824
+ catch (err) {
825
+ if (BaseError.isErrorCode(err, "ResourceInUseException")) {
826
+ nodeLogging?.log({
827
+ level: "info",
828
+ source: this.CLASS_NAME,
829
+ ts: Date.now(),
830
+ message: "viewExists",
831
+ data: { view: this._fullTableName }
832
+ });
833
+ }
834
+ else {
835
+ nodeLogging?.log({
836
+ level: "error",
837
+ source: this.CLASS_NAME,
838
+ ts: Date.now(),
839
+ message: "viewCreateFailed",
840
+ error: err,
841
+ data: { view: this._fullTableName }
842
+ });
843
+ }
844
+ return false;
845
+ }
846
+ return true;
847
+ }
848
+ /**
849
+ * Set an entity.
850
+ * @param entity The entity to set.
851
+ */
852
+ async set(entity) {
853
+ throw new NotSupportedError(this.CLASS_NAME, "set", {});
854
+ }
855
+ /**
856
+ * Delete the entity.
857
+ * @param id The id of the entity to remove.
858
+ */
859
+ async remove(id) {
860
+ throw new NotSupportedError(this.CLASS_NAME, "remove", {});
861
+ }
862
+ }
863
+
864
+ export { ScyllaDBTableConnector, ScyllaDBViewConnector };