diff --git a/foundations/core/packages/core/src/__tests__/memdb.test.ts b/foundations/core/packages/core/src/__tests__/memdb.test.ts index 9d4f3b17170..ae17e9ef2c9 100644 --- a/foundations/core/packages/core/src/__tests__/memdb.test.ts +++ b/foundations/core/packages/core/src/__tests__/memdb.test.ts @@ -14,7 +14,7 @@ // import { type Client, type DomainParams, type DomainRequestOptions, type DomainResult } from '..' -import type { Class, Doc, Obj, OperationDomain, Ref } from '../classes' +import type { Class, Doc, Obj, OperationDomain, Ref, Space } from '../classes' import core from '../component' import { Hierarchy } from '../hierarchy' import { ModelDb, TxDb } from '../memdb' @@ -293,6 +293,155 @@ describe('memdb', () => { expect(result2).toHaveLength(1) }) + it('check associations', async () => { + const { model } = await createModel() + const operations = new TxOperations(model, core.account.System) + const association = await operations.findOne(core.class.Association, {}) + if (association == null) { + throw new Error('Association not found') + } + + const spaces = await operations.findAll(core.class.Space, {}) + expect(spaces).toHaveLength(2) + + const first = await operations.addCollection( + test.class.TestComment, + core.space.Model, + spaces[0]._id, + spaces[0]._class, + 'comments', + { + message: 'msg' + } + ) + + const second = await operations.addCollection( + test.class.TestComment, + core.space.Model, + first, + test.class.TestComment, + 'comments', + { + message: 'msg2' + } + ) + + await operations.createDoc(core.class.Relation, '' as Ref, { + docA: first, + docB: second, + association: association._id + }) + + const r = await operations.findAll( + test.class.TestComment, + { _id: first }, + { + associations: [[association._id, 1]] + } + ) + expect(r.length).toEqual(1) + expect((r[0].$associations?.[association._id + '_b'][0] as any)?._id).toEqual(second) + }) + + it('check deep associations', async () => { + const { model } = await createModel() + const operations = new TxOperations(model, core.account.System) + const association = await operations.findOne(core.class.Association, {}) + if (association == null) { + throw new Error('Association not found') + } + + const spaces = await operations.findAll(core.class.Space, {}) + expect(spaces).toHaveLength(2) + + const zero = await operations.addCollection( + test.class.TestComment, + core.space.Model, + spaces[0]._id, + spaces[0]._class, + 'comments', + { + message: 'msg' + } + ) + + const first = await operations.addCollection( + test.class.TestComment, + core.space.Model, + spaces[0]._id, + spaces[0]._class, + 'comments', + { + message: 'msg' + } + ) + + const second = await operations.addCollection( + test.class.TestComment, + core.space.Model, + first, + test.class.TestComment, + 'comments', + { + message: 'msg2' + } + ) + + const second2 = await operations.addCollection( + test.class.TestComment, + core.space.Model, + first, + test.class.TestComment, + 'comments', + { + message: 'msg2' + } + ) + + const third = await operations.addCollection( + test.class.TestComment, + core.space.Model, + spaces[0]._id, + spaces[0]._class, + 'comments', + { + message: 'msg3' + } + ) + await operations.createDoc(core.class.Relation, '' as Ref, { + docA: first, + docB: second, + association: association._id + }) + + await operations.createDoc(core.class.Relation, '' as Ref, { + docA: first, + docB: second2, + association: association._id + }) + + await operations.createDoc(core.class.Relation, '' as Ref, { + docA: second, + docB: third, + association: association._id + }) + + const r = await operations.findAll( + test.class.TestComment, + { _id: { $in: [zero, first] } }, + { + associations: [[association._id, 1, [[association._id, 1]]]] + } + ) + expect(r.length).toEqual(2) + expect(r[1].$associations?.[`${association._id}_b`]).toHaveLength(2) + expect((r[1].$associations?.[`${association._id}_b`][0] as any)?._id).toEqual(second) + expect(r[1].$associations?.[`${association._id}_b`][1]?.$associations?.[`${association._id}_b`]).toHaveLength(0) + expect( + (r[1].$associations?.[`${association._id}_b`][0]?.$associations?.[`${association._id}_b`][0] as any)?._id + ).toEqual(third) + }) + it('lookups', async () => { const { model } = await createModel() diff --git a/foundations/core/packages/core/src/__tests__/minmodel.ts b/foundations/core/packages/core/src/__tests__/minmodel.ts index cf9f4931260..de03e2a8acf 100644 --- a/foundations/core/packages/core/src/__tests__/minmodel.ts +++ b/foundations/core/packages/core/src/__tests__/minmodel.ts @@ -16,7 +16,7 @@ import type { IntlString, Plugin } from '@hcengineering/platform' import { plugin } from '@hcengineering/platform' import type { Arr, AttachedDoc, Class, Data, Doc, Interface, Mixin, Obj, Ref, Space } from '../classes' -import { ClassifierKind, DOMAIN_MODEL } from '../classes' +import { ClassifierKind, DOMAIN_MODEL, DOMAIN_RELATION } from '../classes' import core from '../component' import type { DocumentUpdate, TxCUD, TxCreateDoc, TxRemoveDoc, TxUpdateDoc } from '../tx' import { DOMAIN_TX, TxFactory } from '../tx' @@ -252,6 +252,34 @@ export function genMinModel (): TxCUD[] { }) ) + txes.push( + createClass(core.class.Relation, { + label: 'Relation' as IntlString, + extends: core.class.Doc, + kind: ClassifierKind.CLASS, + domain: DOMAIN_RELATION + }) + ) + + txes.push( + createClass(core.class.Association, { + label: 'Association' as IntlString, + extends: core.class.Doc, + kind: ClassifierKind.CLASS, + domain: DOMAIN_MODEL + }) + ) + + txes.push( + createDoc(core.class.Association, { + nameA: 'my-assoc', + nameB: 'my-assoc', + classA: test.class.TestComment, + classB: test.class.TestComment, + type: '1:1' + }) + ) + txes.push( createDoc(core.class.Space, { name: 'Sp1', diff --git a/foundations/core/packages/core/src/memdb.ts b/foundations/core/packages/core/src/memdb.ts index 9feddf02015..29cdb088913 100644 --- a/foundations/core/packages/core/src/memdb.ts +++ b/foundations/core/packages/core/src/memdb.ts @@ -159,7 +159,7 @@ export abstract class MemDb extends TxProcessor implements Storage { private async getAssociationValue( doc: T, associations: AssociationQuery[] - ): Promise> { + ): Promise[]>> { const result: Record = {} for (const association of associations) { const _id = association[0] @@ -170,8 +170,11 @@ export abstract class MemDb extends TxProcessor implements Storage { const key2 = !isReverse ? 'docB' : 'docA' const _class = !isReverse ? assoc.classB : assoc.classA const relations = await this.findAll(core.class.Relation, { association: _id, [key]: doc._id }) - const objects = await this.findAll(_class, { _id: { $in: relations.map((r) => r[key2]) } }) - result[_id] = objects + let objects = await this.findAll(_class, { _id: { $in: relations.map((r) => r[key2]) } }) + if (association[2] !== undefined) { + objects = toFindResult(await this.fillAssociations(objects, association[2]), objects.length) + } + result[`${_id}_${!isReverse ? 'b' : 'a'}`] = objects } return result } diff --git a/foundations/core/packages/core/src/storage.ts b/foundations/core/packages/core/src/storage.ts index a97a84b076e..66f6c3c2866 100644 --- a/foundations/core/packages/core/src/storage.ts +++ b/foundations/core/packages/core/src/storage.ts @@ -128,7 +128,7 @@ export type Projection = { [P in keyof T]?: 0 | 1 } -export type AssociationQuery = [Ref, 1 | -1] +export type AssociationQuery = [Ref, 1 | -1] | [Ref, 1 | -1, AssociationQuery[]] /** * @public @@ -205,7 +205,7 @@ export type LookupData = Partial> */ export type WithLookup = T & { $lookup?: LookupData - $associations?: Record + $associations?: Record[]> $source?: { $score: number // Score for document result [key: string]: any diff --git a/foundations/core/packages/query/src/index.ts b/foundations/core/packages/query/src/index.ts index 1ebe6bb6620..165c990f66a 100644 --- a/foundations/core/packages/query/src/index.ts +++ b/foundations/core/packages/query/src/index.ts @@ -780,7 +780,9 @@ export class LiveQuery implements WithTx, Client { } const docs = q.result.getDocs() for (const doc of docs) { - const docToUpdate = doc.$associations?.[association._id]?.find((it) => it._id === tx.objectId) + const docToUpdate = + doc.$associations?.[`${association._id}_a`]?.find((it) => it._id === tx.objectId) ?? + doc.$associations?.[`${association._id}_b`]?.find((it) => it._id === tx.objectId) if (docToUpdate !== undefined) { if (tx._class === core.class.TxMixin) { TxProcessor.updateMixin4Doc(docToUpdate, tx as TxMixin) @@ -1128,12 +1130,13 @@ export class LiveQuery implements WithTx, Client { _id: direct ? relation.docB : relation.docA }) if (docToPush === undefined) return - const arr = res?.$associations?.[relation.association] ?? [] - arr.push(docToPush) if (res?.$associations === undefined) { res.$associations = {} } - res.$associations[relation.association] = arr + const key = direct ? 'b' : 'a' + const arr = res?.$associations?.[`${relation.association}_${key}`] ?? [] + arr.push(docToPush) + res.$associations[`${relation.association}_${key}`] = arr q.result.updateDoc(res, false) this.queriesToUpdate.set(q.id, q) } diff --git a/foundations/server/packages/core/src/__tests__/shared-integration.ts b/foundations/server/packages/core/src/__tests__/shared-integration.ts index 322676db8a9..777927422cc 100644 --- a/foundations/server/packages/core/src/__tests__/shared-integration.ts +++ b/foundations/server/packages/core/src/__tests__/shared-integration.ts @@ -448,7 +448,7 @@ export function runSharedIntegrationTests (adapterName: string, getContext: () = } ) expect(r.length).toEqual(1) - expect(r[0].$associations?.[association._id][0]?._id).toEqual(secondTask) + expect(r[0].$associations?.[`${association._id}_b`][0]?._id).toEqual(secondTask) }) }) diff --git a/foundations/server/packages/mongo/src/__tests__/storage.test.ts b/foundations/server/packages/mongo/src/__tests__/storage.test.ts index 4164ee19887..659aa0e14be 100644 --- a/foundations/server/packages/mongo/src/__tests__/storage.test.ts +++ b/foundations/server/packages/mongo/src/__tests__/storage.test.ts @@ -323,7 +323,78 @@ describe('mongo operations', () => { } ) expect(r.length).toEqual(1) - expect((r[0].$associations?.[association._id][0] as unknown as Task)?._id).toEqual(secondTask) + expect((r[0].$associations?.[association._id + '_b'][0] as unknown as Task)?._id).toEqual(secondTask) + }) + + it('check deep associations', async () => { + const association = await operations.findOne(core.class.Association, {}) + if (association == null) { + throw new Error('Association not found') + } + + const zeroTask = await operations.createDoc(taskPlugin.class.Task, '' as Ref, { + name: 'my-task', + description: 'Descr', + rate: 20 + }) + + const firstTask = await operations.createDoc(taskPlugin.class.Task, '' as Ref, { + name: 'my-task', + description: 'Descr', + rate: 20 + }) + + const secondTask = await operations.createDoc(taskPlugin.class.Task, '' as Ref, { + name: 'my-task2', + description: 'Descr', + rate: 20 + }) + + const secondATask = await operations.createDoc(taskPlugin.class.Task, '' as Ref, { + name: 'my-task22', + description: 'Descr', + rate: 20 + }) + + const thirdTask = await operations.createDoc(taskPlugin.class.Task, '' as Ref, { + name: 'my-task3', + description: 'Descr', + rate: 20 + }) + + await operations.createDoc(core.class.Relation, '' as Ref, { + docA: firstTask, + docB: secondTask, + association: association._id + }) + + await operations.createDoc(core.class.Relation, '' as Ref, { + docA: firstTask, + docB: secondATask, + association: association._id + }) + + await operations.createDoc(core.class.Relation, '' as Ref, { + docA: secondTask, + docB: thirdTask, + association: association._id + }) + + const r = await client.findAll( + taskPlugin.class.Task, + { _id: { $in: [zeroTask, firstTask] } }, + { + associations: [[association._id, 1, [[association._id, 1]]]] + } + ) + expect(r.length).toEqual(2) + expect(r[1].$associations?.[`${association._id}_b`]).toHaveLength(2) + expect((r[1].$associations?.[`${association._id}_b`][0] as unknown as Task)?._id).toEqual(secondTask) + expect(r[1].$associations?.[`${association._id}_b`][1]?.$associations?.[`${association._id}_b`]).toHaveLength(0) + expect( + (r[1].$associations?.[`${association._id}_b`][0]?.$associations?.[`${association._id}_b`][0] as unknown as Task) + ?._id + ).toEqual(thirdTask) }) // Run shared integration tests diff --git a/foundations/server/packages/mongo/src/storage.ts b/foundations/server/packages/mongo/src/storage.ts index 5e67a6894ab..4c8b8513d13 100644 --- a/foundations/server/packages/mongo/src/storage.ts +++ b/foundations/server/packages/mongo/src/storage.ts @@ -467,19 +467,21 @@ abstract class MongoAdapterBase implements DbAdapter { return result } - private getAssociations (associations: AssociationQuery[]): LookupStep[] { + private getAssociations (associations: AssociationQuery[], parentId: string = ''): LookupStep[] { const res: LookupStep[] = [] for (const association of associations) { - const assoc = this.modelDb.findObject(association[0]) + const _id = association[0] + const assoc = this.modelDb.findObject(_id) if (assoc === undefined) continue const isReverse = association[1] === -1 const _class = !isReverse ? assoc.classB : assoc.classA + const fullId = _id + (isReverse ? '_a' : '_b') const targetDomain = this.hierarchy.getDomain(_class) if (targetDomain === DOMAIN_MODEL) continue - const as = association[0] + '_hidden_association' + const as = parentId + fullId + '_hidden_association' res.push({ from: DOMAIN_RELATION, - localField: '_id', + localField: `${parentId !== '' ? parentId + '.' : ''}_id`, foreignField: isReverse ? 'docB' : 'docA', as }) @@ -487,8 +489,11 @@ abstract class MongoAdapterBase implements DbAdapter { from: targetDomain, localField: as + '.' + (isReverse ? 'docA' : 'docB'), foreignField: '_id', - as: association[0] + '_association' + as: parentId + fullId + '_association' }) + if (association[2] !== undefined) { + res.push(...this.getAssociations(association[2], parentId + fullId + '_association')) + } } return res } @@ -518,19 +523,36 @@ abstract class MongoAdapterBase implements DbAdapter { } } - private fillAssociationsValue (associations: AssociationQuery[], object: any): Record { + private fillAssociationsValue ( + source: any, + associations: AssociationQuery[], + parentKey: string, + targetId: Ref + ): Record { const res: Record = {} for (const association of associations) { - const assocKey = association[0] + '_hidden_association' - const data = object[assocKey] + const _id = association[0] + const isReverse = association[1] === -1 + // const key = _id + (isReverse ? '.a' : '.b') + const fullId = _id + (isReverse ? '_a' : '_b') + const assocKey = parentKey + fullId + '_hidden_association' + const data = source[assocKey] if (data !== undefined && Array.isArray(data)) { const filtered = new Set( - data.filter((it) => it.association === association[0]).map((it) => (association[1] === 1 ? it.docB : it.docA)) + data + .filter((it) => it.association === _id && (isReverse ? it.docB : it.docA) === targetId) + .map((it) => (!isReverse ? it.docB : it.docA)) ) - const fullKey = association[0] + '_association' - const arr = object[fullKey] + const fullKey = parentKey + fullId + '_association' + const arr = source[fullKey] as WithLookup[] if (arr !== undefined && Array.isArray(arr)) { - res[association[0]] = arr.filter((it) => filtered.has(it._id)) + const objects = arr.filter((it) => filtered.has(it._id)) + if (association[2] !== undefined) { + for (const obj of objects) { + this.fillAssociations(obj, association[2], fullKey, source) + } + } + res[fullId] = objects } } } @@ -718,10 +740,7 @@ abstract class MongoAdapterBase implements DbAdapter { } } if (options.associations !== undefined && options.associations.length > 0) { - row.$associations = this.fillAssociationsValue(options.associations, row) - for (const [, v] of Object.entries(row.$associations)) { - this.stripHash(v) - } + this.fillAssociations(row, options.associations) } this.clearExtraLookups(row) } @@ -756,6 +775,19 @@ abstract class MongoAdapterBase implements DbAdapter { return toFindResult(this.stripHash(result) as T[], total) } + private fillAssociations( + obj: WithLookup, + associations: AssociationQuery[], + parentId: string = '', + source?: Doc + ): WithLookup { + obj.$associations = this.fillAssociationsValue(source ?? obj, associations, parentId, obj._id) + for (const [, v] of Object.entries(obj.$associations)) { + this.stripHash(v) + } + return obj + } + private translateKey( key: string, clazz: Ref>, diff --git a/foundations/server/packages/postgres/src/__tests__/storage.test.ts b/foundations/server/packages/postgres/src/__tests__/storage.test.ts index c41e056ffc9..3d0287c2f97 100644 --- a/foundations/server/packages/postgres/src/__tests__/storage.test.ts +++ b/foundations/server/packages/postgres/src/__tests__/storage.test.ts @@ -385,6 +385,57 @@ describe('postgres operations', () => { } ) expect(r.length).toEqual(1) - expect((r[0].$associations?.[association._id][0] as unknown as Task)?._id).toEqual(secondTask) + expect((r[0].$associations?.[association._id + '_b'][0] as unknown as Task)?._id).toEqual(secondTask) + }) + + it('check deep associations', async () => { + const association = await operations.findOne(core.class.Association, {}) + if (association == null) { + throw new Error('Association not found') + } + + const firstTask = await operations.createDoc(taskPlugin.class.Task, '' as Ref, { + name: 'my-task', + description: 'Descr', + rate: 20 + }) + + const secondTask = await operations.createDoc(taskPlugin.class.Task, '' as Ref, { + name: 'my-task2', + description: 'Descr', + rate: 20 + }) + + const thirdTask = await operations.createDoc(taskPlugin.class.Task, '' as Ref, { + name: 'my-task2', + description: 'Descr', + rate: 20 + }) + + await operations.createDoc(core.class.Relation, '' as Ref, { + docA: firstTask, + docB: secondTask, + association: association._id + }) + + await operations.createDoc(core.class.Relation, '' as Ref, { + docA: secondTask, + docB: thirdTask, + association: association._id + }) + + const r = await client.findAll( + taskPlugin.class.Task, + { _id: firstTask }, + { + associations: [[association._id, 1, [[association._id, 1]]]] + } + ) + expect(r.length).toEqual(1) + expect((r[0].$associations?.[`${association._id}_b`][0] as unknown as Task)?._id).toEqual(secondTask) + expect( + (r[0].$associations?.[`${association._id}_b`][0]?.$associations?.[`${association._id}_b`][0] as unknown as Task) + ?._id + ).toEqual(thirdTask) }) }) diff --git a/foundations/server/packages/postgres/src/storage.ts b/foundations/server/packages/postgres/src/storage.ts index a6afd5d8a19..02a8938af44 100644 --- a/foundations/server/packages/postgres/src/storage.ts +++ b/foundations/server/packages/postgres/src/storage.ts @@ -100,7 +100,6 @@ import { DBCollectionHelper, type DBDoc, escape, - filterProjection, inferType, isDataField, isOwner, @@ -109,7 +108,8 @@ import { parseDoc, parseDocWithProjection, parseUpdate, - simpleEscape + simpleEscape, + toWithLookup } from './utils' async function * createCursorGenerator ( client: postgres.Sql, @@ -554,7 +554,7 @@ abstract class PostgresAdapterBase implements DbAdapter { total ) } else { - const res = this.parseLookup(result, joins, projection, domain) + const res = await this.parseLookup(ctx, result, joins, projection, options.associations, domain) return toFindResult(res, total) } })) as FindResult @@ -647,144 +647,191 @@ abstract class PostgresAdapterBase implements DbAdapter { } } - private parseLookup( + private async parseLookup( + ctx: MeasureContext, rows: any[], joins: JoinProps[], projection: Projection | undefined, + associations: AssociationQuery[] | undefined, domain: string - ): WithLookup[] { + ): Promise[]> { const map = new Map, WithLookup>() + const modelJoins: JoinProps[] = [] const reverseJoins: JoinProps[] = [] const simpleJoins: JoinProps[] = [] + for (const join of joins) { - if (join.table === DOMAIN_MODEL) { - modelJoins.push(join) - } else if (join.isReverse) { - reverseJoins.push(join) - } else if (join.path !== '') { - simpleJoins.push(join) - } + if (join.table === DOMAIN_MODEL) modelJoins.push(join) + else if (join.isReverse) reverseJoins.push(join) + else if (join.path !== '') simpleJoins.push(join) } - for (const row of rows) { - /* eslint-disable @typescript-eslint/consistent-type-assertions */ - let doc: WithLookup = map.get(row._id) ?? ({ _id: row._id, $lookup: {}, $associations: {} } as WithLookup) - const associations: Record = doc.$associations as Record - const lookup: Record = doc.$lookup as Record - let joinIndex: number | undefined - let skip = false - try { - const schema = getSchema(domain) - for (const column in row) { - if (column.startsWith('reverse_lookup_')) { - if (row[column] != null) { - const join = reverseJoins.find((j) => j.toAlias.toLowerCase() === column) - if (join === undefined) { - continue - } - const res = this.getLookupValue(join.path, lookup, false) - if (res === undefined) continue - const { obj, key } = res - const parsed = row[column].map((p: any) => parseDoc(p, schema)) - obj[key] = parsed - } - } else if (column.startsWith('lookup_')) { - const keys = column.split('_') - let key = keys[keys.length - 1] - if (keys[keys.length - 2] === '') { - key = '_' + key - } - - if (key === 'workspaceId') { - continue - } - - if (key === '_id') { - joinIndex = joinIndex === undefined ? 0 : ++joinIndex - if (row[column] === null) { - skip = true - continue - } - skip = false - } + for (const row of rows) { + const doc = toWithLookup(parseDoc(row, getSchema(row._class))) - if (skip) { - continue - } + const lookup = doc.$lookup as Record - const join = simpleJoins[joinIndex ?? 0] - if (join === undefined) { - continue - } - const res = this.getLookupValue(join.path, lookup) - if (res === undefined) continue - const { obj, key: p } = res + this.parseLookupColumns(row, simpleJoins, reverseJoins, lookup, domain) - if (key === 'data') { - obj[p] = { ...obj[p], ...row[column] } - } else { - if (key === 'createdOn' || key === 'modifiedOn') { - const val = Number.parseInt(row[column]) - obj[p][key] = Number.isNaN(val) ? null : val - } else if (key === '%hash%') { - continue - } else if (key === 'attachedTo' && row[column] === 'NULL') { - continue - } else { - obj[p][key] = row[column] === 'NULL' ? null : row[column] - } - } - } else if (column.startsWith('assoc_')) { - if (row[column] == null) continue - const keys = column.split('_') - const key = keys[keys.length - 1] - const associationDomain = keys[1] - const associationSchema = getSchema(associationDomain) - const parsed = row[column].map((p: any) => parseDoc(p, associationSchema)) - associations[key] = parsed - } else { - joinIndex = undefined - if (!map.has(row._id)) { - if (column === 'workspaceId') { - continue - } - if (column === 'data') { - let data = row[column] - data = filterProjection(data, projection) - doc = { ...doc, ...data } - } else { - if (column === 'createdOn' || column === 'modifiedOn') { - const val = Number.parseInt(row[column]) - ;(doc as any)[column] = Number.isNaN(val) ? null : val - } else if (column === '%hash%') { - // Ignore - } else { - ;(doc as any)[column] = row[column] === 'NULL' ? null : row[column] - } - } - } - } - } - } catch (err) { - console.log(err) - throw err - } for (const modelJoin of modelJoins) { const res = this.getLookupValue(modelJoin.path, lookup) if (res === undefined) continue + const { obj, key } = res const val = this.getModelLookupValue(doc, modelJoin, [...simpleJoins, ...modelJoins]) + if (val !== undefined && modelJoin.toClass !== undefined) { - const res = this.modelDb.findAllSync(modelJoin.toClass, { + const res2 = this.modelDb.findAllSync(modelJoin.toClass, { [modelJoin.toField]: val }) - obj[key] = modelJoin.isReverse ? res : res[0] + obj[key] = modelJoin.isReverse ? res2 : res2[0] } } + map.set(row._id, doc) } - return Array.from(map.values()) + + if (associations !== undefined && map.size > 0) { + await this.fetchAssociations(ctx, map, associations) + } + + return [...map.values()] + } + + async fetchAssociations ( + ctx: MeasureContext, + parentMap: Map>, + associations: AssociationQuery[] + ): Promise { + for (const association of associations) { + const [assocId, dir, nested] = association + const isReverse = dir === -1 + const keyA = isReverse ? 'docB' : 'docA' + const keyB = isReverse ? 'docA' : 'docB' + + const assoc = this.modelDb.findObject(assocId) + if (assoc === undefined) { + continue + } + const _class = isReverse ? assoc.classA : assoc.classB + const domain = this.hierarchy.findDomain(_class) + if (domain === undefined) continue + const tagetDomain = translateDomain(domain) + + const vars = new ValuesVariables() + + const wsId = vars.add(this.workspaceId, '::uuid') + const parentIds = vars.add(Array.from(parentMap.keys()), '::text[]') + const assocIdVar = vars.add(assocId) + + const rows = await this.mgr.retry(ctx.id, this.mgrId, async (connection) => { + return await connection.execute( + ` + SELECT assoc.*, r."${keyA}" as parent_id + FROM ${tagetDomain} AS assoc + JOIN ${translateDomain(DOMAIN_RELATION)} AS r + ON r."${keyB}" = assoc."_id" + WHERE r."${keyA}" = ANY(${parentIds}) + AND r.association = ${assocIdVar} + AND assoc."workspaceId" = ${wsId} + `, + vars.getValues() + ) + }) + + const key = `${assocId}_${!isReverse ? 'b' : 'a'}` + + const nextParentMap = new Map>() + for (const row of rows) { + const parentId = row.parent_id + const parsed = parseDoc(row, getSchema(row._class)) + + const parent = parentMap.get(parentId) + if (parent === undefined) continue + + if (parent.$associations === undefined) { + parent.$associations = {} + } + + if (parent.$associations[key] === undefined) parent.$associations[key] = [] + parent.$associations[key].push(parsed) + nextParentMap.set(parsed._id, parsed) + } + + if (nested !== undefined && nested.length > 0 && nextParentMap.size > 0) { + await this.fetchAssociations(ctx, nextParentMap, nested) + } + } + } + + private parseLookupColumns ( + row: any, + simpleJoins: JoinProps[], + reverseJoins: JoinProps[], + lookup: Record, + domain: string + ): void { + const schema = getSchema(domain) + let joinIndex: number | undefined + let skip = false + + for (const column in row) { + if (column.startsWith('reverse_lookup_')) { + const join = reverseJoins.find((j) => j.toAlias.toLowerCase() === column) + if (join === undefined || row[column] == null) continue + + const parsed = row[column].map((p: any) => parseDoc(p, schema)) + + const res = this.getLookupValue(join.path, lookup, false) + if (res === undefined) continue + const { obj, key } = res + obj[key] = parsed + + continue + } + + if (column.startsWith('lookup_')) { + const keys = column.split('_') + let key = keys[keys.length - 1] + if (keys[keys.length - 2] === '') { + key = '_' + key + } + + if (key === 'workspaceId') continue + + if (key === '_id') { + joinIndex = joinIndex === undefined ? 0 : joinIndex + 1 + if (row[column] == null) { + skip = true + continue + } + skip = false + } + + if (skip) continue + + const join = simpleJoins[joinIndex ?? 0] + if (join === undefined) continue + + const res = this.getLookupValue(join.path, lookup) + if (res === undefined) continue + const { obj, key: p } = res + + if (key === 'data') { + obj[p] = { ...obj[p], ...row[column] } + } else if (key === 'createdOn' || key === 'modifiedOn') { + const val = parseInt(row[column]) + obj[p][key] = Number.isNaN(val) ? null : val + } else if (key === '%hash%') { + // ignore + } else if (key === 'attachedTo' && row[column] === 'NULL') { + // ignore + } else { + obj[p][key] = row[column] === 'NULL' ? null : row[column] + } + } + } } private getLookupValue ( @@ -1399,36 +1446,6 @@ abstract class PostgresAdapterBase implements DbAdapter { return res } - getAssociationsProjections (vars: ValuesVariables, baseDomain: string, associations: AssociationQuery[]): string[] { - const res: string[] = [] - for (const association of associations) { - const _id = escape(association[0]) - const assoc = this.modelDb.findObject(_id) - if (assoc === undefined) { - continue - } - const isReverse = association[1] === -1 - const _class = isReverse ? assoc.classA : assoc.classB - const domain = this.hierarchy.findDomain(_class) - if (domain === undefined) continue - const tagetDomain = translateDomain(domain) - const keyA = isReverse ? 'docB' : 'docA' - const keyB = isReverse ? 'docA' : 'docB' - const wsId = vars.add(this.workspaceId, '::uuid') - res.push( - `(SELECT jsonb_agg(assoc.*) - FROM ${tagetDomain} AS assoc - JOIN ${translateDomain(DOMAIN_RELATION)} as relation - ON relation."${keyB}" = assoc."_id" - AND relation."workspaceId" = ${wsId} - WHERE relation."${keyA}" = ${translateDomain(baseDomain)}."_id" - AND relation.association = '${_id}' - AND assoc."workspaceId" = ${wsId}) AS assoc_${tagetDomain}_${_id}` - ) - } - return res - } - @withContext('get-domain-hash') async getDomainHash (ctx: MeasureContext, domain: Domain): Promise { return await calcHashHash(ctx, domain, this) @@ -1472,9 +1489,6 @@ abstract class PostgresAdapterBase implements DbAdapter { for (const join of joins) { res.push(...this.getProjectionsAliases(vars, join)) } - if (associations !== undefined) { - res.push(...this.getAssociationsProjections(vars, baseDomain, associations)) - } return res.join(', ') } diff --git a/foundations/server/packages/postgres/src/utils.ts b/foundations/server/packages/postgres/src/utils.ts index 91073f3c273..469904e3a93 100644 --- a/foundations/server/packages/postgres/src/utils.ts +++ b/foundations/server/packages/postgres/src/utils.ts @@ -28,6 +28,7 @@ import core, { type Projection, type Ref, systemAccountUuid, + type WithLookup, type WorkspaceUuid } from '@hcengineering/core' import { type DomainHelperOperations } from '@hcengineering/server-core' @@ -473,6 +474,17 @@ export function parseDocWithProjection ( return res } +export function toWithLookup (doc: T): WithLookup { + const res = doc as WithLookup + if (res.$associations === undefined) { + res.$associations = {} + } + if (res.$lookup === undefined) { + res.$lookup = {} + } + return res +} + export function parseDoc (doc: DBDoc, schema: Schema): T { const { workspaceId, data, ...rest } = doc for (const key in rest) { diff --git a/plugins/view-resources/src/components/RelationsEditor.svelte b/plugins/view-resources/src/components/RelationsEditor.svelte index e6ec41edc03..902a2ad8664 100644 --- a/plugins/view-resources/src/components/RelationsEditor.svelte +++ b/plugins/view-resources/src/components/RelationsEditor.svelte @@ -13,9 +13,9 @@ // limitations under the License. --> -{#each associationsB as association (association._id)} +{#each associationsB as association (`${association._id}_b`)} {/each} -{#each associationsA as association (association._id)} +{#each associationsA as association (`${association._id}_a`)}