From c6d9e3c51c4a26686609dfc2d32d8fba27b5fd6e Mon Sep 17 00:00:00 2001 From: OWL Agent Date: Fri, 26 Jun 2026 03:19:44 +0000 Subject: [PATCH 1/3] fix: restore direct pointer match in equalTo for array value queries The 2.3.1 equalTo fix always wrapped values in $eq, which broke queries on array values (e.g. users => ParseUser in ParseRole queries). When the value is a ParseObject (Pointer), set it directly in the where clause instead of using $eq. --- src/Parse/ParseQuery.php | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/Parse/ParseQuery.php b/src/Parse/ParseQuery.php index be7826b8..df9e12db 100644 --- a/src/Parse/ParseQuery.php +++ b/src/Parse/ParseQuery.php @@ -140,7 +140,13 @@ public function get($objectId, $useMasterKey = false) */ public function equalTo($key, $value) { - $this->addCondition($key, '$eq', $value); + if ($value instanceof ParseObject) { + // Pointer values (e.g. users => ParseUser) should match directly, + // not wrapped in $eq, to support queries on array values. + $this->where[$key] = ParseClient::_encode($value, true); + } else { + $this->addCondition($key, '$eq', $value); + } return $this; } From 01b83e6f382a9d57b9a642ebaa6bde952877f19b Mon Sep 17 00:00:00 2001 From: OWL Agent Date: Fri, 26 Jun 2026 03:49:03 +0000 Subject: [PATCH 2/3] fix: use $eq_pointer marker + collapse at build time (CodeRabbit review feedback) Instead of directly assigning to $this->where (which drops same-key constraints), store ParseObject values via addCondition under a special $eq_pointer key, then collapse to raw pointer at query build time when it is the only condition for that key. This preserves the per-key operator map contract while still supporting array value queries. --- src/Parse/ParseQuery.php | 23 +++++-- verify_equalto.php | 83 +++++++++++++++++++++++++ verify_fix.php | 129 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 230 insertions(+), 5 deletions(-) create mode 100644 verify_equalto.php create mode 100644 verify_fix.php diff --git a/src/Parse/ParseQuery.php b/src/Parse/ParseQuery.php index df9e12db..4cd4d9db 100644 --- a/src/Parse/ParseQuery.php +++ b/src/Parse/ParseQuery.php @@ -141,9 +141,9 @@ public function get($objectId, $useMasterKey = false) public function equalTo($key, $value) { if ($value instanceof ParseObject) { - // Pointer values (e.g. users => ParseUser) should match directly, - // not wrapped in $eq, to support queries on array values. - $this->where[$key] = ParseClient::_encode($value, true); + // Mark pointer values with a special marker so we can collapse + // them to raw pointers at query build time (supports array queries) + $this->addCondition($key, '$eq_pointer', $value); } else { $this->addCondition($key, '$eq', $value); } @@ -507,7 +507,14 @@ public function _getOptions() { $opts = []; if (!empty($this->where)) { - $opts['where'] = $this->where; + $where = $this->where; + // Collapse $eq_pointer to raw pointer for array value queries + foreach ($where as $key => $conditions) { + if (is_array($conditions) && array_key_exists('$eq_pointer', $conditions) && count($conditions) === 1) { + $where[$key] = $conditions['$eq_pointer']; + } + } + $opts['where'] = $where; } if (count($this->includes)) { $opts['include'] = implode(',', $this->includes); @@ -639,7 +646,13 @@ public function distinct($key) } $opts = []; if (!empty($this->where)) { - $opts['where'] = $this->where; + $where = $this->where; + foreach ($where as $k => $conditions) { + if (is_array($conditions) && array_key_exists('$eq_pointer', $conditions) && count($conditions) === 1) { + $where[$k] = $conditions['$eq_pointer']; + } + } + $opts['where'] = $where; } $opts['distinct'] = $key; $queryString = $this->buildQueryString($opts); diff --git a/verify_equalto.php b/verify_equalto.php new file mode 100644 index 00000000..a42426de --- /dev/null +++ b/verify_equalto.php @@ -0,0 +1,83 @@ +equalTo('users', $user); +$opts = $query->_getOptions(); +$encoded = json_encode($opts['where'] ?? []); +echo " Built query where = $encoded\n"; +check('should NOT have $eq in output', strpos($encoded, '$eq') === false); +check('should NOT have $eq_pointer in output', strpos($encoded, '$eq_pointer') === false); +check('should have __type=Pointer in output', strpos($encoded, '"__type":"Pointer"') !== false); +check('should have className=_User', strpos($encoded, '"className":"_User"') !== false); +check('should have objectId=user123', strpos($encoded, '"objectId":"user123"') !== false); + +// Test 2: String value still uses $eq +echo "\nTest 2: String uses \$eq\n"; +$query2 = new ParseQuery('TestClass'); +$query2->equalTo('foo', 'bar'); +$opts2 = $query2->_getOptions(); +$encoded2 = json_encode($opts2['where'] ?? []); +echo " where = $encoded2\n"; +check('foo should have $eq', strpos($encoded2, '$eq') !== false); +check('foo $eq should be bar', strpos($encoded2, '"bar"') !== false); + +// Test 3: Number value still uses $eq +echo "\nTest 3: Number uses \$eq\n"; +$query3 = new ParseQuery('TestClass'); +$query3->equalTo('number', 17); +$opts3 = $query3->_getOptions(); +$encoded3 = json_encode($opts3['where'] ?? []); +echo " where = $encoded3\n"; +check('number should have $eq', strpos($encoded3, '$eq') !== false); + +// Test 4: Chained constraints on same key still work +echo "\nTest 4: Chained constraints on same key\n"; +$query4 = new ParseQuery('TestClass'); +$query4->equalTo('foo', 'bar'); +$query4->greaterThan('foo', 10); +$opts4 = $query4->_getOptions(); +$encoded4 = json_encode($opts4['where'] ?? []); +echo " where = $encoded4\n"; +check('foo should have both $eq and $gt', strpos($encoded4, '$eq') !== false && strpos($encoded4, '$gt') !== false); + +// Test 5: ParseObject + another constraint on same key (order-dependent case) +echo "\nTest 5: ParseObject with other constraints\n"; +$query5 = new ParseQuery('TestClass'); +$query5->greaterThan('count', 5); +$query5->equalTo('users', $user); +$opts5 = $query5->_getOptions(); +$encoded5 = json_encode($opts5['where'] ?? []); +echo " where = $encoded5\n"; +check('count should have $gt', strpos($encoded5, '$gt') !== false); +check('users should be direct pointer', strpos($encoded5, '"__type":"Pointer"') !== false); + +echo "\n=== Results: $passed passed, $failed failed ===\n"; +exit($failed > 0 ? 1 : 0); diff --git a/verify_fix.php b/verify_fix.php new file mode 100644 index 00000000..7afcb7fa --- /dev/null +++ b/verify_fix.php @@ -0,0 +1,129 @@ +/** + * Standalone verification for equalTo fix + * Tests that ParseObject values use direct pointer matching (not $eq) + * and non-Object values still use $eq + */ + +require_once __DIR__ . '/src/Parse/ParseClient.php'; +require_once __DIR__ . '/src/Parse/ParseObject.php'; +require_once __DIR__ . '/src/Parse/ParseQuery.php'; +require_once __DIR__ . '/src/Parse/ParseUser.php'; +require_once __DIR__ . '/src/Parse/ParseRole.php'; +require_once __DIR__ . '/src/Parse/Internal/Encodable.php'; + +use Parse\ParseClient; +use Parse\ParseObject; +use Parse\ParseQuery; +use Parse\ParseUser; +use Parse\ParseRole; + +// Initialize Parse client +ParseClient::initialize('appId', 'restKey', 'https://api.example.com'); + +$passed = 0; +$failed = 0; + +function assert_test($name, $condition) { + global $passed, $failed; + if ($condition) { + echo " āœ“ $name\n"; + $passed++; + } else { + echo " āœ— FAIL: $name\n"; + $failed++; + } +} + +echo "=== equalTo Fix Verification ===\n\n"; + +// Test 1: ParseObject value should use direct pointer match (NOT $eq) +echo "Test 1: ParseObject value uses direct pointer match\n"; +$user = ParseObject::create('_User', 'user123'); +$query = ParseQuery::getQuery('ParseRole'); +$query->equalTo('users', $user); +$where = $query->getWhere(); + +assert_test( + "where['users'] should NOT have \$eq key", + !isset($where['users']['$eq']) +); +assert_test( + "where['users'] should have __type = Pointer", + isset($where['users']['__type']) && $where['users']['__type'] === 'Pointer' +); +assert_test( + "where['users'] should have className = _User", + isset($where['users']['className']) && $where['users']['className'] === '_User' +); +assert_test( + "where['users'] should have objectId = user123", + isset($where['users']['objectId']) && $where['users']['objectId'] === 'user123' +); + +// Test 2: String value should still use $eq +echo "\nTest 2: String value uses \$eq\n"; +$query2 = ParseQuery::getQuery('TestClass'); +$query2->equalTo('foo', 'bar'); +$where2 = $query2->getWhere(); + +assert_test( + "where['foo'] should have \$eq key", + isset($where2['foo']['$eq']) +); +assert_test( + "where['foo']['\$eq'] should be 'bar'", + isset($where2['foo']['$eq']) && $where2['foo']['$eq'] === 'bar' +); + +// Test 3: Number value should still use $eq +echo "\nTest 3: Number value uses \$eq\n"; +$query3 = ParseQuery::getQuery('TestClass'); +$query3->equalTo('number', 17); +$where3 = $query3->getWhere(); + +assert_test( + "where['number'] should have \$eq key", + isset($where3['number']['$eq']) +); +assert_test( + "where['number']['\$eq'] should be 17", + isset($where3['number']['$eq']) && $where3['number']['$eq'] === 17 +); + +// Test 4: null value should still use $eq +echo "\nTest 4: null value uses \$eq\n"; +$query4 = ParseQuery::getQuery('TestClass'); +$query4->equalTo('num', null); +$where4 = $query4->getWhere(); + +assert_test( + "where['num'] should have \$eq key", + isset($where4['num']['$eq']) +); +assert_test( + "where['num']['\$eq'] should be null", + isset($where4['num']['$eq']) && $where4['num']['$eq'] === null +); + +// Test 5: Array value should still use $eq +echo "\nTest 5: Array value uses \$eq\n"; +$query5 = ParseQuery::getQuery('TestClass'); +$query5->equalTo('tags', ['foo', 'bar']); +$where5 = $query5->getWhere(); + +assert_test( + "where['tags'] should have \$eq key", + isset($where5['tags']['$eq']) +); + +echo "\n=== Results ===\n"; +echo "Passed: $passed\n"; +echo "Failed: $failed\n"; + +if ($failed > 0) { + echo "\nāŒ SOME TESTS FAILED\n"; + exit(1); +} else { + echo "\nāœ… ALL TESTS PASSED\n"; + exit(0); +} From 4a7f8636bd3a91e3142b0eeea7b40ea079ba1875 Mon Sep 17 00:00:00 2001 From: OWL Agent Date: Fri, 26 Jun 2026 04:02:39 +0000 Subject: [PATCH 3/3] fix: add recursive normalizeWhere helper, address CodeRabbit review - Add normalizeWhere() recursive helper that walks full condition tree - Collapses sole $eq_pointer to raw pointer, rewrites mixed cases to $eq - Both _getOptions() and distinct() now use normalizeWhere() - Fix verify_equalto.php autoloader to use __DIR__ (portable) - Update Test 5 to test same-key mixed constraint case --- src/Parse/ParseQuery.php | 51 ++++++++++++++++++++++++++++------------ verify_equalto.php | 13 +++++----- 2 files changed, 43 insertions(+), 21 deletions(-) diff --git a/src/Parse/ParseQuery.php b/src/Parse/ParseQuery.php index 4cd4d9db..7f43dc4d 100644 --- a/src/Parse/ParseQuery.php +++ b/src/Parse/ParseQuery.php @@ -498,6 +498,40 @@ public function fullText($key, $value) return $this; } + /** + * Recursively normalize the where clause, collapsing $eq_pointer sentinel + * values to raw pointers (for ParseObject equality) or rewriting mixed + * same-key cases to $eq. Handles nested condition trees (or/and/nor queries). + * + * @param mixed $value + * @return mixed + */ + private function normalizeWhere($value) + { + if (!is_array($value)) { + return $value; + } + + if (array_key_exists('$eq_pointer', $value)) { + $pointer = $value['$eq_pointer']; + unset($value['$eq_pointer']); + + if (count($value) === 0) { + // Sole condition: collapse to raw pointer + return $pointer; + } + + // Mixed same-key case: rewrite to $eq alongside other operators + $value['$eq'] = $pointer; + } + + foreach ($value as $k => $v) { + $value[$k] = $this->normalizeWhere($v); + } + + return $value; + } + /** * Returns an associative array of the query constraints. * @@ -507,14 +541,7 @@ public function _getOptions() { $opts = []; if (!empty($this->where)) { - $where = $this->where; - // Collapse $eq_pointer to raw pointer for array value queries - foreach ($where as $key => $conditions) { - if (is_array($conditions) && array_key_exists('$eq_pointer', $conditions) && count($conditions) === 1) { - $where[$key] = $conditions['$eq_pointer']; - } - } - $opts['where'] = $where; + $opts['where'] = $this->normalizeWhere($this->where); } if (count($this->includes)) { $opts['include'] = implode(',', $this->includes); @@ -646,13 +673,7 @@ public function distinct($key) } $opts = []; if (!empty($this->where)) { - $where = $this->where; - foreach ($where as $k => $conditions) { - if (is_array($conditions) && array_key_exists('$eq_pointer', $conditions) && count($conditions) === 1) { - $where[$k] = $conditions['$eq_pointer']; - } - } - $opts['where'] = $where; + $opts['where'] = $this->normalizeWhere($this->where); } $opts['distinct'] = $key; $queryString = $this->buildQueryString($opts); diff --git a/verify_equalto.php b/verify_equalto.php index a42426de..229a9aeb 100644 --- a/verify_equalto.php +++ b/verify_equalto.php @@ -1,7 +1,7 @@ greaterThan('count', 5); $query5->equalTo('users', $user); +$query5->exists('users'); $opts5 = $query5->_getOptions(); $encoded5 = json_encode($opts5['where'] ?? []); echo " where = $encoded5\n"; -check('count should have $gt', strpos($encoded5, '$gt') !== false); -check('users should be direct pointer', strpos($encoded5, '"__type":"Pointer"') !== false); +check('should NOT have $eq_pointer in output', strpos($encoded5, '$eq_pointer') === false); +check('users should still contain the pointer payload', strpos($encoded5, '"__type":"Pointer"') !== false); +check('users should still contain the sibling operator', strpos($encoded5, '$exists') !== false); echo "\n=== Results: $passed passed, $failed failed ===\n"; exit($failed > 0 ? 1 : 0);