@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cuboapp/api-backend",
3
- "version": "3.0.0",
3
+ "version": "3.0.1",
4
4
  "description": "Backend Api for CuboApp",
5
5
  "main": "src/index.ts",
6
6
  "repository": "git@github.com:cuboapp/api-backend.git",
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("[API-BACKEND] опция 'crdt' требует пакета '@cuboapp/crdt' (и 'yjs'). Установите их: npm i @cuboapp/crdt @cuboapp/ws yjs")
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
- let response = await this.getOne<K>(entityAlias, { query: { id: created_id } }, this.readBackOpts(opts))
276
- if (!response) {
277
- throw { status: 400, text: 'Unable to find entity after create' }
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
- await db.update(entity.alias, 'id = :id', { id: (item as any).id }, dto, {
354
- transaction: opts.transaction,
355
- log: opts.log,
356
- pk_key: this.pkKey,
357
- pk_type: this.pkType
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
- let response = await this.getOne<K>(entityAlias, { query: { id: (item as any).id } }, this.readBackOpts(opts))
361
- if (!response) {
362
- throw { status: 400, text: 'Unable to find entity after update' }
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
- await db.update(entity.alias, 'id = :id', { id: (item as any).id }, dto, {
414
- transaction: opts.transaction,
415
- log: opts.log,
416
- pk_key: this.pkKey,
417
- pk_type: this.pkType
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
- /** Опции для внутренних read-back'ов: без наследования queryOptions и без хуков чтения. */
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: ['beforeGetOne', 'afterGetOne', 'beforeGetMany', 'afterGetMany']
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((dto, i) => '(' + cols.map((c) => {
668
- const key = `b_${i}_${c}`
669
- replacements[key] = dto[c] ?? null
670
- return `:${key}`
671
- }).join(', ') + ')')
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}`
@@ -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
+ }
@@ -8,6 +8,7 @@ import { CuboCrudMethodOptions } from './db'
8
8
 
9
9
  export * from './basic'
10
10
  export * from './db'
11
+ export * from './filters'
11
12
 
12
13
  export type CuboBackendApiOptions<T extends CuboApiEntitiesMap<T>, A> = {
13
14
  api?: {