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