mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 01:56:46 +00:00
refactor(core): Move the Memoized decorator to @n8n/decorators (no-changelog) (#14952)
This commit is contained in:
committed by
GitHub
parent
46df8b47d6
commit
2212aeba30
153
packages/@n8n/decorators/src/__tests__/memoized.test.ts
Normal file
153
packages/@n8n/decorators/src/__tests__/memoized.test.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { AssertionError, ok } from 'node:assert';
|
||||
import { setFlagsFromString } from 'node:v8';
|
||||
import { runInNewContext } from 'node:vm';
|
||||
|
||||
import { Memoized } from '../memoized';
|
||||
|
||||
describe('Memoized Decorator', () => {
|
||||
class TestClass {
|
||||
private computeCount = 0;
|
||||
|
||||
constructor(private readonly value: number = 42) {}
|
||||
|
||||
@Memoized
|
||||
get expensiveComputation() {
|
||||
this.computeCount++;
|
||||
return this.value * 2;
|
||||
}
|
||||
|
||||
getComputeCount() {
|
||||
return this.computeCount;
|
||||
}
|
||||
}
|
||||
|
||||
it('should only compute the value once', () => {
|
||||
const instance = new TestClass();
|
||||
|
||||
// First access should compute
|
||||
expect(instance.expensiveComputation).toBe(84);
|
||||
expect(instance.getComputeCount()).toBe(1);
|
||||
|
||||
// Second access should use cached value
|
||||
expect(instance.expensiveComputation).toBe(84);
|
||||
expect(instance.getComputeCount()).toBe(1);
|
||||
|
||||
// Third access should still use cached value
|
||||
expect(instance.expensiveComputation).toBe(84);
|
||||
expect(instance.getComputeCount()).toBe(1);
|
||||
});
|
||||
|
||||
it('should cache values independently for different instances', () => {
|
||||
const instance1 = new TestClass(10);
|
||||
const instance2 = new TestClass(20);
|
||||
|
||||
expect(instance1.expensiveComputation).toBe(20);
|
||||
expect(instance2.expensiveComputation).toBe(40);
|
||||
|
||||
expect(instance1.getComputeCount()).toBe(1);
|
||||
expect(instance2.getComputeCount()).toBe(1);
|
||||
});
|
||||
|
||||
it('should throw error when used on non-getter', () => {
|
||||
expect(() => {
|
||||
class InvalidClass {
|
||||
// @ts-expect-error this code will fail at compile time and at runtime
|
||||
@Memoized
|
||||
normalProperty = 42;
|
||||
}
|
||||
new InvalidClass();
|
||||
}).toThrow(AssertionError);
|
||||
});
|
||||
|
||||
it('should make cached value non-enumerable', () => {
|
||||
const instance = new TestClass();
|
||||
instance.expensiveComputation; // Access to trigger caching
|
||||
|
||||
const propertyNames = Object.keys(instance);
|
||||
expect(propertyNames).not.toContain('expensiveComputation');
|
||||
});
|
||||
|
||||
it('should not allow reconfiguring the cached value', () => {
|
||||
const instance = new TestClass();
|
||||
instance.expensiveComputation; // Access to trigger caching
|
||||
|
||||
expect(() => {
|
||||
Object.defineProperty(instance, 'expensiveComputation', {
|
||||
value: 999,
|
||||
configurable: true,
|
||||
});
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
it('should work when child class references memoized getter in parent class', () => {
|
||||
class ParentClass {
|
||||
protected computeCount = 0;
|
||||
|
||||
@Memoized
|
||||
get parentValue() {
|
||||
this.computeCount++;
|
||||
return 42;
|
||||
}
|
||||
|
||||
getComputeCount() {
|
||||
return this.computeCount;
|
||||
}
|
||||
}
|
||||
|
||||
class ChildClass extends ParentClass {
|
||||
get childValue() {
|
||||
return this.parentValue * 2;
|
||||
}
|
||||
}
|
||||
|
||||
const child = new ChildClass();
|
||||
|
||||
expect(child.childValue).toBe(84);
|
||||
expect(child.getComputeCount()).toBe(1);
|
||||
|
||||
expect(child.childValue).toBe(84);
|
||||
expect(child.getComputeCount()).toBe(1);
|
||||
});
|
||||
|
||||
it('should have correct property descriptor after memoization', () => {
|
||||
const instance = new TestClass();
|
||||
|
||||
// Before accessing (original getter descriptor)
|
||||
const beforeDescriptor = Object.getOwnPropertyDescriptor(
|
||||
TestClass.prototype,
|
||||
'expensiveComputation',
|
||||
);
|
||||
expect(beforeDescriptor?.configurable).toBe(true);
|
||||
expect(beforeDescriptor?.enumerable).toBe(false);
|
||||
expect(typeof beforeDescriptor?.get).toBe('function');
|
||||
expect(beforeDescriptor?.set).toBeUndefined();
|
||||
|
||||
// After accessing (memoized value descriptor)
|
||||
instance.expensiveComputation; // Trigger memoization
|
||||
const afterDescriptor = Object.getOwnPropertyDescriptor(instance, 'expensiveComputation');
|
||||
expect(afterDescriptor?.configurable).toBe(false);
|
||||
expect(afterDescriptor?.enumerable).toBe(false);
|
||||
expect(afterDescriptor?.writable).toBe(false);
|
||||
expect(afterDescriptor?.value).toBe(84);
|
||||
expect(afterDescriptor?.get).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should not prevent garbage collection of instances', async () => {
|
||||
setFlagsFromString('--expose_gc');
|
||||
const gc = runInNewContext('gc') as unknown as () => void;
|
||||
|
||||
let instance: TestClass | undefined = new TestClass();
|
||||
const weakRef = new WeakRef(instance);
|
||||
instance.expensiveComputation;
|
||||
|
||||
// Remove the strong reference
|
||||
instance = undefined;
|
||||
|
||||
// Wait for garbage collection, forcing it if needed
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
gc();
|
||||
|
||||
const ref = weakRef.deref();
|
||||
ok(!ref, 'GC did not collect the instance ref');
|
||||
});
|
||||
});
|
||||
@@ -20,3 +20,4 @@ export type { AccessScope, Controller, RateLimit } from './types';
|
||||
export type { ShutdownHandler } from './types';
|
||||
export { MultiMainMetadata } from './multi-main-metadata';
|
||||
export { OnLeaderTakeover, OnLeaderStepdown } from './on-multi-main-event';
|
||||
export { Memoized } from './memoized';
|
||||
|
||||
41
packages/@n8n/decorators/src/memoized.ts
Normal file
41
packages/@n8n/decorators/src/memoized.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import assert from 'node:assert';
|
||||
|
||||
/**
|
||||
* A decorator that implements memoization for class property getters.
|
||||
*
|
||||
* The decorated getter will only be executed once and its value cached for subsequent access
|
||||
*
|
||||
* @example
|
||||
* class Example {
|
||||
* @Memoized
|
||||
* get computedValue() {
|
||||
* // This will only run once and the result will be cached
|
||||
* return heavyComputation();
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* @throws If decorator is used on something other than a getter
|
||||
*/
|
||||
export function Memoized<T = unknown>(
|
||||
target: object,
|
||||
propertyKey: string | symbol,
|
||||
descriptor?: TypedPropertyDescriptor<T>,
|
||||
): TypedPropertyDescriptor<T> {
|
||||
const originalGetter = descriptor?.get;
|
||||
assert(originalGetter, '@Memoized can only be used on getters');
|
||||
|
||||
// Replace the original getter for the first call
|
||||
descriptor.get = function (this: typeof target.constructor): T {
|
||||
const value = originalGetter.call(this);
|
||||
// Add a property on the class instance to stop reading from the getter on class prototype
|
||||
Object.defineProperty(this, propertyKey, {
|
||||
value,
|
||||
configurable: false,
|
||||
enumerable: false,
|
||||
writable: false,
|
||||
});
|
||||
return value;
|
||||
};
|
||||
|
||||
return descriptor;
|
||||
}
|
||||
Reference in New Issue
Block a user