Skip to content

Commit 3d77846

Browse files
alan-agius4clydin
authored andcommitted
refactor(@angular/cli): create a memoize decorator
With this change we clean up repeated caching code by creating a `memoize` decorator that can be used on get accessors and methods.
1 parent 0160f1a commit 3d77846

File tree

8 files changed

+269
-42
lines changed

8 files changed

+269
-42
lines changed

packages/angular/cli/src/command-builder/architect-command-module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import { Argv } from 'yargs';
1010
import { getProjectByCwd } from '../utilities/config';
11+
import { memoize } from '../utilities/memoize';
1112
import { ArchitectBaseCommandModule } from './architect-base-command-module';
1213
import {
1314
CommandModuleError,
@@ -110,6 +111,7 @@ export abstract class ArchitectCommandModule
110111
return this.commandName;
111112
}
112113

114+
@memoize
113115
private getProjectNamesByTarget(target: string): string[] | undefined {
114116
const workspace = this.getWorkspaceOrThrow();
115117

packages/angular/cli/src/command-builder/command-module.ts

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
import { Parser as yargsParser } from 'yargs/helpers';
2121
import { createAnalytics } from '../analytics/analytics';
2222
import { AngularWorkspace } from '../utilities/config';
23+
import { memoize } from '../utilities/memoize';
2324
import { PackageManagerUtils } from '../utilities/package-manager';
2425
import { Option } from './utilities/json-schema';
2526

@@ -169,17 +170,13 @@ export abstract class CommandModule<T extends {} = {}> implements CommandModuleI
169170
});
170171
}
171172

