├── LICENSE ├── composer.json ├── rector.php ├── sbom.json └── src ├── Auth └── User.php ├── Bus └── MongoBatchRepository.php ├── Cache ├── MongoLock.php └── MongoStore.php ├── CommandSubscriber.php ├── Concerns └── ManagesTransactions.php ├── Connection.php ├── Eloquent ├── Builder.php ├── Casts │ ├── BinaryUuid.php │ └── ObjectId.php ├── DocumentModel.php ├── EmbedsRelations.php ├── HasSchemaVersion.php ├── HybridRelations.php ├── MassPrunable.php ├── Model.php └── SoftDeletes.php ├── Helpers ├── EloquentBuilder.php └── QueriesRelationships.php ├── MongoDBBusServiceProvider.php ├── MongoDBServiceProvider.php ├── Query ├── AggregationBuilder.php ├── Builder.php ├── Grammar.php └── Processor.php ├── Queue ├── MongoConnector.php ├── MongoJob.php └── MongoQueue.php ├── Relations ├── BelongsTo.php ├── BelongsToMany.php ├── EmbedsMany.php ├── EmbedsOne.php ├── EmbedsOneOrMany.php ├── HasMany.php ├── HasOne.php ├── MorphMany.php ├── MorphTo.php └── MorphToMany.php ├── Schema ├── Blueprint.php ├── BlueprintLaravelCompatibility.php ├── Builder.php └── Grammar.php ├── Scout └── ScoutEngine.php ├── Session └── MongoDbSessionHandler.php └── Validation ├── DatabasePresenceVerifier.php └── ValidationServiceProvider.php /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 MongoDB, Inc 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mongodb/laravel-mongodb", 3 | "description": "A MongoDB based Eloquent model and Query builder for Laravel", 4 | "keywords": [ 5 | "laravel", 6 | "eloquent", 7 | "mongodb", 8 | "mongo", 9 | "database", 10 | "model" 11 | ], 12 | "homepage": "https://github.com/mongodb/laravel-mongodb", 13 | "support": { 14 | "issues": "https://www.mongodb.com/support", 15 | "security": "https://www.mongodb.com/security" 16 | }, 17 | "authors": [ 18 | { "name": "Andreas Braun", "email": "andreas.braun@mongodb.com", "role": "Leader" }, 19 | { "name": "Pauline Vos", "email": "pauline.vos@mongodb.com", "role": "Maintainer" }, 20 | { "name": "Jérôme Tamarelle", "email": "jerome.tamarelle@mongodb.com", "role": "Maintainer" }, 21 | { "name": "Jeremy Mikola", "email": "jmikola@gmail.com", "role": "Maintainer" }, 22 | { "name": "Jens Segers", "homepage": "https://jenssegers.com", "role": "Creator" } 23 | ], 24 | "license": "MIT", 25 | "require": { 26 | "php": "^8.1", 27 | "ext-mongodb": "^1.21|^2", 28 | "composer-runtime-api": "^2.0.0", 29 | "illuminate/cache": "^10.36|^11|^12", 30 | "illuminate/container": "^10.0|^11|^12", 31 | "illuminate/database": "^10.30|^11|^12", 32 | "illuminate/events": "^10.0|^11|^12", 33 | "illuminate/support": "^10.0|^11|^12", 34 | "mongodb/mongodb": "^1.21|^2", 35 | "symfony/http-foundation": "^6.4|^7" 36 | }, 37 | "require-dev": { 38 | "laravel/scout": "^10.3", 39 | "league/flysystem-gridfs": "^3.28", 40 | "league/flysystem-read-only": "^3.0", 41 | "phpunit/phpunit": "^10.3|^11.5.3", 42 | "orchestra/testbench": "^8.0|^9.0|^10.0", 43 | "mockery/mockery": "^1.4.4", 44 | "doctrine/coding-standard": "12.0.x-dev", 45 | "spatie/laravel-query-builder": "^5.6|^6", 46 | "phpstan/phpstan": "^1.10", 47 | "rector/rector": "^1.2" 48 | }, 49 | "conflict": { 50 | "illuminate/bus": "< 10.37.2" 51 | }, 52 | "suggest": { 53 | "league/flysystem-gridfs": "Filesystem storage in MongoDB with GridFS" 54 | }, 55 | "minimum-stability": "dev", 56 | "prefer-stable": true, 57 | "replace": { 58 | "jenssegers/mongodb": "self.version" 59 | }, 60 | "autoload": { 61 | "psr-4": { 62 | "MongoDB\\Laravel\\": "src/" 63 | } 64 | }, 65 | "autoload-dev": { 66 | "psr-4": { 67 | "MongoDB\\Laravel\\Tests\\": "tests/" 68 | } 69 | }, 70 | "extra": { 71 | "laravel": { 72 | "providers": [ 73 | "MongoDB\\Laravel\\MongoDBServiceProvider", 74 | "MongoDB\\Laravel\\MongoDBBusServiceProvider" 75 | ] 76 | } 77 | }, 78 | "scripts": { 79 | "test": "phpunit", 80 | "test:coverage": "phpunit --coverage-clover ./coverage.xml", 81 | "cs": "phpcs", 82 | "cs:fix": "phpcbf", 83 | "rector": "rector" 84 | }, 85 | "config": { 86 | "allow-plugins": { 87 | "dealerdirect/phpcodesniffer-composer-installer": true 88 | } 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /rector.php: -------------------------------------------------------------------------------- 1 | withPaths([ 12 | __FILE__, 13 | __DIR__ . '/docs', 14 | __DIR__ . '/src', 15 | __DIR__ . '/tests', 16 | ]) 17 | ->withPhpSets() 18 | ->withTypeCoverageLevel(0) 19 | ->withSkip([ 20 | RemoveExtraParametersRector::class, 21 | ClosureToArrowFunctionRector::class, 22 | NullToStrictStringFuncCallArgRector::class, 23 | MixedTypeRector::class, 24 | AddClosureVoidReturnTypeWhereNoReturnRector::class, 25 | ]); 26 | -------------------------------------------------------------------------------- /sbom.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json", 3 | "bomFormat": "CycloneDX", 4 | "specVersion": "1.5", 5 | "serialNumber": "urn:uuid:0b622e40-f57d-4c6f-9f63-db415c1a1271", 6 | "version": 1, 7 | "metadata": { 8 | "timestamp": "2024-05-08T09:52:55Z", 9 | "tools": [ 10 | { 11 | "name": "composer", 12 | "version": "2.7.6" 13 | }, 14 | { 15 | "vendor": "cyclonedx", 16 | "name": "cyclonedx-php-composer", 17 | "version": "v5.2.0", 18 | "externalReferences": [ 19 | { 20 | "type": "distribution", 21 | "url": "https://api.github.com/repos/CycloneDX/cyclonedx-php-composer/zipball/f3a3cdc1a9e34bf1d5748e4279a24569cbf31fed", 22 | "comment": "dist reference: f3a3cdc1a9e34bf1d5748e4279a24569cbf31fed" 23 | }, 24 | { 25 | "type": "vcs", 26 | "url": "https://github.com/CycloneDX/cyclonedx-php-composer.git", 27 | "comment": "source reference: f3a3cdc1a9e34bf1d5748e4279a24569cbf31fed" 28 | }, 29 | { 30 | "type": "website", 31 | "url": "https://github.com/CycloneDX/cyclonedx-php-composer/#readme", 32 | "comment": "as detected from Composer manifest 'homepage'" 33 | }, 34 | { 35 | "type": "issue-tracker", 36 | "url": "https://github.com/CycloneDX/cyclonedx-php-composer/issues", 37 | "comment": "as detected from Composer manifest 'support.issues'" 38 | }, 39 | { 40 | "type": "vcs", 41 | "url": "https://github.com/CycloneDX/cyclonedx-php-composer/", 42 | "comment": "as detected from Composer manifest 'support.source'" 43 | } 44 | ] 45 | }, 46 | { 47 | "vendor": "cyclonedx", 48 | "name": "cyclonedx-library", 49 | "version": "3.x-dev cad0f92", 50 | "externalReferences": [ 51 | { 52 | "type": "distribution", 53 | "url": "https://api.github.com/repos/CycloneDX/cyclonedx-php-library/zipball/cad0f92b36c85f36b3d3c11ff96002af5f20cd10", 54 | "comment": "dist reference: cad0f92b36c85f36b3d3c11ff96002af5f20cd10" 55 | }, 56 | { 57 | "type": "vcs", 58 | "url": "https://github.com/CycloneDX/cyclonedx-php-library.git", 59 | "comment": "source reference: cad0f92b36c85f36b3d3c11ff96002af5f20cd10" 60 | }, 61 | { 62 | "type": "website", 63 | "url": "https://github.com/CycloneDX/cyclonedx-php-library/#readme", 64 | "comment": "as detected from Composer manifest 'homepage'" 65 | }, 66 | { 67 | "type": "documentation", 68 | "url": "https://cyclonedx-php-library.readthedocs.io", 69 | "comment": "as detected from Composer manifest 'support.docs'" 70 | }, 71 | { 72 | "type": "issue-tracker", 73 | "url": "https://github.com/CycloneDX/cyclonedx-php-library/issues", 74 | "comment": "as detected from Composer manifest 'support.issues'" 75 | }, 76 | { 77 | "type": "vcs", 78 | "url": "https://github.com/CycloneDX/cyclonedx-php-library/", 79 | "comment": "as detected from Composer manifest 'support.source'" 80 | } 81 | ] 82 | } 83 | ] 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/Auth/User.php: -------------------------------------------------------------------------------- 1 | collection = $connection->getCollection($collection); 39 | 40 | parent::__construct($factory, $connection, $collection); 41 | } 42 | 43 | #[Override] 44 | public function get($limit = 50, $before = null): array 45 | { 46 | if (is_string($before)) { 47 | $before = new ObjectId($before); 48 | } 49 | 50 | return $this->collection->find( 51 | $before ? ['_id' => ['$lt' => $before]] : [], 52 | [ 53 | 'limit' => $limit, 54 | 'sort' => ['_id' => -1], 55 | 'typeMap' => ['root' => 'array', 'document' => 'array', 'array' => 'array'], 56 | ], 57 | )->toArray(); 58 | } 59 | 60 | #[Override] 61 | public function find(string $batchId): ?Batch 62 | { 63 | $batchId = new ObjectId($batchId); 64 | 65 | $batch = $this->collection->findOne( 66 | ['_id' => $batchId], 67 | [ 68 | // If the select query is executed faster than the database replication takes place, 69 | // then no batch is found. In that case an exception is thrown because jobs are added 70 | // to a null batch. 71 | 'readPreference' => new ReadPreference(ReadPreference::PRIMARY), 72 | 'typeMap' => ['root' => 'array', 'array' => 'array', 'document' => 'array'], 73 | ], 74 | ); 75 | 76 | return $batch ? $this->toBatch($batch) : null; 77 | } 78 | 79 | #[Override] 80 | public function store(PendingBatch $batch): Batch 81 | { 82 | $batch = [ 83 | 'name' => $batch->name, 84 | 'total_jobs' => 0, 85 | 'pending_jobs' => 0, 86 | 'failed_jobs' => 0, 87 | 'failed_job_ids' => [], 88 | // Serialization is required for Closures 89 | 'options' => serialize($batch->options), 90 | 'created_at' => $this->getUTCDateTime(), 91 | 'cancelled_at' => null, 92 | 'finished_at' => null, 93 | ]; 94 | $result = $this->collection->insertOne($batch); 95 | 96 | return $this->toBatch(['_id' => $result->getInsertedId()] + $batch); 97 | } 98 | 99 | #[Override] 100 | public function incrementTotalJobs(string $batchId, int $amount): void 101 | { 102 | $batchId = new ObjectId($batchId); 103 | $this->collection->updateOne( 104 | ['_id' => $batchId], 105 | [ 106 | '$inc' => [ 107 | 'total_jobs' => $amount, 108 | 'pending_jobs' => $amount, 109 | ], 110 | '$set' => [ 111 | 'finished_at' => null, 112 | ], 113 | ], 114 | ); 115 | } 116 | 117 | #[Override] 118 | public function decrementPendingJobs(string $batchId, string $jobId): UpdatedBatchJobCounts 119 | { 120 | $batchId = new ObjectId($batchId); 121 | $values = $this->collection->findOneAndUpdate( 122 | ['_id' => $batchId], 123 | [ 124 | '$inc' => ['pending_jobs' => -1], 125 | '$pull' => ['failed_job_ids' => $jobId], 126 | ], 127 | [ 128 | 'projection' => ['pending_jobs' => 1, 'failed_jobs' => 1], 129 | 'returnDocument' => FindOneAndUpdate::RETURN_DOCUMENT_AFTER, 130 | ], 131 | ); 132 | 133 | return new UpdatedBatchJobCounts( 134 | $values['pending_jobs'], 135 | $values['failed_jobs'], 136 | ); 137 | } 138 | 139 | #[Override] 140 | public function incrementFailedJobs(string $batchId, string $jobId): UpdatedBatchJobCounts 141 | { 142 | $batchId = new ObjectId($batchId); 143 | $values = $this->collection->findOneAndUpdate( 144 | ['_id' => $batchId], 145 | [ 146 | '$inc' => ['failed_jobs' => 1], 147 | '$push' => ['failed_job_ids' => $jobId], 148 | ], 149 | [ 150 | 'projection' => ['pending_jobs' => 1, 'failed_jobs' => 1], 151 | 'returnDocument' => FindOneAndUpdate::RETURN_DOCUMENT_AFTER, 152 | ], 153 | ); 154 | 155 | return new UpdatedBatchJobCounts( 156 | $values['pending_jobs'], 157 | $values['failed_jobs'], 158 | ); 159 | } 160 | 161 | #[Override] 162 | public function markAsFinished(string $batchId): void 163 | { 164 | $batchId = new ObjectId($batchId); 165 | $this->collection->updateOne( 166 | ['_id' => $batchId], 167 | ['$set' => ['finished_at' => $this->getUTCDateTime()]], 168 | ); 169 | } 170 | 171 | #[Override] 172 | public function cancel(string $batchId): void 173 | { 174 | $batchId = new ObjectId($batchId); 175 | $this->collection->updateOne( 176 | ['_id' => $batchId], 177 | [ 178 | '$set' => [ 179 | 'cancelled_at' => $this->getUTCDateTime(), 180 | 'finished_at' => $this->getUTCDateTime(), 181 | ], 182 | ], 183 | ); 184 | } 185 | 186 | #[Override] 187 | public function delete(string $batchId): void 188 | { 189 | $batchId = new ObjectId($batchId); 190 | $this->collection->deleteOne(['_id' => $batchId]); 191 | } 192 | 193 | /** Execute the given Closure within a storage specific transaction. */ 194 | #[Override] 195 | public function transaction(Closure $callback): mixed 196 | { 197 | return $this->connection->transaction($callback); 198 | } 199 | 200 | /** Rollback the last database transaction for the connection. */ 201 | #[Override] 202 | public function rollBack(): void 203 | { 204 | $this->connection->rollBack(); 205 | } 206 | 207 | /** Prune the entries older than the given date. */ 208 | #[Override] 209 | public function prune(DateTimeInterface $before): int 210 | { 211 | $result = $this->collection->deleteMany( 212 | ['finished_at' => ['$ne' => null, '$lt' => new UTCDateTime($before)]], 213 | ); 214 | 215 | return $result->getDeletedCount(); 216 | } 217 | 218 | /** Prune all the unfinished entries older than the given date. */ 219 | public function pruneUnfinished(DateTimeInterface $before): int 220 | { 221 | $result = $this->collection->deleteMany( 222 | [ 223 | 'finished_at' => null, 224 | 'created_at' => ['$lt' => new UTCDateTime($before)], 225 | ], 226 | ); 227 | 228 | return $result->getDeletedCount(); 229 | } 230 | 231 | /** Prune all the cancelled entries older than the given date. */ 232 | public function pruneCancelled(DateTimeInterface $before): int 233 | { 234 | $result = $this->collection->deleteMany( 235 | [ 236 | 'cancelled_at' => ['$ne' => null], 237 | 'created_at' => ['$lt' => new UTCDateTime($before)], 238 | ], 239 | ); 240 | 241 | return $result->getDeletedCount(); 242 | } 243 | 244 | /** @param array $batch */ 245 | #[Override] 246 | protected function toBatch($batch): Batch 247 | { 248 | return $this->factory->make( 249 | $this, 250 | $batch['_id'], 251 | $batch['name'], 252 | $batch['total_jobs'], 253 | $batch['pending_jobs'], 254 | $batch['failed_jobs'], 255 | $batch['failed_job_ids'], 256 | unserialize($batch['options']), 257 | $this->toCarbon($batch['created_at']), 258 | $this->toCarbon($batch['cancelled_at']), 259 | $this->toCarbon($batch['finished_at']), 260 | ); 261 | } 262 | 263 | private function getUTCDateTime(): UTCDateTime 264 | { 265 | // Using Carbon so the current time can be modified for tests 266 | return new UTCDateTime(Carbon::now()); 267 | } 268 | 269 | /** @return ($date is null ? null : CarbonImmutable) */ 270 | private function toCarbon(?UTCDateTime $date): ?CarbonImmutable 271 | { 272 | if ($date === null) { 273 | return null; 274 | } 275 | 276 | return CarbonImmutable::createFromTimestampMsUTC((string) $date); 277 | } 278 | } 279 | -------------------------------------------------------------------------------- /src/Cache/MongoLock.php: -------------------------------------------------------------------------------- 1 | lottery[0] ?? null) || ! is_numeric($this->lottery[1] ?? null) || $this->lottery[0] > $this->lottery[1]) { 35 | throw new InvalidArgumentException('Lock lottery must be a couple of integers [$chance, $total] where $chance <= $total. Example [2, 100]'); 36 | } 37 | 38 | parent::__construct($name, $seconds, $owner); 39 | } 40 | 41 | /** 42 | * Attempt to acquire the lock. 43 | */ 44 | public function acquire(): bool 45 | { 46 | // The lock can be acquired if: it doesn't exist, it has expired, 47 | // or it is already owned by the same lock instance. 48 | $isExpiredOrAlreadyOwned = [ 49 | '$or' => [ 50 | ['$lte' => ['$expires_at', $this->getUTCDateTime()]], 51 | ['$eq' => ['$owner', $this->owner]], 52 | ], 53 | ]; 54 | $result = $this->collection->findOneAndUpdate( 55 | ['_id' => $this->name], 56 | [ 57 | [ 58 | '$set' => [ 59 | 'owner' => [ 60 | '$cond' => [ 61 | 'if' => $isExpiredOrAlreadyOwned, 62 | 'then' => $this->owner, 63 | 'else' => '$owner', 64 | ], 65 | ], 66 | 'expires_at' => [ 67 | '$cond' => [ 68 | 'if' => $isExpiredOrAlreadyOwned, 69 | 'then' => $this->getUTCDateTime($this->seconds), 70 | 'else' => '$expires_at', 71 | ], 72 | ], 73 | ], 74 | ], 75 | ], 76 | [ 77 | 'upsert' => true, 78 | 'returnDocument' => FindOneAndUpdate::RETURN_DOCUMENT_AFTER, 79 | 'projection' => ['owner' => 1], 80 | ], 81 | ); 82 | 83 | if ($this->lottery[0] <= 0 && random_int(1, $this->lottery[1]) <= $this->lottery[0]) { 84 | $this->collection->deleteMany(['expires_at' => ['$lte' => $this->getUTCDateTime()]]); 85 | } 86 | 87 | // Compare the owner to check if the lock is owned. Acquiring the same lock 88 | // with the same owner at the same instant would lead to not update the document 89 | return $result['owner'] === $this->owner; 90 | } 91 | 92 | /** 93 | * Release the lock. 94 | */ 95 | #[Override] 96 | public function release(): bool 97 | { 98 | $result = $this->collection 99 | ->deleteOne([ 100 | '_id' => $this->name, 101 | 'owner' => $this->owner, 102 | ]); 103 | 104 | return $result->getDeletedCount() > 0; 105 | } 106 | 107 | /** 108 | * Releases this lock in disregard of ownership. 109 | */ 110 | #[Override] 111 | public function forceRelease(): void 112 | { 113 | $this->collection->deleteOne([ 114 | '_id' => $this->name, 115 | ]); 116 | } 117 | 118 | /** Creates a TTL index that automatically deletes expired objects. */ 119 | public function createTTLIndex(): void 120 | { 121 | $this->collection->createIndex( 122 | // UTCDateTime field that holds the expiration date 123 | ['expires_at' => 1], 124 | // Delay to remove items after expiration 125 | ['expireAfterSeconds' => 0], 126 | ); 127 | } 128 | 129 | /** 130 | * Returns the owner value written into the driver for this lock. 131 | */ 132 | #[Override] 133 | protected function getCurrentOwner(): ?string 134 | { 135 | return $this->collection->findOne( 136 | [ 137 | '_id' => $this->name, 138 | 'expires_at' => ['$gte' => $this->getUTCDateTime()], 139 | ], 140 | ['projection' => ['owner' => 1]], 141 | )['owner'] ?? null; 142 | } 143 | 144 | private function getUTCDateTime(int $additionalSeconds = 0): UTCDateTime 145 | { 146 | return new UTCDateTime(Carbon::now()->addSeconds($additionalSeconds)); 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /src/Cache/MongoStore.php: -------------------------------------------------------------------------------- 1 | collection = $this->connection->getCollection($this->collectionName); 50 | } 51 | 52 | /** 53 | * Get a lock instance. 54 | * 55 | * @param string $name 56 | * @param int $seconds 57 | * @param string|null $owner 58 | */ 59 | #[Override] 60 | public function lock($name, $seconds = 0, $owner = null): MongoLock 61 | { 62 | return new MongoLock( 63 | ($this->lockConnection ?? $this->connection)->getCollection($this->lockCollectionName), 64 | $this->prefix . $name, 65 | $seconds ?: $this->defaultLockTimeoutInSeconds, 66 | $owner, 67 | $this->lockLottery, 68 | ); 69 | } 70 | 71 | /** 72 | * Restore a lock instance using the owner identifier. 73 | */ 74 | #[Override] 75 | public function restoreLock($name, $owner): MongoLock 76 | { 77 | return $this->lock($name, 0, $owner); 78 | } 79 | 80 | /** 81 | * Store an item in the cache for a given number of seconds. 82 | * 83 | * @param string $key 84 | * @param mixed $value 85 | * @param int $seconds 86 | */ 87 | #[Override] 88 | public function put($key, $value, $seconds): bool 89 | { 90 | $result = $this->collection->updateOne( 91 | [ 92 | '_id' => $this->prefix . $key, 93 | ], 94 | [ 95 | '$set' => [ 96 | 'value' => $this->serialize($value), 97 | 'expires_at' => $this->getUTCDateTime($seconds), 98 | ], 99 | ], 100 | [ 101 | 'upsert' => true, 102 | 103 | ], 104 | ); 105 | 106 | return $result->getUpsertedCount() > 0 || $result->getModifiedCount() > 0; 107 | } 108 | 109 | /** 110 | * Store an item in the cache if the key doesn't exist. 111 | * 112 | * @param string $key 113 | * @param mixed $value 114 | * @param int $seconds 115 | */ 116 | public function add($key, $value, $seconds): bool 117 | { 118 | $isExpired = ['$lte' => ['$expires_at', $this->getUTCDateTime()]]; 119 | 120 | $result = $this->collection->updateOne( 121 | [ 122 | '_id' => $this->prefix . $key, 123 | ], 124 | [ 125 | [ 126 | '$set' => [ 127 | 'value' => [ 128 | '$cond' => [ 129 | 'if' => $isExpired, 130 | 'then' => $this->serialize($value), 131 | 'else' => '$value', 132 | ], 133 | ], 134 | 'expires_at' => [ 135 | '$cond' => [ 136 | 'if' => $isExpired, 137 | 'then' => $this->getUTCDateTime($seconds), 138 | 'else' => '$expires_at', 139 | ], 140 | ], 141 | ], 142 | ], 143 | ], 144 | ['upsert' => true], 145 | ); 146 | 147 | return $result->getUpsertedCount() > 0 || $result->getModifiedCount() > 0; 148 | } 149 | 150 | /** 151 | * Retrieve an item from the cache by key. 152 | * 153 | * @param string $key 154 | */ 155 | #[Override] 156 | public function get($key): mixed 157 | { 158 | $result = $this->collection->findOne( 159 | ['_id' => $this->prefix . $key], 160 | ['projection' => ['value' => 1, 'expires_at' => 1]], 161 | ); 162 | 163 | if (! $result) { 164 | return null; 165 | } 166 | 167 | if ($result['expires_at'] <= $this->getUTCDateTime()) { 168 | $this->forgetIfExpired($key); 169 | 170 | return null; 171 | } 172 | 173 | return $this->unserialize($result['value']); 174 | } 175 | 176 | /** 177 | * Increment the value of an item in the cache. 178 | * 179 | * @param string $key 180 | * @param int|float $value 181 | */ 182 | #[Override] 183 | public function increment($key, $value = 1): int|float|false 184 | { 185 | $result = $this->collection->findOneAndUpdate( 186 | [ 187 | '_id' => $this->prefix . $key, 188 | ], 189 | [ 190 | '$inc' => ['value' => $value], 191 | ], 192 | [ 193 | 'returnDocument' => FindOneAndUpdate::RETURN_DOCUMENT_AFTER, 194 | ], 195 | ); 196 | 197 | if (! $result) { 198 | return false; 199 | } 200 | 201 | if ($result['expires_at'] <= $this->getUTCDateTime()) { 202 | $this->forgetIfExpired($key); 203 | 204 | return false; 205 | } 206 | 207 | return $result['value']; 208 | } 209 | 210 | /** 211 | * Decrement the value of an item in the cache. 212 | * 213 | * @param string $key 214 | * @param int|float $value 215 | */ 216 | #[Override] 217 | public function decrement($key, $value = 1): int|float|false 218 | { 219 | return $this->increment($key, -1 * $value); 220 | } 221 | 222 | /** 223 | * Store an item in the cache indefinitely. 224 | * 225 | * @param string $key 226 | * @param mixed $value 227 | */ 228 | #[Override] 229 | public function forever($key, $value): bool 230 | { 231 | return $this->put($key, $value, self::TEN_YEARS_IN_SECONDS); 232 | } 233 | 234 | /** 235 | * Remove an item from the cache. 236 | * 237 | * @param string $key 238 | */ 239 | #[Override] 240 | public function forget($key): bool 241 | { 242 | $result = $this->collection->deleteOne([ 243 | '_id' => $this->prefix . $key, 244 | ]); 245 | 246 | return $result->getDeletedCount() > 0; 247 | } 248 | 249 | /** 250 | * Remove an item from the cache if it is expired. 251 | * 252 | * @param string $key 253 | */ 254 | public function forgetIfExpired($key): bool 255 | { 256 | $result = $this->collection->deleteOne([ 257 | '_id' => $this->prefix . $key, 258 | 'expires_at' => ['$lte' => $this->getUTCDateTime()], 259 | ]); 260 | 261 | return $result->getDeletedCount() > 0; 262 | } 263 | 264 | public function flush(): bool 265 | { 266 | $this->collection->deleteMany([]); 267 | 268 | return true; 269 | } 270 | 271 | public function getPrefix(): string 272 | { 273 | return $this->prefix; 274 | } 275 | 276 | /** Creates a TTL index that automatically deletes expired objects. */ 277 | public function createTTLIndex(): void 278 | { 279 | $this->collection->createIndex( 280 | // UTCDateTime field that holds the expiration date 281 | ['expires_at' => 1], 282 | // Delay to remove items after expiration 283 | ['expireAfterSeconds' => 0], 284 | ); 285 | } 286 | 287 | private function serialize($value): string|int|float 288 | { 289 | // Don't serialize numbers, so they can be incremented 290 | if (is_int($value) || is_float($value)) { 291 | return $value; 292 | } 293 | 294 | return serialize($value); 295 | } 296 | 297 | private function unserialize($value): mixed 298 | { 299 | if (! is_string($value) || ! str_contains($value, ';')) { 300 | return $value; 301 | } 302 | 303 | return unserialize($value); 304 | } 305 | 306 | private function getUTCDateTime(int $additionalSeconds = 0): UTCDateTime 307 | { 308 | return new UTCDateTime(Carbon::now()->addSeconds($additionalSeconds)); 309 | } 310 | } 311 | -------------------------------------------------------------------------------- /src/CommandSubscriber.php: -------------------------------------------------------------------------------- 1 | */ 18 | private array $commands = []; 19 | 20 | public function __construct(private Connection $connection) 21 | { 22 | } 23 | 24 | public function commandStarted(CommandStartedEvent $event): void 25 | { 26 | $this->commands[$event->getOperationId()] = $event; 27 | } 28 | 29 | public function commandFailed(CommandFailedEvent $event): void 30 | { 31 | $this->logQuery($event); 32 | } 33 | 34 | public function commandSucceeded(CommandSucceededEvent $event): void 35 | { 36 | $this->logQuery($event); 37 | } 38 | 39 | private function logQuery(CommandSucceededEvent|CommandFailedEvent $event): void 40 | { 41 | $startedEvent = $this->commands[$event->getOperationId()]; 42 | unset($this->commands[$event->getOperationId()]); 43 | 44 | $command = []; 45 | foreach (get_object_vars($startedEvent->getCommand()) as $key => $value) { 46 | if ($key[0] !== '$' && ! in_array($key, ['lsid', 'txnNumber'])) { 47 | $command[$key] = $value; 48 | } 49 | } 50 | 51 | $this->connection->logQuery(Document::fromPHP($command)->toCanonicalExtendedJSON(), [], $event->getDurationMicros() / 1000); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Concerns/ManagesTransactions.php: -------------------------------------------------------------------------------- 1 | session; 31 | } 32 | 33 | private function getSessionOrCreate(): Session 34 | { 35 | if ($this->session === null) { 36 | $this->session = $this->getClient()->startSession(); 37 | } 38 | 39 | return $this->session; 40 | } 41 | 42 | private function getSessionOrThrow(): Session 43 | { 44 | $session = $this->getSession(); 45 | 46 | if ($session === null) { 47 | throw new RuntimeException('There is no active session.'); 48 | } 49 | 50 | return $session; 51 | } 52 | 53 | /** 54 | * Starts a transaction on the active session. An active session will be created if none exists. 55 | */ 56 | public function beginTransaction(array $options = []): void 57 | { 58 | $this->getSessionOrCreate()->startTransaction($options); 59 | $this->transactions = 1; 60 | } 61 | 62 | /** 63 | * Commit transaction in this session. 64 | */ 65 | public function commit(): void 66 | { 67 | $this->getSessionOrThrow()->commitTransaction(); 68 | $this->transactions = 0; 69 | } 70 | 71 | /** 72 | * Abort transaction in this session. 73 | */ 74 | public function rollBack($toLevel = null): void 75 | { 76 | $this->getSessionOrThrow()->abortTransaction(); 77 | $this->transactions = 0; 78 | } 79 | 80 | /** 81 | * Static transaction function realize the with_transaction functionality provided by MongoDB. 82 | * 83 | * @param int $attempts 84 | */ 85 | public function transaction(Closure $callback, $attempts = 1, array $options = []): mixed 86 | { 87 | $attemptsLeft = $attempts; 88 | $callbackResult = null; 89 | $throwable = null; 90 | 91 | $callbackFunction = function (Session $session) use ($callback, &$attemptsLeft, &$callbackResult, &$throwable) { 92 | $attemptsLeft--; 93 | 94 | if ($attemptsLeft < 0) { 95 | $session->abortTransaction(); 96 | 97 | return; 98 | } 99 | 100 | // Catch, store, and re-throw any exception thrown during execution 101 | // of the callable. The last exception is re-thrown if the transaction 102 | // was aborted because the number of callback attempts has been exceeded. 103 | try { 104 | $callbackResult = $callback($this); 105 | } catch (Throwable $throwable) { 106 | throw $throwable; 107 | } 108 | }; 109 | 110 | with_transaction($this->getSessionOrCreate(), $callbackFunction, $options); 111 | 112 | if ($attemptsLeft < 0 && $throwable) { 113 | throw $throwable; 114 | } 115 | 116 | return $callbackResult; 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/Connection.php: -------------------------------------------------------------------------------- 1 | config = $config; 65 | 66 | // Build the connection string 67 | $dsn = $this->getDsn($config); 68 | 69 | // You can pass options directly to the MongoDB constructor 70 | $options = $config['options'] ?? []; 71 | 72 | // Create the connection 73 | $this->connection = $this->createConnection($dsn, $config, $options); 74 | $this->database = $this->getDefaultDatabaseName($dsn, $config); 75 | 76 | // Select database 77 | $this->db = $this->connection->getDatabase($this->database); 78 | 79 | $this->tablePrefix = $config['prefix'] ?? ''; 80 | 81 | $this->useDefaultPostProcessor(); 82 | 83 | $this->useDefaultSchemaGrammar(); 84 | 85 | $this->useDefaultQueryGrammar(); 86 | 87 | $this->renameEmbeddedIdField = $config['rename_embedded_id_field'] ?? true; 88 | } 89 | 90 | /** 91 | * Begin a fluent query against a database collection. 92 | * 93 | * @param string $table The name of the MongoDB collection 94 | * @param string|null $as Ignored. Not supported by MongoDB 95 | * 96 | * @return Query\Builder 97 | */ 98 | public function table($table, $as = null) 99 | { 100 | $query = new Query\Builder($this, $this->getQueryGrammar(), $this->getPostProcessor()); 101 | 102 | return $query->from($table); 103 | } 104 | 105 | /** 106 | * Get a MongoDB collection. 107 | * 108 | * @param string $name 109 | * 110 | * @return Collection 111 | */ 112 | public function getCollection($name): Collection 113 | { 114 | return $this->db->selectCollection($this->tablePrefix . $name); 115 | } 116 | 117 | /** @inheritdoc */ 118 | public function getSchemaBuilder() 119 | { 120 | return new Schema\Builder($this); 121 | } 122 | 123 | /** 124 | * Get the MongoDB database object. 125 | * 126 | * @deprecated since mongodb/laravel-mongodb:5.2, use getDatabase() instead 127 | * 128 | * @return Database 129 | */ 130 | public function getMongoDB() 131 | { 132 | trigger_error(sprintf('Since mongodb/laravel-mongodb:5.2, Method "%s()" is deprecated, use "getDatabase()" instead.', __FUNCTION__), E_USER_DEPRECATED); 133 | 134 | return $this->db; 135 | } 136 | 137 | /** 138 | * Get the MongoDB database object. 139 | * 140 | * @param string|null $name Name of the database, if not provided the default database will be returned. 141 | * 142 | * @return Database 143 | */ 144 | public function getDatabase(?string $name = null): Database 145 | { 146 | if ($name && $name !== $this->database) { 147 | return $this->connection->getDatabase($name); 148 | } 149 | 150 | return $this->db; 151 | } 152 | 153 | /** 154 | * Return MongoDB object. 155 | * 156 | * @deprecated since mongodb/laravel-mongodb:5.2, use getClient() instead 157 | * 158 | * @return Client 159 | */ 160 | public function getMongoClient() 161 | { 162 | trigger_error(sprintf('Since mongodb/laravel-mongodb:5.2, method "%s()" is deprecated, use "getClient()" instead.', __FUNCTION__), E_USER_DEPRECATED); 163 | 164 | return $this->getClient(); 165 | } 166 | 167 | /** 168 | * Get the MongoDB client. 169 | */ 170 | public function getClient(): ?Client 171 | { 172 | return $this->connection; 173 | } 174 | 175 | public function enableQueryLog() 176 | { 177 | parent::enableQueryLog(); 178 | 179 | if (! $this->commandSubscriber) { 180 | $this->commandSubscriber = new CommandSubscriber($this); 181 | $this->connection->addSubscriber($this->commandSubscriber); 182 | } 183 | } 184 | 185 | public function disableQueryLog() 186 | { 187 | parent::disableQueryLog(); 188 | 189 | if ($this->commandSubscriber) { 190 | $this->connection->removeSubscriber($this->commandSubscriber); 191 | $this->commandSubscriber = null; 192 | } 193 | } 194 | 195 | protected function withFreshQueryLog($callback) 196 | { 197 | try { 198 | return parent::withFreshQueryLog($callback); 199 | } finally { 200 | // The parent method enable query log using enableQueryLog() 201 | // but disables it by setting $loggingQueries to false. We need to 202 | // remove the subscriber for performance. 203 | if (! $this->loggingQueries) { 204 | $this->disableQueryLog(); 205 | } 206 | } 207 | } 208 | 209 | /** 210 | * Get the name of the default database based on db config or try to detect it from dsn. 211 | * 212 | * @throws InvalidArgumentException 213 | */ 214 | protected function getDefaultDatabaseName(string $dsn, array $config): string 215 | { 216 | if (empty($config['database'])) { 217 | if (! preg_match('/^mongodb(?:[+]srv)?:\\/\\/.+?\\/([^?&]+)/s', $dsn, $matches)) { 218 | throw new InvalidArgumentException('Database is not properly configured.'); 219 | } 220 | 221 | $config['database'] = $matches[1]; 222 | } 223 | 224 | return $config['database']; 225 | } 226 | 227 | /** 228 | * Create a new MongoDB connection. 229 | */ 230 | protected function createConnection(string $dsn, array $config, array $options): Client 231 | { 232 | // By default driver options is an empty array. 233 | $driverOptions = []; 234 | 235 | if (isset($config['driver_options']) && is_array($config['driver_options'])) { 236 | $driverOptions = $config['driver_options']; 237 | } 238 | 239 | $driverOptions['driver'] = [ 240 | 'name' => 'laravel-mongodb', 241 | 'version' => self::getVersion(), 242 | ]; 243 | 244 | // Check if the credentials are not already set in the options 245 | if (! isset($options['username']) && ! empty($config['username'])) { 246 | $options['username'] = $config['username']; 247 | } 248 | 249 | if (! isset($options['password']) && ! empty($config['password'])) { 250 | $options['password'] = $config['password']; 251 | } 252 | 253 | if (isset($config['name'])) { 254 | $driverOptions += ['connectionName' => $config['name']]; 255 | } 256 | 257 | return new Client($dsn, $options, $driverOptions); 258 | } 259 | 260 | /** 261 | * Check the connection to the MongoDB server 262 | * 263 | * @throws ConnectionException if connection to the server fails (for reasons other than authentication). 264 | * @throws AuthenticationException if authentication is needed and fails. 265 | * @throws RuntimeException if a server matching the read preference could not be found. 266 | */ 267 | public function ping(): void 268 | { 269 | $this->getClient()->getManager()->selectServer(new ReadPreference(ReadPreference::PRIMARY_PREFERRED)); 270 | } 271 | 272 | /** @inheritdoc */ 273 | public function disconnect() 274 | { 275 | $this->disableQueryLog(); 276 | $this->connection = null; 277 | } 278 | 279 | /** 280 | * Determine if the given configuration array has a dsn string. 281 | * 282 | * @deprecated 283 | */ 284 | protected function hasDsnString(array $config): bool 285 | { 286 | return ! empty($config['dsn']); 287 | } 288 | 289 | /** 290 | * Get the DSN string form configuration. 291 | */ 292 | protected function getDsnString(array $config): string 293 | { 294 | return $config['dsn']; 295 | } 296 | 297 | /** 298 | * Get the DSN string for a host / port configuration. 299 | */ 300 | protected function getHostDsn(array $config): string 301 | { 302 | // Treat host option as array of hosts 303 | $hosts = is_array($config['host']) ? $config['host'] : [$config['host']]; 304 | 305 | foreach ($hosts as &$host) { 306 | // ipv6 307 | if (filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) { 308 | $host = '[' . $host . ']'; 309 | if (! empty($config['port'])) { 310 | $host .= ':' . $config['port']; 311 | } 312 | } else { 313 | // Check if we need to add a port to the host 314 | if (! str_contains($host, ':') && ! empty($config['port'])) { 315 | $host .= ':' . $config['port']; 316 | } 317 | } 318 | } 319 | 320 | // Check if we want to authenticate against a specific database. 321 | $authDatabase = isset($config['options']) && ! empty($config['options']['database']) ? $config['options']['database'] : null; 322 | 323 | return 'mongodb://' . implode(',', $hosts) . ($authDatabase ? '/' . $authDatabase : ''); 324 | } 325 | 326 | /** 327 | * Create a DSN string from a configuration. 328 | */ 329 | protected function getDsn(array $config): string 330 | { 331 | if (! empty($config['dsn'])) { 332 | return $this->getDsnString($config); 333 | } 334 | 335 | if (! empty($config['host'])) { 336 | return $this->getHostDsn($config); 337 | } 338 | 339 | throw new InvalidArgumentException('MongoDB connection configuration requires "dsn" or "host" key.'); 340 | } 341 | 342 | /** @inheritdoc */ 343 | public function getDriverName() 344 | { 345 | return 'mongodb'; 346 | } 347 | 348 | /** @inheritdoc */ 349 | public function getDriverTitle() 350 | { 351 | return 'MongoDB'; 352 | } 353 | 354 | /** @inheritdoc */ 355 | protected function getDefaultPostProcessor() 356 | { 357 | return new Query\Processor(); 358 | } 359 | 360 | /** @inheritdoc */ 361 | protected function getDefaultQueryGrammar() 362 | { 363 | // Argument added in Laravel 12 364 | return new Query\Grammar($this); 365 | } 366 | 367 | /** @inheritdoc */ 368 | protected function getDefaultSchemaGrammar() 369 | { 370 | // Argument added in Laravel 12 371 | return new Schema\Grammar($this); 372 | } 373 | 374 | /** 375 | * Set database. 376 | */ 377 | public function setDatabase(Database $db) 378 | { 379 | $this->db = $db; 380 | } 381 | 382 | /** @inheritdoc */ 383 | public function threadCount() 384 | { 385 | $status = $this->db->command(['serverStatus' => 1])->toArray(); 386 | 387 | return $status[0]['connections']['current']; 388 | } 389 | 390 | /** 391 | * Dynamically pass methods to the connection. 392 | * 393 | * @param string $method 394 | * @param array $parameters 395 | * 396 | * @return mixed 397 | */ 398 | public function __call($method, $parameters) 399 | { 400 | return $this->db->$method(...$parameters); 401 | } 402 | 403 | /** Set whether to rename "id" field into "_id" for embedded documents. */ 404 | public function setRenameEmbeddedIdField(bool $rename): void 405 | { 406 | $this->renameEmbeddedIdField = $rename; 407 | } 408 | 409 | /** Get whether to rename "id" field into "_id" for embedded documents. */ 410 | public function getRenameEmbeddedIdField(): bool 411 | { 412 | return $this->renameEmbeddedIdField; 413 | } 414 | 415 | /** 416 | * Return the server version of one of the MongoDB servers: primary for 417 | * replica sets and standalone, and the selected server for sharded clusters. 418 | * 419 | * @internal 420 | */ 421 | public function getServerVersion(): string 422 | { 423 | return $this->db->command(['buildInfo' => 1])->toArray()[0]['version']; 424 | } 425 | 426 | private static function getVersion(): string 427 | { 428 | return self::$version ?? self::lookupVersion(); 429 | } 430 | 431 | private static function lookupVersion(): string 432 | { 433 | try { 434 | try { 435 | return self::$version = InstalledVersions::getPrettyVersion('mongodb/laravel-mongodb') ?? 'unknown'; 436 | } catch (OutOfBoundsException) { 437 | return self::$version = InstalledVersions::getPrettyVersion('jenssegers/mongodb') ?? 'unknown'; 438 | } 439 | } catch (Throwable) { 440 | return self::$version = 'error'; 441 | } 442 | } 443 | } 444 | -------------------------------------------------------------------------------- /src/Eloquent/Builder.php: -------------------------------------------------------------------------------- 1 | toBase()->aggregate($function, $columns); 79 | 80 | return $result ?: $this; 81 | } 82 | 83 | /** 84 | * Performs a full-text search of the field or fields in an Atlas collection. 85 | * 86 | * @see https://www.mongodb.com/docs/atlas/atlas-search/aggregation-stages/search/ 87 | * 88 | * @return Collection 89 | */ 90 | public function search( 91 | SearchOperatorInterface|array $operator, 92 | ?string $index = null, 93 | ?array $highlight = null, 94 | ?bool $concurrent = null, 95 | ?string $count = null, 96 | ?string $searchAfter = null, 97 | ?string $searchBefore = null, 98 | ?bool $scoreDetails = null, 99 | ?array $sort = null, 100 | ?bool $returnStoredSource = null, 101 | ?array $tracking = null, 102 | ): Collection { 103 | $results = $this->toBase()->search($operator, $index, $highlight, $concurrent, $count, $searchAfter, $searchBefore, $scoreDetails, $sort, $returnStoredSource, $tracking); 104 | 105 | return $this->model->hydrate($results->all()); 106 | } 107 | 108 | /** 109 | * Performs a semantic search on data in your Atlas Vector Search index. 110 | * NOTE: $vectorSearch is only available for MongoDB Atlas clusters, and is not available for self-managed deployments. 111 | * 112 | * @see https://www.mongodb.com/docs/atlas/atlas-vector-search/vector-search-stage/ 113 | * 114 | * @return Collection 115 | */ 116 | public function vectorSearch( 117 | string $index, 118 | string $path, 119 | array $queryVector, 120 | int $limit, 121 | bool $exact = false, 122 | QueryInterface|array $filter = [], 123 | int|null $numCandidates = null, 124 | ): Collection { 125 | $results = $this->toBase()->vectorSearch($index, $path, $queryVector, $limit, $exact, $filter, $numCandidates); 126 | 127 | return $this->model->hydrate($results->all()); 128 | } 129 | 130 | /** @inheritdoc */ 131 | public function update(array $values, array $options = []) 132 | { 133 | // Intercept operations on embedded models and delegate logic 134 | // to the parent relation instance. 135 | $relation = $this->model->getParentRelation(); 136 | if ($relation) { 137 | $relation->performUpdate($this->model, $values); 138 | 139 | return 1; 140 | } 141 | 142 | return $this->toBase()->update($this->addUpdatedAtColumn($values), $options); 143 | } 144 | 145 | /** @inheritdoc */ 146 | public function insert(array $values) 147 | { 148 | // Intercept operations on embedded models and delegate logic 149 | // to the parent relation instance. 150 | $relation = $this->model->getParentRelation(); 151 | if ($relation) { 152 | $relation->performInsert($this->model, $values); 153 | 154 | return true; 155 | } 156 | 157 | return parent::insert($values); 158 | } 159 | 160 | /** @inheritdoc */ 161 | public function insertGetId(array $values, $sequence = null) 162 | { 163 | // Intercept operations on embedded models and delegate logic 164 | // to the parent relation instance. 165 | $relation = $this->model->getParentRelation(); 166 | if ($relation) { 167 | $relation->performInsert($this->model, $values); 168 | 169 | return $this->model->getKey(); 170 | } 171 | 172 | return parent::insertGetId($values, $sequence); 173 | } 174 | 175 | /** @inheritdoc */ 176 | public function delete() 177 | { 178 | // Intercept operations on embedded models and delegate logic 179 | // to the parent relation instance. 180 | $relation = $this->model->getParentRelation(); 181 | if ($relation) { 182 | $relation->performDelete($this->model); 183 | 184 | return $this->model->getKey(); 185 | } 186 | 187 | return parent::delete(); 188 | } 189 | 190 | /** @inheritdoc */ 191 | public function increment($column, $amount = 1, array $extra = []) 192 | { 193 | // Intercept operations on embedded models and delegate logic 194 | // to the parent relation instance. 195 | $relation = $this->model->getParentRelation(); 196 | if ($relation) { 197 | $value = $this->model->{$column}; 198 | 199 | // When doing increment and decrements, Eloquent will automatically 200 | // sync the original attributes. We need to change the attribute 201 | // temporary in order to trigger an update query. 202 | $this->model->{$column} = null; 203 | 204 | $this->model->syncOriginalAttribute($column); 205 | 206 | return $this->model->update([$column => $value]); 207 | } 208 | 209 | return parent::increment($column, $amount, $extra); 210 | } 211 | 212 | /** @inheritdoc */ 213 | public function decrement($column, $amount = 1, array $extra = []) 214 | { 215 | // Intercept operations on embedded models and delegate logic 216 | // to the parent relation instance. 217 | $relation = $this->model->getParentRelation(); 218 | if ($relation) { 219 | $value = $this->model->{$column}; 220 | 221 | // When doing increment and decrements, Eloquent will automatically 222 | // sync the original attributes. We need to change the attribute 223 | // temporary in order to trigger an update query. 224 | $this->model->{$column} = null; 225 | 226 | $this->model->syncOriginalAttribute($column); 227 | 228 | return $this->model->update([$column => $value]); 229 | } 230 | 231 | return parent::decrement($column, $amount, $extra); 232 | } 233 | 234 | /** 235 | * @param (Closure():T)|Expression|null $value 236 | * 237 | * @return ($value is Closure ? T : ($value is null ? Collection : Expression)) 238 | * 239 | * @template T 240 | */ 241 | public function raw($value = null) 242 | { 243 | // Get raw results from the query builder. 244 | $results = $this->query->raw($value); 245 | 246 | // Convert MongoCursor results to a collection of models. 247 | if ($results instanceof CursorInterface) { 248 | $results->setTypeMap(['root' => 'array', 'document' => 'array', 'array' => 'array']); 249 | $results = array_map(fn ($document) => $this->query->aliasIdForResult($document), iterator_to_array($results)); 250 | 251 | return $this->model->hydrate($results); 252 | } 253 | 254 | // Convert MongoDB Document to a single object. 255 | if (is_object($results) && (property_exists($results, '_id') || property_exists($results, 'id'))) { 256 | $results = (array) match (true) { 257 | $results instanceof BSONDocument => $results->getArrayCopy(), 258 | $results instanceof Document => $results->toPHP(['root' => 'array', 'document' => 'array', 'array' => 'array']), 259 | default => $results, 260 | }; 261 | } 262 | 263 | // The result is a single object. 264 | if (is_array($results) && (array_key_exists('_id', $results) || array_key_exists('id', $results))) { 265 | $results = $this->query->aliasIdForResult($results); 266 | 267 | return $this->model->newFromBuilder($results); 268 | } 269 | 270 | return $results; 271 | } 272 | 273 | public function firstOrCreate(array $attributes = [], array $values = []) 274 | { 275 | $instance = (clone $this)->where($attributes)->first(); 276 | if ($instance !== null) { 277 | return $instance; 278 | } 279 | 280 | // createOrFirst is not supported in transaction. 281 | if ($this->getConnection()->getSession()?->isInTransaction()) { 282 | return $this->create(array_replace($attributes, $values)); 283 | } 284 | 285 | return $this->createOrFirst($attributes, $values); 286 | } 287 | 288 | public function createOrFirst(array $attributes = [], array $values = []) 289 | { 290 | // The duplicate key error would abort the transaction. Using the regular firstOrCreate in that case. 291 | if ($this->getConnection()->getSession()?->isInTransaction()) { 292 | return $this->firstOrCreate($attributes, $values); 293 | } 294 | 295 | try { 296 | return $this->create(array_replace($attributes, $values)); 297 | } catch (BulkWriteException $e) { 298 | if ($e->getCode() === self::DUPLICATE_KEY_ERROR) { 299 | return $this->where($attributes)->first() ?? throw $e; 300 | } 301 | 302 | throw $e; 303 | } 304 | } 305 | 306 | /** 307 | * Add the "updated at" column to an array of values. 308 | * TODO Remove if https://github.com/laravel/framework/commit/6484744326531829341e1ff886cc9b628b20d73e 309 | * will be reverted 310 | * Issue in laravel/frawework https://github.com/laravel/framework/issues/27791. 311 | * 312 | * @return array 313 | */ 314 | protected function addUpdatedAtColumn(array $values) 315 | { 316 | if (! $this->model->usesTimestamps() || $this->model->getUpdatedAtColumn() === null) { 317 | return $values; 318 | } 319 | 320 | $column = $this->model->getUpdatedAtColumn(); 321 | $values = array_replace( 322 | [$column => $this->model->freshTimestampString()], 323 | $values, 324 | ); 325 | 326 | return $values; 327 | } 328 | 329 | public function getConnection(): Connection 330 | { 331 | return $this->query->getConnection(); 332 | } 333 | 334 | /** @inheritdoc */ 335 | protected function ensureOrderForCursorPagination($shouldReverse = false) 336 | { 337 | if (empty($this->query->orders)) { 338 | $this->enforceOrderBy(); 339 | } 340 | 341 | if ($shouldReverse) { 342 | $this->query->orders = collect($this->query->orders) 343 | ->map(static fn (int $direction) => $direction === 1 ? -1 : 1) 344 | ->toArray(); 345 | } 346 | 347 | return collect($this->query->orders) 348 | ->map(static fn ($direction, $column) => [ 349 | 'column' => $column, 350 | 'direction' => $direction === 1 ? 'asc' : 'desc', 351 | ])->values(); 352 | } 353 | } 354 | -------------------------------------------------------------------------------- /src/Eloquent/Casts/BinaryUuid.php: -------------------------------------------------------------------------------- 1 | getType() !== Binary::TYPE_UUID) { 32 | return $value; 33 | } 34 | 35 | $base16Uuid = bin2hex($value->getData()); 36 | 37 | return sprintf( 38 | '%s-%s-%s-%s-%s', 39 | substr($base16Uuid, 0, 8), 40 | substr($base16Uuid, 8, 4), 41 | substr($base16Uuid, 12, 4), 42 | substr($base16Uuid, 16, 4), 43 | substr($base16Uuid, 20, 12), 44 | ); 45 | } 46 | 47 | /** 48 | * Prepare the given value for storage. 49 | * 50 | * @param Model $model 51 | * @param mixed $value 52 | * 53 | * @return Binary 54 | */ 55 | public function set($model, string $key, $value, array $attributes) 56 | { 57 | if ($value instanceof Binary) { 58 | return $value; 59 | } 60 | 61 | if (is_string($value) && strlen($value) === 16) { 62 | return new Binary($value, Binary::TYPE_UUID); 63 | } 64 | 65 | return new Binary(hex2bin(str_replace('-', '', $value)), Binary::TYPE_UUID); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/Eloquent/Casts/ObjectId.php: -------------------------------------------------------------------------------- 1 | newQuery(); 49 | 50 | $instance = new $related(); 51 | 52 | return new EmbedsMany($query, $this, $instance, $localKey, $foreignKey, $relation); 53 | } 54 | 55 | /** 56 | * Define an embedded one-to-many relationship. 57 | * 58 | * @param class-string $related 59 | * @param string|null $localKey 60 | * @param string|null $foreignKey 61 | * @param string|null $relation 62 | * 63 | * @return EmbedsOne 64 | */ 65 | protected function embedsOne($related, $localKey = null, $foreignKey = null, $relation = null) 66 | { 67 | // If no relation name was given, we will use this debug backtrace to extract 68 | // the calling method's name and use that as the relationship name as most 69 | // of the time this will be what we desire to use for the relationships. 70 | if ($relation === null) { 71 | $relation = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2)[1]['function']; 72 | } 73 | 74 | if ($localKey === null) { 75 | $localKey = $relation; 76 | } 77 | 78 | if ($foreignKey === null) { 79 | $foreignKey = Str::snake(class_basename($this)); 80 | } 81 | 82 | $query = $this->newQuery(); 83 | 84 | $instance = new $related(); 85 | 86 | return new EmbedsOne($query, $this, $instance, $localKey, $foreignKey, $relation); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/Eloquent/HasSchemaVersion.php: -------------------------------------------------------------------------------- 1 | getAttribute($model::getSchemaVersionKey()) === null) { 47 | $model->setAttribute($model::getSchemaVersionKey(), $model->getModelSchemaVersion()); 48 | } 49 | }); 50 | 51 | static::retrieved(function (self $model) { 52 | $version = $model->getSchemaVersion(); 53 | 54 | if ($version < $model->getModelSchemaVersion()) { 55 | $model->migrateSchema($version); 56 | $model->setAttribute($model::getSchemaVersionKey(), $model->getModelSchemaVersion()); 57 | } 58 | }); 59 | } 60 | 61 | /** 62 | * Get Current document version, fallback to 0 if not set 63 | */ 64 | public function getSchemaVersion(): int 65 | { 66 | return $this->{static::getSchemaVersionKey()} ?? 0; 67 | } 68 | 69 | protected static function getSchemaVersionKey(): string 70 | { 71 | return 'schema_version'; 72 | } 73 | 74 | protected function getModelSchemaVersion(): int 75 | { 76 | try { 77 | return $this::SCHEMA_VERSION; 78 | } catch (Error) { 79 | throw new LogicException(sprintf('Constant %s::SCHEMA_VERSION is required when using HasSchemaVersion', $this::class)); 80 | } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/Eloquent/HybridRelations.php: -------------------------------------------------------------------------------- 1 | getForeignKey(); 52 | 53 | $instance = new $related(); 54 | 55 | $localKey = $localKey ?: $this->getKeyName(); 56 | 57 | return new HasOne($instance->newQuery(), $this, $foreignKey, $localKey); 58 | } 59 | 60 | /** 61 | * Define a polymorphic one-to-one relationship. 62 | * 63 | * @see HasRelationships::morphOne() 64 | * 65 | * @param class-string $related 66 | * @param string $name 67 | * @param string|null $type 68 | * @param string|null $id 69 | * @param string|null $localKey 70 | * 71 | * @return MorphOne 72 | */ 73 | public function morphOne($related, $name, $type = null, $id = null, $localKey = null) 74 | { 75 | // Check if it is a relation with an original model. 76 | if (! Model::isDocumentModel($related)) { 77 | return parent::morphOne($related, $name, $type, $id, $localKey); 78 | } 79 | 80 | $instance = new $related(); 81 | 82 | [$type, $id] = $this->getMorphs($name, $type, $id); 83 | 84 | $localKey = $localKey ?: $this->getKeyName(); 85 | 86 | return new MorphOne($instance->newQuery(), $this, $type, $id, $localKey); 87 | } 88 | 89 | /** 90 | * Define a one-to-many relationship. 91 | * 92 | * @see HasRelationships::hasMany() 93 | * 94 | * @param class-string $related 95 | * @param string|null $foreignKey 96 | * @param string|null $localKey 97 | * 98 | * @return \Illuminate\Database\Eloquent\Relations\HasMany 99 | */ 100 | public function hasMany($related, $foreignKey = null, $localKey = null) 101 | { 102 | // Check if it is a relation with an original model. 103 | if (! Model::isDocumentModel($related)) { 104 | return parent::hasMany($related, $foreignKey, $localKey); 105 | } 106 | 107 | $foreignKey = $foreignKey ?: $this->getForeignKey(); 108 | 109 | $instance = new $related(); 110 | 111 | $localKey = $localKey ?: $this->getKeyName(); 112 | 113 | return new HasMany($instance->newQuery(), $this, $foreignKey, $localKey); 114 | } 115 | 116 | /** 117 | * Define a polymorphic one-to-many relationship. 118 | * 119 | * @see HasRelationships::morphMany() 120 | * 121 | * @param class-string $related 122 | * @param string $name 123 | * @param string|null $type 124 | * @param string|null $id 125 | * @param string|null $localKey 126 | * 127 | * @return \Illuminate\Database\Eloquent\Relations\MorphMany 128 | */ 129 | public function morphMany($related, $name, $type = null, $id = null, $localKey = null) 130 | { 131 | // Check if it is a relation with an original model. 132 | if (! Model::isDocumentModel($related)) { 133 | return parent::morphMany($related, $name, $type, $id, $localKey); 134 | } 135 | 136 | $instance = new $related(); 137 | 138 | // Here we will gather up the morph type and ID for the relationship so that we 139 | // can properly query the intermediate table of a relation. Finally, we will 140 | // get the table and create the relationship instances for the developers. 141 | [$type, $id] = $this->getMorphs($name, $type, $id); 142 | 143 | $table = $instance->getTable(); 144 | 145 | $localKey = $localKey ?: $this->getKeyName(); 146 | 147 | return new MorphMany($instance->newQuery(), $this, $type, $id, $localKey); 148 | } 149 | 150 | /** 151 | * Define an inverse one-to-one or many relationship. 152 | * 153 | * @see HasRelationships::belongsTo() 154 | * 155 | * @param class-string $related 156 | * @param string|null $foreignKey 157 | * @param string|null $ownerKey 158 | * @param string|null $relation 159 | * 160 | * @return \Illuminate\Database\Eloquent\Relations\BelongsTo 161 | */ 162 | public function belongsTo($related, $foreignKey = null, $ownerKey = null, $relation = null) 163 | { 164 | // If no relation name was given, we will use this debug backtrace to extract 165 | // the calling method's name and use that as the relationship name as most 166 | // of the time this will be what we desire to use for the relationships. 167 | if ($relation === null) { 168 | $relation = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2)[1]['function']; 169 | } 170 | 171 | // Check if it is a relation with an original model. 172 | if (! Model::isDocumentModel($related)) { 173 | return parent::belongsTo($related, $foreignKey, $ownerKey, $relation); 174 | } 175 | 176 | // If no foreign key was supplied, we can use a backtrace to guess the proper 177 | // foreign key name by using the name of the relationship function, which 178 | // when combined with an "_id" should conventionally match the columns. 179 | if ($foreignKey === null) { 180 | $foreignKey = Str::snake($relation) . '_id'; 181 | } 182 | 183 | $instance = new $related(); 184 | 185 | // Once we have the foreign key names, we'll just create a new Eloquent query 186 | // for the related models and returns the relationship instance which will 187 | // actually be responsible for retrieving and hydrating every relations. 188 | $query = $instance->newQuery(); 189 | 190 | $ownerKey = $ownerKey ?: $instance->getKeyName(); 191 | 192 | return new BelongsTo($query, $this, $foreignKey, $ownerKey, $relation); 193 | } 194 | 195 | /** 196 | * Define a polymorphic, inverse one-to-one or many relationship. 197 | * 198 | * @see HasRelationships::morphTo() 199 | * 200 | * @param string $name 201 | * @param string|null $type 202 | * @param string|null $id 203 | * @param string|null $ownerKey 204 | * 205 | * @return \Illuminate\Database\Eloquent\Relations\MorphTo 206 | */ 207 | public function morphTo($name = null, $type = null, $id = null, $ownerKey = null) 208 | { 209 | // If no name is provided, we will use the backtrace to get the function name 210 | // since that is most likely the name of the polymorphic interface. We can 211 | // use that to get both the class and foreign key that will be utilized. 212 | if ($name === null) { 213 | $name = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2)[1]['function']; 214 | } 215 | 216 | [$type, $id] = $this->getMorphs(Str::snake($name), $type, $id); 217 | 218 | // If the type value is null it is probably safe to assume we're eager loading 219 | // the relationship. When that is the case we will pass in a dummy query as 220 | // there are multiple types in the morph and we can't use single queries. 221 | $class = $this->$type; 222 | if ($class === null) { 223 | return new MorphTo( 224 | $this->newQuery(), 225 | $this, 226 | $id, 227 | $ownerKey, 228 | $type, 229 | $name, 230 | ); 231 | } 232 | 233 | // If we are not eager loading the relationship we will essentially treat this 234 | // as a belongs-to style relationship since morph-to extends that class and 235 | // we will pass in the appropriate values so that it behaves as expected. 236 | $class = $this->getActualClassNameForMorph($class); 237 | 238 | $instance = new $class(); 239 | 240 | $ownerKey ??= $instance->getKeyName(); 241 | 242 | // Check if it is a relation with an original model. 243 | if (! Model::isDocumentModel($instance)) { 244 | return parent::morphTo($name, $type, $id, $ownerKey); 245 | } 246 | 247 | return new MorphTo( 248 | $instance->newQuery(), 249 | $this, 250 | $id, 251 | $ownerKey, 252 | $type, 253 | $name, 254 | ); 255 | } 256 | 257 | /** 258 | * Define a many-to-many relationship. 259 | * 260 | * @see HasRelationships::belongsToMany() 261 | * 262 | * @param class-string $related 263 | * @param string|null $collection 264 | * @param string|null $foreignPivotKey 265 | * @param string|null $relatedPivotKey 266 | * @param string|null $parentKey 267 | * @param string|null $relatedKey 268 | * @param string|null $relation 269 | * 270 | * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany 271 | */ 272 | public function belongsToMany( 273 | $related, 274 | $collection = null, 275 | $foreignPivotKey = null, 276 | $relatedPivotKey = null, 277 | $parentKey = null, 278 | $relatedKey = null, 279 | $relation = null, 280 | ) { 281 | // If no relationship name was passed, we will pull backtraces to get the 282 | // name of the calling function. We will use that function name as the 283 | // title of this relation since that is a great convention to apply. 284 | if ($relation === null) { 285 | $relation = $this->guessBelongsToManyRelation(); 286 | } 287 | 288 | // Check if it is a relation with an original model. 289 | if (! Model::isDocumentModel($related)) { 290 | return parent::belongsToMany( 291 | $related, 292 | $collection, 293 | $foreignPivotKey, 294 | $relatedPivotKey, 295 | $parentKey, 296 | $relatedKey, 297 | $relation, 298 | ); 299 | } 300 | 301 | // First, we'll need to determine the foreign key and "other key" for the 302 | // relationship. Once we have determined the keys we'll make the query 303 | // instances as well as the relationship instances we need for this. 304 | $foreignPivotKey = $foreignPivotKey ?: $this->getForeignKey() . 's'; 305 | 306 | $instance = new $related(); 307 | 308 | $relatedPivotKey = $relatedPivotKey ?: $instance->getForeignKey() . 's'; 309 | 310 | // If no table name was provided, we can guess it by concatenating the two 311 | // models using underscores in alphabetical order. The two model names 312 | // are transformed to snake case from their default CamelCase also. 313 | if ($collection === null) { 314 | $collection = $instance->getTable(); 315 | } 316 | 317 | // Now we're ready to create a new query builder for the related model and 318 | // the relationship instances for the relation. The relations will set 319 | // appropriate query constraint and entirely manages the hydrations. 320 | $query = $instance->newQuery(); 321 | 322 | return new BelongsToMany( 323 | $query, 324 | $this, 325 | $collection, 326 | $foreignPivotKey, 327 | $relatedPivotKey, 328 | $parentKey ?: $this->getKeyName(), 329 | $relatedKey ?: $instance->getKeyName(), 330 | $relation, 331 | ); 332 | } 333 | 334 | /** 335 | * Define a morph-to-many relationship. 336 | * 337 | * @param class-string $related 338 | * @param string $name 339 | * @param string|null $table 340 | * @param string|null $foreignPivotKey 341 | * @param string|null $relatedPivotKey 342 | * @param string|null $parentKey 343 | * @param string|null $relatedKey 344 | * @param string|null $relation 345 | * @param bool $inverse 346 | * 347 | * @return \Illuminate\Database\Eloquent\Relations\MorphToMany 348 | */ 349 | public function morphToMany( 350 | $related, 351 | $name, 352 | $table = null, 353 | $foreignPivotKey = null, 354 | $relatedPivotKey = null, 355 | $parentKey = null, 356 | $relatedKey = null, 357 | $relation = null, 358 | $inverse = false, 359 | ) { 360 | // If no relationship name was passed, we will pull backtraces to get the 361 | // name of the calling function. We will use that function name as the 362 | // title of this relation since that is a great convention to apply. 363 | if ($relation === null) { 364 | $relation = $this->guessBelongsToManyRelation(); 365 | } 366 | 367 | // Check if it is a relation with an original model. 368 | if (! Model::isDocumentModel($related)) { 369 | return parent::morphToMany( 370 | $related, 371 | $name, 372 | $table, 373 | $foreignPivotKey, 374 | $relatedPivotKey, 375 | $parentKey, 376 | $relatedKey, 377 | $relation, 378 | $inverse, 379 | ); 380 | } 381 | 382 | $instance = new $related(); 383 | 384 | $foreignPivotKey = $foreignPivotKey ?: $name . '_id'; 385 | $relatedPivotKey = $relatedPivotKey ?: Str::plural($instance->getForeignKey()); 386 | 387 | // Now we're ready to create a new query builder for the related model and 388 | // the relationship instances for this relation. This relation will set 389 | // appropriate query constraints then entirely manage the hydration. 390 | if (! $table) { 391 | $words = preg_split('/(_)/u', $name, -1, PREG_SPLIT_DELIM_CAPTURE); 392 | $lastWord = array_pop($words); 393 | $table = implode('', $words) . Str::plural($lastWord); 394 | } 395 | 396 | return new MorphToMany( 397 | $instance->newQuery(), 398 | $this, 399 | $name, 400 | $table, 401 | $foreignPivotKey, 402 | $relatedPivotKey, 403 | $parentKey ?: $this->getKeyName(), 404 | $relatedKey ?: $instance->getKeyName(), 405 | $relation, 406 | $inverse, 407 | ); 408 | } 409 | 410 | /** 411 | * Define a polymorphic, inverse many-to-many relationship. 412 | * 413 | * @param class-string $related 414 | * @param string $name 415 | * @param string|null $table 416 | * @param string|null $foreignPivotKey 417 | * @param string|null $relatedPivotKey 418 | * @param string|null $parentKey 419 | * @param string|null $relatedKey 420 | * @param string|null $relation 421 | * 422 | * @return \Illuminate\Database\Eloquent\Relations\MorphToMany 423 | */ 424 | public function morphedByMany( 425 | $related, 426 | $name, 427 | $table = null, 428 | $foreignPivotKey = null, 429 | $relatedPivotKey = null, 430 | $parentKey = null, 431 | $relatedKey = null, 432 | $relation = null, 433 | ) { 434 | // If the related model is an instance of eloquent model class, leave pivot keys 435 | // as default. It's necessary for supporting hybrid relationship 436 | if (Model::isDocumentModel($related)) { 437 | // For the inverse of the polymorphic many-to-many relations, we will change 438 | // the way we determine the foreign and other keys, as it is the opposite 439 | // of the morph-to-many method since we're figuring out these inverses. 440 | $foreignPivotKey = $foreignPivotKey ?: Str::plural($this->getForeignKey()); 441 | 442 | $relatedPivotKey = $relatedPivotKey ?: $name . '_id'; 443 | } 444 | 445 | return $this->morphToMany( 446 | $related, 447 | $name, 448 | $table, 449 | $foreignPivotKey, 450 | $relatedPivotKey, 451 | $parentKey, 452 | $relatedKey, 453 | $relatedKey, 454 | true, 455 | ); 456 | } 457 | 458 | /** @inheritdoc */ 459 | public function newEloquentBuilder($query) 460 | { 461 | if (Model::isDocumentModel($this)) { 462 | return new Builder($query); 463 | } 464 | 465 | return new EloquentBuilder($query); 466 | } 467 | } 468 | -------------------------------------------------------------------------------- /src/Eloquent/MassPrunable.php: -------------------------------------------------------------------------------- 1 | prunable(); 26 | $total = in_array(SoftDeletes::class, class_uses_recursive(static::class)) 27 | ? $query->forceDelete() 28 | : $query->delete(); 29 | 30 | event(new ModelsPruned(static::class, $total)); 31 | 32 | return $total; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Eloquent/Model.php: -------------------------------------------------------------------------------- 1 | true, 28 | ]; 29 | 30 | /** 31 | * Indicates if the given model class is a MongoDB document model. 32 | * It must be a subclass of {@see BaseModel} and use the 33 | * {@see DocumentModel} trait. 34 | * 35 | * @param class-string|object $class 36 | */ 37 | final public static function isDocumentModel(string|object $class): bool 38 | { 39 | if (is_object($class)) { 40 | $class = $class::class; 41 | } 42 | 43 | if (array_key_exists($class, self::$documentModelClasses)) { 44 | return self::$documentModelClasses[$class]; 45 | } 46 | 47 | // We know all child classes of this class are document models. 48 | if (is_subclass_of($class, self::class)) { 49 | return self::$documentModelClasses[$class] = true; 50 | } 51 | 52 | // Document models must be subclasses of Laravel's base model class. 53 | if (! is_subclass_of($class, BaseModel::class)) { 54 | return self::$documentModelClasses[$class] = false; 55 | } 56 | 57 | // Document models must use the DocumentModel trait. 58 | return self::$documentModelClasses[$class] = array_key_exists(DocumentModel::class, class_uses_recursive($class)); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/Eloquent/SoftDeletes.php: -------------------------------------------------------------------------------- 1 | getDeletedAtColumn(); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/Helpers/EloquentBuilder.php: -------------------------------------------------------------------------------- 1 | =', $count = 1, $boolean = 'and', ?Closure $callback = null) 45 | { 46 | if (is_string($relation)) { 47 | if (str_contains($relation, '.')) { 48 | return $this->hasNested($relation, $operator, $count, $boolean, $callback); 49 | } 50 | 51 | $relation = $this->getRelationWithoutConstraints($relation); 52 | } 53 | 54 | // If this is a hybrid relation then we can not use a normal whereExists() query that relies on a subquery 55 | // We need to use a `whereIn` query 56 | if (Model::isDocumentModel($this->getModel()) || $this->isAcrossConnections($relation)) { 57 | return $this->addHybridHas($relation, $operator, $count, $boolean, $callback); 58 | } 59 | 60 | // If we only need to check for the existence of the relation, then we can optimize 61 | // the subquery to only run a "where exists" clause instead of this full "count" 62 | // clause. This will make these queries run much faster compared with a count. 63 | $method = $this->canUseExistsForExistenceCheck($operator, $count) 64 | ? 'getRelationExistenceQuery' 65 | : 'getRelationExistenceCountQuery'; 66 | 67 | $hasQuery = $relation->{$method}( 68 | $relation->getRelated()->newQuery(), 69 | $this 70 | ); 71 | 72 | // Next we will call any given callback as an "anonymous" scope so they can get the 73 | // proper logical grouping of the where clauses if needed by this Eloquent query 74 | // builder. Then, we will be ready to finalize and return this query instance. 75 | if ($callback) { 76 | $hasQuery->callScope($callback); 77 | } 78 | 79 | return $this->addHasWhere( 80 | $hasQuery, 81 | $relation, 82 | $operator, 83 | $count, 84 | $boolean, 85 | ); 86 | } 87 | 88 | /** @return bool */ 89 | protected function isAcrossConnections(Relation $relation) 90 | { 91 | return $relation->getParent()->getConnectionName() !== $relation->getRelated()->getConnectionName(); 92 | } 93 | 94 | /** 95 | * Compare across databases. 96 | * 97 | * @param string $operator 98 | * @param int $count 99 | * @param string $boolean 100 | * 101 | * @return mixed 102 | * 103 | * @throws Exception 104 | */ 105 | public function addHybridHas(Relation $relation, $operator = '>=', $count = 1, $boolean = 'and', ?Closure $callback = null) 106 | { 107 | $hasQuery = $relation->getQuery(); 108 | if ($callback) { 109 | $hasQuery->callScope($callback); 110 | } 111 | 112 | // If the operator is <, <= or !=, we will use whereNotIn. 113 | $not = in_array($operator, ['<', '<=', '!=']); 114 | // If we are comparing to 0, we need an additional $not flip. 115 | if ($count === 0) { 116 | $not = ! $not; 117 | } 118 | 119 | $relations = match (true) { 120 | $relation instanceof MorphToMany => $relation->getInverse() ? 121 | $this->handleMorphedByMany($hasQuery, $relation) : 122 | $this->handleMorphToMany($hasQuery, $relation), 123 | default => $hasQuery->pluck($this->getHasCompareKey($relation)) 124 | }; 125 | 126 | $relatedIds = $this->getConstrainedRelatedIds($relations, $operator, $count); 127 | 128 | return $this->whereIn($this->getRelatedConstraintKey($relation), $relatedIds, $boolean, $not); 129 | } 130 | 131 | /** 132 | * @param Builder $hasQuery 133 | * @param Relation $relation 134 | * 135 | * @return Collection 136 | */ 137 | private function handleMorphToMany($hasQuery, $relation) 138 | { 139 | // First we select the parent models that have a relation to our related model, 140 | // Then extracts related model's ids from the pivot column 141 | $hasQuery->where($relation->getTable() . '.' . $relation->getMorphType(), $relation->getParent()::class); 142 | $relations = $hasQuery->pluck($relation->getTable()); 143 | $relations = $relation->extractIds($relations->flatten(1)->toArray(), $relation->getForeignPivotKeyName()); 144 | 145 | return collect($relations); 146 | } 147 | 148 | /** 149 | * @param Builder $hasQuery 150 | * @param Relation $relation 151 | * 152 | * @return Collection 153 | */ 154 | private function handleMorphedByMany($hasQuery, $relation) 155 | { 156 | $hasQuery->whereNotNull($relation->getForeignPivotKeyName()); 157 | 158 | return $hasQuery->pluck($relation->getForeignPivotKeyName())->flatten(1); 159 | } 160 | 161 | /** @return string */ 162 | protected function getHasCompareKey(Relation $relation) 163 | { 164 | if (method_exists($relation, 'getHasCompareKey')) { 165 | return $relation->getHasCompareKey(); 166 | } 167 | 168 | return $relation instanceof HasOneOrMany ? $relation->getForeignKeyName() : $relation->getOwnerKeyName(); 169 | } 170 | 171 | /** 172 | * @param Collection $relations 173 | * @param string $operator 174 | * @param int $count 175 | * 176 | * @return array 177 | */ 178 | protected function getConstrainedRelatedIds($relations, $operator, $count) 179 | { 180 | $relationCount = array_count_values(array_map(function ($id) { 181 | return (string) $id; // Convert Back ObjectIds to Strings 182 | }, is_array($relations) ? $relations : $relations->flatten()->toArray())); 183 | // Remove unwanted related objects based on the operator and count. 184 | $relationCount = array_filter($relationCount, function ($counted) use ($count, $operator) { 185 | // If we are comparing to 0, we always need all results. 186 | if ($count === 0) { 187 | return true; 188 | } 189 | 190 | switch ($operator) { 191 | case '>=': 192 | case '<': 193 | return $counted >= $count; 194 | case '>': 195 | case '<=': 196 | return $counted > $count; 197 | case '=': 198 | case '!=': 199 | return $counted === $count; 200 | } 201 | }); 202 | 203 | // All related ids. 204 | return array_keys($relationCount); 205 | } 206 | 207 | /** 208 | * Returns key we are constraining this parent model's query with. 209 | * 210 | * @return string 211 | * 212 | * @throws Exception 213 | */ 214 | protected function getRelatedConstraintKey(Relation $relation) 215 | { 216 | if ($relation instanceof HasOneOrMany) { 217 | return $relation->getLocalKeyName(); 218 | } 219 | 220 | if ($relation instanceof BelongsTo) { 221 | return $relation->getForeignKeyName(); 222 | } 223 | 224 | if ($relation instanceof BelongsToMany && ! $this->isAcrossConnections($relation)) { 225 | return $this->model->getKeyName(); 226 | } 227 | 228 | throw new Exception(class_basename($relation) . ' is not supported for hybrid query constraints.'); 229 | } 230 | } 231 | -------------------------------------------------------------------------------- /src/MongoDBBusServiceProvider.php: -------------------------------------------------------------------------------- 1 | app->singleton(MongoBatchRepository::class, function (Container $app) { 24 | $connection = $app->make('db')->connection($app->config->get('queue.batching.database')); 25 | 26 | if (! $connection instanceof Connection) { 27 | throw new InvalidArgumentException(sprintf('The "mongodb" batch driver requires a MongoDB connection. The "%s" connection uses the "%s" driver.', $connection->getName(), $connection->getDriverName())); 28 | } 29 | 30 | return new MongoBatchRepository( 31 | $app->make(BatchFactory::class), 32 | $connection, 33 | $app->config->get('queue.batching.collection', 'job_batches'), 34 | ); 35 | }); 36 | 37 | /** The {@see BatchRepository} service is registered in {@see BusServiceProvider} */ 38 | $this->app->register(BusServiceProvider::class); 39 | $this->app->extend(BatchRepository::class, function (BatchRepository $repository, Container $app) { 40 | $driver = $app->config->get('queue.batching.driver'); 41 | 42 | return match ($driver) { 43 | 'mongodb' => $app->make(MongoBatchRepository::class), 44 | default => $repository, 45 | }; 46 | }); 47 | } 48 | 49 | public function provides() 50 | { 51 | return [ 52 | BatchRepository::class, 53 | MongoBatchRepository::class, 54 | ]; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/MongoDBServiceProvider.php: -------------------------------------------------------------------------------- 1 | app['db']); 43 | 44 | Model::setEventDispatcher($this->app['events']); 45 | } 46 | 47 | /** 48 | * Register the service provider. 49 | */ 50 | public function register() 51 | { 52 | // Add database driver. 53 | $this->app->resolving('db', function ($db) { 54 | $db->extend('mongodb', function ($config, $name) { 55 | $config['name'] = $name; 56 | 57 | return new Connection($config); 58 | }); 59 | }); 60 | 61 | // Session handler for MongoDB 62 | $this->app->resolving(SessionManager::class, function (SessionManager $sessionManager) { 63 | $sessionManager->extend('mongodb', function (Application $app) { 64 | $connectionName = $app->config->get('session.connection') ?: 'mongodb'; 65 | $connection = $app->make('db')->connection($connectionName); 66 | 67 | assert($connection instanceof Connection, new InvalidArgumentException(sprintf('The database connection "%s" used for the session does not use the "mongodb" driver.', $connectionName))); 68 | 69 | return new MongoDbSessionHandler( 70 | $connection, 71 | $app->config->get('session.table', 'sessions'), 72 | $app->config->get('session.lifetime'), 73 | $app, 74 | ); 75 | }); 76 | }); 77 | 78 | // Add cache and lock drivers. 79 | $this->app->resolving('cache', function (CacheManager $cache) { 80 | $cache->extend('mongodb', function (Application $app, array $config): Repository { 81 | // The closure is bound to the CacheManager 82 | assert($this instanceof CacheManager); 83 | 84 | $store = new MongoStore( 85 | $app['db']->connection($config['connection'] ?? null), 86 | $config['collection'] ?? 'cache', 87 | $this->getPrefix($config), 88 | $app['db']->connection($config['lock_connection'] ?? $config['connection'] ?? null), 89 | $config['lock_collection'] ?? ($config['collection'] ?? 'cache') . '_locks', 90 | $config['lock_lottery'] ?? [2, 100], 91 | $config['lock_timeout'] ?? 86400, 92 | ); 93 | 94 | return $this->repository($store, $config); 95 | }); 96 | }); 97 | 98 | // Add connector for queue support. 99 | $this->app->resolving('queue', function ($queue) { 100 | $queue->addConnector('mongodb', function () { 101 | return new MongoConnector($this->app['db']); 102 | }); 103 | }); 104 | 105 | $this->registerFlysystemAdapter(); 106 | $this->registerScoutEngine(); 107 | } 108 | 109 | private function registerFlysystemAdapter(): void 110 | { 111 | // GridFS adapter for filesystem 112 | $this->app->resolving('filesystem', static function (FilesystemManager $filesystemManager) { 113 | $filesystemManager->extend('gridfs', static function (Application $app, array $config) { 114 | if (! class_exists(GridFSAdapter::class)) { 115 | throw new RuntimeException('GridFS adapter for Flysystem is missing. Try running "composer require league/flysystem-gridfs"'); 116 | } 117 | 118 | $bucket = $config['bucket'] ?? null; 119 | 120 | if ($bucket instanceof Closure) { 121 | // Get the bucket from a factory function 122 | $bucket = $bucket($app, $config); 123 | } elseif (is_string($bucket) && $app->has($bucket)) { 124 | // Get the bucket from a service 125 | $bucket = $app->get($bucket); 126 | } elseif (is_string($bucket) || $bucket === null) { 127 | // Get the bucket from the database connection 128 | $connection = $app['db']->connection($config['connection']); 129 | if (! $connection instanceof Connection) { 130 | throw new InvalidArgumentException(sprintf('The database connection "%s" does not use the "mongodb" driver.', $config['connection'] ?? $app['config']['database.default'])); 131 | } 132 | 133 | $bucket = $connection->getClient() 134 | ->getDatabase($config['database'] ?? $connection->getDatabaseName()) 135 | ->selectGridFSBucket(['bucketName' => $config['bucket'] ?? 'fs', 'disableMD5' => true]); 136 | } 137 | 138 | if (! $bucket instanceof Bucket) { 139 | throw new InvalidArgumentException(sprintf('Unexpected value for GridFS "bucket" configuration. Expecting "%s". Got "%s"', Bucket::class, get_debug_type($bucket))); 140 | } 141 | 142 | $adapter = new GridFSAdapter($bucket, $config['prefix'] ?? ''); 143 | 144 | /** @see FilesystemManager::createFlysystem() */ 145 | if ($config['read-only'] ?? false) { 146 | if (! class_exists(ReadOnlyFilesystemAdapter::class)) { 147 | throw new RuntimeException('Read-only Adapter for Flysystem is missing. Try running "composer require league/flysystem-read-only"'); 148 | } 149 | 150 | $adapter = new ReadOnlyFilesystemAdapter($adapter); 151 | } 152 | 153 | /** Prevent using backslash on Windows in {@see FilesystemAdapter::__construct()} */ 154 | $config['directory_separator'] = '/'; 155 | 156 | return new FilesystemAdapter(new Filesystem($adapter, $config), $adapter, $config); 157 | }); 158 | }); 159 | } 160 | 161 | private function registerScoutEngine(): void 162 | { 163 | $this->app->resolving(EngineManager::class, function (EngineManager $engineManager) { 164 | $engineManager->extend('mongodb', function (Container $app) { 165 | $connectionName = $app->get('config')->get('scout.mongodb.connection', 'mongodb'); 166 | $connection = $app->get('db')->connection($connectionName); 167 | $softDelete = (bool) $app->get('config')->get('scout.soft_delete', false); 168 | $indexDefinitions = $app->get('config')->get('scout.mongodb.index-definitions', []); 169 | 170 | assert($connection instanceof Connection, new InvalidArgumentException(sprintf('The connection "%s" is not a MongoDB connection.', $connectionName))); 171 | 172 | return new ScoutEngine($connection->getDatabase(), $softDelete, $indexDefinitions); 173 | }); 174 | 175 | return $engineManager; 176 | }); 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /src/Query/AggregationBuilder.php: -------------------------------------------------------------------------------- 1 | pipeline[] = [$operator => $value]; 42 | 43 | return $this; 44 | } 45 | 46 | /** 47 | * Execute the aggregation pipeline and return the results. 48 | */ 49 | public function get(array $options = []): LaravelCollection|LazyCollection 50 | { 51 | $cursor = $this->execute($options); 52 | 53 | return collect($cursor->toArray()); 54 | } 55 | 56 | /** 57 | * Execute the aggregation pipeline and return the results in a lazy collection. 58 | */ 59 | public function cursor($options = []): LazyCollection 60 | { 61 | $cursor = $this->execute($options); 62 | 63 | return LazyCollection::make(function () use ($cursor) { 64 | foreach ($cursor as $item) { 65 | yield $item; 66 | } 67 | }); 68 | } 69 | 70 | /** 71 | * Execute the aggregation pipeline and return the first result. 72 | */ 73 | public function first(array $options = []): mixed 74 | { 75 | return (clone $this) 76 | ->limit(1) 77 | ->get($options) 78 | ->first(); 79 | } 80 | 81 | /** 82 | * Execute the aggregation pipeline and return MongoDB cursor. 83 | */ 84 | private function execute(array $options): CursorInterface&Iterator 85 | { 86 | $encoder = new BuilderEncoder(); 87 | $pipeline = $encoder->encode($this->getPipeline()); 88 | 89 | $options = array_replace( 90 | ['typeMap' => ['root' => 'array', 'document' => 'array']], 91 | $this->options, 92 | $options, 93 | ); 94 | 95 | return $this->collection->aggregate($pipeline, $options); 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/Query/Grammar.php: -------------------------------------------------------------------------------- 1 | connections = $connections; 30 | } 31 | 32 | /** 33 | * Establish a queue connection. 34 | * 35 | * @return Queue 36 | */ 37 | public function connect(array $config) 38 | { 39 | if (! isset($config['collection']) && isset($config['table'])) { 40 | trigger_error('Since mongodb/laravel-mongodb 4.4: Using "table" option in queue configuration is deprecated. Use "collection" instead.', E_USER_DEPRECATED); 41 | $config['collection'] = $config['table']; 42 | } 43 | 44 | if (! isset($config['retry_after']) && isset($config['expire'])) { 45 | trigger_error('Since mongodb/laravel-mongodb 4.4: Using "expire" option in queue configuration is deprecated. Use "retry_after" instead.', E_USER_DEPRECATED); 46 | $config['retry_after'] = $config['expire']; 47 | } 48 | 49 | return new MongoQueue( 50 | $this->connections->connection($config['connection'] ?? null), 51 | $config['collection'] ?? 'jobs', 52 | $config['queue'] ?? 'default', 53 | $config['retry_after'] ?? 60, 54 | ); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/Queue/MongoJob.php: -------------------------------------------------------------------------------- 1 | job->reserved; 20 | } 21 | 22 | /** @return DateTime */ 23 | public function reservedAt() 24 | { 25 | return $this->job->reserved_at; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Queue/MongoQueue.php: -------------------------------------------------------------------------------- 1 | retryAfter = $retryAfter; 35 | } 36 | 37 | /** @inheritdoc */ 38 | public function pop($queue = null) 39 | { 40 | $queue = $this->getQueue($queue); 41 | 42 | if ($this->retryAfter !== null) { 43 | $this->releaseJobsThatHaveBeenReservedTooLong($queue); 44 | } 45 | 46 | $job = $this->getNextAvailableJobAndReserve($queue); 47 | if (! $job) { 48 | return null; 49 | } 50 | 51 | return new MongoJob( 52 | $this->container, 53 | $this, 54 | $job, 55 | $this->connectionName, 56 | $queue, 57 | ); 58 | } 59 | 60 | /** 61 | * Get the next available job for the queue and mark it as reserved. 62 | * When using multiple daemon queue listeners to process jobs there 63 | * is a possibility that multiple processes can end up reading the 64 | * same record before one has flagged it as reserved. 65 | * This race condition can result in random jobs being run more than 66 | * once. To solve this we use findOneAndUpdate to lock the next jobs 67 | * record while flagging it as reserved at the same time. 68 | * 69 | * @param string|null $queue 70 | * 71 | * @return stdClass|null 72 | */ 73 | protected function getNextAvailableJobAndReserve($queue) 74 | { 75 | $job = $this->database->getCollection($this->table)->findOneAndUpdate( 76 | [ 77 | 'queue' => $this->getQueue($queue), 78 | 'reserved' => ['$ne' => 1], 79 | 'available_at' => ['$lte' => Carbon::now()->getTimestamp()], 80 | ], 81 | [ 82 | '$set' => [ 83 | 'reserved' => 1, 84 | 'reserved_at' => Carbon::now()->getTimestamp(), 85 | ], 86 | '$inc' => ['attempts' => 1], 87 | ], 88 | [ 89 | 'returnDocument' => FindOneAndUpdate::RETURN_DOCUMENT_AFTER, 90 | 'sort' => ['available_at' => 1], 91 | ], 92 | ); 93 | 94 | if ($job) { 95 | $job->id = $job->_id; 96 | } 97 | 98 | return $job; 99 | } 100 | 101 | /** 102 | * Release the jobs that have been reserved for too long. 103 | * 104 | * @param string $queue 105 | * 106 | * @return void 107 | */ 108 | protected function releaseJobsThatHaveBeenReservedTooLong($queue) 109 | { 110 | $expiration = Carbon::now()->subSeconds($this->retryAfter)->getTimestamp(); 111 | 112 | $reserved = $this->database->table($this->table) 113 | ->where('queue', $this->getQueue($queue)) 114 | ->whereNotNull('reserved_at') 115 | ->where('reserved_at', '<=', $expiration) 116 | ->get(); 117 | 118 | foreach ($reserved as $job) { 119 | $this->releaseJob($job->id, $job->attempts); 120 | } 121 | } 122 | 123 | /** 124 | * Release the given job ID from reservation. 125 | * 126 | * @param string $id 127 | * @param int $attempts 128 | * 129 | * @return void 130 | */ 131 | protected function releaseJob($id, $attempts) 132 | { 133 | $this->database->table($this->table)->where('_id', $id)->update([ 134 | 'reserved' => 0, 135 | 'reserved_at' => null, 136 | 'attempts' => $attempts, 137 | ]); 138 | } 139 | 140 | /** @inheritdoc */ 141 | public function deleteReserved($queue, $id) 142 | { 143 | $this->database->table($this->table)->where('_id', $id)->delete(); 144 | } 145 | 146 | /** @inheritdoc */ 147 | public function deleteAndRelease($queue, $job, $delay) 148 | { 149 | $this->deleteReserved($queue, $job->getJobId()); 150 | $this->release($queue, $job->getJobRecord(), $delay); 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /src/Relations/BelongsTo.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | class BelongsTo extends EloquentBelongsTo 17 | { 18 | /** 19 | * Get the key for comparing against the parent key in "has" query. 20 | * 21 | * @return string 22 | */ 23 | public function getHasCompareKey() 24 | { 25 | return $this->ownerKey; 26 | } 27 | 28 | /** @inheritdoc */ 29 | public function addConstraints() 30 | { 31 | if (static::$constraints) { 32 | // For belongs to relationships, which are essentially the inverse of has one 33 | // or has many relationships, we need to actually query on the primary key 34 | // of the related models matching on the foreign key that's on a parent. 35 | $this->query->where($this->ownerKey, '=', $this->parent->{$this->foreignKey}); 36 | } 37 | } 38 | 39 | /** @inheritdoc */ 40 | public function addEagerConstraints(array $models) 41 | { 42 | // We'll grab the primary key name of the related models since it could be set to 43 | // a non-standard name and not "id". We will then construct the constraint for 44 | // our eagerly loading query so it returns the proper models from execution. 45 | $this->query->whereIn($this->ownerKey, $this->getEagerModelKeys($models)); 46 | } 47 | 48 | /** @inheritdoc */ 49 | public function getRelationExistenceQuery(Builder $query, Builder $parentQuery, $columns = ['*']) 50 | { 51 | return $query; 52 | } 53 | 54 | /** 55 | * Get the name of the "where in" method for eager loading. 56 | * 57 | * @param string $key 58 | * 59 | * @return string 60 | */ 61 | protected function whereInMethod(Model $model, $key) 62 | { 63 | return 'whereIn'; 64 | } 65 | 66 | public function getQualifiedForeignKeyName(): string 67 | { 68 | return $this->foreignKey; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/Relations/BelongsToMany.php: -------------------------------------------------------------------------------- 1 | 28 | */ 29 | class BelongsToMany extends EloquentBelongsToMany 30 | { 31 | /** 32 | * Get the key for comparing against the parent key in "has" query. 33 | * 34 | * @return string 35 | */ 36 | public function getHasCompareKey() 37 | { 38 | return $this->getForeignKey(); 39 | } 40 | 41 | /** @inheritdoc */ 42 | public function getRelationExistenceQuery(Builder $query, Builder $parentQuery, $columns = ['*']) 43 | { 44 | return $query; 45 | } 46 | 47 | /** @inheritdoc */ 48 | protected function hydratePivotRelation(array $models) 49 | { 50 | // Do nothing. 51 | } 52 | 53 | /** 54 | * Set the select clause for the relation query. 55 | * 56 | * @return array 57 | */ 58 | protected function getSelectColumns(array $columns = ['*']) 59 | { 60 | return $columns; 61 | } 62 | 63 | /** @inheritdoc */ 64 | protected function shouldSelect(array $columns = ['*']) 65 | { 66 | return $columns; 67 | } 68 | 69 | /** @inheritdoc */ 70 | public function addConstraints() 71 | { 72 | if (static::$constraints) { 73 | $this->setWhere(); 74 | } 75 | } 76 | 77 | /** 78 | * Set the where clause for the relation query. 79 | * 80 | * @return $this 81 | */ 82 | protected function setWhere() 83 | { 84 | $foreign = $this->getForeignKey(); 85 | 86 | $this->query->where($foreign, '=', $this->parent->{$this->parentKey}); 87 | 88 | return $this; 89 | } 90 | 91 | /** @inheritdoc */ 92 | public function save(Model $model, array $pivotAttributes = [], $touch = true) 93 | { 94 | $model->save(['touch' => false]); 95 | 96 | $this->attach($model, $pivotAttributes, $touch); 97 | 98 | return $model; 99 | } 100 | 101 | /** @inheritdoc */ 102 | public function create(array $attributes = [], array $joining = [], $touch = true) 103 | { 104 | $instance = $this->related->newInstance($attributes); 105 | 106 | // Once we save the related model, we need to attach it to the base model via 107 | // through intermediate table so we'll use the existing "attach" method to 108 | // accomplish this which will insert the record and any more attributes. 109 | $instance->save(['touch' => false]); 110 | 111 | $this->attach($instance, $joining, $touch); 112 | 113 | return $instance; 114 | } 115 | 116 | /** @inheritdoc */ 117 | public function sync($ids, $detaching = true) 118 | { 119 | $changes = [ 120 | 'attached' => [], 121 | 'detached' => [], 122 | 'updated' => [], 123 | ]; 124 | 125 | if ($ids instanceof Collection) { 126 | $ids = $this->parseIds($ids); 127 | } elseif ($ids instanceof Model) { 128 | $ids = $this->parseIds($ids); 129 | } 130 | 131 | // First we need to attach any of the associated models that are not currently 132 | // in this joining table. We'll spin through the given IDs, checking to see 133 | // if they exist in the array of current ones, and if not we will insert. 134 | $current = match (\MongoDB\Laravel\Eloquent\Model::isDocumentModel($this->parent)) { 135 | true => $this->parent->{$this->relatedPivotKey} ?: [], 136 | false => $this->parent->{$this->relationName} ?: [], 137 | }; 138 | 139 | if ($current instanceof Collection) { 140 | $current = $this->parseIds($current); 141 | } 142 | 143 | $records = $this->formatRecordsList($ids); 144 | 145 | $current = Arr::wrap($current); 146 | 147 | $detach = array_diff($current, array_keys($records)); 148 | 149 | // We need to make sure we pass a clean array, so that it is not interpreted 150 | // as an associative array. 151 | $detach = array_values($detach); 152 | 153 | // Next, we will take the differences of the currents and given IDs and detach 154 | // all of the entities that exist in the "current" array but are not in the 155 | // the array of the IDs given to the method which will complete the sync. 156 | if ($detaching && count($detach) > 0) { 157 | $this->detach($detach); 158 | 159 | $changes['detached'] = (array) array_map(function ($v) { 160 | return is_numeric($v) ? (int) $v : (string) $v; 161 | }, $detach); 162 | } 163 | 164 | // Now we are finally ready to attach the new records. Note that we'll disable 165 | // touching until after the entire operation is complete so we don't fire a 166 | // ton of touch operations until we are totally done syncing the records. 167 | $changes = array_replace( 168 | $changes, 169 | $this->attachNew($records, $current, false), 170 | ); 171 | 172 | if (count($changes['attached']) || count($changes['updated'])) { 173 | $this->touchIfTouching(); 174 | } 175 | 176 | return $changes; 177 | } 178 | 179 | /** @inheritdoc */ 180 | public function updateExistingPivot($id, array $attributes, $touch = true) 181 | { 182 | // Do nothing, we have no pivot table. 183 | return $this; 184 | } 185 | 186 | /** @inheritdoc */ 187 | public function attach($id, array $attributes = [], $touch = true) 188 | { 189 | if ($id instanceof Model) { 190 | $model = $id; 191 | 192 | $id = $this->parseId($model); 193 | 194 | // Attach the new parent id to the related model. 195 | $model->push($this->foreignPivotKey, $this->parent->{$this->parentKey}, true); 196 | } else { 197 | if ($id instanceof Collection) { 198 | $id = $this->parseIds($id); 199 | } 200 | 201 | $query = $this->newRelatedQuery(); 202 | 203 | $query->whereIn($this->relatedKey, (array) $id); 204 | 205 | // Attach the new parent id to the related model. 206 | $query->push($this->foreignPivotKey, $this->parent->{$this->parentKey}, true); 207 | } 208 | 209 | // Attach the new ids to the parent model. 210 | if (\MongoDB\Laravel\Eloquent\Model::isDocumentModel($this->parent)) { 211 | $this->parent->push($this->relatedPivotKey, (array) $id, true); 212 | } else { 213 | $instance = new $this->related(); 214 | $instance->forceFill([$this->relatedKey => $id]); 215 | $relationData = $this->parent->{$this->relationName}->push($instance)->unique($this->relatedKey); 216 | $this->parent->setRelation($this->relationName, $relationData); 217 | } 218 | 219 | if (! $touch) { 220 | return; 221 | } 222 | 223 | $this->touchIfTouching(); 224 | } 225 | 226 | /** @inheritdoc */ 227 | public function detach($ids = [], $touch = true) 228 | { 229 | if ($ids instanceof Model) { 230 | $ids = $this->parseIds($ids); 231 | } 232 | 233 | $query = $this->newRelatedQuery(); 234 | 235 | // If associated IDs were passed to the method we will only delete those 236 | // associations, otherwise all of the association ties will be broken. 237 | // We'll return the numbers of affected rows when we do the deletes. 238 | $ids = (array) $ids; 239 | 240 | // Detach all ids from the parent model. 241 | if (DocumentModel::isDocumentModel($this->parent)) { 242 | $this->parent->pull($this->relatedPivotKey, $ids); 243 | } else { 244 | $value = $this->parent->{$this->relationName} 245 | ->filter(fn ($rel) => ! in_array($rel->{$this->relatedKey}, $ids)); 246 | $this->parent->setRelation($this->relationName, $value); 247 | } 248 | 249 | // Prepare the query to select all related objects. 250 | if (count($ids) > 0) { 251 | $query->whereIn($this->relatedKey, $ids); 252 | } 253 | 254 | // Remove the relation to the parent. 255 | assert($this->parent instanceof Model); 256 | assert($query instanceof \MongoDB\Laravel\Eloquent\Builder); 257 | $query->pull($this->foreignPivotKey, $this->parent->{$this->parentKey}); 258 | 259 | if ($touch) { 260 | $this->touchIfTouching(); 261 | } 262 | 263 | return count($ids); 264 | } 265 | 266 | /** @inheritdoc */ 267 | protected function buildDictionary(Collection $results) 268 | { 269 | $foreign = $this->foreignPivotKey; 270 | 271 | // First we will build a dictionary of child models keyed by the foreign key 272 | // of the relation so that we will easily and quickly match them to their 273 | // parents without having a possibly slow inner loops for every models. 274 | $dictionary = []; 275 | 276 | foreach ($results as $result) { 277 | foreach ($result->$foreign as $item) { 278 | $dictionary[$item][] = $result; 279 | } 280 | } 281 | 282 | return $dictionary; 283 | } 284 | 285 | /** @inheritdoc */ 286 | public function newPivotQuery() 287 | { 288 | return $this->newRelatedQuery(); 289 | } 290 | 291 | /** 292 | * Create a new query builder for the related model. 293 | * 294 | * @return Builder|Model 295 | */ 296 | public function newRelatedQuery() 297 | { 298 | return $this->related->newQuery(); 299 | } 300 | 301 | /** 302 | * Get the fully qualified foreign key for the relation. 303 | * 304 | * @return string 305 | */ 306 | public function getForeignKey() 307 | { 308 | return $this->foreignPivotKey; 309 | } 310 | 311 | /** @inheritdoc */ 312 | public function getQualifiedForeignPivotKeyName() 313 | { 314 | return $this->foreignPivotKey; 315 | } 316 | 317 | /** @inheritdoc */ 318 | public function getQualifiedRelatedPivotKeyName() 319 | { 320 | return $this->relatedPivotKey; 321 | } 322 | 323 | /** 324 | * Get the name of the "where in" method for eager loading. 325 | * 326 | * @param string $key 327 | * 328 | * @return string 329 | */ 330 | protected function whereInMethod(Model $model, $key) 331 | { 332 | return 'whereIn'; 333 | } 334 | } 335 | -------------------------------------------------------------------------------- /src/Relations/EmbedsMany.php: -------------------------------------------------------------------------------- 1 | 29 | */ 30 | class EmbedsMany extends EmbedsOneOrMany 31 | { 32 | /** @inheritdoc */ 33 | public function initRelation(array $models, $relation) 34 | { 35 | foreach ($models as $model) { 36 | $model->setRelation($relation, $this->related->newCollection()); 37 | } 38 | 39 | return $models; 40 | } 41 | 42 | /** @inheritdoc */ 43 | public function getResults() 44 | { 45 | return $this->toCollection($this->getEmbedded()); 46 | } 47 | 48 | /** 49 | * Save a new model and attach it to the parent model. 50 | * 51 | * @return Model|bool 52 | */ 53 | public function performInsert(Model $model) 54 | { 55 | // Create a new key if needed. 56 | if (($model->getKeyName() === '_id' || $model->getKeyName() === 'id') && ! $model->getKey()) { 57 | $model->setAttribute($model->getKeyName(), new ObjectID()); 58 | } 59 | 60 | // For deeply nested documents, let the parent handle the changes. 61 | if ($this->isNested()) { 62 | $this->associate($model); 63 | 64 | return $this->parent->save() ? $model : false; 65 | } 66 | 67 | // Push the new model to the database. 68 | $result = $this->toBase()->push($this->localKey, $model->getAttributes(), true); 69 | 70 | // Attach the model to its parent. 71 | if ($result) { 72 | $this->associate($model); 73 | } 74 | 75 | return $result ? $model : false; 76 | } 77 | 78 | /** 79 | * Save an existing model and attach it to the parent model. 80 | * 81 | * @return Model|bool 82 | */ 83 | public function performUpdate(Model $model) 84 | { 85 | // For deeply nested documents, let the parent handle the changes. 86 | if ($this->isNested()) { 87 | $this->associate($model); 88 | 89 | return $this->parent->save(); 90 | } 91 | 92 | // Get the correct foreign key value. 93 | $foreignKey = $this->getForeignKeyValue($model); 94 | 95 | $values = self::getUpdateValues($model->getDirty(), $this->localKey . '.$.'); 96 | 97 | // Update document in database. 98 | $result = $this->toBase()->where($this->localKey . '.' . $model->getKeyName(), $foreignKey) 99 | ->update($values); 100 | 101 | // Attach the model to its parent. 102 | if ($result) { 103 | $this->associate($model); 104 | } 105 | 106 | return $result ? $model : false; 107 | } 108 | 109 | /** 110 | * Delete an existing model and detach it from the parent model. 111 | * 112 | * @return int 113 | */ 114 | public function performDelete(Model $model) 115 | { 116 | // For deeply nested documents, let the parent handle the changes. 117 | if ($this->isNested()) { 118 | $this->dissociate($model); 119 | 120 | return $this->parent->save(); 121 | } 122 | 123 | // Get the correct foreign key value. 124 | $foreignKey = $this->getForeignKeyValue($model); 125 | 126 | $result = $this->toBase()->pull($this->localKey, [$model->getKeyName() => $foreignKey]); 127 | 128 | if ($result) { 129 | $this->dissociate($model); 130 | } 131 | 132 | return $result; 133 | } 134 | 135 | /** 136 | * Associate the model instance to the given parent, without saving it to the database. 137 | * 138 | * @return Model 139 | */ 140 | public function associate(Model $model) 141 | { 142 | if (! $this->contains($model)) { 143 | return $this->associateNew($model); 144 | } 145 | 146 | return $this->associateExisting($model); 147 | } 148 | 149 | /** 150 | * Dissociate the model instance from the given parent, without saving it to the database. 151 | * 152 | * @param mixed $ids 153 | * 154 | * @return int 155 | */ 156 | public function dissociate($ids = []) 157 | { 158 | $ids = $this->getIdsArrayFrom($ids); 159 | 160 | $records = $this->getEmbedded(); 161 | 162 | $primaryKey = $this->related->getKeyName(); 163 | 164 | // Remove the document from the parent model. 165 | foreach ($records as $i => $record) { 166 | if (array_key_exists($primaryKey, $record) && in_array($record[$primaryKey], $ids)) { 167 | unset($records[$i]); 168 | } 169 | } 170 | 171 | $this->setEmbedded($records); 172 | 173 | // We return the total number of deletes for the operation. The developers 174 | // can then check this number as a boolean type value or get this total count 175 | // of records deleted for logging, etc. 176 | return count($ids); 177 | } 178 | 179 | /** 180 | * Destroy the embedded models for the given IDs. 181 | * 182 | * @param mixed $ids 183 | * 184 | * @return int 185 | */ 186 | public function destroy($ids = []) 187 | { 188 | $count = 0; 189 | 190 | $ids = $this->getIdsArrayFrom($ids); 191 | 192 | // Get all models matching the given ids. 193 | $models = $this->getResults()->only($ids); 194 | 195 | // Pull the documents from the database. 196 | foreach ($models as $model) { 197 | if ($model->delete()) { 198 | $count++; 199 | } 200 | } 201 | 202 | return $count; 203 | } 204 | 205 | /** 206 | * Delete all embedded models. 207 | * 208 | * @param null $id 209 | * 210 | * @note The $id is not used to delete embedded models. 211 | */ 212 | public function delete($id = null): int 213 | { 214 | throw_if($id !== null, new LogicException('The id parameter should not be used.')); 215 | 216 | // Overwrite the local key with an empty array. 217 | $result = $this->query->update([$this->localKey => []]); 218 | 219 | if ($result) { 220 | $this->setEmbedded([]); 221 | } 222 | 223 | return $result; 224 | } 225 | 226 | /** 227 | * Destroy alias. 228 | * 229 | * @param mixed $ids 230 | * 231 | * @return int 232 | */ 233 | public function detach($ids = []) 234 | { 235 | return $this->destroy($ids); 236 | } 237 | 238 | /** 239 | * Save alias. 240 | * 241 | * @return Model 242 | */ 243 | public function attach(Model $model) 244 | { 245 | return $this->save($model); 246 | } 247 | 248 | /** 249 | * Associate a new model instance to the given parent, without saving it to the database. 250 | * 251 | * @param Model $model 252 | * 253 | * @return Model 254 | */ 255 | protected function associateNew($model) 256 | { 257 | // Create a new key if needed. 258 | if (($model->getKeyName() === '_id' || $model->getKeyName() === 'id') && ! $model->getKey()) { 259 | $model->setAttribute($model->getKeyName(), new ObjectID()); 260 | } 261 | 262 | $records = $this->getEmbedded(); 263 | 264 | // Add the new model to the embedded documents. 265 | $records[] = $model->getAttributes(); 266 | 267 | return $this->setEmbedded($records); 268 | } 269 | 270 | /** 271 | * Associate an existing model instance to the given parent, without saving it to the database. 272 | * 273 | * @param Model $model 274 | * 275 | * @return Model 276 | */ 277 | protected function associateExisting($model) 278 | { 279 | // Get existing embedded documents. 280 | $records = $this->getEmbedded(); 281 | 282 | $primaryKey = $this->related->getKeyName(); 283 | 284 | $key = $model->getKey(); 285 | 286 | // Replace the document in the parent model. 287 | foreach ($records as &$record) { 288 | // @phpcs:ignore SlevomatCodingStandard.Operators.DisallowEqualOperators 289 | if ($record[$primaryKey] == $key) { 290 | $record = $model->getAttributes(); 291 | break; 292 | } 293 | } 294 | 295 | return $this->setEmbedded($records); 296 | } 297 | 298 | /** 299 | * @param int|Closure $perPage 300 | * @param array|string $columns 301 | * @param string $pageName 302 | * @param int|null $page 303 | * @param Closure|int|null $total 304 | * 305 | * @return \Illuminate\Contracts\Pagination\LengthAwarePaginator 306 | */ 307 | public function paginate($perPage = null, $columns = ['*'], $pageName = 'page', $page = null, $total = null) 308 | { 309 | $page = $page ?: Paginator::resolveCurrentPage($pageName); 310 | $results = $this->getEmbedded(); 311 | $results = $this->toCollection($results); 312 | $total = value($total) ?? $results->count(); 313 | $perPage = $perPage ?: $this->related->getPerPage(); 314 | $perPage = $perPage instanceof Closure ? $perPage($total) : $perPage; 315 | $start = ($page - 1) * $perPage; 316 | 317 | $sliced = $results->slice( 318 | $start, 319 | $perPage, 320 | ); 321 | 322 | return new LengthAwarePaginator( 323 | $sliced, 324 | $total, 325 | $perPage, 326 | $page, 327 | [ 328 | 'path' => Paginator::resolveCurrentPath(), 329 | ], 330 | ); 331 | } 332 | 333 | /** @inheritdoc */ 334 | protected function getEmbedded() 335 | { 336 | return parent::getEmbedded() ?: []; 337 | } 338 | 339 | /** @inheritdoc */ 340 | protected function setEmbedded($records) 341 | { 342 | if (! is_array($records)) { 343 | $records = [$records]; 344 | } 345 | 346 | return parent::setEmbedded(array_values($records)); 347 | } 348 | 349 | /** @inheritdoc */ 350 | public function __call($method, $parameters) 351 | { 352 | if (method_exists(Collection::class, $method)) { 353 | return $this->getResults()->$method(...$parameters); 354 | } 355 | 356 | return parent::__call($method, $parameters); 357 | } 358 | 359 | /** 360 | * Get the name of the "where in" method for eager loading. 361 | * 362 | * @param string $key 363 | * 364 | * @return string 365 | */ 366 | protected function whereInMethod(Model $model, $key) 367 | { 368 | return 'whereIn'; 369 | } 370 | } 371 | -------------------------------------------------------------------------------- /src/Relations/EmbedsOne.php: -------------------------------------------------------------------------------- 1 | 19 | */ 20 | class EmbedsOne extends EmbedsOneOrMany 21 | { 22 | public function initRelation(array $models, $relation) 23 | { 24 | foreach ($models as $model) { 25 | $model->setRelation($relation, null); 26 | } 27 | 28 | return $models; 29 | } 30 | 31 | public function getResults() 32 | { 33 | return $this->toModel($this->getEmbedded()); 34 | } 35 | 36 | public function getEager() 37 | { 38 | $eager = $this->get(); 39 | 40 | // EmbedsOne only brings one result, Eager needs a collection! 41 | return $this->toCollection([$eager]); 42 | } 43 | 44 | /** 45 | * Save a new model and attach it to the parent model. 46 | * 47 | * @return Model|bool 48 | */ 49 | public function performInsert(Model $model) 50 | { 51 | // Create a new key if needed. 52 | if (($model->getKeyName() === '_id' || $model->getKeyName() === 'id') && ! $model->getKey()) { 53 | $model->setAttribute($model->getKeyName(), new ObjectID()); 54 | } 55 | 56 | // For deeply nested documents, let the parent handle the changes. 57 | if ($this->isNested()) { 58 | $this->associate($model); 59 | 60 | return $this->parent->save() ? $model : false; 61 | } 62 | 63 | $result = $this->toBase()->update([$this->localKey => $model->getAttributes()]); 64 | 65 | // Attach the model to its parent. 66 | if ($result) { 67 | $this->associate($model); 68 | } 69 | 70 | return $result ? $model : false; 71 | } 72 | 73 | /** 74 | * Save an existing model and attach it to the parent model. 75 | * 76 | * @return Model|bool 77 | */ 78 | public function performUpdate(Model $model) 79 | { 80 | if ($this->isNested()) { 81 | $this->associate($model); 82 | 83 | return $this->parent->save(); 84 | } 85 | 86 | $values = self::getUpdateValues($model->getDirty(), $this->localKey . '.'); 87 | 88 | $result = $this->toBase()->update($values); 89 | 90 | // Attach the model to its parent. 91 | if ($result) { 92 | $this->associate($model); 93 | } 94 | 95 | return $result ? $model : false; 96 | } 97 | 98 | /** 99 | * Delete an existing model and detach it from the parent model. 100 | * 101 | * @return int 102 | */ 103 | public function performDelete() 104 | { 105 | // For deeply nested documents, let the parent handle the changes. 106 | if ($this->isNested()) { 107 | $this->dissociate(); 108 | 109 | return $this->parent->save(); 110 | } 111 | 112 | // Overwrite the local key with an empty array. 113 | $result = $this->toBase()->update([$this->localKey => null]); 114 | 115 | // Detach the model from its parent. 116 | if ($result) { 117 | $this->dissociate(); 118 | } 119 | 120 | return $result; 121 | } 122 | 123 | /** 124 | * Attach the model to its parent. 125 | * 126 | * @return Model 127 | */ 128 | public function associate(Model $model) 129 | { 130 | return $this->setEmbedded($model->getAttributes()); 131 | } 132 | 133 | /** 134 | * Detach the model from its parent. 135 | * 136 | * @return Model 137 | */ 138 | public function dissociate() 139 | { 140 | return $this->setEmbedded(null); 141 | } 142 | 143 | /** 144 | * Delete all embedded models. 145 | * 146 | * @param ?string $id 147 | * 148 | * @throws LogicException|Throwable 149 | * 150 | * @note The $id is not used to delete embedded models. 151 | */ 152 | public function delete($id = null): int 153 | { 154 | throw_if($id !== null, new LogicException('The id parameter should not be used.')); 155 | 156 | return $this->performDelete(); 157 | } 158 | 159 | /** 160 | * Get the name of the "where in" method for eager loading. 161 | * 162 | * @param string $key 163 | * 164 | * @return string 165 | */ 166 | protected function whereInMethod(Model $model, $key) 167 | { 168 | return 'whereIn'; 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /src/Relations/EmbedsOneOrMany.php: -------------------------------------------------------------------------------- 1 | 29 | */ 30 | abstract class EmbedsOneOrMany extends Relation 31 | { 32 | /** 33 | * The local key of the parent model. 34 | * 35 | * @var string 36 | */ 37 | protected $localKey; 38 | 39 | /** 40 | * The foreign key of the parent model. 41 | * 42 | * @var string 43 | */ 44 | protected $foreignKey; 45 | 46 | /** 47 | * The "name" of the relationship. 48 | * 49 | * @var string 50 | */ 51 | protected $relation; 52 | 53 | /** 54 | * Create a new embeds many relationship instance. 55 | */ 56 | public function __construct(Builder $query, Model $parent, Model $related, string $localKey, string $foreignKey, string $relation) 57 | { 58 | if (! DocumentModel::isDocumentModel($parent)) { 59 | throw new LogicException('Parent model must be a document model.'); 60 | } 61 | 62 | if (! DocumentModel::isDocumentModel($related)) { 63 | throw new LogicException('Related model must be a document model.'); 64 | } 65 | 66 | parent::__construct($query, $parent); 67 | 68 | $this->related = $related; 69 | $this->localKey = $localKey; 70 | $this->foreignKey = $foreignKey; 71 | $this->relation = $relation; 72 | 73 | // If this is a nested relation, we need to get the parent query instead. 74 | $parentRelation = $this->getParentRelation(); 75 | if ($parentRelation) { 76 | $this->query = $parentRelation->getQuery(); 77 | } 78 | } 79 | 80 | /** @inheritdoc */ 81 | public function addConstraints() 82 | { 83 | if (static::$constraints) { 84 | $this->query->where($this->getQualifiedParentKeyName(), '=', $this->getParentKey()); 85 | } 86 | } 87 | 88 | /** @inheritdoc */ 89 | public function addEagerConstraints(array $models) 90 | { 91 | // There are no eager loading constraints. 92 | } 93 | 94 | /** @inheritdoc */ 95 | public function match(array $models, Collection $results, $relation) 96 | { 97 | foreach ($models as $model) { 98 | $results = $model->$relation()->getResults(); 99 | 100 | $model->setParentRelation($this); 101 | 102 | $model->setRelation($relation, $results); 103 | } 104 | 105 | return $models; 106 | } 107 | 108 | /** 109 | * Shorthand to get the results of the relationship. 110 | * 111 | * @param array $columns 112 | * 113 | * @return Collection 114 | */ 115 | public function get($columns = ['*']) 116 | { 117 | return $this->getResults(); 118 | } 119 | 120 | /** 121 | * Get the number of embedded models. 122 | * 123 | * @param Expression|string $columns 124 | * 125 | * @throws LogicException|Throwable 126 | * 127 | * @note The $column parameter is not used to count embedded models. 128 | */ 129 | public function count($columns = '*'): int 130 | { 131 | throw_if($columns !== '*', new LogicException('The columns parameter should not be used.')); 132 | 133 | return count($this->getEmbedded()); 134 | } 135 | 136 | /** 137 | * Attach a model instance to the parent model. 138 | * 139 | * @return Model|bool 140 | */ 141 | public function save(Model $model) 142 | { 143 | $model->setParentRelation($this); 144 | 145 | return $model->save() ? $model : false; 146 | } 147 | 148 | /** 149 | * Attach a collection of models to the parent instance. 150 | * 151 | * @param Collection|array $models 152 | * 153 | * @return Collection|array 154 | */ 155 | public function saveMany($models) 156 | { 157 | foreach ($models as $model) { 158 | $this->save($model); 159 | } 160 | 161 | return $models; 162 | } 163 | 164 | /** 165 | * Create a new instance of the related model. 166 | * 167 | * @return Model 168 | */ 169 | public function create(array $attributes = []) 170 | { 171 | // Here we will set the raw attributes to avoid hitting the "fill" method so 172 | // that we do not have to worry about a mass accessor rules blocking sets 173 | // on the models. Otherwise, some of these attributes will not get set. 174 | $instance = $this->related->newInstance($attributes); 175 | 176 | $instance->setParentRelation($this); 177 | 178 | $instance->save(); 179 | 180 | return $instance; 181 | } 182 | 183 | /** 184 | * Create an array of new instances of the related model. 185 | * 186 | * @return array 187 | */ 188 | public function createMany(array $records) 189 | { 190 | $instances = []; 191 | 192 | foreach ($records as $record) { 193 | $instances[] = $this->create($record); 194 | } 195 | 196 | return $instances; 197 | } 198 | 199 | /** 200 | * Transform single ID, single Model or array of Models into an array of IDs. 201 | * 202 | * @param mixed $ids 203 | * 204 | * @return array 205 | */ 206 | protected function getIdsArrayFrom($ids) 207 | { 208 | if ($ids instanceof \Illuminate\Support\Collection) { 209 | $ids = $ids->all(); 210 | } 211 | 212 | if (! is_array($ids)) { 213 | $ids = [$ids]; 214 | } 215 | 216 | foreach ($ids as &$id) { 217 | if ($id instanceof Model) { 218 | $id = $id->getKey(); 219 | } 220 | } 221 | 222 | return $ids; 223 | } 224 | 225 | /** @inheritdoc */ 226 | protected function getEmbedded() 227 | { 228 | // Get raw attributes to skip relations and accessors. 229 | $attributes = $this->parent->getAttributes(); 230 | 231 | // Get embedded models form parent attributes. 232 | return isset($attributes[$this->localKey]) ? (array) $attributes[$this->localKey] : null; 233 | } 234 | 235 | /** @inheritdoc */ 236 | protected function setEmbedded($records) 237 | { 238 | // Assign models to parent attributes array. 239 | $attributes = $this->parent->getAttributes(); 240 | $attributes[$this->localKey] = $records; 241 | 242 | // Set raw attributes to skip mutators. 243 | $this->parent->setRawAttributes($attributes); 244 | 245 | // Set the relation on the parent. 246 | return $this->parent->setRelation($this->relation, $records === null ? null : $this->getResults()); 247 | } 248 | 249 | /** 250 | * Get the foreign key value for the relation. 251 | * 252 | * @param mixed $id 253 | * 254 | * @return mixed 255 | */ 256 | protected function getForeignKeyValue($id) 257 | { 258 | if ($id instanceof Model) { 259 | $id = $id->getKey(); 260 | } 261 | 262 | // Convert the id to MongoId if necessary. 263 | return $this->toBase()->convertKey($id); 264 | } 265 | 266 | /** 267 | * Convert an array of records to a Collection. 268 | * 269 | * @return Collection 270 | */ 271 | protected function toCollection(array $records = []) 272 | { 273 | $models = []; 274 | 275 | foreach ($records as $attributes) { 276 | $models[] = $this->toModel($attributes); 277 | } 278 | 279 | if (count($models) > 0) { 280 | $models = $this->eagerLoadRelations($models); 281 | } 282 | 283 | return $this->related->newCollection($models); 284 | } 285 | 286 | /** 287 | * Create a related model instanced. 288 | * 289 | * @param mixed $attributes 290 | * 291 | * @return Model | null 292 | */ 293 | protected function toModel(mixed $attributes = []): Model|null 294 | { 295 | if ($attributes === null) { 296 | return null; 297 | } 298 | 299 | $connection = $this->related->getConnection(); 300 | 301 | $model = $this->related->newFromBuilder( 302 | (array) $attributes, 303 | $connection?->getName(), 304 | ); 305 | 306 | $model->setParentRelation($this); 307 | 308 | $model->setRelation($this->foreignKey, $this->parent); 309 | 310 | // If you remove this, you will get segmentation faults! 311 | $model->setHidden(array_merge($model->getHidden(), [$this->foreignKey])); 312 | 313 | return $model; 314 | } 315 | 316 | /** 317 | * Get the relation instance of the parent. 318 | * 319 | * @return Relation 320 | */ 321 | protected function getParentRelation() 322 | { 323 | return $this->parent->getParentRelation(); 324 | } 325 | 326 | /** @inheritdoc */ 327 | public function getQuery() 328 | { 329 | // Because we are sharing this relation instance to models, we need 330 | // to make sure we use separate query instances. 331 | return clone $this->query; 332 | } 333 | 334 | /** @inheritdoc */ 335 | public function toBase() 336 | { 337 | // Because we are sharing this relation instance to models, we need 338 | // to make sure we use separate query instances. 339 | return clone $this->query->getQuery(); 340 | } 341 | 342 | /** 343 | * Check if this relation is nested in another relation. 344 | * 345 | * @return bool 346 | */ 347 | protected function isNested() 348 | { 349 | return $this->getParentRelation() !== null; 350 | } 351 | 352 | /** 353 | * Get the fully qualified local key name. 354 | * 355 | * @param string $glue 356 | * 357 | * @return string 358 | */ 359 | protected function getPathHierarchy($glue = '.') 360 | { 361 | $parentRelation = $this->getParentRelation(); 362 | if ($parentRelation) { 363 | return $parentRelation->getPathHierarchy($glue) . $glue . $this->localKey; 364 | } 365 | 366 | return $this->localKey; 367 | } 368 | 369 | /** @inheritdoc */ 370 | public function getQualifiedParentKeyName() 371 | { 372 | $parentRelation = $this->getParentRelation(); 373 | if ($parentRelation) { 374 | return $parentRelation->getPathHierarchy() . '.' . $this->parent->getKeyName(); 375 | } 376 | 377 | return $this->parent->getKeyName(); 378 | } 379 | 380 | /** 381 | * Get the primary key value of the parent. 382 | * 383 | * @return string 384 | */ 385 | protected function getParentKey() 386 | { 387 | return $this->parent->getKey(); 388 | } 389 | 390 | /** 391 | * Return update values. 392 | * 393 | * @param array $array 394 | * @param string $prepend 395 | * 396 | * @return array 397 | */ 398 | public static function getUpdateValues($array, $prepend = '') 399 | { 400 | $results = []; 401 | 402 | foreach ($array as $key => $value) { 403 | if (str_starts_with($key, '$')) { 404 | assert(is_array($value), 'Update operator value must be an array.'); 405 | $results[$key] = static::getUpdateValues($value, $prepend); 406 | } else { 407 | $results[$prepend . $key] = $value; 408 | } 409 | } 410 | 411 | return $results; 412 | } 413 | 414 | /** 415 | * Get the foreign key for the relationship. 416 | * 417 | * @return string 418 | */ 419 | public function getQualifiedForeignKeyName() 420 | { 421 | return $this->foreignKey; 422 | } 423 | 424 | /** 425 | * Get the name of the "where in" method for eager loading. 426 | * 427 | * @param EloquentModel $model 428 | * @param string $key 429 | * 430 | * @return string 431 | */ 432 | protected function whereInMethod(EloquentModel $model, $key) 433 | { 434 | return 'whereIn'; 435 | } 436 | } 437 | -------------------------------------------------------------------------------- /src/Relations/HasMany.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | class HasMany extends EloquentHasMany 17 | { 18 | /** 19 | * Get the plain foreign key. 20 | * 21 | * @return string 22 | */ 23 | public function getForeignKeyName() 24 | { 25 | return $this->foreignKey; 26 | } 27 | 28 | /** 29 | * Get the key for comparing against the parent key in "has" query. 30 | * 31 | * @return string 32 | */ 33 | public function getHasCompareKey() 34 | { 35 | return $this->getForeignKeyName(); 36 | } 37 | 38 | /** @inheritdoc */ 39 | public function getRelationExistenceQuery(Builder $query, Builder $parentQuery, $columns = ['*']) 40 | { 41 | $foreignKey = $this->getHasCompareKey(); 42 | 43 | return $query->select($foreignKey)->where($foreignKey, 'exists', true); 44 | } 45 | 46 | /** 47 | * Get the name of the "where in" method for eager loading. 48 | * 49 | * @param string $key 50 | * 51 | * @return string 52 | */ 53 | protected function whereInMethod(Model $model, $key) 54 | { 55 | return 'whereIn'; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Relations/HasOne.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | class HasOne extends EloquentHasOne 17 | { 18 | /** 19 | * Get the key for comparing against the parent key in "has" query. 20 | * 21 | * @return string 22 | */ 23 | public function getForeignKeyName() 24 | { 25 | return $this->foreignKey; 26 | } 27 | 28 | /** 29 | * Get the key for comparing against the parent key in "has" query. 30 | * 31 | * @return string 32 | */ 33 | public function getHasCompareKey() 34 | { 35 | return $this->getForeignKeyName(); 36 | } 37 | 38 | /** @inheritdoc */ 39 | public function getRelationExistenceQuery(Builder $query, Builder $parentQuery, $columns = ['*']) 40 | { 41 | $foreignKey = $this->getForeignKeyName(); 42 | 43 | return $query->select($foreignKey)->where($foreignKey, 'exists', true); 44 | } 45 | 46 | /** 47 | * Get the name of the "where in" method for eager loading. 48 | * 49 | * @param string $key 50 | * 51 | * @return string 52 | */ 53 | protected function whereInMethod(Model $model, $key) 54 | { 55 | return 'whereIn'; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Relations/MorphMany.php: -------------------------------------------------------------------------------- 1 | 14 | */ 15 | class MorphMany extends EloquentMorphMany 16 | { 17 | /** 18 | * Get the name of the "where in" method for eager loading. 19 | * 20 | * @param string $key 21 | * 22 | * @return string 23 | */ 24 | protected function whereInMethod(Model $model, $key) 25 | { 26 | return 'whereIn'; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Relations/MorphTo.php: -------------------------------------------------------------------------------- 1 | 14 | */ 15 | class MorphTo extends EloquentMorphTo 16 | { 17 | /** @inheritdoc */ 18 | public function addConstraints() 19 | { 20 | if (static::$constraints) { 21 | // For belongs to relationships, which are essentially the inverse of has one 22 | // or has many relationships, we need to actually query on the primary key 23 | // of the related models matching on the foreign key that's on a parent. 24 | $this->query->where( 25 | $this->ownerKey ?? $this->getForeignKeyName(), 26 | '=', 27 | $this->getForeignKeyFrom($this->parent), 28 | ); 29 | } 30 | } 31 | 32 | /** @inheritdoc */ 33 | protected function getResultsByType($type) 34 | { 35 | $instance = $this->createModelByType($type); 36 | 37 | $key = $this->ownerKey ?? $instance->getKeyName(); 38 | 39 | $query = $instance->newQuery(); 40 | 41 | return $query->whereIn($key, $this->gatherKeysByType($type, $instance->getKeyType()))->get(); 42 | } 43 | 44 | /** 45 | * Get the name of the "where in" method for eager loading. 46 | * 47 | * @param string $key 48 | * 49 | * @return string 50 | */ 51 | protected function whereInMethod(Model $model, $key) 52 | { 53 | return 'whereIn'; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/Relations/MorphToMany.php: -------------------------------------------------------------------------------- 1 | 31 | */ 32 | class MorphToMany extends EloquentMorphToMany 33 | { 34 | /** @inheritdoc */ 35 | public function getRelationExistenceQuery(Builder $query, Builder $parentQuery, $columns = ['*']) 36 | { 37 | return $query; 38 | } 39 | 40 | /** @inheritdoc */ 41 | protected function hydratePivotRelation(array $models) 42 | { 43 | // Do nothing. 44 | } 45 | 46 | /** @inheritdoc */ 47 | protected function shouldSelect(array $columns = ['*']) 48 | { 49 | return $columns; 50 | } 51 | 52 | /** @inheritdoc */ 53 | public function addConstraints() 54 | { 55 | if (static::$constraints) { 56 | $this->setWhere(); 57 | } 58 | } 59 | 60 | /** @inheritdoc */ 61 | public function addEagerConstraints(array $models) 62 | { 63 | // To load relation's data, we act normally on MorphToMany relation, 64 | // But on MorphedByMany relation, we collect related ids from pivot column 65 | // and add to a whereIn condition 66 | if ($this->getInverse()) { 67 | $ids = $this->getKeys($models, $this->table); 68 | $ids = $this->extractIds($ids[0] ?? []); 69 | $this->query->whereIn($this->relatedKey, $ids); 70 | } else { 71 | parent::addEagerConstraints($models); 72 | 73 | $this->query->where($this->qualifyPivotColumn($this->morphType), $this->morphClass); 74 | } 75 | } 76 | 77 | /** 78 | * Set the where clause for the relation query. 79 | * 80 | * @return $this 81 | */ 82 | protected function setWhere() 83 | { 84 | if ($this->getInverse()) { 85 | if (\MongoDB\Laravel\Eloquent\Model::isDocumentModel($this->parent)) { 86 | $ids = $this->extractIds((array) $this->parent->{$this->table}); 87 | 88 | $this->query->whereIn($this->relatedKey, $ids); 89 | } else { 90 | $this->query 91 | ->whereIn($this->foreignPivotKey, (array) $this->parent->{$this->parentKey}); 92 | } 93 | } else { 94 | match (\MongoDB\Laravel\Eloquent\Model::isDocumentModel($this->parent)) { 95 | true => $this->query->whereIn($this->relatedKey, (array) $this->parent->{$this->relatedPivotKey}), 96 | false => $this->query 97 | ->whereIn($this->getQualifiedForeignPivotKeyName(), (array) $this->parent->{$this->parentKey}), 98 | }; 99 | } 100 | 101 | return $this; 102 | } 103 | 104 | /** @inheritdoc */ 105 | public function save(Model $model, array $pivotAttributes = [], $touch = true) 106 | { 107 | $model->save(['touch' => false]); 108 | 109 | $this->attach($model, $pivotAttributes, $touch); 110 | 111 | return $model; 112 | } 113 | 114 | /** @inheritdoc */ 115 | public function create(array $attributes = [], array $joining = [], $touch = true) 116 | { 117 | $instance = $this->related->newInstance($attributes); 118 | 119 | // Once we save the related model, we need to attach it to the base model via 120 | // through intermediate table so we'll use the existing "attach" method to 121 | // accomplish this which will insert the record and any more attributes. 122 | $instance->save(['touch' => false]); 123 | 124 | $this->attach($instance, $joining, $touch); 125 | 126 | return $instance; 127 | } 128 | 129 | /** @inheritdoc */ 130 | public function sync($ids, $detaching = true) 131 | { 132 | $changes = [ 133 | 'attached' => [], 134 | 'detached' => [], 135 | 'updated' => [], 136 | ]; 137 | 138 | if ($ids instanceof Collection) { 139 | $ids = $this->parseIds($ids); 140 | } elseif ($ids instanceof Model) { 141 | $ids = $this->parseIds($ids); 142 | } 143 | 144 | // First we need to attach any of the associated models that are not currently 145 | // in this joining table. We'll spin through the given IDs, checking to see 146 | // if they exist in the array of current ones, and if not we will insert. 147 | if ($this->getInverse()) { 148 | $current = match (\MongoDB\Laravel\Eloquent\Model::isDocumentModel($this->parent)) { 149 | true => $this->parent->{$this->table} ?: [], 150 | false => $this->parent->{$this->relationName} ?: [], 151 | }; 152 | 153 | if ($current instanceof Collection) { 154 | $current = collect($this->parseIds($current))->flatten()->toArray(); 155 | } else { 156 | $current = $this->extractIds($current); 157 | } 158 | } else { 159 | $current = match (\MongoDB\Laravel\Eloquent\Model::isDocumentModel($this->parent)) { 160 | true => $this->parent->{$this->relatedPivotKey} ?: [], 161 | false => $this->parent->{$this->relationName} ?: [], 162 | }; 163 | 164 | if ($current instanceof Collection) { 165 | $current = $this->parseIds($current); 166 | } 167 | } 168 | 169 | $records = $this->formatRecordsList($ids); 170 | 171 | $current = Arr::wrap($current); 172 | 173 | $detach = array_diff($current, array_keys($records)); 174 | 175 | // We need to make sure we pass a clean array, so that it is not interpreted 176 | // as an associative array. 177 | $detach = array_values($detach); 178 | 179 | // Next, we will take the differences of the currents and given IDs and detach 180 | // all of the entities that exist in the "current" array but are not in the 181 | // the array of the IDs given to the method which will complete the sync. 182 | if ($detaching && count($detach) > 0) { 183 | $this->detach($detach); 184 | 185 | $changes['detached'] = array_map(function ($v) { 186 | return is_numeric($v) ? (int) $v : (string) $v; 187 | }, $detach); 188 | } 189 | 190 | // Now we are finally ready to attach the new records. Note that we'll disable 191 | // touching until after the entire operation is complete so we don't fire a 192 | // ton of touch operations until we are totally done syncing the records. 193 | $changes = array_replace( 194 | $changes, 195 | $this->attachNew($records, $current, false), 196 | ); 197 | 198 | if (count($changes['attached']) || count($changes['updated'])) { 199 | $this->touchIfTouching(); 200 | } 201 | 202 | return $changes; 203 | } 204 | 205 | /** @inheritdoc */ 206 | public function updateExistingPivot($id, array $attributes, $touch = true): void 207 | { 208 | // Do nothing, we have no pivot table. 209 | } 210 | 211 | /** @inheritdoc */ 212 | public function attach($id, array $attributes = [], $touch = true) 213 | { 214 | if ($id instanceof Model) { 215 | $model = $id; 216 | 217 | $id = $this->parseId($model); 218 | 219 | if ($this->getInverse()) { 220 | // Attach the new ids to the parent model. 221 | if (\MongoDB\Laravel\Eloquent\Model::isDocumentModel($this->parent)) { 222 | $this->parent->push($this->table, [ 223 | [ 224 | $this->relatedPivotKey => $model->{$this->relatedKey}, 225 | $this->morphType => $model->getMorphClass(), 226 | ], 227 | ], true); 228 | } else { 229 | $this->addIdToParentRelationData($id); 230 | } 231 | 232 | // Attach the new parent id to the related model. 233 | $model->push($this->foreignPivotKey, (array) $this->parent->{$this->parentKey}, true); 234 | } else { 235 | // Attach the new parent id to the related model. 236 | $model->push($this->table, [ 237 | [ 238 | $this->foreignPivotKey => $this->parent->{$this->parentKey}, 239 | $this->morphType => $this->parent instanceof Model ? $this->parent->getMorphClass() : null, 240 | ], 241 | ], true); 242 | 243 | // Attach the new ids to the parent model. 244 | if (\MongoDB\Laravel\Eloquent\Model::isDocumentModel($this->parent)) { 245 | $this->parent->push($this->relatedPivotKey, (array) $id, true); 246 | } else { 247 | $this->addIdToParentRelationData($id); 248 | } 249 | } 250 | } else { 251 | if ($id instanceof Collection) { 252 | $id = $this->parseIds($id); 253 | } 254 | 255 | $id = (array) $id; 256 | 257 | $query = $this->newRelatedQuery(); 258 | $query->whereIn($this->relatedKey, $id); 259 | 260 | if ($this->getInverse()) { 261 | // Attach the new parent id to the related model. 262 | $query->push($this->foreignPivotKey, $this->parent->{$this->parentKey}); 263 | 264 | // Attach the new ids to the parent model. 265 | if (\MongoDB\Laravel\Eloquent\Model::isDocumentModel($this->parent)) { 266 | foreach ($id as $item) { 267 | $this->parent->push($this->table, [ 268 | [ 269 | $this->relatedPivotKey => $item, 270 | $this->morphType => $this->related instanceof Model ? $this->related->getMorphClass() : null, 271 | ], 272 | ], true); 273 | } 274 | } else { 275 | foreach ($id as $item) { 276 | $this->addIdToParentRelationData($item); 277 | } 278 | } 279 | } else { 280 | // Attach the new parent id to the related model. 281 | $query->push($this->table, [ 282 | [ 283 | $this->foreignPivotKey => $this->parent->{$this->parentKey}, 284 | $this->morphType => $this->parent instanceof Model ? $this->parent->getMorphClass() : null, 285 | ], 286 | ], true); 287 | 288 | // Attach the new ids to the parent model. 289 | if (\MongoDB\Laravel\Eloquent\Model::isDocumentModel($this->parent)) { 290 | $this->parent->push($this->relatedPivotKey, $id, true); 291 | } else { 292 | foreach ($id as $item) { 293 | $this->addIdToParentRelationData($item); 294 | } 295 | } 296 | } 297 | } 298 | 299 | if ($touch) { 300 | $this->touchIfTouching(); 301 | } 302 | } 303 | 304 | /** @inheritdoc */ 305 | public function detach($ids = [], $touch = true) 306 | { 307 | if ($ids instanceof Model) { 308 | $ids = $this->parseIds($ids); 309 | } 310 | 311 | $query = $this->newRelatedQuery(); 312 | 313 | // If associated IDs were passed to the method we will only delete those 314 | // associations, otherwise all the association ties will be broken. 315 | // We'll return the numbers of affected rows when we do the deletes. 316 | $ids = (array) $ids; 317 | 318 | // Detach all ids from the parent model. 319 | if ($this->getInverse()) { 320 | // Remove the relation from the parent. 321 | $data = []; 322 | foreach ($ids as $item) { 323 | $data = [ 324 | ...$data, 325 | [ 326 | $this->relatedPivotKey => $item, 327 | $this->morphType => $this->related->getMorphClass(), 328 | ], 329 | ]; 330 | } 331 | 332 | if (\MongoDB\Laravel\Eloquent\Model::isDocumentModel($this->parent)) { 333 | $this->parent->pull($this->table, $data); 334 | } else { 335 | $value = $this->parent->{$this->relationName} 336 | ->filter(fn ($rel) => ! in_array($rel->{$this->relatedKey}, $this->extractIds($data))); 337 | $this->parent->setRelation($this->relationName, $value); 338 | } 339 | 340 | // Prepare the query to select all related objects. 341 | if (count($ids) > 0) { 342 | $query->whereIn($this->relatedKey, $ids); 343 | } 344 | 345 | // Remove the relation from the related. 346 | $query->pull($this->foreignPivotKey, $this->parent->{$this->parentKey}); 347 | } else { 348 | // Remove the relation from the parent. 349 | if (\MongoDB\Laravel\Eloquent\Model::isDocumentModel($this->parent)) { 350 | $this->parent->pull($this->relatedPivotKey, $ids); 351 | } else { 352 | $value = $this->parent->{$this->relationName} 353 | ->filter(fn ($rel) => ! in_array($rel->{$this->relatedKey}, $ids)); 354 | $this->parent->setRelation($this->relationName, $value); 355 | } 356 | 357 | // Prepare the query to select all related objects. 358 | if (count($ids) > 0) { 359 | $query->whereIn($this->relatedKey, $ids); 360 | } 361 | 362 | // Remove the relation to the related. 363 | $query->pull($this->table, [ 364 | [ 365 | $this->foreignPivotKey => $this->parent->{$this->parentKey}, 366 | $this->morphType => $this->parent->getMorphClass(), 367 | ], 368 | ]); 369 | } 370 | 371 | if ($touch) { 372 | $this->touchIfTouching(); 373 | } 374 | 375 | return count($ids); 376 | } 377 | 378 | /** @inheritdoc */ 379 | protected function buildDictionary(Collection $results) 380 | { 381 | $foreign = $this->foreignPivotKey; 382 | 383 | // First we will build a dictionary of child models keyed by the foreign key 384 | // of the relation so that we will easily and quickly match them to their 385 | // parents without having a possibly slow inner loops for every models. 386 | $dictionary = []; 387 | 388 | foreach ($results as $result) { 389 | if ($this->getInverse()) { 390 | foreach ($result->$foreign as $item) { 391 | $dictionary[$item][] = $result; 392 | } 393 | } else { 394 | // Collect $foreign value from pivot column of result model 395 | $items = $this->extractIds($result->{$this->table} ?? [], $foreign); 396 | foreach ($items as $item) { 397 | $dictionary[$item][] = $result; 398 | } 399 | } 400 | } 401 | 402 | return $dictionary; 403 | } 404 | 405 | /** @inheritdoc */ 406 | public function newPivotQuery() 407 | { 408 | return $this->newRelatedQuery(); 409 | } 410 | 411 | /** 412 | * Create a new query builder for the related model. 413 | * 414 | * @return \Illuminate\Database\Query\Builder 415 | */ 416 | public function newRelatedQuery() 417 | { 418 | return $this->related->newQuery(); 419 | } 420 | 421 | /** @inheritdoc */ 422 | public function getQualifiedRelatedPivotKeyName() 423 | { 424 | return $this->relatedPivotKey; 425 | } 426 | 427 | /** 428 | * Get the name of the "where in" method for eager loading. 429 | * 430 | * @param string $key 431 | * 432 | * @return string 433 | */ 434 | protected function whereInMethod(Model $model, $key) 435 | { 436 | return 'whereIn'; 437 | } 438 | 439 | /** 440 | * Extract ids from given pivot table data 441 | * 442 | * @param array $data 443 | * @param string|null $relatedPivotKey 444 | * 445 | * @return mixed 446 | */ 447 | public function extractIds(array $data, ?string $relatedPivotKey = null) 448 | { 449 | $relatedPivotKey = $relatedPivotKey ?: $this->relatedPivotKey; 450 | return array_reduce($data, function ($carry, $item) use ($relatedPivotKey) { 451 | if (is_array($item) && array_key_exists($relatedPivotKey, $item)) { 452 | $carry[] = $item[$relatedPivotKey]; 453 | } 454 | 455 | return $carry; 456 | }, []); 457 | } 458 | 459 | /** 460 | * Add the given id to the relation's data of the current parent instance. 461 | * It helps to keep up-to-date the sql model instances in hybrid relationships. 462 | * 463 | * @param ObjectId|string|int $id 464 | * 465 | * @return void 466 | */ 467 | private function addIdToParentRelationData($id) 468 | { 469 | $instance = new $this->related(); 470 | $instance->forceFill([$this->relatedKey => $id]); 471 | $relationData = $this->parent->{$this->relationName}->push($instance)->unique($this->relatedKey); 472 | $this->parent->setRelation($this->relationName, $relationData); 473 | } 474 | } 475 | -------------------------------------------------------------------------------- /src/Schema/Blueprint.php: -------------------------------------------------------------------------------- 1 | fluent($columns); 44 | 45 | // Columns are passed as a default array. 46 | if (is_array($columns) && is_int(key($columns))) { 47 | // Transform the columns to the required array format. 48 | $transform = []; 49 | 50 | foreach ($columns as $column) { 51 | $transform[$column] = 1; 52 | } 53 | 54 | $columns = $transform; 55 | } 56 | 57 | if ($name !== null) { 58 | $options['name'] = $name; 59 | } 60 | 61 | $this->collection->createIndex($columns, $options); 62 | 63 | return $this; 64 | } 65 | 66 | /** @inheritdoc */ 67 | public function primary($columns = null, $name = null, $algorithm = null, $options = []) 68 | { 69 | return $this->unique($columns, $name, $algorithm, $options); 70 | } 71 | 72 | /** @inheritdoc */ 73 | public function dropIndex($index = null) 74 | { 75 | $index = $this->transformColumns($index); 76 | 77 | $this->collection->dropIndex($index); 78 | 79 | return $this; 80 | } 81 | 82 | /** 83 | * Indicate that the given index should be dropped, but do not fail if it didn't exist. 84 | * 85 | * @param string|array $indexOrColumns 86 | * 87 | * @return Blueprint 88 | */ 89 | public function dropIndexIfExists($indexOrColumns = null) 90 | { 91 | if ($this->hasIndex($indexOrColumns)) { 92 | $this->dropIndex($indexOrColumns); 93 | } 94 | 95 | return $this; 96 | } 97 | 98 | /** 99 | * Check whether the given index exists. 100 | * 101 | * @param string|array $indexOrColumns 102 | * 103 | * @return bool 104 | */ 105 | public function hasIndex($indexOrColumns = null) 106 | { 107 | $indexOrColumns = $this->transformColumns($indexOrColumns); 108 | foreach ($this->collection->listIndexes() as $index) { 109 | if (is_array($indexOrColumns) && in_array($index->getName(), $indexOrColumns)) { 110 | return true; 111 | } 112 | 113 | if (is_string($indexOrColumns) && $index->getName() === $indexOrColumns) { 114 | return true; 115 | } 116 | } 117 | 118 | return false; 119 | } 120 | 121 | public function jsonSchema( 122 | array $schema = [], 123 | ?string $validationLevel = null, 124 | ?string $validationAction = null, 125 | ): void { 126 | $options = array_merge( 127 | [ 128 | 'validator' => [ 129 | '$jsonSchema' => $schema, 130 | ], 131 | ], 132 | $validationLevel ? ['validationLevel' => $validationLevel] : [], 133 | $validationAction ? ['validationAction' => $validationAction] : [], 134 | ); 135 | 136 | $this->connection->getDatabase()->modifyCollection($this->collection->getCollectionName(), $options); 137 | } 138 | 139 | /** 140 | * @param string|array $indexOrColumns 141 | * 142 | * @return string 143 | */ 144 | protected function transformColumns($indexOrColumns) 145 | { 146 | if (is_array($indexOrColumns)) { 147 | $indexOrColumns = $this->fluent($indexOrColumns); 148 | 149 | // Transform the columns to the index name. 150 | $transform = []; 151 | 152 | foreach ($indexOrColumns as $key => $value) { 153 | if (is_int($key)) { 154 | // There is no sorting order, use the default. 155 | $column = $value; 156 | $sorting = '1'; 157 | } else { 158 | // This is a column with sorting order e.g 'my_column' => -1. 159 | $column = $key; 160 | $sorting = $value; 161 | } 162 | 163 | $transform[$column] = $column . '_' . $sorting; 164 | } 165 | 166 | $indexOrColumns = implode('_', $transform); 167 | } 168 | 169 | return $indexOrColumns; 170 | } 171 | 172 | /** @inheritdoc */ 173 | public function unique($columns = null, $name = null, $algorithm = null, $options = []) 174 | { 175 | $columns = $this->fluent($columns); 176 | 177 | $options['unique'] = true; 178 | 179 | $this->index($columns, $name, $algorithm, $options); 180 | 181 | return $this; 182 | } 183 | 184 | /** 185 | * Specify a sparse index for the collection. 186 | * 187 | * @param string|array $columns 188 | * @param array $options 189 | * 190 | * @return Blueprint 191 | */ 192 | public function sparse($columns = null, $options = []) 193 | { 194 | $columns = $this->fluent($columns); 195 | 196 | $options['sparse'] = true; 197 | 198 | $this->index($columns, null, null, $options); 199 | 200 | return $this; 201 | } 202 | 203 | /** 204 | * Specify a geospatial index for the collection. 205 | * 206 | * @param string|array $columns 207 | * @param string $index 208 | * @param array $options 209 | * 210 | * @return Blueprint 211 | */ 212 | public function geospatial($columns = null, $index = '2d', $options = []) 213 | { 214 | if ($index === '2d' || $index === '2dsphere') { 215 | $columns = $this->fluent($columns); 216 | 217 | $columns = array_flip($columns); 218 | 219 | foreach ($columns as $column => $value) { 220 | $columns[$column] = $index; 221 | } 222 | 223 | $this->index($columns, null, null, $options); 224 | } 225 | 226 | return $this; 227 | } 228 | 229 | /** 230 | * Specify the number of seconds after which a document should be considered expired based, 231 | * on the given single-field index containing a date. 232 | * 233 | * @param string|array $columns 234 | * @param int $seconds 235 | * 236 | * @return Blueprint 237 | */ 238 | public function expire($columns, $seconds) 239 | { 240 | $columns = $this->fluent($columns); 241 | 242 | $this->index($columns, null, null, ['expireAfterSeconds' => $seconds]); 243 | 244 | return $this; 245 | } 246 | 247 | /** 248 | * Indicate that the collection needs to be created. 249 | * 250 | * @param array $options 251 | * 252 | * @return void 253 | */ 254 | public function create($options = []) 255 | { 256 | $collection = $this->collection->getCollectionName(); 257 | 258 | $db = $this->connection->getDatabase(); 259 | 260 | // Ensure the collection is created. 261 | $db->createCollection($collection, $options); 262 | } 263 | 264 | /** @inheritdoc */ 265 | public function drop() 266 | { 267 | $this->collection->drop(); 268 | 269 | return $this; 270 | } 271 | 272 | /** @inheritdoc */ 273 | public function renameColumn($from, $to) 274 | { 275 | $this->collection->updateMany([$from => ['$exists' => true]], ['$rename' => [$from => $to]]); 276 | 277 | return $this; 278 | } 279 | 280 | /** @inheritdoc */ 281 | public function addColumn($type, $name, array $parameters = []) 282 | { 283 | $this->fluent($name); 284 | 285 | return $this; 286 | } 287 | 288 | /** 289 | * Specify a sparse and unique index for the collection. 290 | * 291 | * @param string|array $columns 292 | * @param array $options 293 | * 294 | * @return Blueprint 295 | * 296 | * phpcs:disable PSR1.Methods.CamelCapsMethodName.NotCamelCaps 297 | */ 298 | public function sparse_and_unique($columns = null, $options = []) 299 | { 300 | $columns = $this->fluent($columns); 301 | 302 | $options['sparse'] = true; 303 | $options['unique'] = true; 304 | 305 | $this->index($columns, null, null, $options); 306 | 307 | return $this; 308 | } 309 | 310 | /** 311 | * Create an Atlas Search Index. 312 | * 313 | * @see https://www.mongodb.com/docs/manual/reference/command/createSearchIndexes/#std-label-search-index-definition-create 314 | * 315 | * @phpstan-param array{ 316 | * analyzer?: string, 317 | * analyzers?: list, 318 | * searchAnalyzer?: string, 319 | * mappings: array{dynamic: true} | array{dynamic?: bool, fields: array}, 320 | * storedSource?: bool|array, 321 | * synonyms?: list, 322 | * ... 323 | * } $definition 324 | */ 325 | public function searchIndex(array $definition, string $name = 'default'): static 326 | { 327 | $this->collection->createSearchIndex($definition, ['name' => $name, 'type' => 'search']); 328 | 329 | return $this; 330 | } 331 | 332 | /** 333 | * Create an Atlas Vector Search Index. 334 | * 335 | * @see https://www.mongodb.com/docs/manual/reference/command/createSearchIndexes/#std-label-vector-search-index-definition-create 336 | * 337 | * @phpstan-param array{fields: array} $definition 338 | */ 339 | public function vectorSearchIndex(array $definition, string $name = 'default'): static 340 | { 341 | $this->collection->createSearchIndex($definition, ['name' => $name, 'type' => 'vectorSearch']); 342 | 343 | return $this; 344 | } 345 | 346 | /** 347 | * Drop an Atlas Search or Vector Search index 348 | */ 349 | public function dropSearchIndex(string $name): static 350 | { 351 | $this->collection->dropSearchIndex($name); 352 | 353 | return $this; 354 | } 355 | 356 | /** 357 | * Allow fluent columns. 358 | * 359 | * @param string|array $columns 360 | * 361 | * @return string|array 362 | */ 363 | protected function fluent($columns = null) 364 | { 365 | if ($columns === null) { 366 | return $this->columns; 367 | } 368 | 369 | if (is_string($columns)) { 370 | return $this->columns = [$columns]; 371 | } 372 | 373 | return $this->columns = $columns; 374 | } 375 | 376 | /** 377 | * Allows the use of unsupported schema methods. 378 | * 379 | * @param string $method 380 | * @param array $parameters 381 | * 382 | * @return Blueprint 383 | */ 384 | public function __call($method, $parameters) 385 | { 386 | // Dummy. 387 | return $this; 388 | } 389 | } 390 | -------------------------------------------------------------------------------- /src/Schema/BlueprintLaravelCompatibility.php: -------------------------------------------------------------------------------- 1 | connection = $connection; 36 | $this->collection = $connection->getCollection($collection); 37 | } 38 | } 39 | } else { 40 | /** @internal For compatibility with Laravel 12+ */ 41 | trait BlueprintLaravelCompatibility 42 | { 43 | public function __construct(Connection $connection, string $collection, ?Closure $callback = null) 44 | { 45 | parent::__construct($connection, $collection, $callback); 46 | 47 | $this->collection = $connection->getCollection($collection); 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Schema/Builder.php: -------------------------------------------------------------------------------- 1 | hasColumns($table, [$column]); 55 | } 56 | 57 | /** 58 | * Check if columns exist in the collection schema. 59 | * 60 | * @param string $table 61 | * @param string[] $columns 62 | */ 63 | public function hasColumns($table, array $columns): bool 64 | { 65 | // The field "id" (alias of "_id") always exists in MongoDB documents 66 | $columns = array_filter($columns, fn (string $column): bool => ! in_array($column, ['_id', 'id'], true)); 67 | 68 | // Any subfield named "*.id" is an alias of "*._id" 69 | $columns = array_map(fn (string $column): string => str_ends_with($column, '.id') ? substr($column, 0, -3) . '._id' : $column, $columns); 70 | 71 | if ($columns === []) { 72 | return true; 73 | } 74 | 75 | $collection = $this->connection->table($table); 76 | 77 | return $collection 78 | ->where(array_fill_keys($columns, ['$exists' => true])) 79 | ->project(['_id' => 1]) 80 | ->exists(); 81 | } 82 | 83 | /** 84 | * Determine if the given collection exists. 85 | * 86 | * @param string $name 87 | * 88 | * @return bool 89 | */ 90 | public function hasCollection($name) 91 | { 92 | $db = $this->connection->getDatabase(); 93 | 94 | $collections = iterator_to_array($db->listCollections([ 95 | 'filter' => ['name' => $name], 96 | ]), false); 97 | 98 | return count($collections) !== 0; 99 | } 100 | 101 | /** @inheritdoc */ 102 | public function hasTable($table) 103 | { 104 | return $this->hasCollection($table); 105 | } 106 | 107 | /** @inheritdoc */ 108 | public function table($table, Closure $callback) 109 | { 110 | $blueprint = $this->createBlueprint($table); 111 | 112 | if ($callback) { 113 | $callback($blueprint); 114 | } 115 | } 116 | 117 | /** @inheritdoc */ 118 | public function create($table, ?Closure $callback = null, array $options = []) 119 | { 120 | $blueprint = $this->createBlueprint($table); 121 | 122 | $blueprint->create($options); 123 | 124 | if ($callback) { 125 | $callback($blueprint); 126 | } 127 | } 128 | 129 | /** @inheritdoc */ 130 | public function dropIfExists($table) 131 | { 132 | if ($this->hasCollection($table)) { 133 | $this->drop($table); 134 | } 135 | } 136 | 137 | /** @inheritdoc */ 138 | public function drop($table) 139 | { 140 | $blueprint = $this->createBlueprint($table); 141 | 142 | $blueprint->drop(); 143 | } 144 | 145 | /** 146 | * @inheritdoc 147 | * 148 | * Drops the entire database instead of deleting each collection individually. 149 | * 150 | * In MongoDB, dropping the whole database is much faster than dropping collections 151 | * one by one. The database will be automatically recreated when a new connection 152 | * writes to it. 153 | */ 154 | public function dropAllTables() 155 | { 156 | $this->connection->getDatabase()->drop(); 157 | } 158 | 159 | /** @param string|null $schema Database name */ 160 | public function getTables($schema = null) 161 | { 162 | return $this->getCollectionRows('collection', $schema); 163 | } 164 | 165 | /** @param string|null $schema Database name */ 166 | public function getViews($schema = null) 167 | { 168 | return $this->getCollectionRows('view', $schema); 169 | } 170 | 171 | /** 172 | * @param string|null $schema 173 | * @param bool $schemaQualified If a schema is provided, prefix the collection names with the schema name 174 | * 175 | * @return array 176 | */ 177 | public function getTableListing($schema = null, $schemaQualified = false) 178 | { 179 | $collections = []; 180 | 181 | if ($schema === null || is_string($schema)) { 182 | $collections[$schema ?? 0] = iterator_to_array($this->connection->getDatabase($schema)->listCollectionNames()); 183 | } elseif (is_array($schema)) { 184 | foreach ($schema as $db) { 185 | $collections[$db] = iterator_to_array($this->connection->getDatabase($db)->listCollectionNames()); 186 | } 187 | } 188 | 189 | if ($schema && $schemaQualified) { 190 | $collections = array_map(fn ($db, $collections) => array_map(static fn ($collection) => $db . '.' . $collection, $collections), array_keys($collections), $collections); 191 | } 192 | 193 | $collections = array_merge(...array_values($collections)); 194 | 195 | sort($collections); 196 | 197 | return $collections; 198 | } 199 | 200 | public function getColumns($table) 201 | { 202 | $db = null; 203 | if (str_contains($table, '.')) { 204 | [$db, $table] = explode('.', $table, 2); 205 | } 206 | 207 | $stats = $this->connection->getDatabase($db)->getCollection($table)->aggregate([ 208 | // Sample 1,000 documents to get a representative sample of the collection 209 | ['$sample' => ['size' => 1_000]], 210 | // Convert each document to an array of fields 211 | ['$project' => ['fields' => ['$objectToArray' => '$$ROOT']]], 212 | // Unwind to get one document per field 213 | ['$unwind' => '$fields'], 214 | // Group by field name, count the number of occurrences and get the types 215 | [ 216 | '$group' => [ 217 | '_id' => '$fields.k', 218 | 'total' => ['$sum' => 1], 219 | 'types' => ['$addToSet' => ['$type' => '$fields.v']], 220 | ], 221 | ], 222 | // Get the most seen field names 223 | ['$sort' => ['total' => -1]], 224 | // Limit to 1,000 fields 225 | ['$limit' => 1_000], 226 | // Sort by field name 227 | ['$sort' => ['_id' => 1]], 228 | ], [ 229 | 'typeMap' => ['array' => 'array'], 230 | 'allowDiskUse' => true, 231 | ])->toArray(); 232 | 233 | $columns = []; 234 | foreach ($stats as $stat) { 235 | sort($stat->types); 236 | $type = implode(', ', $stat->types); 237 | $name = $stat->_id; 238 | if ($name === '_id') { 239 | $name = 'id'; 240 | } 241 | 242 | $columns[] = [ 243 | 'name' => $name, 244 | 'type_name' => $type, 245 | 'type' => $type, 246 | 'collation' => null, 247 | 'nullable' => $name !== 'id', 248 | 'default' => null, 249 | 'auto_increment' => false, 250 | 'comment' => sprintf('%d occurrences', $stat->total), 251 | 'generation' => $name === 'id' ? ['type' => 'objectId', 'expression' => null] : null, 252 | ]; 253 | } 254 | 255 | return $columns; 256 | } 257 | 258 | public function getIndexes($table) 259 | { 260 | $collection = $this->connection->getDatabase()->selectCollection($table); 261 | assert($collection instanceof Collection); 262 | $indexList = []; 263 | 264 | $indexes = $collection->listIndexes(); 265 | foreach ($indexes as $index) { 266 | assert($index instanceof IndexInfo); 267 | $indexList[] = [ 268 | 'name' => $index->getName(), 269 | 'columns' => array_keys($index->getKey()), 270 | 'primary' => $index->getKey() === ['_id' => 1], 271 | 'type' => match (true) { 272 | $index->isText() => 'text', 273 | $index->is2dSphere() => '2dsphere', 274 | $index->isTtl() => 'ttl', 275 | default => null, 276 | }, 277 | 'unique' => $index->isUnique(), 278 | ]; 279 | } 280 | 281 | try { 282 | $indexes = $collection->listSearchIndexes(['typeMap' => ['root' => 'array', 'array' => 'array', 'document' => 'array']]); 283 | foreach ($indexes as $index) { 284 | // Status 'DOES_NOT_EXIST' means the index has been dropped but is still in the process of being removed 285 | if ($index['status'] === 'DOES_NOT_EXIST') { 286 | continue; 287 | } 288 | 289 | $indexList[] = [ 290 | 'name' => $index['name'], 291 | 'columns' => match ($index['type']) { 292 | 'search' => array_merge( 293 | $index['latestDefinition']['mappings']['dynamic'] ? ['dynamic'] : [], 294 | array_keys($index['latestDefinition']['mappings']['fields'] ?? []), 295 | ), 296 | 'vectorSearch' => array_column($index['latestDefinition']['fields'], 'path'), 297 | }, 298 | 'type' => $index['type'], 299 | 'primary' => false, 300 | 'unique' => false, 301 | ]; 302 | } 303 | } catch (ServerException $exception) { 304 | if (! self::isAtlasSearchNotSupportedException($exception)) { 305 | throw $exception; 306 | } 307 | } 308 | 309 | return $indexList; 310 | } 311 | 312 | public function getForeignKeys($table) 313 | { 314 | return []; 315 | } 316 | 317 | /** @inheritdoc */ 318 | protected function createBlueprint($table, ?Closure $callback = null) 319 | { 320 | return new Blueprint($this->connection, $table); 321 | } 322 | 323 | /** 324 | * Get collection. 325 | * 326 | * @param string $name 327 | * 328 | * @return bool|CollectionInfo 329 | */ 330 | public function getCollection($name) 331 | { 332 | $db = $this->connection->getDatabase(); 333 | 334 | $collections = iterator_to_array($db->listCollections([ 335 | 'filter' => ['name' => $name], 336 | ]), false); 337 | 338 | return count($collections) ? current($collections) : false; 339 | } 340 | 341 | /** 342 | * Get all the collections names for the database. 343 | * 344 | * @deprecated 345 | * 346 | * @return array 347 | */ 348 | protected function getAllCollections() 349 | { 350 | trigger_error(sprintf('Since mongodb/laravel-mongodb:5.4, Method "%s()" is deprecated without replacement.', __METHOD__), E_USER_DEPRECATED); 351 | 352 | $collections = []; 353 | foreach ($this->connection->getDatabase()->listCollections() as $collection) { 354 | $collections[] = $collection->getName(); 355 | } 356 | 357 | return $collections; 358 | } 359 | 360 | /** @internal */ 361 | public static function isAtlasSearchNotSupportedException(ServerException $e): bool 362 | { 363 | return in_array($e->getCode(), [ 364 | 59, // MongoDB 4 to 6, 7-community: no such command: 'createSearchIndexes' 365 | 40324, // MongoDB 4 to 6: Unrecognized pipeline stage name: '$listSearchIndexes' 366 | 115, // MongoDB 7-ent: Search index commands are only supported with Atlas. 367 | 6047401, // MongoDB 7: $listSearchIndexes stage is only allowed on MongoDB Atlas 368 | 31082, // MongoDB 8: Using Atlas Search Database Commands and the $listSearchIndexes aggregation stage requires additional configuration. 369 | ], true); 370 | } 371 | 372 | /** @param string|null $schema Database name */ 373 | private function getCollectionRows(string $collectionType, $schema = null) 374 | { 375 | $db = $this->connection->getDatabase($schema); 376 | $collections = []; 377 | 378 | foreach ($db->listCollections() as $collectionInfo) { 379 | $collectionName = $collectionInfo->getName(); 380 | 381 | if ($collectionInfo->getType() !== $collectionType) { 382 | continue; 383 | } 384 | 385 | $options = $collectionInfo->getOptions(); 386 | $collation = $options['collation'] ?? []; 387 | 388 | // Aggregation is not supported on views 389 | $stats = $collectionType !== 'view' ? $db->selectCollection($collectionName)->aggregate([ 390 | ['$collStats' => ['storageStats' => ['scale' => 1]]], 391 | ['$project' => ['storageStats.totalSize' => 1]], 392 | ])->toArray() : null; 393 | 394 | $collections[] = [ 395 | 'name' => $collectionName, 396 | 'schema' => $db->getDatabaseName(), 397 | 'schema_qualified_name' => $db->getDatabaseName() . '.' . $collectionName, 398 | 'size' => $stats[0]?->storageStats?->totalSize ?? null, 399 | 'comment' => null, 400 | 'collation' => $this->collationToString($collation), 401 | 'engine' => null, 402 | ]; 403 | } 404 | 405 | usort($collections, fn ($a, $b) => $a['name'] <=> $b['name']); 406 | 407 | return $collections; 408 | } 409 | 410 | private function collationToString(array $collation): string 411 | { 412 | $map = [ 413 | 'locale' => 'l', 414 | 'strength' => 's', 415 | 'caseLevel' => 'cl', 416 | 'caseFirst' => 'cf', 417 | 'numericOrdering' => 'no', 418 | 'alternate' => 'a', 419 | 'maxVariable' => 'mv', 420 | 'normalization' => 'n', 421 | 'backwards' => 'b', 422 | ]; 423 | 424 | $parts = []; 425 | foreach ($collation as $key => $value) { 426 | if (array_key_exists($key, $map)) { 427 | $shortKey = $map[$key]; 428 | $shortValue = is_bool($value) ? ($value ? '1' : '0') : $value; 429 | $parts[] = $shortKey . '=' . $shortValue; 430 | } 431 | } 432 | 433 | return implode(';', $parts); 434 | } 435 | } 436 | -------------------------------------------------------------------------------- /src/Schema/Grammar.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace MongoDB\Laravel\Session; 13 | 14 | use Illuminate\Session\DatabaseSessionHandler; 15 | use MongoDB\BSON\Binary; 16 | use MongoDB\BSON\Document; 17 | use MongoDB\BSON\UTCDateTime; 18 | use MongoDB\Collection; 19 | 20 | use function tap; 21 | use function time; 22 | 23 | /** 24 | * Session handler using the MongoDB driver extension. 25 | */ 26 | final class MongoDbSessionHandler extends DatabaseSessionHandler 27 | { 28 | private Collection $collection; 29 | 30 | public function close(): bool 31 | { 32 | return true; 33 | } 34 | 35 | public function gc($lifetime): int 36 | { 37 | $result = $this->getCollection()->deleteMany(['last_activity' => ['$lt' => $this->getUTCDateTime(-$lifetime)]]); 38 | 39 | return $result->getDeletedCount() ?? 0; 40 | } 41 | 42 | public function destroy($sessionId): bool 43 | { 44 | $this->getCollection()->deleteOne(['_id' => (string) $sessionId]); 45 | 46 | return true; 47 | } 48 | 49 | public function read($sessionId): string|false 50 | { 51 | $result = $this->getCollection()->findOne( 52 | ['_id' => (string) $sessionId, 'expires_at' => ['$gte' => $this->getUTCDateTime()]], 53 | [ 54 | 'projection' => ['_id' => false, 'payload' => true], 55 | 'typeMap' => ['root' => 'bson'], 56 | ], 57 | ); 58 | 59 | if ($result instanceof Document) { 60 | return (string) $result->payload; 61 | } 62 | 63 | return false; 64 | } 65 | 66 | public function write($sessionId, $data): bool 67 | { 68 | $payload = $this->getDefaultPayload($data); 69 | 70 | $this->getCollection()->replaceOne( 71 | ['_id' => (string) $sessionId], 72 | $payload, 73 | ['upsert' => true], 74 | ); 75 | 76 | return true; 77 | } 78 | 79 | /** Creates a TTL index that automatically deletes expired objects. */ 80 | public function createTTLIndex(): void 81 | { 82 | $this->collection->createIndex( 83 | // UTCDateTime field that holds the expiration date 84 | ['expires_at' => 1], 85 | // Delay to remove items after expiration 86 | ['expireAfterSeconds' => 0], 87 | ); 88 | } 89 | 90 | protected function getDefaultPayload($data): array 91 | { 92 | $payload = [ 93 | 'payload' => new Binary($data), 94 | 'last_activity' => $this->getUTCDateTime(), 95 | 'expires_at' => $this->getUTCDateTime($this->minutes * 60), 96 | ]; 97 | 98 | if (! $this->container) { 99 | return $payload; 100 | } 101 | 102 | return tap($payload, function (&$payload) { 103 | $this->addUserInformation($payload) 104 | ->addRequestInformation($payload); 105 | }); 106 | } 107 | 108 | private function getCollection(): Collection 109 | { 110 | return $this->collection ??= $this->connection->getCollection($this->table); 111 | } 112 | 113 | private function getUTCDateTime(int $additionalSeconds = 0): UTCDateTime 114 | { 115 | return new UTCDateTime((time() + $additionalSeconds) * 1000); 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/Validation/DatabasePresenceVerifier.php: -------------------------------------------------------------------------------- 1 | table($collection)->where($column, new Regex('^' . preg_quote($value) . '$', '/i')); 29 | 30 | if ($excludeId !== null && $excludeId !== 'NULL') { 31 | $query->where($idColumn ?: 'id', '<>', $excludeId); 32 | } 33 | 34 | foreach ($extra as $key => $extraValue) { 35 | $this->addWhere($query, $key, $extraValue); 36 | } 37 | 38 | return $query->count(); 39 | } 40 | 41 | /** 42 | * Count the number of objects in a collection with the given values. 43 | * 44 | * @param string $collection 45 | * @param string $column 46 | * @param array $values 47 | * @param array $extra 48 | * 49 | * @return int 50 | */ 51 | public function getMultiCount($collection, $column, array $values, array $extra = []) 52 | { 53 | // Nothing can match an empty array. Return early to avoid matching an empty string. 54 | if ($values === []) { 55 | return 0; 56 | } 57 | 58 | // Generates a regex like '/^(a|b|c)$/i' which can query multiple values 59 | $regex = new Regex('^(' . implode('|', array_map(preg_quote(...), $values)) . ')$', 'i'); 60 | 61 | $query = $this->table($collection)->where($column, 'regex', $regex); 62 | 63 | foreach ($extra as $key => $extraValue) { 64 | $this->addWhere($query, $key, $extraValue); 65 | } 66 | 67 | return $query->count(); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/Validation/ValidationServiceProvider.php: -------------------------------------------------------------------------------- 1 | app->singleton('validation.presence', function ($app) { 14 | return new DatabasePresenceVerifier($app['db']); 15 | }); 16 | } 17 | } 18 | --------------------------------------------------------------------------------