mirror of
https://github.com/hcengineering/platform.git
synced 2026-08-17 18:05:42 +02:00
Fix controlled document folder export (#10775)
Signed-off-by: Artem Savchenko <armisav@gmail.com>
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user