Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add sort operator to $search stage #2554

Merged
merged 5 commits into from
Nov 23, 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
6 changes: 2 additions & 4 deletions lib/Doctrine/ODM/MongoDB/Aggregation/Stage.php
Original file line number Diff line number Diff line change
Expand Up @@ -236,10 +236,8 @@ public function indexStats(): Stage\IndexStats
* Limits the number of documents passed to the next stage in the pipeline.
*
* @see https://docs.mongodb.com/manual/reference/operator/aggregation/limit/
*
* @return Stage\Limit
*/
public function limit(int $limit)
public function limit(int $limit): self
{
return $this->builder->limit($limit);
}
Expand Down Expand Up @@ -435,7 +433,7 @@ public function sortByCount(string $expression): Stage\SortByCount
* @param array<string, int|string>|string $fieldName Field name or array of field/order pairs
* @param int|string $order Field order (if one field is specified)
*/
public function sort($fieldName, $order = null): Stage\Sort
public function sort($fieldName, $order = null): self
{
return $this->builder->sort($fieldName, $order);
}
Expand Down
44 changes: 44 additions & 0 deletions lib/Doctrine/ODM/MongoDB/Aggregation/Stage/Search.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,17 @@
use Doctrine\ODM\MongoDB\Aggregation\Stage\Search\SupportsAllSearchOperators;
use Doctrine\ODM\MongoDB\Aggregation\Stage\Search\SupportsAllSearchOperatorsTrait;

use function in_array;
use function is_array;
use function is_string;
use function strtolower;

/**
* @psalm-import-type SortDirectionKeywords from Sort
* @psalm-type CountType = 'lowerBound'|'total'
* @psalm-type SortMetaKeywords = 'searchScore'
Copy link
Member

Choose a reason for hiding this comment

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

This looks odd, name suggests there can be multiple keywords yet this is a string. Is this coming from MongoDB itself?

Copy link
Member Author

Choose a reason for hiding this comment

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

I used the same strategy we use in the Sort stage itself, where there are multiple meta sort options. In the $search aggregation pipeline stage, the only allowed meta sort is on the search score, hence the single keyword here. I can also change the naming to SortMetaKeywordType or something similar.

* @psalm-type SortMeta = array{'$meta': SortMetaKeywords}
* @psalm-type SortShape = array<string, int|SortMeta|SortDirectionKeywords>
* @psalm-type SearchStageExpression = array{
* '$search': object{
* index?: string,
Expand All @@ -25,6 +34,7 @@
* maxNumPassages?: int,
* },
* returnStoredSource?: bool,
* sort?: object,
* autocomplete?: object,
* compound?: object,
* embeddedDocument?: object,
Expand Down Expand Up @@ -53,6 +63,9 @@ class Search extends Stage implements SupportsAllSearchOperators
private ?bool $returnStoredSource = null;
private ?SearchOperator $operator = null;

/** @var array<string, -1|1|SortMeta> */
private array $sort = [];

public function __construct(Builder $builder)
{
parent::__construct($builder);
Expand All @@ -79,6 +92,10 @@ public function getExpression(): array
$params->returnStoredSource = $this->returnStoredSource;
}

if ($this->sort) {
$params->sort = (object) $this->sort;
}

if ($this->operator !== null) {
$operatorName = $this->operator->getOperatorName();
$params->$operatorName = $this->operator->getOperatorParams();
Expand Down Expand Up @@ -128,6 +145,33 @@ public function returnStoredSource(bool $returnStoredSource = true): static
return $this;
}

/**
* @param array<string, int|string>|string $fieldName Field name or array of field/order pairs
* @param int|string $order Field order (if one field is specified)
* @psalm-param SortShape|string $fieldName
* @psalm-param int|SortMeta|SortDirectionKeywords|null $order
*/
public function sort($fieldName, $order = null): static
Copy link
Member

@malarzm malarzm Nov 6, 2023

Choose a reason for hiding this comment

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

Please add PHPDoc, especially given multiple meanings of $fieldName. Or maybe there's a way to get rid of this overloading? Maybe we should encourage multiple calls to sort instead?

Copy link
Member Author

Choose a reason for hiding this comment

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

This all grew out of the query builder, which allows for precisely that:

$builder
    ->sort('foo', 1)
    ->sort('bar', -1);

// Equivalent to the above
$builder
    ->sort(['foo' => 1, 'bar' => -1]);

I've kept this strategy when creating the egg builder, and now it continues in the $search stage.

In the new aggregation pipeline builder we're creating we're instead leveraging named arguments for this:

Stage::sort(foo: 1, bar: -1)

Long story short, this is going to change once the MongoDB-supported builder is stable enough for us to use it underneath and deprecate ODM's builder :)

Copy link
Member

Choose a reason for hiding this comment

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

