Skip to content

Commit a4ba856

Browse files
authored
Improve dot notation for updating nested objects (#729)
* Allow dot in field names * more tests * fix issues * improve coverage
1 parent fcd43da commit a4ba856

File tree

5 files changed

+279
-6
lines changed

5 files changed

+279
-6
lines changed

integration/test/ParseObjectTest.js

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,181 @@ describe('Parse Object', () => {
229229
});
230230
});
231231

232+
it('can increment nested fields', async () => {
233+
const obj = new TestObject();
234+
obj.set('objectField', { number: 5 });
235+
assert.equal(obj.get('objectField').number, 5);
236+
await obj.save();
237+
238+
obj.increment('objectField.number', 15);
239+
assert.equal(obj.get('objectField').number, 20);
240+
await obj.save();
241+
242+
assert.equal(obj.get('objectField').number, 20);
243+
244+
const query = new Parse.Query(TestObject);
245+
const result = await query.get(obj.id);
246+
assert.equal(result.get('objectField').number, 20);
247+
});
248+
249+
it('can increment non existing field', async () => {
250+
const obj = new TestObject();
251+
obj.set('objectField', { number: 5 });
252+
await obj.save();
253+
254+
obj.increment('objectField.unknown', 15);
255+
assert.deepEqual(obj.get('objectField'), {
256+
number: 5,
257+
unknown: 15,
258+
});
259+
await obj.save();
260+
261+
const query = new Parse.Query(TestObject);
262+
const result = await query.get(obj.id);
263+
assert.equal(result.get('objectField').number, 5);
264+
assert.equal(result.get('objectField').unknown, 15);
265+
});
266+
267+
it('can increment nested fields two levels', async () => {
268+
const obj = new TestObject();
269+
obj.set('objectField', { foo: { bar: 5 } });
270+
assert.equal(obj.get('objectField').foo.bar, 5);
271+
await obj.save();
272+
273+
obj.increment('objectField.foo.bar', 15);
274+
assert.equal(obj.get('objectField').foo.bar, 20);
275+
await obj.save();
276+
277+
assert.equal(obj.get('objectField').foo.bar, 20);
278+
279+
const query = new Parse.Query(TestObject);
280+
const result = await query.get(obj.id);
281+
assert.equal(result.get('objectField').foo.bar, 20);
282+
});
283+
284+
it('can increment nested fields without object', async () => {
285+
const obj = new TestObject();
286+
obj.set('hello', 'world');
287+
await obj.save();
288+
289+
obj.increment('hello.dot', 15);
290+
try {
291+
await obj.save();
292+
assert.equal(false, true);
293+
} catch(error) {
294+
assert.equal(error.message, "Cannot create property 'dot' on string 'world'");
295+
}
296+
});
297+
298+
it('can set nested fields', async () => {
299+
const obj = new TestObject({ objectField: { number: 5 } });
300+
assert.equal(obj.get('objectField').number, 5);
301+
await obj.save();
302+
303+
assert.equal(obj.get('objectField').number, 5);
304+
obj.set('objectField.number', 20);
305+
assert.equal(obj.get('objectField').number, 20);
306+
await obj.save();
307+
308+
const query = new Parse.Query(TestObject);
309+
const result = await query.get(obj.id);
310+
assert.equal(result.get('objectField').number, 20);
311+
});
312+
313+
it('can set non existing fields', async () => {
314+
const obj = new TestObject();
315+
obj.set('objectField', { number: 5 });
316+
await obj.save();
317+
318+
obj.set('objectField.unknown', 20);
319+
await obj.save();
320+
const query = new Parse.Query(TestObject);
321+
const result = await query.get(obj.id);
322+
assert.equal(result.get('objectField').number, 5);
323+
assert.equal(result.get('objectField').unknown, 20);
324+
});
325+
326+
it('ignore set nested fields on new object', async () => {
327+
const obj = new TestObject();
328+
obj.set('objectField.number', 5);
329+
assert.deepEqual(obj._getPendingOps()[0], {});
330+
assert.equal(obj.get('objectField'), undefined);
331+
332+
await obj.save();
333+
assert.equal(obj.get('objectField'), undefined);
334+
});
335+
336+
it('can set nested fields two levels', async () => {
337+
const obj = new TestObject({ objectField: { foo: { bar: 5 } } });
338+
assert.equal(obj.get('objectField').foo.bar, 5);
339+
await obj.save();
340+
341+
assert.equal(obj.get('objectField').foo.bar, 5);
342+
obj.set('objectField.foo.bar', 20);
343+
assert.equal(obj.get('objectField').foo.bar, 20);
344+
await obj.save();
345+
346+
const query = new Parse.Query(TestObject);
347+
const result = await query.get(obj.id);
348+
assert.equal(result.get('objectField').foo.bar, 20);
349+
});
350+
351+
it('can unset nested fields', async () => {
352+
const obj = new TestObject({
353+
objectField: {
354+
number: 5,
355+
string: 'hello',
356+
}
357+
});
358+
await obj.save();
359+
360+
obj.unset('objectField.number');
361+
assert.equal(obj.get('objectField').number, undefined);
362+
assert.equal(obj.get('objectField').string, 'hello');
363+
await obj.save();
364+
365+
const query = new Parse.Query(TestObject);
366+
const result = await query.get(obj.id);
367+
assert.equal(result.get('objectField').number, undefined);
368+
assert.equal(result.get('objectField').string, 'hello');
369+
});
370+
371+
it('can unset nested fields two levels', async () => {
372+
const obj = new TestObject({
373+
objectField: {
374+
foo: {
375+
bar: 5,
376+
},
377+
string: 'hello',
378+
}
379+
});
380+
await obj.save();
381+
382+
obj.unset('objectField.foo.bar');
383+
assert.equal(obj.get('objectField').foo.bar, undefined);
384+
assert.equal(obj.get('objectField').string, 'hello');
385+
await obj.save();
386+
387+
const query = new Parse.Query(TestObject);
388+
const result = await query.get(obj.id);
389+
assert.equal(result.get('objectField').foo.bar, undefined);
390+
assert.equal(result.get('objectField').string, 'hello');
391+
});
392+
393+
it('can unset non existing fields', async () => {
394+
const obj = new TestObject();
395+
obj.set('objectField', { number: 5 });
396+
await obj.save();
397+
398+
obj.unset('objectField.unknown');
399+
await obj.save();
400+
401+
const query = new Parse.Query(TestObject);
402+
const result = await query.get(obj.id);
403+
assert.equal(result.get('objectField').number, 5);
404+
assert.equal(result.get('objectField').unknown, undefined);
405+
});
406+
232407
it('can set keys to null', (done) => {
233408
const obj = new TestObject();
234409
obj.set('foo', null);

src/ObjectStateMutations.js

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,18 @@ export function estimateAttributes(serverData: AttributeMap, pendingOps: Array<O
123123
);
124124
}
125125
} else {
126-
data[attr] = pendingOps[i][attr].applyTo(data[attr]);
126+
if (attr.includes('.')) {
127+
// convert a.b.c into { a: { b: { c: value } } }
128+
const fields = attr.split('.');
129+
const last = fields[fields.length - 1];
130+
let object = Object.assign({}, data);
131+
for (let i = 0; i < fields.length - 1; i++) {
132+
object = object[fields[i]];
133+
}
134+
object[last] = pendingOps[i][attr].applyTo(object[last]);
135+
} else {
136+
data[attr] = pendingOps[i][attr].applyTo(data[attr]);
137+
}
127138
}
128139
}
129140
}

