UBERF-9560: Filter query fixes

Signed-off-by: Andrey Sobolev <haiodo@gmail.com>
This commit is contained in:
Andrey Sobolev 2025-03-07 00:15:04 +07:00
parent 8eeef589f8
commit b8c27cc0a6
No known key found for this signature in database
GPG Key ID: BD80F68D68D8F7F2
3 changed files with 129 additions and 30 deletions

View File

@ -11,7 +11,7 @@ import core, {
type WorkspaceUuid
} from '@hcengineering/core'
import { PostgresAdapter } from '../storage'
import { convertArrayParams, decodeArray } from '../utils'
import { convertArrayParams, decodeArray, filterProjection } from '../utils'
import { genMinModel, test, type ComplexClass } from './minmodel'
import { createDummyClient, type TypedQuery } from './utils'
@ -160,3 +160,47 @@ function createTestContext (): { adapter: PostgresAdapter, ctx: MeasureMetricsCo
)
return { adapter, ctx, queries }
}
describe('projection', () => {
it('mixin query projection', () => {
const data = {
'638611f18894c91979399ef3': {
Источник_6386125d8894c91979399eff: 'Workable'
},
attachments: 1,
avatar: null,
avatarProps: null,
avatarType: 'color',
channels: 3,
city: 'Poland',
docUpdateMessages: 31,
name: 'Mulkuha,Muklyi',
'notification:mixin:Collaborators': {
collaborators: []
},
'recruit:mixin:Candidate': {
Title_63f38419efefd99805238bbd: 'Backend-RoR',
Trash_64493626f9b50e77bf82d231: 'Нет',
__mixin: 'true',
applications: 1,
onsite: null,
remote: null,
skills: 18,
title: '',
Опытработы_63860d5c8894c91979399e73: '2018',
Уровеньанглийского_63860d038894c91979399e6f: 'UPPER'
}
}
const projected = filterProjection<any>(data, {
'recruit:mixin:Candidate.Уровеньанглийского_63860d038894c91979399e6f': 1,
_class: 1,
space: 1,
modifiedOn: 1
})
expect(projected).toEqual({
'recruit:mixin:Candidate': {
Уровеньанглийского_63860d038894c91979399e6f: 'UPPER'
}
})
})
})

View File

