diff --git a/models/controlled-documents/src/index.ts b/models/controlled-documents/src/index.ts index c60a24f062..973f13fc61 100644 --- a/models/controlled-documents/src/index.ts +++ b/models/controlled-documents/src/index.ts @@ -96,12 +96,19 @@ function defineRelationMetadata (builder: Builder): void { rel(documents.class.Document, 'category', documents.class.DocumentCategory, 'forward') rel(documents.class.Document, 'template', documents.class.Document, 'forward') - rel(documents.class.Document, 'meta', documents.class.ProjectMeta, 'inverse') rel(documents.class.Document, 'document', documents.class.ProjectDocument, 'inverse') rel(documents.class.HierarchyDocument, 'attachedTo', documents.class.DocumentMeta, 'forward') rel(documents.class.ControlledDocument, 'changeControl', documents.class.ChangeControl, 'forward') - rel(documents.class.ProjectMeta, 'meta', documents.class.ProjectMeta, 'inverse') - rel(documents.class.ProjectMeta, 'document', documents.class.ProjectDocument, 'inverse') + rel(documents.class.DocumentMeta, 'meta', documents.class.ProjectMeta, 'inverse') + rel(documents.class.Project, 'project', documents.class.ProjectMeta, 'inverse') + rel(documents.class.ProjectMeta, 'meta', documents.class.DocumentMeta, 'forward') + rel(documents.class.ProjectMeta, 'parent', documents.class.DocumentMeta, 'forward') + rel(documents.class.ProjectMeta, 'path', documents.class.DocumentMeta, 'forward') + rel(documents.class.ProjectMeta, 'project', documents.class.Project, 'forward') + rel(documents.class.ProjectDocument, 'project', documents.class.Project, 'forward') + rel(documents.class.ProjectDocument, 'initial', documents.class.Project, 'forward') + rel(documents.class.ProjectDocument, 'attachedTo', documents.class.ProjectMeta, 'forward') + rel(documents.class.ProjectDocument, 'document', documents.class.Document, 'forward') } export function createModel (builder: Builder): void { diff --git a/plugins/export/src/types.ts b/plugins/export/src/types.ts index bd3acfb2ab..eb373d272a 100644 --- a/plugins/export/src/types.ts +++ b/plugins/export/src/types.ts @@ -66,6 +66,8 @@ export interface TransformConfig { } export interface RelationDefinition { + /** When set, this relation applies only to documents of this class (or its subclasses). */ + sourceClass?: Ref> field: string class: Ref> direction?: 'forward' | 'inverse' diff --git a/services/export/pod-export/src/__tests__/relation-exporter.test.ts b/services/export/pod-export/src/__tests__/relation-exporter.test.ts new file mode 100644 index 0000000000..2dc20315be --- /dev/null +++ b/services/export/pod-export/src/__tests__/relation-exporter.test.ts @@ -0,0 +1,250 @@ +/* eslint-disable @typescript-eslint/unbound-method */ +// +// 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 { + Class, + type Doc, + type Hierarchy, + type LowLevelStorage, + MeasureMetricsContext, + type Ref, + type Space +} from '@hcengineering/core' +import { RelationExporter } from '../workspace/relation-exporter' +import type { ExportState, RelationDefinition } from '../workspace/types' + +const spaceId = '69286daacb49b698d3ea2c51' as Ref +const classParent = 'test:class:ParentDoc' as Ref> +const classChild = 'test:class:ChildDoc' as Ref> +const classUnrelated = 'test:class:UnrelatedDoc' as Ref> +const classTarget = 'test:class:TargetDoc' as Ref> + +function doc (id: string, _class: Ref>, extra: Record = {}): Doc { + return { + _id: id as Ref, + _class, + space: spaceId, + modifiedOn: 0, + modifiedBy: 'test:account:user' as any, + ...extra + } +} + +function hierarchyWithDerivation (childDerivesParent: boolean): Hierarchy { + return { + findDomain: jest.fn((classRef: Ref>) => `domain:${String(classRef)}`), + isDerived: jest.fn((cls: Ref>, base: Ref>) => { + if (cls === base) return true + if (childDerivesParent && cls === classChild && base === classParent) return true + return false + }), + getAllAttributes: jest.fn(() => new Map()), + isMixin: jest.fn(() => false), + hasMixin: jest.fn(), + getClass: jest.fn(() => ({ label: 'Test' })) + } as unknown as Hierarchy +} + +function lowLevelForDocs (...docs: Doc[]): LowLevelStorage { + const byDomain = new Map() + for (const d of docs) { + const domain = `domain:${String(d._class)}` + const list = byDomain.get(domain) ?? [] + list.push(d) + byDomain.set(domain, list) + } + return { + rawFindAll: jest.fn(async (_domain: string, query: any): Promise => { + const domainDocs = byDomain.get(_domain) ?? [] + return domainDocs.filter((d) => { + if (query._id !== undefined && d._id !== query._id) return false + for (const [key, val] of Object.entries(query)) { + if (key.startsWith('_')) continue + if ((d as any)[key] !== val) return false + } + return true + }) as T[] + }) + } as unknown as LowLevelStorage +} + +function createExporter (exportDocument: jest.Mock): { exporter: RelationExporter, state: ExportState } { + const state: ExportState = { + idMapping: new Map(), + spaceMapping: new Map(), + processingDocs: new Set(), + uniqueFieldValues: new Map() + } + const ctx = new MeasureMetricsContext('relation-exporter-test', {}) + const exporter = new RelationExporter(ctx, state, exportDocument as any) + return { exporter, state } +} + +describe('RelationExporter sourceClass', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('runs forward relation when doc class is derived from sourceClass', async () => { + const parentId = '69286dc0cb49b698d3ea2c95' as Ref + const parent = doc(parentId, classTarget, { name: 'parent' }) + const child = doc('69286dc1cb49b698d3ea2c98', classChild, { folder: parentId }) + + const exportDocument = jest.fn().mockResolvedValue(true) + const { exporter } = createExporter(exportDocument) + const hierarchy = hierarchyWithDerivation(true) + const lowLevel = lowLevelForDocs(parent) + + const relations: RelationDefinition[] = [ + { + sourceClass: classParent, + field: 'folder', + class: classTarget, + direction: 'forward' + } + ] + + await exporter.exportForwardRelations(child, relations, 'duplicate', true, hierarchy, lowLevel) + + expect(exportDocument).toHaveBeenCalledTimes(1) + expect(exportDocument).toHaveBeenCalledWith( + parent, + 'duplicate', + true, + hierarchy, + lowLevel, + expect.any(Map), + relations + ) + }) + + it('skips forward relation when doc class is not derived from sourceClass', async () => { + const parentId = '69286dc0cb49b698d3ea2c95' as Ref + const parent = doc(parentId, classTarget, {}) + const unrelated = doc('69286dc1cb49b698d3ea2c98', classUnrelated, { folder: parentId }) + + const exportDocument = jest.fn().mockResolvedValue(true) + const { exporter } = createExporter(exportDocument) + const hierarchy = hierarchyWithDerivation(true) + const lowLevel = lowLevelForDocs(parent) + + const relations: RelationDefinition[] = [ + { + sourceClass: classParent, + field: 'folder', + class: classTarget, + direction: 'forward' + } + ] + + await exporter.exportForwardRelations(unrelated, relations, 'duplicate', true, hierarchy, lowLevel) + + expect(exportDocument).not.toHaveBeenCalled() + }) + + it('runs forward relation without sourceClass for any doc (backward compatible)', async () => { + const refId = '69286dc0cb49b698d3ea2c95' as Ref + const target = doc(refId, classTarget, {}) + const anyDoc = doc('69286dc1cb49b698d3ea2c98', classUnrelated, { link: refId }) + + const exportDocument = jest.fn().mockResolvedValue(true) + const { exporter } = createExporter(exportDocument) + const hierarchy = hierarchyWithDerivation(false) + jest.mocked(hierarchy.findDomain).mockImplementation((c) => `domain:${String(c)}` as any) + const lowLevel = lowLevelForDocs(target) + + const relations: RelationDefinition[] = [{ field: 'link', class: classTarget, direction: 'forward' }] + + await exporter.exportForwardRelations(anyDoc, relations, 'duplicate', true, hierarchy, lowLevel) + + expect(exportDocument).toHaveBeenCalledTimes(1) + expect(exportDocument).toHaveBeenCalledWith( + target, + 'duplicate', + true, + hierarchy, + lowLevel, + expect.any(Map), + relations + ) + }) + + it('runs inverse relation when doc class is derived from sourceClass', async () => { + const anchorId = '69286dc0cb49b698d3ea2c95' as Ref + const anchor = doc(anchorId, classChild, {}) + const pointing = doc('69286dc1cb49b698d3ea2c98', classTarget, { owner: anchorId }) + + const exportDocument = jest.fn().mockResolvedValue(true) + const { exporter } = createExporter(exportDocument) + const hierarchy = hierarchyWithDerivation(true) + jest.mocked(hierarchy.findDomain).mockReturnValue(`domain:${String(classTarget)}` as any) + jest.mocked(hierarchy.isDerived).mockImplementation((cls, base) => { + if (cls === base) return true + if (cls === classChild && base === classParent) return true + if (cls === classTarget && base === classTarget) return true + return false + }) + + const lowLevel = lowLevelForDocs(pointing) + + const relations: RelationDefinition[] = [ + { + sourceClass: classParent, + field: 'owner', + class: classTarget, + direction: 'inverse' + } + ] + + await exporter.exportInverseRelations(anchor, relations, 'duplicate', true, hierarchy, lowLevel) + + expect(exportDocument).toHaveBeenCalledTimes(1) + expect(exportDocument).toHaveBeenCalledWith( + pointing, + 'duplicate', + true, + hierarchy, + lowLevel, + expect.any(Map), + relations + ) + }) + + it('skips inverse relation when doc class is not derived from sourceClass', async () => { + const anchor = doc('69286dc0cb49b698d3ea2c95', classUnrelated, {}) + const pointing = doc('69286dc1cb49b698d3ea2c98', classTarget, { owner: anchor._id }) + + const exportDocument = jest.fn().mockResolvedValue(true) + const { exporter } = createExporter(exportDocument) + const hierarchy = hierarchyWithDerivation(true) + jest.mocked(hierarchy.findDomain).mockReturnValue(`domain:${String(classTarget)}` as any) + + const lowLevel = lowLevelForDocs(pointing) + + const relations: RelationDefinition[] = [ + { + sourceClass: classParent, + field: 'owner', + class: classTarget, + direction: 'inverse' + } + ] + + await exporter.exportInverseRelations(anchor, relations, 'duplicate', true, hierarchy, lowLevel) + + expect(exportDocument).not.toHaveBeenCalled() + }) +}) diff --git a/services/export/pod-export/src/server.ts b/services/export/pod-export/src/server.ts index e5c9ae4a72..7761a1e97e 100644 --- a/services/export/pod-export/src/server.ts +++ b/services/export/pod-export/src/server.ts @@ -126,6 +126,7 @@ const extractQueryToken = (queryParams: any): string | null => { } interface RelationPayloadEntry { + sourceClass?: Ref> field?: string class?: Ref> direction?: 'forward' | 'inverse' @@ -154,6 +155,7 @@ const normalizeRelations = (input: unknown): RelationDefinition[] | undefined => } result.push({ + ...(item.sourceClass !== undefined ? { sourceClass: item.sourceClass } : {}), field: item.field, class: item.class, direction: item.direction ?? 'forward' @@ -180,7 +182,12 @@ const normalizeRelations = (input: unknown): RelationDefinition[] | undefined => } const field = typeof value.field === 'string' ? value.field : key - result.push({ field, class: value.class, direction: value.direction ?? 'forward' }) + result.push({ + ...(value.sourceClass !== undefined ? { sourceClass: value.sourceClass } : {}), + field, + class: value.class, + direction: value.direction ?? 'forward' + }) } return result.length > 0 ? result : undefined diff --git a/services/export/pod-export/src/workspace/relation-exporter.ts b/services/export/pod-export/src/workspace/relation-exporter.ts index 724cb8c0d8..99de990cc4 100644 --- a/services/export/pod-export/src/workspace/relation-exporter.ts +++ b/services/export/pod-export/src/workspace/relation-exporter.ts @@ -154,6 +154,10 @@ export class RelationExporter { sourceLowLevel: LowLevelStorage, relations: RelationDefinition[] ): Promise { + if (relation.sourceClass !== undefined && !sourceHierarchy.isDerived(doc._class, relation.sourceClass)) { + return + } + const value = (doc as any)[relation.field] if (value === undefined || value === null) { return @@ -195,6 +199,10 @@ export class RelationExporter { sourceLowLevel: LowLevelStorage, relations: RelationDefinition[] ): Promise { + if (relation.sourceClass !== undefined && !sourceHierarchy.isDerived(doc._class, relation.sourceClass)) { + return + } + const domain = sourceHierarchy.findDomain(relation.class) if (domain === undefined) { this.context.warn(`Domain not found for relation class ${relation.class}`) diff --git a/services/export/pod-export/src/workspace/types.ts b/services/export/pod-export/src/workspace/types.ts index 599a03b6d8..a2d5205689 100644 --- a/services/export/pod-export/src/workspace/types.ts +++ b/services/export/pod-export/src/workspace/types.ts @@ -27,6 +27,8 @@ import { type Pipeline } from '@hcengineering/server-core' export type PipelineFactory = (ctx: MeasureContext, workspace: WorkspaceIds) => Promise export interface RelationDefinition { + /** When set, this relation applies only to documents of this class (or its subclasses). */ + sourceClass?: Ref> field: string class: Ref> direction?: 'forward' | 'inverse' diff --git a/services/export/pod-export/src/workspace/workspace-exporter.ts b/services/export/pod-export/src/workspace/workspace-exporter.ts index e78a74735c..918f8911cf 100644 --- a/services/export/pod-export/src/workspace/workspace-exporter.ts +++ b/services/export/pod-export/src/workspace/workspace-exporter.ts @@ -184,6 +184,7 @@ export class CrossWorkspaceExporter { if (resolvedRelations.length === 0) { const relations = await sourcePipeline.findAll(this.context, core.class.RelationMetadata, {}) resolvedRelations = relations.map((doc) => ({ + sourceClass: doc.sourceClass, field: doc.field, class: doc.targetClass, direction: (doc.direction ?? 'forward') as 'forward' | 'inverse'