src/ParseObject.js

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -282,8 +282,24 @@ class ParseObject {
282282
const dirtyObjects = this._getDirtyObjectAttributes();
283283
const json = {};
284284
let attr;
285+
285286
for (attr in dirtyObjects) {
286-
json[attr] = new SetOp(dirtyObjects[attr]).toJSON();
287+
let isDotNotation = false;
288+
for (let i = 0; i < pending.length; i += 1) {
289+
for (const field in pending[i]) {
290+
// Dot notation operations are handled later
291+
if (field.includes('.')) {
292+
const fieldName = field.split('.')[0];
293+
if (fieldName === attr) {
294+
isDotNotation = true;
295+
break;
296+
}
297+
}
298+
}
299+
}
300+
if (!isDotNotation) {
301+
json[attr] = new SetOp(dirtyObjects[attr]).toJSON();
302+
}
287303
}
288304
for (attr in pending[0]) {
289305
json[attr] = pending[0][attr].toJSON();
@@ -582,8 +598,8 @@ class ParseObject {
582598
/**
583599
* Sets a hash of model attributes on the object.
584600
*
585-
* <p>You can call it with an object containing keys and values, or with one
586-
* key and value. For example:<pre>
601+
* <p>You can call it with an object containing keys and values, with one
602+
* key and value, or dot notation. For example:<pre>
587603
* gameTurn.set({
588604
* player: player1,
589605
* diceRoll: 2
@@ -601,6 +617,8 @@ class ParseObject {
601617
*
602618
* game.set("finished", true);</pre></p>
603619
*
620+
* game.set("player.score", 10);</pre></p>
621+
*
604622
* @param {String} key The key to set.
605623
* @param {} value The value to give it.
606624
* @param {Object} options A set of options for the set.
@@ -661,8 +679,18 @@ class ParseObject {
661679
}
662680
}
663681

664-
// Calculate new values
665682
const currentAttributes = this.attributes;
683+
684+
// Only set nested fields if exists
685+
const serverData = this._getServerData();
686+
if (typeof key === 'string' && key.includes('.')) {
687+
const field = key.split('.')[0];
688+
if (!serverData[field]) {
689+
return this;
690+
}
691+
}
692+
693+
// Calculate new values
666694
const newValues = {};
667695
for (const attr in newOps) {
668696
if (newOps[attr] instanceof RelationOp) {
@@ -910,7 +938,7 @@ class ParseObject {
910938
);
911939
}
912940
for (const key in attrs) {
913-
if (!(/^[A-Za-z][0-9A-Za-z_]*$/).test(key)) {
941+
if (!(/^[A-Za-z][0-9A-Za-z_.]*$/).test(key)) {
914942
return new ParseError(ParseError.INVALID_KEY_NAME);
915943
}
916944
}

src/__tests__/ObjectStateMutations-test.js

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,22 @@ describe('ObjectStateMutations', () => {
119119
expect(attributes.likes.key).toBe('likes');
120120
});
121121

122+
it('can estimate attributes for nested documents', () => {
123+
const serverData = { objectField: { counter: 10 } };
124+
let pendingOps = [{ 'objectField.counter': new ParseOps.IncrementOp(2) }];
125+
expect(ObjectStateMutations.estimateAttributes(serverData, pendingOps, 'someClass', 'someId')).toEqual({
126+
objectField: {
127+
counter: 12
128+
},
129+
});
130+
pendingOps = [{ 'objectField.counter': new ParseOps.SetOp(20) }];
131+
expect(ObjectStateMutations.estimateAttributes(serverData, pendingOps, 'someClass', 'someId')).toEqual({
132+
objectField: {
133+
counter: 20
134+
},
135+
});
136+
});
137+
122138
it('can commit changes from the server', () => {
123139
const serverData = {};
124140
const objectCache = {};

src/__tests__/ParseObject-test.js

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -474,6 +474,45 @@ describe('ParseObject', () => {
474474
expect(o2.attributes).toEqual({ age: 41 });
475475
});
476476

477+
it('can set nested field', () => {
478+
const o = new ParseObject('Person');
479+
o._finishFetch({
480+
objectId: 'setNested',
481+
objectField: {
482+
number: 5
483+
},
484+
otherField: {},
485+
});
486+
487+
expect(o.attributes).toEqual({
488+
objectField: { number: 5 },
489+
otherField: {},
490+
});
491+
o.set('otherField', { hello: 'world' });
492+
o.set('objectField.number', 20);
493+
494+
expect(o.attributes).toEqual({
495+
objectField: { number: 20 },
496+
otherField: { hello: 'world' },
497+
});
498+
expect(o.op('objectField.number') instanceof SetOp).toBe(true);
499+
expect(o.dirtyKeys()).toEqual(['otherField', 'objectField.number', 'objectField']);
500+
expect(o._getSaveJSON()).toEqual({
501+
'objectField.number': 20,
502+
otherField: { hello: 'world' },
503+
});
504+
});
505+
506+
it('ignore set nested field on new object', () => {
507+
const o = new ParseObject('Person');
508+
o.set('objectField.number', 20);
509+
510+
expect(o.attributes).toEqual({});
511+
expect(o.op('objectField.number') instanceof SetOp).toBe(false);
512+
expect(o.dirtyKeys()).toEqual([]);
513+
expect(o._getSaveJSON()).toEqual({});
514+
});
515+
477516
it('can add elements to an array field', () => {
478517
const o = new ParseObject('Schedule');
479518
o.add('available', 'Monday');
@@ -650,6 +689,10 @@ describe('ParseObject', () => {
650689
expect(o.validate({
651690
noProblem: 'here'
652691
})).toBe(false);
692+
693+
expect(o.validate({
694+
'dot.field': 'here'
695+
})).toBe(false);
653696
});
654697

655698
it('validates attributes on set()', () => {

0 commit comments

Comments
 (0)