Stage::sort(foo: 1, bar: -1)

this looks nice :) looking forward to it!

{
$allowedMetaSort = ['searchScore'];

$fields = is_array($fieldName) ? $fieldName : [$fieldName => $order];

foreach ($fields as $fieldName => $order) {
if (is_string($order)) {
if (in_array($order, $allowedMetaSort, true)) {
$order = ['$meta' => $order];
} else {
$order = strtolower($order) === 'asc' ? 1 : -1;
}
}

$this->sort[$fieldName] = $order;
}

return $this;
}

/**
* @param T $operator
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,16 @@

use Doctrine\ODM\MongoDB\Aggregation\Stage;
use Doctrine\ODM\MongoDB\Aggregation\Stage\Search;
use Doctrine\ODM\MongoDB\Aggregation\Stage\Sort;

/** @internal */
/**
* @internal
*
* @psalm-import-type SortDirectionKeywords from Sort
* @psalm-import-type SortMetaKeywords from Search
* @psalm-import-type SortMeta from Search
* @psalm-import-type SortShape from Search
*/
abstract class AbstractSearchOperator extends Stage implements SearchOperator
{
public function __construct(private Search $search)
Expand Down Expand Up @@ -35,6 +43,17 @@ public function returnStoredSource(bool $returnStoredSource): Search
return $this->search->returnStoredSource($returnStoredSource);
}

/**
* @param array<string, int|string>|string $fieldName Field name or array of field/order pairs
* @param int|string $order Field order (if one field is specified)
* @psalm-param SortShape|string $fieldName
* @psalm-param int|SortMeta|SortDirectionKeywords|null $order
*/
public function sort($fieldName, $order = null): Search
Copy link
Member

Choose a reason for hiding this comment

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

PHPDoc is missing here too

{
return $this->search->sort($fieldName, $order);
}

