Skip to content
This repository was archived by the owner on Aug 22, 2023. It is now read-only.

PHPORM-45 Add Query\Builder::toMql() to simplify comprehensive query tests #6

Merged
merged 5 commits into from
Jul 12, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
],
"license": "MIT",
"require": {
"ext-mongodb": "^1.15",
"illuminate/support": "^10.0",
"illuminate/container": "^10.0",
"illuminate/database": "^10.0",
Expand Down
132 changes: 70 additions & 62 deletions src/Query/Builder.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
use MongoDB\BSON\ObjectID;
use MongoDB\BSON\Regex;
use MongoDB\BSON\UTCDateTime;
use MongoDB\Driver\Cursor;
use RuntimeException;

/**
Expand Down Expand Up @@ -215,27 +216,21 @@ public function cursor($columns = [])
}

/**
* Execute the query as a fresh "select" statement.
* Return the MongoDB query to be run in the form of an element array like ['method' => [arguments]].
*
* @param array $columns
* @param bool $returnLazy
* @return array|static[]|Collection|LazyCollection
* Example: ['find' => [['name' => 'John Doe'], ['projection' => ['birthday' => 1]]]]
*
* @return array<string, mixed[]>
*/
public function getFresh($columns = [], $returnLazy = false)
public function toMql(): array
{
// If no columns have been specified for the select statement, we will set them
// here to either the passed columns, or the standard default of retrieving
// all of the columns on the table using the "wildcard" column character.
if ($this->columns === null) {
$this->columns = $columns;
}
$columns = $this->columns ?? [];

// Drop all columns if * is present, MongoDB does not work this way.
if (in_array('*', $this->columns)) {
$this->columns = [];
if (in_array('*', $columns)) {
$columns = [];
}

// Compile wheres
$wheres = $this->compileWheres();

// Use MongoDB's aggregation framework when using grouping or aggregation functions.
Expand All @@ -254,7 +249,7 @@ public function getFresh($columns = [], $returnLazy = false)
}

// Do the same for other columns that are selected.
foreach ($this->columns as $column) {
foreach ($columns as $column) {
$key = str_replace('.', '_', $column);

$group[$key] = ['$last' => '$'.$column];
Expand All @@ -274,26 +269,10 @@ public function getFresh($columns = [], $returnLazy = false)
$column = implode('.', $splitColumns);
}

// Null coalense only > 7.2

$aggregations = blank($this->aggregate['columns']) ? [] : $this->aggregate['columns'];

if (in_array('*', $aggregations) && $function == 'count') {
// When ORM is paginating, count doesnt need a aggregation, just a cursor operation
// elseif added to use this only in pagination
// https://docs.mongodb.com/manual/reference/method/cursor.count/
// count method returns int

$totalResults = $this->collection->count($wheres);
// Preserving format expected by framework
$results = [
[
'_id' => null,
'aggregate' => $totalResults,
],
];

return new Collection($results);
return ['count' => [$wheres, []]];
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Related to #4 (comment)
I came back to "count" for now. Otherwise there is a test failure.


1) Jenssegers\Mongodb\Tests\TransactionTest::testQuery
Failed asserting that 0 matches expected 1.

tests/TransactionTest.php:302

} elseif ($function == 'count') {
// Translate count into sum.
$group['aggregate'] = ['$sum' => 1];
Expand Down Expand Up @@ -348,34 +327,23 @@ public function getFresh($columns = [], $returnLazy = false)

$options = $this->inheritConnectionOptions($options);

// Execute aggregation
$results = iterator_to_array($this->collection->aggregate($pipeline, $options));

// Return results
return new Collection($results);
return ['aggregate' => [$pipeline, $options]];
} // Distinct query
elseif ($this->distinct) {
// Return distinct results directly
$column = isset($this->columns[0]) ? $this->columns[0] : '_id';
$column = isset($columns[0]) ? $columns[0] : '_id';

$options = $this->inheritConnectionOptions();

// Execute distinct
$result = $this->collection->distinct($column, $wheres ?: [], $options);

return new Collection($result);
return ['distinct' => [$column, $wheres ?: [], $options]];
} // Normal query
else {
$columns = [];

// Convert select columns to simple projections.
foreach ($this->columns as $column) {
$columns[$column] = true;
}
$projection = array_fill_keys($columns, true);

// Add custom projections.
if ($this->projections) {
$columns = array_merge($columns, $this->projections);
$projection = array_merge($projection, $this->projections);
}
$options = [];

Expand All @@ -395,8 +363,8 @@ public function getFresh($columns = [], $returnLazy = false)
if ($this->hint) {
$options['hint'] = $this->hint;
}
if ($columns) {
$options['projection'] = $columns;
if ($projection) {
$options['projection'] = $projection;
}

// Fix for legacy support, converts the results to arrays instead of objects.
Expand All @@ -409,22 +377,62 @@ public function getFresh($columns = [], $returnLazy = false)

$options = $this->inheritConnectionOptions($options);

// Execute query and get MongoCursor
$cursor = $this->collection->find($wheres, $options);
return ['find' => [$wheres, $options]];
}
}

if ($returnLazy) {
return LazyCollection::make(function () use ($cursor) {
foreach ($cursor as $item) {
yield $item;
}
});
}
/**
* Execute the query as a fresh "select" statement.
*
* @param array $columns
* @param bool $returnLazy
* @return array|static[]|Collection|LazyCollection
*/
public function getFresh($columns = [], $returnLazy = false)
{
// If no columns have been specified for the select statement, we will set them
// here to either the passed columns, or the standard default of retrieving
// all of the columns on the table using the "wildcard" column character.
if ($this->columns === null) {
$this->columns = $columns;
}

// Drop all columns if * is present, MongoDB does not work this way.
if (in_array('*', $this->columns)) {
$this->columns = [];
}

$command = $this->toMql($columns);
assert(count($command) >= 1, 'At least one method call is required to execute a query');

$result = $this->collection;
foreach ($command as $method => $arguments) {
$result = call_user_func_array([$result, $method], $arguments);
}

// countDocuments method returns int, wrap it to the format expected by the framework
if (is_int($result)) {
$result = [
[
'_id' => null,
'aggregate' => $result,
],
];
}

// Return results as an array with numeric keys
$results = iterator_to_array($cursor, false);
if ($returnLazy) {
return LazyCollection::make(function () use ($result) {
foreach ($result as $item) {
yield $item;
}
});
}

return new Collection($results);
if ($result instanceof Cursor) {
$result = $result->toArray();
}

return new Collection($result);
}

/**
Expand Down
85 changes: 85 additions & 0 deletions tests/Query/BuilderTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
<?php

declare(strict_types=1);

namespace Jenssegers\Mongodb\Tests\Query;

use DateTimeImmutable;
use Jenssegers\Mongodb\Connection;
use Jenssegers\Mongodb\Query\Builder;
use Jenssegers\Mongodb\Query\Processor;
use Mockery as m;
use MongoDB\BSON\UTCDateTime;
use PHPUnit\Framework\TestCase;

class BuilderTest extends TestCase
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This new test class will be reserved to unit-tests on generated queries.
The QueryBuilderTest class have a tearDown method that slow down the tests and make it depend on a server.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In PHPLIB we have a few of those instances, and we usually name the database-dependent test SomethingFunctionalTest to indicate the difference to FunctionalTest. We may want to apply the same pattern here to avoid confusion between QueryBuilderTest and BuilderTest.

{
/**
* @dataProvider provideQueryBuilderToMql
*/
public function testMql(array $expected, \Closure $build): void
{
$builder = $build(self::getBuilder());
$this->assertInstanceOf(Builder::class, $builder);
$mql = $builder->toMql();

// Operations that return a Cursor expect a "typeMap" option.
if (isset($expected['find'][1])) {
$expected['find'][1]['typeMap'] = ['root' => 'array', 'document' => 'array'];
}
if (isset($expected['aggregate'][1])) {
$expected['aggregate'][1]['typeMap'] = ['root' => 'array', 'document' => 'array'];
}

// Compare with assertEquals because the query can contain BSON objects.
$this->assertEquals($expected, $mql, var_export($mql, true));
}

public static function provideQueryBuilderToMql(): iterable
{
/**
* Builder::aggregate() and Builder::count() cannot be tested because they return the result,
* without modifying the builder.
*/
$date = new DateTimeImmutable('2016-07-12 15:30:00');

yield 'find' => [
['find' => [['foo' => 'bar'], []]],
fn (Builder $builder) => $builder->where('foo', 'bar'),
];

yield 'find > date' => [
['find' => [['foo' => ['$gt' => new UTCDateTime($date)]], []]],
fn (Builder $builder) => $builder->where('foo', '>', $date),
];

yield 'find in array' => [
['find' => [['foo' => ['$in' => ['bar', 'baz']]], []]],
fn (Builder $builder) => $builder->whereIn('foo', ['bar', 'baz']),
];

yield 'find limit offset select' => [
['find' => [[], ['limit' => 10, 'skip' => 5, 'projection' => ['foo' => 1, 'bar' => 1]]]],
fn (Builder $builder) => $builder->limit(10)->offset(5)->select('foo', 'bar'),
];

yield 'distinct' => [
['distinct' => ['foo', [], []]],
fn (Builder $builder) => $builder->distinct('foo'),
];

yield 'groupBy' => [
['aggregate' => [[['$group' => ['_id' => ['foo' => '$foo'], 'foo' => ['$last' => '$foo']]]], []]],
fn (Builder $builder) => $builder->groupBy('foo'),
];
}

private static function getBuilder(): Builder
{
$connection = m::mock(Connection::class);
$processor = m::mock(Processor::class);
$connection->shouldReceive('getSession')->andReturn(null);

return new Builder($connection, $processor);
}
}
5 changes: 2 additions & 3 deletions tests/QueryBuilderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -144,8 +144,7 @@ public function testFindWithTimeout()
{
$id = DB::collection('users')->insertGetId(['name' => 'John Doe']);

$subscriber = new class implements CommandSubscriber
{
$subscriber = new class implements CommandSubscriber {
public function commandStarted(CommandStartedEvent $event)
{
if ($event->getCommandName() !== 'find') {
Expand Down Expand Up @@ -830,7 +829,7 @@ public function testValue()
public function testHintOptions()
{
DB::collection('items')->insert([
['name' => 'fork', 'tags' => ['sharp', 'pointy']],
['name' => 'fork', 'tags' => ['sharp', 'pointy']],
['name' => 'spork', 'tags' => ['sharp', 'pointy', 'round', 'bowl']],
['name' => 'spoon', 'tags' => ['round', 'bowl']],
]);
Expand Down