From 3ff80a095cdd9827a77eaa25818d9bfb6d8fd6ae Mon Sep 17 00:00:00 2001 From: Denis Bykhov Date: Wed, 23 Jul 2025 18:15:34 +0500 Subject: [PATCH] $size query (#9591) Signed-off-by: Denis Bykhov --- models/view/src/plugin.ts | 2 - packages/core/src/predicate.ts | 24 +++ packages/core/src/storage.ts | 15 ++ plugins/process-assets/lang/en.json | 7 +- plugins/process-assets/lang/es.json | 7 +- plugins/process-assets/lang/fr.json | 7 +- plugins/process-assets/lang/it.json | 7 +- plugins/process-assets/lang/ja.json | 7 +- plugins/process-assets/lang/pt.json | 7 +- plugins/process-assets/lang/ru.json | 7 +- plugins/process-assets/lang/zh.json | 7 +- .../components/criterias/ArrayCriteria.svelte | 147 ++++++++++++++---- plugins/view-resources/src/plugin.ts | 9 +- server/postgres/src/__tests__/minmodel.ts | 12 ++ server/postgres/src/__tests__/storage.test.ts | 18 ++- server/postgres/src/__tests__/tasks.ts | 13 +- server/postgres/src/storage.ts | 25 +++ 17 files changed, 274 insertions(+), 47 deletions(-) diff --git a/models/view/src/plugin.ts b/models/view/src/plugin.ts index 3d5eb35376..450ff6e8ef 100644 --- a/models/view/src/plugin.ts +++ b/models/view/src/plugin.ts @@ -61,8 +61,6 @@ export default mergeIds(viewId, view, { HyperlinkEditorPopup: '' as AnyComponent, IntlStringPresenter: '' as AnyComponent, FileSizePresenter: '' as AnyComponent, - NumberEditor: '' as AnyComponent, - NumberPresenter: '' as AnyComponent, MarkupDiffPresenter: '' as AnyComponent, MarkupPresenter: '' as AnyComponent, BooleanPresenter: '' as AnyComponent, diff --git a/packages/core/src/predicate.ts b/packages/core/src/predicate.ts index daccd7d373..4fbdc47dd1 100644 --- a/packages/core/src/predicate.ts +++ b/packages/core/src/predicate.ts @@ -110,6 +110,30 @@ const predicates: Record = { $ne: (o, propertyKey) => { // eslint-disable-next-line eqeqeq return (docs) => execPredicate(docs, propertyKey, (value) => (o != null ? !deepEqual(o, value) : value != null)) + }, + $size: (o, propertyKey) => { + return (docs) => + execPredicate(docs, propertyKey, (value) => { + if (!Array.isArray(value)) { + throw new Error('$size predicate requires array') + } + if (typeof o === 'number') { + return value.length === o + } + if (typeof o === 'object' && o.$gt !== undefined) { + return value.length > o.$gt + } + if (typeof o === 'object' && o.$gte !== undefined) { + return value.length >= o.$gte + } + if (typeof o === 'object' && o.$lt !== undefined) { + return value.length < o.$lt + } + if (typeof o === 'object' && o.$lte !== undefined) { + return value.length <= o.$lte + } + return false + }) } } diff --git a/packages/core/src/storage.ts b/packages/core/src/storage.ts index 4f0b69348c..b66cf9c88f 100644 --- a/packages/core/src/storage.ts +++ b/packages/core/src/storage.ts @@ -19,6 +19,20 @@ import type { KeysByType } from 'simplytyped' import type { Association, AttachedDoc, Class, Doc, Domain, Ref, Space } from './classes' import type { Tx } from './tx' +export type ArraySizeSelector = + | { + $gt: number + } + | { + $lt: number + } + | { + $gte: number + } + | { + $lte: number + } + /** * @public */ @@ -36,6 +50,7 @@ export type QuerySelector = { $like?: string $regex?: string $options?: string + $size?: T extends Array ? number | ArraySizeSelector : never } /** diff --git a/plugins/process-assets/lang/en.json b/plugins/process-assets/lang/en.json index 461fab3c4c..4205d7b199 100644 --- a/plugins/process-assets/lang/en.json +++ b/plugins/process-assets/lang/en.json @@ -91,7 +91,12 @@ "Started": "Started", "Each": "Each", "CreateCard": "Create card", - "OnCardUpdate": "On card update" + "OnCardUpdate": "On card update", + "ArraySizeEquals": "Size equals", + "ArraySizeGt": "Size greater than", + "ArraySizeGte": "Size greater than or equal", + "ArraySizeLt": "Size less than", + "ArraySizeLte": "Size less than or equal" }, "error": { "MethodNotFound": "Method not found: {methodId}", diff --git a/plugins/process-assets/lang/es.json b/plugins/process-assets/lang/es.json index df33aec237..3d0187f1f9 100644 --- a/plugins/process-assets/lang/es.json +++ b/plugins/process-assets/lang/es.json @@ -91,7 +91,12 @@ "Started": "Iniciado", "Each": "Cada", "CreateCard": "Crear tarjeta", - "OnCardUpdate": "Al actualizar la tarjeta" + "OnCardUpdate": "Al actualizar la tarjeta", + "ArraySizeEquals": "Tamaño igual a", + "ArraySizeGt": "Tamaño mayor que", + "ArraySizeGte": "Tamaño mayor o igual que", + "ArraySizeLt": "Tamaño menor que", + "ArraySizeLte": "Tamaño menor o igual que" }, "error": { "MethodNotFound": "Método no encontrado: {methodId}", diff --git a/plugins/process-assets/lang/fr.json b/plugins/process-assets/lang/fr.json index c028c85cf2..8d1d46696d 100644 --- a/plugins/process-assets/lang/fr.json +++ b/plugins/process-assets/lang/fr.json @@ -91,7 +91,12 @@ "Started": "Démarré", "Each": "Chaque", "CreateCard": "Créer une carte", - "OnCardUpdate": "Lors de la mise à jour de la carte" + "OnCardUpdate": "À la modification de la carte", + "ArraySizeEquals": "Taille égale à", + "ArraySizeGt": "Taille supérieure à", + "ArraySizeGte": "Taille supérieure ou égale à", + "ArraySizeLt": "Taille inférieure à", + "ArraySizeLte": "Taille inférieure ou égale à" }, "error": { "MethodNotFound": "Méthode introuvable : {methodId}", diff --git a/plugins/process-assets/lang/it.json b/plugins/process-assets/lang/it.json index 55f13ca6ae..5e28a7b14d 100644 --- a/plugins/process-assets/lang/it.json +++ b/plugins/process-assets/lang/it.json @@ -91,7 +91,12 @@ "Started": "Avviato", "Each": "Ogni", "CreateCard": "Crea scheda", - "OnCardUpdate": "Alla modifica della scheda" + "OnCardUpdate": "Alla modifica della scheda", + "ArraySizeEquals": "Dimensione uguale a", + "ArraySizeGt": "Dimensione maggiore di", + "ArraySizeGte": "Dimensione maggiore o uguale a", + "ArraySizeLt": "Dimensione minore di", + "ArraySizeLte": "Dimensione minore o uguale a" }, "error": { "MethodNotFound": "Metodo non trovato: {methodId}", diff --git a/plugins/process-assets/lang/ja.json b/plugins/process-assets/lang/ja.json index 094ca2c8b9..2f09bcd193 100644 --- a/plugins/process-assets/lang/ja.json +++ b/plugins/process-assets/lang/ja.json @@ -90,7 +90,12 @@ "Started": "開始", "Each": "各", "CreateCard": "カードを作成", - "OnCardUpdate": "カード更新時" + "OnCardUpdate": "カード更新時", + "ArraySizeEquals": "サイズが等しい", + "ArraySizeGt": "サイズがより大きい", + "ArraySizeGte": "サイズが以上", + "ArraySizeLt": "サイズがより小さい", + "ArraySizeLte": "サイズが以下" }, "error": { "MethodNotFound": "メソッドが見つかりません: {methodId}", diff --git a/plugins/process-assets/lang/pt.json b/plugins/process-assets/lang/pt.json index e872621fcf..377a37faf6 100644 --- a/plugins/process-assets/lang/pt.json +++ b/plugins/process-assets/lang/pt.json @@ -91,7 +91,12 @@ "Started": "Iniciado", "Each": "Cada", "CreateCard": "Criar Cartão", - "OnCardUpdate": "Ao atualizar o cartão" + "OnCardUpdate": "Ao atualizar o cartão", + "ArraySizeEquals": "Tamanho igual a", + "ArraySizeGt": "Tamanho maior que", + "ArraySizeGte": "Tamanho maior ou igual a", + "ArraySizeLt": "Tamanho menor que", + "ArraySizeLte": "Tamanho menor ou igual a" }, "error": { "MethodNotFound": "Método não encontrado: {methodId}", diff --git a/plugins/process-assets/lang/ru.json b/plugins/process-assets/lang/ru.json index 4e1cf9416a..2c5c14936b 100644 --- a/plugins/process-assets/lang/ru.json +++ b/plugins/process-assets/lang/ru.json @@ -91,7 +91,12 @@ "Started": "Начат", "Each": "Каждый", "CreateCard": "Создать карточку", - "OnCardUpdate": "При обновлении карточки" + "OnCardUpdate": "При обновлении карточки", + "ArraySizeEquals": "Размер равен", + "ArraySizeGt": "Размер больше чем", + "ArraySizeGte": "Размер больше или равен", + "ArraySizeLt": "Размер меньше чем", + "ArraySizeLte": "Размер меньше или равен" }, "error": { "MethodNotFound": "Метод не найден: {methodId}", diff --git a/plugins/process-assets/lang/zh.json b/plugins/process-assets/lang/zh.json index ce33237741..00a0469a95 100644 --- a/plugins/process-assets/lang/zh.json +++ b/plugins/process-assets/lang/zh.json @@ -91,7 +91,12 @@ "Started": "已启动", "Each": "每个", "CreateCard": "创建卡片", - "OnCardUpdate": "在卡片更新时" + "OnCardUpdate": "在卡片更新时", + "ArraySizeEquals": "大小等于", + "ArraySizeGt": "大小大于", + "ArraySizeGte": "大小大于等于", + "ArraySizeLt": "大小小于", + "ArraySizeLte": "大小小于等于" }, "error": { "MethodNotFound": "找不到方法:{methodId}", diff --git a/plugins/process-resources/src/components/criterias/ArrayCriteria.svelte b/plugins/process-resources/src/components/criterias/ArrayCriteria.svelte index 243940489a..eabdfe4a5e 100644 --- a/plugins/process-resources/src/components/criterias/ArrayCriteria.svelte +++ b/plugins/process-resources/src/components/criterias/ArrayCriteria.svelte @@ -15,6 +15,7 @@
@@ -124,44 +171,78 @@ width={'100%'} on:selected={(e) => { mode = e.detail - buildResult() + buildResult(val) }} />
- {#if contextValue} + {#if !isSize(mode)} + {#if contextValue} + { + onSelect(e.detail) + }} + /> + {:else} +
+ {#if baseEditor} + + {/if} +
+ {/if} + {:else if contextValue} { onSelect(e.detail) }} /> - {:else} + {:else if !Number.isNaN(val)}
- {#if baseEditor} - - {/if} +
{/if}
diff --git a/plugins/view-resources/src/plugin.ts b/plugins/view-resources/src/plugin.ts index d2985ff9e3..f3457fc8ab 100644 --- a/plugins/view-resources/src/plugin.ts +++ b/plugins/view-resources/src/plugin.ts @@ -31,7 +31,9 @@ export default mergeIds(viewId, view, { ProxyPresenter: '' as AnyComponent, ArrayEditor: '' as AnyComponent, SpaceTypeSelector: '' as AnyComponent, - MasterDetailBrowser: '' as AnyComponent + MasterDetailBrowser: '' as AnyComponent, + NumberEditor: '' as AnyComponent, + NumberPresenter: '' as AnyComponent }, string: { Contains: '' as IntlString, @@ -62,6 +64,11 @@ export default mergeIds(viewId, view, { FilterUpdated: '' as IntlString, FilterLessThan: '' as IntlString, FilterGreaterThan: '' as IntlString, + ArraySizeEquals: '' as IntlString, + ArraySizeGt: '' as IntlString, + ArraySizeGte: '' as IntlString, + ArraySizeLt: '' as IntlString, + ArraySizeLte: '' as IntlString, Before: '' as IntlString, After: '' as IntlString, Apply: '' as IntlString, diff --git a/server/postgres/src/__tests__/minmodel.ts b/server/postgres/src/__tests__/minmodel.ts index 2089d852ae..2432eac880 100644 --- a/server/postgres/src/__tests__/minmodel.ts +++ b/server/postgres/src/__tests__/minmodel.ts @@ -15,6 +15,7 @@ import core, { type AccountUuid, + type AnyAttribute, type Arr, type AttachedDoc, type Class, @@ -43,6 +44,10 @@ export function createClass (_class: Ref>, attributes: Data): TxCreateDoc { + return txFactory.createTxCreateDoc(core.class.Attribute, core.space.Model, attribute) +} + /** * @public */ @@ -115,6 +120,13 @@ export function genMinModel (): TxCUD[] { txes.push( createClass(core.class.Doc, { label: 'Doc' as IntlString, extends: core.class.Obj, kind: ClassifierKind.CLASS }) ) + txes.push( + createClass(core.class.Attribute, { + label: 'Attribute' as IntlString, + extends: core.class.Doc, + kind: ClassifierKind.CLASS + }) + ) txes.push( createClass(core.class.Relation, { label: 'Relation' as IntlString, diff --git a/server/postgres/src/__tests__/storage.test.ts b/server/postgres/src/__tests__/storage.test.ts index 4efad9985e..524d6d1e19 100644 --- a/server/postgres/src/__tests__/storage.test.ts +++ b/server/postgres/src/__tests__/storage.test.ts @@ -154,7 +154,8 @@ describe('postgres operations', () => { await operations.createDoc(taskPlugin.class.Task, '' as Ref, { name: `my-task-${i}`, description: `${i * i}`, - rate: 20 + i + rate: 20 + i, + arr: new Array(i).fill(i) }) } @@ -169,6 +170,21 @@ describe('postgres operations', () => { const third = await client.findAll(taskPlugin.class.Task, { rate: { $in: [25, 26, 27, 28] } }) expect(third.length).toEqual(4) + + const size = await client.findAll(taskPlugin.class.Task, { arr: { $size: 5 } }) + expect(size.length).toEqual(1) + + const sizeGt = await client.findAll(taskPlugin.class.Task, { arr: { $size: { $gt: 45 } } }) + expect(sizeGt.length).toEqual(4) + + const sizeGte = await client.findAll(taskPlugin.class.Task, { arr: { $size: { $gte: 45 } } }) + expect(sizeGte.length).toEqual(5) + + const sizeLt = await client.findAll(taskPlugin.class.Task, { arr: { $size: { $lt: 45 } } }) + expect(sizeLt.length).toEqual(45) + + const sizeLte = await client.findAll(taskPlugin.class.Task, { arr: { $size: { $lte: 45 } } }) + expect(sizeLte.length).toEqual(46) }) it('check update', async () => { diff --git a/server/postgres/src/__tests__/tasks.ts b/server/postgres/src/__tests__/tasks.ts index 773b9c0521..7b7c9064ef 100644 --- a/server/postgres/src/__tests__/tasks.ts +++ b/server/postgres/src/__tests__/tasks.ts @@ -1,4 +1,4 @@ -import { +import core, { type AttachedDoc, type Class, ClassifierKind, @@ -11,7 +11,7 @@ import { type Tx } from '@hcengineering/core' import { type IntlString, plugin, type Plugin } from '@hcengineering/platform' -import { createClass } from './minmodel' +import { createAttribute, createClass } from './minmodel' export interface TaskComment extends AttachedDoc { message: string @@ -107,6 +107,15 @@ export function createTaskModel (txes: Tx[]): void { kind: ClassifierKind.CLASS, label: 'Comment' as IntlString, domain: 'test-task' as Domain + }), + createAttribute({ + attributeOf: taskPlugin.class.Task, + name: 'arr', + type: { + _class: core.class.ArrOf, + label: 'arr' as IntlString, + type: core.class.TypeNumber + } }) ) } diff --git a/server/postgres/src/storage.ts b/server/postgres/src/storage.ts index 2bcbe924e7..aab59f08c1 100644 --- a/server/postgres/src/storage.ts +++ b/server/postgres/src/storage.ts @@ -1263,6 +1263,31 @@ abstract class PostgresAdapterBase implements DbAdapter { res.push(`${tkey} @> ${vars.addArray(val, valType)}`) } break + case '$size': { + let v = val + let op = '=' + if (typeof val === 'object') { + if (val.$gt !== undefined) { + v = val.$gt + op = '>' + } else if (val.$gte !== undefined) { + v = val.$gte + op = '>=' + } else if (val.$lt !== undefined) { + v = val.$lt + op = '<' + } else if (val.$lte !== undefined) { + v = val.$lte + op = '<=' + } + } + if (type === 'dataArray') { + res.push(`coalesce(jsonb_array_length(${tkey}), 0) ${op} ${vars.add(v, '::integer')}`) + } else { + res.push(`array_length(${tkey}, 1) ${op} ${vars.add(v, '::integer')}`) + } + break + } default: nonOperator[operator] = value[operator] break