/**
* @return array<string, object>
* @psalm-return non-empty-array<non-empty-string, object>
Expand Down
85 changes: 5 additions & 80 deletions phpstan-baseline.neon
Original file line number Diff line number Diff line change
@@ -1,10 +1,5 @@
parameters:
ignoreErrors:
-
message: "#^Circular definition detected in type alias PipelineExpression\\.$#"
count: 1
path: lib/Doctrine/ODM/MongoDB/Aggregation/Builder.php

-
message: "#^PHPDoc tag @param references unknown parameter\\: \\$applyFilters$#"
count: 1
Expand Down Expand Up @@ -50,36 +45,11 @@ parameters:
count: 1
path: lib/Doctrine/ODM/MongoDB/Aggregation/Stage/Bucket/AbstractOutput.php

-
message: "#^Method Doctrine\\\\ODM\\\\MongoDB\\\\Aggregation\\\\Stage\\\\Densify\\:\\:getExpression\\(\\) return type has no value type specified in iterable type array\\.$#"
count: 1
path: lib/Doctrine/ODM/MongoDB/Aggregation/Stage/Densify.php

-
message: "#^PHPDoc tag @return with type mixed is not subtype of native type array\\.$#"
count: 1
path: lib/Doctrine/ODM/MongoDB/Aggregation/Stage/Densify.php

-
message: "#^Circular definition detected in type alias FacetStageExpression\\.$#"
count: 1
path: lib/Doctrine/ODM/MongoDB/Aggregation/Stage/Facet.php

-
message: "#^Return type \\(static\\(Doctrine\\\\ODM\\\\MongoDB\\\\Aggregation\\\\Stage\\\\GeoNear\\)\\) of method Doctrine\\\\ODM\\\\MongoDB\\\\Aggregation\\\\Stage\\\\GeoNear\\:\\:limit\\(\\) should be compatible with return type \\(Doctrine\\\\ODM\\\\MongoDB\\\\Aggregation\\\\Stage\\\\Limit\\) of method Doctrine\\\\ODM\\\\MongoDB\\\\Aggregation\\\\Stage\\:\\:limit\\(\\)$#"
count: 1
path: lib/Doctrine/ODM/MongoDB/Aggregation/Stage/GeoNear.php

-
message: "#^Unable to resolve the template type T in call to method Doctrine\\\\ODM\\\\MongoDB\\\\DocumentManager\\:\\:getClassMetadata\\(\\)$#"
count: 1
path: lib/Doctrine/ODM/MongoDB/Aggregation/Stage/GraphLookup.php

-
message: "#^Circular definition detected in type alias PipelineParamType\\.$#"
count: 1
path: lib/Doctrine/ODM/MongoDB/Aggregation/Stage/Lookup.php

-
message: "#^Method Doctrine\\\\ODM\\\\MongoDB\\\\Aggregation\\\\Stage\\\\Lookup\\:\\:getExpression\\(\\) return type has no value type specified in iterable type array\\.$#"
count: 1
Expand All @@ -95,31 +65,11 @@ parameters:
count: 1
path: lib/Doctrine/ODM/MongoDB/Aggregation/Stage/Lookup.php

-
message: "#^Circular definition detected in type alias WhenMatchedParamType\\.$#"
count: 1
path: lib/Doctrine/ODM/MongoDB/Aggregation/Stage/Merge.php

-
message: "#^Circular definition detected in type alias WhenMatchedType\\.$#"
count: 1
path: lib/Doctrine/ODM/MongoDB/Aggregation/Stage/Merge.php

-
message: "#^Method Doctrine\\\\ODM\\\\MongoDB\\\\Aggregation\\\\Stage\\\\Merge\\:\\:getExpression\\(\\) return type has no value type specified in iterable type array\\.$#"
count: 1
path: lib/Doctrine/ODM/MongoDB/Aggregation/Stage/Merge.php

-
message: "#^Method Doctrine\\\\ODM\\\\MongoDB\\\\Aggregation\\\\Stage\\\\Merge\\:\\:whenMatched\\(\\) has parameter \\$whenMatched with no value type specified in iterable type array\\.$#"
count: 1
path: lib/Doctrine/ODM/MongoDB/Aggregation/Stage/Merge.php

-
message: "#^PHPDoc tag @return with type mixed is not subtype of native type array\\.$#"
count: 1
path: lib/Doctrine/ODM/MongoDB/Aggregation/Stage/Merge.php

-
message: "#^Property Doctrine\\\\ODM\\\\MongoDB\\\\Aggregation\\\\Stage\\\\Merge\\:\\:\\$whenMatched type has no value type specified in iterable type array\\.$#"
count: 1
Expand All @@ -140,21 +90,11 @@ parameters:
count: 1
path: lib/Doctrine/ODM/MongoDB/Aggregation/Stage/Search.php

-
message: "#^Method Doctrine\\\\ODM\\\\MongoDB\\\\Aggregation\\\\Stage\\\\Search\\:\\:getExpression\\(\\) return type has no value type specified in iterable type array\\.$#"
count: 1
path: lib/Doctrine/ODM/MongoDB/Aggregation/Stage/Search.php

-
message: "#^Method Doctrine\\\\ODM\\\\MongoDB\\\\Aggregation\\\\Stage\\\\Search\\:\\:near\\(\\) has parameter \\$origin with no value type specified in iterable type array\\.$#"
count: 1
path: lib/Doctrine/ODM/MongoDB/Aggregation/Stage/Search.php

-
message: "#^PHPDoc tag @return with type mixed is not subtype of native type array\\.$#"
count: 1
path: lib/Doctrine/ODM/MongoDB/Aggregation/Stage/Search.php

-
message: "#^Method Doctrine\\\\ODM\\\\MongoDB\\\\Aggregation\\\\Stage\\\\Search\\\\Compound\\:\\:geoShape\\(\\) has parameter \\$geometry with no value type specified in iterable type array\\.$#"
count: 1
Expand Down Expand Up @@ -470,31 +410,11 @@ parameters:
count: 1
path: lib/Doctrine/ODM/MongoDB/Aggregation/Stage/Search/SupportsNearOperator.php

-
message: "#^Method Doctrine\\\\ODM\\\\MongoDB\\\\Aggregation\\\\Stage\\\\SetWindowFields\\:\\:getExpression\\(\\) return type has no value type specified in iterable type array\\.$#"
count: 1
path: lib/Doctrine/ODM/MongoDB/Aggregation/Stage/SetWindowFields.php

-
message: "#^PHPDoc tag @return with type mixed is not subtype of native type array\\.$#"
count: 1
path: lib/Doctrine/ODM/MongoDB/Aggregation/Stage/SetWindowFields.php

-
message: "#^Method Doctrine\\\\ODM\\\\MongoDB\\\\Aggregation\\\\Stage\\\\UnionWith\\:\\:getExpression\\(\\) return type has no value type specified in iterable type array\\.$#"
count: 1
path: lib/Doctrine/ODM/MongoDB/Aggregation/Stage/UnionWith.php

-
message: "#^Method Doctrine\\\\ODM\\\\MongoDB\\\\Aggregation\\\\Stage\\\\UnionWith\\:\\:pipeline\\(\\) has parameter \\$pipeline with no value type specified in iterable type array\\.$#"
count: 1
path: lib/Doctrine/ODM/MongoDB/Aggregation/Stage/UnionWith.php

-
message: "#^PHPDoc tag @return with type mixed is not subtype of native type array\\.$#"
count: 1
path: lib/Doctrine/ODM/MongoDB/Aggregation/Stage/UnionWith.php

-
message: "#^Property Doctrine\\\\ODM\\\\MongoDB\\\\Aggregation\\\\Stage\\\\UnionWith\\:\\:\\$pipeline type has no value type specified in iterable type array\\.$#"
count: 1
Expand Down Expand Up @@ -710,6 +630,11 @@ parameters:
count: 1
path: tests/Doctrine/ODM/MongoDB/Tests/Aggregation/Stage/SearchTest.php

-
message: "#^Method Doctrine\\\\ODM\\\\MongoDB\\\\Tests\\\\Aggregation\\\\Stage\\\\SearchTest\\:\\:testSearchOperatorsWithSort\\(\\) has parameter \\$expectedOperator with no value type specified in iterable type array\\.$#"
count: 1
path: tests/Doctrine/ODM/MongoDB/Tests/Aggregation/Stage/SearchTest.php

-
message: "#^Method Doctrine\\\\ODM\\\\MongoDB\\\\Tests\\\\Aggregation\\\\Stage\\\\SetWindowFieldsTest\\:\\:testOperators\\(\\) has parameter \\$args with no type specified\\.$#"
count: 1
Expand Down
6 changes: 6 additions & 0 deletions phpstan.neon.dist
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,16 @@ parameters:
ignoreErrors:
# Ignore typing providers in tests
- '#^Method Doctrine\\ODM\\MongoDB\\Tests\\[^:]+(Test)::(get\w+|data\w+|provide\w+)\(\) return type has no value type specified in iterable type (array|iterable)\.#'

# Ignore circular references in Psalm types
- message: '#^Circular definition detected in type alias#'
path: lib/Doctrine/ODM/MongoDB/Aggregation/

# To be removed when reaching phpstan level 6
checkMissingVarTagTypehint: true
checkMissingTypehints: true
checkMissingIterableValueType: true

# Disabled due to inconsistent errors upon encountering psalm types
reportUnmatchedIgnoredErrors: false

Expand Down
12 changes: 12 additions & 0 deletions psalm-baseline.xml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,18 @@
<code>IteratorAggregate</code>
</MissingTemplateParam>
</file>
<file src="lib/Doctrine/ODM/MongoDB/Aggregation/Stage/Search.php">
<MoreSpecificImplementedParamType>
<code>$fieldName</code>
<code>$order</code>
</MoreSpecificImplementedParamType>
</file>
<file src="lib/Doctrine/ODM/MongoDB/Aggregation/Stage/Search/AbstractSearchOperator.php">
<MoreSpecificImplementedParamType>
<code>$fieldName</code>
<code>$order</code>
</MoreSpecificImplementedParamType>
</file>
<file src="lib/Doctrine/ODM/MongoDB/Configuration.php">
<TypeDoesNotContainType>
<code><![CDATA[$reflectionClass->implementsInterface(ClassMetadataFactoryInterface::class)]]></code>
Expand Down
47 changes: 47 additions & 0 deletions tests/Doctrine/ODM/MongoDB/Tests/Aggregation/Stage/SearchTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -1181,6 +1181,53 @@ public function testSearchOperators(array $expectedOperator, Closure $createOper
);
}

#[DataProvider('provideAutocompleteBuilders')]
#[DataProvider('provideCompoundBuilders')]
#[DataProvider('provideEmbeddedDocumentBuilders')]
#[DataProvider('provideEqualsBuilders')]
#[DataProvider('provideExistsBuilders')]
#[DataProvider('provideGeoShapeBuilders')]
#[DataProvider('provideGeoWithinBuilders')]
#[DataProvider('provideMoreLikeThisBuilders')]
#[DataProvider('provideNearBuilders')]
#[DataProvider('providePhraseBuilders')]
#[DataProvider('provideQueryStringBuilders')]
#[DataProvider('provideRangeBuilders')]
#[DataProvider('provideRegexBuilders')]
#[DataProvider('provideTextBuilders')]
#[DataProvider('provideWildcardBuilders')]
public function testSearchOperatorsWithSort(array $expectedOperator, Closure $createOperator): void
{
$baseExpected = [
'index' => 'my_search_index',
'sort' => (object) [
'unused' => ['$meta' => 'searchScore'],
'date' => -1,
'bar' => 1,
],
];

$searchStage = new Search($this->getTestAggregationBuilder());
$searchStage
->index('my_search_index');

$result = $createOperator($searchStage);

self::logicalOr(
new IsInstanceOf(AbstractSearchOperator::class),
new IsInstanceOf(Search::class),
);

$result
->sort(['unused' => 'searchScore', 'date' => -1])
->sort(['bar' => 1]);

self::assertEquals(
['$search' => (object) array_merge($baseExpected, $expectedOperator)],
$searchStage->getExpression(),
);
}

#[DataProvider('provideAutocompleteBuilders')]
#[DataProvider('provideEmbeddedDocumentBuilders')]
#[DataProvider('provideEqualsBuilders')]
Expand Down