Skip to content

Commit afacddb

Browse files
authored
Merge a7f5387 into c67fe66
2 parents c67fe66 + a7f5387 commit afacddb

File tree

7 files changed

+292
-3
lines changed

7 files changed

+292
-3
lines changed

integration/test/ParseObjectTest.js

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2108,6 +2108,75 @@ describe('Parse Object', () => {
21082108
});
21092109
});
21102110

2111+
it('allow binding', async () => {
2112+
const object = new Parse.Object('TestObject2');
2113+
object.bind.foo = 'bar';
2114+
await object.save();
2115+
expect(object.bind.foo).toBe('bar');
2116+
expect(object.get('foo')).toBe('bar');
2117+
expect(Object.keys(object.toJSON()).sort()).toEqual([
2118+
'createdAt',
2119+
'foo',
2120+
'objectId',
2121+
'updatedAt',
2122+
]);
2123+
2124+
const query = new Parse.Query('TestObject2');
2125+
const result = await query.get(object.id);
2126+
expect(result.bind.foo).toBe('bar');
2127+
expect(result.get('foo')).toBe('bar');
2128+
expect(result.id).toBe(object.id);
2129+
result.bind.foo = 'baz';
2130+
expect(result.get('foo')).toBe('baz');
2131+
await result.save();
2132+
2133+
const afterSave = await query.get(object.id);
2134+
expect(afterSave.bind.foo).toBe('baz');
2135+
expect(afterSave.get('foo')).toBe('baz');
2136+
});
2137+
2138+
it('allow binding on pointers', async () => {
2139+
const grandparent = new Parse.Object('DotGrandparent');
2140+
grandparent.bind.foo = 'bar1';
2141+
const parent = new Parse.Object('DotParent');
2142+
parent.bind.foo = 'bar2';
2143+
grandparent.bind.parent = parent;
2144+
const child = new Parse.Object('DotChild');
2145+
child.bind.foo = 'bar3';
2146+
parent.bind.child = child;
2147+
await Parse.Object.saveAll([child, parent, grandparent]);
2148+
expect(grandparent.bind.foo).toBe('bar1');
2149+
expect(grandparent.bind.parent.bind.foo).toBe('bar2');
2150+
expect(grandparent.bind.parent.bind.child.bind.foo).toBe('bar3');
2151+
expect(grandparent.get('foo')).toBe('bar1');
2152+
expect(grandparent.get('parent').get('foo')).toBe('bar2');
2153+
expect(grandparent.get('parent').get('child').get('foo')).toBe('bar3');
2154+
expect(Object.keys(grandparent.toJSON()).sort()).toEqual([
2155+
'createdAt',
2156+
'foo',
2157+
'objectId',
2158+
'parent',
2159+
'updatedAt',
2160+
]);
2161+
expect(Object.keys(grandparent.bind.parent.toJSON()).sort()).toEqual([
2162+
'child',
2163+
'createdAt',
2164+
'foo',
2165+
'objectId',
2166+
'updatedAt',
2167+
]);
2168+
expect(Object.keys(grandparent.bind.parent.bind.child.toJSON()).sort()).toEqual([
2169+
'createdAt',
2170+
'foo',
2171+
'objectId',
2172+
'updatedAt',
2173+
]);
2174+
const grandparentQuery = await new Parse.Query('DotGrandparent')
2175+
.include('parent', 'parent.child')
2176+
.first();
2177+
expect(grandparentQuery.bind.parent.bind.child.bind.foo).toEqual('bar3');
2178+
});
2179+
21112180
describe('allowCustomObjectId saveAll', () => {
21122181
it('can save without setting an objectId', async () => {
21132182
await reconfigureServer({ allowCustomObjectId: true });

integration/test/ParseUserTest.js

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1042,6 +1042,29 @@ describe('Parse User', () => {
10421042
Parse.CoreManager.set('ENCRYPTED_KEY', null);
10431043
});
10441044

1045+
it('allow binding', async () => {
1046+
const user = new Parse.User();
1047+
const username = uuidv4();
1048+
user.bind.username = username;
1049+
user.bind.password = username;
1050+
user.bind.foo = 'bar';
1051+
await user.signUp();
1052+
expect(Object.keys(user.toJSON()).sort()).toEqual([
1053+
'createdAt',
1054+
'foo',
1055+
'objectId',
1056+
'sessionToken',
1057+
'updatedAt',
1058+
'username',
1059+
]);
1060+
expect(user.bind.username).toBe(username);
1061+
expect(user.bind.foo).toBe('bar');
1062+
const userFromQuery = await new Parse.Query(Parse.User).first();
1063+
expect(userFromQuery.bind.username).toBe(username);
1064+
expect(userFromQuery.bind.password).toBeUndefined();
1065+
expect(userFromQuery.bind.foo).toBe('bar');
1066+
});
1067+
10451068
it('fix GHSA-wvh7-5p38-2qfc', async () => {
10461069
Parse.User.enableUnsafeCurrentUser();
10471070
const user = new Parse.User();

src/ParseObject.js

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import ParseError from './ParseError';
1414
import ParseFile from './ParseFile';
1515
import { when, continueWhile, resolvingPromise } from './promiseUtils';
1616
import { DEFAULT_PIN, PIN_PREFIX } from './LocalDatastoreUtils';
17+
import proxyHandler from './proxy';
1718

1819
import {
1920
opFromJSON,
@@ -136,6 +137,7 @@ class ParseObject {
136137
if (toSet && !this.set(toSet, options)) {
137138
throw new Error("Can't create an invalid Parse Object");
138139
}
140+
this._createProxy();
139141
}
140142

141143
/**
@@ -148,6 +150,17 @@ class ParseObject {
148150
_objCount: number;
149151
className: string;
150152

153+
/**
154+
* Bind, used for two way directonal binding using
155+
*
156+
* When using a responsive framework that supports binding to an object's keys, use `object.bind.key` for dynamic updating of a Parse.Object
157+
*
158+
* `object.get("key")` and `object.set("set")` is preffered for one way binding.
159+
*
160+
* @property {object} bind
161+
*/
162+
bind: AttributeMap;
163+
151164
/* Prototype getters / setters */
152165

153166
get attributes(): AttributeMap {
@@ -367,6 +380,7 @@ class ParseObject {
367380
decoded.updatedAt = decoded.createdAt;
368381
}
369382
stateController.commitServerChanges(this._getStateIdentifier(), decoded);
383+
this._createProxy();
370384
}
371385

372386
_setExisted(existed: boolean) {
@@ -377,6 +391,10 @@ class ParseObject {
377391
}
378392
}
379393

394+
_createProxy() {
395+
this.bind = new Proxy(this, proxyHandler);
396+
}
397+
380398
_migrateId(serverId: string) {
381399
if (this._localId && serverId) {
382400
if (singleInstance) {
@@ -1098,6 +1116,8 @@ class ParseObject {
10981116
}
10991117
}
11001118
this._clearPendingOps(keysToRevert);
1119+
this._createProxy();
1120+
return this;
11011121
}
11021122

11031123
/**
@@ -1332,9 +1352,15 @@ class ParseObject {
13321352
}
13331353
const controller = CoreManager.getObjectController();
13341354
const unsaved = options.cascadeSave !== false ? unsavedChildren(this) : null;
1335-
return controller.save(unsaved, saveOptions).then(() => {
1336-
return controller.save(this, saveOptions);
1337-
});
1355+
return controller
1356+
.save(unsaved, saveOptions)
1357+
.then(() => {
1358+
return controller.save(this, saveOptions);
1359+
})
1360+
.then(res => {
1361+
this._createProxy();
1362+
return res;
1363+
});
13381364
}
13391365

13401366
/**
@@ -1972,6 +1998,7 @@ class ParseObject {
19721998
throw new Error("Can't create an invalid Parse Object");
19731999
}
19742000
}
2001+
this._createProxy();
19752002
};
19762003
if (classMap[adjustedClassName]) {
19772004
ParseObjectSubclass = classMap[adjustedClassName];

src/__tests__/Parse-test.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ jest.dontMock('../Parse');
66
jest.dontMock('../LocalDatastore');
77
jest.dontMock('crypto-js/aes');
88
jest.setMock('../EventuallyQueue', { poll: jest.fn() });
9+
jest.dontMock('../proxy');
910

1011
global.indexedDB = require('./test_helpers/mockIndexedDB');
1112
const CoreManager = require('../CoreManager');

src/__tests__/ParseObject-test.js

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ jest.dontMock('../UniqueInstanceStateController');
2323
jest.dontMock('../unsavedChildren');
2424
jest.dontMock('../ParseACL');
2525
jest.dontMock('../LocalDatastore');
26+
jest.dontMock('../proxy');
2627

2728
jest.mock('../uuid', () => {
2829
let value = 0;
@@ -2568,6 +2569,70 @@ describe('ParseObject', () => {
25682569
jest.runAllTicks();
25692570
});
25702571
});
2572+
it('can save object with dot notation', async () => {
2573+
CoreManager.getRESTController()._setXHR(
2574+
mockXHR([
2575+
{
2576+
status: 200,
2577+
response: {
2578+
objectId: 'P1',
2579+
},
2580+
},
2581+
])
2582+
);
2583+
const obj = new ParseObject('TestObject');
2584+
obj.bind.name = 'Foo';
2585+
expect(Object.keys(obj.bind)).toEqual(['name'])
2586+
await obj.save();
2587+
expect(obj.bind.name).toBe('Foo');
2588+
expect(obj.toJSON()).toEqual({ name: 'Foo', objectId: 'P1' });
2589+
expect(obj.attributes).toEqual({ name: 'Foo' });
2590+
expect(obj.get('name')).toBe('Foo');
2591+
});
2592+
2593+
it('can set and revert deep with dot notation', async () => {
2594+
CoreManager.getRESTController()._setXHR(
2595+
mockXHR([
2596+
{
2597+
status: 200,
2598+
response: { objectId: 'I1', nested: { foo: { a: 1 } } },
2599+
},
2600+
])
2601+
);
2602+
const object = await new ParseObject('Test').save();
2603+
expect(object.id).toBe('I1');
2604+
expect(object.bind.nested.foo).toEqual({ a: 1 });
2605+
object.bind.a = '123';
2606+
object.bind.nested.foo.a = 2;
2607+
expect(object.bind.nested.foo).toEqual({ a: 2 });
2608+
expect(object.dirtyKeys()).toEqual(['a', 'nested']);
2609+
object.revert('a');
2610+
expect(object.dirtyKeys()).toEqual(['nested']);
2611+
object.revert();
2612+
expect(object.bind.nested.foo).toEqual({ a: 1 });
2613+
expect(object.bind.a).toBeUndefined();
2614+
expect(object.dirtyKeys()).toEqual([]);
2615+
object.bind.nested.foo.a = 2;
2616+
expect(object.bind.nested.foo).toEqual({ a: 2 });
2617+
});
2618+
2619+
it('can delete with dot notation', async () => {
2620+
const obj = new ParseObject('TestObject');
2621+
obj.bind.name = 'Foo';
2622+
expect(obj.attributes).toEqual({ name: 'Foo' });
2623+
expect(obj.get('name')).toBe('Foo');
2624+
delete obj.bind.name;
2625+
expect(obj.op('name') instanceof ParseOp.UnsetOp).toEqual(true);
2626+
expect(obj.get('name')).toBeUndefined();
2627+
expect(obj.attributes).toEqual({});
2628+
});
2629+
2630+
it('can delete nested keys dot notation', async () => {
2631+
const obj = new ParseObject('TestObject', { name: { foo: { bar: 'a' } } });
2632+
delete obj.bind.name.foo.bar;
2633+
expect(obj.bind.name.foo).toEqual({});
2634+
});
2635+
25712636
});
25722637

25732638
describe('ObjectController', () => {

src/__tests__/ParseQuery-test.js

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ jest.dontMock('../ObjectStateMutations');
1111
jest.dontMock('../LocalDatastore');
1212
jest.dontMock('../OfflineQuery');
1313
jest.dontMock('../LiveQuerySubscription');
14+
jest.dontMock('../proxy');
1415

1516
jest.mock('../uuid', () => {
1617
let value = 0;
@@ -3797,4 +3798,19 @@ describe('ParseQuery LocalDatastore', () => {
37973798
expect(subscription.sessionToken).toBe('r:test');
37983799
expect(subscription.query).toEqual(query);
37993800
});
3801+
3802+
it('can query with dot notation', async () => {
3803+
CoreManager.setQueryController({
3804+
aggregate() {},
3805+
find() {
3806+
return Promise.resolve({
3807+
results: [{ objectId: 'I1', size: 'small', name: 'Product 3' }],
3808+
});
3809+
},
3810+
});
3811+
const object = await new ParseQuery('Item').equalTo('size', 'small').first();
3812+
expect(object.id).toBe('I1');
3813+
expect(object.bind.size).toBe('small');
3814+
expect(object.bind.name).toBe('Product 3');
3815+
});
38003816
});

src/proxy.js

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
const nestedHandler = {
2+
updateParent(key, value) {
3+
const levels = this._path.split('.');
4+
levels.push(key);
5+
const topLevel = levels[0];
6+
levels.shift();
7+
const scope = JSON.parse(JSON.stringify(this._parent[topLevel]));
8+
let target = scope;
9+
const max_level = levels.length - 1;
10+
for (let i = 0; i < levels.length; i++) {
11+
const level = levels[i];
12+
if (typeof level === 'undefined') {
13+
break;
14+
}
15+
if (i === max_level) {
16+
if (value == null) {
17+
delete target[level];
18+
} else {
19+
target[level] = value;
20+
}
21+
} else {
22+
const obj = target[level] || {};
23+
target = obj;
24+
}
25+
}
26+
this._parent[topLevel] = scope;
27+
},
28+
get(target, key, receiver) {
29+
const reflector = Reflect.get(target, key, receiver);
30+
const prop = target[key];
31+
if (
32+
Object.prototype.toString.call(prop) === '[object Object]' &&
33+
prop?.constructor?.name === 'Object'
34+
) {
35+
const thisHandler = { ...nestedHandler };
36+
thisHandler._path = `${this._path}.${key}`;
37+
thisHandler._parent = this._parent;
38+
return new Proxy({ ...prop }, thisHandler);
39+
}
40+
return reflector;
41+
},
42+
set(target, key, value) {
43+
target[key] = value;
44+
this.updateParent(key, value);
45+
return true;
46+
},
47+
deleteProperty(target, key) {
48+
const response = delete target[key];
49+
this.updateParent(key);
50+
return response;
51+
},
52+
};
53+
const proxyHandler = {
54+
get(target, key, receiver) {
55+
const getValue = target.get(key);
56+
if (
57+
Object.prototype.toString.call(getValue) === '[object Object]' &&
58+
getValue?.constructor?.name === 'Object'
59+
) {
60+
const thisHandler = { ...nestedHandler };
61+
thisHandler._path = key;
62+
thisHandler._parent = receiver;
63+
return new Proxy({ ...getValue }, thisHandler);
64+
}
65+
return getValue;
66+
},
67+
68+
set(target, key, value) {
69+
target.set(key, value);
70+
return true;
71+
},
72+
73+
deleteProperty(target, key) {
74+
return target.unset(key);
75+
},
76+
ownKeys(target) {
77+
// called once to get a list of properties
78+
return Object.keys(target.attributes);
79+
},
80+
81+
getOwnPropertyDescriptor() {
82+
return {
83+
enumerable: true,
84+
configurable: true,
85+
};
86+
},
87+
};
88+
export default proxyHandler;

0 commit comments

Comments
 (0)