From 89f38f7e3ab61a3d63708ac4f8516b4f6fed583f Mon Sep 17 00:00:00 2001 From: Benjamin Wilson Friedman Date: Thu, 7 Sep 2017 17:06:25 -0700 Subject: [PATCH] ParseObject encode/decode (#351) * Initial working for encode/decode functionality * Changed markup for PHP * Adjust encode/decode methods * Modified ParseRelation for proper encoding, intended for usage in encode/decode for ParseObject * Later fetch to pass on travis * Checking formatted dates to avoid issues with microseconds having 6 digits natively in php and 3 digits for parse * Removed redundant code path for ACL data, is always handled & removed in advance * Removed redundant check & fixed exception message typo * removed additional redundant check & upped tests * Use 'operationSet' instead of estimatedData for a more accurate snapshot * lint * Using dates formatted for parse server --- README.md | 7 +- src/Parse/Internal/ParseRelationOperation.php | 5 +- src/Parse/ParseObject.php | 169 +++++++++++- src/Parse/ParseRelation.php | 16 +- tests/Parse/ParseObjectTest.php | 254 ++++++++++++++++++ tests/Parse/ParseRelationOperationTest.php | 12 +- 6 files changed, 446 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 06397e27..df19743b 100644 --- a/README.md +++ b/README.md @@ -59,12 +59,13 @@ ParseClient::initialize( $app_id, null, $master_key ); ParseClient::setServerURL('https://my-parse-server.com:port','parse'); ``` -Notice -Parse server's default port is `1337` and the second parameter `parse` is the route prefix of your parse server. +Notice Parse server's default port is `1337` and the second parameter `parse` is the route prefix of your parse server. For example if your parse server's url is `http://example.com:1337/parse` then you can set the server url using the following snippet -`ParseClient::setServerURL('https://example.com:1337','parse');` +```php +ParseClient::setServerURL('https://example.com:1337','parse'); +``` Getting Started --------------- diff --git a/src/Parse/Internal/ParseRelationOperation.php b/src/Parse/Internal/ParseRelationOperation.php index 6e2b4e7e..c85b1681 100644 --- a/src/Parse/Internal/ParseRelationOperation.php +++ b/src/Parse/Internal/ParseRelationOperation.php @@ -117,9 +117,6 @@ private function addObjects($objects, &$container) */ private function removeObjects($objects, &$container) { - if (!is_array($objects)) { - $objects = [$objects]; - } $nullObjects = []; foreach ($objects as $object) { if ($object->getObjectId() == null) { @@ -186,7 +183,7 @@ public function _mergeWithPrevious($previous) && $previous->targetClassName != $this->targetClassName ) { throw new Exception( - 'Related object object must be of class ' + 'Related object must be of class ' .$this->targetClassName.', but '.$previous->targetClassName .' was passed in.', 103 diff --git a/src/Parse/ParseObject.php b/src/Parse/ParseObject.php index 125f969f..94648d4a 100644 --- a/src/Parse/ParseObject.php +++ b/src/Parse/ParseObject.php @@ -12,6 +12,7 @@ use Parse\Internal\Encodable; use Parse\Internal\FieldOperation; use Parse\Internal\IncrementOperation; +use Parse\Internal\ParseRelationOperation; use Parse\Internal\RemoveOperation; use Parse\Internal\SetOperation; @@ -673,9 +674,6 @@ private function mergeFromServer($data, $completeData = true) $decodedValue = new ParseRelation($this, $key, $className); } } - if ($key == 'ACL') { - $decodedValue = ParseACL::_createACLFromJSON($decodedValue); - } } $this->serverData[$key] = $decodedValue; $this->dataAvailability[$key] = true; @@ -688,13 +686,10 @@ private function mergeFromServer($data, $completeData = true) /** * Merge data from other object. * - * @param ParseObject $other + * @param ParseObject $other Other object to merge data from */ private function mergeFromObject($other) { - if (!$other) { - return; - } $this->objectId = $other->getObjectId(); $this->createdAt = $other->getCreatedAt(); $this->updatedAt = $other->getUpdatedAt(); @@ -726,6 +721,7 @@ public function _mergeMagicFields(&$data) if (isset($data['ACL'])) { $acl = ParseACL::_createACLFromJSON($data['ACL']); $this->serverData['ACL'] = $acl; + $this->dataAvailability['ACL'] = true; unset($data['ACL']); } } @@ -952,10 +948,167 @@ public function _encode() $out[$key] = $value; } } - return json_encode($out); } + /** + * Returns a JSON encoded value of a ParseObject, + * defers to encodeObject internally + * + * @return string + */ + public function encode() + { + $encoded = [ + 'className' => $this->className, + 'serverData' => [], + 'operationSet' => [] + ]; + + // add special fields + if (isset($this->objectId)) { + $encoded['objectId'] = $this->objectId; + } + if (isset($this->createdAt)) { + $encoded['serverData']['createdAt'] = ParseClient::_encode( + $this->createdAt, + false + ); + } + if (isset($this->updatedAt)) { + $encoded['serverData']['updatedAt'] = ParseClient::_encode( + $this->updatedAt, + false + ); + } + + // add server data + foreach ($this->serverData as $key => $value) { + $encoded['serverData'][$key] = ParseClient::_encode($value, true); + } + + // add pending ops + foreach ($this->operationSet as $key => $op) { + $encoded['operationSet'][$key] = $op->_encode(); + } + + return json_encode($encoded); + } + + /** + * Decodes and returns an encoded ParseObject + * + * @param string|array $encoded Encoded ParseObject to decode + * @return ParseObject + * @throws ParseException + */ + public static function decode($encoded) + { + if (!is_array($encoded)) { + // decode this string + $encoded = json_decode($encoded, true); + } + + // pull out objectId, if set + $objectId = isset($encoded['objectId']) ? $encoded['objectId'] : null; + + // recreate this object + $obj = ParseObject::create($encoded['className'], $objectId, !isset($objectId)); + + if (isset($encoded['serverData']['createdAt'])) { + $encoded['serverData']['createdAt'] = ParseClient::getProperDateFormat( + ParseClient::_decode($encoded['serverData']['createdAt']) + ); + } + if (isset($encoded['serverData']['updatedAt'])) { + $encoded['serverData']['updatedAt'] = ParseClient::getProperDateFormat( + ParseClient::_decode($encoded['serverData']['updatedAt']) + ); + } + + // unset className + unset($encoded['className']); + + // set server data + $obj->_mergeAfterFetch($encoded['serverData']); + + // reinstate op set + foreach ($encoded['operationSet'] as $key => $value) { + if (is_array($value)) { + if (isset($value['__op'])) { + $op = $value['__op']; + + if ($op === 'Add') { + $obj->_performOperation( + $key, + new AddOperation(ParseClient::_decode($value['objects'])) + ); + } elseif ($op === 'AddUnique') { + $obj->_performOperation( + $key, + new AddUniqueOperation(ParseClient::_decode($value['objects'])) + ); + } elseif ($op === 'Delete') { + $obj->_performOperation($key, new DeleteOperation()); + } elseif ($op === 'Increment') { + $obj->_performOperation( + $key, + new IncrementOperation($value['amount']) + ); + } elseif ($op === 'AddRelation') { + $obj->_performOperation( + $key, + new ParseRelationOperation(ParseClient::_decode($value['objects']), null) + ); + } elseif ($op === 'RemoveRelation') { + $obj->_performOperation( + $key, + new ParseRelationOperation(null, ParseClient::_decode($value['objects'])) + ); + } elseif ($op === 'Batch') { + $ops = $value['ops']; + $obj->_performOperation( + $key, + new ParseRelationOperation( + ParseClient::_decode($ops[0]['objects']), + ParseClient::_decode($ops[1]['objects']) + ) + ); + } elseif ($op === 'Remove') { + $obj->_performOperation( + $key, + new RemoveOperation(ParseClient::_decode($value['objects'])) + ); + } else { + throw new ParseException("Unrecognized op '{$op}' found during decode."); + } + } else { + if (isset($value['__type'])) { + // encoded object + $obj->_performOperation($key, new SetOperation(ParseClient::_decode($value))); + } elseif ($key === 'ACL') { + // encoded ACL + $obj->_performOperation($key, new SetOperation(ParseACL::_createACLFromJSON($value))); + } else { + // array + if (count(array_filter(array_keys($value), 'is_string')) > 0) { + // associative + $obj->_performOperation($key, new SetOperation($value, true)); + } else { + // sequential + $obj->_performOperation($key, new SetOperation($value)); + } + } + } + } else { + // set op (not an associative array) + $obj->_performOperation($key, new SetOperation($value)); + } + } + + return $obj; + } + /** * Returns JSON object of the unsaved operations. * diff --git a/src/Parse/ParseRelation.php b/src/Parse/ParseRelation.php index 90fd2bb4..a881d099 100644 --- a/src/Parse/ParseRelation.php +++ b/src/Parse/ParseRelation.php @@ -5,6 +5,7 @@ namespace Parse; +use Parse\Internal\Encodable; use Parse\Internal\ParseRelationOperation; /** @@ -14,7 +15,7 @@ * @author Mohamed Madbouli * @package Parse */ -class ParseRelation +class ParseRelation implements Encodable { /** * The parent of this relation. @@ -124,4 +125,17 @@ public function getQuery() return $query; } + + /** + * Return an encoded array of this relation. + * + * @return array + */ + public function _encode() + { + return [ + '__type' => 'Relation', + 'className' => $this->targetClassName + ]; + } } diff --git a/tests/Parse/ParseObjectTest.php b/tests/Parse/ParseObjectTest.php index efd323f9..82780817 100644 --- a/tests/Parse/ParseObjectTest.php +++ b/tests/Parse/ParseObjectTest.php @@ -7,10 +7,14 @@ use Parse\Internal\SetOperation; use Parse\ParseACL; use Parse\ParseClient; +use Parse\ParseFile; +use Parse\ParseGeoPoint; use Parse\ParseInstallation; use Parse\ParseObject; +use Parse\ParsePolygon; use Parse\ParsePushStatus; use Parse\ParseQuery; +use Parse\ParseRelation; use Parse\ParseRole; use Parse\ParseSession; use Parse\ParseUser; @@ -1380,6 +1384,9 @@ public function testRevertingUnsavedChangesViaFetch() $obj->destroy(); } + /** + * @group merge-from-server + */ public function testMergeFromServer() { $obj = new ParseObject('TestClass'); @@ -1474,6 +1481,7 @@ public function testGettingQueryForUnregisteredSubclass() */ public function testEncodeEncodable() { + $obj = new ParseObject('TestClass'); // set an Encodable value $encodable1 = new SetOperation(['key'=>'value']); @@ -1488,4 +1496,250 @@ public function testEncodeEncodable() $this->assertEquals($encoded['key1'], $encodable1->_encode()); $this->assertEquals($encoded['key2'][0], $encodable2->_encode()); } + + /** + * Returns an object with one of every type set + * + * @return ParseObject + */ + private function getTestObject() + { + $obj = new ParseObject('TestClass'); + + // setup IVs + $stringVal = 'this-is-foo'; + $numberVal = 32.23; + + // use a 'clean' date value + $dateVal = new \DateTime(); + $dateVal = ParseClient::_encode($dateVal, false); + $dateVal = ParseClient::_decode($dateVal); + + $boolVal = false; + $arrayVal = ['bar1','bar2']; + $assocVal = ['foo1' => 'bar1']; + $polygon = new ParsePolygon([[0,0],[0,1],[1,1]]); + $geoPoint = new ParseGeoPoint(1, 0); + + $child = new ParseObject('TestClass'); + $child->save(); + $child = ParseObject::create('TestClass', $child->getObjectId()); + + $file = ParseFile::createFromData('a file', 'test.txt', 'text/plain'); + $file->save(); + + $acl = new ParseACL(); + $acl->setPublicReadAccess(true); + $acl->setPublicWriteAccess(true); + $obj->setACL($acl); + + // set IVs + $obj->set('foo', $stringVal); + $obj->set('number', $numberVal); + $obj->set('date', $dateVal); + $obj->set('bool', $boolVal); + $obj->setArray('array', $arrayVal); + $obj->setAssociativeArray('assoc_array', $assocVal); + $obj->set('pointer', $child); + $obj->set('file', $file); + $obj->set('polygon', $polygon); + $obj->set('geopoint', $geoPoint); + $relation = $obj->getRelation('relation', 'TestClass'); + $relation->add([$child]); + + return $obj; + } + + /** + * Runs tests on encoding/decoding an unsaved ParseObject + * @group decode-test + */ + public function testDecodeOnObject() + { + $obj = $this->getTestObject(); + + $encoded = $obj->encode(); + $decoded = ParseObject::decode($encoded); + + // pull out file to compare separately + $decodedFile = $decoded->get('file'); + $origFile = $obj->get('file'); + $decoded->delete('file'); + $obj->delete('file'); + + $this->assertEquals($obj, $decoded, 'Objects did not match'); + + // check files separately + $this->assertEquals($origFile->_encode(), $decodedFile->_encode(), 'Files did not match'); + + // check that we can still revert these changes + $this->assertTrue($obj->has('foo')); + $obj->revert(); + $this->assertFalse($obj->has('foo')); + } + + /** + * Runs tests on encoding/decoding a ParseObject that has been saved + * + * @group decode-test + */ + public function testDecodeOnSavedObject() + { + // setup IVs + $stringVal = 'this-is-foo'; + $numberVal = 32.23; + $boolVal = false; + $arrayVal = ['bar1','bar2']; + $assocVal = ['foo1' => 'bar1']; + $polygon = new ParsePolygon([[0,0],[0,1],[1,1]]); + $geoPoint = new ParseGeoPoint(1, 0); + + $child = new ParseObject('TestClass'); + $child->save(); + $child = ParseObject::create('TestClass', $child->getObjectId()); + + $obj = $this->getTestObject(); + + // change to a pointer we can check against + $obj->set('pointer', $child); + $relation = $obj->getRelation('relation', 'TestClass'); + $relation->remove([$child]); + + // not testing file comparisons, as the the content type differs slightly + // this is tested above in 'testDecodeOnObject' + $obj->delete('file'); + + $obj->save(); + + // add an unsaved modifications + $obj->set('unsaved', 'not a saved value'); + + $encoded = $obj->encode(); + + $decoded = ParseObject::decode($encoded); + + $this->assertNotNull($decoded->getCreatedAt(), 'Created at was not set'); + $this->assertNotNull($decoded->getUpdatedAt(), 'Updated at was not set'); + + //$this->assertEquals($encoded, $decoded->encode(), 'Encoded strings did not match'); + $this->assertEquals($obj, $decoded, 'Decoded object did not match original'); + + // verify IVs + $this->assertEquals($obj->getObjectId(), $decoded->getObjectId(), 'Object ids did not match'); + $this->assertEquals($obj->getCreatedAt(), $decoded->getCreatedAt(), 'Created at did not match'); + $this->assertEquals($obj->getUpdatedAt(), $decoded->getUpdatedAt(), 'Updated at did not match'); + $this->assertEquals($stringVal, $decoded->get('foo'), 'Strings did not match'); + $this->assertEquals($numberVal, $decoded->get('number'), 'Numbers did not match'); + $this->assertEquals( + ParseClient::getProperDateFormat($obj->get('date')), + ParseClient::getProperDateFormat($decoded->get('date')), + 'Dates did not match' + ); + $this->assertEquals($boolVal, $decoded->get('bool'), 'Booleans did not match'); + $this->assertEquals($arrayVal, $decoded->get('array'), 'Arrays did not match'); + $this->assertEquals($assocVal, $decoded->get('assoc_array'), 'Associative arrays did not match'); + $pointee = $decoded->get('pointer'); + $pointee->fetch(); + $child->fetch(); + $this->assertEquals($child->_encode(), $pointee->_encode(), 'Pointers did not match'); + $this->assertEquals($polygon, $decoded->get('polygon'), 'Polygons did not match'); + $this->assertEquals($geoPoint, $decoded->get('geopoint'), 'Geopoints did not match'); + + // verify unsaved key/value is present as well + $this->assertEquals('not a saved value', $decoded->get('unsaved')); + + // verify relation + $relation = $decoded->getRelation('relation', 'TestClass'); + $query = $relation->getQuery(); + $found = $query->find(); + $this->assertEquals(1, count($found)); + + // attempt to add another object to this relation + $child2 = new ParseObject('TestClass'); + $child2->save(); + $relation->add([$child2]); + $decoded->save(); + $this->assertEquals(2, $query->count()); + + // attempt to remove objects from this relation + $relation->remove([$found[0], $child2]); + $decoded->save(); + $this->assertEquals(0, $query->count()); + + // cleanup + ParseObject::destroyAll([$decoded,$child]); + } + + /** + * Tests decoding with various ops + * + * @group decode-test + */ + public function testDecodeWithOps() + { + $obj = new ParseObject('TestClass'); + $obj->set('number', 5); + $obj->setArray('array', ['apples']); + $obj->setArray('uniquearray', ['apples']); + $obj->setArray('removearray', ['apples']); + $obj->save(); + + // add op + $obj->add('array', ['bananas']); + + // unique op + $obj->addUnique('uniquearray', ['unique-value']); + + // remove op + $obj->remove('removearray', 'apples'); + + // delete op + $obj->delete('foo'); + + // increment op + $obj->increment('number', 5); + + // remove relation op + $child = new ParseObject('TestClass'); + $child->save(); + $child = ParseObject::create('TestClass', $child->getObjectId()); + + $child2 = new ParseObject('TestClass'); + $child2->save(); + $child2 = ParseObject::create('TestClass', $child2->getObjectId()); + + $relation = $obj->getRelation('relation3', 'TestClass'); + $relation->add([$child]); + $relation->remove([$child2]); + + $relation = $obj->getRelation('relation4', 'TestClass'); + $relation->remove([$child]); + + $encoded = $obj->encode(); + + $decoded = ParseObject::decode($encoded); + + $this->assertEquals($obj, $decoded, 'Decoded object did not match'); + } + + /** + * Tests decoding with an unrecognized op + * + * @group decode-unrecognized-test + */ + public function testUnrecognizedOp() + { + $this->setExpectedException( + '\Parse\ParseException', + "Unrecognized op 'Unrecognized' found during decode." + ); + + $obj = new ParseObject('TestClass'); + $encoded = $obj->encode(); + $encoded = json_decode($encoded, true); + $encoded['operationSet'][] = [ + '__op' => 'Unrecognized' + ]; + ParseObject::decode($encoded); + } } diff --git a/tests/Parse/ParseRelationOperationTest.php b/tests/Parse/ParseRelationOperationTest.php index 12258c51..35deba04 100644 --- a/tests/Parse/ParseRelationOperationTest.php +++ b/tests/Parse/ParseRelationOperationTest.php @@ -123,7 +123,7 @@ public function testMergeDifferentClass() { $this->setExpectedException( '\Exception', - 'Related object object must be of class ' + 'Related object must be of class ' .'Class1, but AnotherClass' .' was passed in.' ); @@ -164,4 +164,14 @@ public function testRemoveElementsFromArray() $this->assertEmpty($array); } + + /** + * @group relation-remove-missing-object-id + */ + public function testRemoveMissingObjectId() + { + $obj = new ParseObject('Class1'); + $op = new ParseRelationOperation(null, $obj); + $op->_mergeWithPrevious(new ParseRelationOperation(null, $obj)); + } }