Skip to content

Commit 99cdc50

Browse files
author
Lee Richmond
committed
Add nested write support
Some of this will need to be refactored/enhanced.
1 parent 8869954 commit 99cdc50

14 files changed

+904
-19
lines changed

src/attribute.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,14 @@ export default class Attribute {
88
name: string;
99

1010
isAttr: boolean = true;
11+
isRelationship: boolean = false;
1112

1213
static applyAll(klass: typeof Model) : void {
1314
this._eachAttribute(klass, (attr) => {
1415
klass.attributeList.push(attr.name);
15-
let instance = new klass();
1616
let descriptor = attr.descriptor();
1717
Object.defineProperty(klass.prototype, attr.name, descriptor);
18+
let instance = new klass();
1819

1920
let decorators = instance['__attrDecorators'] || [];
2021
decorators.forEach((d) => {

src/model.ts

Lines changed: 54 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import _extend from './util/extend';
99
import { camelize } from './util/string';
1010
import WritePayload from './util/write-payload';
1111
import IncludeDirective from './util/include-directive';
12+
import DirtyChecker from './util/dirty-check';
13+
import relationshipIdentifiersFor from './util/relationship-identifiers';
1214
import Request from './request';
1315
import * as _cloneDeep from './util/clonedeep';
1416
let cloneDeep: any = (<any>_cloneDeep).default || _cloneDeep;
@@ -26,13 +28,18 @@ export default class Model {
2628
static parentClass: typeof Model;
2729

2830
id: string;
31+
temp_id: string;
2932
_attributes: Object = {};
33+
_originalAttributes: Object = {};
34+
_originalRelationships: Object = {};
3035
relationships: Object = {};
3136
__meta__: Object | void = null;
3237
_persisted: boolean = false;
38+
_markedForDestruction: boolean = false;
3339
klass: typeof Model;
3440

3541
static attributeList = [];
42+
static relationList = [];
3643
private static _scope: Scope;
3744

3845
static extend(obj : any) : any {
@@ -138,12 +145,31 @@ export default class Model {
138145

139146
constructor(attributes?: Object) {
140147
this.attributes = attributes;
148+
this._originalAttributes = cloneDeep(this.attributes);
149+
this._originalRelationships = this.relationshipResourceIdentifiers(Object.keys(this.relationships));
150+
}
151+
152+
// Todo:
153+
// * needs to recurse the directive
154+
// * remove the corresponding code from isPersisted and handle here (likely
155+
// only an issue with test setup)
156+
// * Make all calls go through resetRelationTracking();
157+
resetRelationTracking(includeDirective: Object) {
158+
this._originalRelationships = this.relationshipResourceIdentifiers(Object.keys(includeDirective));
159+
}
160+
161+
relationshipResourceIdentifiers(relationNames: Array<string>) {
162+
return relationshipIdentifiersFor(this, relationNames);
141163
}
142164

143165
isType(jsonapiType : string) {
144166
return this.klass.jsonapiType === jsonapiType;
145167
}
146168

169+
get resourceIdentifier() : Object {
170+
return { type: this.klass.jsonapiType, id: this.id };
171+
}
172+
147173
get attributes() : Object {
148174
return this._attributes;
149175
}
@@ -160,16 +186,37 @@ export default class Model {
160186
isPersisted(val? : boolean) : boolean {
161187
if (val != undefined) {
162188
this._persisted = val;
189+
this._originalAttributes = cloneDeep(this.attributes);
190+
this._originalRelationships = this.relationshipResourceIdentifiers(Object.keys(this.relationships));
163191
return val;
164192
} else {
165193
return this._persisted;
166194
}
167195
}
168196

197+
isMarkedForDestruction(val? : boolean) : boolean {
198+
if (val != undefined) {
199+
this._markedForDestruction = val;
200+
return val;
201+
} else {
202+
return this._markedForDestruction;
203+
}
204+
}
205+
169206
fromJsonapi(resource: japiResource, payload: japiDoc) : any {
170207
return deserializeInstance(this, resource, payload);
171208
}
172209

210+
isDirty(relationships?: Object | Array<any> | string) : boolean {
211+
let dc = new DirtyChecker(this);
212+
return dc.check(relationships);
213+
}
214+
215+
hasDirtyRelation(relationName: string, relatedModel: Model) : boolean {
216+
let dc = new DirtyChecker(this);
217+
return dc.checkRelation(relationName, relatedModel);
218+
}
219+
173220
destroy() : Promise<any> {
174221
let url = this.klass.url(this.id);
175222
let verb = 'delete';
@@ -182,35 +229,35 @@ export default class Model {
182229
});
183230
}
184231

185-
save() : Promise<any> {
232+
save(options: Object = {}) : Promise<any> {
186233
let url = this.klass.url();
187234
let verb = 'post';
188235
let request = new Request();
189-
let payload = new WritePayload(this);
236+
let payload = new WritePayload(this, options['with']);
190237
let jwt = this.klass.getJWT();
191238

192239
if (this.isPersisted()) {
193240
url = this.klass.url(this.id);
194241
verb = 'put';
195242
}
196243

197-
let requestPromise = request[verb](url, payload.asJSON(), { jwt });
244+
let json = payload.asJSON();
245+
let requestPromise = request[verb](url, json, { jwt });
198246
return this._writeRequest(requestPromise, () => {
199247
this.isPersisted(true);
248+
payload.postProcess();
200249
});
201250
}
202251

203-
private
204-
205-
_writeRequest(requestPromise : Promise<any>, callback: Function) : Promise<any> {
252+
private _writeRequest(requestPromise : Promise<any>, callback: Function) : Promise<any> {
206253
return new Promise((resolve, reject) => {
207254
return requestPromise.then((response) => {
208255
this._handleResponse(response, resolve, reject, callback);
209256
});
210257
});
211258
}
212259

213-
_handleResponse(response: any, resolve: Function, reject: Function, callback: Function) : void {
260+
private _handleResponse(response: any, resolve: Function, reject: Function, callback: Function) : void {
214261
if (response.status == 422) {
215262
resolve(false);
216263
} else if (response.status >= 500) {

src/scope.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import Request from './request';
77
import colorize from './util/colorize';
88
import * as _cloneDeep from './util/clonedeep';
99
let cloneDeep: any = (<any>_cloneDeep).default || _cloneDeep;
10+
cloneDeep = cloneDeep.default || cloneDeep;
1011

1112
export default class Scope {
1213
model: typeof Model;

src/util/deserialize.ts

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ class Deserializer {
2626
}
2727

2828
addResources(data) {
29+
if (!data) return;
30+
2931
if (Array.isArray(data)) {
3032
for (let datum of data) {
3133
this._resources.push(datum);
@@ -45,6 +47,7 @@ class Deserializer {
4547
let klass = Config.modelForType(resource.type);
4648
let instance = new klass();
4749
record = this.deserializeInstance(instance, resource);
50+
record.isPersisted(true);
4851
}
4952

5053
return record;
@@ -57,19 +60,53 @@ class Deserializer {
5760
instance.attributes = resource.attributes;
5861
this._processRelationships(instance, resource.relationships);
5962
instance.__meta__ = resource.meta;
63+
instance.isPersisted(true);
6064
return instance;
6165
}
6266

67+
_pushRelationship(instance: Model, associationName: string, relatedRecord: Model) {
68+
let records = instance[associationName];
69+
let existingIndex = this._existingRecordIndex(records, relatedRecord);
70+
if (existingIndex > -1) {
71+
if (records[existingIndex].isMarkedForDestruction()) {
72+
records.splice(existingIndex, 1);
73+
} else {
74+
records[existingIndex] = relatedRecord;
75+
}
76+
} else {
77+
records.push(relatedRecord);
78+
}
79+
instance[associationName] = records;
80+
}
81+
82+
_existingRecordIndex(records: Array<Model>, record: Model) : number {
83+
let index = -1;
84+
records.forEach((r, i) => {
85+
if ((r.temp_id && r.temp_id === record.temp_id) || (r.id && r.id === record.id)) {
86+
index = i;
87+
}
88+
});
89+
return index;
90+
}
91+
6392
_processRelationships(instance, relationships) {
6493
this._iterateValidRelationships(instance, relationships, (relationName, relationData) => {
6594
if (Array.isArray(relationData)) {
6695
for (let datum of relationData) {
6796
let relatedRecord = this.deserialize(datum, true);
68-
instance[relationName].push(relatedRecord);
97+
relatedRecord.temp_id = datum['temp-id'];
98+
this._pushRelationship(instance, relationName, relatedRecord)
99+
relatedRecord.temp_id = null;
69100
}
70101
} else {
71-
let relatedRecord = this.deserialize(relationData, true);
72-
instance[relationName] = relatedRecord;
102+
if (instance[relationName] && instance[relationName].isMarkedForDestruction()) {
103+
instance[relationName] = null;
104+
} else {
105+
// todo belongsto destruction test removes relation
106+
let relatedRecord = this.deserialize(relationData, true);
107+
relatedRecord.temp_id = null;
108+
instance[relationName] = relatedRecord;
109+
}
73110
}
74111
});
75112
}

src/util/dirty-check.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import Model from '../model';
2+
import IncludeDirective from './include-directive';
3+
4+
class DirtyChecker {
5+
model: Model;
6+
7+
constructor(model: Model) {
8+
this.model = model;
9+
}
10+
11+
// Check if we are switching persisted objects. Either:
12+
// * adding a new already-persisted object to a hasMany array
13+
// * switching an existing persisted hasOne/belongsTo object
14+
checkRelation(relationName: string, relatedModel: Model) : boolean {
15+
let dirty = false;
16+
17+
if (relatedModel.isPersisted()) {
18+
let identifiers = this.model._originalRelationships[relationName] || [];
19+
let found = identifiers.find((ri) => {
20+
return JSON.stringify(ri) == JSON.stringify(relatedModel.resourceIdentifier);
21+
});
22+
if (!found) dirty = true;
23+
}
24+
25+
return dirty;
26+
}
27+
28+
// Either:
29+
// * attributes changed
30+
// * marked for destruction
31+
// * not persisted (and thus must be send to server)
32+
// * not itself dirty, but has nested relations that are dirty
33+
check(relationships: Object | Array<any> | string = {}) : boolean {
34+
let includeDirective = new IncludeDirective(relationships);
35+
let includeHash = includeDirective.toObject();
36+
37+
return this._hasDirtyAttributes() ||
38+
this._hasDirtyRelationships(includeHash) ||
39+
this.model.isMarkedForDestruction() ||
40+
this._isUnpersisted()
41+
}
42+
43+
// TODO: allow attributes == {} configurable
44+
private _isUnpersisted() {
45+
return !this.model.isPersisted() && JSON.stringify(this.model.attributes) !== JSON.stringify({});
46+
}
47+
48+
private _hasDirtyAttributes() {
49+
let originalAttrs = this.model._originalAttributes;
50+
let currentAttrs = this.model.attributes;
51+
52+
return JSON.stringify(originalAttrs) !== JSON.stringify(currentAttrs);
53+
}
54+
55+
private _hasDirtyRelationships(includeHash: Object) : boolean {
56+
let dirty = false;
57+
58+
this._eachRelatedObject(includeHash, (relationName, relatedObject, nested) => {
59+
if (relatedObject.isDirty(nested)) {
60+
dirty = true;
61+
}
62+
63+
if (this.checkRelation(relationName, relatedObject)) {
64+
dirty = true;
65+
}
66+
});
67+
68+
return dirty;
69+
}
70+
71+
_eachRelatedObject(includeHash: Object, callback: Function) : void {
72+
Object.keys(includeHash).forEach((key) => {
73+
let nested = includeHash[key];
74+
let relatedObjects = this.model[key];
75+
if (!Array.isArray(relatedObjects)) relatedObjects = [relatedObjects];
76+
relatedObjects.forEach((relatedObject) => {
77+
if (relatedObject) {
78+
callback(key, relatedObject, nested);
79+
}
80+
});
81+
});
82+
}
83+
}
84+
85+
export default DirtyChecker;

src/util/relationship-identifiers.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import Model from '../model';
2+
3+
// Build a hash like
4+
// {
5+
// posts: [{id: 1, type: 'posts'}],
6+
// category: [{id: 1, type: 'categories}]
7+
// }
8+
// Will be array regardless of relationship type
9+
// This will only contain persisted objects
10+
// Used for dirty tracking associations
11+
export default function(model: Model, relationNames: Array<string>) : Object {
12+
let identifiers = {};
13+
relationNames.forEach((relationName) => {
14+
let relatedObjects = model.relationships[relationName];
15+
if (relatedObjects) {
16+
if (!Array.isArray(relatedObjects)) relatedObjects = [relatedObjects];
17+
relatedObjects.forEach((r) => {
18+
if (r.isPersisted()) {
19+
if (!identifiers[relationName]) {
20+
identifiers[relationName] = [];
21+
}
22+
identifiers[relationName].push(r.resourceIdentifier);
23+
}
24+
});
25+
}
26+
});
27+
return identifiers;
28+
}

src/util/uuid.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
const generate = function() {
2+
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
3+
let r = Math.random()*16|0, v = c == 'x' ? r : (r&0x3|0x8);
4+
return v.toString(16);
5+
});
6+
}
7+
8+
export default { generate };

0 commit comments

Comments
 (0)