@ -1305,7 +1305,11 @@ abstract class PostgresAdapterBase implements DbAdapter {
private translateQueryValue (vars: ValuesVariables, tkey: string, value: any, type: ValueType): string | undefined {
const tkeyData = tkey.includes('data') && (tkey.includes('->') || tkey.includes('#>>'))
if (tkeyData && (Array.isArray(value) || (typeof value !== 'object' && typeof value !== 'string'))) {
value = Array.isArray(value) ? value.map((it) => (it == null ? null : `${it}`)) : `${value}`
value = Array.isArray(value)
? value.map((it) => (it == null ? null : `${it}`))
: value == null
? null
: `${value}`
}
if (value === null) {
@ -1316,51 +1320,58 @@ abstract class PostgresAdapterBase implements DbAdapter {
for (const operator in value) {
let val = value[operator]
if (tkeyData && (Array.isArray(val) || (typeof val !== 'object' && typeof val !== 'string'))) {
val = Array.isArray(val) ? val.map((it) => (it == null ? null : `${it}`)) : `${val}`
val = Array.isArray(val) ? val.map((it) => (it == null ? null : `${it}`)) : val == null ? null : `${val}`
}
let valType = inferType(val)
const { tlkey, arrowCount } = prepareJsonValue(tkey, valType)
if (arrowCount > 0 && valType === '::text') {
valType = ''
}
switch (operator) {
case '$ne':
if (val === null) {
res.push(`${tkey} IS NOT NULL`)
if (val == null) {
res.push(`${tlkey} IS NOT NULL`)
} else {
res.push(`${tkey} != ${vars.add(val, inferType(val))}`)
res.push(`${tlkey} != ${vars.add(val, valType)}`)
}
break
case '$gt':
res.push(`${tkey} > ${vars.add(val, inferType(val))}`)
res.push(`${tlkey} > ${vars.add(val, valType)}`)
break
case '$gte':
res.push(`${tkey} >= ${vars.add(val, inferType(val))}`)
res.push(`${tlkey} >= ${vars.add(val, valType)}`)
break
case '$lt':
res.push(`${tkey} < ${vars.add(val, inferType(val))}`)
res.push(`${tlkey} < ${vars.add(val, valType)}`)
break
case '$lte':
res.push(`${tkey} <= ${vars.add(val, inferType(val))}`)
res.push(`${tlkey} <= ${vars.add(val, valType)}`)
break
case '$in':
switch (type) {
case 'common':
if (Array.isArray(val) && val.includes(null)) {
const vv = vars.addArray(val, inferType(val))
res.push(`(${tkey} = ANY(${vv}) OR ${tkey} IS NULL)`)
const vv = vars.addArray(val, valType)
res.push(`(${tlkey} = ANY(${vv}) OR ${tkey} IS NULL)`)
} else {
if (val.length > 0) {
res.push(`${tkey} = ANY(${vars.addArray(val, inferType(val))})`)
res.push(`${tlkey} = ANY(${vars.addArray(val, valType)})`)
} else {
res.push(`${tkey} IN ('NULL')`)
res.push(`${tlkey} IN ('NULL')`)
}
}
break
case 'array':
{
const vv = vars.addArrayI(val, inferType(val))
const vv = vars.addArrayI(val, valType)
res.push(`${tkey} && ${vv}`)
}
break
case 'dataArray':
{
const vv = vars.addArrayI(val, inferType(val))
const vv = vars.addArrayI(val, valType)
res.push(`${tkey} ?| ${vv}`)
}
break
@ -1368,24 +1379,28 @@ abstract class PostgresAdapterBase implements DbAdapter {
break
case '$nin':
if (Array.isArray(val) && val.includes(null)) {
res.push(`(${tkey} != ALL(${vars.addArray(val, inferType(val))}) AND ${tkey} IS NOT NULL)`)
res.push(`(${tlkey} != ALL(${vars.addArray(val, valType)}) AND ${tkey} IS NOT NULL)`)
} else if (Array.isArray(val) && val.length > 0) {
res.push(`${tkey} != ALL(${vars.addArray(val, inferType(val))})`)
res.push(`${tlkey} != ALL(${vars.addArray(val, valType)})`)
}
break
case '$like':
res.push(`${tkey} ILIKE ${vars.add(val, inferType(val))}`)
res.push(`${tlkey} ILIKE ${vars.add(val, valType)}`)
break
case '$exists':
res.push(`${tkey} IS ${val === true || val === 'true' ? 'NOT NULL' : 'NULL'}`)
res.push(`${tlkey} IS ${val === true || val === 'true' ? 'NOT NULL' : 'NULL'}`)
break
case '$regex':
res.push(`${tkey} SIMILAR TO ${vars.add(val, inferType(val))}`)
res.push(`${tlkey} SIMILAR TO ${vars.add(val, valType)}`)
break
case '$options':
break
case '$all':
res.push(`${tkey} @> ${vars.addArray(value, inferType(value))}`)
if (arrowCount > 0) {
res.push(`${tkey} @> '${JSON.stringify(val)}'::jsonb`)
} else {
res.push(`${tkey} @> ${vars.addArray(val, valType)}`)
}
break
default:
res.push(`${tkey} @> '[${JSON.stringify(value)}]'`)
@ -1395,8 +1410,13 @@ abstract class PostgresAdapterBase implements DbAdapter {
return res.length === 0 ? undefined : res.join(' AND ')
}
let valType = inferType(value)
const { tlkey, arrowCount } = prepareJsonValue(tkey, valType)
if (arrowCount > 0 && valType === '::text') {
valType = ''
}
return type === 'common'
? `${tkey} = ${vars.add(value, inferType(value))}`
? `${tlkey} = ${vars.add(value, valType)}`
: type === 'array'
? `${tkey} @> '${typeof value === 'string' ? '{"' + value + '"}' : value}'`
: `${tkey} @> '${typeof value === 'string' ? '"' + value + '"' : value}'`
@ -2083,6 +2103,21 @@ class PostgresTxAdapter extends PostgresAdapterBase implements TxAdapter {
return this.stripHash(systemTx.concat(userTx)) as Tx[]
}
}
function prepareJsonValue (tkey: string, valType: string): { tlkey: string, arrowCount: number } {
if (valType === '::string') {
valType = '' // No need to add a string conversion
}
const arrowCount = (tkey.match(/->/g) ?? []).length
// We need to convert to type without array if pressent
let tlkey = arrowCount > 0 ? `(${tkey})${valType.replace('[]', '')}` : tkey
if (arrowCount > 0) {
// We need to replace only the last -> to ->>
tlkey = arrowCount === 1 ? tlkey.replace('->', '->>') : tlkey.replace(/->(?!.*->)/, '->>')
}
return { tlkey, arrowCount }
}
/**
* @public
*/

View File

@ -553,6 +553,30 @@ export function convertArrayParams (parameters?: ParameterOrJSON<any>[]): any[]
})
}
export function filterProjection<T extends Doc> (data: any, projection: Projection<T> | undefined): any {
for (const key in data) {
if (!Object.prototype.hasOwnProperty.call(projection, key) || (projection as any)[key] === 0) {
// check nested projections in case of object
let value = data[key]
if (typeof value === 'object' && !Array.isArray(value) && value != null) {
// We need to filter projection for nested objects
const innerP = Object.entries(projection as any)
.filter((it) => it[0].startsWith(key))
.map((it) => [it[0].substring(key.length + 1), it[1]])
if (innerP.length > 0) {
value = filterProjection(value, Object.fromEntries(innerP))
data[key] = value
continue
}
}
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete data[key]
}
}
return data
}
export function parseDocWithProjection<T extends Doc> (
doc: DBDoc,
domain: string,
@ -574,16 +598,12 @@ export function parseDocWithProjection<T extends Doc> (
;(rest as any)[key] = decodeArray((rest as any)[key])
}
}
let resultData = data
if (projection !== undefined) {
for (const key in data) {
if (!Object.prototype.hasOwnProperty.call(projection, key) || (projection as any)[key] === 0) {
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete data[key]
}
}
resultData = filterProjection(data, projection)
}
const res = {
...data,
...resultData,
...rest
} as any as T