From ea2da7ad02cfd1b19cab156417d2e7c312bc52c4 Mon Sep 17 00:00:00 2001 From: Andreas Braun Date: Thu, 29 Jun 2023 10:33:52 +0200 Subject: [PATCH 01/19] PHPORM-39: Add namespace for tests directory (#2) * Skip MySQL tests if database is not available * Introduce tests namespace --- composer.json | 8 ++--- tests/AuthTest.php | 6 ++++ tests/CollectionTest.php | 2 ++ tests/ConnectionTest.php | 4 +++ tests/EmbeddedRelationsTest.php | 14 ++++++++- tests/GeospatialTest.php | 5 ++++ tests/HybridRelationsTest.php | 18 +++++++++++ tests/ModelTest.php | 10 +++++++ tests/{models => Models}/Address.php | 4 ++- tests/{models => Models}/Birthday.php | 2 ++ tests/{models => Models}/Book.php | 6 ++-- tests/{models => Models}/Client.php | 8 +++-- tests/{models => Models}/Group.php | 4 ++- tests/{models => Models}/Guarded.php | 2 ++ tests/{models => Models}/Item.php | 4 ++- tests/{models => Models}/Location.php | 2 ++ tests/{models => Models}/MemberStatus.php | 2 ++ tests/{models => Models}/MysqlBook.php | 7 +++-- tests/{models => Models}/MysqlRole.php | 9 ++++-- tests/{models => Models}/MysqlUser.php | 12 +++++--- tests/{models => Models}/Photo.php | 2 ++ tests/{models => Models}/Role.php | 6 ++-- tests/{models => Models}/Scoped.php | 2 ++ tests/{models => Models}/Soft.php | 2 ++ tests/{models => Models}/User.php | 23 +++++++------- tests/QueryBuilderTest.php | 6 ++++ tests/QueryTest.php | 6 ++++ tests/QueueTest.php | 7 ++++- tests/RelationsTest.php | 12 +++++++- tests/SchemaTest.php | 4 +++ tests/{seeds => Seeder}/DatabaseSeeder.php | 4 ++- tests/{seeds => Seeder}/UserTableSeeder.php | 2 ++ tests/SeederTest.php | 9 +++++- tests/TestCase.php | 33 +++++++++++++-------- tests/TransactionTest.php | 4 +++ tests/ValidationTest.php | 5 ++++ 36 files changed, 205 insertions(+), 51 deletions(-) rename tests/{models => Models}/Address.php (76%) rename tests/{models => Models}/Birthday.php (91%) rename tests/{models => Models}/Book.php (76%) rename tests/{models => Models}/Client.php (70%) rename tests/{models => Models}/Group.php (70%) rename tests/{models => Models}/Guarded.php (85%) rename tests/{models => Models}/Item.php (85%) rename tests/{models => Models}/Location.php (84%) rename tests/{models => Models}/MemberStatus.php (59%) rename tests/{models => Models}/MysqlBook.php (84%) rename tests/{models => Models}/MysqlRole.php (80%) rename tests/{models => Models}/MysqlUser.php (76%) rename tests/{models => Models}/Photo.php (89%) rename tests/{models => Models}/Role.php (73%) rename tests/{models => Models}/Scoped.php (91%) rename tests/{models => Models}/Soft.php (90%) rename tests/{models => Models}/User.php (75%) rename tests/{seeds => Seeder}/DatabaseSeeder.php (68%) rename tests/{seeds => Seeder}/UserTableSeeder.php (86%) diff --git a/composer.json b/composer.json index b2dba6529..fbc082a83 100644 --- a/composer.json +++ b/composer.json @@ -36,11 +36,9 @@ } }, "autoload-dev": { - "classmap": [ - "tests/TestCase.php", - "tests/models", - "tests/seeds" - ] + "psr-4": { + "Jenssegers\\Mongodb\\Tests\\": "tests/" + } }, "suggest": { "jenssegers/mongodb-session": "Add MongoDB session support to Laravel-MongoDB", diff --git a/tests/AuthTest.php b/tests/AuthTest.php index 86261696e..702257035 100644 --- a/tests/AuthTest.php +++ b/tests/AuthTest.php @@ -1,6 +1,12 @@ 'John Doe']); - /** @var \Address $address */ + /** @var Address $address */ $address = $user->addresses()->create(['city' => 'New York']); $father = $user->father()->create(['name' => 'Mark Doe']); diff --git a/tests/GeospatialTest.php b/tests/GeospatialTest.php index c86e155af..1d492d38e 100644 --- a/tests/GeospatialTest.php +++ b/tests/GeospatialTest.php @@ -2,6 +2,11 @@ declare(strict_types=1); +namespace Jenssegers\Mongodb\Tests; + +use Illuminate\Support\Facades\Schema; +use Jenssegers\Mongodb\Tests\Models\Location; + class GeospatialTest extends TestCase { public function setUp(): void diff --git a/tests/HybridRelationsTest.php b/tests/HybridRelationsTest.php index 7b4e7cdad..aa3a402b7 100644 --- a/tests/HybridRelationsTest.php +++ b/tests/HybridRelationsTest.php @@ -2,7 +2,18 @@ declare(strict_types=1); +namespace Jenssegers\Mongodb\Tests; + +use Illuminate\Database\Connection; use Illuminate\Database\MySqlConnection; +use Illuminate\Support\Facades\DB; +use Jenssegers\Mongodb\Tests\Models\Book; +use Jenssegers\Mongodb\Tests\Models\MysqlBook; +use Jenssegers\Mongodb\Tests\Models\MysqlRole; +use Jenssegers\Mongodb\Tests\Models\MysqlUser; +use Jenssegers\Mongodb\Tests\Models\Role; +use Jenssegers\Mongodb\Tests\Models\User; +use PDOException; class HybridRelationsTest extends TestCase { @@ -10,6 +21,13 @@ public function setUp(): void { parent::setUp(); + /** @var Connection */ + try { + DB::connection('mysql')->select('SELECT 1'); + } catch (PDOException) { + $this->markTestSkipped('MySQL connection is not available.'); + } + MysqlUser::executeSchema(); MysqlBook::executeSchema(); MysqlRole::executeSchema(); diff --git a/tests/ModelTest.php b/tests/ModelTest.php index e4eeefbb4..21523c7f4 100644 --- a/tests/ModelTest.php +++ b/tests/ModelTest.php @@ -2,7 +2,11 @@ declare(strict_types=1); +namespace Jenssegers\Mongodb\Tests; + use Carbon\Carbon; +use DateTime; +use DateTimeImmutable; use Illuminate\Database\Eloquent\Collection as EloquentCollection; use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Support\Facades\Date; @@ -10,6 +14,12 @@ use Jenssegers\Mongodb\Collection; use Jenssegers\Mongodb\Connection; use Jenssegers\Mongodb\Eloquent\Model; +use Jenssegers\Mongodb\Tests\Models\Book; +use Jenssegers\Mongodb\Tests\Models\Guarded; +use Jenssegers\Mongodb\Tests\Models\Item; +use Jenssegers\Mongodb\Tests\Models\MemberStatus; +use Jenssegers\Mongodb\Tests\Models\Soft; +use Jenssegers\Mongodb\Tests\Models\User; use MongoDB\BSON\ObjectID; use MongoDB\BSON\UTCDateTime; diff --git a/tests/models/Address.php b/tests/Models/Address.php similarity index 76% rename from tests/models/Address.php rename to tests/Models/Address.php index 5e12ddbb7..1050eb0e8 100644 --- a/tests/models/Address.php +++ b/tests/Models/Address.php @@ -2,6 +2,8 @@ declare(strict_types=1); +namespace Jenssegers\Mongodb\Tests\Models; + use Jenssegers\Mongodb\Eloquent\Model as Eloquent; use Jenssegers\Mongodb\Relations\EmbedsMany; @@ -12,6 +14,6 @@ class Address extends Eloquent public function addresses(): EmbedsMany { - return $this->embedsMany('Address'); + return $this->embedsMany(self::class); } } diff --git a/tests/models/Birthday.php b/tests/Models/Birthday.php similarity index 91% rename from tests/models/Birthday.php rename to tests/Models/Birthday.php index 3e725e495..2afca41e0 100644 --- a/tests/models/Birthday.php +++ b/tests/Models/Birthday.php @@ -2,6 +2,8 @@ declare(strict_types=1); +namespace Jenssegers\Mongodb\Tests\Models; + use Jenssegers\Mongodb\Eloquent\Model as Eloquent; /** diff --git a/tests/models/Book.php b/tests/Models/Book.php similarity index 76% rename from tests/models/Book.php rename to tests/Models/Book.php index e247abbfb..74eb8ee09 100644 --- a/tests/models/Book.php +++ b/tests/Models/Book.php @@ -2,6 +2,8 @@ declare(strict_types=1); +namespace Jenssegers\Mongodb\Tests\Models; + use Illuminate\Database\Eloquent\Relations\BelongsTo; use Jenssegers\Mongodb\Eloquent\Model as Eloquent; @@ -21,11 +23,11 @@ class Book extends Eloquent public function author(): BelongsTo { - return $this->belongsTo('User', 'author_id'); + return $this->belongsTo(User::class, 'author_id'); } public function mysqlAuthor(): BelongsTo { - return $this->belongsTo('MysqlUser', 'author_id'); + return $this->belongsTo(MysqlUser::class, 'author_id'); } } diff --git a/tests/models/Client.php b/tests/Models/Client.php similarity index 70% rename from tests/models/Client.php rename to tests/Models/Client.php index 65c5d81a0..0ccc5451f 100644 --- a/tests/models/Client.php +++ b/tests/Models/Client.php @@ -2,6 +2,8 @@ declare(strict_types=1); +namespace Jenssegers\Mongodb\Tests\Models; + use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\MorphOne; @@ -15,16 +17,16 @@ class Client extends Eloquent public function users(): BelongsToMany { - return $this->belongsToMany('User'); + return $this->belongsToMany(User::class); } public function photo(): MorphOne { - return $this->morphOne('Photo', 'has_image'); + return $this->morphOne(Photo::class, 'has_image'); } public function addresses(): HasMany { - return $this->hasMany('Address', 'data.client_id', 'data.client_id'); + return $this->hasMany(Address::class, 'data.client_id', 'data.client_id'); } } diff --git a/tests/models/Group.php b/tests/Models/Group.php similarity index 70% rename from tests/models/Group.php rename to tests/Models/Group.php index bf4edd9bc..8631dbfc8 100644 --- a/tests/models/Group.php +++ b/tests/Models/Group.php @@ -2,6 +2,8 @@ declare(strict_types=1); +namespace Jenssegers\Mongodb\Tests\Models; + use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Jenssegers\Mongodb\Eloquent\Model as Eloquent; @@ -13,6 +15,6 @@ class Group extends Eloquent public function users(): BelongsToMany { - return $this->belongsToMany('User', 'users', 'groups', 'users', '_id', '_id', 'users'); + return $this->belongsToMany(User::class, 'users', 'groups', 'users', '_id', '_id', 'users'); } } diff --git a/tests/models/Guarded.php b/tests/Models/Guarded.php similarity index 85% rename from tests/models/Guarded.php rename to tests/Models/Guarded.php index 8438867e9..8b838b1f4 100644 --- a/tests/models/Guarded.php +++ b/tests/Models/Guarded.php @@ -2,6 +2,8 @@ declare(strict_types=1); +namespace Jenssegers\Mongodb\Tests\Models; + use Jenssegers\Mongodb\Eloquent\Model as Eloquent; class Guarded extends Eloquent diff --git a/tests/models/Item.php b/tests/Models/Item.php similarity index 85% rename from tests/models/Item.php rename to tests/Models/Item.php index 4a29aa05a..eb9d5b882 100644 --- a/tests/models/Item.php +++ b/tests/Models/Item.php @@ -2,6 +2,8 @@ declare(strict_types=1); +namespace Jenssegers\Mongodb\Tests\Models; + use Illuminate\Database\Eloquent\Relations\BelongsTo; use Jenssegers\Mongodb\Eloquent\Builder; use Jenssegers\Mongodb\Eloquent\Model as Eloquent; @@ -19,7 +21,7 @@ class Item extends Eloquent public function user(): BelongsTo { - return $this->belongsTo('User'); + return $this->belongsTo(User::class); } public function scopeSharp(Builder $query) diff --git a/tests/models/Location.php b/tests/Models/Location.php similarity index 84% rename from tests/models/Location.php rename to tests/Models/Location.php index 9ecaff37a..c1fbc94cd 100644 --- a/tests/models/Location.php +++ b/tests/Models/Location.php @@ -2,6 +2,8 @@ declare(strict_types=1); +namespace Jenssegers\Mongodb\Tests\Models; + use Jenssegers\Mongodb\Eloquent\Model as Eloquent; class Location extends Eloquent diff --git a/tests/models/MemberStatus.php b/tests/Models/MemberStatus.php similarity index 59% rename from tests/models/MemberStatus.php rename to tests/Models/MemberStatus.php index 0c702218e..5dde2263e 100644 --- a/tests/models/MemberStatus.php +++ b/tests/Models/MemberStatus.php @@ -1,5 +1,7 @@ belongsTo('User', 'author_id'); + return $this->belongsTo(User::class, 'author_id'); } /** diff --git a/tests/models/MysqlRole.php b/tests/Models/MysqlRole.php similarity index 80% rename from tests/models/MysqlRole.php rename to tests/Models/MysqlRole.php index a8a490d76..7637f31f0 100644 --- a/tests/models/MysqlRole.php +++ b/tests/Models/MysqlRole.php @@ -2,12 +2,15 @@ declare(strict_types=1); +namespace Jenssegers\Mongodb\Tests\Models; + +use Illuminate\Database\Eloquent\Model as EloquentModel; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; use Jenssegers\Mongodb\Eloquent\HybridRelations; -class MysqlRole extends Eloquent +class MysqlRole extends EloquentModel { use HybridRelations; @@ -17,12 +20,12 @@ class MysqlRole extends Eloquent public function user(): BelongsTo { - return $this->belongsTo('User'); + return $this->belongsTo(User::class); } public function mysqlUser(): BelongsTo { - return $this->belongsTo('MysqlUser'); + return $this->belongsTo(MysqlUser::class); } /** diff --git a/tests/models/MysqlUser.php b/tests/Models/MysqlUser.php similarity index 76% rename from tests/models/MysqlUser.php rename to tests/Models/MysqlUser.php index 8c1393fd5..35929faa5 100644 --- a/tests/models/MysqlUser.php +++ b/tests/Models/MysqlUser.php @@ -2,13 +2,17 @@ declare(strict_types=1); +namespace Jenssegers\Mongodb\Tests\Models; + +use Illuminate\Database\Eloquent\Model as EloquentModel; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Database\Schema\Blueprint; +use Illuminate\Database\Schema\MySqlBuilder; use Illuminate\Support\Facades\Schema; use Jenssegers\Mongodb\Eloquent\HybridRelations; -class MysqlUser extends Eloquent +class MysqlUser extends EloquentModel { use HybridRelations; @@ -18,12 +22,12 @@ class MysqlUser extends Eloquent public function books(): HasMany { - return $this->hasMany('Book', 'author_id'); + return $this->hasMany(Book::class, 'author_id'); } public function role(): HasOne { - return $this->hasOne('Role'); + return $this->hasOne(Role::class); } public function mysqlBooks(): HasMany @@ -36,7 +40,7 @@ public function mysqlBooks(): HasMany */ public static function executeSchema(): void { - /** @var \Illuminate\Database\Schema\MySqlBuilder $schema */ + /** @var MySqlBuilder $schema */ $schema = Schema::connection('mysql'); if (! $schema->hasTable('users')) { diff --git a/tests/models/Photo.php b/tests/Models/Photo.php similarity index 89% rename from tests/models/Photo.php rename to tests/Models/Photo.php index 05c06d443..068f3c56c 100644 --- a/tests/models/Photo.php +++ b/tests/Models/Photo.php @@ -2,6 +2,8 @@ declare(strict_types=1); +namespace Jenssegers\Mongodb\Tests\Models; + use Illuminate\Database\Eloquent\Relations\MorphTo; use Jenssegers\Mongodb\Eloquent\Model as Eloquent; diff --git a/tests/models/Role.php b/tests/Models/Role.php similarity index 73% rename from tests/models/Role.php rename to tests/Models/Role.php index 6c8684ecf..6c7eea3d5 100644 --- a/tests/models/Role.php +++ b/tests/Models/Role.php @@ -2,6 +2,8 @@ declare(strict_types=1); +namespace Jenssegers\Mongodb\Tests\Models; + use Illuminate\Database\Eloquent\Relations\BelongsTo; use Jenssegers\Mongodb\Eloquent\Model as Eloquent; @@ -13,11 +15,11 @@ class Role extends Eloquent public function user(): BelongsTo { - return $this->belongsTo('User'); + return $this->belongsTo(User::class); } public function mysqlUser(): BelongsTo { - return $this->belongsTo('MysqlUser'); + return $this->belongsTo(MysqlUser::class); } } diff --git a/tests/models/Scoped.php b/tests/Models/Scoped.php similarity index 91% rename from tests/models/Scoped.php rename to tests/Models/Scoped.php index f94246414..09138753a 100644 --- a/tests/models/Scoped.php +++ b/tests/Models/Scoped.php @@ -2,6 +2,8 @@ declare(strict_types=1); +namespace Jenssegers\Mongodb\Tests\Models; + use Jenssegers\Mongodb\Eloquent\Builder; use Jenssegers\Mongodb\Eloquent\Model as Eloquent; diff --git a/tests/models/Soft.php b/tests/Models/Soft.php similarity index 90% rename from tests/models/Soft.php rename to tests/Models/Soft.php index 30711e61d..6315ac0c0 100644 --- a/tests/models/Soft.php +++ b/tests/Models/Soft.php @@ -2,6 +2,8 @@ declare(strict_types=1); +namespace Jenssegers\Mongodb\Tests\Models; + use Jenssegers\Mongodb\Eloquent\Model as Eloquent; use Jenssegers\Mongodb\Eloquent\SoftDeletes; diff --git a/tests/models/User.php b/tests/Models/User.php similarity index 75% rename from tests/models/User.php rename to tests/Models/User.php index d32d1f8b4..f559af470 100644 --- a/tests/models/User.php +++ b/tests/Models/User.php @@ -2,6 +2,9 @@ declare(strict_types=1); +namespace Jenssegers\Mongodb\Tests\Models; + +use DateTimeInterface; use Illuminate\Auth\Authenticatable; use Illuminate\Auth\Passwords\CanResetPassword; use Illuminate\Contracts\Auth\Authenticatable as AuthenticatableContract; @@ -43,52 +46,52 @@ class User extends Eloquent implements AuthenticatableContract, CanResetPassword public function books() { - return $this->hasMany('Book', 'author_id'); + return $this->hasMany(Book::class, 'author_id'); } public function mysqlBooks() { - return $this->hasMany('MysqlBook', 'author_id'); + return $this->hasMany(MysqlBook::class, 'author_id'); } public function items() { - return $this->hasMany('Item'); + return $this->hasMany(Item::class); } public function role() { - return $this->hasOne('Role'); + return $this->hasOne(Role::class); } public function mysqlRole() { - return $this->hasOne('MysqlRole'); + return $this->hasOne(MysqlRole::class); } public function clients() { - return $this->belongsToMany('Client'); + return $this->belongsToMany(Client::class); } public function groups() { - return $this->belongsToMany('Group', 'groups', 'users', 'groups', '_id', '_id', 'groups'); + return $this->belongsToMany(Group::class, 'groups', 'users', 'groups', '_id', '_id', 'groups'); } public function photos() { - return $this->morphMany('Photo', 'has_image'); + return $this->morphMany(Photo::class, 'has_image'); } public function addresses() { - return $this->embedsMany('Address'); + return $this->embedsMany(Address::class); } public function father() { - return $this->embedsOne('User'); + return $this->embedsOne(self::class); } protected function serializeDate(DateTimeInterface $date) diff --git a/tests/QueryBuilderTest.php b/tests/QueryBuilderTest.php index 235784829..9cb3af405 100644 --- a/tests/QueryBuilderTest.php +++ b/tests/QueryBuilderTest.php @@ -2,12 +2,18 @@ declare(strict_types=1); +namespace Jenssegers\Mongodb\Tests; + +use DateTime; +use DateTimeImmutable; use Illuminate\Support\Facades\Date; use Illuminate\Support\Facades\DB; use Illuminate\Support\LazyCollection; use Illuminate\Testing\Assert; use Jenssegers\Mongodb\Collection; use Jenssegers\Mongodb\Query\Builder; +use Jenssegers\Mongodb\Tests\Models\Item; +use Jenssegers\Mongodb\Tests\Models\User; use MongoDB\BSON\ObjectId; use MongoDB\BSON\Regex; use MongoDB\BSON\UTCDateTime; diff --git a/tests/QueryTest.php b/tests/QueryTest.php index c85cd2a21..4179748d0 100644 --- a/tests/QueryTest.php +++ b/tests/QueryTest.php @@ -2,6 +2,12 @@ declare(strict_types=1); +namespace Jenssegers\Mongodb\Tests; + +use Jenssegers\Mongodb\Tests\Models\Birthday; +use Jenssegers\Mongodb\Tests\Models\Scoped; +use Jenssegers\Mongodb\Tests\Models\User; + class QueryTest extends TestCase { protected static $started = false; diff --git a/tests/QueueTest.php b/tests/QueueTest.php index a0bcbc17d..601d712ae 100644 --- a/tests/QueueTest.php +++ b/tests/QueueTest.php @@ -2,10 +2,15 @@ declare(strict_types=1); +namespace Jenssegers\Mongodb\Tests; + use Carbon\Carbon; +use Illuminate\Support\Facades\Config; +use Illuminate\Support\Facades\Queue; use Illuminate\Support\Str; use Jenssegers\Mongodb\Queue\Failed\MongoFailedJobProvider; use Jenssegers\Mongodb\Queue\MongoQueue; +use Mockery; class QueueTest extends TestCase { @@ -31,7 +36,7 @@ public function testQueueJobLifeCycle(): void // Get and reserve the test job (next available) $job = Queue::pop('test'); - $this->assertInstanceOf(Jenssegers\Mongodb\Queue\MongoJob::class, $job); + $this->assertInstanceOf(\Jenssegers\Mongodb\Queue\MongoJob::class, $job); $this->assertEquals(1, $job->isReserved()); $this->assertEquals(json_encode([ 'uuid' => $uuid, diff --git a/tests/RelationsTest.php b/tests/RelationsTest.php index b1b73e6cc..66c27583f 100644 --- a/tests/RelationsTest.php +++ b/tests/RelationsTest.php @@ -2,7 +2,18 @@ declare(strict_types=1); +namespace Jenssegers\Mongodb\Tests; + use Illuminate\Database\Eloquent\Collection; +use Jenssegers\Mongodb\Tests\Models\Address; +use Jenssegers\Mongodb\Tests\Models\Book; +use Jenssegers\Mongodb\Tests\Models\Client; +use Jenssegers\Mongodb\Tests\Models\Group; +use Jenssegers\Mongodb\Tests\Models\Item; +use Jenssegers\Mongodb\Tests\Models\Photo; +use Jenssegers\Mongodb\Tests\Models\Role; +use Jenssegers\Mongodb\Tests\Models\User; +use Mockery; class RelationsTest extends TestCase { @@ -16,7 +27,6 @@ public function tearDown(): void Book::truncate(); Item::truncate(); Role::truncate(); - Client::truncate(); Group::truncate(); Photo::truncate(); } diff --git a/tests/SchemaTest.php b/tests/SchemaTest.php index fdad70e22..4e820e58a 100644 --- a/tests/SchemaTest.php +++ b/tests/SchemaTest.php @@ -2,6 +2,10 @@ declare(strict_types=1); +namespace Jenssegers\Mongodb\Tests; + +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Schema; use Jenssegers\Mongodb\Schema\Blueprint; class SchemaTest extends TestCase diff --git a/tests/seeds/DatabaseSeeder.php b/tests/Seeder/DatabaseSeeder.php similarity index 68% rename from tests/seeds/DatabaseSeeder.php rename to tests/Seeder/DatabaseSeeder.php index dbd6172f4..a5d7c940f 100644 --- a/tests/seeds/DatabaseSeeder.php +++ b/tests/Seeder/DatabaseSeeder.php @@ -1,5 +1,7 @@ call('UserTableSeeder'); + $this->call(UserTableSeeder::class); } } diff --git a/tests/seeds/UserTableSeeder.php b/tests/Seeder/UserTableSeeder.php similarity index 86% rename from tests/seeds/UserTableSeeder.php rename to tests/Seeder/UserTableSeeder.php index 9f053bedc..95f1331e3 100644 --- a/tests/seeds/UserTableSeeder.php +++ b/tests/Seeder/UserTableSeeder.php @@ -1,5 +1,7 @@ DatabaseSeeder::class]); $user = User::where('name', 'John Doe')->first(); $this->assertTrue($user->seed); diff --git a/tests/TestCase.php b/tests/TestCase.php index dbe8c97c0..51121d528 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -2,21 +2,30 @@ declare(strict_types=1); -use Illuminate\Auth\Passwords\PasswordResetServiceProvider; +namespace Jenssegers\Mongodb\Tests; -class TestCase extends Orchestra\Testbench\TestCase +use Illuminate\Auth\Passwords\PasswordResetServiceProvider as BasePasswordResetServiceProviderAlias; +use Illuminate\Foundation\Application; +use Jenssegers\Mongodb\Auth\PasswordResetServiceProvider; +use Jenssegers\Mongodb\MongodbQueueServiceProvider; +use Jenssegers\Mongodb\MongodbServiceProvider; +use Jenssegers\Mongodb\Tests\Models\User; +use Jenssegers\Mongodb\Validation\ValidationServiceProvider; +use Orchestra\Testbench\TestCase as OrchestraTestCase; + +class TestCase extends OrchestraTestCase { /** * Get application providers. * - * @param \Illuminate\Foundation\Application $app + * @param Application $app * @return array */ protected function getApplicationProviders($app) { $providers = parent::getApplicationProviders($app); - unset($providers[array_search(PasswordResetServiceProvider::class, $providers)]); + unset($providers[array_search(BasePasswordResetServiceProviderAlias::class, $providers)]); return $providers; } @@ -24,23 +33,23 @@ protected function getApplicationProviders($app) /** * Get package providers. * - * @param \Illuminate\Foundation\Application $app + * @param Application $app * @return array */ protected function getPackageProviders($app) { return [ - Jenssegers\Mongodb\MongodbServiceProvider::class, - Jenssegers\Mongodb\MongodbQueueServiceProvider::class, - Jenssegers\Mongodb\Auth\PasswordResetServiceProvider::class, - Jenssegers\Mongodb\Validation\ValidationServiceProvider::class, + MongodbServiceProvider::class, + MongodbQueueServiceProvider::class, + PasswordResetServiceProvider::class, + ValidationServiceProvider::class, ]; } /** * Define environment setup. * - * @param Illuminate\Foundation\Application $app + * @param Application $app * @return void */ protected function getEnvironmentSetUp($app) @@ -57,8 +66,8 @@ protected function getEnvironmentSetUp($app) $app['config']->set('database.connections.mongodb', $config['connections']['mongodb']); $app['config']->set('database.connections.mongodb2', $config['connections']['mongodb']); - $app['config']->set('auth.model', 'User'); - $app['config']->set('auth.providers.users.model', 'User'); + $app['config']->set('auth.model', User::class); + $app['config']->set('auth.providers.users.model', User::class); $app['config']->set('cache.driver', 'array'); $app['config']->set('queue.default', 'database'); diff --git a/tests/TransactionTest.php b/tests/TransactionTest.php index 52ce422a7..46fbf2e2a 100644 --- a/tests/TransactionTest.php +++ b/tests/TransactionTest.php @@ -1,11 +1,15 @@ Date: Thu, 29 Jun 2023 12:58:51 +0200 Subject: [PATCH 02/19] Add classes to cast ObjectId and UUID instances (#1) --- src/Eloquent/Casts/BinaryUuid.php | 63 +++++++++++++++++++++++++++++++ src/Eloquent/Casts/ObjectId.php | 46 ++++++++++++++++++++++ tests/Casts/BinaryUuidTest.php | 48 +++++++++++++++++++++++ tests/Casts/ObjectIdTest.php | 50 ++++++++++++++++++++++++ tests/Models/CastBinaryUuid.php | 17 +++++++++ tests/Models/CastObjectId.php | 17 +++++++++ 6 files changed, 241 insertions(+) create mode 100644 src/Eloquent/Casts/BinaryUuid.php create mode 100644 src/Eloquent/Casts/ObjectId.php create mode 100644 tests/Casts/BinaryUuidTest.php create mode 100644 tests/Casts/ObjectIdTest.php create mode 100644 tests/Models/CastBinaryUuid.php create mode 100644 tests/Models/CastObjectId.php diff --git a/src/Eloquent/Casts/BinaryUuid.php b/src/Eloquent/Casts/BinaryUuid.php new file mode 100644 index 000000000..1ca9d407a --- /dev/null +++ b/src/Eloquent/Casts/BinaryUuid.php @@ -0,0 +1,63 @@ +getType() !== Binary::TYPE_UUID) { + return $value; + } + + $base16Uuid = bin2hex($value->getData()); + + return sprintf( + '%s-%s-%s-%s-%s', + substr($base16Uuid, 0, 8), + substr($base16Uuid, 8, 4), + substr($base16Uuid, 12, 4), + substr($base16Uuid, 16, 4), + substr($base16Uuid, 20, 12), + ); + } + + /** + * Prepare the given value for storage. + * + * @param Model $model + * @param string $key + * @param mixed $value + * @param array $attributes + * @return mixed + */ + public function set($model, string $key, $value, array $attributes) + { + if ($value instanceof Binary) { + return $value; + } + + if (is_string($value) && strlen($value) === 16) { + return new Binary($value, Binary::TYPE_UUID); + } + + return new Binary(hex2bin(str_replace('-', '', $value)), Binary::TYPE_UUID); + } +} diff --git a/src/Eloquent/Casts/ObjectId.php b/src/Eloquent/Casts/ObjectId.php new file mode 100644 index 000000000..bf34bea2f --- /dev/null +++ b/src/Eloquent/Casts/ObjectId.php @@ -0,0 +1,46 @@ + $saveUuid]); + + $model = CastBinaryUuid::firstWhere('uuid', $queryUuid); + $this->assertNotNull($model); + $this->assertSame($expectedUuid, $model->uuid); + } + + public static function provideBinaryUuidCast(): Generator + { + $uuid = '0c103357-3806-48c9-a84b-867dcb625cfb'; + $binaryUuid = new Binary(hex2bin('0c103357380648c9a84b867dcb625cfb'), Binary::TYPE_UUID); + + yield 'Save Binary, Query Binary' => [$uuid, $binaryUuid, $binaryUuid]; + yield 'Save string, Query Binary' => [$uuid, $uuid, $binaryUuid]; + } + + public function testQueryByStringDoesNotCast(): void + { + $uuid = '0c103357-3806-48c9-a84b-867dcb625cfb'; + + CastBinaryUuid::create(['uuid' => $uuid]); + + $model = CastBinaryUuid::firstWhere('uuid', $uuid); + $this->assertNull($model); + } +} diff --git a/tests/Casts/ObjectIdTest.php b/tests/Casts/ObjectIdTest.php new file mode 100644 index 000000000..d9f385543 --- /dev/null +++ b/tests/Casts/ObjectIdTest.php @@ -0,0 +1,50 @@ + $saveObjectId]); + + $model = CastObjectId::firstWhere('oid', $queryObjectId); + $this->assertNotNull($model); + $this->assertSame($stringObjectId, $model->oid); + } + + public static function provideObjectIdCast(): Generator + { + $objectId = new ObjectId(); + $stringObjectId = (string) $objectId; + + yield 'Save ObjectId, Query ObjectId' => [$objectId, $objectId]; + yield 'Save string, Query ObjectId' => [$stringObjectId, $objectId]; + } + + public function testQueryByStringDoesNotCast(): void + { + $objectId = new ObjectId(); + $stringObjectId = (string) $objectId; + + CastObjectId::create(['oid' => $objectId]); + + $model = CastObjectId::firstWhere('oid', $stringObjectId); + $this->assertNull($model); + } +} diff --git a/tests/Models/CastBinaryUuid.php b/tests/Models/CastBinaryUuid.php new file mode 100644 index 000000000..cb8aa5537 --- /dev/null +++ b/tests/Models/CastBinaryUuid.php @@ -0,0 +1,17 @@ + BinaryUuid::class, + ]; +} diff --git a/tests/Models/CastObjectId.php b/tests/Models/CastObjectId.php new file mode 100644 index 000000000..0c82cb9f8 --- /dev/null +++ b/tests/Models/CastObjectId.php @@ -0,0 +1,17 @@ + ObjectId::class, + ]; +} From bc364b5ed5a053c69c69a9c8ae5206725a84488e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Tue, 11 Jul 2023 17:19:19 +0200 Subject: [PATCH 03/19] PHPORM-44: Throw an exception when Query\Builder::push() is used incorrectly (#5) --- src/Query/Builder.php | 5 ++++- tests/QueryBuilderTest.php | 8 ++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/Query/Builder.php b/src/Query/Builder.php index 066412734..1f707e9b3 100644 --- a/src/Query/Builder.php +++ b/src/Query/Builder.php @@ -786,9 +786,12 @@ public function push($column, $value = null, $unique = false) $operator = $unique ? '$addToSet' : '$push'; // Check if we are pushing multiple values. - $batch = (is_array($value) && array_keys($value) === range(0, count($value) - 1)); + $batch = is_array($value) && array_is_list($value); if (is_array($column)) { + if ($value !== null) { + throw new \InvalidArgumentException(sprintf('2nd argument of %s() must be "null" when 1st argument is an array. Got "%s" instead.', __METHOD__, get_debug_type($value))); + } $query = [$operator => $column]; } elseif ($batch) { $query = [$operator => [$column => ['$each' => $value]]]; diff --git a/tests/QueryBuilderTest.php b/tests/QueryBuilderTest.php index 9cb3af405..92c1bbe75 100644 --- a/tests/QueryBuilderTest.php +++ b/tests/QueryBuilderTest.php @@ -345,6 +345,14 @@ public function testPush() $this->assertCount(3, $user['messages']); } + public function testPushRefuses2ndArgumentWhen1stIsAnArray() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('2nd argument of Jenssegers\Mongodb\Query\Builder::push() must be "null" when 1st argument is an array. Got "string" instead.'); + + DB::collection('users')->push(['tags' => 'tag1'], 'tag2'); + } + public function testPull() { $message1 = ['from' => 'Jane', 'body' => 'Hi John']; From 60a22f490c3423e9d3b33baa0396dba945b697cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Wed, 12 Jul 2023 13:56:04 +0200 Subject: [PATCH 04/19] PHPORM-45 Add Query\Builder::toMql() to simplify comprehensive query tests (#6) * PHPORM-45 Add Query\Builder::toMql() to simplify comprehensive query tests * Move Query/Builder unit tests to a dedicated test class --- composer.json | 1 + src/Query/Builder.php | 132 +++++++++++++++++++----------------- tests/Query/BuilderTest.php | 85 +++++++++++++++++++++++ tests/QueryBuilderTest.php | 5 +- 4 files changed, 158 insertions(+), 65 deletions(-) create mode 100644 tests/Query/BuilderTest.php diff --git a/composer.json b/composer.json index fbc082a83..58bfb3c65 100644 --- a/composer.json +++ b/composer.json @@ -19,6 +19,7 @@ ], "license": "MIT", "require": { + "ext-mongodb": "^1.15", "illuminate/support": "^10.0", "illuminate/container": "^10.0", "illuminate/database": "^10.0", diff --git a/src/Query/Builder.php b/src/Query/Builder.php index 1f707e9b3..893de033d 100644 --- a/src/Query/Builder.php +++ b/src/Query/Builder.php @@ -15,6 +15,7 @@ use MongoDB\BSON\ObjectID; use MongoDB\BSON\Regex; use MongoDB\BSON\UTCDateTime; +use MongoDB\Driver\Cursor; use RuntimeException; /** @@ -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 */ - 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. @@ -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]; @@ -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, []]]; } elseif ($function == 'count') { // Translate count into sum. $group['aggregate'] = ['$sum' => 1]; @@ -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 = []; @@ -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. @@ -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); } /** diff --git a/tests/Query/BuilderTest.php b/tests/Query/BuilderTest.php new file mode 100644 index 000000000..17ce184b5 --- /dev/null +++ b/tests/Query/BuilderTest.php @@ -0,0 +1,85 @@ +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); + } +} diff --git a/tests/QueryBuilderTest.php b/tests/QueryBuilderTest.php index 92c1bbe75..d2356d2f3 100644 --- a/tests/QueryBuilderTest.php +++ b/tests/QueryBuilderTest.php @@ -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') { @@ -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']], ]); From 843904b21afc48cac6d96d6fa077a15f0586efe0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Wed, 12 Jul 2023 13:59:05 +0200 Subject: [PATCH 05/19] Create UTCDateTime from DateTimeInterface objects (#8) --- src/Auth/DatabaseTokenRepository.php | 2 +- src/Eloquent/Model.php | 2 +- src/Query/Builder.php | 8 ++++---- tests/QueryBuilderTest.php | 16 ++++++++-------- 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/Auth/DatabaseTokenRepository.php b/src/Auth/DatabaseTokenRepository.php index cf0f89ea1..4574cf615 100644 --- a/src/Auth/DatabaseTokenRepository.php +++ b/src/Auth/DatabaseTokenRepository.php @@ -18,7 +18,7 @@ protected function getPayload($email, $token) return [ 'email' => $email, 'token' => $this->hasher->make($token), - 'created_at' => new UTCDateTime(Date::now()->format('Uv')), + 'created_at' => new UTCDateTime(Date::now()), ]; } diff --git a/src/Eloquent/Model.php b/src/Eloquent/Model.php index 2d938b745..2d985f627 100644 --- a/src/Eloquent/Model.php +++ b/src/Eloquent/Model.php @@ -134,7 +134,7 @@ public function getDateFormat() */ public function freshTimestamp() { - return new UTCDateTime(Date::now()->format('Uv')); + return new UTCDateTime(Date::now()); } /** diff --git a/src/Query/Builder.php b/src/Query/Builder.php index 893de033d..22d933fea 100644 --- a/src/Query/Builder.php +++ b/src/Query/Builder.php @@ -882,7 +882,7 @@ protected function performUpdate($query, array $options = []) $options = $this->inheritConnectionOptions($options); $wheres = $this->compileWheres(); - $result = $this->collection->UpdateMany($wheres, $query, $options); + $result = $this->collection->updateMany($wheres, $query, $options); if (1 == (int) $result->isAcknowledged()) { return $result->getModifiedCount() ? $result->getModifiedCount() : $result->getUpsertedCount(); } @@ -981,18 +981,18 @@ protected function compileWheres(): array if (is_array($where['value'])) { array_walk_recursive($where['value'], function (&$item, $key) { if ($item instanceof DateTimeInterface) { - $item = new UTCDateTime($item->format('Uv')); + $item = new UTCDateTime($item); } }); } else { if ($where['value'] instanceof DateTimeInterface) { - $where['value'] = new UTCDateTime($where['value']->format('Uv')); + $where['value'] = new UTCDateTime($where['value']); } } } elseif (isset($where['values'])) { array_walk_recursive($where['values'], function (&$item, $key) { if ($item instanceof DateTimeInterface) { - $item = new UTCDateTime($item->format('Uv')); + $item = new UTCDateTime($item); } }); } diff --git a/tests/QueryBuilderTest.php b/tests/QueryBuilderTest.php index d2356d2f3..5dbc67cc2 100644 --- a/tests/QueryBuilderTest.php +++ b/tests/QueryBuilderTest.php @@ -600,19 +600,19 @@ public function testUpdateSubdocument() public function testDates() { DB::collection('users')->insert([ - ['name' => 'John Doe', 'birthday' => new UTCDateTime(Date::parse('1980-01-01 00:00:00')->format('Uv'))], - ['name' => 'Robert Roe', 'birthday' => new UTCDateTime(Date::parse('1982-01-01 00:00:00')->format('Uv'))], - ['name' => 'Mark Moe', 'birthday' => new UTCDateTime(Date::parse('1983-01-01 00:00:00.1')->format('Uv'))], - ['name' => 'Frank White', 'birthday' => new UTCDateTime(Date::parse('1960-01-01 12:12:12.1')->format('Uv'))], + ['name' => 'John Doe', 'birthday' => new UTCDateTime(Date::parse('1980-01-01 00:00:00'))], + ['name' => 'Robert Roe', 'birthday' => new UTCDateTime(Date::parse('1982-01-01 00:00:00'))], + ['name' => 'Mark Moe', 'birthday' => new UTCDateTime(Date::parse('1983-01-01 00:00:00.1'))], + ['name' => 'Frank White', 'birthday' => new UTCDateTime(Date::parse('1960-01-01 12:12:12.1'))], ]); $user = DB::collection('users') - ->where('birthday', new UTCDateTime(Date::parse('1980-01-01 00:00:00')->format('Uv'))) + ->where('birthday', new UTCDateTime(Date::parse('1980-01-01 00:00:00'))) ->first(); $this->assertEquals('John Doe', $user['name']); $user = DB::collection('users') - ->where('birthday', new UTCDateTime(Date::parse('1960-01-01 12:12:12.1')->format('Uv'))) + ->where('birthday', new UTCDateTime(Date::parse('1960-01-01 12:12:12.1'))) ->first(); $this->assertEquals('Frank White', $user['name']); @@ -629,8 +629,8 @@ public function testDates() public function testImmutableDates() { DB::collection('users')->insert([ - ['name' => 'John Doe', 'birthday' => new UTCDateTime(Date::parse('1980-01-01 00:00:00')->format('Uv'))], - ['name' => 'Robert Roe', 'birthday' => new UTCDateTime(Date::parse('1982-01-01 00:00:00')->format('Uv'))], + ['name' => 'John Doe', 'birthday' => new UTCDateTime(Date::parse('1980-01-01 00:00:00'))], + ['name' => 'Robert Roe', 'birthday' => new UTCDateTime(Date::parse('1982-01-01 00:00:00'))], ]); $users = DB::collection('users')->where('birthday', '=', new DateTimeImmutable('1980-01-01 00:00:00'))->get(); From 8562a4bd251a6a2dae199332a94d8571bd9cb351 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Wed, 12 Jul 2023 14:54:16 +0200 Subject: [PATCH 06/19] PHPORM-46 Throw an exception when Query\Builder::orderBy() is used with invalid direction (#7) * Convert only strings, let the driver fail for int values * Add more tests on Builder::orderBy --- src/Query/Builder.php | 7 +++- tests/Query/BuilderTest.php | 82 +++++++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 1 deletion(-) diff --git a/src/Query/Builder.php b/src/Query/Builder.php index 22d933fea..78aabffeb 100644 --- a/src/Query/Builder.php +++ b/src/Query/Builder.php @@ -513,11 +513,16 @@ public function distinct($column = false) /** * @inheritdoc + * @param int|string|array $direction */ public function orderBy($column, $direction = 'asc') { if (is_string($direction)) { - $direction = (strtolower($direction) == 'asc' ? 1 : -1); + $direction = match ($direction) { + 'asc', 'ASC' => 1, + 'desc', 'DESC' => -1, + default => throw new \InvalidArgumentException('Order direction must be "asc" or "desc".'), + }; } if ($column == 'natural') { diff --git a/tests/Query/BuilderTest.php b/tests/Query/BuilderTest.php index 17ce184b5..b06a89f8e 100644 --- a/tests/Query/BuilderTest.php +++ b/tests/Query/BuilderTest.php @@ -5,6 +5,7 @@ namespace Jenssegers\Mongodb\Tests\Query; use DateTimeImmutable; +use Illuminate\Tests\Database\DatabaseQueryBuilderTest; use Jenssegers\Mongodb\Connection; use Jenssegers\Mongodb\Query\Builder; use Jenssegers\Mongodb\Query\Processor; @@ -63,6 +64,66 @@ public static function provideQueryBuilderToMql(): iterable fn (Builder $builder) => $builder->limit(10)->offset(5)->select('foo', 'bar'), ]; + /** @see DatabaseQueryBuilderTest::testOrderBys() */ + yield 'orderBy multiple columns' => [ + ['find' => [[], ['sort' => ['email' => 1, 'age' => -1]]]], + fn (Builder $builder) => $builder + ->orderBy('email') + ->orderBy('age', 'desc'), + ]; + + yield 'orders = null' => [ + ['find' => [[], []]], + function (Builder $builder) { + $builder->orders = null; + + return $builder; + }, + ]; + + yield 'orders = []' => [ + ['find' => [[], []]], + function (Builder $builder) { + $builder->orders = []; + + return $builder; + }, + ]; + + yield 'multiple orders with direction' => [ + ['find' => [[], ['sort' => ['email' => -1, 'age' => 1]]]], + fn (Builder $builder) => $builder + ->orderBy('email', -1) + ->orderBy('age', 1), + ]; + + yield 'orderByDesc' => [ + ['find' => [[], ['sort' => ['email' => -1]]]], + fn (Builder $builder) => $builder->orderByDesc('email'), + ]; + + /** @see DatabaseQueryBuilderTest::testReorder() */ + yield 'reorder reset' => [ + ['find' => [[], []]], + fn (Builder $builder) => $builder->orderBy('name')->reorder(), + ]; + + yield 'reorder column' => [ + ['find' => [[], ['sort' => ['name' => -1]]]], + fn (Builder $builder) => $builder->orderBy('name')->reorder('name', 'desc'), + ]; + + /** @link https://www.mongodb.com/docs/manual/reference/method/cursor.sort/#text-score-metadata-sort */ + yield 'orderBy array meta' => [ + ['find' => [ + ['$text' => ['$search' => 'operating']], + ['sort' => ['score' => ['$meta' => 'textScore']]], + ]], + fn (Builder $builder) => $builder + ->where('$text', ['$search' => 'operating']) + ->orderBy('score', ['$meta' => 'textScore']), + ]; + yield 'distinct' => [ ['distinct' => ['foo', [], []]], fn (Builder $builder) => $builder->distinct('foo'), @@ -74,6 +135,27 @@ public static function provideQueryBuilderToMql(): iterable ]; } + /** + * @dataProvider provideExceptions + */ + public function testException($class, $message, \Closure $build): void + { + $builder = self::getBuilder(); + + $this->expectException($class); + $this->expectExceptionMessage($message); + $build($builder); + } + + public static function provideExceptions(): iterable + { + yield 'orderBy invalid direction' => [ + \InvalidArgumentException::class, + 'Order direction must be "asc" or "desc"', + fn (Builder $builder) => $builder->orderBy('_id', 'dasc'), + ]; + } + private static function getBuilder(): Builder { $connection = m::mock(Connection::class); From 4658d542119c7e34b85bd02dd1e3c81f4a78fed2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Wed, 12 Jul 2023 20:28:49 +0200 Subject: [PATCH 07/19] PHPORM-51 Throw an exception when unsupported query builder method is used (#9) --- src/Query/Builder.php | 66 +++++++++++++++++++++++++++++++++++++ tests/Query/BuilderTest.php | 46 ++++++++++++++++++++++++++ 2 files changed, 112 insertions(+) diff --git a/src/Query/Builder.php b/src/Query/Builder.php index 78aabffeb..0e6a8266f 100644 --- a/src/Query/Builder.php +++ b/src/Query/Builder.php @@ -1303,4 +1303,70 @@ public function __call($method, $parameters) return parent::__call($method, $parameters); } + + /** @internal This method is not supported by MongoDB. */ + public function toSql() + { + throw new \BadMethodCallException('This method is not supported by MongoDB. Try "toMql()" instead.'); + } + + /** @internal This method is not supported by MongoDB. */ + public function toRawSql() + { + throw new \BadMethodCallException('This method is not supported by MongoDB. Try "toMql()" instead.'); + } + + /** @internal This method is not supported by MongoDB. */ + public function whereColumn($first, $operator = null, $second = null, $boolean = 'and') + { + throw new \BadMethodCallException('This method is not supported by MongoDB'); + } + + /** @internal This method is not supported by MongoDB. */ + public function whereFullText($columns, $value, array $options = [], $boolean = 'and') + { + throw new \BadMethodCallException('This method is not supported by MongoDB'); + } + + /** @internal This method is not supported by MongoDB. */ + public function groupByRaw($sql, array $bindings = []) + { + throw new \BadMethodCallException('This method is not supported by MongoDB'); + } + + /** @internal This method is not supported by MongoDB. */ + public function orderByRaw($sql, $bindings = []) + { + throw new \BadMethodCallException('This method is not supported by MongoDB'); + } + + /** @internal This method is not supported by MongoDB. */ + public function unionAll($query) + { + throw new \BadMethodCallException('This method is not supported by MongoDB'); + } + + /** @internal This method is not supported by MongoDB. */ + public function union($query, $all = false) + { + throw new \BadMethodCallException('This method is not supported by MongoDB'); + } + + /** @internal This method is not supported by MongoDB. */ + public function having($column, $operator = null, $value = null, $boolean = 'and') + { + throw new \BadMethodCallException('This method is not supported by MongoDB'); + } + + /** @internal This method is not supported by MongoDB. */ + public function havingRaw($sql, array $bindings = [], $boolean = 'and') + { + throw new \BadMethodCallException('This method is not supported by MongoDB'); + } + + /** @internal This method is not supported by MongoDB. */ + public function havingBetween($column, iterable $values, $boolean = 'and', $not = false) + { + throw new \BadMethodCallException('This method is not supported by MongoDB'); + } } diff --git a/tests/Query/BuilderTest.php b/tests/Query/BuilderTest.php index b06a89f8e..f7d12ad4a 100644 --- a/tests/Query/BuilderTest.php +++ b/tests/Query/BuilderTest.php @@ -156,6 +156,52 @@ public static function provideExceptions(): iterable ]; } + /** @dataProvider getEloquentMethodsNotSupported */ + public function testEloquentMethodsNotSupported(\Closure $callback) + { + $builder = self::getBuilder(); + + $this->expectException(\BadMethodCallException::class); + $this->expectExceptionMessage('This method is not supported by MongoDB'); + + $callback($builder); + } + + public static function getEloquentMethodsNotSupported() + { + // Most of this methods can be implemented using aggregation framework + // whereInRaw, whereNotInRaw, orWhereInRaw, orWhereNotInRaw, whereBetweenColumns + + yield 'toSql' => [fn (Builder $builder) => $builder->toSql()]; + yield 'toRawSql' => [fn (Builder $builder) => $builder->toRawSql()]; + + /** @see DatabaseQueryBuilderTest::testBasicWhereColumn() */ + /** @see DatabaseQueryBuilderTest::testArrayWhereColumn() */ + yield 'whereColumn' => [fn (Builder $builder) => $builder->whereColumn('first_name', 'last_name')]; + yield 'orWhereColumn' => [fn (Builder $builder) => $builder->orWhereColumn('first_name', 'last_name')]; + + /** @see DatabaseQueryBuilderTest::testWhereFulltextMySql() */ + yield 'whereFulltext' => [fn (Builder $builder) => $builder->whereFulltext('body', 'Hello World')]; + + /** @see DatabaseQueryBuilderTest::testGroupBys() */ + yield 'groupByRaw' => [fn (Builder $builder) => $builder->groupByRaw('DATE(created_at)')]; + + /** @see DatabaseQueryBuilderTest::testOrderBys() */ + yield 'orderByRaw' => [fn (Builder $builder) => $builder->orderByRaw('"age" ? desc', ['foo'])]; + + /** @see DatabaseQueryBuilderTest::testInRandomOrderMySql */ + yield 'inRandomOrder' => [fn (Builder $builder) => $builder->inRandomOrder()]; + + yield 'union' => [fn (Builder $builder) => $builder->union($builder)]; + yield 'unionAll' => [fn (Builder $builder) => $builder->unionAll($builder)]; + + /** @see DatabaseQueryBuilderTest::testRawHavings */ + yield 'havingRaw' => [fn (Builder $builder) => $builder->havingRaw('user_foo < user_bar')]; + yield 'having' => [fn (Builder $builder) => $builder->having('baz', '=', 1)]; + yield 'havingBetween' => [fn (Builder $builder) => $builder->havingBetween('last_login_date', ['2018-11-16', '2018-12-16'])]; + yield 'orHavingRaw' => [fn (Builder $builder) => $builder->orHavingRaw('user_foo < user_bar')]; + } + private static function getBuilder(): Builder { $connection = m::mock(Connection::class); From 78319d49da0f9e95d288319bdc54c9f404a29c1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Thu, 13 Jul 2023 08:41:07 +0200 Subject: [PATCH 08/19] Optimize calls to debug_backtrace (#11) --- src/Eloquent/EmbedsRelations.php | 8 ++------ src/Eloquent/HybridRelations.php | 8 ++------ 2 files changed, 4 insertions(+), 12 deletions(-) diff --git a/src/Eloquent/EmbedsRelations.php b/src/Eloquent/EmbedsRelations.php index 9e5f77d92..95231a542 100644 --- a/src/Eloquent/EmbedsRelations.php +++ b/src/Eloquent/EmbedsRelations.php @@ -23,9 +23,7 @@ protected function embedsMany($related, $localKey = null, $foreignKey = null, $r // the calling method's name and use that as the relationship name as most // of the time this will be what we desire to use for the relationships. if ($relation === null) { - [, $caller] = debug_backtrace(false); - - $relation = $caller['function']; + $relation = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2)[1]['function']; } if ($localKey === null) { @@ -58,9 +56,7 @@ protected function embedsOne($related, $localKey = null, $foreignKey = null, $re // the calling method's name and use that as the relationship name as most // of the time this will be what we desire to use for the relationships. if ($relation === null) { - [, $caller] = debug_backtrace(false); - - $relation = $caller['function']; + $relation = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2)[1]['function']; } if ($localKey === null) { diff --git a/src/Eloquent/HybridRelations.php b/src/Eloquent/HybridRelations.php index 0818ca3ee..398d26f81 100644 --- a/src/Eloquent/HybridRelations.php +++ b/src/Eloquent/HybridRelations.php @@ -134,9 +134,7 @@ public function belongsTo($related, $foreignKey = null, $otherKey = null, $relat // the calling method's name and use that as the relationship name as most // of the time this will be what we desire to use for the relationships. if ($relation === null) { - [$current, $caller] = debug_backtrace(false, 2); - - $relation = $caller['function']; + $relation = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2)[1]['function']; } // Check if it is a relation with an original model. @@ -178,9 +176,7 @@ public function morphTo($name = null, $type = null, $id = null, $ownerKey = null // since that is most likely the name of the polymorphic interface. We can // use that to get both the class and foreign key that will be utilized. if ($name === null) { - [$current, $caller] = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2); - - $name = $caller['function']; + $name = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2)[1]['function']; } [$type, $id] = $this->getMorphs(Str::snake($name), $type, $id); From bd86f85768f02ab23bcbb9bc5c2a65fa878362d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Thu, 13 Jul 2023 11:33:08 +0200 Subject: [PATCH 09/19] Add header documentation for classes & traits that can be used in applications (#12) * Add header documentation for classes & traits that can be used in applications * Precise mixed types when possible --- src/Collection.php | 3 +++ src/Connection.php | 3 +++ src/Eloquent/Casts/BinaryUuid.php | 2 +- src/Eloquent/EmbedsRelations.php | 3 +++ src/Eloquent/HybridRelations.php | 4 ++++ src/Query/Builder.php | 14 +++++++------- 6 files changed, 21 insertions(+), 8 deletions(-) diff --git a/src/Collection.php b/src/Collection.php index 3980e8de6..ac1c09f74 100644 --- a/src/Collection.php +++ b/src/Collection.php @@ -6,6 +6,9 @@ use MongoDB\BSON\ObjectID; use MongoDB\Collection as MongoCollection; +/** + * @mixin MongoCollection + */ class Collection { /** diff --git a/src/Connection.php b/src/Connection.php index 3a3d235ed..278642081 100644 --- a/src/Connection.php +++ b/src/Connection.php @@ -12,6 +12,9 @@ use MongoDB\Database; use Throwable; +/** + * @mixin Database + */ class Connection extends BaseConnection { use ManagesTransactions; diff --git a/src/Eloquent/Casts/BinaryUuid.php b/src/Eloquent/Casts/BinaryUuid.php index 1ca9d407a..8c8628f76 100644 --- a/src/Eloquent/Casts/BinaryUuid.php +++ b/src/Eloquent/Casts/BinaryUuid.php @@ -46,7 +46,7 @@ public function get($model, string $key, $value, array $attributes) * @param string $key * @param mixed $value * @param array $attributes - * @return mixed + * @return Binary */ public function set($model, string $key, $value, array $attributes) { diff --git a/src/Eloquent/EmbedsRelations.php b/src/Eloquent/EmbedsRelations.php index 95231a542..cd921d603 100644 --- a/src/Eloquent/EmbedsRelations.php +++ b/src/Eloquent/EmbedsRelations.php @@ -6,6 +6,9 @@ use Jenssegers\Mongodb\Relations\EmbedsMany; use Jenssegers\Mongodb\Relations\EmbedsOne; +/** + * Embeds relations for MongoDB models. + */ trait EmbedsRelations { /** diff --git a/src/Eloquent/HybridRelations.php b/src/Eloquent/HybridRelations.php index 398d26f81..bb544f9ae 100644 --- a/src/Eloquent/HybridRelations.php +++ b/src/Eloquent/HybridRelations.php @@ -12,6 +12,10 @@ use Jenssegers\Mongodb\Relations\MorphMany; use Jenssegers\Mongodb\Relations\MorphTo; +/** + * Cross-database relationships between SQL and MongoDB. + * Use this trait in SQL models to define relationships with MongoDB models. + */ trait HybridRelations { /** diff --git a/src/Query/Builder.php b/src/Query/Builder.php index 0e6a8266f..b5141a080 100644 --- a/src/Query/Builder.php +++ b/src/Query/Builder.php @@ -788,9 +788,9 @@ public function raw($expression = null) /** * Append one or more values to an array. * - * @param mixed $column - * @param mixed $value - * @param bool $unique + * @param string|array $column + * @param mixed $value + * @param bool $unique * @return int */ public function push($column, $value = null, $unique = false) @@ -818,14 +818,14 @@ public function push($column, $value = null, $unique = false) /** * Remove one or more values from an array. * - * @param mixed $column - * @param mixed $value + * @param string|array $column + * @param mixed $value * @return int */ public function pull($column, $value = null) { // Check if we passed an associative array. - $batch = (is_array($value) && array_keys($value) === range(0, count($value) - 1)); + $batch = is_array($value) && array_is_list($value); // If we are pulling multiple values, we need to use $pullAll. $operator = $batch ? '$pullAll' : '$pull'; @@ -842,7 +842,7 @@ public function pull($column, $value = null) /** * Remove one or more fields. * - * @param mixed $columns + * @param string|string[] $columns * @return int */ public function drop($columns) From cf103ba0ddc8a4ca601aab338e8298e0fdbff018 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Wed, 19 Jul 2023 10:39:18 +0200 Subject: [PATCH 10/19] PHPORM-47 Improve Builder::whereBetween to support CarbonPeriod and reject invalid array (#10) The Query\Builder::whereBetween() method can be used like this: whereBetween('date_field', [min, max]) whereBetween('date_field', collect([min, max])) whereBetween('date_field', CarbonPeriod) Laravel allows other formats: the $values array is flatten and the builder assumes there are at least 2 elements and ignore the others. It's a design that can lead to misunderstandings. I prefer to raise an exception when we have incorrect values, rather than trying to guess what the developer would like to do. Support for CarbonPeriod was fixed in Laravel 10: laravel/framework#46720 because the query builder was taking the 1st 2 values of the iterator instead of the start & end dates. --- src/Query/Builder.php | 27 ++++-- tests/Query/BuilderTest.php | 163 +++++++++++++++++++++++++++++++++++- 2 files changed, 184 insertions(+), 6 deletions(-) diff --git a/src/Query/Builder.php b/src/Query/Builder.php index b5141a080..b6924bb47 100644 --- a/src/Query/Builder.php +++ b/src/Query/Builder.php @@ -2,6 +2,7 @@ namespace Jenssegers\Mongodb\Query; +use Carbon\CarbonPeriod; use Closure; use DateTimeInterface; use Illuminate\Database\Query\Builder as BaseBuilder; @@ -554,11 +555,20 @@ public function whereAll($column, array $values, $boolean = 'and', $not = false) /** * @inheritdoc + * @param list{mixed, mixed}|CarbonPeriod $values */ public function whereBetween($column, iterable $values, $boolean = 'and', $not = false) { $type = 'between'; + if ($values instanceof Collection) { + $values = $values->all(); + } + + if (is_array($values) && (! array_is_list($values) || count($values) !== 2)) { + throw new \InvalidArgumentException('Between $values must be a list with exactly two elements: [min, max]'); + } + $this->wheres[] = compact('column', 'type', 'boolean', 'values', 'not'); return $this; @@ -995,11 +1005,18 @@ protected function compileWheres(): array } } } elseif (isset($where['values'])) { - array_walk_recursive($where['values'], function (&$item, $key) { - if ($item instanceof DateTimeInterface) { - $item = new UTCDateTime($item); - } - }); + if (is_array($where['values'])) { + array_walk_recursive($where['values'], function (&$item, $key) { + if ($item instanceof DateTimeInterface) { + $item = new UTCDateTime($item); + } + }); + } elseif ($where['values'] instanceof CarbonPeriod) { + $where['values'] = [ + new UTCDateTime($where['values']->getStartDate()), + new UTCDateTime($where['values']->getEndDate()), + ]; + } } // The next item in a "chain" of wheres devices the boolean of the diff --git a/tests/Query/BuilderTest.php b/tests/Query/BuilderTest.php index f7d12ad4a..f600fa73a 100644 --- a/tests/Query/BuilderTest.php +++ b/tests/Query/BuilderTest.php @@ -5,6 +5,7 @@ namespace Jenssegers\Mongodb\Tests\Query; use DateTimeImmutable; +use Illuminate\Database\Eloquent\Collection; use Illuminate\Tests\Database\DatabaseQueryBuilderTest; use Jenssegers\Mongodb\Connection; use Jenssegers\Mongodb\Query\Builder; @@ -124,13 +125,142 @@ function (Builder $builder) { ->orderBy('score', ['$meta' => 'textScore']), ]; + /** @see DatabaseQueryBuilderTest::testWhereBetweens() */ + yield 'whereBetween array of numbers' => [ + ['find' => [['id' => ['$gte' => 1, '$lte' => 2]], []]], + fn (Builder $builder) => $builder->whereBetween('id', [1, 2]), + ]; + + yield 'whereBetween nested array of numbers' => [ + ['find' => [['id' => ['$gte' => [1], '$lte' => [2, 3]]], []]], + fn (Builder $builder) => $builder->whereBetween('id', [[1], [2, 3]]), + ]; + + $period = now()->toPeriod(now()->addMonth()); + yield 'whereBetween CarbonPeriod' => [ + ['find' => [ + ['created_at' => ['$gte' => new UTCDateTime($period->start), '$lte' => new UTCDateTime($period->end)]], + [], // options + ]], + fn (Builder $builder) => $builder->whereBetween('created_at', $period), + ]; + + yield 'whereBetween collection' => [ + ['find' => [['id' => ['$gte' => 1, '$lte' => 2]], []]], + fn (Builder $builder) => $builder->whereBetween('id', collect([1, 2])), + ]; + + /** @see DatabaseQueryBuilderTest::testOrWhereBetween() */ + yield 'orWhereBetween array of numbers' => [ + ['find' => [ + ['$or' => [ + ['id' => 1], + ['id' => ['$gte' => 3, '$lte' => 5]], + ]], + [], // options + ]], + fn (Builder $builder) => $builder + ->where('id', '=', 1) + ->orWhereBetween('id', [3, 5]), + ]; + + /** @link https://www.mongodb.com/docs/manual/reference/bson-type-comparison-order/#arrays */ + yield 'orWhereBetween nested array of numbers' => [ + ['find' => [ + ['$or' => [ + ['id' => 1], + ['id' => ['$gte' => [4], '$lte' => [6, 8]]], + ]], + [], // options + ]], + fn (Builder $builder) => $builder + ->where('id', '=', 1) + ->orWhereBetween('id', [[4], [6, 8]]), + ]; + + yield 'orWhereBetween collection' => [ + ['find' => [ + ['$or' => [ + ['id' => 1], + ['id' => ['$gte' => 3, '$lte' => 4]], + ]], + [], // options + ]], + fn (Builder $builder) => $builder + ->where('id', '=', 1) + ->orWhereBetween('id', collect([3, 4])), + ]; + + yield 'whereNotBetween array of numbers' => [ + ['find' => [ + ['$or' => [ + ['id' => ['$lte' => 1]], + ['id' => ['$gte' => 2]], + ]], + [], // options + ]], + fn (Builder $builder) => $builder->whereNotBetween('id', [1, 2]), + ]; + + /** @see DatabaseQueryBuilderTest::testOrWhereNotBetween() */ + yield 'orWhereNotBetween array of numbers' => [ + ['find' => [ + ['$or' => [ + ['id' => 1], + ['$or' => [ + ['id' => ['$lte' => 3]], + ['id' => ['$gte' => 5]], + ]], + ]], + [], // options + ]], + fn (Builder $builder) => $builder + ->where('id', '=', 1) + ->orWhereNotBetween('id', [3, 5]), + ]; + + yield 'orWhereNotBetween nested array of numbers' => [ + ['find' => [ + ['$or' => [ + ['id' => 1], + ['$or' => [ + ['id' => ['$lte' => [2, 3]]], + ['id' => ['$gte' => [5]]], + ]], + ]], + [], // options + ]], + fn (Builder $builder) => $builder + ->where('id', '=', 1) + ->orWhereNotBetween('id', [[2, 3], [5]]), + ]; + + yield 'orWhereNotBetween collection' => [ + ['find' => [ + ['$or' => [ + ['id' => 1], + ['$or' => [ + ['id' => ['$lte' => 3]], + ['id' => ['$gte' => 4]], + ]], + ]], + [], // options + ]], + fn (Builder $builder) => $builder + ->where('id', '=', 1) + ->orWhereNotBetween('id', collect([3, 4])), + ]; + yield 'distinct' => [ ['distinct' => ['foo', [], []]], fn (Builder $builder) => $builder->distinct('foo'), ]; yield 'groupBy' => [ - ['aggregate' => [[['$group' => ['_id' => ['foo' => '$foo'], 'foo' => ['$last' => '$foo']]]], []]], + ['aggregate' => [ + [['$group' => ['_id' => ['foo' => '$foo'], 'foo' => ['$last' => '$foo']]]], + [], // options + ]], fn (Builder $builder) => $builder->groupBy('foo'), ]; } @@ -154,6 +284,37 @@ public static function provideExceptions(): iterable 'Order direction must be "asc" or "desc"', fn (Builder $builder) => $builder->orderBy('_id', 'dasc'), ]; + + /** @see DatabaseQueryBuilderTest::testWhereBetweens */ + yield 'whereBetween array too short' => [ + \InvalidArgumentException::class, + 'Between $values must be a list with exactly two elements: [min, max]', + fn (Builder $builder) => $builder->whereBetween('id', [1]), + ]; + + yield 'whereBetween array too short (nested)' => [ + \InvalidArgumentException::class, + 'Between $values must be a list with exactly two elements: [min, max]', + fn (Builder $builder) => $builder->whereBetween('id', [[1, 2]]), + ]; + + yield 'whereBetween array too long' => [ + \InvalidArgumentException::class, + 'Between $values must be a list with exactly two elements: [min, max]', + fn (Builder $builder) => $builder->whereBetween('id', [1, 2, 3]), + ]; + + yield 'whereBetween collection too long' => [ + \InvalidArgumentException::class, + 'Between $values must be a list with exactly two elements: [min, max]', + fn (Builder $builder) => $builder->whereBetween('id', new Collection([1, 2, 3])), + ]; + + yield 'whereBetween array is not a list' => [ + \InvalidArgumentException::class, + 'Between $values must be a list with exactly two elements: [min, max]', + fn (Builder $builder) => $builder->whereBetween('id', ['min' => 1, 'max' => 2]), + ]; } /** @dataProvider getEloquentMethodsNotSupported */ From 9d9c7c845ef7c8c99bcc4484f97f1ae355b2a0ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Wed, 19 Jul 2023 22:12:08 +0200 Subject: [PATCH 11/19] PHPORM-49 Implement `Query\Builder::whereNot` by encapsulating into `$not` (#13) `Query\Builder::whereNot` was simply ignoring the "not" and breaking the built query. --- CHANGELOG.md | 10 +- README.md | 6 ++ src/Query/Builder.php | 19 ++-- tests/Query/BuilderTest.php | 182 ++++++++++++++++++++++++++++++++++++ 4 files changed, 210 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b1018c1cb..04b823816 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,9 +3,17 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +- Add classes to cast ObjectId and UUID instances [#1](https://github.com/GromNaN/laravel-mongodb-private/pull/1) by [@alcaeus](https://github.com/alcaeus). +- Add `Query\Builder::toMql()` to simplify comprehensive query tests [#6](https://github.com/GromNaN/laravel-mongodb-private/pull/6) by [@GromNaN](https://github.com/GromNaN). +- Fix `Query\Builder::whereNot` to use MongoDB [`$not`](https://www.mongodb.com/docs/manual/reference/operator/query/not/) operator [#13](https://github.com/GromNaN/laravel-mongodb-private/pull/13) by [@GromNaN](https://github.com/GromNaN). +- Fix `Query\Builder::whereBetween` to accept `Carbon\Period` object [#10](https://github.com/GromNaN/laravel-mongodb-private/pull/10) by [@GromNaN](https://github.com/GromNaN). +- Throw an exception for unsupported `Query\Builder` methods [#9](https://github.com/GromNaN/laravel-mongodb-private/pull/9) by [@GromNaN](https://github.com/GromNaN). +- Throw an exception when `Query\Builder::orderBy()` is used with invalid direction [#7](https://github.com/GromNaN/laravel-mongodb-private/pull/7) by [@GromNaN](https://github.com/GromNaN). +- Throw an exception when `Query\Builder::push()` is used incorrectly [#5](https://github.com/GromNaN/laravel-mongodb-private/pull/5) by [@GromNaN](https://github.com/GromNaN). + ## [3.9.2] - 2022-09-01 -### Addded +### Added - Add single word name mutators [#2438](https://github.com/jenssegers/laravel-mongodb/pull/2438) by [@RosemaryOrchard](https://github.com/RosemaryOrchard) & [@mrneatly](https://github.com/mrneatly). ### Fixed diff --git a/README.md b/README.md index 6a6752575..71e7768e5 100644 --- a/README.md +++ b/README.md @@ -332,6 +332,12 @@ $users = ->get(); ``` +**NOT statements** + +```php +$users = User::whereNot('age', '>', 18)->get(); +``` + **whereIn** ```php diff --git a/src/Query/Builder.php b/src/Query/Builder.php index b6924bb47..4db2b5a91 100644 --- a/src/Query/Builder.php +++ b/src/Query/Builder.php @@ -1019,19 +1019,26 @@ protected function compileWheres(): array } } - // The next item in a "chain" of wheres devices the boolean of the - // first item. So if we see that there are multiple wheres, we will - // use the operator of the next where. - if ($i == 0 && count($wheres) > 1 && $where['boolean'] == 'and') { - $where['boolean'] = $wheres[$i + 1]['boolean']; + // In a sequence of "where" clauses, the logical operator of the + // first "where" is determined by the 2nd "where". + // $where['boolean'] = "and", "or", "and not" or "or not" + if ($i == 0 && count($wheres) > 1 + && str_starts_with($where['boolean'], 'and') + && str_starts_with($wheres[$i + 1]['boolean'], 'or') + ) { + $where['boolean'] = 'or'.(str_ends_with($where['boolean'], 'not') ? ' not' : ''); } // We use different methods to compile different wheres. $method = "compileWhere{$where['type']}"; $result = $this->{$method}($where); + if (str_ends_with($where['boolean'], 'not')) { + $result = ['$not' => $result]; + } + // Wrap the where with an $or operator. - if ($where['boolean'] == 'or') { + if (str_starts_with($where['boolean'], 'or')) { $result = ['$or' => [$result]]; } diff --git a/tests/Query/BuilderTest.php b/tests/Query/BuilderTest.php index f600fa73a..8c8a00c50 100644 --- a/tests/Query/BuilderTest.php +++ b/tests/Query/BuilderTest.php @@ -50,6 +50,17 @@ public static function provideQueryBuilderToMql(): iterable fn (Builder $builder) => $builder->where('foo', 'bar'), ]; + yield 'where with single array of conditions' => [ + ['find' => [ + ['$and' => [ + ['foo' => 1], + ['bar' => 2], + ]], + [], // options + ]], + fn (Builder $builder) => $builder->where(['foo' => 1, 'bar' => 2]), + ]; + yield 'find > date' => [ ['find' => [['foo' => ['$gt' => new UTCDateTime($date)]], []]], fn (Builder $builder) => $builder->where('foo', '>', $date), @@ -65,6 +76,177 @@ public static function provideQueryBuilderToMql(): iterable fn (Builder $builder) => $builder->limit(10)->offset(5)->select('foo', 'bar'), ]; + /** @see DatabaseQueryBuilderTest::testBasicWhereNot() */ + yield 'whereNot (multiple)' => [ + ['find' => [ + ['$and' => [ + ['$not' => ['name' => 'foo']], + ['$not' => ['name' => ['$ne' => 'bar']]], + ]], + [], // options + ]], + fn (Builder $builder) => $builder + ->whereNot('name', 'foo') + ->whereNot('name', '<>', 'bar'), + ]; + + /** @see DatabaseQueryBuilderTest::testBasicOrWheres() */ + yield 'where orWhere' => [ + ['find' => [ + ['$or' => [ + ['id' => 1], + ['email' => 'foo'], + ]], + [], // options + ]], + fn (Builder $builder) => $builder + ->where('id', '=', 1) + ->orWhere('email', '=', 'foo'), + ]; + + /** @see DatabaseQueryBuilderTest::testBasicOrWhereNot() */ + yield 'orWhereNot' => [ + ['find' => [ + ['$or' => [ + ['$not' => ['name' => 'foo']], + ['$not' => ['name' => ['$ne' => 'bar']]], + ]], + [], // options + ]], + fn (Builder $builder) => $builder + ->orWhereNot('name', 'foo') + ->orWhereNot('name', '<>', 'bar'), + ]; + + yield 'whereNot orWhere' => [ + ['find' => [ + ['$or' => [ + ['$not' => ['name' => 'foo']], + ['name' => ['$ne' => 'bar']], + ]], + [], // options + ]], + fn (Builder $builder) => $builder + ->whereNot('name', 'foo') + ->orWhere('name', '<>', 'bar'), + ]; + + /** @see DatabaseQueryBuilderTest::testWhereNot() */ + yield 'whereNot callable' => [ + ['find' => [ + ['$not' => ['name' => 'foo']], + [], // options + ]], + fn (Builder $builder) => $builder + ->whereNot(fn (Builder $q) => $q->where('name', 'foo')), + ]; + + yield 'where whereNot' => [ + ['find' => [ + ['$and' => [ + ['name' => 'bar'], + ['$not' => ['email' => 'foo']], + ]], + [], // options + ]], + fn (Builder $builder) => $builder + ->where('name', '=', 'bar') + ->whereNot(function (Builder $q) { + $q->where('email', '=', 'foo'); + }), + ]; + + yield 'whereNot (nested)' => [ + ['find' => [ + ['$not' => [ + '$and' => [ + ['name' => 'foo'], + ['$not' => ['email' => ['$ne' => 'bar']]], + ], + ]], + [], // options + ]], + fn (Builder $builder) => $builder + ->whereNot(function (Builder $q) { + $q->where('name', '=', 'foo') + ->whereNot('email', '<>', 'bar'); + }), + ]; + + yield 'orWhere orWhereNot' => [ + ['find' => [ + ['$or' => [ + ['name' => 'bar'], + ['$not' => ['email' => 'foo']], + ]], + [], // options + ]], + fn (Builder $builder) => $builder + ->orWhere('name', '=', 'bar') + ->orWhereNot(function (Builder $q) { + $q->where('email', '=', 'foo'); + }), + ]; + + yield 'where orWhereNot' => [ + ['find' => [ + ['$or' => [ + ['name' => 'bar'], + ['$not' => ['email' => 'foo']], + ]], + [], // options + ]], + fn (Builder $builder) => $builder + ->where('name', '=', 'bar') + ->orWhereNot('email', '=', 'foo'), + ]; + + /** @see DatabaseQueryBuilderTest::testWhereNotWithArrayConditions() */ + yield 'whereNot with arrays of single condition' => [ + ['find' => [ + ['$not' => [ + '$and' => [ + ['foo' => 1], + ['bar' => 2], + ], + ]], + [], // options + ]], + fn (Builder $builder) => $builder + ->whereNot([['foo', 1], ['bar', 2]]), + ]; + + yield 'whereNot with single array of conditions' => [ + ['find' => [ + ['$not' => [ + '$and' => [ + ['foo' => 1], + ['bar' => 2], + ], + ]], + [], // options + ]], + fn (Builder $builder) => $builder + ->whereNot(['foo' => 1, 'bar' => 2]), + ]; + + yield 'whereNot with arrays of single condition with operator' => [ + ['find' => [ + ['$not' => [ + '$and' => [ + ['foo' => 1], + ['bar' => ['$lt' => 2]], + ], + ]], + [], // options + ]], + fn (Builder $builder) => $builder + ->whereNot([ + ['foo', 1], + ['bar', '<', 2], + ]), + ]; + /** @see DatabaseQueryBuilderTest::testOrderBys() */ yield 'orderBy multiple columns' => [ ['find' => [[], ['sort' => ['email' => 1, 'age' => -1]]]], From 52c0ea33cc6ceb842363fadeaa9400347eaf3d9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Thu, 20 Jul 2023 23:48:26 +0200 Subject: [PATCH 12/19] PHPORM-49 Implement `Query\Builder::whereNot` by encapsulating into `$not` (#13) (#15) `Query\Builder::whereNot` was simply ignoring the "not" and breaking the built query. --- CHANGELOG.md | 1 + src/Query/Builder.php | 17 ----------------- tests/Query/BuilderTest.php | 6 ++++++ 3 files changed, 7 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 04b823816..d28e9beae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ All notable changes to this project will be documented in this file. - Throw an exception for unsupported `Query\Builder` methods [#9](https://github.com/GromNaN/laravel-mongodb-private/pull/9) by [@GromNaN](https://github.com/GromNaN). - Throw an exception when `Query\Builder::orderBy()` is used with invalid direction [#7](https://github.com/GromNaN/laravel-mongodb-private/pull/7) by [@GromNaN](https://github.com/GromNaN). - Throw an exception when `Query\Builder::push()` is used incorrectly [#5](https://github.com/GromNaN/laravel-mongodb-private/pull/5) by [@GromNaN](https://github.com/GromNaN). +- Remove public property `Query\Builder::$paginating` [#15](https://github.com/GromNaN/laravel-mongodb-private/pull/15) by [@GromNaN](https://github.com/GromNaN). ## [3.9.2] - 2022-09-01 diff --git a/src/Query/Builder.php b/src/Query/Builder.php index 4db2b5a91..6321b86de 100644 --- a/src/Query/Builder.php +++ b/src/Query/Builder.php @@ -59,13 +59,6 @@ class Builder extends BaseBuilder */ public $options = []; - /** - * Indicate if we are executing a pagination query. - * - * @var bool - */ - public $paginating = false; - /** * All of the available clause operators. * @@ -574,16 +567,6 @@ public function whereBetween($column, iterable $values, $boolean = 'and', $not = return $this; } - /** - * @inheritdoc - */ - public function forPage($page, $perPage = 15) - { - $this->paginating = true; - - return $this->skip(($page - 1) * $perPage)->take($perPage); - } - /** * @inheritdoc */ diff --git a/tests/Query/BuilderTest.php b/tests/Query/BuilderTest.php index 8c8a00c50..7cd6f0584 100644 --- a/tests/Query/BuilderTest.php +++ b/tests/Query/BuilderTest.php @@ -247,6 +247,12 @@ public static function provideQueryBuilderToMql(): iterable ]), ]; + /** @see DatabaseQueryBuilderTest::testForPage() */ + yield 'forPage' => [ + ['find' => [[], ['limit' => 20, 'skip' => 40]]], + fn (Builder $builder) => $builder->forPage(3, 20), + ]; + /** @see DatabaseQueryBuilderTest::testOrderBys() */ yield 'orderBy multiple columns' => [ ['find' => [[], ['sort' => ['email' => 1, 'age' => -1]]]], From 9cbadea8d4fd7a9e84dc6ef13579d596776078cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Wed, 26 Jul 2023 09:10:43 +0200 Subject: [PATCH 13/19] PHPORM-50 PHPORM-65 Remove call to deprecated Collection::count for countDocuments (#18) https://www.mongodb.com/docs/php-library/current/reference/method/MongoDBCollection-count/ Fix pass options to countDocuments for transaction session --- CHANGELOG.md | 1 + src/Query/Builder.php | 8 +++++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d28e9beae..0932cb357 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ All notable changes to this project will be documented in this file. - Throw an exception when `Query\Builder::orderBy()` is used with invalid direction [#7](https://github.com/GromNaN/laravel-mongodb-private/pull/7) by [@GromNaN](https://github.com/GromNaN). - Throw an exception when `Query\Builder::push()` is used incorrectly [#5](https://github.com/GromNaN/laravel-mongodb-private/pull/5) by [@GromNaN](https://github.com/GromNaN). - Remove public property `Query\Builder::$paginating` [#15](https://github.com/GromNaN/laravel-mongodb-private/pull/15) by [@GromNaN](https://github.com/GromNaN). +- Remove call to deprecated `Collection::count` for `countDocuments` [#18](https://github.com/GromNaN/laravel-mongodb-private/pull/18) by [@GromNaN](https://github.com/GromNaN). ## [3.9.2] - 2022-09-01 diff --git a/src/Query/Builder.php b/src/Query/Builder.php index 6321b86de..4c699a863 100644 --- a/src/Query/Builder.php +++ b/src/Query/Builder.php @@ -266,7 +266,9 @@ public function toMql(): array $aggregations = blank($this->aggregate['columns']) ? [] : $this->aggregate['columns']; if (in_array('*', $aggregations) && $function == 'count') { - return ['count' => [$wheres, []]]; + $options = $this->inheritConnectionOptions(); + + return ['countDocuments' => [$wheres, $options]]; } elseif ($function == 'count') { // Translate count into sum. $group['aggregate'] = ['$sum' => 1]; @@ -329,7 +331,7 @@ public function toMql(): array $options = $this->inheritConnectionOptions(); - return ['distinct' => [$column, $wheres ?: [], $options]]; + return ['distinct' => [$column, $wheres, $options]]; } // Normal query else { // Convert select columns to simple projections. @@ -396,7 +398,7 @@ public function getFresh($columns = [], $returnLazy = false) $this->columns = []; } - $command = $this->toMql($columns); + $command = $this->toMql(); assert(count($command) >= 1, 'At least one method call is required to execute a query'); $result = $this->collection; From 03c58eaea87a41b2e15bcca28fe0f780baad480f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Wed, 26 Jul 2023 09:14:42 +0200 Subject: [PATCH 14/19] PHPORM-67 Accept operators prefixed by $ in Query\Builder::orWhere (#20) --- CHANGELOG.md | 1 + src/Query/Builder.php | 4 ++-- tests/Query/BuilderTest.php | 13 +++++++++++++ 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0932cb357..39e9875f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ All notable changes to this project will be documented in this file. - Throw an exception when `Query\Builder::push()` is used incorrectly [#5](https://github.com/GromNaN/laravel-mongodb-private/pull/5) by [@GromNaN](https://github.com/GromNaN). - Remove public property `Query\Builder::$paginating` [#15](https://github.com/GromNaN/laravel-mongodb-private/pull/15) by [@GromNaN](https://github.com/GromNaN). - Remove call to deprecated `Collection::count` for `countDocuments` [#18](https://github.com/GromNaN/laravel-mongodb-private/pull/18) by [@GromNaN](https://github.com/GromNaN). +- Accept operators prefixed by `$` in `Query\Builder::orWhere` [#20](https://github.com/GromNaN/laravel-mongodb-private/pull/20) by [@GromNaN](https://github.com/GromNaN). ## [3.9.2] - 2022-09-01 diff --git a/src/Query/Builder.php b/src/Query/Builder.php index 4c699a863..4def94573 100644 --- a/src/Query/Builder.php +++ b/src/Query/Builder.php @@ -917,10 +917,10 @@ public function where($column, $operator = null, $value = null, $boolean = 'and' $params = func_get_args(); // Remove the leading $ from operators. - if (func_num_args() == 3) { + if (func_num_args() >= 3) { $operator = &$params[1]; - if (Str::startsWith($operator, '$')) { + if (is_string($operator) && str_starts_with($operator, '$')) { $operator = substr($operator, 1); } } diff --git a/tests/Query/BuilderTest.php b/tests/Query/BuilderTest.php index 7cd6f0584..fb5bc2032 100644 --- a/tests/Query/BuilderTest.php +++ b/tests/Query/BuilderTest.php @@ -76,6 +76,19 @@ public static function provideQueryBuilderToMql(): iterable fn (Builder $builder) => $builder->limit(10)->offset(5)->select('foo', 'bar'), ]; + yield 'where accepts $ in operators' => [ + ['find' => [ + ['$or' => [ + ['foo' => ['$type' => 2]], + ['foo' => ['$type' => 4]], + ]], + [], // options + ]], + fn (Builder $builder) => $builder + ->where('foo', '$type', 2) + ->orWhere('foo', '$type', 4), + ]; + /** @see DatabaseQueryBuilderTest::testBasicWhereNot() */ yield 'whereNot (multiple)' => [ ['find' => [ From b0b796caf4461fdaaefc96e132a9a1b704149053 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Wed, 26 Jul 2023 09:28:06 +0200 Subject: [PATCH 15/19] PHPORM-33 Add tests on Query\Builder methods (#14) - Add tests on query builder methods that don't need to be fixed. - Throw exception when calling unsupported methods: whereIntegerInRaw, orWhereIntegerInRaw, whereIntegerNotInRaw, orWhereIntegerNotInRaw - Throw an exception when Query\Builder::where is called with only a column name --- src/Query/Builder.php | 28 ++++++ tests/Query/BuilderTest.php | 178 +++++++++++++++++++++++++++++++++++- tests/TransactionTest.php | 2 +- 3 files changed, 205 insertions(+), 3 deletions(-) diff --git a/src/Query/Builder.php b/src/Query/Builder.php index 4def94573..1a0152a95 100644 --- a/src/Query/Builder.php +++ b/src/Query/Builder.php @@ -925,6 +925,10 @@ 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)); + } + return parent::where(...$params); } @@ -1378,4 +1382,28 @@ public function havingBetween($column, iterable $values, $boolean = 'and', $not { throw new \BadMethodCallException('This method is not supported by MongoDB'); } + + /** @internal This method is not supported by MongoDB. */ + public function whereIntegerInRaw($column, $values, $boolean = 'and', $not = false) + { + throw new \BadMethodCallException('This method is not supported by MongoDB'); + } + + /** @internal This method is not supported by MongoDB. */ + public function orWhereIntegerInRaw($column, $values) + { + throw new \BadMethodCallException('This method is not supported by MongoDB'); + } + + /** @internal This method is not supported by MongoDB. */ + public function whereIntegerNotInRaw($column, $values, $boolean = 'and') + { + throw new \BadMethodCallException('This method is not supported by MongoDB'); + } + + /** @internal This method is not supported by MongoDB. */ + public function orWhereIntegerNotInRaw($column, $values, $boolean = 'and') + { + throw new \BadMethodCallException('This method is not supported by MongoDB'); + } } diff --git a/tests/Query/BuilderTest.php b/tests/Query/BuilderTest.php index fb5bc2032..8f7d8f851 100644 --- a/tests/Query/BuilderTest.php +++ b/tests/Query/BuilderTest.php @@ -45,7 +45,36 @@ public static function provideQueryBuilderToMql(): iterable */ $date = new DateTimeImmutable('2016-07-12 15:30:00'); - yield 'find' => [ + yield 'select replaces previous select' => [ + ['find' => [[], ['projection' => ['bar' => 1]]]], + fn (Builder $builder) => $builder->select('foo')->select('bar'), + ]; + + yield 'select array' => [ + ['find' => [[], ['projection' => ['foo' => 1, 'bar' => 1]]]], + fn (Builder $builder) => $builder->select(['foo', 'bar']), + ]; + + /** @see DatabaseQueryBuilderTest::testAddingSelects */ + yield 'addSelect' => [ + ['find' => [[], ['projection' => ['foo' => 1, 'bar' => 1, 'baz' => 1, 'boom' => 1]]]], + fn (Builder $builder) => $builder->select('foo') + ->addSelect('bar') + ->addSelect(['baz', 'boom']) + ->addSelect('bar'), + ]; + + yield 'select all' => [ + ['find' => [[], []]], + fn (Builder $builder) => $builder->select('*'), + ]; + + yield 'find all with select' => [ + ['find' => [[], ['projection' => ['foo' => 1, 'bar' => 1]]]], + fn (Builder $builder) => $builder->select('foo', 'bar'), + ]; + + yield 'find equals' => [ ['find' => [['foo' => 'bar'], []]], fn (Builder $builder) => $builder->where('foo', 'bar'), ]; @@ -66,11 +95,55 @@ public static function provideQueryBuilderToMql(): iterable fn (Builder $builder) => $builder->where('foo', '>', $date), ]; - yield 'find in array' => [ + /** @see DatabaseQueryBuilderTest::testBasicWhereIns */ + yield 'whereIn' => [ ['find' => [['foo' => ['$in' => ['bar', 'baz']]], []]], fn (Builder $builder) => $builder->whereIn('foo', ['bar', 'baz']), ]; + // Nested array are not flattened like in the Eloquent builder. MongoDB can compare objects. + $array = [['issue' => 45582], ['id' => 2], [3]]; + yield 'whereIn nested array' => [ + ['find' => [['id' => ['$in' => $array]], []]], + fn (Builder $builder) => $builder->whereIn('id', $array), + ]; + + yield 'orWhereIn' => [ + ['find' => [ + ['$or' => [ + ['id' => 1], + ['id' => ['$in' => [1, 2, 3]]], + ]], + [], // options + ]], + fn (Builder $builder) => $builder->where('id', '=', 1) + ->orWhereIn('id', [1, 2, 3]), + ]; + + /** @see DatabaseQueryBuilderTest::testBasicWhereNotIns */ + yield 'whereNotIn' => [ + ['find' => [['id' => ['$nin' => [1, 2, 3]]], []]], + fn (Builder $builder) => $builder->whereNotIn('id', [1, 2, 3]), + ]; + + yield 'orWhereNotIn' => [ + ['find' => [ + ['$or' => [ + ['id' => 1], + ['id' => ['$nin' => [1, 2, 3]]], + ]], + [], // options + ]], + fn (Builder $builder) => $builder->where('id', '=', 1) + ->orWhereNotIn('id', [1, 2, 3]), + ]; + + /** @see DatabaseQueryBuilderTest::testEmptyWhereIns */ + yield 'whereIn empty array' => [ + ['find' => [['id' => ['$in' => []]], []]], + fn (Builder $builder) => $builder->whereIn('id', []), + ]; + 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'), @@ -266,6 +339,43 @@ public static function provideQueryBuilderToMql(): iterable fn (Builder $builder) => $builder->forPage(3, 20), ]; + /** @see DatabaseQueryBuilderTest::testLimitsAndOffsets() */ + yield 'offset limit' => [ + ['find' => [[], ['skip' => 5, 'limit' => 10]]], + fn (Builder $builder) => $builder->offset(5)->limit(10), + ]; + + yield 'offset limit zero (unset)' => [ + ['find' => [[], []]], + fn (Builder $builder) => $builder + ->offset(0)->limit(0), + ]; + + yield 'offset limit zero (reset)' => [ + ['find' => [[], []]], + fn (Builder $builder) => $builder + ->offset(5)->limit(10) + ->offset(0)->limit(0), + ]; + + yield 'offset limit negative (unset)' => [ + ['find' => [[], []]], + fn (Builder $builder) => $builder + ->offset(-5)->limit(-10), + ]; + + yield 'offset limit null (reset)' => [ + ['find' => [[], []]], + fn (Builder $builder) => $builder + ->offset(5)->limit(10) + ->offset(null)->limit(null), + ]; + + yield 'skip take (aliases)' => [ + ['find' => [[], ['skip' => 5, 'limit' => 10]]], + fn (Builder $builder) => $builder->skip(5)->limit(10), + ]; + /** @see DatabaseQueryBuilderTest::testOrderBys() */ yield 'orderBy multiple columns' => [ ['find' => [[], ['sort' => ['email' => 1, 'age' => -1]]]], @@ -452,11 +562,57 @@ function (Builder $builder) { ->orWhereNotBetween('id', collect([3, 4])), ]; + /** @see DatabaseQueryBuilderTest::testBasicSelectDistinct */ yield 'distinct' => [ ['distinct' => ['foo', [], []]], fn (Builder $builder) => $builder->distinct('foo'), ]; + yield 'select distinct' => [ + ['distinct' => ['foo', [], []]], + fn (Builder $builder) => $builder->select('foo', 'bar') + ->distinct(), + ]; + + /** @see DatabaseQueryBuilderTest::testBasicSelectDistinctOnColumns */ + yield 'select distinct on' => [ + ['distinct' => ['foo', [], []]], + fn (Builder $builder) => $builder->distinct('foo') + ->select('foo', 'bar'), + ]; + + /** @see DatabaseQueryBuilderTest::testLatest() */ + yield 'latest' => [ + ['find' => [[], ['sort' => ['created_at' => -1]]]], + fn (Builder $builder) => $builder->latest(), + ]; + + yield 'latest limit' => [ + ['find' => [[], ['sort' => ['created_at' => -1], 'limit' => 1]]], + fn (Builder $builder) => $builder->latest()->limit(1), + ]; + + yield 'latest custom field' => [ + ['find' => [[], ['sort' => ['updated_at' => -1]]]], + fn (Builder $builder) => $builder->latest('updated_at'), + ]; + + /** @see DatabaseQueryBuilderTest::testOldest() */ + yield 'oldest' => [ + ['find' => [[], ['sort' => ['created_at' => 1]]]], + fn (Builder $builder) => $builder->oldest(), + ]; + + yield 'oldest limit' => [ + ['find' => [[], ['sort' => ['created_at' => 1], 'limit' => 1]]], + fn (Builder $builder) => $builder->oldest()->limit(1), + ]; + + yield 'oldest custom field' => [ + ['find' => [[], ['sort' => ['updated_at' => 1]]]], + fn (Builder $builder) => $builder->oldest('updated_at'), + ]; + yield 'groupBy' => [ ['aggregate' => [ [['$group' => ['_id' => ['foo' => '$foo'], 'foo' => ['$last' => '$foo']]]], @@ -516,6 +672,12 @@ public static function provideExceptions(): iterable 'Between $values must be a list with exactly two elements: [min, max]', fn (Builder $builder) => $builder->whereBetween('id', ['min' => 1, 'max' => 2]), ]; + + yield 'find with single string argument' => [ + \ArgumentCountError::class, + 'Too few arguments to function Jenssegers\Mongodb\Query\Builder::where("foo"), 1 passed and at least 2 expected when the 1st is a string', + fn (Builder $builder) => $builder->where('foo'), + ]; } /** @dataProvider getEloquentMethodsNotSupported */ @@ -562,6 +724,18 @@ public static function getEloquentMethodsNotSupported() yield 'having' => [fn (Builder $builder) => $builder->having('baz', '=', 1)]; yield 'havingBetween' => [fn (Builder $builder) => $builder->havingBetween('last_login_date', ['2018-11-16', '2018-12-16'])]; yield 'orHavingRaw' => [fn (Builder $builder) => $builder->orHavingRaw('user_foo < user_bar')]; + + /** @see DatabaseQueryBuilderTest::testWhereIntegerInRaw */ + yield 'whereIntegerInRaw' => [fn (Builder $builder) => $builder->whereIntegerInRaw('id', ['1a', 2])]; + + /** @see DatabaseQueryBuilderTest::testOrWhereIntegerInRaw */ + yield 'orWhereIntegerInRaw' => [fn (Builder $builder) => $builder->orWhereIntegerInRaw('id', ['1a', 2])]; + + /** @see DatabaseQueryBuilderTest::testWhereIntegerNotInRaw */ + yield 'whereIntegerNotInRaw' => [fn (Builder $builder) => $builder->whereIntegerNotInRaw('id', ['1a', 2])]; + + /** @see DatabaseQueryBuilderTest::testOrWhereIntegerNotInRaw */ + yield 'orWhereIntegerNotInRaw' => [fn (Builder $builder) => $builder->orWhereIntegerNotInRaw('id', ['1a', 2])]; } private static function getBuilder(): Builder diff --git a/tests/TransactionTest.php b/tests/TransactionTest.php index 46fbf2e2a..06f1c2150 100644 --- a/tests/TransactionTest.php +++ b/tests/TransactionTest.php @@ -332,7 +332,7 @@ public function testTransaction(): void $count = User::count(); $this->assertEquals(2, $count); - $this->assertTrue(User::where('alcaeus')->exists()); + $this->assertTrue(User::where('name', 'alcaeus')->exists()); $this->assertTrue(User::where(['name' => 'klinson'])->where('age', 21)->exists()); } From 2824dc4d262da6bde264770f2c968f9b8b5b6d55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Wed, 26 Jul 2023 11:13:34 +0200 Subject: [PATCH 16/19] PHPORM-64 Remove Query\Builder::whereAll (#16) --- CHANGELOG.md | 1 + README.md | 11 +++++++++++ src/Query/Builder.php | 29 ----------------------------- tests/Query/BuilderTest.php | 16 ++++++++++++++++ 4 files changed, 28 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 39e9875f7..18adfc131 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ All notable changes to this project will be documented in this file. - Remove public property `Query\Builder::$paginating` [#15](https://github.com/GromNaN/laravel-mongodb-private/pull/15) by [@GromNaN](https://github.com/GromNaN). - Remove call to deprecated `Collection::count` for `countDocuments` [#18](https://github.com/GromNaN/laravel-mongodb-private/pull/18) by [@GromNaN](https://github.com/GromNaN). - Accept operators prefixed by `$` in `Query\Builder::orWhere` [#20](https://github.com/GromNaN/laravel-mongodb-private/pull/20) by [@GromNaN](https://github.com/GromNaN). +- Remove `Query\Builder::whereAll($column, $values)`. Use `Query\Builder::where($column, 'all', $values)` instead. [#16](https://github.com/GromNaN/laravel-mongodb-private/pull/16) by [@GromNaN](https://github.com/GromNaN). ## [3.9.2] - 2022-09-01 diff --git a/README.md b/README.md index 71e7768e5..f00b3a2c7 100644 --- a/README.md +++ b/README.md @@ -483,6 +483,17 @@ Car::where('weight', 300) ### MongoDB-specific operators +In addition to the Laravel Eloquent operators, all available MongoDB query operators can be used with `where`: + +```php +User::where($fieldName, $operator, $value)->get(); +``` + +It generates the following MongoDB filter: +```ts +{ $fieldName: { $operator: $value } } +``` + **Exists** Matches documents that have the specified field. diff --git a/src/Query/Builder.php b/src/Query/Builder.php index 1a0152a95..574bf8f2b 100644 --- a/src/Query/Builder.php +++ b/src/Query/Builder.php @@ -530,24 +530,6 @@ public function orderBy($column, $direction = 'asc') return $this; } - /** - * Add a "where all" clause to the query. - * - * @param string $column - * @param array $values - * @param string $boolean - * @param bool $not - * @return $this - */ - public function whereAll($column, array $values, $boolean = 'and', $not = false) - { - $type = 'all'; - - $this->wheres[] = compact('column', 'type', 'boolean', 'values', 'not'); - - return $this; - } - /** * @inheritdoc * @param list{mixed, mixed}|CarbonPeriod $values @@ -1044,17 +1026,6 @@ protected function compileWheres(): array return $compiled; } - /** - * @param array $where - * @return array - */ - protected function compileWhereAll(array $where): array - { - extract($where); - - return [$column => ['$all' => array_values($values)]]; - } - /** * @param array $where * @return array diff --git a/tests/Query/BuilderTest.php b/tests/Query/BuilderTest.php index 8f7d8f851..bc0644909 100644 --- a/tests/Query/BuilderTest.php +++ b/tests/Query/BuilderTest.php @@ -333,6 +333,22 @@ public static function provideQueryBuilderToMql(): iterable ]), ]; + yield 'where all' => [ + ['find' => [['tags' => ['$all' => ['ssl', 'security']]], []]], + fn (Builder $builder) => $builder->where('tags', 'all', ['ssl', 'security']), + ]; + + yield 'where all nested operators' => [ + ['find' => [['tags' => ['$all' => [ + ['$elemMatch' => ['size' => 'M', 'num' => ['$gt' => 50]]], + ['$elemMatch' => ['num' => 100, 'color' => 'green']], + ]]], []]], + fn (Builder $builder) => $builder->where('tags', 'all', [ + ['$elemMatch' => ['size' => 'M', 'num' => ['$gt' => 50]]], + ['$elemMatch' => ['num' => 100, 'color' => 'green']], + ]), + ]; + /** @see DatabaseQueryBuilderTest::testForPage() */ yield 'forPage' => [ ['find' => [[], ['limit' => 20, 'skip' => 40]]], From 3a46876ab4d48312f362464f7a06e9be2438264c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Wed, 26 Jul 2023 15:48:40 +0200 Subject: [PATCH 17/19] PHPORM-68 Fix unique validator when the validated value is part of an existing value (#21) --- CHANGELOG.md | 1 + src/Validation/DatabasePresenceVerifier.php | 4 +++- tests/ValidationTest.php | 6 ++++++ 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 18adfc131..9ad3e0ea6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ All notable changes to this project will be documented in this file. - Remove call to deprecated `Collection::count` for `countDocuments` [#18](https://github.com/GromNaN/laravel-mongodb-private/pull/18) by [@GromNaN](https://github.com/GromNaN). - Accept operators prefixed by `$` in `Query\Builder::orWhere` [#20](https://github.com/GromNaN/laravel-mongodb-private/pull/20) by [@GromNaN](https://github.com/GromNaN). - Remove `Query\Builder::whereAll($column, $values)`. Use `Query\Builder::where($column, 'all', $values)` instead. [#16](https://github.com/GromNaN/laravel-mongodb-private/pull/16) by [@GromNaN](https://github.com/GromNaN). +- Fix validation of unique values when the validated value is found as part of an existing value. [#21](https://github.com/GromNaN/laravel-mongodb-private/pull/21) by [@GromNaN](https://github.com/GromNaN). ## [3.9.2] - 2022-09-01 diff --git a/src/Validation/DatabasePresenceVerifier.php b/src/Validation/DatabasePresenceVerifier.php index 6c38a04b2..c563a9976 100644 --- a/src/Validation/DatabasePresenceVerifier.php +++ b/src/Validation/DatabasePresenceVerifier.php @@ -2,6 +2,8 @@ namespace Jenssegers\Mongodb\Validation; +use MongoDB\BSON\Regex; + class DatabasePresenceVerifier extends \Illuminate\Validation\DatabasePresenceVerifier { /** @@ -17,7 +19,7 @@ class DatabasePresenceVerifier extends \Illuminate\Validation\DatabasePresenceVe */ public function getCount($collection, $column, $value, $excludeId = null, $idColumn = null, array $extra = []) { - $query = $this->table($collection)->where($column, 'regex', '/'.preg_quote($value).'/i'); + $query = $this->table($collection)->where($column, new Regex('^'.preg_quote($value).'$', '/i')); if ($excludeId !== null && $excludeId != 'NULL') { $query->where($idColumn ?: 'id', '<>', $excludeId); diff --git a/tests/ValidationTest.php b/tests/ValidationTest.php index d4a2fcfdd..5a0459215 100644 --- a/tests/ValidationTest.php +++ b/tests/ValidationTest.php @@ -48,6 +48,12 @@ public function testUnique(): void ); $this->assertFalse($validator->fails()); + $validator = Validator::make( + ['name' => 'John'], // Part of an existing value + ['name' => 'required|unique:users'] + ); + $this->assertFalse($validator->fails()); + User::create(['name' => 'Johnny Cash', 'email' => 'johnny.cash+200@gmail.com']); $validator = Validator::make( From 2319d535b3deeb1b8292784bd6a0bd92b5e90f98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Thu, 27 Jul 2023 19:03:49 +0200 Subject: [PATCH 18/19] PHPORM-53 Fix and test `like` and `regex` operators (#17) - Fix support for % and _ in like expression and escaped \% and \_ - Keep ilike and regexp operators as aliases for like and regex - Allow /, # and ~ as regex delimiters - Add functional tests on regexp and not regexp - Add support for not regex --- CHANGELOG.md | 3 +- src/Query/Builder.php | 117 ++++++++++++++++++++---------------- tests/Query/BuilderTest.php | 81 ++++++++++++++++++++++++- tests/QueryTest.php | 21 +++++++ 4 files changed, 167 insertions(+), 55 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ad3e0ea6..30413ef3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ All notable changes to this project will be documented in this file. ## [Unreleased] -- Add classes to cast ObjectId and UUID instances [#1](https://github.com/GromNaN/laravel-mongodb-private/pull/1) by [@alcaeus](https://github.com/alcaeus). +- Add classes to cast `ObjectId` and `UUID` instances [#1](https://github.com/GromNaN/laravel-mongodb-private/pull/1) by [@alcaeus](https://github.com/alcaeus). - Add `Query\Builder::toMql()` to simplify comprehensive query tests [#6](https://github.com/GromNaN/laravel-mongodb-private/pull/6) by [@GromNaN](https://github.com/GromNaN). - Fix `Query\Builder::whereNot` to use MongoDB [`$not`](https://www.mongodb.com/docs/manual/reference/operator/query/not/) operator [#13](https://github.com/GromNaN/laravel-mongodb-private/pull/13) by [@GromNaN](https://github.com/GromNaN). - Fix `Query\Builder::whereBetween` to accept `Carbon\Period` object [#10](https://github.com/GromNaN/laravel-mongodb-private/pull/10) by [@GromNaN](https://github.com/GromNaN). @@ -15,6 +15,7 @@ All notable changes to this project will be documented in this file. - Accept operators prefixed by `$` in `Query\Builder::orWhere` [#20](https://github.com/GromNaN/laravel-mongodb-private/pull/20) by [@GromNaN](https://github.com/GromNaN). - Remove `Query\Builder::whereAll($column, $values)`. Use `Query\Builder::where($column, 'all', $values)` instead. [#16](https://github.com/GromNaN/laravel-mongodb-private/pull/16) by [@GromNaN](https://github.com/GromNaN). - Fix validation of unique values when the validated value is found as part of an existing value. [#21](https://github.com/GromNaN/laravel-mongodb-private/pull/21) by [@GromNaN](https://github.com/GromNaN). +- Support `%` and `_` in `like` expression [#17](https://github.com/GromNaN/laravel-mongodb-private/pull/17) by [@GromNaN](https://github.com/GromNaN). ## [3.9.2] - 2022-09-01 diff --git a/src/Query/Builder.php b/src/Query/Builder.php index 574bf8f2b..dd448ed01 100644 --- a/src/Query/Builder.php +++ b/src/Query/Builder.php @@ -24,6 +24,8 @@ */ class Builder extends BaseBuilder { + private const REGEX_DELIMITERS = ['/', '#', '~']; + /** * The database collection. * @@ -91,6 +93,7 @@ class Builder extends BaseBuilder 'all', 'size', 'regex', + 'not regex', 'text', 'slice', 'elemmatch', @@ -113,13 +116,22 @@ class Builder extends BaseBuilder * @var array */ protected $conversion = [ - '=' => '=', - '!=' => '$ne', - '<>' => '$ne', - '<' => '$lt', - '<=' => '$lte', - '>' => '$gt', - '>=' => '$gte', + '!=' => 'ne', + '<>' => 'ne', + '<' => 'lt', + '<=' => 'lte', + '>' => 'gt', + '>=' => 'gte', + 'regexp' => 'regex', + 'not regexp' => 'not regex', + 'ilike' => 'like', + 'elemmatch' => 'elemMatch', + 'geointersects' => 'geoIntersects', + 'geowithin' => 'geoWithin', + 'nearsphere' => 'nearSphere', + 'maxdistance' => 'maxDistance', + 'centersphere' => 'centerSphere', + 'uniquedocs' => 'uniqueDocs', ]; /** @@ -932,20 +944,9 @@ protected function compileWheres(): array if (isset($where['operator'])) { $where['operator'] = strtolower($where['operator']); - // Operator conversions - $convert = [ - 'regexp' => 'regex', - 'elemmatch' => 'elemMatch', - 'geointersects' => 'geoIntersects', - 'geowithin' => 'geoWithin', - 'nearsphere' => 'nearSphere', - 'maxdistance' => 'maxDistance', - 'centersphere' => 'centerSphere', - 'uniquedocs' => 'uniqueDocs', - ]; - - if (array_key_exists($where['operator'], $convert)) { - $where['operator'] = $convert[$where['operator']]; + // Convert aliased operators + if (isset($this->conversion[$where['operator']])) { + $where['operator'] = $this->conversion[$where['operator']]; } } @@ -1036,45 +1037,55 @@ protected function compileWhereBasic(array $where): array // Replace like or not like with a Regex instance. if (in_array($operator, ['like', 'not like'])) { - if ($operator === 'not like') { - $operator = 'not'; - } else { - $operator = '='; - } - - // Convert to regular expression. - $regex = preg_replace('#(^|[^\\\])%#', '$1.*', preg_quote($value)); - - // Convert like to regular expression. - if (! Str::startsWith($value, '%')) { - $regex = '^'.$regex; - } - if (! Str::endsWith($value, '%')) { - $regex .= '$'; - } + $regex = preg_replace( + [ + // Unescaped % are converted to .* + // Group consecutive % + '#(^|[^\\\])%+#', + // Unescaped _ are converted to . + // Use positive lookahead to replace consecutive _ + '#(?<=^|[^\\\\])_#', + // Escaped \% or \_ are unescaped + '#\\\\\\\(%|_)#', + ], + ['$1.*', '$1.', '$1'], + // Escape any regex reserved characters, so they are matched + // All backslashes are converted to \\, which are needed in matching regexes. + preg_quote($value), + ); + $value = new Regex('^'.$regex.'$', 'i'); + + // For inverse like operations, we can just use the $not operator with the Regex + $operator = $operator === 'like' ? '=' : 'not'; + } - $value = new Regex($regex, 'i'); - } // Manipulate regexp operations. - elseif (in_array($operator, ['regexp', 'not regexp', 'regex', 'not regex'])) { + // Manipulate regex operations. + elseif (in_array($operator, ['regex', 'not regex'])) { // Automatically convert regular expression strings to Regex objects. - if (! $value instanceof Regex) { - $e = explode('/', $value); - $flag = end($e); - $regstr = substr($value, 1, -(strlen($flag) + 1)); - $value = new Regex($regstr, $flag); + if (is_string($value)) { + // Detect the delimiter and validate the preg pattern + $delimiter = substr($value, 0, 1); + if (! in_array($delimiter, self::REGEX_DELIMITERS)) { + throw new \LogicException(sprintf('Missing expected starting delimiter in regular expression "%s", supported delimiters are: %s', $value, implode(' ', self::REGEX_DELIMITERS))); + } + $e = explode($delimiter, $value); + // We don't try to detect if the last delimiter is escaped. This would be an invalid regex. + if (count($e) < 3) { + throw new \LogicException(sprintf('Missing expected ending delimiter "%s" in regular expression "%s"', $delimiter, $value)); + } + // Flags are after the last delimiter + $flags = end($e); + // Extract the regex string between the delimiters + $regstr = substr($value, 1, -1 - strlen($flags)); + $value = new Regex($regstr, $flags); } - // For inverse regexp operations, we can just use the $not operator - // and pass it a Regex instence. - if (Str::startsWith($operator, 'not')) { - $operator = 'not'; - } + // For inverse regex operations, we can just use the $not operator with the Regex + $operator = $operator === 'regex' ? '=' : 'not'; } if (! isset($operator) || $operator == '=') { $query = [$column => $value]; - } elseif (array_key_exists($operator, $this->conversion)) { - $query = [$column => [$this->conversion[$operator] => $value]]; } else { $query = [$column => ['$'.$operator => $value]]; } @@ -1133,7 +1144,7 @@ protected function compileWhereNull(array $where): array */ protected function compileWhereNotNull(array $where): array { - $where['operator'] = '!='; + $where['operator'] = 'ne'; $where['value'] = null; return $this->compileWhereBasic($where); diff --git a/tests/Query/BuilderTest.php b/tests/Query/BuilderTest.php index bc0644909..f34642274 100644 --- a/tests/Query/BuilderTest.php +++ b/tests/Query/BuilderTest.php @@ -11,6 +11,7 @@ use Jenssegers\Mongodb\Query\Builder; use Jenssegers\Mongodb\Query\Processor; use Mockery as m; +use MongoDB\BSON\Regex; use MongoDB\BSON\UTCDateTime; use PHPUnit\Framework\TestCase; @@ -578,6 +579,72 @@ function (Builder $builder) { ->orWhereNotBetween('id', collect([3, 4])), ]; + yield 'where like' => [ + ['find' => [['name' => new Regex('^acme$', 'i')], []]], + fn (Builder $builder) => $builder->where('name', 'like', 'acme'), + ]; + + yield 'where ilike' => [ // Alias for like + ['find' => [['name' => new Regex('^acme$', 'i')], []]], + fn (Builder $builder) => $builder->where('name', 'ilike', 'acme'), + ]; + + yield 'where like escape' => [ + ['find' => [['name' => new Regex('^\^ac\.me\$$', 'i')], []]], + fn (Builder $builder) => $builder->where('name', 'like', '^ac.me$'), + ]; + + yield 'where like unescaped \% \_' => [ + ['find' => [['name' => new Regex('^a%cm_e$', 'i')], []]], + fn (Builder $builder) => $builder->where('name', 'like', 'a\%cm\_e'), + ]; + + yield 'where like %' => [ + ['find' => [['name' => new Regex('^.*ac.*me.*$', 'i')], []]], + fn (Builder $builder) => $builder->where('name', 'like', '%ac%%me%'), + ]; + + yield 'where like _' => [ + ['find' => [['name' => new Regex('^.ac..me.$', 'i')], []]], + fn (Builder $builder) => $builder->where('name', 'like', '_ac__me_'), + ]; + + $regex = new Regex('^acme$', 'si'); + yield 'where BSON\Regex' => [ + ['find' => [['name' => $regex], []]], + fn (Builder $builder) => $builder->where('name', 'regex', $regex), + ]; + + yield 'where regexp' => [ // Alias for regex + ['find' => [['name' => $regex], []]], + fn (Builder $builder) => $builder->where('name', 'regex', '/^acme$/si'), + ]; + + yield 'where regex delimiter /' => [ + ['find' => [['name' => $regex], []]], + fn (Builder $builder) => $builder->where('name', 'regex', '/^acme$/si'), + ]; + + yield 'where regex delimiter #' => [ + ['find' => [['name' => $regex], []]], + fn (Builder $builder) => $builder->where('name', 'regex', '#^acme$#si'), + ]; + + yield 'where regex delimiter ~' => [ + ['find' => [['name' => $regex], []]], + fn (Builder $builder) => $builder->where('name', 'regex', '#^acme$#si'), + ]; + + yield 'where regex with escaped characters' => [ + ['find' => [['name' => new Regex('a\.c\/m\+e', '')], []]], + fn (Builder $builder) => $builder->where('name', 'regex', '/a\.c\/m\+e/'), + ]; + + yield 'where not regex' => [ + ['find' => [['name' => ['$not' => $regex]], []]], + fn (Builder $builder) => $builder->where('name', 'not regex', '/^acme$/si'), + ]; + /** @see DatabaseQueryBuilderTest::testBasicSelectDistinct */ yield 'distinct' => [ ['distinct' => ['foo', [], []]], @@ -647,7 +714,7 @@ public function testException($class, $message, \Closure $build): void $this->expectException($class); $this->expectExceptionMessage($message); - $build($builder); + $build($builder)->toMQL(); } public static function provideExceptions(): iterable @@ -694,6 +761,18 @@ public static function provideExceptions(): iterable 'Too few arguments to function Jenssegers\Mongodb\Query\Builder::where("foo"), 1 passed and at least 2 expected when the 1st is a string', fn (Builder $builder) => $builder->where('foo'), ]; + + yield 'where regex not starting with /' => [ + \LogicException::class, + 'Missing expected starting delimiter in regular expression "^ac/me$", supported delimiters are: / # ~', + fn (Builder $builder) => $builder->where('name', 'regex', '^ac/me$'), + ]; + + yield 'where regex not ending with /' => [ + \LogicException::class, + 'Missing expected ending delimiter "/" in regular expression "/foo#bar"', + fn (Builder $builder) => $builder->where('name', 'regex', '/foo#bar'), + ]; } /** @dataProvider getEloquentMethodsNotSupported */ diff --git a/tests/QueryTest.php b/tests/QueryTest.php index 4179748d0..754f204dc 100644 --- a/tests/QueryTest.php +++ b/tests/QueryTest.php @@ -70,6 +70,21 @@ public function testAndWhere(): void $this->assertCount(2, $users); } + public function testRegexp(): void + { + User::create(['name' => 'Simple', 'company' => 'acme']); + User::create(['name' => 'With slash', 'company' => 'oth/er']); + + $users = User::where('company', 'regexp', '/^acme$/')->get(); + $this->assertCount(1, $users); + + $users = User::where('company', 'regexp', '/^ACME$/i')->get(); + $this->assertCount(1, $users); + + $users = User::where('company', 'regexp', '/^oth\/er$/')->get(); + $this->assertCount(1, $users); + } + public function testLike(): void { $users = User::where('name', 'like', '%doe')->get(); @@ -83,6 +98,12 @@ public function testLike(): void $users = User::where('name', 'like', 't%')->get(); $this->assertCount(1, $users); + + $users = User::where('name', 'like', 'j___ doe')->get(); + $this->assertCount(2, $users); + + $users = User::where('name', 'like', '_oh_ _o_')->get(); + $this->assertCount(1, $users); } public function testNotLike(): void From 9f1f8f3105ad7931d20f9417f29a24f7e9ce3693 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Wed, 2 Aug 2023 16:03:05 +0200 Subject: [PATCH 19/19] PHPORM-35 Add various tests on Model `_id` types (#22) * PHPORM-35 Add various tests on Model _id * Add assertion on expected value * Test _id as array and object * Remove tests for arrays and objects as identifiers when keyType is string --------- Co-authored-by: Andreas Braun --- tests/ModelTest.php | 104 ++++++++++++++++++++++++++++++-- tests/Models/IdIsBinaryUuid.php | 17 ++++++ tests/Models/IdIsInt.php | 17 ++++++ tests/Models/IdIsString.php | 16 +++++ 4 files changed, 150 insertions(+), 4 deletions(-) create mode 100644 tests/Models/IdIsBinaryUuid.php create mode 100644 tests/Models/IdIsInt.php create mode 100644 tests/Models/IdIsString.php diff --git a/tests/ModelTest.php b/tests/ModelTest.php index 21523c7f4..1042a07bc 100644 --- a/tests/ModelTest.php +++ b/tests/ModelTest.php @@ -16,10 +16,14 @@ use Jenssegers\Mongodb\Eloquent\Model; use Jenssegers\Mongodb\Tests\Models\Book; use Jenssegers\Mongodb\Tests\Models\Guarded; +use Jenssegers\Mongodb\Tests\Models\IdIsBinaryUuid; +use Jenssegers\Mongodb\Tests\Models\IdIsInt; +use Jenssegers\Mongodb\Tests\Models\IdIsString; use Jenssegers\Mongodb\Tests\Models\Item; use Jenssegers\Mongodb\Tests\Models\MemberStatus; use Jenssegers\Mongodb\Tests\Models\Soft; use Jenssegers\Mongodb\Tests\Models\User; +use MongoDB\BSON\Binary; use MongoDB\BSON\ObjectID; use MongoDB\BSON\UTCDateTime; @@ -325,11 +329,103 @@ public function testSoftDelete(): void $this->assertEquals(2, Soft::count()); } - public function testPrimaryKey(): void + /** + * @dataProvider provideId + */ + public function testPrimaryKey(string $model, $id, $expected, bool $expectedFound): void + { + $model::truncate(); + $expectedType = get_debug_type($expected); + + $document = new $model; + $this->assertEquals('_id', $document->getKeyName()); + + $document->_id = $id; + $document->save(); + $this->assertSame($expectedType, get_debug_type($document->_id)); + $this->assertEquals($expected, $document->_id); + $this->assertSame($expectedType, get_debug_type($document->getKey())); + $this->assertEquals($expected, $document->getKey()); + + $check = $model::find($id); + + if ($expectedFound) { + $this->assertNotNull($check, 'Not found'); + $this->assertSame($expectedType, get_debug_type($check->_id)); + $this->assertEquals($id, $check->_id); + $this->assertSame($expectedType, get_debug_type($check->getKey())); + $this->assertEquals($id, $check->getKey()); + } else { + $this->assertNull($check, 'Found'); + } + } + + public static function provideId(): iterable + { + yield 'int' => [ + 'model' => User::class, + 'id' => 10, + 'expected' => 10, + // Don't expect this to be found, as the int is cast to string for the query + 'expectedFound' => false, + ]; + + yield 'cast as int' => [ + 'model' => IdIsInt::class, + 'id' => 10, + 'expected' => 10, + 'expectedFound' => true, + ]; + + yield 'string' => [ + 'model' => User::class, + 'id' => 'user-10', + 'expected' => 'user-10', + 'expectedFound' => true, + ]; + + yield 'cast as string' => [ + 'model' => IdIsString::class, + 'id' => 'user-10', + 'expected' => 'user-10', + 'expectedFound' => true, + ]; + + $objectId = new ObjectID(); + yield 'ObjectID' => [ + 'model' => User::class, + 'id' => $objectId, + 'expected' => (string) $objectId, + 'expectedFound' => true, + ]; + + $binaryUuid = new Binary(hex2bin('0c103357380648c9a84b867dcb625cfb'), Binary::TYPE_UUID); + yield 'BinaryUuid' => [ + 'model' => User::class, + 'id' => $binaryUuid, + 'expected' => (string) $binaryUuid, + 'expectedFound' => true, + ]; + + yield 'cast as BinaryUuid' => [ + 'model' => IdIsBinaryUuid::class, + 'id' => $binaryUuid, + 'expected' => (string) $binaryUuid, + 'expectedFound' => true, + ]; + + $date = new UTCDateTime(); + yield 'UTCDateTime' => [ + 'model' => User::class, + 'id' => $date, + 'expected' => $date, + // Don't expect this to be found, as the original value is stored as UTCDateTime but then cast to string + 'expectedFound' => false, + ]; + } + + public function testCustomPrimaryKey(): void { - $user = new User; - $this->assertEquals('_id', $user->getKeyName()); - $book = new Book; $this->assertEquals('title', $book->getKeyName()); diff --git a/tests/Models/IdIsBinaryUuid.php b/tests/Models/IdIsBinaryUuid.php new file mode 100644 index 000000000..1d8c59259 --- /dev/null +++ b/tests/Models/IdIsBinaryUuid.php @@ -0,0 +1,17 @@ + BinaryUuid::class, + ]; +} diff --git a/tests/Models/IdIsInt.php b/tests/Models/IdIsInt.php new file mode 100644 index 000000000..d721320c9 --- /dev/null +++ b/tests/Models/IdIsInt.php @@ -0,0 +1,17 @@ + 'int', + ]; +} diff --git a/tests/Models/IdIsString.php b/tests/Models/IdIsString.php new file mode 100644 index 000000000..48a284551 --- /dev/null +++ b/tests/Models/IdIsString.php @@ -0,0 +1,16 @@ + 'string', + ]; +}