-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathmodel-manager.ts
More file actions
280 lines (238 loc) · 9.21 KB
/
model-manager.ts
File metadata and controls
280 lines (238 loc) · 9.21 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
import { cloneDeepWith, without } from 'lodash-es';
import { Constructable } from '../../util/constructable';
import { Logger } from '../../util/logging/logger';
import { ModelApiBuilder } from '../api/builder/model-api-builder';
import { ModelApi } from '../api/model-api';
import { BeforeModelDestroyedEvent } from '../events/before-model-destroyed-event';
import { ModelCreatedEvent } from '../events/model-created-event';
import { ModelDestroyedEvent } from '../events/model-destroyed-event';
import { ModelOnDestroy, ModelOnInit } from './model-lifecycle-hooks';
/**
* Model Manager creates, destroys and tracks existing models. It is used to maintain relationships between
* models.
*/
export class ModelManager {
private readonly modelInstanceMap: Map<object, ModelInstanceData> = new Map();
private readonly apiBuilders: ModelApiBuilder<ModelApi>[] = [];
private readonly decorators: ModelDecorator[] = [];
public constructor(
private readonly logger: Logger,
private readonly modelCreatedEvent: ModelCreatedEvent,
private readonly modelDestroyedEvent: ModelDestroyedEvent,
private readonly beforeModelDestroyedEvent: BeforeModelDestroyedEvent
) {}
/**
* Returns a shallow copy array of model instances that match the argument model class
*/
public getModelInstances<T extends object>(modelClass: Constructable<T>): object[] {
return Array.from(this.modelInstanceMap.keys()).filter(modelInstance => modelInstance instanceof modelClass);
}
/**
* Constructs (@see `ModelManager.construct`) then initializes (@see `ModelManager.initialize`) it
*
* Throws Error if a parent is provided which is not tracked
*/
public create<T extends object>(modelClass: Constructable<T>, parent?: object): T {
return this.initialize(this.construct(modelClass, parent));
}
/**
* Initializes the provided model instance, calling appropriate lifecycle hooks and marking it
* ready.
*/
public initialize<T extends object>(modelInstance: T): T {
if (this.modelHasInitHook(modelInstance)) {
modelInstance.modelOnInit();
}
return modelInstance;
}
/**
* Constructs the provided class, tracking its relationships to other models based on the provided
* parent.
*
* Models must be created through this method and cannot take constructor parameters.
*
* This does not initialize the model, which must be done separately. @see `ModelManager.initialize`
*
* Throws Error if a parent is provided which is not tracked
*/
public construct<T extends object>(modelClass: Constructable<T>, parent?: object): T {
const instance = new modelClass();
this.modelInstanceMap.set(instance, {
parent: parent,
children: []
});
if (parent) {
this.trackNewChild(parent, instance);
}
const modelApi = this.buildApiForModel(instance);
this.decorators.forEach(decorator => decorator.decorate(instance, modelApi));
this.modelCreatedEvent.publish(instance);
return instance;
}
/**
* Untracks any model instances descending from the provided value.
*
* If `value` is a model, it will be untracked along with its descendents, starting from the leaf of the model tree.
* That is, a child will always be destroyed before its parent.
*
* If `value` is an array, each of its object-typed values will be recursively passed to this function.
*
* If `value` is a non-model, non-array object, each of its object-typed values will be recursively passed to this
* function.
*
* If the value is a primitve or no model is found, no action is taken.
*/
public destroy(value: unknown): void {
if (typeof value !== 'object' || !value) {
return;
}
if (this.modelInstanceMap.has(value)) {
this.destroyModel(value);
} else if (Array.isArray(value)) {
value.forEach((arrayValue: unknown) => this.destroy(arrayValue));
} else {
Object.values(value).forEach((objectValue: unknown) => this.destroy(objectValue));
}
}
/**
* Returns a copy of the children registered to the provided model.
*
* Throws Error if the provided instance is not tracked
*/
public getChildren(modelInstance: object): object[] {
return this.getInstanceDataOrThrow(modelInstance).children;
}
/**
* Returns the parent registered to the provided model, or undefined if
* no parent is registered.
*
* Throws Error if the provided instance is not tracked
*/
public getParent(modelInstance: object): object | undefined {
return this.getInstanceDataOrThrow(modelInstance).parent;
}
/**
* Returns the root node in the model tree to which the provided instance
* belongs. Returns itself if the provided node is a root.
*
* Throws Error if the provided instance is not tracked
*/
public getRoot(modelInstance: object): object {
let currentModel = modelInstance;
let currentModelParent = this.getParent(currentModel);
while (currentModelParent) {
currentModel = currentModelParent;
currentModelParent = this.getParent(currentModel);
}
return currentModel;
}
/**
* Returns true if `potentialAncestor` is an ancestor of `model`.
* Returns false otherwise, including if `model === potentialAncestor`.
* Throws Error if `model` is not tracked
*/
public isAncestor(model: object, potentialAncestor: object): boolean {
let currentAncestor: object | undefined = model;
while (currentAncestor) {
currentAncestor = this.getParent(currentAncestor);
if (currentAncestor === potentialAncestor) {
return true;
}
}
return false;
}
/**
* Adds the provided API builder to the search list. The first builder that matches a given model,
* in the order registered, will be used.
*/
public registerModelApiBuilder(modelApiBuilder: ModelApiBuilder<ModelApi>): void {
this.apiBuilders.push(modelApiBuilder);
}
/**
* Returns true if the provided value is a tracked model, false otherwise
*/
public isTrackedModel(value: unknown): boolean {
if (typeof value !== 'object' || value === null) {
return false;
}
return this.modelInstanceMap.has(value);
}
/**
* Registeres a ModelDecorator which will be called when creating all future
* model instances. @see `ModelDecorator`
*/
public registerDecorator(decorator: ModelDecorator): void {
this.decorators.push(decorator);
}
private removeChildFromParent(parent: object, childToRemove: object): void {
const originalParentData = this.getInstanceDataOrThrow(parent);
const newParentData = {
...originalParentData,
children: without(originalParentData.children, childToRemove)
};
this.modelInstanceMap.set(parent, newParentData);
}
private trackNewChild(parent: object, newChild: object): void {
const originalParentData = this.getInstanceDataOrThrow(parent);
const newParentData = {
...originalParentData,
children: originalParentData.children.concat(newChild)
};
this.modelInstanceMap.set(parent, newParentData);
}
private getInstanceDataOrThrow(instance: object): ModelInstanceData {
if (!this.modelInstanceMap.has(instance)) {
this.logger.warn('Could not retrieve data for provided instance, it has not been registered').throw();
}
// Make sure this isn't mutated by always returning a copy, only leaving actual models in tact
const cloneFunction = (value: object) => (this.modelInstanceMap.has(value) ? value : undefined);
return cloneDeepWith(this.modelInstanceMap.get(instance)!, cloneFunction) as ModelInstanceData;
}
private modelHasInitHook<T extends object>(model: T & Partial<ModelOnInit>): model is T & ModelOnInit {
return typeof model.modelOnInit === 'function';
}
private modelHasDestroyHook<T extends object>(model: T & Partial<ModelOnDestroy>): model is T & ModelOnDestroy {
return typeof model.modelOnDestroy === 'function';
}
private buildApiForModel(model: object): ModelApi {
const matchingBuilder = this.apiBuilders.find(builder => builder.matches(model));
if (!matchingBuilder) {
return this.logger.error('No model API builder registered matching provided model').throw();
}
return matchingBuilder.build(model);
}
private destroyModel(modelInstance: object): void {
const instanceData = this.getInstanceDataOrThrow(modelInstance);
// Depth first, destroy children before self
instanceData.children.forEach(child => this.destroy(child));
this.beforeModelDestroyedEvent.publish(modelInstance);
if (this.modelHasDestroyHook(modelInstance)) {
modelInstance.modelOnDestroy();
}
if (instanceData.parent) {
this.removeChildFromParent(instanceData.parent, modelInstance);
}
this.modelInstanceMap.delete(modelInstance);
this.modelDestroyedEvent.publish(modelInstance);
}
}
interface ModelInstanceData {
/**
* Parent of tracked model
*/
readonly parent?: object;
/**
* Children of tracked model
*/
readonly children: object[]; // No mutation!
}
/**
* A decorator class that can optionally decorate created models.
*/
export interface ModelDecorator {
/**
* Will be invoked for each created model object before it is initialized and before the
* creation event is published.
*/
decorate(modelInstance: object, api: ModelApi): void;
}