umbot-ydb-adapter 1.0.0
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 +21 -0
- package/LICENSE.md +21 -0
- package/README.md +293 -0
- package/dist/YdbAdapter.d.ts +199 -0
- package/dist/YdbAdapter.js +457 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +8 -0
- package/dist/yql.d.ts +196 -0
- package/dist/yql.js +584 -0
- package/package.json +95 -0
package/dist/yql.js
ADDED
|
@@ -0,0 +1,584 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.SUPPORTED_OPERATORS = exports.YqlBuildError = exports.YDB_COLUMN_TYPES = void 0;
|
|
4
|
+
exports.tableInfoFromSchema = tableInfoFromSchema;
|
|
5
|
+
exports.tableInfoFromDefinition = tableInfoFromDefinition;
|
|
6
|
+
exports.quoteTable = quoteTable;
|
|
7
|
+
exports.quoteColumn = quoteColumn;
|
|
8
|
+
exports.chooseIndex = chooseIndex;
|
|
9
|
+
exports.buildSelect = buildSelect;
|
|
10
|
+
exports.buildUpsert = buildUpsert;
|
|
11
|
+
exports.buildUpdate = buildUpdate;
|
|
12
|
+
exports.buildDelete = buildDelete;
|
|
13
|
+
exports.buildCreateTable = buildCreateTable;
|
|
14
|
+
exports.buildSchemaCheck = buildSchemaCheck;
|
|
15
|
+
exports.buildColumnCheck = buildColumnCheck;
|
|
16
|
+
exports.buildAddColumn = buildAddColumn;
|
|
17
|
+
exports.buildIndexCheck = buildIndexCheck;
|
|
18
|
+
exports.buildAddIndex = buildAddIndex;
|
|
19
|
+
exports.escapeYqlString = escapeYqlString;
|
|
20
|
+
exports.normalizeRow = normalizeRow;
|
|
21
|
+
const umbot_1 = require("umbot");
|
|
22
|
+
const primitive_1 = require("@ydbjs/value/primitive");
|
|
23
|
+
const list_1 = require("@ydbjs/value/list");
|
|
24
|
+
/**
|
|
25
|
+
* Типы колонок YDB, которые понимает адаптер.
|
|
26
|
+
*/
|
|
27
|
+
exports.YDB_COLUMN_TYPES = ['Utf8', 'Int64', 'Double', 'Bool'];
|
|
28
|
+
/**
|
|
29
|
+
* Ошибка сборки запроса: некорректное имя, условие или значение.
|
|
30
|
+
* Адаптер не отправляет такой запрос в базу, а возвращает ошибку вызывающему коду.
|
|
31
|
+
*/
|
|
32
|
+
class YqlBuildError extends Error {
|
|
33
|
+
constructor(message) {
|
|
34
|
+
super(message);
|
|
35
|
+
this.name = 'YqlBuildError';
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
exports.YqlBuildError = YqlBuildError;
|
|
39
|
+
// Сегмент пути таблицы: имя может содержать директории YDB (`bots/UsersData`)
|
|
40
|
+
const TABLE_SEGMENT_REGEXP = /^[A-Za-z_][\w-]*$/;
|
|
41
|
+
const COLUMN_NAME_REGEXP = /^[A-Za-z_]\w*$/;
|
|
42
|
+
// Через эти ключи можно добраться до прототипа: в имени колонки их быть не может
|
|
43
|
+
const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
|
|
44
|
+
const COMPARE_OPERATORS = {
|
|
45
|
+
$gt: '>',
|
|
46
|
+
$gte: '>=',
|
|
47
|
+
$lt: '<',
|
|
48
|
+
$lte: '<=',
|
|
49
|
+
$ne: '!=',
|
|
50
|
+
$like: 'LIKE',
|
|
51
|
+
};
|
|
52
|
+
/**
|
|
53
|
+
* Операторы условий, которые понимает адаптер.
|
|
54
|
+
*/
|
|
55
|
+
exports.SUPPORTED_OPERATORS = [
|
|
56
|
+
'$gt',
|
|
57
|
+
'$gte',
|
|
58
|
+
'$lt',
|
|
59
|
+
'$lte',
|
|
60
|
+
'$ne',
|
|
61
|
+
'$in',
|
|
62
|
+
'$nin',
|
|
63
|
+
'$like',
|
|
64
|
+
'$null',
|
|
65
|
+
];
|
|
66
|
+
/**
|
|
67
|
+
* Вторичные индексы таблицы. Индекс, совпадающий с началом первичного ключа, не создаётся:
|
|
68
|
+
* ключ уже даёт быстрый поиск по этим полям.
|
|
69
|
+
*/
|
|
70
|
+
function buildIndexes(primaryKey, indexes) {
|
|
71
|
+
const result = [];
|
|
72
|
+
for (const fields of indexes) {
|
|
73
|
+
const isKeyPrefix = fields.every((field, i) => primaryKey[i] === field);
|
|
74
|
+
if (!isKeyPrefix) {
|
|
75
|
+
result.push({ name: `idx_${fields.join('_')}`, fields: [...fields] });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return result;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Собирает описание таблицы из схемы umbot (`DB_TABLES_SCHEMA`).
|
|
82
|
+
* Все поля встроенных таблиц строковые, поэтому колонки получают тип `Utf8`.
|
|
83
|
+
* @param schema Описание таблицы
|
|
84
|
+
* @returns Описание таблицы для адаптера
|
|
85
|
+
*/
|
|
86
|
+
function tableInfoFromSchema(schema) {
|
|
87
|
+
const primaryKey = [schema.primaryKeyName, ...schema.uniqueKeys];
|
|
88
|
+
const columns = new Map();
|
|
89
|
+
for (const name of Object.keys(schema.fields)) {
|
|
90
|
+
columns.set(name, 'Utf8');
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
tableName: schema.tableName,
|
|
94
|
+
primaryKey,
|
|
95
|
+
columns,
|
|
96
|
+
indexes: buildIndexes(primaryKey, schema.indexes),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Собирает описание своей таблицы из опции `tables` и проверяет его.
|
|
101
|
+
* @param definition Описание таблицы
|
|
102
|
+
* @returns Описание таблицы для адаптера
|
|
103
|
+
*/
|
|
104
|
+
function tableInfoFromDefinition(definition) {
|
|
105
|
+
const { tableName, primaryKey } = definition;
|
|
106
|
+
const columns = new Map(Object.entries(definition.columns));
|
|
107
|
+
quoteTable(tableName);
|
|
108
|
+
if (!primaryKey.length) {
|
|
109
|
+
throw new YqlBuildError(`У таблицы "${tableName}" не задан первичный ключ.`);
|
|
110
|
+
}
|
|
111
|
+
const indexes = definition.indexes ?? [];
|
|
112
|
+
for (const column of [...primaryKey, ...indexes.flat()]) {
|
|
113
|
+
if (!columns.has(column)) {
|
|
114
|
+
throw new YqlBuildError(`Колонка "${column}" таблицы "${tableName}" используется в ключе или индексе, но не описана в columns.`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
for (const [column, type] of columns) {
|
|
118
|
+
quoteColumn(column);
|
|
119
|
+
if (!exports.YDB_COLUMN_TYPES.includes(type)) {
|
|
120
|
+
throw new YqlBuildError(`Неизвестный тип "${type}" колонки "${column}" таблицы "${tableName}". Поддерживаются: ${exports.YDB_COLUMN_TYPES.join(', ')}.`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return {
|
|
124
|
+
tableName,
|
|
125
|
+
primaryKey: [...primaryKey],
|
|
126
|
+
columns,
|
|
127
|
+
indexes: buildIndexes(primaryKey, indexes),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Проверяет имя таблицы и возвращает его в обратных кавычках.
|
|
132
|
+
* @param name Имя таблицы
|
|
133
|
+
* @returns Имя для подстановки в YQL
|
|
134
|
+
*/
|
|
135
|
+
function quoteTable(name) {
|
|
136
|
+
if (!name.split('/').every((segment) => TABLE_SEGMENT_REGEXP.test(segment))) {
|
|
137
|
+
throw new YqlBuildError(`Недопустимое имя таблицы: "${name}"`);
|
|
138
|
+
}
|
|
139
|
+
return `\`${name}\``;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Проверяет имя колонки (или индекса) и возвращает его в обратных кавычках.
|
|
143
|
+
* @param name Имя колонки
|
|
144
|
+
* @returns Имя для подстановки в YQL
|
|
145
|
+
*/
|
|
146
|
+
function quoteColumn(name) {
|
|
147
|
+
if (FORBIDDEN_KEYS.has(name) || !COLUMN_NAME_REGEXP.test(name)) {
|
|
148
|
+
throw new YqlBuildError(`Недопустимое имя колонки: "${name}"`);
|
|
149
|
+
}
|
|
150
|
+
return `\`${name}\``;
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Набор параметров запроса: выдаёт имена `$p0`, `$p1`, ...
|
|
154
|
+
*/
|
|
155
|
+
class YqlParams {
|
|
156
|
+
params = {};
|
|
157
|
+
#index = 0;
|
|
158
|
+
add(value) {
|
|
159
|
+
const name = `$p${this.#index++}`;
|
|
160
|
+
this.params[name] = value;
|
|
161
|
+
return name;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Определяет тип колонки: из схемы таблицы, затем из правил модели.
|
|
166
|
+
* @returns Тип колонки или undefined, если он неизвестен (тогда тип выводится из значения)
|
|
167
|
+
*/
|
|
168
|
+
function getColumnType(column, ctx) {
|
|
169
|
+
const fromSchema = ctx.table?.columns.get(column);
|
|
170
|
+
if (fromSchema) {
|
|
171
|
+
return fromSchema;
|
|
172
|
+
}
|
|
173
|
+
const rule = ctx.rules?.find((item) => item.name.includes(column));
|
|
174
|
+
switch (rule?.type) {
|
|
175
|
+
case 'string':
|
|
176
|
+
case 'text':
|
|
177
|
+
return 'Utf8';
|
|
178
|
+
case 'int':
|
|
179
|
+
case 'integer':
|
|
180
|
+
return 'Int64';
|
|
181
|
+
case 'bool':
|
|
182
|
+
return 'Bool';
|
|
183
|
+
default:
|
|
184
|
+
return undefined;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Максимальная длина строки из правил модели (`max` у правил `string`/`text`).
|
|
189
|
+
*/
|
|
190
|
+
function getMaxLength(column, ctx) {
|
|
191
|
+
const rule = ctx.rules?.find((item) => (item.type === 'string' || item.type === 'text') && item.name.includes(column));
|
|
192
|
+
return rule?.max;
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Выводит тип колонки из JS-значения, когда ни схема, ни правила его не задают.
|
|
196
|
+
*/
|
|
197
|
+
function inferType(column, value) {
|
|
198
|
+
switch (typeof value) {
|
|
199
|
+
case 'string':
|
|
200
|
+
return 'Utf8';
|
|
201
|
+
case 'boolean':
|
|
202
|
+
return 'Bool';
|
|
203
|
+
case 'bigint':
|
|
204
|
+
return 'Int64';
|
|
205
|
+
case 'number':
|
|
206
|
+
return Number.isInteger(value) ? 'Int64' : 'Double';
|
|
207
|
+
default:
|
|
208
|
+
throw new YqlBuildError(`Значение поля "${column}" имеет неподдерживаемый тип (${typeof value}). ` +
|
|
209
|
+
'Передайте строку, число или boolean, либо выполните запрос через query().');
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Приводит значение к целому для колонки Int64.
|
|
214
|
+
*/
|
|
215
|
+
function toBigInt(column, value) {
|
|
216
|
+
if (typeof value === 'bigint') {
|
|
217
|
+
return value;
|
|
218
|
+
}
|
|
219
|
+
const num = typeof value === 'string' && value.trim() !== '' ? Number(value) : value;
|
|
220
|
+
if (typeof num === 'number' && Number.isSafeInteger(num)) {
|
|
221
|
+
return BigInt(num);
|
|
222
|
+
}
|
|
223
|
+
throw new YqlBuildError(`Поле "${column}" ожидает целое число, получено: ${String(value)}`);
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Приводит значение к строке для колонки Utf8 и обрезает его по `max` из правил модели.
|
|
227
|
+
*/
|
|
228
|
+
function toText(column, value, ctx) {
|
|
229
|
+
const text = typeof value === 'object' && value !== null ? JSON.stringify(value) : String(value);
|
|
230
|
+
const max = getMaxLength(column, ctx);
|
|
231
|
+
return max === undefined ? text : umbot_1.Text.resize(text, max);
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Превращает значение (не null и не undefined) в типизированное значение YDB.
|
|
235
|
+
* @param column Имя колонки
|
|
236
|
+
* @param value Значение
|
|
237
|
+
* @param type Тип колонки
|
|
238
|
+
* @param ctx Контекст запроса
|
|
239
|
+
* @returns Значение для параметра запроса
|
|
240
|
+
*/
|
|
241
|
+
function toYdbValue(column, value, type, ctx) {
|
|
242
|
+
switch (type) {
|
|
243
|
+
case 'Utf8':
|
|
244
|
+
return new primitive_1.Text(toText(column, value, ctx));
|
|
245
|
+
case 'Int64':
|
|
246
|
+
return new primitive_1.Int64(toBigInt(column, value));
|
|
247
|
+
case 'Double': {
|
|
248
|
+
const num = Number(value);
|
|
249
|
+
if (!Number.isFinite(num)) {
|
|
250
|
+
throw new YqlBuildError(`Поле "${column}" ожидает число, получено: ${String(value)}`);
|
|
251
|
+
}
|
|
252
|
+
return new primitive_1.Double(num);
|
|
253
|
+
}
|
|
254
|
+
case 'Bool':
|
|
255
|
+
return new primitive_1.Bool(value === true || value === 1 || value === '1' || value === 'true');
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Добавляет значение колонки в параметры запроса.
|
|
260
|
+
* @returns Имя параметра (`$pN`)
|
|
261
|
+
*/
|
|
262
|
+
function bindValue(column, value, ctx, params) {
|
|
263
|
+
const type = getColumnType(column, ctx) ?? inferType(column, value);
|
|
264
|
+
return params.add(toYdbValue(column, value, type, ctx));
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Собирает условие `IN` / `NOT IN` для списка значений.
|
|
268
|
+
*/
|
|
269
|
+
function buildInCondition(column, operator, value, ctx, params) {
|
|
270
|
+
if (!Array.isArray(value)) {
|
|
271
|
+
throw new YqlBuildError(`Оператор "${operator}" для поля "${column}" ожидает массив.`);
|
|
272
|
+
}
|
|
273
|
+
if (!value.length) {
|
|
274
|
+
// Пустой список: IN не совпадает ни с чем, NOT IN — со всем
|
|
275
|
+
return operator === '$in' ? 'FALSE' : 'TRUE';
|
|
276
|
+
}
|
|
277
|
+
if (value.some((item) => item === null || item === undefined)) {
|
|
278
|
+
throw new YqlBuildError(`Оператор "${operator}" для поля "${column}" не принимает null; используйте $null.`);
|
|
279
|
+
}
|
|
280
|
+
const type = getColumnType(column, ctx) ?? inferType(column, value[0]);
|
|
281
|
+
const items = value.map((item) => toYdbValue(column, item, type, ctx));
|
|
282
|
+
const name = params.add(new list_1.List(...items));
|
|
283
|
+
return `${quoteColumn(column)} ${operator === '$in' ? 'IN' : 'NOT IN'} ${name}`;
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Собирает одно условие с оператором (`{ age: { $gt: 18 } }`).
|
|
287
|
+
*/
|
|
288
|
+
function buildOperatorCondition(column, operator, value, ctx, params) {
|
|
289
|
+
const quoted = quoteColumn(column);
|
|
290
|
+
switch (operator) {
|
|
291
|
+
case '$in':
|
|
292
|
+
case '$nin':
|
|
293
|
+
return buildInCondition(column, operator, value, ctx, params);
|
|
294
|
+
case '$null':
|
|
295
|
+
return `${quoted} ${value ? 'IS NULL' : 'IS NOT NULL'}`;
|
|
296
|
+
case '$ne':
|
|
297
|
+
if (value === null) {
|
|
298
|
+
return `${quoted} IS NOT NULL`;
|
|
299
|
+
}
|
|
300
|
+
break;
|
|
301
|
+
}
|
|
302
|
+
if (value === null || value === undefined) {
|
|
303
|
+
throw new YqlBuildError(`Оператор "${operator}" для поля "${column}" не принимает null.`);
|
|
304
|
+
}
|
|
305
|
+
return `${quoted} ${COMPARE_OPERATORS[operator]} ${bindValue(column, value, ctx, params)}`;
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* Собирает условие для одного поля `IQueryData`.
|
|
309
|
+
*/
|
|
310
|
+
function buildFieldCondition(column, value, ctx, params) {
|
|
311
|
+
if (value === undefined) {
|
|
312
|
+
// «Условие», которого нет, для UPDATE/DELETE опаснее ошибки
|
|
313
|
+
throw new YqlBuildError(`Условие для поля "${column}" не задано (undefined).`);
|
|
314
|
+
}
|
|
315
|
+
if (value === null) {
|
|
316
|
+
return [`${quoteColumn(column)} IS NULL`];
|
|
317
|
+
}
|
|
318
|
+
if (Array.isArray(value)) {
|
|
319
|
+
throw new YqlBuildError(`Условие для поля "${column}" — массив. Для списка значений используйте $in.`);
|
|
320
|
+
}
|
|
321
|
+
if (typeof value !== 'object') {
|
|
322
|
+
return [`${quoteColumn(column)} = ${bindValue(column, value, ctx, params)}`];
|
|
323
|
+
}
|
|
324
|
+
const conditions = value;
|
|
325
|
+
const keys = Object.keys(conditions);
|
|
326
|
+
if (!keys.length || !keys.every((key) => key.startsWith('$'))) {
|
|
327
|
+
throw new YqlBuildError(`Условие для поля "${column}" не содержит операторов. Поддерживаются: ${exports.SUPPORTED_OPERATORS.join(', ')}.`);
|
|
328
|
+
}
|
|
329
|
+
return keys.map((key) => {
|
|
330
|
+
if (!exports.SUPPORTED_OPERATORS.includes(key)) {
|
|
331
|
+
throw new YqlBuildError(`Неизвестный оператор "${key}" для поля "${column}". Поддерживаются: ${exports.SUPPORTED_OPERATORS.join(', ')}.`);
|
|
332
|
+
}
|
|
333
|
+
return buildOperatorCondition(column, key, conditions[key], ctx, params);
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* Переводит условия `IQueryData` в выражение WHERE.
|
|
338
|
+
* Скалярное значение — равенство, объект — набор операторов, null — `IS NULL`.
|
|
339
|
+
* @returns Выражение без слова WHERE; пустая строка, если условий нет
|
|
340
|
+
*/
|
|
341
|
+
function buildWhere(where, ctx, params) {
|
|
342
|
+
if (!where) {
|
|
343
|
+
return '';
|
|
344
|
+
}
|
|
345
|
+
const parts = [];
|
|
346
|
+
for (const column of Object.keys(where)) {
|
|
347
|
+
parts.push(...buildFieldCondition(column, where[column], ctx, params));
|
|
348
|
+
}
|
|
349
|
+
return parts.join(' AND ');
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* Поля, заданные в условии простым равенством с не-null значением.
|
|
353
|
+
*/
|
|
354
|
+
function getEqualityKeys(where) {
|
|
355
|
+
const keys = new Set();
|
|
356
|
+
if (where) {
|
|
357
|
+
for (const key of Object.keys(where)) {
|
|
358
|
+
const value = where[key];
|
|
359
|
+
if (value !== null && value !== undefined && typeof value !== 'object') {
|
|
360
|
+
keys.add(key);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
return keys;
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* Выбирает вторичный индекс для SELECT. YDB не использует индекс без явного `VIEW`,
|
|
368
|
+
* поэтому поиск по (platform, path) в таблицах токенов без него шёл бы полным перебором.
|
|
369
|
+
* @returns Имя индекса или undefined, если поиск идёт по первичному ключу или индекса нет
|
|
370
|
+
*/
|
|
371
|
+
function chooseIndex(table, where) {
|
|
372
|
+
if (!table || !table.indexes.length) {
|
|
373
|
+
return undefined;
|
|
374
|
+
}
|
|
375
|
+
const keys = getEqualityKeys(where);
|
|
376
|
+
if (table.primaryKey.every((key) => keys.has(key))) {
|
|
377
|
+
return undefined;
|
|
378
|
+
}
|
|
379
|
+
return table.indexes.find((index) => index.fields.every((field) => keys.has(field)))?.name;
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* Собирает SELECT.
|
|
383
|
+
* @param tableName Имя таблицы
|
|
384
|
+
* @param where Условия выборки
|
|
385
|
+
* @param isOne Нужна только одна запись
|
|
386
|
+
* @param ctx Контекст запроса
|
|
387
|
+
* @returns Запрос
|
|
388
|
+
*/
|
|
389
|
+
function buildSelect(tableName, where, isOne, ctx) {
|
|
390
|
+
const params = new YqlParams();
|
|
391
|
+
const index = chooseIndex(ctx.table, where);
|
|
392
|
+
const view = index ? ` VIEW ${quoteColumn(index)}` : '';
|
|
393
|
+
const condition = buildWhere(where, ctx, params);
|
|
394
|
+
const text = `SELECT * FROM ${quoteTable(tableName)}${view}` +
|
|
395
|
+
(condition ? ` WHERE ${condition}` : '') +
|
|
396
|
+
(isOne ? ' LIMIT 1' : '');
|
|
397
|
+
return { text, params: params.params };
|
|
398
|
+
}
|
|
399
|
+
/**
|
|
400
|
+
* Собирает UPSERT одной записи. Поля со значением undefined пропускаются,
|
|
401
|
+
* null записывается как NULL.
|
|
402
|
+
* @param tableName Имя таблицы
|
|
403
|
+
* @param data Данные записи
|
|
404
|
+
* @param ctx Контекст запроса
|
|
405
|
+
* @returns Запрос
|
|
406
|
+
*/
|
|
407
|
+
function buildUpsert(tableName, data, ctx) {
|
|
408
|
+
const params = new YqlParams();
|
|
409
|
+
const columns = [];
|
|
410
|
+
const values = [];
|
|
411
|
+
for (const column of Object.keys(data)) {
|
|
412
|
+
const value = data[column];
|
|
413
|
+
if (value === undefined) {
|
|
414
|
+
continue;
|
|
415
|
+
}
|
|
416
|
+
columns.push(quoteColumn(column));
|
|
417
|
+
values.push(value === null ? 'NULL' : bindValue(column, value, ctx, params));
|
|
418
|
+
}
|
|
419
|
+
for (const key of ctx.table?.primaryKey ?? []) {
|
|
420
|
+
if (data[key] === undefined || data[key] === null) {
|
|
421
|
+
throw new YqlBuildError(`Не задано поле первичного ключа "${key}" таблицы "${tableName}".`);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
if (!columns.length) {
|
|
425
|
+
throw new YqlBuildError(`Нет данных для записи в таблицу "${tableName}".`);
|
|
426
|
+
}
|
|
427
|
+
const text = `UPSERT INTO ${quoteTable(tableName)} (${columns.join(', ')}) VALUES (${values.join(', ')})`;
|
|
428
|
+
return { text, params: params.params };
|
|
429
|
+
}
|
|
430
|
+
/**
|
|
431
|
+
* Собирает UPDATE. Колонки первичного ключа из SET убираются: YDB не меняет ключ
|
|
432
|
+
* записи, а модель umbot передаёт `uniqueKeys` и в данных, и в условии.
|
|
433
|
+
* @param tableName Имя таблицы
|
|
434
|
+
* @param data Новые значения
|
|
435
|
+
* @param where Условия (обязательны)
|
|
436
|
+
* @param ctx Контекст запроса
|
|
437
|
+
* @returns Запрос или null, если менять нечего
|
|
438
|
+
*/
|
|
439
|
+
function buildUpdate(tableName, data, where, ctx) {
|
|
440
|
+
const params = new YqlParams();
|
|
441
|
+
const primaryKey = ctx.table?.primaryKey ?? [];
|
|
442
|
+
const assignments = [];
|
|
443
|
+
for (const column of Object.keys(data)) {
|
|
444
|
+
const value = data[column];
|
|
445
|
+
if (value === undefined || primaryKey.includes(column)) {
|
|
446
|
+
continue;
|
|
447
|
+
}
|
|
448
|
+
const bound = value === null ? 'NULL' : bindValue(column, value, ctx, params);
|
|
449
|
+
assignments.push(`${quoteColumn(column)} = ${bound}`);
|
|
450
|
+
}
|
|
451
|
+
const condition = buildWhere(where, ctx, params);
|
|
452
|
+
if (!condition) {
|
|
453
|
+
throw new YqlBuildError(`UPDATE таблицы "${tableName}" без условий запрещён.`);
|
|
454
|
+
}
|
|
455
|
+
if (!assignments.length) {
|
|
456
|
+
return null;
|
|
457
|
+
}
|
|
458
|
+
const text = `UPDATE ${quoteTable(tableName)} SET ${assignments.join(', ')} WHERE ${condition}`;
|
|
459
|
+
return { text, params: params.params };
|
|
460
|
+
}
|
|
461
|
+
/**
|
|
462
|
+
* Собирает DELETE.
|
|
463
|
+
* @param tableName Имя таблицы
|
|
464
|
+
* @param where Условия (обязательны: удаление всей таблицы этим методом запрещено)
|
|
465
|
+
* @param ctx Контекст запроса
|
|
466
|
+
* @returns Запрос
|
|
467
|
+
*/
|
|
468
|
+
function buildDelete(tableName, where, ctx) {
|
|
469
|
+
const params = new YqlParams();
|
|
470
|
+
const condition = buildWhere(where, ctx, params);
|
|
471
|
+
if (!condition) {
|
|
472
|
+
throw new YqlBuildError(`DELETE из таблицы "${tableName}" без условий запрещён.`);
|
|
473
|
+
}
|
|
474
|
+
return {
|
|
475
|
+
text: `DELETE FROM ${quoteTable(tableName)} WHERE ${condition}`,
|
|
476
|
+
params: params.params,
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
/**
|
|
480
|
+
* Описание вторичного индекса для CREATE TABLE / ALTER TABLE.
|
|
481
|
+
* Индекс синхронный: запись, только что сохранённая через UPSERT, сразу видна в поиске по индексу.
|
|
482
|
+
*/
|
|
483
|
+
function indexDefinition(index) {
|
|
484
|
+
return `INDEX ${quoteColumn(index.name)} GLOBAL SYNC ON (${index.fields.map(quoteColumn).join(', ')})`;
|
|
485
|
+
}
|
|
486
|
+
/**
|
|
487
|
+
* Собирает CREATE TABLE IF NOT EXISTS. Колонки ключа объявляются NOT NULL.
|
|
488
|
+
* @param table Описание таблицы
|
|
489
|
+
* @returns Текст запроса
|
|
490
|
+
*/
|
|
491
|
+
function buildCreateTable(table) {
|
|
492
|
+
const lines = [];
|
|
493
|
+
for (const [column, type] of table.columns) {
|
|
494
|
+
const notNull = table.primaryKey.includes(column) ? ' NOT NULL' : '';
|
|
495
|
+
lines.push(`${quoteColumn(column)} ${type}${notNull}`);
|
|
496
|
+
}
|
|
497
|
+
for (const index of table.indexes) {
|
|
498
|
+
lines.push(indexDefinition(index));
|
|
499
|
+
}
|
|
500
|
+
lines.push(`PRIMARY KEY (${table.primaryKey.map(quoteColumn).join(', ')})`);
|
|
501
|
+
return `CREATE TABLE IF NOT EXISTS ${quoteTable(table.tableName)} (\n ${lines.join(',\n ')}\n)`;
|
|
502
|
+
}
|
|
503
|
+
/**
|
|
504
|
+
* Собирает запрос-проверку: выполняется без ошибок, только если таблица, все её колонки
|
|
505
|
+
* и индексы уже есть. Данные не читаются (`LIMIT 0`).
|
|
506
|
+
* @param table Описание таблицы
|
|
507
|
+
* @returns Текст запроса (несколько SELECT через `;`)
|
|
508
|
+
*/
|
|
509
|
+
function buildSchemaCheck(table) {
|
|
510
|
+
const name = quoteTable(table.tableName);
|
|
511
|
+
const columns = [...table.columns.keys()].map(quoteColumn).join(', ');
|
|
512
|
+
const statements = [`SELECT ${columns} FROM ${name} LIMIT 0;`];
|
|
513
|
+
for (const index of table.indexes) {
|
|
514
|
+
const fields = index.fields.map(quoteColumn).join(', ');
|
|
515
|
+
statements.push(`SELECT ${fields} FROM ${name} VIEW ${quoteColumn(index.name)} LIMIT 0;`);
|
|
516
|
+
}
|
|
517
|
+
return statements.join('\n');
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* Собирает проверку наличия одной колонки.
|
|
521
|
+
*/
|
|
522
|
+
function buildColumnCheck(tableName, column) {
|
|
523
|
+
return `SELECT ${quoteColumn(column)} FROM ${quoteTable(tableName)} LIMIT 0`;
|
|
524
|
+
}
|
|
525
|
+
/**
|
|
526
|
+
* Собирает ALTER TABLE ... ADD COLUMN.
|
|
527
|
+
*/
|
|
528
|
+
function buildAddColumn(tableName, column, type) {
|
|
529
|
+
return `ALTER TABLE ${quoteTable(tableName)} ADD COLUMN ${quoteColumn(column)} ${type}`;
|
|
530
|
+
}
|
|
531
|
+
/**
|
|
532
|
+
* Собирает проверку наличия индекса.
|
|
533
|
+
*/
|
|
534
|
+
function buildIndexCheck(tableName, index) {
|
|
535
|
+
const fields = index.fields.map(quoteColumn).join(', ');
|
|
536
|
+
return `SELECT ${fields} FROM ${quoteTable(tableName)} VIEW ${quoteColumn(index.name)} LIMIT 0`;
|
|
537
|
+
}
|
|
538
|
+
/**
|
|
539
|
+
* Собирает ALTER TABLE ... ADD INDEX.
|
|
540
|
+
*/
|
|
541
|
+
function buildAddIndex(tableName, index) {
|
|
542
|
+
return `ALTER TABLE ${quoteTable(tableName)} ADD ${indexDefinition(index)}`;
|
|
543
|
+
}
|
|
544
|
+
/**
|
|
545
|
+
* Экранирует строку для подстановки внутрь строкового литерала YQL (`'...'` или `"..."`).
|
|
546
|
+
* Адаптер сам так не делает — все значения уходят параметрами; функция нужна тем,
|
|
547
|
+
* кто собирает свой запрос через `model.escapeString()`.
|
|
548
|
+
* @param value Значение
|
|
549
|
+
* @returns Строка с экранированными обратным слешем, кавычками и управляющими символами
|
|
550
|
+
*/
|
|
551
|
+
function escapeYqlString(value) {
|
|
552
|
+
return String(value).replace(/[\\'"\n\r\0]/g, (char) => {
|
|
553
|
+
switch (char) {
|
|
554
|
+
case '\n':
|
|
555
|
+
return '\\n';
|
|
556
|
+
case '\r':
|
|
557
|
+
return '\\r';
|
|
558
|
+
case '\0':
|
|
559
|
+
return '\\x00';
|
|
560
|
+
default:
|
|
561
|
+
return '\\' + char;
|
|
562
|
+
}
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
/**
|
|
566
|
+
* Приводит значение из ответа YDB к виду, который понимают модели umbot:
|
|
567
|
+
* Int64 приходит как bigint, а JSON.stringify и сравнения с числами на нём ломаются.
|
|
568
|
+
* @param row Строка результата
|
|
569
|
+
* @returns Строка с bigint, переведёнными в number (или в строку, если не помещаются)
|
|
570
|
+
*/
|
|
571
|
+
function normalizeRow(row) {
|
|
572
|
+
const result = {};
|
|
573
|
+
for (const key of Object.keys(row)) {
|
|
574
|
+
const value = row[key];
|
|
575
|
+
if (typeof value === 'bigint') {
|
|
576
|
+
const num = Number(value);
|
|
577
|
+
result[key] = Number.isSafeInteger(num) ? num : value.toString();
|
|
578
|
+
}
|
|
579
|
+
else {
|
|
580
|
+
result[key] = value;
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
return result;
|
|
584
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "umbot-ydb-adapter",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "YDB (Yandex Database) adapter for the umbot framework: storage for Alice skills and chat bots in Yandex Cloud Functions without a separate database server.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"umbot",
|
|
7
|
+
"universal-bot",
|
|
8
|
+
"ydb",
|
|
9
|
+
"yandex-database",
|
|
10
|
+
"yandex-cloud",
|
|
11
|
+
"cloud-functions",
|
|
12
|
+
"serverless",
|
|
13
|
+
"alice",
|
|
14
|
+
"alisa",
|
|
15
|
+
"database",
|
|
16
|
+
"db-adapter",
|
|
17
|
+
"typescript",
|
|
18
|
+
"bot-framework"
|
|
19
|
+
],
|
|
20
|
+
"author": {
|
|
21
|
+
"name": "Maxim-M",
|
|
22
|
+
"email": "maximco36895@yandex.ru",
|
|
23
|
+
"url": "https://github.com/max36895"
|
|
24
|
+
},
|
|
25
|
+
"license": "MIT",
|
|
26
|
+
"main": "./dist/index.js",
|
|
27
|
+
"types": "./dist/index.d.ts",
|
|
28
|
+
"type": "commonjs",
|
|
29
|
+
"homepage": "https://github.com/max36895/umbot-ydb-adapter#readme",
|
|
30
|
+
"bugs": {
|
|
31
|
+
"url": "https://github.com/max36895/umbot-ydb-adapter/issues"
|
|
32
|
+
},
|
|
33
|
+
"repository": {
|
|
34
|
+
"type": "git",
|
|
35
|
+
"url": "https://github.com/max36895/umbot-ydb-adapter.git"
|
|
36
|
+
},
|
|
37
|
+
"scripts": {
|
|
38
|
+
"build": "tsc",
|
|
39
|
+
"test": "jest",
|
|
40
|
+
"test:coverage": "jest --coverage",
|
|
41
|
+
"bt": "npm run build && npm run test",
|
|
42
|
+
"live": "npm run build && node scripts/live-check.js",
|
|
43
|
+
"typecheck": "tsc --noEmit && tsc -p tsconfig.test.json",
|
|
44
|
+
"lint": "eslint .",
|
|
45
|
+
"lint:fix": "eslint . --fix",
|
|
46
|
+
"prettier": "prettier --write .",
|
|
47
|
+
"prettier:check": "prettier --check .",
|
|
48
|
+
"check": "npm run prettier:check && npm run typecheck && npm run lint",
|
|
49
|
+
"prepublishOnly": "npm run build",
|
|
50
|
+
"deploy": "npm run build && npm publish"
|
|
51
|
+
},
|
|
52
|
+
"engines": {
|
|
53
|
+
"node": ">=20.19.0"
|
|
54
|
+
},
|
|
55
|
+
"files": [
|
|
56
|
+
"dist",
|
|
57
|
+
"README.md",
|
|
58
|
+
"LICENSE.md"
|
|
59
|
+
],
|
|
60
|
+
"peerDependencies": {
|
|
61
|
+
"umbot": "^3.1.4"
|
|
62
|
+
},
|
|
63
|
+
"dependencies": {
|
|
64
|
+
"@ydbjs/auth": "^6.3.1",
|
|
65
|
+
"@ydbjs/core": "^6.3.1",
|
|
66
|
+
"@ydbjs/query": "^6.3.0",
|
|
67
|
+
"@ydbjs/value": "^6.0.8"
|
|
68
|
+
},
|
|
69
|
+
"devDependencies": {
|
|
70
|
+
"@eslint/js": "^10.0.1",
|
|
71
|
+
"@types/jest": "^30.0.0",
|
|
72
|
+
"@types/node": "^20.19.30",
|
|
73
|
+
"eslint": "^10.2.0",
|
|
74
|
+
"eslint-config-prettier": "^10.1.8",
|
|
75
|
+
"eslint-plugin-security": "^4.0.0",
|
|
76
|
+
"globals": "^17.4.0",
|
|
77
|
+
"jest": "^30.3.0",
|
|
78
|
+
"prettier": "^3.8.1",
|
|
79
|
+
"ts-jest": "^29.4.9",
|
|
80
|
+
"typescript": "^5.9.3",
|
|
81
|
+
"typescript-eslint": "^8.60.0",
|
|
82
|
+
"umbot": "^3.1.4"
|
|
83
|
+
},
|
|
84
|
+
"funding": {
|
|
85
|
+
"type": "individual",
|
|
86
|
+
"url": "https://boosty.to/max36895/donate"
|
|
87
|
+
},
|
|
88
|
+
"maintainers": [
|
|
89
|
+
{
|
|
90
|
+
"name": "max36895",
|
|
91
|
+
"email": "maximco36895@yandex.ru",
|
|
92
|
+
"url": "https://github.com/max36895"
|
|
93
|
+
}
|
|
94
|
+
]
|
|
95
|
+
}
|