From 8dcc88617fad985d751681921730d7eb826f6a5c Mon Sep 17 00:00:00 2001 From: Andreas Braun Date: Mon, 22 Jan 2024 15:13:54 +0100 Subject: [PATCH 01/17] Split encoders into multiple files --- src/Builder/BuilderEncoder.php | 316 +++--------------- .../Encoder/CombinedFieldQueryEncoder.php | 90 +++++ src/Builder/Encoder/ExpressionEncoder.php | 13 + src/Builder/Encoder/FieldPathEncoder.php | 45 +++ src/Builder/Encoder/OperatorEncoder.php | 199 +++++++++++ src/Builder/Encoder/OutputWindowEncoder.php | 99 ++++++ src/Builder/Encoder/PipelineEncoder.php | 51 +++ src/Builder/Encoder/QueryEncoder.php | 97 ++++++ src/Builder/Encoder/VariableEncoder.php | 44 +++ 9 files changed, 687 insertions(+), 267 deletions(-) create mode 100644 src/Builder/Encoder/CombinedFieldQueryEncoder.php create mode 100644 src/Builder/Encoder/ExpressionEncoder.php create mode 100644 src/Builder/Encoder/FieldPathEncoder.php create mode 100644 src/Builder/Encoder/OperatorEncoder.php create mode 100644 src/Builder/Encoder/OutputWindowEncoder.php create mode 100644 src/Builder/Encoder/PipelineEncoder.php create mode 100644 src/Builder/Encoder/QueryEncoder.php create mode 100644 src/Builder/Encoder/VariableEncoder.php diff --git a/src/Builder/BuilderEncoder.php b/src/Builder/BuilderEncoder.php index c7c0b27..cbd97d0 100644 --- a/src/Builder/BuilderEncoder.php +++ b/src/Builder/BuilderEncoder.php @@ -4,323 +4,105 @@ namespace MongoDB\Builder; -use LogicException; +use MongoDB\Builder\Encoder\CombinedFieldQueryEncoder; +use MongoDB\Builder\Encoder\ExpressionEncoder; +use MongoDB\Builder\Encoder\FieldPathEncoder; +use MongoDB\Builder\Encoder\OperatorEncoder; +use MongoDB\Builder\Encoder\OutputWindowEncoder; +use MongoDB\Builder\Encoder\PipelineEncoder; +use MongoDB\Builder\Encoder\QueryEncoder; +use MongoDB\Builder\Encoder\VariableEncoder; use MongoDB\Builder\Expression\Variable; -use MongoDB\Builder\Stage\GroupStage; -use MongoDB\Builder\Type\AccumulatorInterface; use MongoDB\Builder\Type\CombinedFieldQuery; -use MongoDB\Builder\Type\Encode; use MongoDB\Builder\Type\ExpressionInterface; use MongoDB\Builder\Type\FieldPathInterface; -use MongoDB\Builder\Type\FieldQueryInterface; use MongoDB\Builder\Type\OperatorInterface; -use MongoDB\Builder\Type\Optional; use MongoDB\Builder\Type\OutputWindow; -use MongoDB\Builder\Type\ProjectionInterface; use MongoDB\Builder\Type\QueryInterface; use MongoDB\Builder\Type\QueryObject; use MongoDB\Builder\Type\StageInterface; -use MongoDB\Builder\Type\WindowInterface; use MongoDB\Codec\EncodeIfSupported; use MongoDB\Codec\Encoder; use MongoDB\Exception\UnsupportedValueException; use stdClass; -use function array_key_exists; -use function array_key_first; -use function assert; -use function get_debug_type; -use function get_object_vars; -use function is_array; use function is_object; -use function MongoDB\is_first_key_operator; -use function property_exists; -use function sprintf; /** @template-implements Encoder */ class BuilderEncoder implements Encoder { use EncodeIfSupported; - /** - * {@inheritdoc} - */ - public function canEncode($value): bool + /** @var array> */ + private array $defaultEncoders = [ + Pipeline::class => PipelineEncoder::class, + Variable::class => VariableEncoder::class, + FieldPathInterface::class => FieldPathEncoder::class, + CombinedFieldQuery::class => CombinedFieldQueryEncoder::class, + QueryObject::class => QueryEncoder::class, + OutputWindow::class => OutputWindowEncoder::class, + OperatorInterface::class => OperatorEncoder::class, + ]; + + /** @var array> */ + private array $cachedEncoders = []; + + /** @param array> $customEncoders */ + public function __construct(private readonly array $customEncoders = []) { - return $value instanceof Pipeline - || $value instanceof OperatorInterface - || $value instanceof ExpressionInterface - || $value instanceof QueryInterface - || $value instanceof FieldQueryInterface - || $value instanceof AccumulatorInterface - || $value instanceof ProjectionInterface - || $value instanceof WindowInterface; } /** * {@inheritdoc} */ - public function encode($value): stdClass|array|string - { - if (! $this->canEncode($value)) { - throw UnsupportedValueException::invalidEncodableValue($value); - } - - // A pipeline is encoded as a list of stages - if ($value instanceof Pipeline) { - $encoded = []; - foreach ($value->getIterator() as $stage) { - $encoded[] = $this->encodeIfSupported($stage); - } - - return $encoded; - } - - // This specific encoding code if temporary until we have a generic way to encode stages and operators - if ($value instanceof FieldPathInterface) { - return '$' . $value->name; - } - - if ($value instanceof Variable) { - return '$$' . $value->name; - } - - if ($value instanceof QueryObject) { - return $this->encodeQueryObject($value); - } - - if ($value instanceof CombinedFieldQuery) { - return $this->encodeCombinedFilter($value); - } - - if ($value instanceof OutputWindow) { - return $this->encodeOutputWindow($value); - } - - if (! $value instanceof OperatorInterface) { - throw new LogicException(sprintf('Class "%s" does not implement OperatorInterface.', $value::class)); - } - - // The generic but incomplete encoding code - switch ($value::ENCODE) { - case Encode::Single: - return $this->encodeAsSingle($value); - - case Encode::Array: - return $this->encodeAsArray($value); - - case Encode::Object: - return $this->encodeAsObject($value); - - case Encode::DollarObject: - return $this->encodeAsDollarObject($value); - - case Encode::Group: - assert($value instanceof GroupStage); - - return $this->encodeAsGroup($value); - } - - throw new LogicException(sprintf('Class "%s" does not have a valid ENCODE constant.', $value::class)); - } - - /** - * Encode the value as an array of properties, in the order they are defined in the class. - */ - private function encodeAsArray(OperatorInterface $value): stdClass - { - $result = []; - /** @var mixed $val */ - foreach (get_object_vars($value) as $val) { - // Skip optional arguments. - // $slice operator has the optional argument in the middle of the array - if ($val === Optional::Undefined) { - continue; - } - - $result[] = $this->recursiveEncode($val); - } - - return $this->wrap($value, $result); - } - - /** - * $group stage have a specific encoding because the _id argument is required and others are variadic - */ - private function encodeAsGroup(GroupStage $value): stdClass - { - $result = new stdClass(); - $result->_id = $this->recursiveEncode($value->_id); - - foreach (get_object_vars($value->field) as $key => $val) { - $result->{$key} = $this->recursiveEncode($val); - } - - return $this->wrap($value, $result); - } - - private function encodeAsObject(OperatorInterface $value): stdClass - { - $result = new stdClass(); - foreach (get_object_vars($value) as $key => $val) { - // Skip optional arguments. If they have a default value, it is resolved by the server. - if ($val === Optional::Undefined) { - continue; - } - - $result->{$key} = $this->recursiveEncode($val); - } - - return $this->wrap($value, $result); - } - - private function encodeAsDollarObject(OperatorInterface $value): stdClass - { - $result = new stdClass(); - foreach (get_object_vars($value) as $key => $val) { - // Skip optional arguments. If they have a default value, it is resolved by the server. - if ($val === Optional::Undefined) { - continue; - } - - $val = $this->recursiveEncode($val); - - if ($key === 'geometry') { - if (is_object($val) && property_exists($val, '$geometry')) { - $result->{'$geometry'} = $val->{'$geometry'}; - } elseif (is_array($val) && array_key_exists('$geometry', $val)) { - $result->{'$geometry'} = $val->{'$geometry'}; - } else { - $result->{'$geometry'} = $val; - } - } else { - $result->{'$' . $key} = $val; - } - } - - return $this->wrap($value, $result); - } - - /** - * Get the unique property of the operator as value - */ - private function encodeAsSingle(OperatorInterface $value): stdClass + public function canEncode($value): bool { - foreach (get_object_vars($value) as $val) { - $result = $this->recursiveEncode($val); - - return $this->wrap($value, $result); + if (! is_object($value)) { + return false; } - throw new LogicException(sprintf('Class "%s" does not have a single property.', $value::class)); - } - - private function encodeCombinedFilter(CombinedFieldQuery $filter): stdClass - { - $result = new stdClass(); - foreach ($filter->fieldQueries as $filter) { - $filter = $this->recursiveEncode($filter); - if (is_object($filter)) { - $filter = get_object_vars($filter); - } elseif (! is_array($filter)) { - throw new LogicException(sprintf('Query filters must an array or an object. Got "%s"', get_debug_type($filter))); - } + $encoder = $this->getEncoderFor($value); - foreach ($filter as $key => $value) { - $result->{$key} = $value; - } - } - - return $result; + return $encoder !== null && $encoder->canEncode($value); } /** - * Query objects are encoded by merging query operator with field path to filter operators in the same object. + * {@inheritdoc} */ - private function encodeQueryObject(QueryObject $query): stdClass + public function encode($value): stdClass|array|string { - $result = new stdClass(); - foreach ($query->queries as $key => $value) { - if ($value instanceof QueryInterface) { - // The sub-objects is merged into the main object, replacing duplicate keys - foreach (get_object_vars($this->recursiveEncode($value)) as $subKey => $subValue) { - if (property_exists($result, (string) $subKey)) { - throw new LogicException(sprintf('Duplicate key "%s" in query object', $subKey)); - } - - $result->{$subKey} = $subValue; - } - } else { - if (property_exists($result, (string) $key)) { - throw new LogicException(sprintf('Duplicate key "%s" in query object', $key)); - } + $encoder = $this->getEncoderFor($value); - $result->{$key} = $this->encodeIfSupported($value); - } + if (! $encoder || ! $encoder->canEncode($value)) { + throw UnsupportedValueException::invalidEncodableValue($value); } - return $result; + return $encoder->encode($value); } - /** - * For the $setWindowFields stage output parameter, the optional window parameter is encoded in the same object - * of the window operator. - * - * @see https://www.mongodb.com/docs/manual/reference/operator/aggregation/setWindowFields/ - */ - private function encodeOutputWindow(OutputWindow $outputWindow): stdClass + private function getEncoderFor(object $value): ExpressionEncoder|null { - $result = $this->recursiveEncode($outputWindow->operator); - - // Transform the result into an stdClass if a document is provided - if (! $outputWindow->operator instanceof WindowInterface && (is_array($result) || is_object($result))) { - if (! is_first_key_operator($result)) { - throw new LogicException(sprintf('Expected OutputWindow::$operator to be an operator. Got "%s"', array_key_first((array) $result))); - } - - $result = (object) $result; + $valueClass = $value::class; + if (isset($this->cachedEncoders[$valueClass])) { + return $this->cachedEncoders[$valueClass]; } - if (! $result instanceof stdClass) { - throw new LogicException(sprintf('Expected OutputWindow::$operator to be an stdClass, array or WindowInterface. Got "%s"', get_debug_type($result))); - } + $encoderList = $this->customEncoders + $this->defaultEncoders; - if ($outputWindow->window !== Optional::Undefined) { - $result->window = $this->recursiveEncode($outputWindow->window); - } - - return $result; - } - - /** - * Nested arrays and objects must be encoded recursively. - */ - private function recursiveEncode(mixed $value): mixed - { - if (is_array($value)) { - foreach ($value as $key => $val) { - $value[$key] = $this->recursiveEncode($val); + // First attempt: match class name exactly + foreach ($encoderList as $className => $encoderClass) { + if ($className == $valueClass) { + return $this->cachedEncoders[$valueClass] = $encoderClass::createForEncoder($this); } - - return $value; } - if ($value instanceof stdClass) { - foreach (get_object_vars($value) as $key => $val) { - $value->{$key} = $this->recursiveEncode($val); + // Second attempt: catch child classes + foreach ($encoderList as $className => $encoderClass) { + if ($value instanceof $className) { + return $this->cachedEncoders[$valueClass] = $encoderClass::createForEncoder($this); } - - return $value; } - return $this->encodeIfSupported($value); - } - - private function wrap(OperatorInterface $value, mixed $result): stdClass - { - $object = new stdClass(); - $object->{$value->getOperator()} = $result; - - return $object; + return null; } } diff --git a/src/Builder/Encoder/CombinedFieldQueryEncoder.php b/src/Builder/Encoder/CombinedFieldQueryEncoder.php new file mode 100644 index 0000000..9c109e5 --- /dev/null +++ b/src/Builder/Encoder/CombinedFieldQueryEncoder.php @@ -0,0 +1,90 @@ + */ +class CombinedFieldQueryEncoder implements ExpressionEncoder +{ + /** @template-use EncodeIfSupported */ + use EncodeIfSupported; + + public function __construct(private readonly BuilderEncoder $encoder) + { + } + + public static function createForEncoder(BuilderEncoder $encoder): static + { + return new self($encoder); + } + + /** @psalm-assert-if-true CombinedFieldQuery $value */ + public function canEncode(mixed $value): bool + { + return $value instanceof CombinedFieldQuery; + } + + /** + * {@inheritdoc} + */ + public function encode($value): stdClass + { + if (! $this->canEncode($value)) { + throw UnsupportedValueException::invalidEncodableValue($value); + } + + $result = new stdClass(); + foreach ($value->fieldQueries as $filter) { + $filter = $this->recursiveEncode($filter); + if (is_object($filter)) { + $filter = get_object_vars($filter); + } elseif (! is_array($filter)) { + throw new LogicException(sprintf('Query filters must an array or an object. Got "%s"', get_debug_type($filter))); + } + + foreach ($filter as $key => $filterValue) { + $result->{$key} = $filterValue; + } + } + + return $result; + } + + /** + * Nested arrays and objects must be encoded recursively. + */ + private function recursiveEncode(mixed $value): mixed + { + if (is_array($value)) { + foreach ($value as $key => $val) { + $value[$key] = $this->recursiveEncode($val); + } + + return $value; + } + + if ($value instanceof stdClass) { + foreach (get_object_vars($value) as $key => $val) { + $value->{$key} = $this->recursiveEncode($val); + } + + return $value; + } + + return $this->encoder->encodeIfSupported($value); + } +} diff --git a/src/Builder/Encoder/ExpressionEncoder.php b/src/Builder/Encoder/ExpressionEncoder.php new file mode 100644 index 0000000..a32dcc6 --- /dev/null +++ b/src/Builder/Encoder/ExpressionEncoder.php @@ -0,0 +1,13 @@ + */ +class FieldPathEncoder implements ExpressionEncoder +{ + /** @template-use EncodeIfSupported */ + use EncodeIfSupported; + + public function __construct(private readonly BuilderEncoder $encoder) + { + } + + public static function createForEncoder(BuilderEncoder $encoder): static + { + return new self($encoder); + } + + /** @psalm-assert-if-true FieldPathInterface $value */ + public function canEncode(mixed $value): bool + { + return $value instanceof FieldPathInterface; + } + + /** + * {@inheritdoc} + */ + public function encode($value): string + { + if (! $this->canEncode($value)) { + throw UnsupportedValueException::invalidEncodableValue($value); + } + + // TODO: needs method because of interface + return '$' . $value->name; + } +} diff --git a/src/Builder/Encoder/OperatorEncoder.php b/src/Builder/Encoder/OperatorEncoder.php new file mode 100644 index 0000000..54c810a --- /dev/null +++ b/src/Builder/Encoder/OperatorEncoder.php @@ -0,0 +1,199 @@ + */ +class OperatorEncoder implements ExpressionEncoder +{ + /** @template-use EncodeIfSupported */ + use EncodeIfSupported; + + public function __construct(protected readonly BuilderEncoder $encoder) + { + } + + public static function createForEncoder(BuilderEncoder $encoder): static + { + return new self($encoder); + } + + /** @psalm-assert-if-true OperatorInterface $value */ + public function canEncode(mixed $value): bool + { + return $value instanceof OperatorInterface; + } + + /** + * {@inheritdoc} + */ + public function encode($value): stdClass|array|string + { + if (! $this->canEncode($value)) { + throw UnsupportedValueException::invalidEncodableValue($value); + } + + switch ($value::ENCODE) { + case Encode::Single: + return $this->encodeAsSingle($value); + + case Encode::Array: + return $this->encodeAsArray($value); + + case Encode::Object: + return $this->encodeAsObject($value); + + case Encode::DollarObject: + return $this->encodeAsDollarObject($value); + + case Encode::Group: + assert($value instanceof GroupStage); + + return $this->encodeAsGroup($value); + } + + throw new LogicException(sprintf('Class "%s" does not have a valid ENCODE constant.', $value::class)); + } + + /** + * Encode the value as an array of properties, in the order they are defined in the class. + */ + private function encodeAsArray(OperatorInterface $value): stdClass + { + $result = []; + /** @var mixed $val */ + foreach (get_object_vars($value) as $val) { + // Skip optional arguments. + // $slice operator has the optional argument in the middle of the array + if ($val === Optional::Undefined) { + continue; + } + + $result[] = $this->recursiveEncode($val); + } + + return $this->wrap($value, $result); + } + + /** + * $group stage have a specific encoding because the _id argument is required and others are variadic + */ + private function encodeAsGroup(GroupStage $value): stdClass + { + $result = new stdClass(); + $result->_id = $this->recursiveEncode($value->_id); + + foreach (get_object_vars($value->field) as $key => $val) { + $result->{$key} = $this->recursiveEncode($val); + } + + return $this->wrap($value, $result); + } + + private function encodeAsObject(OperatorInterface $value): stdClass + { + $result = new stdClass(); + foreach (get_object_vars($value) as $key => $val) { + // Skip optional arguments. If they have a default value, it is resolved by the server. + if ($val === Optional::Undefined) { + continue; + } + + $result->{$key} = $this->recursiveEncode($val); + } + + return $this->wrap($value, $result); + } + + private function encodeAsDollarObject(OperatorInterface $value): stdClass + { + $result = new stdClass(); + foreach (get_object_vars($value) as $key => $val) { + // Skip optional arguments. If they have a default value, it is resolved by the server. + if ($val === Optional::Undefined) { + continue; + } + + $val = $this->recursiveEncode($val); + + if ($key === 'geometry') { + if (is_object($val) && property_exists($val, '$geometry')) { + $result->{'$geometry'} = $val->{'$geometry'}; + } elseif (is_array($val) && array_key_exists('$geometry', $val)) { + $result->{'$geometry'} = $val->{'$geometry'}; + } else { + $result->{'$geometry'} = $val; + } + } else { + $result->{'$' . $key} = $val; + } + } + + return $this->wrap($value, $result); + } + + /** + * Get the unique property of the operator as value + */ + private function encodeAsSingle(OperatorInterface $value): stdClass + { + foreach (get_object_vars($value) as $val) { + $result = $this->recursiveEncode($val); + + return $this->wrap($value, $result); + } + + throw new LogicException(sprintf('Class "%s" does not have a single property.', $value::class)); + } + + /** + * Nested arrays and objects must be encoded recursively. + */ + private function recursiveEncode(mixed $value): mixed + { + if (is_array($value)) { + foreach ($value as $key => $val) { + $value[$key] = $this->recursiveEncode($val); + } + + return $value; + } + + if ($value instanceof stdClass) { + foreach (get_object_vars($value) as $key => $val) { + $value->{$key} = $this->recursiveEncode($val); + } + + return $value; + } + + return $this->encoder->encodeIfSupported($value); + } + + private function wrap(OperatorInterface $value, mixed $result): stdClass + { + $object = new stdClass(); + $object->{$value->getOperator()} = $result; + + return $object; + } +} diff --git a/src/Builder/Encoder/OutputWindowEncoder.php b/src/Builder/Encoder/OutputWindowEncoder.php new file mode 100644 index 0000000..0c13c35 --- /dev/null +++ b/src/Builder/Encoder/OutputWindowEncoder.php @@ -0,0 +1,99 @@ + */ +class OutputWindowEncoder implements ExpressionEncoder +{ + /** @template-use EncodeIfSupported */ + use EncodeIfSupported; + + public function __construct(protected readonly BuilderEncoder $encoder) + { + } + + public static function createForEncoder(BuilderEncoder $encoder): static + { + return new self($encoder); + } + + /** @psalm-assert-if-true OutputWindow $value */ + public function canEncode(mixed $value): bool + { + return $value instanceof OutputWindow; + } + + /** + * {@inheritdoc} + */ + public function encode($value): stdClass + { + if (! $this->canEncode($value)) { + throw UnsupportedValueException::invalidEncodableValue($value); + } + + $result = $this->recursiveEncode($value->operator); + + // Transform the result into an stdClass if a document is provided + if (! $value->operator instanceof WindowInterface && (is_array($result) || is_object($result))) { + if (! is_first_key_operator($result)) { + throw new LogicException(sprintf('Expected OutputWindow::$operator to be an operator. Got "%s"', array_key_first((array) $result))); + } + + $result = (object) $result; + } + + if (! $result instanceof stdClass) { + throw new LogicException(sprintf('Expected OutputWindow::$operator to be an stdClass, array or WindowInterface. Got "%s"', get_debug_type($result))); + } + + if ($value->window !== Optional::Undefined) { + $result->window = $this->recursiveEncode($value->window); + } + + return $result; + } + + /** + * Nested arrays and objects must be encoded recursively. + */ + private function recursiveEncode(mixed $value): mixed + { + if (is_array($value)) { + foreach ($value as $key => $val) { + $value[$key] = $this->recursiveEncode($val); + } + + return $value; + } + + if ($value instanceof stdClass) { + foreach (get_object_vars($value) as $key => $val) { + $value->{$key} = $this->recursiveEncode($val); + } + + return $value; + } + + return $this->encoder->encodeIfSupported($value); + } +} diff --git a/src/Builder/Encoder/PipelineEncoder.php b/src/Builder/Encoder/PipelineEncoder.php new file mode 100644 index 0000000..258e2ee --- /dev/null +++ b/src/Builder/Encoder/PipelineEncoder.php @@ -0,0 +1,51 @@ + */ +class PipelineEncoder implements ExpressionEncoder +{ + /** @template-use EncodeIfSupported */ + use EncodeIfSupported; + + public function __construct(protected readonly BuilderEncoder $encoder) + { + } + + public static function createForEncoder(BuilderEncoder $encoder): static + { + return new self($encoder); + } + + /** @psalm-assert-if-true Pipeline $value */ + public function canEncode(mixed $value): bool + { + return $value instanceof Pipeline; + } + + /** + * {@inheritdoc} + */ + public function encode($value): stdClass|array|string + { + if (! $this->canEncode($value)) { + throw UnsupportedValueException::invalidEncodableValue($value); + } + + $encoded = []; + foreach ($value->getIterator() as $stage) { + // Todo: Needs StageEncoder + $encoded[] = $this->encoder->encodeIfSupported($stage); + } + + return $encoded; + } +} diff --git a/src/Builder/Encoder/QueryEncoder.php b/src/Builder/Encoder/QueryEncoder.php new file mode 100644 index 0000000..075f210 --- /dev/null +++ b/src/Builder/Encoder/QueryEncoder.php @@ -0,0 +1,97 @@ + */ +class QueryEncoder implements ExpressionEncoder +{ + /** @template-use EncodeIfSupported */ + use EncodeIfSupported; + + public function __construct(protected readonly BuilderEncoder $encoder) + { + } + + public static function createForEncoder(BuilderEncoder $encoder): static + { + return new self($encoder); + } + + /** @psalm-assert-if-true Variable $value */ + public function canEncode(mixed $value): bool + { + return $value instanceof QueryObject; + } + + /** + * {@inheritdoc} + */ + public function encode($value): stdClass + { + if (! $this->canEncode($value)) { + throw UnsupportedValueException::invalidEncodableValue($value); + } + + $result = new stdClass(); + foreach ($value->queries as $key => $value) { + if ($value instanceof QueryInterface) { + // The sub-objects is merged into the main object, replacing duplicate keys + foreach (get_object_vars($this->recursiveEncode($value)) as $subKey => $subValue) { + if (property_exists($result, (string) $subKey)) { + throw new LogicException(sprintf('Duplicate key "%s" in query object', $subKey)); + } + + $result->{$subKey} = $subValue; + } + } else { + if (property_exists($result, (string) $key)) { + throw new LogicException(sprintf('Duplicate key "%s" in query object', $key)); + } + + $result->{$key} = $this->encoder->encodeIfSupported($value); + } + } + + return $result; + } + + /** + * Nested arrays and objects must be encoded recursively. + */ + private function recursiveEncode(mixed $value): mixed + { + if (is_array($value)) { + foreach ($value as $key => $val) { + $value[$key] = $this->recursiveEncode($val); + } + + return $value; + } + + if ($value instanceof stdClass) { + foreach (get_object_vars($value) as $key => $val) { + $value->{$key} = $this->recursiveEncode($val); + } + + return $value; + } + + return $this->encoder->encodeIfSupported($value); + } +} diff --git a/src/Builder/Encoder/VariableEncoder.php b/src/Builder/Encoder/VariableEncoder.php new file mode 100644 index 0000000..c470a6c --- /dev/null +++ b/src/Builder/Encoder/VariableEncoder.php @@ -0,0 +1,44 @@ + */ +class VariableEncoder implements ExpressionEncoder +{ + /** @template-use EncodeIfSupported */ + use EncodeIfSupported; + + public function __construct(protected readonly BuilderEncoder $encoder) + { + } + + public static function createForEncoder(BuilderEncoder $encoder): static + { + return new self($encoder); + } + + /** @psalm-assert-if-true Variable $value */ + public function canEncode(mixed $value): bool + { + return $value instanceof Variable; + } + + /** + * {@inheritdoc} + */ + public function encode($value): string + { + if (! $this->canEncode($value)) { + throw UnsupportedValueException::invalidEncodableValue($value); + } + + return '$$' . $value->name; + } +} From 02bfae85b62440a29024928c35db7f40bf258ad4 Mon Sep 17 00:00:00 2001 From: Andreas Braun Date: Tue, 23 Jan 2024 08:48:24 +0100 Subject: [PATCH 02/17] Remove unnecessary factory method --- src/Builder/BuilderEncoder.php | 6 +++--- src/Builder/Encoder/CombinedFieldQueryEncoder.php | 5 ----- src/Builder/Encoder/ExpressionEncoder.php | 2 +- src/Builder/Encoder/FieldPathEncoder.php | 7 +------ src/Builder/Encoder/OperatorEncoder.php | 5 ----- src/Builder/Encoder/OutputWindowEncoder.php | 5 ----- src/Builder/Encoder/PipelineEncoder.php | 5 ----- src/Builder/Encoder/QueryEncoder.php | 5 ----- src/Builder/Encoder/VariableEncoder.php | 5 ----- 9 files changed, 5 insertions(+), 40 deletions(-) diff --git a/src/Builder/BuilderEncoder.php b/src/Builder/BuilderEncoder.php index cbd97d0..91f0f0b 100644 --- a/src/Builder/BuilderEncoder.php +++ b/src/Builder/BuilderEncoder.php @@ -44,7 +44,7 @@ class BuilderEncoder implements Encoder OperatorInterface::class => OperatorEncoder::class, ]; - /** @var array> */ + /** @var array */ private array $cachedEncoders = []; /** @param array> $customEncoders */ @@ -92,14 +92,14 @@ private function getEncoderFor(object $value): ExpressionEncoder|null // First attempt: match class name exactly foreach ($encoderList as $className => $encoderClass) { if ($className == $valueClass) { - return $this->cachedEncoders[$valueClass] = $encoderClass::createForEncoder($this); + return $this->cachedEncoders[$valueClass] = new $encoderClass($this); } } // Second attempt: catch child classes foreach ($encoderList as $className => $encoderClass) { if ($value instanceof $className) { - return $this->cachedEncoders[$valueClass] = $encoderClass::createForEncoder($this); + return $this->cachedEncoders[$valueClass] = new $encoderClass($this); } } diff --git a/src/Builder/Encoder/CombinedFieldQueryEncoder.php b/src/Builder/Encoder/CombinedFieldQueryEncoder.php index 9c109e5..7f6e539 100644 --- a/src/Builder/Encoder/CombinedFieldQueryEncoder.php +++ b/src/Builder/Encoder/CombinedFieldQueryEncoder.php @@ -27,11 +27,6 @@ public function __construct(private readonly BuilderEncoder $encoder) { } - public static function createForEncoder(BuilderEncoder $encoder): static - { - return new self($encoder); - } - /** @psalm-assert-if-true CombinedFieldQuery $value */ public function canEncode(mixed $value): bool { diff --git a/src/Builder/Encoder/ExpressionEncoder.php b/src/Builder/Encoder/ExpressionEncoder.php index a32dcc6..5e31c71 100644 --- a/src/Builder/Encoder/ExpressionEncoder.php +++ b/src/Builder/Encoder/ExpressionEncoder.php @@ -9,5 +9,5 @@ interface ExpressionEncoder extends Encoder { - public static function createForEncoder(BuilderEncoder $encoder): static; + public function __construct(BuilderEncoder $encoder); } diff --git a/src/Builder/Encoder/FieldPathEncoder.php b/src/Builder/Encoder/FieldPathEncoder.php index 173cdc3..f99bfdf 100644 --- a/src/Builder/Encoder/FieldPathEncoder.php +++ b/src/Builder/Encoder/FieldPathEncoder.php @@ -9,7 +9,7 @@ use MongoDB\Codec\EncodeIfSupported; use MongoDB\Exception\UnsupportedValueException; -/** @template-implements Encoder */ +/** @template-implements ExpressionEncoder */ class FieldPathEncoder implements ExpressionEncoder { /** @template-use EncodeIfSupported */ @@ -19,11 +19,6 @@ public function __construct(private readonly BuilderEncoder $encoder) { } - public static function createForEncoder(BuilderEncoder $encoder): static - { - return new self($encoder); - } - /** @psalm-assert-if-true FieldPathInterface $value */ public function canEncode(mixed $value): bool { diff --git a/src/Builder/Encoder/OperatorEncoder.php b/src/Builder/Encoder/OperatorEncoder.php index 54c810a..eeb8e5b 100644 --- a/src/Builder/Encoder/OperatorEncoder.php +++ b/src/Builder/Encoder/OperatorEncoder.php @@ -32,11 +32,6 @@ public function __construct(protected readonly BuilderEncoder $encoder) { } - public static function createForEncoder(BuilderEncoder $encoder): static - { - return new self($encoder); - } - /** @psalm-assert-if-true OperatorInterface $value */ public function canEncode(mixed $value): bool { diff --git a/src/Builder/Encoder/OutputWindowEncoder.php b/src/Builder/Encoder/OutputWindowEncoder.php index 0c13c35..f0d2b1c 100644 --- a/src/Builder/Encoder/OutputWindowEncoder.php +++ b/src/Builder/Encoder/OutputWindowEncoder.php @@ -31,11 +31,6 @@ public function __construct(protected readonly BuilderEncoder $encoder) { } - public static function createForEncoder(BuilderEncoder $encoder): static - { - return new self($encoder); - } - /** @psalm-assert-if-true OutputWindow $value */ public function canEncode(mixed $value): bool { diff --git a/src/Builder/Encoder/PipelineEncoder.php b/src/Builder/Encoder/PipelineEncoder.php index 258e2ee..61b143e 100644 --- a/src/Builder/Encoder/PipelineEncoder.php +++ b/src/Builder/Encoder/PipelineEncoder.php @@ -20,11 +20,6 @@ public function __construct(protected readonly BuilderEncoder $encoder) { } - public static function createForEncoder(BuilderEncoder $encoder): static - { - return new self($encoder); - } - /** @psalm-assert-if-true Pipeline $value */ public function canEncode(mixed $value): bool { diff --git a/src/Builder/Encoder/QueryEncoder.php b/src/Builder/Encoder/QueryEncoder.php index 075f210..3bfedea 100644 --- a/src/Builder/Encoder/QueryEncoder.php +++ b/src/Builder/Encoder/QueryEncoder.php @@ -28,11 +28,6 @@ public function __construct(protected readonly BuilderEncoder $encoder) { } - public static function createForEncoder(BuilderEncoder $encoder): static - { - return new self($encoder); - } - /** @psalm-assert-if-true Variable $value */ public function canEncode(mixed $value): bool { diff --git a/src/Builder/Encoder/VariableEncoder.php b/src/Builder/Encoder/VariableEncoder.php index c470a6c..767a3ed 100644 --- a/src/Builder/Encoder/VariableEncoder.php +++ b/src/Builder/Encoder/VariableEncoder.php @@ -19,11 +19,6 @@ public function __construct(protected readonly BuilderEncoder $encoder) { } - public static function createForEncoder(BuilderEncoder $encoder): static - { - return new self($encoder); - } - /** @psalm-assert-if-true Variable $value */ public function canEncode(mixed $value): bool { From 54d0aabe0d382bae37056d4e26bcdf385f096c6f Mon Sep 17 00:00:00 2001 From: Andreas Braun Date: Tue, 23 Jan 2024 08:59:36 +0100 Subject: [PATCH 03/17] Add missing template annotation --- src/Builder/Encoder/ExpressionEncoder.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/Builder/Encoder/ExpressionEncoder.php b/src/Builder/Encoder/ExpressionEncoder.php index 5e31c71..d9373a2 100644 --- a/src/Builder/Encoder/ExpressionEncoder.php +++ b/src/Builder/Encoder/ExpressionEncoder.php @@ -7,6 +7,11 @@ use MongoDB\Builder\BuilderEncoder; use MongoDB\Codec\Encoder; +/** + * @psalm-template BSONType + * @psalm-template NativeType + * @template-extends Encoder + */ interface ExpressionEncoder extends Encoder { public function __construct(BuilderEncoder $encoder); From 9444978e7571c4553688cefd2344ef6f87478136 Mon Sep 17 00:00:00 2001 From: Andreas Braun Date: Tue, 23 Jan 2024 09:03:53 +0100 Subject: [PATCH 04/17] Add MixedArgument and MixedAssignment to psalm baseline --- psalm-baseline.xml | 67 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 66 insertions(+), 1 deletion(-) diff --git a/psalm-baseline.xml b/psalm-baseline.xml index f4acb63..9fcfc51 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -1,3 +1,68 @@ - + + + + $value + + + + + $filter + $filterValue + $val + $val + $value[$key] + + + + + $result + $result[] + $val + $val + $val + $val + $val + $val + $val + $value[$key] + + + + + $val + $val + $value[$key] + + + + + $encoded[] + + + + + $key + recursiveEncode($value)]]> + + + $key + $subValue + $val + $val + $value + $value[$key] + + + + + $fieldQuery + + + + + $queries[$fieldPath] + $query + + From 8c11b8cd747e4c2ad1cab3ce626bb94ceb7bcadb Mon Sep 17 00:00:00 2001 From: Andreas Braun Date: Tue, 23 Jan 2024 11:41:52 +0100 Subject: [PATCH 05/17] Fix type issues in query classes --- src/Builder/Type/CombinedFieldQuery.php | 23 ++++++++++++++++------- src/Builder/Type/QueryObject.php | 8 +++----- 2 files changed, 19 insertions(+), 12 deletions(-) diff --git a/src/Builder/Type/CombinedFieldQuery.php b/src/Builder/Type/CombinedFieldQuery.php index b85096e..7619d0c 100644 --- a/src/Builder/Type/CombinedFieldQuery.php +++ b/src/Builder/Type/CombinedFieldQuery.php @@ -37,15 +37,24 @@ public function __construct(array $fieldQueries) } // Flatten nested CombinedFieldQuery - $this->fieldQueries = array_reduce($fieldQueries, static function (array $fieldQueries, QueryInterface|FieldQueryInterface|Type|stdClass|array|bool|float|int|string|null $fieldQuery): array { - if ($fieldQuery instanceof CombinedFieldQuery) { - return array_merge($fieldQueries, $fieldQuery->fieldQueries); - } + $this->fieldQueries = array_reduce( + $fieldQueries, + /** + * @param list $fieldQueries + * + * @return list + */ + static function (array $fieldQueries, QueryInterface|FieldQueryInterface|Type|stdClass|array|bool|float|int|string|null $fieldQuery): array { + if ($fieldQuery instanceof CombinedFieldQuery) { + return array_merge($fieldQueries, $fieldQuery->fieldQueries); + } - $fieldQueries[] = $fieldQuery; + $fieldQueries[] = $fieldQuery; - return $fieldQueries; - }, []); + return $fieldQueries; + }, + [], + ); // Validate FieldQuery types and non-duplicate operators $seenOperators = []; diff --git a/src/Builder/Type/QueryObject.php b/src/Builder/Type/QueryObject.php index 293350b..e07877f 100644 --- a/src/Builder/Type/QueryObject.php +++ b/src/Builder/Type/QueryObject.php @@ -4,9 +4,7 @@ namespace MongoDB\Builder\Type; -use MongoDB\BSON\Decimal128; -use MongoDB\BSON\Int64; -use MongoDB\BSON\Regex; +use MongoDB\BSON\Type; use MongoDB\Exception\InvalidArgumentException; use stdClass; @@ -27,7 +25,7 @@ final class QueryObject implements QueryInterface { public readonly array $queries; - /** @param array $queries */ + /** @param array $queries */ public static function create(array $queries): QueryInterface { // We don't wrap a single query in a QueryObject @@ -38,7 +36,7 @@ public static function create(array $queries): QueryInterface return new self($queries); } - /** @param array $queriesOrArrayOfQueries */ + /** @param array $queriesOrArrayOfQueries */ private function __construct(array $queriesOrArrayOfQueries) { // If the first element is an array and not an operator, we assume variadic arguments were not used From 9192f90de896b62ed979ee81bde1203d8b650ef1 Mon Sep 17 00:00:00 2001 From: Andreas Braun Date: Tue, 23 Jan 2024 11:42:07 +0100 Subject: [PATCH 06/17] Extract common builder code and fix template annotations --- src/Builder/BuilderEncoder.php | 14 ++--- .../Encoder/AbstractExpressionEncoder.php | 53 +++++++++++++++++++ .../Encoder/CombinedFieldQueryEncoder.php | 41 ++------------ src/Builder/Encoder/ExpressionEncoder.php | 5 +- src/Builder/Encoder/FieldPathEncoder.php | 17 ++---- src/Builder/Encoder/OperatorEncoder.php | 43 ++------------- src/Builder/Encoder/OutputWindowEncoder.php | 47 +++------------- src/Builder/Encoder/PipelineEncoder.php | 18 ++----- src/Builder/Encoder/QueryEncoder.php | 45 ++-------------- src/Builder/Encoder/VariableEncoder.php | 17 ++---- 10 files changed, 94 insertions(+), 206 deletions(-) create mode 100644 src/Builder/Encoder/AbstractExpressionEncoder.php diff --git a/src/Builder/BuilderEncoder.php b/src/Builder/BuilderEncoder.php index 91f0f0b..a1927dd 100644 --- a/src/Builder/BuilderEncoder.php +++ b/src/Builder/BuilderEncoder.php @@ -28,9 +28,10 @@ use function is_object; -/** @template-implements Encoder */ +/** @template-implements Encoder */ class BuilderEncoder implements Encoder { + /** @template-use EncodeIfSupported */ use EncodeIfSupported; /** @var array> */ @@ -52,10 +53,8 @@ public function __construct(private readonly array $customEncoders = []) { } - /** - * {@inheritdoc} - */ - public function canEncode($value): bool + /** @psalm-assert-if-true object $value */ + public function canEncode(mixed $value): bool { if (! is_object($value)) { return false; @@ -66,10 +65,7 @@ public function canEncode($value): bool return $encoder !== null && $encoder->canEncode($value); } - /** - * {@inheritdoc} - */ - public function encode($value): stdClass|array|string + public function encode(mixed $value): stdClass|array|string { $encoder = $this->getEncoderFor($value); diff --git a/src/Builder/Encoder/AbstractExpressionEncoder.php b/src/Builder/Encoder/AbstractExpressionEncoder.php new file mode 100644 index 0000000..0df55de --- /dev/null +++ b/src/Builder/Encoder/AbstractExpressionEncoder.php @@ -0,0 +1,53 @@ + + */ +abstract class AbstractExpressionEncoder implements ExpressionEncoder +{ + final public function __construct(protected readonly BuilderEncoder $encoder) + { + } + + /** + * Nested arrays and objects must be encoded recursively. + * + * @psalm-param T $value + * + * @psalm-return (T is object ? object : (T is array ? array : mixed)) + * + * @template T + */ + final protected function recursiveEncode(mixed $value): mixed + { + if (is_array($value)) { + foreach ($value as $key => $val) { + $value[$key] = $this->recursiveEncode($val); + } + + return $value; + } + + if ($value instanceof stdClass) { + foreach (get_object_vars($value) as $key => $val) { + $value->{$key} = $this->recursiveEncode($val); + } + + return $value; + } + + return $this->encoder->encodeIfSupported($value); + } +} diff --git a/src/Builder/Encoder/CombinedFieldQueryEncoder.php b/src/Builder/Encoder/CombinedFieldQueryEncoder.php index 7f6e539..4ad4239 100644 --- a/src/Builder/Encoder/CombinedFieldQueryEncoder.php +++ b/src/Builder/Encoder/CombinedFieldQueryEncoder.php @@ -5,7 +5,6 @@ namespace MongoDB\Builder\Encoder; use LogicException; -use MongoDB\Builder\BuilderEncoder; use MongoDB\Builder\Type\CombinedFieldQuery; use MongoDB\Codec\EncodeIfSupported; use MongoDB\Exception\UnsupportedValueException; @@ -17,26 +16,18 @@ use function is_object; use function sprintf; -/** @template-implements ExpressionEncoder */ -class CombinedFieldQueryEncoder implements ExpressionEncoder +/** @template-extends AbstractExpressionEncoder */ +class CombinedFieldQueryEncoder extends AbstractExpressionEncoder { - /** @template-use EncodeIfSupported */ + /** @template-use EncodeIfSupported */ use EncodeIfSupported; - public function __construct(private readonly BuilderEncoder $encoder) - { - } - - /** @psalm-assert-if-true CombinedFieldQuery $value */ public function canEncode(mixed $value): bool { return $value instanceof CombinedFieldQuery; } - /** - * {@inheritdoc} - */ - public function encode($value): stdClass + public function encode(mixed $value): stdClass { if (! $this->canEncode($value)) { throw UnsupportedValueException::invalidEncodableValue($value); @@ -58,28 +49,4 @@ public function encode($value): stdClass return $result; } - - /** - * Nested arrays and objects must be encoded recursively. - */ - private function recursiveEncode(mixed $value): mixed - { - if (is_array($value)) { - foreach ($value as $key => $val) { - $value[$key] = $this->recursiveEncode($val); - } - - return $value; - } - - if ($value instanceof stdClass) { - foreach (get_object_vars($value) as $key => $val) { - $value->{$key} = $this->recursiveEncode($val); - } - - return $value; - } - - return $this->encoder->encodeIfSupported($value); - } } diff --git a/src/Builder/Encoder/ExpressionEncoder.php b/src/Builder/Encoder/ExpressionEncoder.php index d9373a2..6613c45 100644 --- a/src/Builder/Encoder/ExpressionEncoder.php +++ b/src/Builder/Encoder/ExpressionEncoder.php @@ -6,10 +6,11 @@ use MongoDB\Builder\BuilderEncoder; use MongoDB\Codec\Encoder; +use stdClass; /** - * @psalm-template BSONType - * @psalm-template NativeType + * @template BSONType of stdClass|array|string + * @template NativeType * @template-extends Encoder */ interface ExpressionEncoder extends Encoder diff --git a/src/Builder/Encoder/FieldPathEncoder.php b/src/Builder/Encoder/FieldPathEncoder.php index f99bfdf..3ec33bd 100644 --- a/src/Builder/Encoder/FieldPathEncoder.php +++ b/src/Builder/Encoder/FieldPathEncoder.php @@ -4,31 +4,22 @@ namespace MongoDB\Builder\Encoder; -use MongoDB\Builder\BuilderEncoder; use MongoDB\Builder\Type\FieldPathInterface; use MongoDB\Codec\EncodeIfSupported; use MongoDB\Exception\UnsupportedValueException; -/** @template-implements ExpressionEncoder */ -class FieldPathEncoder implements ExpressionEncoder +/** @template-extends AbstractExpressionEncoder */ +class FieldPathEncoder extends AbstractExpressionEncoder { - /** @template-use EncodeIfSupported */ + /** @template-use EncodeIfSupported */ use EncodeIfSupported; - public function __construct(private readonly BuilderEncoder $encoder) - { - } - - /** @psalm-assert-if-true FieldPathInterface $value */ public function canEncode(mixed $value): bool { return $value instanceof FieldPathInterface; } - /** - * {@inheritdoc} - */ - public function encode($value): string + public function encode(mixed $value): string { if (! $this->canEncode($value)) { throw UnsupportedValueException::invalidEncodableValue($value); diff --git a/src/Builder/Encoder/OperatorEncoder.php b/src/Builder/Encoder/OperatorEncoder.php index eeb8e5b..029d967 100644 --- a/src/Builder/Encoder/OperatorEncoder.php +++ b/src/Builder/Encoder/OperatorEncoder.php @@ -5,7 +5,6 @@ namespace MongoDB\Builder\Encoder; use LogicException; -use MongoDB\Builder\BuilderEncoder; use MongoDB\Builder\Stage\GroupStage; use MongoDB\Builder\Type\Encode; use MongoDB\Builder\Type\OperatorInterface; @@ -22,26 +21,18 @@ use function property_exists; use function sprintf; -/** @template-implements ExpressionEncoder */ -class OperatorEncoder implements ExpressionEncoder +/** @template-extends AbstractExpressionEncoder */ +class OperatorEncoder extends AbstractExpressionEncoder { - /** @template-use EncodeIfSupported */ + /** @template-use EncodeIfSupported */ use EncodeIfSupported; - public function __construct(protected readonly BuilderEncoder $encoder) - { - } - - /** @psalm-assert-if-true OperatorInterface $value */ public function canEncode(mixed $value): bool { return $value instanceof OperatorInterface; } - /** - * {@inheritdoc} - */ - public function encode($value): stdClass|array|string + public function encode(mixed $value): stdClass { if (! $this->canEncode($value)) { throw UnsupportedValueException::invalidEncodableValue($value); @@ -134,7 +125,7 @@ private function encodeAsDollarObject(OperatorInterface $value): stdClass if (is_object($val) && property_exists($val, '$geometry')) { $result->{'$geometry'} = $val->{'$geometry'}; } elseif (is_array($val) && array_key_exists('$geometry', $val)) { - $result->{'$geometry'} = $val->{'$geometry'}; + $result->{'$geometry'} = $val['$geometry']; } else { $result->{'$geometry'} = $val; } @@ -160,30 +151,6 @@ private function encodeAsSingle(OperatorInterface $value): stdClass throw new LogicException(sprintf('Class "%s" does not have a single property.', $value::class)); } - /** - * Nested arrays and objects must be encoded recursively. - */ - private function recursiveEncode(mixed $value): mixed - { - if (is_array($value)) { - foreach ($value as $key => $val) { - $value[$key] = $this->recursiveEncode($val); - } - - return $value; - } - - if ($value instanceof stdClass) { - foreach (get_object_vars($value) as $key => $val) { - $value->{$key} = $this->recursiveEncode($val); - } - - return $value; - } - - return $this->encoder->encodeIfSupported($value); - } - private function wrap(OperatorInterface $value, mixed $result): stdClass { $object = new stdClass(); diff --git a/src/Builder/Encoder/OutputWindowEncoder.php b/src/Builder/Encoder/OutputWindowEncoder.php index f0d2b1c..7743c6c 100644 --- a/src/Builder/Encoder/OutputWindowEncoder.php +++ b/src/Builder/Encoder/OutputWindowEncoder.php @@ -5,7 +5,6 @@ namespace MongoDB\Builder\Encoder; use LogicException; -use MongoDB\Builder\BuilderEncoder; use MongoDB\Builder\Type\Optional; use MongoDB\Builder\Type\OutputWindow; use MongoDB\Builder\Type\WindowInterface; @@ -15,32 +14,23 @@ use function array_key_first; use function get_debug_type; -use function get_object_vars; use function is_array; use function is_object; use function MongoDB\is_first_key_operator; use function sprintf; -/** @template-implements ExpressionEncoder */ -class OutputWindowEncoder implements ExpressionEncoder +/** @template-extends AbstractExpressionEncoder */ +class OutputWindowEncoder extends AbstractExpressionEncoder { - /** @template-use EncodeIfSupported */ + /** @template-use EncodeIfSupported */ use EncodeIfSupported; - public function __construct(protected readonly BuilderEncoder $encoder) - { - } - - /** @psalm-assert-if-true OutputWindow $value */ public function canEncode(mixed $value): bool { return $value instanceof OutputWindow; } - /** - * {@inheritdoc} - */ - public function encode($value): stdClass + public function encode(mixed $value): stdClass { if (! $this->canEncode($value)) { throw UnsupportedValueException::invalidEncodableValue($value); @@ -49,9 +39,10 @@ public function encode($value): stdClass $result = $this->recursiveEncode($value->operator); // Transform the result into an stdClass if a document is provided - if (! $value->operator instanceof WindowInterface && (is_array($result) || is_object($result))) { + if (! $value->operator instanceof WindowInterface) { if (! is_first_key_operator($result)) { - throw new LogicException(sprintf('Expected OutputWindow::$operator to be an operator. Got "%s"', array_key_first((array) $result))); + $firstKey = array_key_first((array) $result); + throw new LogicException(sprintf('Expected OutputWindow::$operator to be an operator. Got "%s"', $firstKey ?? 'null')); } $result = (object) $result; @@ -67,28 +58,4 @@ public function encode($value): stdClass return $result; } - - /** - * Nested arrays and objects must be encoded recursively. - */ - private function recursiveEncode(mixed $value): mixed - { - if (is_array($value)) { - foreach ($value as $key => $val) { - $value[$key] = $this->recursiveEncode($val); - } - - return $value; - } - - if ($value instanceof stdClass) { - foreach (get_object_vars($value) as $key => $val) { - $value->{$key} = $this->recursiveEncode($val); - } - - return $value; - } - - return $this->encoder->encodeIfSupported($value); - } } diff --git a/src/Builder/Encoder/PipelineEncoder.php b/src/Builder/Encoder/PipelineEncoder.php index 61b143e..eeaa472 100644 --- a/src/Builder/Encoder/PipelineEncoder.php +++ b/src/Builder/Encoder/PipelineEncoder.php @@ -4,32 +4,23 @@ namespace MongoDB\Builder\Encoder; -use MongoDB\Builder\BuilderEncoder; use MongoDB\Builder\Pipeline; use MongoDB\Codec\EncodeIfSupported; use MongoDB\Exception\UnsupportedValueException; use stdClass; -/** @template-implements ExpressionEncoder */ -class PipelineEncoder implements ExpressionEncoder +/** @template-extends AbstractExpressionEncoder */ +class PipelineEncoder extends AbstractExpressionEncoder { - /** @template-use EncodeIfSupported */ + /** @template-use EncodeIfSupported */ use EncodeIfSupported; - public function __construct(protected readonly BuilderEncoder $encoder) - { - } - - /** @psalm-assert-if-true Pipeline $value */ public function canEncode(mixed $value): bool { return $value instanceof Pipeline; } - /** - * {@inheritdoc} - */ - public function encode($value): stdClass|array|string + public function encode(mixed $value): stdClass|array|string { if (! $this->canEncode($value)) { throw UnsupportedValueException::invalidEncodableValue($value); @@ -37,7 +28,6 @@ public function encode($value): stdClass|array|string $encoded = []; foreach ($value->getIterator() as $stage) { - // Todo: Needs StageEncoder $encoded[] = $this->encoder->encodeIfSupported($stage); } diff --git a/src/Builder/Encoder/QueryEncoder.php b/src/Builder/Encoder/QueryEncoder.php index 3bfedea..d589050 100644 --- a/src/Builder/Encoder/QueryEncoder.php +++ b/src/Builder/Encoder/QueryEncoder.php @@ -5,8 +5,6 @@ namespace MongoDB\Builder\Encoder; use LogicException; -use MongoDB\Builder\BuilderEncoder; -use MongoDB\Builder\Expression\Variable; use MongoDB\Builder\Type\QueryInterface; use MongoDB\Builder\Type\QueryObject; use MongoDB\Codec\EncodeIfSupported; @@ -14,30 +12,21 @@ use stdClass; use function get_object_vars; -use function is_array; use function property_exists; use function sprintf; -/** @template-implements ExpressionEncoder */ -class QueryEncoder implements ExpressionEncoder +/** @template-extends AbstractExpressionEncoder */ +class QueryEncoder extends AbstractExpressionEncoder { - /** @template-use EncodeIfSupported */ + /** @template-use EncodeIfSupported */ use EncodeIfSupported; - public function __construct(protected readonly BuilderEncoder $encoder) - { - } - - /** @psalm-assert-if-true Variable $value */ public function canEncode(mixed $value): bool { return $value instanceof QueryObject; } - /** - * {@inheritdoc} - */ - public function encode($value): stdClass + public function encode(mixed $value): stdClass { if (! $this->canEncode($value)) { throw UnsupportedValueException::invalidEncodableValue($value); @@ -48,7 +37,7 @@ public function encode($value): stdClass if ($value instanceof QueryInterface) { // The sub-objects is merged into the main object, replacing duplicate keys foreach (get_object_vars($this->recursiveEncode($value)) as $subKey => $subValue) { - if (property_exists($result, (string) $subKey)) { + if (property_exists($result, $subKey)) { throw new LogicException(sprintf('Duplicate key "%s" in query object', $subKey)); } @@ -65,28 +54,4 @@ public function encode($value): stdClass return $result; } - - /** - * Nested arrays and objects must be encoded recursively. - */ - private function recursiveEncode(mixed $value): mixed - { - if (is_array($value)) { - foreach ($value as $key => $val) { - $value[$key] = $this->recursiveEncode($val); - } - - return $value; - } - - if ($value instanceof stdClass) { - foreach (get_object_vars($value) as $key => $val) { - $value->{$key} = $this->recursiveEncode($val); - } - - return $value; - } - - return $this->encoder->encodeIfSupported($value); - } } diff --git a/src/Builder/Encoder/VariableEncoder.php b/src/Builder/Encoder/VariableEncoder.php index 767a3ed..c8b81bc 100644 --- a/src/Builder/Encoder/VariableEncoder.php +++ b/src/Builder/Encoder/VariableEncoder.php @@ -4,31 +4,22 @@ namespace MongoDB\Builder\Encoder; -use MongoDB\Builder\BuilderEncoder; use MongoDB\Builder\Expression\Variable; use MongoDB\Codec\EncodeIfSupported; use MongoDB\Exception\UnsupportedValueException; -/** @template-implements ExpressionEncoder */ -class VariableEncoder implements ExpressionEncoder +/** @template-extends AbstractExpressionEncoder */ +class VariableEncoder extends AbstractExpressionEncoder { - /** @template-use EncodeIfSupported */ + /** @template-use EncodeIfSupported */ use EncodeIfSupported; - public function __construct(protected readonly BuilderEncoder $encoder) - { - } - - /** @psalm-assert-if-true Variable $value */ public function canEncode(mixed $value): bool { return $value instanceof Variable; } - /** - * {@inheritdoc} - */ - public function encode($value): string + public function encode(mixed $value): string { if (! $this->canEncode($value)) { throw UnsupportedValueException::invalidEncodableValue($value); From 6297c92abb9dd6abbfe38e8314eb9341bb8e35b0 Mon Sep 17 00:00:00 2001 From: Andreas Braun Date: Tue, 23 Jan 2024 11:42:16 +0100 Subject: [PATCH 07/17] Update psalm baseline --- psalm-baseline.xml | 129 +++++++++++++++++++++++++++++++++------------ 1 file changed, 95 insertions(+), 34 deletions(-) diff --git a/psalm-baseline.xml b/psalm-baseline.xml index 9fcfc51..4052eef 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -1,19 +1,23 @@ - - - $value - + + + $val + $val + $value[$key] + $filter $filterValue - $val - $val - $value[$key] + + + name]]> + + $result @@ -23,46 +27,103 @@ $val $val $val - $val - $val - $value[$key] - - - - - $val - $val - $value[$key] - - - - - $encoded[] + + $value::ENCODE + - - $key - recursiveEncode($value)]]> - - $key $subValue - $val - $val $value - $value[$key] - - - $fieldQuery - + + + $query + + + + + $query + + + + + $query + + + + + $expression + + + stdClass + + + + + $facet + + + stdClass + + + + + $query + + + + + $restrictSearchWithMatch + + + + + $field + + + stdClass + + + + + $query + + + + + $specification + + + stdClass + + + + + $field + + + stdClass + + + + + + + $queries[$fieldPath] $query + + 0]]> + From 81cb39728c9eeadbcd556cfb8be1480cab0b4900 Mon Sep 17 00:00:00 2001 From: Andreas Braun Date: Thu, 25 Jan 2024 10:25:58 +0100 Subject: [PATCH 08/17] Define ENCODE constant in OperatorInterface --- psalm-baseline.xml | 3 --- src/Builder/Type/OperatorInterface.php | 3 +++ 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/psalm-baseline.xml b/psalm-baseline.xml index 4052eef..27bee5d 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -28,9 +28,6 @@ $val $val - - $value::ENCODE - diff --git a/src/Builder/Type/OperatorInterface.php b/src/Builder/Type/OperatorInterface.php index 6622dbf..f9917cd 100644 --- a/src/Builder/Type/OperatorInterface.php +++ b/src/Builder/Type/OperatorInterface.php @@ -9,5 +9,8 @@ */ interface OperatorInterface { + /** To be overridden by implementing classes */ + public const ENCODE = Encode::Single; + public function getOperator(): string; } From a913b72f5c5d575486e66d5574b94d5ca9471574 Mon Sep 17 00:00:00 2001 From: Andreas Braun Date: Thu, 25 Jan 2024 10:27:02 +0100 Subject: [PATCH 09/17] Simplify canEncode check in BuilderEncoder --- src/Builder/BuilderEncoder.php | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/Builder/BuilderEncoder.php b/src/Builder/BuilderEncoder.php index a1927dd..848f225 100644 --- a/src/Builder/BuilderEncoder.php +++ b/src/Builder/BuilderEncoder.php @@ -60,16 +60,14 @@ public function canEncode(mixed $value): bool return false; } - $encoder = $this->getEncoderFor($value); - - return $encoder !== null && $encoder->canEncode($value); + return (bool) $this->getEncoderFor($value)?->canEncode($value); } public function encode(mixed $value): stdClass|array|string { $encoder = $this->getEncoderFor($value); - if (! $encoder || ! $encoder->canEncode($value)) { + if (! $encoder?->canEncode($value)) { throw UnsupportedValueException::invalidEncodableValue($value); } From 5ce111a9d844548c8cf972fe4a7502f239352c4d Mon Sep 17 00:00:00 2001 From: Andreas Braun Date: Thu, 25 Jan 2024 10:27:55 +0100 Subject: [PATCH 10/17] Use strict comparison --- phpcs.xml.dist | 1 - src/Builder/BuilderEncoder.php | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/phpcs.xml.dist b/phpcs.xml.dist index 74f7c0b..872605b 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -42,7 +42,6 @@ - diff --git a/src/Builder/BuilderEncoder.php b/src/Builder/BuilderEncoder.php index 848f225..871b29c 100644 --- a/src/Builder/BuilderEncoder.php +++ b/src/Builder/BuilderEncoder.php @@ -85,7 +85,7 @@ private function getEncoderFor(object $value): ExpressionEncoder|null // First attempt: match class name exactly foreach ($encoderList as $className => $encoderClass) { - if ($className == $valueClass) { + if ($className === $valueClass) { return $this->cachedEncoders[$valueClass] = new $encoderClass($this); } } From 254cc4a6405292988c277373fd5d1ae58fa785ab Mon Sep 17 00:00:00 2001 From: Andreas Braun Date: Thu, 25 Jan 2024 10:29:03 +0100 Subject: [PATCH 11/17] Fix checkstyle errors --- src/Builder/Encoder/OutputWindowEncoder.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Builder/Encoder/OutputWindowEncoder.php b/src/Builder/Encoder/OutputWindowEncoder.php index 7743c6c..9db5673 100644 --- a/src/Builder/Encoder/OutputWindowEncoder.php +++ b/src/Builder/Encoder/OutputWindowEncoder.php @@ -14,8 +14,6 @@ use function array_key_first; use function get_debug_type; -use function is_array; -use function is_object; use function MongoDB\is_first_key_operator; use function sprintf; @@ -42,6 +40,7 @@ public function encode(mixed $value): stdClass if (! $value->operator instanceof WindowInterface) { if (! is_first_key_operator($result)) { $firstKey = array_key_first((array) $result); + throw new LogicException(sprintf('Expected OutputWindow::$operator to be an operator. Got "%s"', $firstKey ?? 'null')); } From 5795818c00e0d9f7cb4d2456c6a996d6881d7088 Mon Sep 17 00:00:00 2001 From: Andreas Braun Date: Thu, 25 Jan 2024 10:31:32 +0100 Subject: [PATCH 12/17] Fix wrong object type check --- psalm-baseline.xml | 8 ++++++++ src/Builder/Encoder/AbstractExpressionEncoder.php | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/psalm-baseline.xml b/psalm-baseline.xml index 27bee5d..a8793bf 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -29,7 +29,15 @@ $val + + + $result + + + + recursiveEncode($value)]]> + $subValue $value diff --git a/src/Builder/Encoder/AbstractExpressionEncoder.php b/src/Builder/Encoder/AbstractExpressionEncoder.php index 0df55de..8bbdbae 100644 --- a/src/Builder/Encoder/AbstractExpressionEncoder.php +++ b/src/Builder/Encoder/AbstractExpressionEncoder.php @@ -26,7 +26,7 @@ final public function __construct(protected readonly BuilderEncoder $encoder) * * @psalm-param T $value * - * @psalm-return (T is object ? object : (T is array ? array : mixed)) + * @psalm-return (T is stdClass ? stdClass : (T is array ? array : mixed)) * * @template T */ From ba2ac3c273e4639aed2d4ffd022d23e60869ac13 Mon Sep 17 00:00:00 2001 From: Andreas Braun Date: Thu, 25 Jan 2024 10:32:09 +0100 Subject: [PATCH 13/17] Update todo for properties in interfaces --- src/Builder/Encoder/FieldPathEncoder.php | 2 +- src/Builder/Encoder/VariableEncoder.php | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Builder/Encoder/FieldPathEncoder.php b/src/Builder/Encoder/FieldPathEncoder.php index 3ec33bd..8fe7381 100644 --- a/src/Builder/Encoder/FieldPathEncoder.php +++ b/src/Builder/Encoder/FieldPathEncoder.php @@ -25,7 +25,7 @@ public function encode(mixed $value): string throw UnsupportedValueException::invalidEncodableValue($value); } - // TODO: needs method because of interface + // TODO: needs method because interfaces can't have properties return '$' . $value->name; } } diff --git a/src/Builder/Encoder/VariableEncoder.php b/src/Builder/Encoder/VariableEncoder.php index c8b81bc..726acb9 100644 --- a/src/Builder/Encoder/VariableEncoder.php +++ b/src/Builder/Encoder/VariableEncoder.php @@ -25,6 +25,7 @@ public function encode(mixed $value): string throw UnsupportedValueException::invalidEncodableValue($value); } + // TODO: needs method because interfaces can't have properties return '$$' . $value->name; } } From a9b02b2a5f3064a17fd0c2235bc877695771a75f Mon Sep 17 00:00:00 2001 From: Andreas Braun Date: Thu, 25 Jan 2024 10:34:40 +0100 Subject: [PATCH 14/17] Fix return type of PipelineEncoder::encode --- src/Builder/Encoder/PipelineEncoder.php | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/Builder/Encoder/PipelineEncoder.php b/src/Builder/Encoder/PipelineEncoder.php index eeaa472..f0b319e 100644 --- a/src/Builder/Encoder/PipelineEncoder.php +++ b/src/Builder/Encoder/PipelineEncoder.php @@ -7,20 +7,21 @@ use MongoDB\Builder\Pipeline; use MongoDB\Codec\EncodeIfSupported; use MongoDB\Exception\UnsupportedValueException; -use stdClass; -/** @template-extends AbstractExpressionEncoder */ +/** @template-extends AbstractExpressionEncoder, Pipeline> */ class PipelineEncoder extends AbstractExpressionEncoder { - /** @template-use EncodeIfSupported */ + /** @template-use EncodeIfSupported, Pipeline> */ use EncodeIfSupported; + /** @psalm-assert-if-true Pipeline $value */ public function canEncode(mixed $value): bool { return $value instanceof Pipeline; } - public function encode(mixed $value): stdClass|array|string + /** @return list */ + public function encode(mixed $value): array { if (! $this->canEncode($value)) { throw UnsupportedValueException::invalidEncodableValue($value); From 24ae4d3964c910c39b48403e47e6f4531bd291c1 Mon Sep 17 00:00:00 2001 From: Andreas Braun Date: Thu, 25 Jan 2024 10:49:41 +0100 Subject: [PATCH 15/17] Update comment in OperatorEncoder Co-authored-by: Jeremy Mikola --- src/Builder/Encoder/OperatorEncoder.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Builder/Encoder/OperatorEncoder.php b/src/Builder/Encoder/OperatorEncoder.php index 029d967..4561f22 100644 --- a/src/Builder/Encoder/OperatorEncoder.php +++ b/src/Builder/Encoder/OperatorEncoder.php @@ -68,8 +68,8 @@ private function encodeAsArray(OperatorInterface $value): stdClass $result = []; /** @var mixed $val */ foreach (get_object_vars($value) as $val) { - // Skip optional arguments. - // $slice operator has the optional argument in the middle of the array + // Skip optional arguments. For example, the $slice expression operator has an optional argument + // in the middle of the array. if ($val === Optional::Undefined) { continue; } From ab93a1e8f70b0e7b11ce476b1b4aa094e2d75bbc Mon Sep 17 00:00:00 2001 From: Andreas Braun Date: Fri, 26 Jan 2024 11:01:51 +0100 Subject: [PATCH 16/17] Optimise cached encoders structure --- src/Builder/BuilderEncoder.php | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/Builder/BuilderEncoder.php b/src/Builder/BuilderEncoder.php index 871b29c..8cc9699 100644 --- a/src/Builder/BuilderEncoder.php +++ b/src/Builder/BuilderEncoder.php @@ -26,6 +26,7 @@ use MongoDB\Exception\UnsupportedValueException; use stdClass; +use function array_key_exists; use function is_object; /** @template-implements Encoder */ @@ -45,7 +46,7 @@ class BuilderEncoder implements Encoder OperatorInterface::class => OperatorEncoder::class, ]; - /** @var array */ + /** @var array */ private array $cachedEncoders = []; /** @param array> $customEncoders */ @@ -77,17 +78,15 @@ public function encode(mixed $value): stdClass|array|string private function getEncoderFor(object $value): ExpressionEncoder|null { $valueClass = $value::class; - if (isset($this->cachedEncoders[$valueClass])) { + if (array_key_exists($valueClass, $this->cachedEncoders)) { return $this->cachedEncoders[$valueClass]; } $encoderList = $this->customEncoders + $this->defaultEncoders; // First attempt: match class name exactly - foreach ($encoderList as $className => $encoderClass) { - if ($className === $valueClass) { - return $this->cachedEncoders[$valueClass] = new $encoderClass($this); - } + if (isset($encoderList[$valueClass])) { + return $this->cachedEncoders[$valueClass] = new $encoderList[$valueClass]($this); } // Second attempt: catch child classes @@ -97,6 +96,6 @@ private function getEncoderFor(object $value): ExpressionEncoder|null } } - return null; + return $this->cachedEncoders[$valueClass] = null; } } From c49a25637de2e015458215006b27a2a118f0679c Mon Sep 17 00:00:00 2001 From: Andreas Braun Date: Mon, 29 Jan 2024 08:49:14 +0100 Subject: [PATCH 17/17] Add undefined case for Encode enum --- src/Builder/Type/Encode.php | 5 +++++ src/Builder/Type/OperatorInterface.php | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/Builder/Type/Encode.php b/src/Builder/Type/Encode.php index d5ec603..c42b158 100644 --- a/src/Builder/Type/Encode.php +++ b/src/Builder/Type/Encode.php @@ -37,4 +37,9 @@ enum Encode * Specific for $group stage */ case Group; + + /** + * Default case used in the interface; implementing classes are expected to override this value + */ + case Undefined; } diff --git a/src/Builder/Type/OperatorInterface.php b/src/Builder/Type/OperatorInterface.php index f9917cd..f219fb5 100644 --- a/src/Builder/Type/OperatorInterface.php +++ b/src/Builder/Type/OperatorInterface.php @@ -10,7 +10,7 @@ interface OperatorInterface { /** To be overridden by implementing classes */ - public const ENCODE = Encode::Single; + public const ENCODE = Encode::Undefined; public function getOperator(): string; }