172-
private _analytics: analytics.Analytics | undefined;
173-
protected async getAnalytics(): Promise<analytics.Analytics> {
174-
if (this._analytics) {
175-
return this._analytics;
176-
}
177-
178-
return (this._analytics = await createAnalytics(
173+
@memoize
174+
protected getAnalytics(): Promise<analytics.Analytics> {
175+
return createAnalytics(
179176
!!this.context.workspace,
180177
// Don't prompt for `ng update` and `ng analytics` commands.
181178
['update', 'analytics'].includes(this.commandName),
182-
));
179+
);
183180
}
184181

185182
/**

packages/angular/cli/src/command-builder/schematics-command-module.ts

Lines changed: 7 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
import type { CheckboxQuestion, Question } from 'inquirer';
1717
import { Argv } from 'yargs';
1818
import { getProjectByCwd, getSchematicDefaults } from '../utilities/config';
19+
import { memoize } from '../utilities/memoize';
1920
import { isTTY } from '../utilities/tty';
2021
import {
2122
CommandModule,
@@ -90,32 +91,19 @@ export abstract class SchematicsCommandModule
9091
return parseJsonSchemaToOptions(workflow.registry, schemaJson);
9192
}
9293

93-
private _workflowForBuilder = new Map<string, NodeWorkflow>();
94+
@memoize
9495
protected getOrCreateWorkflowForBuilder(collectionName: string): NodeWorkflow {
95-
const cached = this._workflowForBuilder.get(collectionName);
96-
if (cached) {
97-
return cached;
98-
}
99-
100-
const workflow = new NodeWorkflow(this.context.root, {
96+
return new NodeWorkflow(this.context.root, {
10197
resolvePaths: this.getResolvePaths(collectionName),
10298
engineHostCreator: (options) => new SchematicEngineHost(options.resolvePaths),
10399
});
104-
105-
this._workflowForBuilder.set(collectionName, workflow);
106-
107-
return workflow;
108100
}
109101

110-
private _workflowForExecution: NodeWorkflow | undefined;
102+
@memoize
111103
protected async getOrCreateWorkflowForExecution(
112104
collectionName: string,
113105
options: SchematicsExecutionOptions,
114106
): Promise<NodeWorkflow> {
115-
if (this._workflowForExecution) {
116-
return this._workflowForExecution;
117-
}
118-
119107
const { logger, root, packageManager } = this.context;
120108
const { force, dryRun, packageRegistry } = options;
121109

@@ -241,15 +229,11 @@ export abstract class SchematicsCommandModule
241229
});
242230
}
243231

244-
return (this._workflowForExecution = workflow);
232+
return workflow;
245233
}
246234

247-
private _schematicCollections: Set<string> | undefined;
235+
@memoize
248236
protected async getSchematicCollections(): Promise<Set<string>> {
249-
if (this._schematicCollections) {
250-
return this._schematicCollections;
251-
}
252-
253237
const getSchematicCollections = (
254238
configSection: Record<string, unknown> | undefined,
255239
): Set<string> | undefined => {
@@ -273,8 +257,6 @@ export abstract class SchematicsCommandModule
273257
if (project) {
274258
const value = getSchematicCollections(workspace.getProjectCli(project));
275259
if (value) {
276-
this._schematicCollections = value;
277-
278260
return value;
279261
}
280262
}
@@ -284,14 +266,10 @@ export abstract class SchematicsCommandModule
284266
getSchematicCollections(workspace?.getCli()) ??
285267
getSchematicCollections(globalConfiguration?.getCli());
286268
if (value) {
287-
this._schematicCollections = value;
288-
289269
return value;
290270
}
291271

292-
this._schematicCollections = new Set([DEFAULT_SCHEMATICS_COLLECTION]);
293-
294-
return this._schematicCollections;
272+
return new Set([DEFAULT_SCHEMATICS_COLLECTION]);
295273
}
296274

297275
protected parseSchematicInfo(

packages/angular/cli/src/commands/new/cli.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,10 +63,14 @@ export class NewCommandModule
6363
async run(options: Options<NewCommandArgs> & OtherOptions): Promise<number | void> {
6464
// Register the version of the CLI in the registry.
6565
const collectionName = options.collection ?? (await this.getCollectionFromConfig());
66-
const workflow = await this.getOrCreateWorkflowForExecution(collectionName, options);
67-
workflow.registry.addSmartDefaultProvider('ng-cli-version', () => VERSION.full);
68-
6966
const { dryRun, force, interactive, defaults, collection, ...schematicOptions } = options;
67+
const workflow = await this.getOrCreateWorkflowForExecution(collectionName, {
68+
dryRun,
69+
force,
70+
interactive,
71+
defaults,
72+
});
73+
workflow.registry.addSmartDefaultProvider('ng-cli-version', () => VERSION.full);
7074

7175
// Compatibility check for NPM 7
7276
if (
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
/**
10+
* A decorator that memoizes methods and getters.
11+
*
12+
* **Note**: Be cautious where and how to use this decorator as the size of the cache will grow unbounded.
13+
*
14+
* @see https://en.wikipedia.org/wiki/Memoization
15+
*/
16+
export function memoize<T>(
17+
target: Object,
18+
propertyKey: string | symbol,
19+
descriptor: TypedPropertyDescriptor<T>,
20+
): TypedPropertyDescriptor<T> {
21+
const descriptorPropertyName = descriptor.get ? 'get' : 'value';
22+
const originalMethod: unknown = descriptor[descriptorPropertyName];
23+
24+
if (typeof originalMethod !== 'function') {
25+
throw new Error('Memoize decorator can only be used on methods or get accessors.');
26+
}
27+
28+
const cache = new Map<string, unknown>();
29+
30+
return {
31+
...descriptor,
32+
[descriptorPropertyName]: function (this: unknown, ...args: unknown[]) {
33+
for (const arg of args) {
34+
if (!isJSONSerializable(arg)) {
35+
throw new Error(
36+
`Argument ${isNonPrimitive(arg) ? arg.toString() : arg} is JSON serializable.`,
37+
);
38+
}
39+
}
40+
41+
const key = JSON.stringify(args);
42+
if (cache.has(key)) {
43+
return cache.get(key);
44+
}
45+
46+
const result = originalMethod.apply(this, args);
47+
cache.set(key, result);
48+
49+
return result;
50+
},
51+
};
52+
}
53+
54+
/** Method to check if value is a non primitive. */
55+
function isNonPrimitive(value: unknown): value is object | Function | symbol {
56+
return (
57+
(value !== null && typeof value === 'object') ||
58+
typeof value === 'function' ||
59+
typeof value === 'symbol'
60+
);
61+
}
62+
63+
/** Method to check if the values are JSON serializable */
64+
function isJSONSerializable(value: unknown): boolean {
65+
if (!isNonPrimitive(value)) {
66+
// Can be seralized since it's a primitive.
67+
return true;
68+
}
69+
70+
let nestedValues: unknown[] | undefined;
71+
if (Array.isArray(value)) {
72+
// It's an array, check each item.
73+
nestedValues = value;
74+
} else if (Object.prototype.toString.call(value) === '[object Object]') {
75+
// It's a plain object, check each value.
76+
nestedValues = Object.values(value);
77+
}
78+
79+
if (!nestedValues || nestedValues.some((v) => !isJSONSerializable(v))) {
80+
return false;
81+
}
82+
83+
return true;
84+
}
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import { memoize } from './memoize';
10+
11+
describe('memoize', () => {
12+
class Dummy {
13+
@memoize
14+
get random(): number {
15+
return Math.random();
16+
}
17+
18+
@memoize
19+
getRandom(_parameter?: unknown): number {
20+
return Math.random();
21+
}
22+
23+
@memoize
24+
async getRandomAsync(): Promise<number> {
25+
return Math.random();
26+
}
27+
}
28+
29+
it('should call method once', () => {
30+
const dummy = new Dummy();
31+
const val1 = dummy.getRandom();
32+
const val2 = dummy.getRandom();
33+
34+
// Should return same value since memoized
35+
expect(val1).toBe(val2);
36+
});
37+
38+
it('should call method once (async)', async () => {
39+
const dummy = new Dummy();
40+
const [val1, val2] = await Promise.all([dummy.getRandomAsync(), dummy.getRandomAsync()]);
41+
42+
// Should return same value since memoized
43+
expect(val1).toBe(val2);
44+
});
45+
46+
it('should call getter once', () => {
47+
const dummy = new Dummy();
48+
const val1 = dummy.random;
49+
const val2 = dummy.random;
50+
51+
// Should return same value since memoized
52+
expect(val2).toBe(val1);
53+
});
54+
55+
it('should call method when parameter changes', () => {
56+
const dummy = new Dummy();
57+
const val1 = dummy.getRandom(1);
58+
const val2 = dummy.getRandom(2);
59+
const val3 = dummy.getRandom(1);
60+
const val4 = dummy.getRandom(2);
61+
62+
// Should return same value since memoized
63+
expect(val1).not.toBe(val2);
64+
expect(val1).toBe(val3);
65+
expect(val2).toBe(val4);
66+
});
67+
68+
it('should error when used on non getters and methods', () => {
69+
const test = () => {
70+
class DummyError {
71+
@memoize
72+
set random(_value: number) {}
73+
}
74+
75+
return new DummyError();
76+
};
77+
78+
expect(test).toThrowError('Memoize decorator can only be used on methods or get accessors.');
79+
});
80+
81+
describe('validate method arguments', () => {
82+
it('should error when using Map', () => {
83+
const test = () => new Dummy().getRandom(new Map());
84+
85+
expect(test).toThrowError(/Argument \[object Map\] is JSON serializable./);
86+
});
87+
88+
it('should error when using Symbol', () => {
89+
const test = () => new Dummy().getRandom(Symbol(''));
90+
91+
expect(test).toThrowError(/Argument Symbol\(\) is JSON serializable/);
92+
});
93+
94+
it('should error when using Function', () => {
95+
const test = () => new Dummy().getRandom(function () {});
96+
97+
expect(test).toThrowError(/Argument function \(\) { } is JSON serializable/);
98+
});
99+
100+
it('should error when using Map in an array', () => {
101+
const test = () => new Dummy().getRandom([new Map(), true]);
102+
103+
expect(test).toThrowError(/Argument \[object Map\],true is JSON serializable/);
104+
});
105+
106+
it('should error when using Map in an Object', () => {
107+
const test = () => new Dummy().getRandom({ foo: true, prop: new Map() });
108+
109+
expect(test).toThrowError(/Argument \[object Object\] is JSON serializable/);
110+
});
111+
112+
it('should error when using Function in an Object', () => {
113+
const test = () => new Dummy().getRandom({ foo: true, prop: function () {} });
114+
115+
expect(test).toThrowError(/Argument \[object Object\] is JSON serializable/);
116+
});
117+
118+
it('should not error when using primitive values in an array', () => {
119+
const test = () => new Dummy().getRandom([1, true, ['foo']]);
120+
121+
expect(test).not.toThrow();
122+
});
123+
124+
it('should not error when using primitive values in an Object', () => {
125+
const test = () => new Dummy().getRandom({ foo: true, prop: [1, true] });
126+
127+
expect(test).not.toThrow();
128+
});
129+
130+
it('should not error when using Boolean', () => {
131+
const test = () => new Dummy().getRandom(true);
132+
133+
expect(test).not.toThrow();
134+
});
135+
136+
it('should not error when using String', () => {
137+
const test = () => new Dummy().getRandom('foo');
138+
139+
expect(test).not.toThrow();
140+
});
141+
142+
it('should not error when using Number', () => {
143+
const test = () => new Dummy().getRandom(1);
144+
145+
expect(test).not.toThrow();
146+
});
147+
148+
it('should not error when using null', () => {
149+
const test = () => new Dummy().getRandom(null);
150+
151+
expect(test).not.toThrow();
152+
});
153+
154+
it('should not error when using undefined', () => {
155+
const test = () => new Dummy().getRandom(undefined);
156+
157+
expect(test).not.toThrow();
158+
});
159+
});
160+
});

0 commit comments

Comments
 (0)