-
-
Notifications
You must be signed in to change notification settings - Fork 828
/
Model.ts
377 lines (317 loc) · 10.8 KB
/
Model.ts
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
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
import app from '../common/app';
import { FlarumRequestOptions } from './Application';
import Store, { ApiPayloadSingle, ApiResponseSingle, MetaInformation } from './Store';
export interface ModelIdentifier {
type: string;
id: string;
}
export interface ModelAttributes {
[key: string]: unknown;
}
export interface ModelRelationships {
[relationship: string]: {
data: ModelIdentifier | ModelIdentifier[];
};
}
export interface UnsavedModelData {
type?: string;
attributes?: ModelAttributes;
relationships?: ModelRelationships;
}
export interface SavedModelData {
type: string;
id: string;
attributes?: ModelAttributes;
relationships?: ModelRelationships;
}
export type ModelData = UnsavedModelData | SavedModelData;
export interface SaveRelationships {
[relationship: string]: null | Model | Model[];
}
export interface SaveAttributes {
[key: string]: unknown;
relationships?: SaveRelationships;
}
/**
* The `Model` class represents a local data resource. It provides methods to
* persist changes via the API.
*/
export default abstract class Model {
/**
* The resource object from the API.
*/
data: ModelData = {};
/**
* The time at which the model's data was last updated. Watching the value
* of this property is a fast way to retain/cache a subtree if data hasn't
* changed.
*/
freshness: Date = new Date();
/**
* Whether or not the resource exists on the server.
*/
exists: boolean = false;
/**
* The data store that this resource should be persisted to.
*/
protected store: Store;
/**
* @param data A resource object from the API.
* @param store The data store that this model should be persisted to.
*/
constructor(data: ModelData = {}, store = app.store) {
this.data = data;
this.store = store;
}
/**
* Get the model's ID.
*
* @final
*/
id(): string | undefined {
return 'id' in this.data ? this.data.id : undefined;
}
/**
* Get one of the model's attributes.
*
* @final
*/
attribute<T = unknown>(attribute: string): T {
return this.data?.attributes?.[attribute] as T;
}
/**
* Merge new data into this model locally.
*
* @param data A resource object to merge into this model
*/
pushData(data: ModelData | { relationships?: SaveRelationships }): this {
if ('id' in data) {
(this.data as SavedModelData).id = data.id;
}
if ('type' in data) {
this.data.type = data.type;
}
if ('attributes' in data) {
this.data.attributes ||= {};
// Filter out relationships that got in by accident.
for (const key in data.attributes) {
const val = data.attributes[key];
if (val && val instanceof Model) {
delete data.attributes[key];
}
}
Object.assign(this.data.attributes, data.attributes);
}
if ('relationships' in data) {
const relationships = this.data.relationships ?? {};
// For every relationship field, we need to check if we've
// been handed a Model instance. If so, we will convert it to a
// relationship data object.
for (const r in data.relationships) {
const relationship = data.relationships[r];
if (relationship === null) {
delete relationships[r];
delete data.relationships[r];
continue;
}
let identifier: ModelRelationships[string];
if (relationship instanceof Model) {
identifier = { data: Model.getIdentifier(relationship) };
} else if (relationship instanceof Array) {
identifier = { data: relationship.map(Model.getIdentifier) };
} else {
identifier = relationship;
}
data.relationships[r] = identifier;
relationships[r] = identifier;
}
this.data.relationships = relationships;
}
// Now that we've updated the data, we can say that the model is fresh.
// This is an easy way to invalidate retained subtrees etc.
this.freshness = new Date();
return this;
}
/**
* Merge new attributes into this model locally.
*
* @param attributes The attributes to merge.
*/
pushAttributes(attributes: ModelAttributes) {
this.pushData({ attributes });
}
/**
* Merge new attributes into this model, both locally and with persistence.
*
* @param attributes The attributes to save. If a 'relationships' key
* exists, it will be extracted and relationships will also be saved.
*/
save(
attributes: SaveAttributes,
options: Omit<FlarumRequestOptions<ApiPayloadSingle>, 'url'> & { meta?: MetaInformation } = {}
): Promise<ApiResponseSingle<this>> {
const data: ModelData & { id?: string } = {
type: this.data.type,
attributes,
};
if ('id' in this.data) {
data.id = this.data.id;
}
// If a 'relationships' key exists, extract it from the attributes hash and
// set it on the top-level data object instead. We will be sending this data
// object to the API for persistence.
if (attributes.relationships) {
data.relationships = {};
for (const key in attributes.relationships) {
const model = attributes.relationships[key];
if (model === null) continue;
data.relationships[key] = {
data: model instanceof Array ? model.map(Model.getIdentifier) : Model.getIdentifier(model),
};
}
delete attributes.relationships;
}
// Before we update the model's data, we should make a copy of the model's
// old data so that we can revert back to it if something goes awry during
// persistence.
const oldData = this.copyData();
this.pushData(data);
const request = {
data,
meta: options.meta || undefined,
};
return app
.request<ApiPayloadSingle>({
method: this.exists ? 'PATCH' : 'POST',
url: app.forum.attribute('apiUrl') + this.apiEndpoint(),
body: request,
...options,
})
.then(
// If everything went well, we'll make sure the store knows that this
// model exists now (if it didn't already), and we'll push the data that
// the API returned into the store.
(payload) => {
return this.store.pushPayload<this>(payload);
},
// If something went wrong, though... good thing we backed up our model's
// old data! We'll revert to that and let others handle the error.
(err: Error) => {
this.pushData(oldData);
m.redraw();
throw err;
}
);
}
/**
* Send a request to delete the resource.
*
* @param body Data to send along with the DELETE request.
*/
delete(body: FlarumRequestOptions<void>['body'] = {}, options: Omit<FlarumRequestOptions<void>, 'url'> = {}): Promise<void> {
if (!this.exists) return Promise.resolve();
return app
.request({
method: 'DELETE',
url: app.forum.attribute('apiUrl') + this.apiEndpoint(),
body,
...options,
})
.then(() => {
this.exists = false;
this.store.remove(this);
});
}
/**
* Construct a path to the API endpoint for this resource.
*/
protected apiEndpoint(): string {
return '/' + this.data.type + ('id' in this.data ? '/' + this.data.id : '');
}
protected copyData(): ModelData {
return JSON.parse(JSON.stringify(this.data));
}
protected rawRelationship<M extends Model>(relationship: string): undefined | ModelIdentifier;
protected rawRelationship<M extends Model[]>(relationship: string): undefined | ModelIdentifier[];
protected rawRelationship<_M extends Model | Model[]>(relationship: string): undefined | ModelIdentifier | ModelIdentifier[] {
return this.data.relationships?.[relationship]?.data;
}
/**
* Generate a function which returns the value of the given attribute.
*
* @param transform A function to transform the attribute value
*/
static attribute<T>(name: string): () => T;
static attribute<T, O = unknown>(name: string, transform: (attr: O) => T): () => T;
static attribute<T, O = unknown>(name: string, transform?: (attr: O) => T): () => T {
return function (this: Model) {
if (transform) {
return transform(this.attribute(name));
}
return this.attribute(name);
};
}
/**
* Generate a function which returns the value of the given has-one
* relationship.
*
* @return false if no information about the
* relationship exists; undefined if the relationship exists but the model
* has not been loaded; or the model if it has been loaded.
*/
static hasOne<M extends Model>(name: string): () => M | false;
static hasOne<M extends Model | null>(name: string): () => M | null | false;
static hasOne<M extends Model>(name: string): () => M | false {
return function (this: Model) {
const relationshipData = this.data.relationships?.[name]?.data;
if (relationshipData && relationshipData instanceof Array) {
throw new Error(`Relationship ${name} on model ${this.data.type} is plural, so the hasOne method cannot be used to access it.`);
}
if (relationshipData) {
return this.store.getById<M>(relationshipData.type, relationshipData.id) as M;
}
return false;
};
}
/**
* Generate a function which returns the value of the given has-many
* relationship.
*
* @return false if no information about the relationship
* exists; an array if it does, containing models if they have been
* loaded, and undefined for those that have not.
*/
static hasMany<M extends Model>(name: string): () => (M | undefined)[] | false {
return function (this: Model) {
const relationshipData = this.data.relationships?.[name]?.data;
if (relationshipData && !(relationshipData instanceof Array)) {
throw new Error(`Relationship ${name} on model ${this.data.type} is singular, so the hasMany method cannot be used to access it.`);
}
if (relationshipData) {
return relationshipData.map((data) => this.store.getById<M>(data.type, data.id));
}
return false;
};
}
/**
* Transform the given value into a Date object.
*/
static transformDate(value: string): Date;
static transformDate(value: string | null): Date | null;
static transformDate(value: string | undefined): Date | undefined;
static transformDate(value: string | null | undefined): Date | null | undefined;
static transformDate(value: string | null | undefined): Date | null | undefined {
return value != null ? new Date(value) : value;
}
/**
* Get a resource identifier object for the given model.
*/
protected static getIdentifier(model: Model): ModelIdentifier;
protected static getIdentifier(model?: Model): ModelIdentifier | null {
if (!model || !('id' in model.data)) return null;
return {
type: model.data.type,
id: model.data.id,
};
}
}