diff --git a/src/Eloquent/Model.php b/src/Eloquent/Model.php index 05a20bb31..30497ad86 100644 --- a/src/Eloquent/Model.php +++ b/src/Eloquent/Model.php @@ -169,6 +169,8 @@ public function getAttribute($key) return null; } + $key = (string) $key; + // An unset attribute is null or throw an exception. if (isset($this->unset[$key])) { return $this->throwMissingAttributeExceptionIfApplicable($key); @@ -194,6 +196,8 @@ public function getAttribute($key) /** @inheritdoc */ protected function getAttributeFromArray($key) { + $key = (string) $key; + // Support keys in dot notation. if (str_contains($key, '.')) { return Arr::get($this->attributes, $key); @@ -205,6 +209,8 @@ protected function getAttributeFromArray($key) /** @inheritdoc */ public function setAttribute($key, $value) { + $key = (string) $key; + // Convert _id to ObjectID. if ($key === '_id' && is_string($value)) { $builder = $this->newBaseQueryBuilder(); @@ -314,6 +320,8 @@ public function originalIsEquivalent($key) /** @inheritdoc */ public function offsetUnset($offset): void { + $offset = (string) $offset; + if (str_contains($offset, '.')) { // Update the field in the subdocument Arr::forget($this->attributes, $offset); diff --git a/src/Query/Builder.php b/src/Query/Builder.php index 82ba9d09a..cd2326dce 100644 --- a/src/Query/Builder.php +++ b/src/Query/Builder.php @@ -23,13 +23,12 @@ use MongoDB\BSON\UTCDateTime; use MongoDB\Driver\Cursor; use RuntimeException; -use Stringable; use function array_fill_keys; use function array_is_list; use function array_key_exists; +use function array_map; use function array_merge; -use function array_merge_recursive; use function array_values; use function array_walk_recursive; use function assert; @@ -46,7 +45,11 @@ use function implode; use function in_array; use function is_array; +use function is_bool; +use function is_callable; +use function is_float; use function is_int; +use function is_null; use function is_string; use function md5; use function preg_match; @@ -60,6 +63,7 @@ use function strlen; use function strtolower; use function substr; +use function var_export; class Builder extends BaseBuilder { @@ -665,7 +669,7 @@ public function update(array $values, array $options = []) { // Use $set as default operator for field names that are not in an operator foreach ($values as $key => $value) { - if (str_starts_with($key, '$')) { + if (is_string($key) && str_starts_with($key, '$')) { continue; } @@ -952,7 +956,20 @@ public function convertKey($id) return $id; } - /** @inheritdoc */ + /** + * Add a basic where clause to the query. + * + * If 1 argument, the signature is: where(array|Closure $where) + * If 2 arguments, the signature is: where(string $column, mixed $value) + * If 3 arguments, the signature is: where(string $colum, string $operator, mixed $value) + * + * @param Closure|string|array $column + * @param mixed $operator + * @param mixed $value + * @param string $boolean + * + * @return $this + */ public function where($column, $operator = null, $value = null, $boolean = 'and') { $params = func_get_args(); @@ -966,8 +983,12 @@ public function where($column, $operator = null, $value = null, $boolean = 'and' } } - if (func_num_args() === 1 && is_string($column)) { - throw new ArgumentCountError(sprintf('Too few arguments to function %s("%s"), 1 passed and at least 2 expected when the 1st is a string.', __METHOD__, $column)); + if (func_num_args() === 1 && ! is_array($column) && ! is_callable($column)) { + throw new ArgumentCountError(sprintf('Too few arguments to function %s(%s), 1 passed and at least 2 expected when the 1st is not an array or a callable', __METHOD__, var_export($column, true))); + } + + if (is_float($column) || is_bool($column) || is_null($column)) { + throw new InvalidArgumentException(sprintf('First argument of %s must be a field path as "string". Got "%s"', __METHOD__, get_debug_type($column))); } return parent::where(...$params); @@ -998,7 +1019,7 @@ protected function compileWheres(): array } // Convert column name to string to use as array key - if (isset($where['column']) && $where['column'] instanceof Stringable) { + if (isset($where['column'])) { $where['column'] = (string) $where['column']; } @@ -1006,9 +1027,7 @@ protected function compileWheres(): array if (isset($where['column']) && ($where['column'] === '_id' || str_ends_with($where['column'], '._id'))) { if (isset($where['values'])) { // Multiple values. - foreach ($where['values'] as &$value) { - $value = $this->convertKey($value); - } + $where['values'] = array_map($this->convertKey(...), $where['values']); } elseif (isset($where['value'])) { // Single value. $where['value'] = $this->convertKey($where['value']); @@ -1076,7 +1095,14 @@ protected function compileWheres(): array } // Merge the compiled where with the others. - $compiled = array_merge_recursive($compiled, $result); + // array_merge_recursive can't be used here because it converts int keys to sequential int. + foreach ($result as $key => $value) { + if (in_array($key, ['$and', '$or', '$nor'])) { + $compiled[$key] = array_merge($compiled[$key] ?? [], $value); + } else { + $compiled[$key] = $value; + } + } } return $compiled; diff --git a/tests/ModelTest.php b/tests/ModelTest.php index afa95c203..ef25ebaef 100644 --- a/tests/ModelTest.php +++ b/tests/ModelTest.php @@ -971,4 +971,20 @@ public function testEnumCast(): void $this->assertSame(MemberStatus::Member->value, $check->getRawOriginal('member_status')); $this->assertSame(MemberStatus::Member, $check->member_status); } + + public function testNumericFieldName(): void + { + $user = new User(); + $user->{1} = 'one'; + $user->{2} = ['3' => 'two.three']; + $user->save(); + + $found = User::where(1, 'one')->first(); + $this->assertInstanceOf(User::class, $found); + $this->assertEquals('one', $found[1]); + + $found = User::where('2.3', 'two.three')->first(); + $this->assertInstanceOf(User::class, $found); + $this->assertEquals([3 => 'two.three'], $found[2]); + } } diff --git a/tests/Query/BuilderTest.php b/tests/Query/BuilderTest.php index 2bfc03515..1b3dcd2ad 100644 --- a/tests/Query/BuilderTest.php +++ b/tests/Query/BuilderTest.php @@ -90,6 +90,11 @@ public static function provideQueryBuilderToMql(): iterable fn (Builder $builder) => $builder->where('foo', 'bar'), ]; + yield 'find with numeric field name' => [ + ['find' => [['123' => 'bar'], []]], + fn (Builder $builder) => $builder->where(123, 'bar'), + ]; + yield 'where with single array of conditions' => [ [ 'find' => [ @@ -1175,10 +1180,16 @@ public static function provideExceptions(): iterable yield 'find with single string argument' => [ ArgumentCountError::class, - 'Too few arguments to function MongoDB\Laravel\Query\Builder::where("foo"), 1 passed and at least 2 expected when the 1st is a string', + 'Too few arguments to function MongoDB\Laravel\Query\Builder::where(\'foo\'), 1 passed and at least 2 expected when the 1st is not an array', fn (Builder $builder) => $builder->where('foo'), ]; + yield 'find with single numeric argument' => [ + ArgumentCountError::class, + 'Too few arguments to function MongoDB\Laravel\Query\Builder::where(123), 1 passed and at least 2 expected when the 1st is not an array', + fn (Builder $builder) => $builder->where(123), + ]; + yield 'where regex not starting with /' => [ LogicException::class, 'Missing expected starting delimiter in regular expression "^ac/me$", supported delimiters are: / # ~', @@ -1208,6 +1219,12 @@ public static function provideExceptions(): iterable 'Invalid time format, expected HH:MM:SS, HH:MM or HH, got "stdClass"', fn (Builder $builder) => $builder->whereTime('created_at', new stdClass()), ]; + + yield 'where invalid column type' => [ + InvalidArgumentException::class, + 'First argument of MongoDB\Laravel\Query\Builder::where must be a field path as "string". Got "float"', + fn (Builder $builder) => $builder->where(2.3, '>', 1), + ]; } /** @dataProvider getEloquentMethodsNotSupported */