@cuboapp/api-backend 3.0.0 → 3.0.1
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/package.json +1 -1
- package/src/core/index.ts +76 -36
- package/src/helpers/index.ts +101 -1
- package/src/types/filters.ts +50 -0
- package/src/types/index.ts +1 -0
package/package.json
CHANGED
package/src/core/index.ts
CHANGED
|
@@ -60,11 +60,13 @@ export class CuboBackendApi<T extends CuboApiEntitiesMap<T>, A extends unknown =
|
|
|
60
60
|
// @cuboapp/crdt (и yjs) — опциональная зависимость: грузим лениво только если
|
|
61
61
|
// включён crdt, чтобы проекты без CRDT не тянули yjs.
|
|
62
62
|
if (this.options.crdt?.ws) {
|
|
63
|
-
let createCrdtServer: typeof import('@cuboapp/crdt')['createCrdtServer']
|
|
63
|
+
let createCrdtServer: (typeof import('@cuboapp/crdt'))['createCrdtServer']
|
|
64
64
|
try {
|
|
65
65
|
;({ createCrdtServer } = await import('@cuboapp/crdt'))
|
|
66
66
|
} catch (e) {
|
|
67
|
-
throw new Error(
|
|
67
|
+
throw new Error(
|
|
68
|
+
"[API-BACKEND] опция 'crdt' требует пакета '@cuboapp/crdt' (и 'yjs'). Установите их: npm i @cuboapp/crdt @cuboapp/ws yjs"
|
|
69
|
+
)
|
|
68
70
|
}
|
|
69
71
|
|
|
70
72
|
this.crdt = new CuboCrdtAdapter<T, A>(this, this.options.crdt.ws, createCrdtServer, this.options.crdt.debug)
|
|
@@ -138,6 +140,7 @@ export class CuboBackendApi<T extends CuboApiEntitiesMap<T>, A extends unknown =
|
|
|
138
140
|
): Promise<CuboCrudGetManyResponse<T[K]>> {
|
|
139
141
|
opts = opts || {}
|
|
140
142
|
req = { ...(req || {}), query: cloneDeep(req?.query || {}) }
|
|
143
|
+
this.helpers.applyDataFilters(req.query)
|
|
141
144
|
|
|
142
145
|
const entity = await this.getEntity(entityAlias)
|
|
143
146
|
const augmentation = this.options?.augmentations?.[entityAlias]
|
|
@@ -194,6 +197,7 @@ export class CuboBackendApi<T extends CuboApiEntitiesMap<T>, A extends unknown =
|
|
|
194
197
|
): Promise<T[K] | undefined> {
|
|
195
198
|
opts = opts || {}
|
|
196
199
|
req = { ...(req || {}), query: cloneDeep(req?.query || {}) }
|
|
200
|
+
this.helpers.applyDataFilters(req.query)
|
|
197
201
|
|
|
198
202
|
const entity = await this.getEntity(entityAlias)
|
|
199
203
|
const augmentation = this.options?.augmentations?.[entityAlias]
|
|
@@ -262,21 +266,30 @@ export class CuboBackendApi<T extends CuboApiEntitiesMap<T>, A extends unknown =
|
|
|
262
266
|
}
|
|
263
267
|
|
|
264
268
|
const db = await this.helpers.getConnection('write')
|
|
265
|
-
const [created_id] = await db.create(entity.alias, dto, {
|
|
266
|
-
transaction: opts.transaction,
|
|
267
|
-
log: opts.log,
|
|
268
|
-
pk_key: this.pkKey,
|
|
269
|
-
pk_type: this.pkType
|
|
270
|
-
})
|
|
271
|
-
if (!created_id) {
|
|
272
|
-
throw { status: 400, text: 'Unable to create entity' }
|
|
273
|
-
}
|
|
274
269
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
270
|
+
// INSERT + read-back в одной транзакции: getOne-хуки на re-select'е теперь
|
|
271
|
+
// срабатывают; если beforeGetOne «спрячет» свежесозданную строку (или
|
|
272
|
+
// afterGetOne вернёт пусто), throw откатит INSERT — без «осиротевшей» записи.
|
|
273
|
+
const run = async (tx: any): Promise<T[K]> => {
|
|
274
|
+
const [created_id] = await db.create(entity.alias, dto, {
|
|
275
|
+
transaction: tx,
|
|
276
|
+
log: opts!.log,
|
|
277
|
+
pk_key: this.pkKey,
|
|
278
|
+
pk_type: this.pkType
|
|
279
|
+
})
|
|
280
|
+
if (!created_id) {
|
|
281
|
+
throw { status: 400, text: 'Unable to create entity' }
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const created = await this.getOne<K>(entityAlias, { query: { id: created_id } }, this.readBackOpts({ ...opts, transaction: tx }))
|
|
285
|
+
if (!created) {
|
|
286
|
+
throw { status: 400, text: 'Unable to find entity after create' }
|
|
287
|
+
}
|
|
288
|
+
return created as T[K]
|
|
278
289
|
}
|
|
279
290
|
|
|
291
|
+
let response = opts.transaction ? await run(opts.transaction) : await this.withTransaction(run)
|
|
292
|
+
|
|
280
293
|
if (augmentation?.afterCreate && !this.excluded(opts, 'afterCreate')) {
|
|
281
294
|
response = await augmentation.afterCreate({ ...this.ctx(entity, req, opts, queryOptions), row: response })
|
|
282
295
|
}
|
|
@@ -350,18 +363,27 @@ export class CuboBackendApi<T extends CuboApiEntitiesMap<T>, A extends unknown =
|
|
|
350
363
|
}
|
|
351
364
|
|
|
352
365
|
const db = await this.helpers.getConnection('write')
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
366
|
+
const id = (item as any).id
|
|
367
|
+
|
|
368
|
+
// UPDATE + read-back в одной транзакции: getOne-хуки на re-select'е теперь
|
|
369
|
+
// срабатывают; если они «спрячут» обновлённую строку, throw откатит UPDATE.
|
|
370
|
+
const run = async (tx: any): Promise<T[K]> => {
|
|
371
|
+
await db.update(entity.alias, 'id = :id', { id }, dto, {
|
|
372
|
+
transaction: tx,
|
|
373
|
+
log: opts!.log,
|
|
374
|
+
pk_key: this.pkKey,
|
|
375
|
+
pk_type: this.pkType
|
|
376
|
+
})
|
|
359
377
|
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
378
|
+
const updated = await this.getOne<K>(entityAlias, { query: { id } }, this.readBackOpts({ ...opts, transaction: tx }))
|
|
379
|
+
if (!updated) {
|
|
380
|
+
throw { status: 400, text: 'Unable to find entity after update' }
|
|
381
|
+
}
|
|
382
|
+
return updated as T[K]
|
|
363
383
|
}
|
|
364
384
|
|
|
385
|
+
let response = opts.transaction ? await run(opts.transaction) : await this.withTransaction(run)
|
|
386
|
+
|
|
365
387
|
if (augmentation?.afterUpdate && !this.excluded(opts, 'afterUpdate')) {
|
|
366
388
|
response = await augmentation.afterUpdate({ ...this.ctx(entity, req, opts, queryOptions), row: response, prev: prev as T[K] })
|
|
367
389
|
}
|
|
@@ -410,12 +432,17 @@ export class CuboBackendApi<T extends CuboApiEntitiesMap<T>, A extends unknown =
|
|
|
410
432
|
}
|
|
411
433
|
|
|
412
434
|
const db = await this.helpers.getConnection('write')
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
435
|
+
const id = (item as any).id
|
|
436
|
+
|
|
437
|
+
const runDelete = (tx: any) =>
|
|
438
|
+
db.update(entity.alias, 'id = :id', { id }, dto, {
|
|
439
|
+
transaction: tx,
|
|
440
|
+
log: opts!.log,
|
|
441
|
+
pk_key: this.pkKey,
|
|
442
|
+
pk_type: this.pkType
|
|
443
|
+
})
|
|
444
|
+
|
|
445
|
+
await (opts.transaction ? runDelete(opts.transaction) : this.withTransaction(runDelete))
|
|
419
446
|
|
|
420
447
|
if (this.options.hooks?.onAfterDelete && !this.excluded(opts, 'baseHooks')) {
|
|
421
448
|
await this.options.hooks.onAfterDelete(entity, item as T[K], opts)
|
|
@@ -608,14 +635,20 @@ export class CuboBackendApi<T extends CuboApiEntitiesMap<T>, A extends unknown =
|
|
|
608
635
|
return this.helpers.getConnection(opts.transaction ? 'write' : 'read')
|
|
609
636
|
}
|
|
610
637
|
|
|
611
|
-
/**
|
|
638
|
+
/**
|
|
639
|
+
* Опции для внутренних read-back'ов: без наследования queryOptions.
|
|
640
|
+
* getOne-хуки чтения (beforeGetOne/afterGetOne) ВКЛЮЧЕНЫ — они должны срабатывать
|
|
641
|
+
* на select/re-select внутри createOne/updateOne/deleteOne (запись+read-back идут
|
|
642
|
+
* в одной транзакции, поэтому «спрятавший» строку beforeGetOne откатит запись).
|
|
643
|
+
* getMany-хуки подавляются — они нужны только для batch read-back'ов.
|
|
644
|
+
*/
|
|
612
645
|
private readBackOpts(opts: CuboCrudMethodOptions): CuboCrudMethodOptions {
|
|
613
646
|
return {
|
|
614
647
|
transaction: opts.transaction,
|
|
615
648
|
performer_id: opts.performer_id,
|
|
616
649
|
auth: opts.auth,
|
|
617
650
|
log: opts.log,
|
|
618
|
-
excludeHooks: ['
|
|
651
|
+
excludeHooks: ['beforeGetMany', 'afterGetMany']
|
|
619
652
|
}
|
|
620
653
|
}
|
|
621
654
|
|
|
@@ -664,11 +697,18 @@ export class CuboBackendApi<T extends CuboApiEntitiesMap<T>, A extends unknown =
|
|
|
664
697
|
const replacements: Record<string, any> = {}
|
|
665
698
|
|
|
666
699
|
const valuesSql = dtos
|
|
667
|
-
.map(
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
700
|
+
.map(
|
|
701
|
+
(dto, i) =>
|
|
702
|
+
'(' +
|
|
703
|
+
cols
|
|
704
|
+
.map((c) => {
|
|
705
|
+
const key = `b_${i}_${c}`
|
|
706
|
+
replacements[key] = dto[c] ?? null
|
|
707
|
+
return `:${key}`
|
|
708
|
+
})
|
|
709
|
+
.join(', ') +
|
|
710
|
+
')'
|
|
711
|
+
)
|
|
672
712
|
.join(', ')
|
|
673
713
|
|
|
674
714
|
let sql = `insert into ${entity.alias} (${cols.join(', ')}) values ${valuesSql}`
|
package/src/helpers/index.ts
CHANGED
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
CUBO_CRUD_QUERY_SYMBOL_NOT
|
|
14
14
|
} from '../constants'
|
|
15
15
|
import { CuboDialect, getDialect } from '../dialects'
|
|
16
|
-
import { CuboBackendApiDbConnectionType, CuboCrudFindQuery, CuboCrudRequest } from '../types'
|
|
16
|
+
import { CuboBackendApiDbConnectionType, CuboCrudFindQuery, CuboCrudRequest, CuboDataFilter, CuboDataFiltersQuery } from '../types'
|
|
17
17
|
|
|
18
18
|
import { ApiHelpersConvert } from './convert'
|
|
19
19
|
import { ApiHelpersData } from './data'
|
|
@@ -316,6 +316,106 @@ export class ApiHelpers<T extends CuboApiEntitiesMap<T>, A = unknown> {
|
|
|
316
316
|
}
|
|
317
317
|
}
|
|
318
318
|
|
|
319
|
+
/**
|
|
320
|
+
* Разворачивает структурный query-параметр `filters` (JSON вида
|
|
321
|
+
* `{"filters":[{"field","op","value"}],"sort":[{"field","dir"}]}`) в обычные
|
|
322
|
+
* flat-DSL записи `req.query`. Дальше их подхватывает общий пайплайн
|
|
323
|
+
* (createFindQuery + withes): вложенные связи по dot-notation, приведение типов
|
|
324
|
+
* и сортировка работают без дополнительного кода. Мутирует и возвращает `query`.
|
|
325
|
+
*
|
|
326
|
+
* Ограничения наследуются от flat-DSL: одно условие на поле (при нескольких
|
|
327
|
+
* фильтрах на одно поле побеждает последний) и значения без `:` (двоеточие —
|
|
328
|
+
* разделитель условий). Для экзотики — `queryOptions.raw`.
|
|
329
|
+
*/
|
|
330
|
+
public applyDataFilters(query: Record<string, any>): Record<string, any> {
|
|
331
|
+
const raw = query?.filters
|
|
332
|
+
if (raw === undefined || raw === null || raw === '') {
|
|
333
|
+
return query
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
let parsed: CuboDataFiltersQuery
|
|
337
|
+
try {
|
|
338
|
+
parsed = typeof raw === 'string' ? JSON.parse(raw) : raw
|
|
339
|
+
} catch {
|
|
340
|
+
throw { status: 400, text: 'Invalid "filters" query param: must be valid JSON' }
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// убираем сырой ключ, чтобы пайплайн не принял "filters" за поле-условие
|
|
344
|
+
delete query.filters
|
|
345
|
+
|
|
346
|
+
for (const filter of parsed?.filters || []) {
|
|
347
|
+
if (!filter || !filter.field) {
|
|
348
|
+
continue
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const value = this.dataFilterToQueryValue(filter)
|
|
352
|
+
if (value !== undefined) {
|
|
353
|
+
query[filter.field] = value
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
if (parsed?.sort?.length) {
|
|
358
|
+
const sort = parsed.sort
|
|
359
|
+
.filter((s) => s && s.field)
|
|
360
|
+
.map((s) => (s.dir === 'desc' ? '-' : '') + s.field)
|
|
361
|
+
.join(',')
|
|
362
|
+
|
|
363
|
+
if (sort) {
|
|
364
|
+
query.sort = sort
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
return query
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** Маппинг `CuboDataFilter.op` -> flat-DSL значение движка (`ilike:foo`, `not:gt:5`, `in:1,2`, ...). */
|
|
372
|
+
private dataFilterToQueryValue(filter: CuboDataFilter): string | undefined {
|
|
373
|
+
const C = CUBO_CRUD_QUERY_CONDITION
|
|
374
|
+
const NOT = CUBO_CRUD_QUERY_SYMBOL_NOT
|
|
375
|
+
const v = filter.value
|
|
376
|
+
const list = () => (Array.isArray(v) ? v.join(',') : `${v}`)
|
|
377
|
+
|
|
378
|
+
switch (filter.op) {
|
|
379
|
+
case 'eq':
|
|
380
|
+
return `${v}`
|
|
381
|
+
case 'ne':
|
|
382
|
+
return `${NOT}:${v}`
|
|
383
|
+
case 'contains':
|
|
384
|
+
return `${C.ILIKE}:${v}`
|
|
385
|
+
case 'notContains':
|
|
386
|
+
return `${NOT}:${C.ILIKE}:${v}`
|
|
387
|
+
case 'like':
|
|
388
|
+
return `${C.LIKE}:${v}`
|
|
389
|
+
case 'ilike':
|
|
390
|
+
return `${C.ILIKE}:${v}`
|
|
391
|
+
case 'gt':
|
|
392
|
+
return `${C.GT}:${v}`
|
|
393
|
+
case 'gte':
|
|
394
|
+
return `${C.GTE}:${v}`
|
|
395
|
+
case 'lt':
|
|
396
|
+
return `${C.LT}:${v}`
|
|
397
|
+
case 'lte':
|
|
398
|
+
return `${C.LTE}:${v}`
|
|
399
|
+
case 'in':
|
|
400
|
+
return `${C.IN}:${list()}`
|
|
401
|
+
case 'nin':
|
|
402
|
+
return `${C.NIN}:${list()}`
|
|
403
|
+
case 'between': {
|
|
404
|
+
const [from, to] = Array.isArray(v) ? v : `${v}`.split(':')
|
|
405
|
+
if (from === undefined || to === undefined) {
|
|
406
|
+
throw { status: 400, text: `Filter "${filter.field}": "between" requires [from, to]` }
|
|
407
|
+
}
|
|
408
|
+
return `${C.BTW}:${from}:${to}`
|
|
409
|
+
}
|
|
410
|
+
case 'isNull':
|
|
411
|
+
return 'null'
|
|
412
|
+
case 'isNotNull':
|
|
413
|
+
return `${NOT}:null`
|
|
414
|
+
default:
|
|
415
|
+
throw { status: 400, text: `Unsupported filter op "${(filter as any).op}" for "${filter.field}"` }
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
319
419
|
public async dropConnection(type: CuboBackendApiDbConnectionType) {
|
|
320
420
|
if (this.connections[type]) {
|
|
321
421
|
await this.connections[type].disconnect()
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Структурированные фильтры из query-параметра `filters` (передаётся JSON-строкой).
|
|
3
|
+
* Формат совместим со слоем data-view в @cuboapp/ui: массив `{ field, op, value }`
|
|
4
|
+
* + сортировка `{ field, dir }`.
|
|
5
|
+
*
|
|
6
|
+
* Пример query-параметра (URL-encoded):
|
|
7
|
+
* filters={"filters":[{"field":"name","op":"contains","value":"asdf"}],"sort":[{"field":"name","dir":"asc"}]}
|
|
8
|
+
*
|
|
9
|
+
* Транслируется в существующий flat-DSL движка (см. `ApiHelpers.applyDataFilters`),
|
|
10
|
+
* поэтому работает и для вложенных полей (dot-notation, напр. `company.name`), и с
|
|
11
|
+
* тем же приведением типов, что и обычные query-условия.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export type CuboDataFilterOp =
|
|
15
|
+
| 'eq'
|
|
16
|
+
| 'ne'
|
|
17
|
+
| 'contains'
|
|
18
|
+
| 'notContains'
|
|
19
|
+
| 'like'
|
|
20
|
+
| 'ilike'
|
|
21
|
+
| 'gt'
|
|
22
|
+
| 'gte'
|
|
23
|
+
| 'lt'
|
|
24
|
+
| 'lte'
|
|
25
|
+
| 'in'
|
|
26
|
+
| 'nin'
|
|
27
|
+
| 'between'
|
|
28
|
+
| 'isNull'
|
|
29
|
+
| 'isNotNull'
|
|
30
|
+
|
|
31
|
+
export type CuboDataFilter = {
|
|
32
|
+
/** Имя поля; поддерживается dot-notation для вложенных связей (`company.name`). */
|
|
33
|
+
field: string
|
|
34
|
+
op: CuboDataFilterOp
|
|
35
|
+
/** Значение условия. Для `in`/`nin` — массив, для `between` — `[from, to]`, для `isNull`/`isNotNull` — игнорируется. */
|
|
36
|
+
value?: any
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type CuboDataSortDir = 'asc' | 'desc'
|
|
40
|
+
|
|
41
|
+
export type CuboDataSort = {
|
|
42
|
+
field: string
|
|
43
|
+
dir?: CuboDataSortDir
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Полезная нагрузка query-параметра `filters`. */
|
|
47
|
+
export type CuboDataFiltersQuery = {
|
|
48
|
+
filters?: CuboDataFilter[]
|
|
49
|
+
sort?: CuboDataSort[]
|
|
50
|
+
}
|