Skip to content

Commit f29ce90

Browse files
committed
Improve hierarchy + tests
Add tests for hierarchy and few performance/memory optimizations.
1 parent 5ec9c0b commit f29ce90

File tree

3 files changed

+252
-24
lines changed

3 files changed

+252
-24
lines changed
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"changes": [
3+
{
4+
"packageName": "@hcengineering/core",
5+
"comment": "Hierarchy improvements",
6+
"type": "patch"
7+
}
8+
],
9+
"packageName": "@hcengineering/core"
10+
}

packages/core/src/__tests__/hierarchy.test.ts

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,4 +106,226 @@ describe('hierarchy', () => {
106106
spyMixinClass.mockReset()
107107
spyMixinClass.mockRestore()
108108
})
109+
110+
// Memory optimization tests - ancestors stored as array
111+
it('getAncestors should return array directly', async () => {
112+
const hierarchy = prepare()
113+
const ancestors = hierarchy.getAncestors(core.class.TxCreateDoc)
114+
115+
// Verify it's an array
116+
expect(Array.isArray(ancestors)).toBeTruthy()
117+
118+
// Verify it contains expected ancestors
119+
expect(ancestors).toContain(core.class.TxCreateDoc)
120+
expect(ancestors).toContain(core.class.TxCUD)
121+
expect(ancestors).toContain(core.class.Tx)
122+
expect(ancestors).toContain(core.class.Doc)
123+
expect(ancestors).toContain(core.class.Obj)
124+
125+
// Verify order is consistent
126+
const indexTx = ancestors.indexOf(core.class.Tx)
127+
const indexDoc = ancestors.indexOf(core.class.Doc)
128+
expect(indexDoc).toBeGreaterThan(indexTx)
129+
})
130+
131+
it('isDerived should work with array-based ancestors', async () => {
132+
const hierarchy = prepare()
133+
134+
// Test various inheritance chains
135+
expect(hierarchy.isDerived(core.class.TxCreateDoc, core.class.Tx)).toBeTruthy()
136+
expect(hierarchy.isDerived(core.class.TxCreateDoc, core.class.TxCUD)).toBeTruthy()
137+
expect(hierarchy.isDerived(core.class.TxCreateDoc, core.class.Doc)).toBeTruthy()
138+
expect(hierarchy.isDerived(core.class.TxCreateDoc, core.class.Obj)).toBeTruthy()
139+
140+
// Test self-derivation (class is in its own ancestors)
141+
expect(hierarchy.isDerived(core.class.TxCreateDoc, core.class.TxCreateDoc)).toBeTruthy()
142+
143+
// Test non-derived classes
144+
expect(hierarchy.isDerived(core.class.TxCreateDoc, core.class.Space)).toBeFalsy()
145+
expect(hierarchy.isDerived(core.class.Space, core.class.Tx)).toBeFalsy()
146+
147+
// Test with mixins
148+
expect(hierarchy.isDerived(test.class.TestComment, core.class.AttachedDoc)).toBeTruthy()
149+
expect(hierarchy.isDerived(test.mixin.TaskMixinTodos, test.class.Task)).toBeTruthy()
150+
})
151+
152+
it('should handle deep inheritance chains efficiently', async () => {
153+
const hierarchy = prepare()
154+
155+
// TxCreateDoc has a chain: TxCreateDoc -> TxCUD -> Tx -> Doc -> Obj
156+
const ancestors = hierarchy.getAncestors(core.class.TxCreateDoc)
157+
expect(ancestors.length).toBeGreaterThanOrEqual(5)
158+
159+
// All intermediate classes should be present
160+
expect(hierarchy.isDerived(core.class.TxCreateDoc, core.class.TxCUD)).toBeTruthy()
161+
expect(hierarchy.isDerived(core.class.TxCUD, core.class.Tx)).toBeTruthy()
162+
expect(hierarchy.isDerived(core.class.Tx, core.class.Doc)).toBeTruthy()
163+
expect(hierarchy.isDerived(core.class.Doc, core.class.Obj)).toBeTruthy()
164+
})
165+
166+
// Classifier properties tests - Map-based storage
167+
it('getClassifierProp and setClassifierProp should work with Map', async () => {
168+
const hierarchy = prepare()
169+
170+
// Set a property
171+
hierarchy.setClassifierProp(core.class.Space, 'testProp', 'testValue')
172+
173+
// Get the property
174+
const value = hierarchy.getClassifierProp(core.class.Space, 'testProp')
175+
expect(value).toBe('testValue')
176+
177+
// Update the property
178+
hierarchy.setClassifierProp(core.class.Space, 'testProp', 'updatedValue')
179+
const updatedValue = hierarchy.getClassifierProp(core.class.Space, 'testProp')
180+
expect(updatedValue).toBe('updatedValue')
181+
})
182+
183+
it('should handle multiple properties per classifier', async () => {
184+
const hierarchy = prepare()
185+
186+
// Set multiple properties
187+
hierarchy.setClassifierProp(core.class.Space, 'prop1', 'value1')
188+
hierarchy.setClassifierProp(core.class.Space, 'prop2', 'value2')
189+
hierarchy.setClassifierProp(core.class.Space, 'prop3', 42)
190+
hierarchy.setClassifierProp(core.class.Space, 'prop4', { nested: 'object' })
191+
192+
// Verify all properties are stored correctly
193+
expect(hierarchy.getClassifierProp(core.class.Space, 'prop1')).toBe('value1')
194+
expect(hierarchy.getClassifierProp(core.class.Space, 'prop2')).toBe('value2')
195+
expect(hierarchy.getClassifierProp(core.class.Space, 'prop3')).toBe(42)
196+
expect(hierarchy.getClassifierProp(core.class.Space, 'prop4')).toEqual({ nested: 'object' })
197+
198+
// Verify undefined for non-existent property
199+
expect(hierarchy.getClassifierProp(core.class.Space, 'nonExistent')).toBeUndefined()
200+
})
201+
202+
it('should isolate properties between different classifiers', async () => {
203+
const hierarchy = prepare()
204+
205+
// Set properties on different classifiers
206+
hierarchy.setClassifierProp(core.class.Space, 'name', 'Space')
207+
hierarchy.setClassifierProp(core.class.Doc, 'name', 'Doc')
208+
hierarchy.setClassifierProp(test.class.Task, 'name', 'Task')
209+
210+
// Verify isolation
211+
expect(hierarchy.getClassifierProp(core.class.Space, 'name')).toBe('Space')
212+
expect(hierarchy.getClassifierProp(core.class.Doc, 'name')).toBe('Doc')
213+
expect(hierarchy.getClassifierProp(test.class.Task, 'name')).toBe('Task')
214+
})
215+
216+
it('should handle property updates without creating new objects', async () => {
217+
const hierarchy = prepare()
218+
219+
// Set initial value
220+
hierarchy.setClassifierProp(core.class.Space, 'counter', 0)
221+
222+
// Update multiple times (testing that we're not creating new objects each time)
223+
for (let i = 1; i <= 100; i++) {
224+
hierarchy.setClassifierProp(core.class.Space, 'counter', i)
225+
}
226+
227+
// Verify final value
228+
expect(hierarchy.getClassifierProp(core.class.Space, 'counter')).toBe(100)
229+
})
230+
231+
// Edge cases and integration tests
232+
it('should handle interface implementation checks correctly', async () => {
233+
const hierarchy = prepare()
234+
235+
// Task implements DummyWithState which extends WithState
236+
expect(hierarchy.isImplements(test.class.Task, test.interface.WithState)).toBeTruthy()
237+
expect(hierarchy.isImplements(test.class.Task, test.interface.DummyWithState)).toBeTruthy()
238+
239+
// TaskCheckItem directly implements WithState
240+
expect(hierarchy.isImplements(test.class.TaskCheckItem, test.interface.WithState)).toBeTruthy()
241+
242+
// Negative cases
243+
expect(hierarchy.isImplements(core.class.Space, test.interface.WithState)).toBeFalsy()
244+
})
245+
246+
it('should maintain consistency after multiple hierarchy operations', async () => {
247+
const hierarchy = prepare()
248+
249+
// Perform multiple operations
250+
const ancestors1 = hierarchy.getAncestors(test.class.Task)
251+
const isDerived1 = hierarchy.isDerived(test.class.Task, core.class.Doc)
252+
253+
// Set some properties
254+
hierarchy.setClassifierProp(test.class.Task, 'test', 'value')
255+
256+
// Verify operations still work correctly
257+
const ancestors2 = hierarchy.getAncestors(test.class.Task)
258+
const isDerived2 = hierarchy.isDerived(test.class.Task, core.class.Doc)
259+
260+
expect(ancestors1).toEqual(ancestors2)
261+
expect(isDerived1).toBe(isDerived2)
262+
expect(isDerived2).toBeTruthy()
263+
})
264+
265+
it('should handle getDescendants correctly', async () => {
266+
const hierarchy = prepare()
267+
268+
// Get descendants of Doc (should include many classes)
269+
const descendants = hierarchy.getDescendants(core.class.Doc)
270+
271+
expect(descendants).toContain(core.class.Space)
272+
expect(descendants).toContain(core.class.Tx)
273+
expect(descendants).toContain(test.class.Task)
274+
expect(Array.isArray(descendants)).toBeTruthy()
275+
})
276+
277+
it('should work with getBaseClass', async () => {
278+
const hierarchy = prepare()
279+
280+
// Get base class of a mixin
281+
const baseClass = hierarchy.getBaseClass(test.mixin.TaskMixinTodos)
282+
expect(baseClass).toBe(test.class.Task)
283+
284+
// Get base class of a regular class (should return itself)
285+
const baseClass2 = hierarchy.getBaseClass(test.class.Task)
286+
expect(baseClass2).toBe(test.class.Task)
287+
})
288+
289+
it('should handle getAllAttributes correctly', async () => {
290+
const hierarchy = prepare()
291+
292+
// Get all attributes for a class
293+
const attributes = hierarchy.getAllAttributes(core.class.TxCreateDoc)
294+
295+
// Should return a Map
296+
expect(attributes instanceof Map).toBeTruthy()
297+
298+
// Test with to parameter
299+
const attributesTo = hierarchy.getAllAttributes(core.class.TxCreateDoc, core.class.Tx)
300+
expect(attributesTo instanceof Map).toBeTruthy()
301+
})
302+
303+
it('should maintain immutability of returned ancestors array', async () => {
304+
const hierarchy = prepare()
305+
306+
// Get ancestors
307+
const ancestors = hierarchy.getAncestors(test.class.Task)
308+
const originalLength = ancestors.length
309+
310+
// The returned array should be the internal array, but modifying it shouldn't break hierarchy
311+
// (This is a trade-off for memory optimization - callers should treat it as read-only)
312+
expect(ancestors.length).toBe(originalLength)
313+
expect(ancestors).toContain(core.class.Doc)
314+
})
315+
316+
it('should handle performance for multiple isDerived checks', async () => {
317+
const hierarchy = prepare()
318+
319+
// Perform many isDerived checks to ensure array-based lookup is performant
320+
const startTime = Date.now()
321+
for (let i = 0; i < 1000; i++) {
322+
hierarchy.isDerived(core.class.TxCreateDoc, core.class.Tx)
323+
hierarchy.isDerived(test.class.Task, core.class.Doc)
324+
hierarchy.isDerived(test.class.TaskCheckItem, core.class.AttachedDoc)
325+
}
326+
const endTime = Date.now()
327+
328+
// Should complete in reasonable time (< 100ms for 3000 checks)
329+
expect(endTime - startTime).toBeLessThan(100)
330+
})
109331
})

