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

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