@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.
- package/LICENSE +201 -0
- package/README.md +35 -0
- package/dist/cjs/index.cjs +867 -0
- package/dist/esm/index.mjs +864 -0
- package/dist/types/abstractScyllaDBConnector.d.ts +50 -0
- package/dist/types/index.d.ts +4 -0
- package/dist/types/models/IScyllaDBConfig.d.ts +17 -0
- package/dist/types/models/IScyllaDBTableConfig.d.ts +11 -0
- package/dist/types/models/IScyllaDBViewConfig.d.ts +11 -0
- package/dist/types/scyllaDBTableConnector.d.ts +48 -0
- package/dist/types/scyllaDBViewConnector.d.ts +42 -0
- package/docs/changelog.md +5 -0
- package/docs/examples.md +1 -0
- package/docs/reference/classes/ScyllaDBTableConnector.md +246 -0
- package/docs/reference/classes/ScyllaDBViewConnector.md +226 -0
- package/docs/reference/index.md +11 -0
- package/docs/reference/interfaces/IScyllaDBTableConfig.md +61 -0
- package/docs/reference/interfaces/IScyllaDBViewConfig.md +75 -0
- package/locales/en.json +28 -0
- package/package.json +73 -0
|
@@ -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 };
|