From 10015aac55469fb9ec21c93664ef34e7a69c93f0 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sat, 19 Jul 2025 12:29:13 +0200 Subject: [PATCH 1/7] Add query API to collections --- src/main/php/com/mongodb/Collection.class.php | 32 +++++++ .../unittest/CollectionQueryTest.class.php | 95 +++++++++++++++++++ 2 files changed, 127 insertions(+) create mode 100755 src/test/php/com/mongodb/unittest/CollectionQueryTest.class.php diff --git a/src/main/php/com/mongodb/Collection.class.php b/src/main/php/com/mongodb/Collection.class.php index d48d489..47da5a7 100755 --- a/src/main/php/com/mongodb/Collection.class.php +++ b/src/main/php/com/mongodb/Collection.class.php @@ -9,6 +9,7 @@ * A collection inside a database. * * @test com.mongodb.unittest.CollectionTest + * @test com.mongodb.unittest.CollectionQueryTest */ class Collection implements Value { private $proto, $database, $name; @@ -277,6 +278,37 @@ public function aggregate(array $pipeline= [], Options... $options): Cursor { return new Cursor($commands, $options, $result['body']['cursor']); } + /** + * Runs a query and returns a cursor. + * + * @param ?string|com.mongodb.ObjectId|[:var]|[:var][] $query + * @param com.mongodb.Options... $options + * @return com.mongodb.result.Cursor + * @throws com.mongodb.Error + */ + public function query($query= null, Options... $options) { + if (null === $query) { + return $this->find([], ...$options); + } else if (is_array($query) && 0 === key($query)) { + return $this->aggregate($query, ...$options); + } else { + return $this->find($query, ...$options); + } + } + + /** + * Returns the first document for a given query, or NULL. Shorthand + * for running `$collection->query($query)->first()`. + * + * @param ?string|com.mongodb.ObjectId|[:var]|[:var][] $query + * @param com.mongodb.Options... $options + * @return ?com.mongodb.Document + * @throws com.mongodb.Error + */ + public function first($query= null, Options... $options) { + return $this->query($query, ...$options)->first(); + } + /** * Watch for changes in this collection * diff --git a/src/test/php/com/mongodb/unittest/CollectionQueryTest.class.php b/src/test/php/com/mongodb/unittest/CollectionQueryTest.class.php new file mode 100755 index 0000000..3adf51d --- /dev/null +++ b/src/test/php/com/mongodb/unittest/CollectionQueryTest.class.php @@ -0,0 +1,95 @@ + 'one', 'name' => 'A']; + const SECOND= ['_id' => 'one', 'name' => 'A']; + const DOCUMENTS= [self::FIRST, self::SECOND]; + const QUERY= ['$db' => 'testing', '$readPreference' => ['mode' => 'primary']]; + + /** + * Returns a new fixture + * + * @param var... $messages + * @return com.mongodb.io.Protocol + */ + private function newFixture(... $messages) { + return $this->protocol([self::$PRIMARY => [$this->hello(self::$PRIMARY), ...$messages]], 'primary')->connect(); + } + + #[Test] + public function query() { + $protocol= $this->newFixture($this->cursor(self::DOCUMENTS)); + Assert::equals( + [new Document(self::FIRST), new Document(self::SECOND)], + (new Collection($protocol, 'testing', 'tests'))->query()->all() + ); + Assert::equals( + ['find' => 'tests', 'filter' => (object)[]] + self::QUERY, + current($protocol->connections())->command(1) + ); + } + + #[Test] + public function first() { + $protocol= $this->newFixture($this->cursor(self::DOCUMENTS)); + Assert::equals( + new Document(self::FIRST), + (new Collection($protocol, 'testing', 'tests'))->first() + ); + Assert::equals( + ['find' => 'tests', 'filter' => (object)[]] + self::QUERY, + current($protocol->connections())->command(1) + ); + } + + #[Test] + public function first_with_id() { + $protocol= $this->newFixture($this->cursor(self::DOCUMENTS)); + Assert::equals( + new Document(self::FIRST), + (new Collection($protocol, 'testing', 'tests'))->first('one') + ); + Assert::equals( + ['find' => 'tests', 'filter' => ['_id' => 'one']] + self::QUERY, + current($protocol->connections())->command(1) + ); + } + + #[Test] + public function first_with_query() { + $protocol= $this->newFixture($this->cursor(self::DOCUMENTS)); + Assert::equals( + new Document(self::FIRST), + (new Collection($protocol, 'testing', 'tests'))->first(['_id' => 'one']) + ); + Assert::equals( + ['find' => 'tests', 'filter' => ['_id' => 'one']] + self::QUERY, + current($protocol->connections())->command(1) + ); + } + + #[Test] + public function first_with_pipeline() { + $pipeline= [['$match' => ['_id' => 'one']]]; + $protocol= $this->newFixture($this->cursor(self::DOCUMENTS)); + Assert::equals( + new Document(self::FIRST), + (new Collection($protocol, 'testing', 'tests'))->first($pipeline) + ); + Assert::equals( + ['aggregate' => 'tests', 'pipeline' => $pipeline, 'cursor' => (object)[]] + self::QUERY, + current($protocol->connections())->command(1) + ); + } + + #[Test] + public function first_without_result() { + $protocol= $this->newFixture($this->cursor([])); + Assert::null((new Collection($protocol, 'testing', 'tests'))->first()); + } +} \ No newline at end of file From 853563833fd64b5c6af0dcd8a46bb5704cd2a218 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sat, 19 Jul 2025 13:26:50 +0200 Subject: [PATCH 2/7] Test invoking query() with an empty query --- .../unittest/CollectionQueryTest.class.php | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/test/php/com/mongodb/unittest/CollectionQueryTest.class.php b/src/test/php/com/mongodb/unittest/CollectionQueryTest.class.php index 3adf51d..823f087 100755 --- a/src/test/php/com/mongodb/unittest/CollectionQueryTest.class.php +++ b/src/test/php/com/mongodb/unittest/CollectionQueryTest.class.php @@ -1,7 +1,7 @@ newFixture($this->cursor(self::DOCUMENTS)); + Assert::equals( + new Document(self::FIRST), + (new Collection($protocol, 'testing', 'tests'))->first($query) + ); + Assert::equals( + ['find' => 'tests', 'filter' => (object)[]] + self::QUERY, + current($protocol->connections())->command(1) + ); + } + #[Test] public function first_with_id() { $protocol= $this->newFixture($this->cursor(self::DOCUMENTS)); From 6546c6fc7d7f66d25d1e7a0f13ed61eb03025535 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sat, 19 Jul 2025 20:01:40 +0200 Subject: [PATCH 3/7] Remove support for passing null to query() and first() --- src/main/php/com/mongodb/Collection.class.php | 10 ++++------ .../com/mongodb/unittest/CollectionQueryTest.class.php | 8 ++++---- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/src/main/php/com/mongodb/Collection.class.php b/src/main/php/com/mongodb/Collection.class.php index 47da5a7..ba59634 100755 --- a/src/main/php/com/mongodb/Collection.class.php +++ b/src/main/php/com/mongodb/Collection.class.php @@ -281,15 +281,13 @@ public function aggregate(array $pipeline= [], Options... $options): Cursor { /** * Runs a query and returns a cursor. * - * @param ?string|com.mongodb.ObjectId|[:var]|[:var][] $query + * @param string|com.mongodb.ObjectId|[:var]|[:var][] $query * @param com.mongodb.Options... $options * @return com.mongodb.result.Cursor * @throws com.mongodb.Error */ - public function query($query= null, Options... $options) { - if (null === $query) { - return $this->find([], ...$options); - } else if (is_array($query) && 0 === key($query)) { + public function query($query= [], Options... $options) { + if (is_array($query) && 0 === key($query)) { return $this->aggregate($query, ...$options); } else { return $this->find($query, ...$options); @@ -305,7 +303,7 @@ public function query($query= null, Options... $options) { * @return ?com.mongodb.Document * @throws com.mongodb.Error */ - public function first($query= null, Options... $options) { + public function first($query= [], Options... $options) { return $this->query($query, ...$options)->first(); } diff --git a/src/test/php/com/mongodb/unittest/CollectionQueryTest.class.php b/src/test/php/com/mongodb/unittest/CollectionQueryTest.class.php index 823f087..bb6520f 100755 --- a/src/test/php/com/mongodb/unittest/CollectionQueryTest.class.php +++ b/src/test/php/com/mongodb/unittest/CollectionQueryTest.class.php @@ -1,7 +1,7 @@ newFixture($this->cursor(self::DOCUMENTS)); Assert::equals( new Document(self::FIRST), - (new Collection($protocol, 'testing', 'tests'))->first($query) + (new Collection($protocol, 'testing', 'tests'))->first([]) ); Assert::equals( ['find' => 'tests', 'filter' => (object)[]] + self::QUERY, From fa43ac75826b587d6a618dd846715ee653d6b1f1 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sat, 19 Jul 2025 20:02:46 +0200 Subject: [PATCH 4/7] Inline find / aggregate delegation --- src/main/php/com/mongodb/Collection.class.php | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/main/php/com/mongodb/Collection.class.php b/src/main/php/com/mongodb/Collection.class.php index ba59634..a478f36 100755 --- a/src/main/php/com/mongodb/Collection.class.php +++ b/src/main/php/com/mongodb/Collection.class.php @@ -288,10 +288,23 @@ public function aggregate(array $pipeline= [], Options... $options): Cursor { */ public function query($query= [], Options... $options) { if (is_array($query) && 0 === key($query)) { - return $this->aggregate($query, ...$options); + $sections= [ + 'aggregate' => $this->name, + 'pipeline' => $query, + 'cursor' => (object)[], + '$db' => $this->database, + ]; } else { - return $this->find($query, ...$options); + $sections= [ + 'find' => $this->name, + 'filter' => is_array($query) ? ($query ?: (object)[]) : ['_id' => $query], + '$db' => $this->database, + ]; } + + $commands= Commands::reading($this->proto); + $result= $commands->send($options, $sections); + return new Cursor($commands, $options, $result['body']['cursor']); } /** From 977e65f799fcbf2242be1bb5673f68fb58de2362 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sat, 19 Jul 2025 20:08:04 +0200 Subject: [PATCH 5/7] Elaborate in query() API docs --- src/main/php/com/mongodb/Collection.class.php | 22 ++++++++++++------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/src/main/php/com/mongodb/Collection.class.php b/src/main/php/com/mongodb/Collection.class.php index a478f36..c57b61a 100755 --- a/src/main/php/com/mongodb/Collection.class.php +++ b/src/main/php/com/mongodb/Collection.class.php @@ -279,7 +279,13 @@ public function aggregate(array $pipeline= [], Options... $options): Cursor { } /** - * Runs a query and returns a cursor. + * Runs a query and returns a cursor. The query may either be a string + * or an object ID, in which case the `_id` member is matched, a map of + * fields and match values which is passed to `find`, or an aggregation + * pipeline. + * + * Note: The pipeline may not contain `$merge` or `$out` stages, this + * method always uses read context for sending the query! * * @param string|com.mongodb.ObjectId|[:var]|[:var][] $query * @param com.mongodb.Options... $options @@ -289,16 +295,16 @@ public function aggregate(array $pipeline= [], Options... $options): Cursor { public function query($query= [], Options... $options) { if (is_array($query) && 0 === key($query)) { $sections= [ - 'aggregate' => $this->name, - 'pipeline' => $query, - 'cursor' => (object)[], - '$db' => $this->database, + 'aggregate' => $this->name, + 'pipeline' => $query, + 'cursor' => (object)[], + '$db' => $this->database, ]; } else { $sections= [ - 'find' => $this->name, - 'filter' => is_array($query) ? ($query ?: (object)[]) : ['_id' => $query], - '$db' => $this->database, + 'find' => $this->name, + 'filter' => is_array($query) ? ($query ?: (object)[]) : ['_id' => $query], + '$db' => $this->database, ]; } From 8f7de6846340bd873fbc0ca1e215bd9b1e710eae Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sat, 19 Jul 2025 20:12:05 +0200 Subject: [PATCH 6/7] Advertise query() instead of find(), show aggregation pipelines --- README.md | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 8ab21af..f929ec0 100755 --- a/README.md +++ b/README.md @@ -23,16 +23,28 @@ use com\mongodb\{MongoConnection, ObjectId}; use util\cmd\Console; $c= new MongoConnection('mongodb://localhost'); -$id= new ObjectId(...); +$id= new ObjectId('...'); // Find all documents -$cursor= $c->collection('test.products')->find(); +$cursor= $c->collection('test.products')->query(); // Find document with the specified ID -$cursor= $c->collection('test.products')->find($id); +$cursor= $c->collection('test.products')->query($id); // Find all documents with a name of "Test" -$cursor= $c->collection('test.products')->find(['name' => 'Test']); +$cursor= $c->collection('test.products')->query(['name' => 'Test']); + +// Use aggregation pipelines +$cursor= $c->collection('test.products')->query([ + ['$match' => ['color' => 'green', 'state' => 'ACTIVE']], + ['$lookup' => [ + 'from' => 'users', + 'localField' => 'owner.id', + 'foreignField' => '_id', + 'as' => 'owner', + ]], + ['$addFields' => ['owner' => ['$first' => '$owner']]], +]); foreach ($cursor as $document) { Console::writeLine('>> ', $document); @@ -62,7 +74,7 @@ use com\mongodb\{MongoConnection, ObjectId}; use util\cmd\Console; $c= new MongoConnection('mongodb://localhost'); -$id= new ObjectId(...); +$id= new ObjectId('...'); // Select a single document for updating by its ID $result= $c->collection('test.products')->update($id, ['$inc' => ['qty' => 1]]); @@ -102,7 +114,7 @@ use com\mongodb\{MongoConnection, ObjectId}; use util\cmd\Console; $c= new MongoConnection('mongodb://localhost'); -$id= new ObjectId(...); +$id= new ObjectId('...'); // Select a single document to be removed $result= $c->collection('test.products')->delete($id); From b167c99980a2429cb19c194e10c8f00ff6daac12 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sat, 19 Jul 2025 20:13:35 +0200 Subject: [PATCH 7/7] Prevent calling `is_array()` twice --- src/main/php/com/mongodb/Collection.class.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/main/php/com/mongodb/Collection.class.php b/src/main/php/com/mongodb/Collection.class.php index c57b61a..d0a846f 100755 --- a/src/main/php/com/mongodb/Collection.class.php +++ b/src/main/php/com/mongodb/Collection.class.php @@ -293,7 +293,8 @@ public function aggregate(array $pipeline= [], Options... $options): Cursor { * @throws com.mongodb.Error */ public function query($query= [], Options... $options) { - if (is_array($query) && 0 === key($query)) { + $array= is_array($query); + if ($array && 0 === key($query)) { $sections= [ 'aggregate' => $this->name, 'pipeline' => $query, @@ -303,7 +304,7 @@ public function query($query= [], Options... $options) { } else { $sections= [ 'find' => $this->name, - 'filter' => is_array($query) ? ($query ?: (object)[]) : ['_id' => $query], + 'filter' => $array ? ($query ?: (object)[]) : ['_id' => $query], '$db' => $this->database, ]; }