@twin.org/entity-storage-connector-scylladb 0.0.3-next.3 → 0.0.3-next.31
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/README.md +4 -10
- package/dist/es/abstractScyllaDBConnector.js +250 -98
- package/dist/es/abstractScyllaDBConnector.js.map +1 -1
- package/dist/es/models/IScyllaDBTableConfig.js.map +1 -1
- package/dist/es/models/IScyllaDBViewConfig.js.map +1 -1
- package/dist/es/scyllaDBTableConnector.js +419 -49
- package/dist/es/scyllaDBTableConnector.js.map +1 -1
- package/dist/es/scyllaDBViewConnector.js +39 -3
- package/dist/es/scyllaDBViewConnector.js.map +1 -1
- package/dist/types/abstractScyllaDBConnector.d.ts +12 -0
- package/dist/types/models/IScyllaDBTableConfig.d.ts +1 -1
- package/dist/types/models/IScyllaDBViewConfig.d.ts +2 -2
- package/dist/types/scyllaDBTableConnector.d.ts +53 -6
- package/dist/types/scyllaDBViewConnector.d.ts +20 -0
- package/docs/changelog.md +603 -49
- package/docs/examples.md +123 -1
- package/docs/reference/classes/ScyllaDBTableConnector.md +308 -22
- package/docs/reference/classes/ScyllaDBViewConnector.md +154 -8
- package/docs/reference/interfaces/IScyllaDBConfig.md +5 -5
- package/docs/reference/interfaces/IScyllaDBTableConfig.md +7 -7
- package/docs/reference/interfaces/IScyllaDBTableConnectorConstructorOptions.md +6 -6
- package/docs/reference/interfaces/IScyllaDBViewConfig.md +10 -10
- package/docs/reference/interfaces/IScyllaDBViewConnectorConstructorOptions.md +7 -7
- package/locales/en.json +23 -5
- package/package.json +6 -6
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Entity Storage Connector ScyllaDB
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
This package provides a ScyllaDB backend for distributed workloads that need low latency and high throughput. It is designed to work with the wider storage ecosystem so applications can keep behaviour consistent across connectors and environments.
|
|
4
4
|
|
|
5
5
|
## Installation
|
|
6
6
|
|
|
@@ -8,18 +8,12 @@ Entity Storage connector implementation using ScyllaDB.
|
|
|
8
8
|
npm install @twin.org/entity-storage-connector-scylladb
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
-
##
|
|
11
|
+
## Docker
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
To perform testing of this component it may be necessary to launch a local instance to communicate with.
|
|
14
14
|
|
|
15
15
|
```shell
|
|
16
|
-
docker run -
|
|
17
|
-
```
|
|
18
|
-
|
|
19
|
-
Afterwards you can run the tests as follows:
|
|
20
|
-
|
|
21
|
-
```shell
|
|
22
|
-
npm run test
|
|
16
|
+
docker run -d --name twin-entity-storage-scylladb -p 9042:9042 scylladb/scylla:5.4.9
|
|
23
17
|
```
|
|
24
18
|
|
|
25
19
|
## Examples
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// Copyright 2024 IOTA Stiftung.
|
|
2
2
|
// SPDX-License-Identifier: Apache-2.0.
|
|
3
3
|
import { ContextIdHelper, ContextIdStore } from "@twin.org/context";
|
|
4
|
-
import { Coerce, ComponentFactory, GeneralError, Guards, Is,
|
|
4
|
+
import { Coerce, ComponentFactory, GeneralError, Guards, Is, Validation } from "@twin.org/core";
|
|
5
5
|
import { ComparisonOperator, EntitySchemaFactory, EntitySchemaHelper, LogicalOperator, SortDirection } from "@twin.org/entity";
|
|
6
|
+
import { EntityStorageHelper } from "@twin.org/entity-storage-models";
|
|
6
7
|
import { types as CassandraTypes, Client } from "cassandra-driver";
|
|
7
8
|
/**
|
|
8
9
|
* Store entities using ScyllaDB.
|
|
@@ -26,7 +27,7 @@ export class AbstractScyllaDBConnector {
|
|
|
26
27
|
* Limit the number of entities when finding.
|
|
27
28
|
* @internal
|
|
28
29
|
*/
|
|
29
|
-
static
|
|
30
|
+
static DEFAULT_LIMIT = 40;
|
|
30
31
|
/**
|
|
31
32
|
* The name of the database table.
|
|
32
33
|
* @internal
|
|
@@ -57,6 +58,13 @@ export class AbstractScyllaDBConnector {
|
|
|
57
58
|
* @internal
|
|
58
59
|
*/
|
|
59
60
|
_primaryKey;
|
|
61
|
+
/**
|
|
62
|
+
* Cached persistent client (keyspace-scoped). Reused across all operations on this
|
|
63
|
+
* connector instance so the expensive cassandra-driver `connect()` only runs once.
|
|
64
|
+
* Closed by `closePersistentClient()` which callers (e.g. `teardown()`) must invoke.
|
|
65
|
+
* @internal
|
|
66
|
+
*/
|
|
67
|
+
_persistentClient;
|
|
60
68
|
/**
|
|
61
69
|
* Create a new instance of AbstractScyllaDBConnector.
|
|
62
70
|
* @param options The options for the connector.
|
|
@@ -72,12 +80,12 @@ export class AbstractScyllaDBConnector {
|
|
|
72
80
|
Guards.arrayValue(AbstractScyllaDBConnector.CLASS_NAME, "options.config.hosts", options.config.hosts);
|
|
73
81
|
Guards.stringValue(AbstractScyllaDBConnector.CLASS_NAME, "options.config.localDataCenter", options.config.localDataCenter);
|
|
74
82
|
Guards.stringValue(AbstractScyllaDBConnector.CLASS_NAME, "options.config.keyspace", options.config.keyspace);
|
|
75
|
-
this._logging = ComponentFactory.getIfExists(options.loggingComponentType
|
|
83
|
+
this._logging = ComponentFactory.getIfExists(options.loggingComponentType);
|
|
76
84
|
this._entitySchema = EntitySchemaFactory.get(options.entitySchema);
|
|
77
85
|
this._partitionContextIds = options.partitionContextIds;
|
|
78
86
|
this._primaryKey = EntitySchemaHelper.getPrimaryKey(this._entitySchema);
|
|
79
87
|
this._config = options.config;
|
|
80
|
-
this._fullTableName =
|
|
88
|
+
this._fullTableName = options.config.tableName;
|
|
81
89
|
}
|
|
82
90
|
/**
|
|
83
91
|
* Returns the class name of the component.
|
|
@@ -117,7 +125,7 @@ export class AbstractScyllaDBConnector {
|
|
|
117
125
|
value: id
|
|
118
126
|
});
|
|
119
127
|
const { sqlCondition, conditionValues } = this.buildConditions(conditions);
|
|
120
|
-
let sql = `SELECT * FROM "${this._fullTableName}" WHERE ${sqlCondition}`;
|
|
128
|
+
let sql = `SELECT * FROM "${this.safeTableName(this._fullTableName)}" WHERE ${sqlCondition}`;
|
|
121
129
|
if (secondaryIndex) {
|
|
122
130
|
sql += " ALLOW FILTERING";
|
|
123
131
|
}
|
|
@@ -157,100 +165,51 @@ export class AbstractScyllaDBConnector {
|
|
|
157
165
|
let connection;
|
|
158
166
|
const contextIds = await ContextIdStore.getContextIds();
|
|
159
167
|
const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
|
|
168
|
+
EntityStorageHelper.validateSortProperties(this._entitySchema, sortProperties);
|
|
169
|
+
EntityStorageHelper.validateProperties(this._entitySchema, properties);
|
|
170
|
+
if (!Is.empty(limit)) {
|
|
171
|
+
const validationFailures = [];
|
|
172
|
+
Validation.integer("limit", limit, validationFailures, undefined, { minValue: 1 });
|
|
173
|
+
Validation.asValidationError(AbstractScyllaDBConnector.CLASS_NAME, "query", validationFailures);
|
|
174
|
+
}
|
|
175
|
+
// CQL ORDER BY is only valid on clustering columns (isPrimary). Secondary-index
|
|
176
|
+
// properties cannot be used; throw before reaching the try-catch so the error
|
|
177
|
+
// surfaces directly to the caller without being wrapped as findFailed.
|
|
178
|
+
if (Is.arrayValue(sortProperties)) {
|
|
179
|
+
for (const sortProperty of sortProperties) {
|
|
180
|
+
const propertySchema = this._entitySchema.properties?.find(p => p.property === sortProperty.property);
|
|
181
|
+
if (!propertySchema?.isPrimary) {
|
|
182
|
+
throw new GeneralError(AbstractScyllaDBConnector.CLASS_NAME, "sortOnlyPrimaryKey", {
|
|
183
|
+
property: sortProperty.property
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
// Validates and throws for unsupported conditions before entering the try-catch
|
|
189
|
+
// so that comparisonNotSupported errors surface directly to the caller.
|
|
190
|
+
const { whereClause, params, noResults } = this.buildCqlConditions(conditions, partitionKey);
|
|
191
|
+
if (noResults) {
|
|
192
|
+
return { entities: [], cursor: undefined };
|
|
193
|
+
}
|
|
160
194
|
try {
|
|
161
|
-
|
|
162
|
-
let sql = `SELECT * FROM "${this._fullTableName}"`;
|
|
195
|
+
const returnSize = limit ?? AbstractScyllaDBConnector.DEFAULT_LIMIT;
|
|
196
|
+
let sql = `SELECT * FROM "${this.safeTableName(this._fullTableName)}"`;
|
|
163
197
|
if (Is.array(properties)) {
|
|
164
198
|
const fields = [];
|
|
165
199
|
for (const property of properties) {
|
|
166
200
|
fields.push(property.toString());
|
|
167
201
|
}
|
|
168
|
-
|
|
169
|
-
sql = sql.replace("*", selectFields);
|
|
170
|
-
}
|
|
171
|
-
const conds = [];
|
|
172
|
-
let conditionQuery = "";
|
|
173
|
-
// The params to be used to execute the query
|
|
174
|
-
const params = [];
|
|
175
|
-
let finalConditionQuery = `"${AbstractScyllaDBConnector.PARTITION_KEY}" = ?`;
|
|
176
|
-
params.push(partitionKey ?? AbstractScyllaDBConnector.PARTITION_KEY_VALUE);
|
|
177
|
-
let theConditions = [];
|
|
178
|
-
if (!Is.undefined(conditions)) {
|
|
179
|
-
if ("conditions" in conditions) {
|
|
180
|
-
theConditions = conditions.conditions;
|
|
181
|
-
}
|
|
182
|
-
else {
|
|
183
|
-
theConditions.push(conditions);
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
// TODO: This code needs refactoring to support conditions for sub properties.
|
|
187
|
-
for (const cond of theConditions) {
|
|
188
|
-
const condition = cond;
|
|
189
|
-
const descriptor = this._entitySchema.properties?.find(p => p.property === condition.property);
|
|
190
|
-
if (condition.comparison === ComparisonOperator.Includes ||
|
|
191
|
-
condition.comparison === ComparisonOperator.NotIncludes) {
|
|
192
|
-
const propValue = `'%${condition.value?.toString()}%'`;
|
|
193
|
-
if (condition.comparison === ComparisonOperator.Includes) {
|
|
194
|
-
conds.push(`"${condition.property}" LIKE ${propValue}`);
|
|
195
|
-
}
|
|
196
|
-
else if (condition.comparison === ComparisonOperator.NotIncludes) {
|
|
197
|
-
conds.push(`"${condition.property}" NOT LIKE ${propValue}`);
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
else if (condition.comparison === ComparisonOperator.In) {
|
|
201
|
-
let value = [];
|
|
202
|
-
if (!Is.arrayValue(condition.value)) {
|
|
203
|
-
value.push(this.propertyToDbValue(condition.value, descriptor));
|
|
204
|
-
}
|
|
205
|
-
else {
|
|
206
|
-
value = condition.value.map(v => this.propertyToDbValue(v, descriptor));
|
|
207
|
-
}
|
|
208
|
-
params.push(value);
|
|
209
|
-
conds.push(`"${condition.property}" IN ?`);
|
|
210
|
-
}
|
|
211
|
-
else {
|
|
212
|
-
const propValue = condition.value;
|
|
213
|
-
params.push(propValue);
|
|
214
|
-
if (condition.comparison === ComparisonOperator.Equals) {
|
|
215
|
-
conds.push(`"${condition.property}" = ?`);
|
|
216
|
-
}
|
|
217
|
-
else if (condition.comparison === ComparisonOperator.NotEquals) {
|
|
218
|
-
conds.push(`"${condition.property}" <> ?`);
|
|
219
|
-
}
|
|
220
|
-
else if (condition.comparison === ComparisonOperator.GreaterThan) {
|
|
221
|
-
conds.push(`"${condition.property}" > ?`);
|
|
222
|
-
}
|
|
223
|
-
else if (condition.comparison === ComparisonOperator.LessThan) {
|
|
224
|
-
conds.push(`"${condition.property}" < ?`);
|
|
225
|
-
}
|
|
226
|
-
else if (condition.comparison === ComparisonOperator.GreaterThanOrEqual) {
|
|
227
|
-
conds.push(`"${condition.property}" >= ?`);
|
|
228
|
-
}
|
|
229
|
-
else if (condition.comparison === ComparisonOperator.LessThanOrEqual) {
|
|
230
|
-
conds.push(`"${condition.property}" <= ?`);
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
const operator = conditions.logicalOperator ?? LogicalOperator.And;
|
|
234
|
-
conditionQuery = `${conds.join(` ${operator} `)}`;
|
|
202
|
+
sql = sql.replace("*", fields.join(","));
|
|
235
203
|
}
|
|
236
|
-
|
|
237
|
-
finalConditionQuery += ` AND (${conditionQuery})`;
|
|
238
|
-
}
|
|
239
|
-
sql += ` WHERE ${finalConditionQuery}`;
|
|
240
|
-
connection = await this.openConnection();
|
|
241
|
-
// TODO: Only supported one sort property at the moment. This code would need to be revised in a follow-up
|
|
204
|
+
sql += ` WHERE ${whereClause}`;
|
|
242
205
|
if (Is.array(sortProperties) && sortProperties.length >= 1) {
|
|
243
|
-
const
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
sqlSortDir = "desc";
|
|
249
|
-
}
|
|
250
|
-
sql += ` ORDER BY "${String(sortKey)}" ${sqlSortDir.toUpperCase()}`;
|
|
251
|
-
// Disabling paging in order by situations
|
|
252
|
-
returnSize = 0;
|
|
206
|
+
const orderClauses = sortProperties.map(sp => {
|
|
207
|
+
const dir = sp.sortDirection === SortDirection.Descending ? "DESC" : "ASC";
|
|
208
|
+
return `"${String(sp.property)}" ${dir}`;
|
|
209
|
+
});
|
|
210
|
+
sql += ` ORDER BY ${orderClauses.join(", ")}`;
|
|
253
211
|
}
|
|
212
|
+
connection = await this.openConnection();
|
|
254
213
|
sql += " ALLOW FILTERING";
|
|
255
214
|
await this._logging?.log({
|
|
256
215
|
level: "info",
|
|
@@ -264,13 +223,49 @@ export class AbstractScyllaDBConnector {
|
|
|
264
223
|
for (const row of result.rows) {
|
|
265
224
|
entities.push(this.convertRowToObject(this._entitySchema.properties, row));
|
|
266
225
|
}
|
|
226
|
+
// ScyllaDB may return a pageState even when the current page is the last one
|
|
227
|
+
// (when rows.length == fetchSize). Peek at the next page to verify there are
|
|
228
|
+
// actually more rows before surfacing the cursor to the caller.
|
|
229
|
+
let nextCursor;
|
|
230
|
+
if (returnSize > 0 && result.rows.length >= returnSize && Is.stringValue(result.pageState)) {
|
|
231
|
+
const peek = await this.queryDB(connection, sql, params, result.pageState, 1);
|
|
232
|
+
if (peek.rows.length > 0) {
|
|
233
|
+
nextCursor = result.pageState;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
267
236
|
return {
|
|
268
237
|
entities,
|
|
269
|
-
cursor:
|
|
238
|
+
cursor: nextCursor
|
|
270
239
|
};
|
|
271
240
|
}
|
|
272
241
|
catch (error) {
|
|
273
|
-
throw new GeneralError(AbstractScyllaDBConnector.CLASS_NAME, "findFailed", { table: this._fullTableName }, error);
|
|
242
|
+
throw new GeneralError(AbstractScyllaDBConnector.CLASS_NAME, "findFailed", { table: this.safeTableName(this._fullTableName) }, error);
|
|
243
|
+
}
|
|
244
|
+
finally {
|
|
245
|
+
await this.closeConnection(connection);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Count all the entities which match the conditions.
|
|
250
|
+
* @param conditions The optional conditions to match for the entities.
|
|
251
|
+
* @returns The total count of entities in the storage.
|
|
252
|
+
*/
|
|
253
|
+
async count(conditions) {
|
|
254
|
+
let connection;
|
|
255
|
+
try {
|
|
256
|
+
const contextIds = await ContextIdStore.getContextIds();
|
|
257
|
+
const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
|
|
258
|
+
const { whereClause, params, noResults } = this.buildCqlConditions(conditions, partitionKey);
|
|
259
|
+
if (noResults) {
|
|
260
|
+
return 0;
|
|
261
|
+
}
|
|
262
|
+
const sql = `SELECT COUNT(*) FROM "${this.safeTableName(this._fullTableName)}" WHERE ${whereClause} ALLOW FILTERING`;
|
|
263
|
+
connection = await this.openConnection();
|
|
264
|
+
const result = await this.queryDB(connection, sql, params);
|
|
265
|
+
return Number(result.rows[0]?.get("count") ?? 0);
|
|
266
|
+
}
|
|
267
|
+
catch (err) {
|
|
268
|
+
throw new GeneralError(AbstractScyllaDBConnector.CLASS_NAME, "countFailed", undefined, err);
|
|
274
269
|
}
|
|
275
270
|
finally {
|
|
276
271
|
await this.closeConnection(connection);
|
|
@@ -278,12 +273,16 @@ export class AbstractScyllaDBConnector {
|
|
|
278
273
|
}
|
|
279
274
|
/**
|
|
280
275
|
* Open a new database connection.
|
|
281
|
-
* @param config The config for the connection.
|
|
282
276
|
* @param skipKeySpace Don't include the keyspace in the connection.
|
|
283
277
|
* @returns The new connection.
|
|
284
278
|
* @internal
|
|
285
279
|
*/
|
|
286
280
|
async openConnection(skipKeySpace = false) {
|
|
281
|
+
// Reuse the cached keyspace-scoped client when available (avoids repeated
|
|
282
|
+
// cassandra-driver cluster-discovery on every operation).
|
|
283
|
+
if (!skipKeySpace && this._persistentClient !== undefined) {
|
|
284
|
+
return this._persistentClient;
|
|
285
|
+
}
|
|
287
286
|
const client = new Client({
|
|
288
287
|
contactPoints: this._config.hosts,
|
|
289
288
|
localDataCenter: this._config.localDataCenter,
|
|
@@ -293,25 +292,48 @@ export class AbstractScyllaDBConnector {
|
|
|
293
292
|
}
|
|
294
293
|
});
|
|
295
294
|
await client.connect();
|
|
295
|
+
if (!skipKeySpace) {
|
|
296
|
+
this._persistentClient = client;
|
|
297
|
+
}
|
|
296
298
|
return client;
|
|
297
299
|
}
|
|
298
300
|
/**
|
|
299
301
|
* Close database connection.
|
|
302
|
+
* When `connection` is the cached persistent client it is kept alive so it
|
|
303
|
+
* can be reused by future operations; call `closePersistentClient()` to
|
|
304
|
+
* explicitly shut it down (e.g. from `teardown()`).
|
|
300
305
|
* @param connection The connection to close.
|
|
306
|
+
* @returns Nothing.
|
|
301
307
|
* @internal
|
|
302
308
|
*/
|
|
303
309
|
async closeConnection(connection) {
|
|
304
310
|
if (!connection) {
|
|
305
311
|
return;
|
|
306
312
|
}
|
|
313
|
+
if (connection === this._persistentClient) {
|
|
314
|
+
// Keep the persistent client alive for reuse.
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
307
317
|
return connection.shutdown();
|
|
308
318
|
}
|
|
319
|
+
/**
|
|
320
|
+
* Shut down and clear the persistent client. Call this from `teardown()`
|
|
321
|
+
* implementations to release the underlying TCP connection.
|
|
322
|
+
* @internal
|
|
323
|
+
*/
|
|
324
|
+
async closePersistentClient() {
|
|
325
|
+
if (this._persistentClient !== undefined) {
|
|
326
|
+
await this._persistentClient.shutdown();
|
|
327
|
+
this._persistentClient = undefined;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
309
330
|
/**
|
|
310
331
|
* Query the database.
|
|
311
332
|
* @param connection The connection to query.
|
|
312
333
|
* @param sql The sql statement to execute.
|
|
313
334
|
* @param params The params to use when executing the query.
|
|
314
|
-
* @param
|
|
335
|
+
* @param pageState The page state to use when it comes to pagination.
|
|
336
|
+
* @param limit The maximum number of rows to return.
|
|
315
337
|
* @returns The rows.
|
|
316
338
|
* @internal
|
|
317
339
|
*/
|
|
@@ -321,7 +343,7 @@ export class AbstractScyllaDBConnector {
|
|
|
321
343
|
connection.eachRow(sql, params, {
|
|
322
344
|
prepare: true,
|
|
323
345
|
autoPage: false,
|
|
324
|
-
fetchSize: limit ?? AbstractScyllaDBConnector.
|
|
346
|
+
fetchSize: limit ?? AbstractScyllaDBConnector.DEFAULT_LIMIT,
|
|
325
347
|
pageState
|
|
326
348
|
}, (n, row) => {
|
|
327
349
|
rows.push(row);
|
|
@@ -339,6 +361,8 @@ export class AbstractScyllaDBConnector {
|
|
|
339
361
|
* Execute on the database.
|
|
340
362
|
* @param connection The connection to execute.
|
|
341
363
|
* @param sql The sql statement to execute.
|
|
364
|
+
* @param params The optional params to use when executing the statement.
|
|
365
|
+
* @returns The result set.
|
|
342
366
|
* @internal
|
|
343
367
|
*/
|
|
344
368
|
async execute(connection, sql, params) {
|
|
@@ -348,6 +372,7 @@ export class AbstractScyllaDBConnector {
|
|
|
348
372
|
* Create keyspace if it doesn't exist.
|
|
349
373
|
* @param connection The connection to perform the query with.
|
|
350
374
|
* @param keyspaceName The name of the keyspace to create.
|
|
375
|
+
* @returns The result set.
|
|
351
376
|
* @internal
|
|
352
377
|
*/
|
|
353
378
|
async createKeyspace(connection, keyspaceName) {
|
|
@@ -392,6 +417,7 @@ export class AbstractScyllaDBConnector {
|
|
|
392
417
|
* @param value The value to convert to original form.
|
|
393
418
|
* @param fieldDescriptor The descriptor for the field.
|
|
394
419
|
* @returns The value as a property for the object.
|
|
420
|
+
* @throws GeneralError if parsing JSON fails.
|
|
395
421
|
* @internal
|
|
396
422
|
*/
|
|
397
423
|
dbValueToProperty(value, fieldDescriptor) {
|
|
@@ -436,9 +462,9 @@ export class AbstractScyllaDBConnector {
|
|
|
436
462
|
return value;
|
|
437
463
|
}
|
|
438
464
|
/**
|
|
439
|
-
* Format a value for the DB.
|
|
465
|
+
* Format a value for the DB.
|
|
440
466
|
* @param value The value to format.
|
|
441
|
-
* @param fieldDescriptor The descriptor for the field
|
|
467
|
+
* @param fieldDescriptor The descriptor for the field.
|
|
442
468
|
* @returns The value after conversion.
|
|
443
469
|
* @internal
|
|
444
470
|
*/
|
|
@@ -475,7 +501,7 @@ export class AbstractScyllaDBConnector {
|
|
|
475
501
|
obj[field.property] = this.dbValueToProperty(value, field);
|
|
476
502
|
}
|
|
477
503
|
}
|
|
478
|
-
return
|
|
504
|
+
return EntityStorageHelper.unPrepareEntity(obj, [AbstractScyllaDBConnector.PARTITION_KEY]);
|
|
479
505
|
}
|
|
480
506
|
/**
|
|
481
507
|
* Wrap a string for DB format.
|
|
@@ -541,5 +567,131 @@ export class AbstractScyllaDBConnector {
|
|
|
541
567
|
}
|
|
542
568
|
return { sqlCondition: sqlConditions.join(" AND "), conditionValues };
|
|
543
569
|
}
|
|
570
|
+
/**
|
|
571
|
+
* Get a safe table name by replacing any non-alphanumeric characters.
|
|
572
|
+
* @param name The name to sanitize.
|
|
573
|
+
* @returns The safe table name.
|
|
574
|
+
*/
|
|
575
|
+
safeTableName(name) {
|
|
576
|
+
return name.replace(/[^\dA-Za-z]/g, "");
|
|
577
|
+
}
|
|
578
|
+
/**
|
|
579
|
+
* Parse, validate, and build a CQL WHERE clause from an EntityCondition tree.
|
|
580
|
+
* The partition key equality is always the first clause; user conditions follow.
|
|
581
|
+
* @param conditions The optional conditions to match for the entities.
|
|
582
|
+
* @param partitionKey The partition key value to filter by.
|
|
583
|
+
* @returns The complete WHERE clause (without the WHERE keyword) and bound params.
|
|
584
|
+
* @throws GeneralError if OR conditions, dot-notation paths, null comparisons, NotEquals, or NotIncludes operators are used.
|
|
585
|
+
* @internal
|
|
586
|
+
*/
|
|
587
|
+
buildCqlConditions(conditions, partitionKey) {
|
|
588
|
+
let conditionsList = [];
|
|
589
|
+
if (conditions !== undefined) {
|
|
590
|
+
if ("conditions" in conditions) {
|
|
591
|
+
if (conditions.logicalOperator === LogicalOperator.Or) {
|
|
592
|
+
throw new GeneralError(AbstractScyllaDBConnector.CLASS_NAME, "orConditionNotSupported");
|
|
593
|
+
}
|
|
594
|
+
conditionsList = conditions.conditions;
|
|
595
|
+
}
|
|
596
|
+
else {
|
|
597
|
+
conditionsList = [conditions];
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
for (const cond of conditionsList) {
|
|
601
|
+
const comparator = cond;
|
|
602
|
+
if (String(comparator.property).includes(".")) {
|
|
603
|
+
throw new GeneralError(AbstractScyllaDBConnector.CLASS_NAME, "comparisonNotSupported", {
|
|
604
|
+
property: comparator.property,
|
|
605
|
+
reason: "dot-notation nested property paths are not supported in CQL"
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
if ((comparator.comparison === ComparisonOperator.Equals ||
|
|
609
|
+
comparator.comparison === ComparisonOperator.NotEquals) &&
|
|
610
|
+
(comparator.value === null || comparator.value === undefined)) {
|
|
611
|
+
throw new GeneralError(AbstractScyllaDBConnector.CLASS_NAME, "comparisonNotSupported", {
|
|
612
|
+
property: comparator.property,
|
|
613
|
+
reason: "null/undefined comparisons are not supported in CQL WHERE clauses"
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
if (comparator.comparison === ComparisonOperator.NotEquals) {
|
|
617
|
+
throw new GeneralError(AbstractScyllaDBConnector.CLASS_NAME, "notEqualsNotSupported", {
|
|
618
|
+
property: comparator.property
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
if (comparator.comparison === ComparisonOperator.NotIncludes) {
|
|
622
|
+
throw new GeneralError(AbstractScyllaDBConnector.CLASS_NAME, "notIncludesNotSupported", {
|
|
623
|
+
property: comparator.property
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
const conds = [];
|
|
628
|
+
const params = [partitionKey ?? AbstractScyllaDBConnector.PARTITION_KEY_VALUE];
|
|
629
|
+
for (const cond of conditionsList) {
|
|
630
|
+
const condition = cond;
|
|
631
|
+
const descriptor = this._entitySchema.properties?.find(p => p.property === condition.property);
|
|
632
|
+
if (condition.comparison === ComparisonOperator.Includes ||
|
|
633
|
+
condition.comparison === ComparisonOperator.NotIncludes) {
|
|
634
|
+
const serialized = this.propertyToDbValue(condition.value, descriptor);
|
|
635
|
+
const propValue = `'%${Is.stringValue(serialized) ? serialized : ""}%'`;
|
|
636
|
+
if (condition.comparison === ComparisonOperator.Includes) {
|
|
637
|
+
conds.push(`"${condition.property}" LIKE ${propValue}`);
|
|
638
|
+
}
|
|
639
|
+
else if (condition.comparison === ComparisonOperator.NotIncludes) {
|
|
640
|
+
conds.push(`"${condition.property}" NOT LIKE ${propValue}`);
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
else if (condition.comparison === ComparisonOperator.In) {
|
|
644
|
+
// Guard must come first: Is.arrayValue([]) returns false for an empty array,
|
|
645
|
+
// so an empty value would be wrapped as a single element below and bypass
|
|
646
|
+
// the length check. Check Is.array (true for any array) before branching (#141).
|
|
647
|
+
if (Is.array(condition.value) && condition.value.length === 0) {
|
|
648
|
+
return {
|
|
649
|
+
whereClause: "",
|
|
650
|
+
params: [],
|
|
651
|
+
noResults: true
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
let value = [];
|
|
655
|
+
if (!Is.arrayValue(condition.value)) {
|
|
656
|
+
value.push(this.propertyToDbValue(condition.value, descriptor));
|
|
657
|
+
}
|
|
658
|
+
else {
|
|
659
|
+
value = condition.value.map(v => this.propertyToDbValue(v, descriptor));
|
|
660
|
+
}
|
|
661
|
+
params.push(value);
|
|
662
|
+
conds.push(`"${condition.property}" IN ?`);
|
|
663
|
+
}
|
|
664
|
+
else {
|
|
665
|
+
const propValue = this.propertyToDbValue(condition.value, descriptor);
|
|
666
|
+
params.push(propValue);
|
|
667
|
+
if (condition.comparison === ComparisonOperator.Equals) {
|
|
668
|
+
conds.push(`"${condition.property}" = ?`);
|
|
669
|
+
}
|
|
670
|
+
else if (condition.comparison === ComparisonOperator.NotEquals) {
|
|
671
|
+
conds.push(`"${condition.property}" != ?`);
|
|
672
|
+
}
|
|
673
|
+
else if (condition.comparison === ComparisonOperator.GreaterThan) {
|
|
674
|
+
conds.push(`"${condition.property}" > ?`);
|
|
675
|
+
}
|
|
676
|
+
else if (condition.comparison === ComparisonOperator.LessThan) {
|
|
677
|
+
conds.push(`"${condition.property}" < ?`);
|
|
678
|
+
}
|
|
679
|
+
else if (condition.comparison === ComparisonOperator.GreaterThanOrEqual) {
|
|
680
|
+
conds.push(`"${condition.property}" >= ?`);
|
|
681
|
+
}
|
|
682
|
+
else if (condition.comparison === ComparisonOperator.LessThanOrEqual) {
|
|
683
|
+
conds.push(`"${condition.property}" <= ?`);
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
const operator = "conditions" in (conditions ?? {})
|
|
688
|
+
? (conditions.logicalOperator ?? LogicalOperator.And)
|
|
689
|
+
: LogicalOperator.And;
|
|
690
|
+
let whereClause = `"${AbstractScyllaDBConnector.PARTITION_KEY}" = ?`;
|
|
691
|
+
if (conds.length > 0) {
|
|
692
|
+
whereClause += ` AND ${conds.join(` ${operator} `)}`;
|
|
693
|
+
}
|
|
694
|
+
return { whereClause, params };
|
|
695
|
+
}
|
|
544
696
|
}
|
|
545
697
|
//# sourceMappingURL=abstractScyllaDBConnector.js.map
|