Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions models/controlled-documents/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 2 additions & 0 deletions plugins/export/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Class<Doc>>
field: string
class: Ref<Class<Doc>>
direction?: 'forward' | 'inverse'
Expand Down
250 changes: 250 additions & 0 deletions services/export/pod-export/src/__tests__/relation-exporter.test.ts
Original file line number Diff line number Diff line change
@@ -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<Space>
const classParent = 'test:class:ParentDoc' as Ref<Class<Doc>>
const classChild = 'test:class:ChildDoc' as Ref<Class<Doc>>
const classUnrelated = 'test:class:UnrelatedDoc' as Ref<Class<Doc>>
const classTarget = 'test:class:TargetDoc' as Ref<Class<Doc>>

function doc (id: string, _class: Ref<Class<Doc>>, extra: Record<string, any> = {}): Doc {
return {
_id: id as Ref<Doc>,
_class,
space: spaceId,
modifiedOn: 0,
modifiedBy: 'test:account:user' as any,
...extra
}
}

function hierarchyWithDerivation (childDerivesParent: boolean): Hierarchy {
return {
findDomain: jest.fn((classRef: Ref<Class<Doc>>) => `domain:${String(classRef)}`),
isDerived: jest.fn((cls: Ref<Class<Doc>>, base: Ref<Class<Doc>>) => {
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<string, Doc[]>()
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 <T extends Doc>(_domain: string, query: any): Promise<T[]> => {
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<Doc>
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<Doc>
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<Doc>
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<Doc>
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()
})
})
9 changes: 8 additions & 1 deletion services/export/pod-export/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ const extractQueryToken = (queryParams: any): string | null => {
}

interface RelationPayloadEntry {
sourceClass?: Ref<Class<Doc>>
field?: string
class?: Ref<Class<Doc>>
direction?: 'forward' | 'inverse'
Expand Down Expand Up @@ -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'
Expand All @@ -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
Expand Down
8 changes: 8 additions & 0 deletions services/export/pod-export/src/workspace/relation-exporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,10 @@ export class RelationExporter {
sourceLowLevel: LowLevelStorage,
relations: RelationDefinition[]
): Promise<void> {
if (relation.sourceClass !== undefined && !sourceHierarchy.isDerived(doc._class, relation.sourceClass)) {
return
}

const value = (doc as any)[relation.field]
if (value === undefined || value === null) {
return
Expand Down Expand Up @@ -195,6 +199,10 @@ export class RelationExporter {
sourceLowLevel: LowLevelStorage,
relations: RelationDefinition[]
): Promise<void> {
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}`)
Expand Down
2 changes: 2 additions & 0 deletions services/export/pod-export/src/workspace/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ import { type Pipeline } from '@hcengineering/server-core'
export type PipelineFactory = (ctx: MeasureContext, workspace: WorkspaceIds) => Promise<Pipeline>

export interface RelationDefinition {
/** When set, this relation applies only to documents of this class (or its subclasses). */
sourceClass?: Ref<Class<Doc>>
field: string
class: Ref<Class<Doc>>
direction?: 'forward' | 'inverse'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
Loading