From 08fca90eee1896a1bea96b3fedfce18d0c60b9e4 Mon Sep 17 00:00:00 2001 From: Artem Savchenko Date: Mon, 13 Apr 2026 11:11:54 +0700 Subject: [PATCH] Fix SQL operator does not exist Signed-off-by: Artem Savchenko --- .../postgres/src/__tests__/query-sql.spec.ts | 105 ++++++++++++++++++ .../server/packages/postgres/src/storage.ts | 40 ++++++- 2 files changed, 142 insertions(+), 3 deletions(-) create mode 100644 foundations/server/packages/postgres/src/__tests__/query-sql.spec.ts diff --git a/foundations/server/packages/postgres/src/__tests__/query-sql.spec.ts b/foundations/server/packages/postgres/src/__tests__/query-sql.spec.ts new file mode 100644 index 0000000000..68b7817c67 --- /dev/null +++ b/foundations/server/packages/postgres/src/__tests__/query-sql.spec.ts @@ -0,0 +1,105 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { + type DocumentQuery, + Hierarchy, + MeasureMetricsContext, + ModelDb, + type Ref, + type Space, + type WorkspaceUuid +} from '@hcengineering/core' +import { ConnectionMgr } from '@hcengineering/postgres-base' +import { PostgresAdapter } from '../storage' +import { genMinModel, test, type ComplexClass } from './minmodel' +import { createDummyClient, type TypedQuery } from './utils' + +function createAdapterWithQueryCapture (): { + adapter: PostgresAdapter + ctx: MeasureMetricsContext + queries: TypedQuery[] +} { + const queries: TypedQuery[] = [] + const client = createDummyClient(queries) + const minModel = genMinModel() + const hierarchy = new Hierarchy() + for (const tx of minModel) { + hierarchy.tx(tx) + } + const modelDb = new ModelDb(hierarchy) + const ctx = new MeasureMetricsContext('query-sql-test', {}) + modelDb.addTxes(ctx, minModel, true) + const adapter = new PostgresAdapter( + client, + new ConnectionMgr(client), + { + url: () => 'test', + close: () => {} + }, + 'workspace' as WorkspaceUuid, + hierarchy, + modelDb, + 'test' + ) + return { adapter, ctx, queries } +} + +describe('PostgresAdapter findAll SQL (top-level $and, empty $in)', () => { + it('does not treat $and as a JSON data path (security-style merged query)', async () => { + const { adapter, ctx, queries } = createAdapterWithQueryCapture() + const docId = '6968889052c59ef292857a08' as Ref + const noIds: Ref[] = [] + const noSpaces: Ref[] = [] + const query: DocumentQuery = { + $and: [{ _id: docId }, { _id: { $in: noIds } }], + space: { $in: noSpaces } + } + + await adapter.findAll(ctx, test.class.ComplexClass, query) + + expect(queries.length).toBeGreaterThan(0) + const sql = queries[queries.length - 1]?.query ?? '' + expect(sql).not.toMatch(/data#>>'\{\$and\}'/) + expect(sql).not.toMatch(/#>>'\{\$and\}'/) + expect(sql).toContain('FALSE') + expect(sql).not.toContain("IN ('NULL')") + expect(sql).toMatch(/_id/) + }) + + it("uses FALSE for empty $in on a column field (not IN ('NULL'))", async () => { + const { adapter, ctx, queries } = createAdapterWithQueryCapture() + + const noSpaces: Ref[] = [] + const q: DocumentQuery = { space: { $in: noSpaces } } + await adapter.findAll(ctx, test.class.ComplexClass, q) + + const sql = queries[queries.length - 1]?.query ?? '' + expect(sql).toContain('FALSE') + expect(sql).not.toContain("IN ('NULL')") + }) + + it('uses FALSE for empty $in on _id alone', async () => { + const { adapter, ctx, queries } = createAdapterWithQueryCapture() + + const noIds: Ref[] = [] + const q: DocumentQuery = { _id: { $in: noIds } } + await adapter.findAll(ctx, test.class.ComplexClass, q) + + const sql = queries[queries.length - 1]?.query ?? '' + expect(sql).toContain('FALSE') + expect(sql).not.toContain("IN ('NULL')") + }) +}) diff --git a/foundations/server/packages/postgres/src/storage.ts b/foundations/server/packages/postgres/src/storage.ts index c2fec29622..8fbf16d3bf 100644 --- a/foundations/server/packages/postgres/src/storage.ts +++ b/foundations/server/packages/postgres/src/storage.ts @@ -1094,12 +1094,29 @@ abstract class PostgresAdapterBase implements DbAdapter { joins: JoinProps[], options?: ServerFindOptions ): string { - const res: string[] = [] const query = { ..._query } + const res: string[] = [] res.push(`${baseDomain}."workspaceId" = ${vars.add(this.workspaceId, '::uuid')}`) if (options?.skipClass !== true) { query._class = this.fillClass(_class, query) as any } + res.push(...this.buildQueryClauses(vars, _class, baseDomain, query, joins, options)) + return res.join(' AND ') + } + + /** + * Builds AND fragments for a query object (workspace filter is handled in {@link buildQuery}). + * Supports top-level `$and` (e.g. from security middleware) as nested conjunctions. + */ + private buildQueryClauses( + vars: ValuesVariables, + _class: Ref>, + baseDomain: string, + query: DocumentQuery, + joins: JoinProps[], + options?: ServerFindOptions + ): string[] { + const res: string[] = [] for (const _key in query) { if (options?.skipSpace === true && _key === 'space') { continue @@ -1109,6 +1126,23 @@ abstract class PostgresAdapterBase implements DbAdapter { } const value = query[_key] if (value === undefined) continue + + if (_key === '$and') { + if (!Array.isArray(value)) continue + for (const rawSub of value as DocumentQuery[]) { + if (rawSub == null || typeof rawSub !== 'object') continue + const sub: DocumentQuery = { ...rawSub } + if (sub._class === undefined && query._class !== undefined) { + ;(sub as any)._class = query._class + } + const nested = this.buildQueryClauses(vars, _class, baseDomain, sub, joins, options) + if (nested.length > 0) { + res.push(`(${nested.join(' AND ')})`) + } + } + continue + } + const key = escape(_key) const valueType = this.getValueType(_class, key) const tkey = this.getKey(_class, baseDomain, key, joins, valueType === 'dataArray') @@ -1117,7 +1151,7 @@ abstract class PostgresAdapterBase implements DbAdapter { res.push(translated) } } - return res.join(' AND ') + return res } private getValueType(_class: Ref>, key: string): ValueType { @@ -1335,7 +1369,7 @@ abstract class PostgresAdapterBase implements DbAdapter { if (val.length > 0) { res.push(`${tlkey} = ANY(${vars.addArray(val, valType)})`) } else { - res.push(`${tlkey} IN ('NULL')`) + res.push('FALSE') } } break