Skip to content

Improve dot notation for updating nested objects #729

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Feb 8, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
175 changes: 175 additions & 0 deletions integration/test/ParseObjectTest.js
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,181 @@ describe('Parse Object', () => {
});
});

it('can increment nested fields', async () => {
const obj = new TestObject();
obj.set('objectField', { number: 5 });
assert.equal(obj.get('objectField').number, 5);
await obj.save();

obj.increment('objectField.number', 15);
assert.equal(obj.get('objectField').number, 20);
await obj.save();

assert.equal(obj.get('objectField').number, 20);

const query = new Parse.Query(TestObject);
const result = await query.get(obj.id);
assert.equal(result.get('objectField').number, 20);
});

it('can increment non existing field', async () => {
const obj = new TestObject();
obj.set('objectField', { number: 5 });
await obj.save();

obj.increment('objectField.unknown', 15);
assert.deepEqual(obj.get('objectField'), {
number: 5,
unknown: 15,
});
await obj.save();

const query = new Parse.Query(TestObject);
const result = await query.get(obj.id);
assert.equal(result.get('objectField').number, 5);
assert.equal(result.get('objectField').unknown, 15);
});

it('can increment nested fields two levels', async () => {
const obj = new TestObject();
obj.set('objectField', { foo: { bar: 5 } });
assert.equal(obj.get('objectField').foo.bar, 5);
await obj.save();

obj.increment('objectField.foo.bar', 15);
assert.equal(obj.get('objectField').foo.bar, 20);
await obj.save();

assert.equal(obj.get('objectField').foo.bar, 20);

const query = new Parse.Query(TestObject);
const result = await query.get(obj.id);
assert.equal(result.get('objectField').foo.bar, 20);
});

it('can increment nested fields without object', async () => {
const obj = new TestObject();
obj.set('hello', 'world');
await obj.save();

obj.increment('hello.dot', 15);
try {
await obj.save();
assert.equal(false, true);
} catch(error) {
assert.equal(error.message, "Cannot create property 'dot' on string 'world'");
}
});

it('can set nested fields', async () => {
const obj = new TestObject({ objectField: { number: 5 } });
assert.equal(obj.get('objectField').number, 5);
await obj.save();

assert.equal(obj.get('objectField').number, 5);
obj.set('objectField.number', 20);
assert.equal(obj.get('objectField').number, 20);
await obj.save();

const query = new Parse.Query(TestObject);
const result = await query.get(obj.id);
assert.equal(result.get('objectField').number, 20);
});

it('can set non existing fields', async () => {
const obj = new TestObject();
obj.set('objectField', { number: 5 });
await obj.save();

obj.set('objectField.unknown', 20);
await obj.save();
const query = new Parse.Query(TestObject);
const result = await query.get(obj.id);
assert.equal(result.get('objectField').number, 5);
assert.equal(result.get('objectField').unknown, 20);
});

it('ignore set nested fields on new object', async () => {
const obj = new TestObject();
obj.set('objectField.number', 5);
assert.deepEqual(obj._getPendingOps()[0], {});
assert.equal(obj.get('objectField'), undefined);

await obj.save();
assert.equal(obj.get('objectField'), undefined);
});

it('can set nested fields two levels', async () => {
const obj = new TestObject({ objectField: { foo: { bar: 5 } } });
assert.equal(obj.get('objectField').foo.bar, 5);
await obj.save();

assert.equal(obj.get('objectField').foo.bar, 5);
obj.set('objectField.foo.bar', 20);
assert.equal(obj.get('objectField').foo.bar, 20);
await obj.save();

const query = new Parse.Query(TestObject);
const result = await query.get(obj.id);
assert.equal(result.get('objectField').foo.bar, 20);
});

it('can unset nested fields', async () => {
const obj = new TestObject({
objectField: {
number: 5,
string: 'hello',
}
});
await obj.save();

obj.unset('objectField.number');
assert.equal(obj.get('objectField').number, undefined);
assert.equal(obj.get('objectField').string, 'hello');
await obj.save();

const query = new Parse.Query(TestObject);
const result = await query.get(obj.id);
assert.equal(result.get('objectField').number, undefined);
assert.equal(result.get('objectField').string, 'hello');
});

it('can unset nested fields two levels', async () => {
const obj = new TestObject({
objectField: {
foo: {
bar: 5,
},
string: 'hello',
}
});
await obj.save();

obj.unset('objectField.foo.bar');
assert.equal(obj.get('objectField').foo.bar, undefined);
assert.equal(obj.get('objectField').string, 'hello');
await obj.save();

const query = new Parse.Query(TestObject);
const result = await query.get(obj.id);
assert.equal(result.get('objectField').foo.bar, undefined);
assert.equal(result.get('objectField').string, 'hello');
});

it('can unset non existing fields', async () => {
const obj = new TestObject();
obj.set('objectField', { number: 5 });
await obj.save();

obj.unset('objectField.unknown');
await obj.save();

const query = new Parse.Query(TestObject);
const result = await query.get(obj.id);
assert.equal(result.get('objectField').number, 5);
assert.equal(result.get('objectField').unknown, undefined);
});

