Fix controlled document folder export (#10775)

Signed-off-by: Artem Savchenko <armisav@gmail.com>
This commit is contained in:
Artyom Savchenko
2026-04-17 11:36:11 +07:00
committed by GitHub
parent 749a3603b5
commit db97de7c63
7 changed files with 281 additions and 4 deletions
+10 -3
View File
@@ -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 {
+2
View File
@@ -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'
@@ -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()
})
})
+8 -1
View File
@@ -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'
@@ -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
@@ -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
@@ -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}`)
@@ -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'
@@ -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'