diff --git a/integration/test/ParseObjectTest.js b/integration/test/ParseObjectTest.js index 8657f1f57..2fe64128b 100644 --- a/integration/test/ParseObjectTest.js +++ b/integration/test/ParseObjectTest.js @@ -2108,6 +2108,75 @@ describe('Parse Object', () => { }); }); + it('allow binding', async () => { + const object = new Parse.Object('TestObject2'); + object.bind.foo = 'bar'; + await object.save(); + expect(object.bind.foo).toBe('bar'); + expect(object.get('foo')).toBe('bar'); + expect(Object.keys(object.toJSON()).sort()).toEqual([ + 'createdAt', + 'foo', + 'objectId', + 'updatedAt', + ]); + + const query = new Parse.Query('TestObject2'); + const result = await query.get(object.id); + expect(result.bind.foo).toBe('bar'); + expect(result.get('foo')).toBe('bar'); + expect(result.id).toBe(object.id); + result.bind.foo = 'baz'; + expect(result.get('foo')).toBe('baz'); + await result.save(); + + const afterSave = await query.get(object.id); + expect(afterSave.bind.foo).toBe('baz'); + expect(afterSave.get('foo')).toBe('baz'); + }); + + it('allow binding on pointers', async () => { + const grandparent = new Parse.Object('DotGrandparent'); + grandparent.bind.foo = 'bar1'; + const parent = new Parse.Object('DotParent'); + parent.bind.foo = 'bar2'; + grandparent.bind.parent = parent; + const child = new Parse.Object('DotChild'); + child.bind.foo = 'bar3'; + parent.bind.child = child; + await Parse.Object.saveAll([child, parent, grandparent]); + expect(grandparent.bind.foo).toBe('bar1'); + expect(grandparent.bind.parent.bind.foo).toBe('bar2'); + expect(grandparent.bind.parent.bind.child.bind.foo).toBe('bar3'); + expect(grandparent.get('foo')).toBe('bar1'); + expect(grandparent.get('parent').get('foo')).toBe('bar2'); + expect(grandparent.get('parent').get('child').get('foo')).toBe('bar3'); + expect(Object.keys(grandparent.toJSON()).sort()).toEqual([ + 'createdAt', + 'foo', + 'objectId', + 'parent', + 'updatedAt', + ]); + expect(Object.keys(grandparent.bind.parent.toJSON()).sort()).toEqual([ + 'child', + 'createdAt', + 'foo', + 'objectId', + 'updatedAt', + ]); + expect(Object.keys(grandparent.bind.parent.bind.child.toJSON()).sort()).toEqual([ + 'createdAt', + 'foo', + 'objectId', + 'updatedAt', + ]); + const grandparentQuery = await new Parse.Query('DotGrandparent') + .include('parent', 'parent.child') + .first(); + expect(grandparentQuery.bind.parent.bind.child.bind.foo).toEqual('bar3'); + }); + describe('allowCustomObjectId saveAll', () => { it('can save without setting an objectId', async () => { await reconfigureServer({ allowCustomObjectId: true }); diff --git a/integration/test/ParseUserTest.js b/integration/test/ParseUserTest.js index 64b6f2bd9..6a041a3a5 100644 --- a/integration/test/ParseUserTest.js +++ b/integration/test/ParseUserTest.js @@ -1042,6 +1042,29 @@ describe('Parse User', () => { Parse.CoreManager.set('ENCRYPTED_KEY', null); }); + it('allow binding', async () => { + const user = new Parse.User(); + const username = uuidv4(); + user.bind.username = username; + user.bind.password = username; + user.bind.foo = 'bar'; + await user.signUp(); + expect(Object.keys(user.toJSON()).sort()).toEqual([ + 'createdAt', + 'foo', + 'objectId', + 'sessionToken', + 'updatedAt', + 'username', + ]); + expect(user.bind.username).toBe(username); + expect(user.bind.foo).toBe('bar'); + const userFromQuery = await new Parse.Query(Parse.User).first(); + expect(userFromQuery.bind.username).toBe(username); + expect(userFromQuery.bind.password).toBeUndefined(); + expect(userFromQuery.bind.foo).toBe('bar'); + }); + it('fix GHSA-wvh7-5p38-2qfc', async () => { Parse.User.enableUnsafeCurrentUser(); const user = new Parse.User(); diff --git a/package-lock.json b/package-lock.json index 4b48bec3e..e744e9e0a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "Apache-2.0", "dependencies": { "@babel/runtime-corejs3": "7.21.0", + "deepcopy": "2.1.0", "idb-keyval": "6.2.0", "react-native-crypto-js": "1.0.0", "uuid": "9.0.0", @@ -8696,7 +8697,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/deepcopy/-/deepcopy-2.1.0.tgz", "integrity": "sha512-8cZeTb1ZKC3bdSCP6XOM1IsTczIO73fdqtwa2B0N15eAz7gmyhQo+mc5gnFuulsgN3vIQYmTgbmQVKalH1dKvQ==", - "dev": true, "dependencies": { "type-detect": "^4.0.8" } @@ -26355,7 +26355,6 @@ "version": "4.0.8", "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", - "dev": true, "engines": { "node": ">=4" } @@ -34235,7 +34234,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/deepcopy/-/deepcopy-2.1.0.tgz", "integrity": "sha512-8cZeTb1ZKC3bdSCP6XOM1IsTczIO73fdqtwa2B0N15eAz7gmyhQo+mc5gnFuulsgN3vIQYmTgbmQVKalH1dKvQ==", - "dev": true, "requires": { "type-detect": "^4.0.8" } @@ -44884,7 +44882,7 @@ "version": "git+ssh://git@github.com/parse-community/parse-server.git#0f1979f814f69b8994cbf84949f6dcf659053d26", "integrity": "sha512-YOBKTFBp1nPZUXY71TkC/V1NWDGHQy+NSJY76+DNoe7liY23p/qHaIaqcRkhWPzxmhWaJWNMk8fE2rD6JzQdeA==", "dev": true, - "from": "parse-server@git+https://github.com/parse-community/parse-server#0f1979f814f69b8994cbf84949f6dcf659053d26", + "from": "parse-server@git+https://github.com/parse-community/parse-server#alpha", "requires": { "@babel/eslint-parser": "7.19.1", "@graphql-tools/merge": "8.3.6", @@ -48024,8 +48022,7 @@ "type-detect": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", - "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", - "dev": true + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==" }, "type-fest": { "version": "0.18.1", diff --git a/package.json b/package.json index 2ec5090a9..f880d852c 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ }, "dependencies": { "@babel/runtime-corejs3": "7.21.0", + "deepcopy": "2.1.0", "idb-keyval": "6.2.0", "react-native-crypto-js": "1.0.0", "uuid": "9.0.0", diff --git a/src/ParseObject.js b/src/ParseObject.js index 2ce00599b..1abe86577 100644 --- a/src/ParseObject.js +++ b/src/ParseObject.js @@ -14,6 +14,7 @@ import ParseError from './ParseError'; import ParseFile from './ParseFile'; import { when, continueWhile, resolvingPromise } from './promiseUtils'; import { DEFAULT_PIN, PIN_PREFIX } from './LocalDatastoreUtils'; +import proxyHandler from './proxy'; import { opFromJSON, @@ -136,6 +137,7 @@ class ParseObject { if (toSet && !this.set(toSet, options)) { throw new Error("Can't create an invalid Parse Object"); } + this._createProxy(); } /** @@ -148,6 +150,17 @@ class ParseObject { _objCount: number; className: string; + /** + * Bind, used for two way directonal binding using + * + * When using a responsive framework that supports binding to an object's keys, use `object.bind.key` for dynamic updating of a Parse.Object + * + * `object.get("key")` and `object.set("set")` is preffered for one way binding. + * + * @property {object} bind + */ + bind: AttributeMap; + /* Prototype getters / setters */ get attributes(): AttributeMap { @@ -367,6 +380,7 @@ class ParseObject { decoded.updatedAt = decoded.createdAt; } stateController.commitServerChanges(this._getStateIdentifier(), decoded); + this._createProxy(); } _setExisted(existed: boolean) { @@ -377,6 +391,10 @@ class ParseObject { } } + _createProxy() { + this.bind = new Proxy(this, proxyHandler); + } + _migrateId(serverId: string) { if (this._localId && serverId) { if (singleInstance) { @@ -1098,6 +1116,8 @@ class ParseObject { } } this._clearPendingOps(keysToRevert); + this._createProxy(); + return this; } /** @@ -1332,9 +1352,15 @@ class ParseObject { } const controller = CoreManager.getObjectController(); const unsaved = options.cascadeSave !== false ? unsavedChildren(this) : null; - return controller.save(unsaved, saveOptions).then(() => { - return controller.save(this, saveOptions); - }); + return controller + .save(unsaved, saveOptions) + .then(() => { + return controller.save(this, saveOptions); + }) + .then(res => { + this._createProxy(); + return res; + }); } /** @@ -1972,6 +1998,7 @@ class ParseObject { throw new Error("Can't create an invalid Parse Object"); } } + this._createProxy(); }; if (classMap[adjustedClassName]) { ParseObjectSubclass = classMap[adjustedClassName]; diff --git a/src/__tests__/Parse-test.js b/src/__tests__/Parse-test.js index 4bb09217c..256f78eab 100644 --- a/src/__tests__/Parse-test.js +++ b/src/__tests__/Parse-test.js @@ -6,6 +6,8 @@ jest.dontMock('../Parse'); jest.dontMock('../LocalDatastore'); jest.dontMock('crypto-js/aes'); jest.setMock('../EventuallyQueue', { poll: jest.fn() }); +jest.dontMock('../proxy'); +jest.dontMock('deepcopy'); global.indexedDB = require('./test_helpers/mockIndexedDB'); const CoreManager = require('../CoreManager'); diff --git a/src/__tests__/ParseObject-test.js b/src/__tests__/ParseObject-test.js index e1aa9ce9e..b17212b5e 100644 --- a/src/__tests__/ParseObject-test.js +++ b/src/__tests__/ParseObject-test.js @@ -23,6 +23,8 @@ jest.dontMock('../UniqueInstanceStateController'); jest.dontMock('../unsavedChildren'); jest.dontMock('../ParseACL'); jest.dontMock('../LocalDatastore'); +jest.dontMock('../proxy'); +jest.dontMock('deepcopy'); jest.mock('../uuid', () => { let value = 0; @@ -2568,6 +2570,76 @@ describe('ParseObject', () => { jest.runAllTicks(); }); }); + it('can save object with dot notation', async () => { + CoreManager.getRESTController()._setXHR( + mockXHR([ + { + status: 200, + response: { + objectId: 'P1', + }, + }, + ]) + ); + const obj = new ParseObject('TestObject'); + obj.bind.name = 'Foo'; + expect(Object.keys(obj.bind)).toEqual(['name']) + await obj.save(); + expect(obj.bind.name).toBe('Foo'); + expect(obj.toJSON()).toEqual({ name: 'Foo', objectId: 'P1' }); + expect(obj.attributes).toEqual({ name: 'Foo' }); + expect(obj.get('name')).toBe('Foo'); + }); + + it('can set and revert deep with dot notation', async () => { + CoreManager.getRESTController()._setXHR( + mockXHR([ + { + status: 200, + response: { objectId: 'I1', nested: { foo: { a: 1 } } }, + }, + ]) + ); + const object = await new ParseObject('Test').save(); + expect(object.id).toBe('I1'); + expect(object.bind.nested.foo).toEqual({ a: 1 }); + object.bind.a = '123'; + object.bind.nested.foo.a = 2; + expect(object.bind.nested.foo).toEqual({ a: 2 }); + expect(object.dirtyKeys()).toEqual(['a', 'nested']); + object.revert('a'); + expect(object.dirtyKeys()).toEqual(['nested']); + object.revert(); + expect(object.bind.nested.foo).toEqual({ a: 1 }); + expect(object.bind.a).toBeUndefined(); + expect(object.dirtyKeys()).toEqual([]); + object.bind.nested.foo.a = 2; + expect(object.bind.nested.foo).toEqual({ a: 2 }); + }); + + it('can delete with dot notation', async () => { + const obj = new ParseObject('TestObject'); + obj.bind.name = 'Foo'; + expect(obj.attributes).toEqual({ name: 'Foo' }); + expect(obj.get('name')).toBe('Foo'); + delete obj.bind.name; + expect(obj.op('name') instanceof ParseOp.UnsetOp).toEqual(true); + expect(obj.get('name')).toBeUndefined(); + expect(obj.attributes).toEqual({}); + }); + + it('can delete nested keys dot notation', async () => { + const obj = new ParseObject('TestObject', { name: { foo: { bar: 'a' } } }); + delete obj.bind.name.foo.bar; + expect(obj.bind.name.foo).toEqual({}); + }); + + it('can update nested array with dot notation', async () => { + const obj = new ParseObject('TestObject', { name: [{foo: { bar: 'a' } }] }); + obj.bind.name[0].foo.bar = 'b'; + expect(obj.get('name')).toEqual([{foo: { bar: 'b' } }]); + }); + }); describe('ObjectController', () => { diff --git a/src/__tests__/ParseQuery-test.js b/src/__tests__/ParseQuery-test.js index 622f9bb51..05a277acc 100644 --- a/src/__tests__/ParseQuery-test.js +++ b/src/__tests__/ParseQuery-test.js @@ -11,6 +11,8 @@ jest.dontMock('../ObjectStateMutations'); jest.dontMock('../LocalDatastore'); jest.dontMock('../OfflineQuery'); jest.dontMock('../LiveQuerySubscription'); +jest.dontMock('../proxy'); +jest.dontMock('deepcopy'); jest.mock('../uuid', () => { let value = 0; @@ -3797,4 +3799,19 @@ describe('ParseQuery LocalDatastore', () => { expect(subscription.sessionToken).toBe('r:test'); expect(subscription.query).toEqual(query); }); + + it('can query with dot notation', async () => { + CoreManager.setQueryController({ + aggregate() {}, + find() { + return Promise.resolve({ + results: [{ objectId: 'I1', size: 'small', name: 'Product 3' }], + }); + }, + }); + const object = await new ParseQuery('Item').equalTo('size', 'small').first(); + expect(object.id).toBe('I1'); + expect(object.bind.size).toBe('small'); + expect(object.bind.name).toBe('Product 3'); + }); }); diff --git a/src/proxy.js b/src/proxy.js new file mode 100644 index 000000000..3257681c2 --- /dev/null +++ b/src/proxy.js @@ -0,0 +1,91 @@ +import deepcopy from 'deepcopy'; +const nestedHandler = { + updateParent(key, value) { + const levels = this._path.split('.'); + levels.push(key); + const topLevel = levels[0]; + levels.shift(); + const scope = deepcopy(this._parent[topLevel]); + let target = scope; + const max_level = levels.length - 1; + levels.some((level, i) => { + if (typeof level === 'undefined') { + return true; + } + if (i === max_level) { + if (value == null) { + delete target[level]; + } else { + target[level] = value; + } + } else { + const obj = target[level] || (this._array ? [] : {}); + target = obj; + } + }); + this._parent[topLevel] = scope; + }, + get(target, key, receiver) { + const reflector = Reflect.get(target, key, receiver); + const prop = target[key]; + if ( + Array.isArray(prop) || + (Object.prototype.toString.call(prop) === '[object Object]' && + prop?.constructor?.name === 'Object') + ) { + const thisHandler = deepcopy(nestedHandler); + thisHandler._path = `${this._path}.${key}`; + thisHandler._parent = this._parent; + const isArray = Array.isArray(prop); + thisHandler._array = isArray; + return new Proxy(deepcopy(prop), thisHandler); + } + return reflector; + }, + set(target, key, value) { + target[key] = value; + this.updateParent(key, value); + return true; + }, + deleteProperty(target, key) { + const response = delete target[key]; + this.updateParent(key); + return response; + }, +}; +const proxyHandler = { + get(target, key, receiver) { + const getValue = target.get(key); + if ( + Object.prototype.toString.call(getValue) === '[object Object]' && + getValue?.constructor?.name === 'Object' + ) { + const thisHandler = deepcopy(nestedHandler); + thisHandler._path = key; + thisHandler._parent = receiver; + return new Proxy(deepcopy(getValue), thisHandler); + } + return getValue; + }, + + set(target, key, value) { + target.set(key, value); + return true; + }, + + deleteProperty(target, key) { + return target.unset(key); + }, + ownKeys(target) { + // called once to get a list of properties + return Object.keys(target.attributes); + }, + + getOwnPropertyDescriptor() { + return { + enumerable: true, + configurable: true, + }; + }, +}; +export default proxyHandler;