it('can set keys to null', (done) => {
const obj = new TestObject();
obj.set('foo', null);
Expand Down
13 changes: 12 additions & 1 deletion src/ObjectStateMutations.js
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,18 @@ export function estimateAttributes(serverData: AttributeMap, pendingOps: Array<O
);
}
} else {
data[attr] = pendingOps[i][attr].applyTo(data[attr]);
if (attr.includes('.')) {
// convert a.b.c into { a: { b: { c: value } } }
const fields = attr.split('.');
const last = fields[fields.length - 1];
let object = Object.assign({}, data);
for (let i = 0; i < fields.length - 1; i++) {
object = object[fields[i]];
}
object[last] = pendingOps[i][attr].applyTo(object[last]);
} else {
data[attr] = pendingOps[i][attr].applyTo(data[attr]);
}
}
}
}
Expand Down
38 changes: 33 additions & 5 deletions src/ParseObject.js
Original file line number Diff line number Diff line change
Expand Up @@ -282,8 +282,24 @@ class ParseObject {
const dirtyObjects = this._getDirtyObjectAttributes();
const json = {};
let attr;

for (attr in dirtyObjects) {
json[attr] = new SetOp(dirtyObjects[attr]).toJSON();
let isDotNotation = false;
for (let i = 0; i < pending.length; i += 1) {
for (const field in pending[i]) {
// Dot notation operations are handled later
if (field.includes('.')) {
const fieldName = field.split('.')[0];
if (fieldName === attr) {
isDotNotation = true;
break;
}
}
}
}
if (!isDotNotation) {
json[attr] = new SetOp(dirtyObjects[attr]).toJSON();
}
}
for (attr in pending[0]) {
json[attr] = pending[0][attr].toJSON();
Expand Down Expand Up @@ -582,8 +598,8 @@ class ParseObject {
/**
* Sets a hash of model attributes on the object.
*
* <p>You can call it with an object containing keys and values, or with one
* key and value. For example:<pre>
* <p>You can call it with an object containing keys and values, with one
* key and value, or dot notation. For example:<pre>
* gameTurn.set({
* player: player1,
* diceRoll: 2
Expand All @@ -601,6 +617,8 @@ class ParseObject {
*
* game.set("finished", true);</pre></p>
*
* game.set("player.score", 10);</pre></p>
*
* @param {String} key The key to set.
* @param {} value The value to give it.
* @param {Object} options A set of options for the set.
Expand Down Expand Up @@ -661,8 +679,18 @@ class ParseObject {
}
}

// Calculate new values
const currentAttributes = this.attributes;

// Only set nested fields if exists
const serverData = this._getServerData();
if (typeof key === 'string' && key.includes('.')) {
const field = key.split('.')[0];
if (!serverData[field]) {
return this;
}
}

// Calculate new values
const newValues = {};
for (const attr in newOps) {
if (newOps[attr] instanceof RelationOp) {
Expand Down Expand Up @@ -910,7 +938,7 @@ class ParseObject {
);
}
for (const key in attrs) {
if (!(/^[A-Za-z][0-9A-Za-z_]*$/).test(key)) {
if (!(/^[A-Za-z][0-9A-Za-z_.]*$/).test(key)) {
return new ParseError(ParseError.INVALID_KEY_NAME);
}
}
Expand Down
16 changes: 16 additions & 0 deletions src/__tests__/ObjectStateMutations-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,22 @@ describe('ObjectStateMutations', () => {
expect(attributes.likes.key).toBe('likes');
});

it('can estimate attributes for nested documents', () => {
const serverData = { objectField: { counter: 10 } };
let pendingOps = [{ 'objectField.counter': new ParseOps.IncrementOp(2) }];
expect(ObjectStateMutations.estimateAttributes(serverData, pendingOps, 'someClass', 'someId')).toEqual({
objectField: {
counter: 12
},
});
pendingOps = [{ 'objectField.counter': new ParseOps.SetOp(20) }];
expect(ObjectStateMutations.estimateAttributes(serverData, pendingOps, 'someClass', 'someId')).toEqual({
objectField: {
counter: 20
},
});
});

it('can commit changes from the server', () => {
const serverData = {};
const objectCache = {};
Expand Down
43 changes: 43 additions & 0 deletions src/__tests__/ParseObject-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -474,6 +474,45 @@ describe('ParseObject', () => {
expect(o2.attributes).toEqual({ age: 41 });
});

it('can set nested field', () => {
const o = new ParseObject('Person');
o._finishFetch({
objectId: 'setNested',
objectField: {
number: 5
},
otherField: {},
});

expect(o.attributes).toEqual({
objectField: { number: 5 },
otherField: {},
});
o.set('otherField', { hello: 'world' });
o.set('objectField.number', 20);

expect(o.attributes).toEqual({
objectField: { number: 20 },
otherField: { hello: 'world' },
});
expect(o.op('objectField.number') instanceof SetOp).toBe(true);
expect(o.dirtyKeys()).toEqual(['otherField', 'objectField.number', 'objectField']);
expect(o._getSaveJSON()).toEqual({
'objectField.number': 20,
otherField: { hello: 'world' },
});
});

it('ignore set nested field on new object', () => {
const o = new ParseObject('Person');
o.set('objectField.number', 20);

expect(o.attributes).toEqual({});
expect(o.op('objectField.number') instanceof SetOp).toBe(false);
expect(o.dirtyKeys()).toEqual([]);
expect(o._getSaveJSON()).toEqual({});
});

it('can add elements to an array field', () => {
const o = new ParseObject('Schedule');
o.add('available', 'Monday');
Expand Down Expand Up @@ -650,6 +689,10 @@ describe('ParseObject', () => {
expect(o.validate({
noProblem: 'here'
})).toBe(false);

expect(o.validate({
'dot.field': 'here'
})).toBe(false);
});

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