mirror of
https://github.com/hcengineering/platform.git
synced 2026-09-09 11:17:43 +02:00
Fix SQL operator does not exist
Signed-off-by: Artem Savchenko <armisav@gmail.com>
This commit is contained in:
@@ -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<ComplexClass>
|
||||
const noIds: Ref<ComplexClass>[] = []
|
||||
const noSpaces: Ref<Space>[] = []
|
||||
const query: DocumentQuery<ComplexClass> = {
|
||||
$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<Space>[] = []
|
||||
const q: DocumentQuery<ComplexClass> = { 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<ComplexClass>[] = []
|
||||
const q: DocumentQuery<ComplexClass> = { _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')")
|
||||
})
|
||||
})
|
||||
@@ -1094,12 +1094,29 @@ abstract class PostgresAdapterBase implements DbAdapter {
|
||||
joins: JoinProps[],
|
||||
options?: ServerFindOptions<T>
|
||||
): 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<T extends Doc>(
|
||||
vars: ValuesVariables,
|
||||
_class: Ref<Class<T>>,
|
||||
baseDomain: string,
|
||||
query: DocumentQuery<T>,
|
||||
joins: JoinProps[],
|
||||
options?: ServerFindOptions<T>
|
||||
): 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<T>[]) {
|
||||
if (rawSub == null || typeof rawSub !== 'object') continue
|
||||
const sub: DocumentQuery<T> = { ...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<T extends Doc>(_class: Ref<Class<T>>, 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
|
||||
|
||||
Reference in New Issue
Block a user