packages/core/src/hierarchy.ts

Lines changed: 20 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,10 @@ export class Hierarchy {
3030
private readonly attributes = new Map<Ref<Classifier>, Map<string, AnyAttribute>>()
3131
private readonly attributesById = new Map<Ref<AnyAttribute>, AnyAttribute>()
3232
private readonly descendants = new Map<Ref<Classifier>, Ref<Classifier>[]>()
33-
private readonly ancestors = new Map<Ref<Classifier>, Set<Ref<Classifier>>>()
33+
private readonly ancestors = new Map<Ref<Classifier>, Ref<Classifier>[]>()
3434
private readonly proxies = new Map<Ref<Mixin<Doc>>, ProxyHandler<Doc>>()
3535

36-
private readonly classifierProperties = new Map<Ref<Classifier>, Record<string, any>>()
36+
private readonly classifierProperties = new Map<Ref<Classifier>, Map<string, any>>()
3737

3838
private createMixinProxyHandler (mixin: Ref<Mixin<Doc>>): ProxyHandler<Doc> {
3939
const value = this.getClass(mixin)
@@ -172,7 +172,7 @@ export class Hierarchy {
172172
if (result === undefined) {
173173
throw new Error('ancestors not found: ' + _class)
174174
}
175-
return Array.from(result)
175+
return result
176176
}
177177

178178
getClass<T extends Obj = Obj>(_class: Ref<Class<T>>): Class<T> {
@@ -318,7 +318,7 @@ export class Hierarchy {
318318
* It will iterate over parents.
319319
*/
320320
isDerived<T extends Obj>(_class: Ref<Class<T>>, from: Ref<Class<T>>): boolean {
321-
return this.ancestors.get(_class)?.has(from) ?? false
321+
return this.ancestors.get(_class)?.includes(from) ?? false
322322
}
323323

324324
/**
@@ -408,29 +408,21 @@ export class Hierarchy {
408408
private updateAncestors (_class: Ref<Classifier>, add = true): void {
409409
const cl: Ref<Classifier>[] = [_class]
410410
const visited = new Set<Ref<Classifier>>()
411+
const ancestorList: Ref<Classifier>[] = []
412+
411413
while (cl.length > 0) {
412414
const classifier = cl.shift() as Ref<Classifier>
413415
if (addNew(visited, classifier)) {
414-
const list = this.ancestors.get(_class)
415-
if (list === undefined) {
416-
if (add) {
417-
this.ancestors.set(_class, new Set([classifier]))
418-
}
419-
} else {
420-
if (add) {
421-
if (!list.has(classifier)) {
422-
list.add(classifier)
423-
}
424-
} else {
425-
const pos = list.has(classifier)
426-
if (pos) {
427-
list.delete(classifier)
428-
}
429-
}
430-
}
416+
ancestorList.push(classifier)
431417
cl.push(...this.ancestorsOf(classifier))
432418
}
433419
}
420+
421+
if (add) {
422+
this.ancestors.set(_class, ancestorList)
423+
} else {
424+
this.ancestors.delete(_class)
425+
}
434426
}
435427

436428
/**
@@ -623,12 +615,16 @@ export class Hierarchy {
623615
}
624616

625617
getClassifierProp (cl: Ref<Class<Doc>>, prop: string): any | undefined {
626-
return this.classifierProperties.get(cl)?.[prop]
618+
return this.classifierProperties.get(cl)?.get(prop)
627619
}
628620

629621
setClassifierProp (cl: Ref<Class<Doc>>, prop: string, value: any): void {
630-
const cur = this.classifierProperties.get(cl)
631-
this.classifierProperties.set(cl, { ...cur, [prop]: value })
622+
let cur = this.classifierProperties.get(cl)
623+
if (cur === undefined) {
624+
cur = new Map<string, any>()
625+
this.classifierProperties.set(cl, cur)
626+
}
627+
cur.set(prop, value)
632628
}
633629
}
634630

0 commit comments

Comments
 (0)