├── src ├── Query │ ├── Grammar.php │ ├── Processor.php │ └── AggregationBuilder.php ├── Schema │ ├── Grammar.php │ ├── BlueprintLaravelCompatibility.php │ ├── Blueprint.php │ └── Builder.php ├── Helpers │ ├── EloquentBuilder.php │ └── QueriesRelationships.php ├── Auth │ └── User.php ├── Validation │ ├── ValidationServiceProvider.php │ └── DatabasePresenceVerifier.php ├── Queue │ ├── MongoJob.php │ ├── MongoConnector.php │ └── MongoQueue.php ├── Relations │ ├── MorphMany.php │ ├── HasOne.php │ ├── HasMany.php │ ├── MorphTo.php │ ├── BelongsTo.php │ ├── EmbedsOne.php │ ├── EmbedsMany.php │ ├── BelongsToMany.php │ └── EmbedsOneOrMany.php ├── Eloquent │ ├── SoftDeletes.php │ ├── MassPrunable.php │ ├── Casts │ │ ├── ObjectId.php │ │ └── BinaryUuid.php │ ├── Model.php │ ├── HasSchemaVersion.php │ ├── EmbedsRelations.php │ ├── Builder.php │ └── HybridRelations.php ├── CommandSubscriber.php ├── MongoDBBusServiceProvider.php ├── Session │ └── MongoDbSessionHandler.php ├── Cache │ ├── MongoLock.php │ └── MongoStore.php ├── Concerns │ └── ManagesTransactions.php ├── MongoDBServiceProvider.php ├── Bus │ └── MongoBatchRepository.php └── Connection.php ├── rector.php ├── LICENSE ├── composer.json └── sbom.json /src/Query/Grammar.php: -------------------------------------------------------------------------------- 1 | app->singleton('validation.presence', function ($app) { 16 | return new DatabasePresenceVerifier($app['db']); 17 | }); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /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/Relations/MorphMany.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | class MorphMany extends EloquentMorphMany 17 | { 18 | #[Override] 19 | protected function whereInMethod(Model $model, $key) 20 | { 21 | return 'whereIn'; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Eloquent/SoftDeletes.php: -------------------------------------------------------------------------------- 1 | getDeletedAtColumn(); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/Eloquent/MassPrunable.php: -------------------------------------------------------------------------------- 1 | prunable(); 27 | $total = in_array(SoftDeletes::class, class_uses_recursive(static::class)) 28 | ? $query->forceDelete() 29 | : $query->delete(); 30 | 31 | event(new ModelsPruned(static::class, $total)); 32 | 33 | return $total; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/Eloquent/Casts/ObjectId.php: -------------------------------------------------------------------------------- 1 | 16 | */ 17 | class HasOne extends EloquentHasOne 18 | { 19 | /** 20 | * Get the key for comparing against the parent key in "has" query. 21 | * 22 | * @return string 23 | */ 24 | #[Override] 25 | public function getForeignKeyName() 26 | { 27 | return $this->foreignKey; 28 | } 29 | 30 | /** 31 | * Get the key for comparing against the parent key in "has" query. 32 | * 33 | * @return string 34 | */ 35 | public function getHasCompareKey() 36 | { 37 | return $this->getForeignKeyName(); 38 | } 39 | 40 | /** @inheritdoc */ 41 | #[Override] 42 | public function getRelationExistenceQuery(Builder $query, Builder $parentQuery, $columns = ['*']) 43 | { 44 | $foreignKey = $this->getForeignKeyName(); 45 | 46 | return $query->select($foreignKey)->where($foreignKey, 'exists', true); 47 | } 48 | 49 | /** Get the name of the "where in" method for eager loading. */ 50 | #[Override] 51 | protected function whereInMethod(Model $model, $key) 52 | { 53 | return 'whereIn'; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/Relations/HasMany.php: -------------------------------------------------------------------------------- 1 | 16 | */ 17 | class HasMany extends EloquentHasMany 18 | { 19 | /** 20 | * Get the plain foreign key. 21 | * 22 | * @return string 23 | */ 24 | #[Override] 25 | public function getForeignKeyName() 26 | { 27 | return $this->foreignKey; 28 | } 29 | 30 | /** 31 | * Get the key for comparing against the parent key in "has" query. 32 | * 33 | * @return string 34 | */ 35 | public function getHasCompareKey() 36 | { 37 | return $this->getForeignKeyName(); 38 | } 39 | 40 | /** @inheritdoc */ 41 | #[Override] 42 | public function getRelationExistenceQuery(Builder $query, Builder $parentQuery, $columns = ['*']) 43 | { 44 | $foreignKey = $this->getHasCompareKey(); 45 | 46 | return $query->select($foreignKey)->where($foreignKey, 'exists', true); 47 | } 48 | 49 | /** 50 | * Get the name of the "where in" method for eager loading. 51 | * 52 | * @inheritdoc 53 | */ 54 | #[Override] 55 | protected function whereInMethod(Model $model, $key) 56 | { 57 | return 'whereIn'; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/Relations/MorphTo.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | class MorphTo extends EloquentMorphTo 17 | { 18 | /** @inheritdoc */ 19 | #[Override] 20 | public function addConstraints() 21 | { 22 | if (static::$constraints) { 23 | // For belongs to relationships, which are essentially the inverse of has one 24 | // or has many relationships, we need to actually query on the primary key 25 | // of the related models matching on the foreign key that's on a parent. 26 | $this->query->where( 27 | $this->ownerKey ?? $this->getForeignKeyName(), 28 | '=', 29 | $this->getForeignKeyFrom($this->parent), 30 | ); 31 | } 32 | } 33 | 34 | /** @inheritdoc */ 35 | #[Override] 36 | protected function getResultsByType($type) 37 | { 38 | $instance = $this->createModelByType($type); 39 | 40 | $key = $this->ownerKey ?? $instance->getKeyName(); 41 | 42 | $query = $instance->newQuery(); 43 | 44 | return $query->whereIn($key, $this->gatherKeysByType($type, $instance->getKeyType()))->get(); 45 | } 46 | 47 | /** Get the name of the "where in" method for eager loading. */ 48 | #[Override] 49 | protected function whereInMethod(Model $model, $key) 50 | { 51 | return 'whereIn'; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Queue/MongoConnector.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/CommandSubscriber.php: -------------------------------------------------------------------------------- 1 | */ 19 | private array $commands = []; 20 | 21 | public function __construct(private Connection $connection) 22 | { 23 | } 24 | 25 | #[Override] 26 | public function commandStarted(CommandStartedEvent $event): void 27 | { 28 | $this->commands[$event->getOperationId()] = $event; 29 | } 30 | 31 | #[Override] 32 | public function commandFailed(CommandFailedEvent $event): void 33 | { 34 | $this->logQuery($event); 35 | } 36 | 37 | #[Override] 38 | public function commandSucceeded(CommandSucceededEvent $event): void 39 | { 40 | $this->logQuery($event); 41 | } 42 | 43 | private function logQuery(CommandSucceededEvent|CommandFailedEvent $event): void 44 | { 45 | $startedEvent = $this->commands[$event->getOperationId()]; 46 | unset($this->commands[$event->getOperationId()]); 47 | 48 | $command = []; 49 | foreach (get_object_vars($startedEvent->getCommand()) as $key => $value) { 50 | if ($key[0] !== '$' && ! in_array($key, ['lsid', 'txnNumber'])) { 51 | $command[$key] = $value; 52 | } 53 | } 54 | 55 | $this->connection->logQuery(Document::fromPHP($command)->toCanonicalExtendedJSON(), [], $event->getDurationMicros() / 1000); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /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/Validation/DatabasePresenceVerifier.php: -------------------------------------------------------------------------------- 1 | table($collection)->where($column, new Regex('^' . preg_quote($value) . '$', '/i')); 21 | 22 | if ($excludeId !== null && $excludeId !== 'NULL') { 23 | $query->where($idColumn ?: 'id', '<>', $excludeId); 24 | } 25 | 26 | foreach ($extra as $key => $extraValue) { 27 | $this->addWhere($query, $key, $extraValue); 28 | } 29 | 30 | return $query->count(); 31 | } 32 | 33 | /** Count the number of objects in a collection with the given values. */ 34 | #[Override] 35 | public function getMultiCount($collection, $column, array $values, array $extra = []) 36 | { 37 | // Nothing can match an empty array. Return early to avoid matching an empty string. 38 | if ($values === []) { 39 | return 0; 40 | } 41 | 42 | // Generates a regex like '/^(a|b|c)$/i' which can query multiple values 43 | $regex = new Regex('^(' . implode('|', array_map(preg_quote(...), $values)) . ')$', 'i'); 44 | 45 | $query = $this->table($collection)->where($column, 'regex', $regex); 46 | 47 | foreach ($extra as $key => $extraValue) { 48 | $this->addWhere($query, $key, $extraValue); 49 | } 50 | 51 | return $query->count(); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /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/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/MongoDBBusServiceProvider.php: -------------------------------------------------------------------------------- 1 | app->singleton(MongoBatchRepository::class, function (Container $app) { 26 | $connection = $app->make('db')->connection($app->config->get('queue.batching.database')); 27 | 28 | if (! $connection instanceof Connection) { 29 | throw new InvalidArgumentException(sprintf('The "mongodb" batch driver requires a MongoDB connection. The "%s" connection uses the "%s" driver.', $connection->getName(), $connection->getDriverName())); 30 | } 31 | 32 | return new MongoBatchRepository( 33 | $app->make(BatchFactory::class), 34 | $connection, 35 | $app->config->get('queue.batching.collection', 'job_batches'), 36 | ); 37 | }); 38 | 39 | /** The {@see BatchRepository} service is registered in {@see BusServiceProvider} */ 40 | $this->app->register(BusServiceProvider::class); 41 | $this->app->extend(BatchRepository::class, function (BatchRepository $repository, Container $app) { 42 | $driver = $app->config->get('queue.batching.driver'); 43 | 44 | return match ($driver) { 45 | 'mongodb' => $app->make(MongoBatchRepository::class), 46 | default => $repository, 47 | }; 48 | }); 49 | } 50 | 51 | /** @inheritdoc */ 52 | #[Override] 53 | public function provides() 54 | { 55 | return [ 56 | BatchRepository::class, 57 | MongoBatchRepository::class, 58 | ]; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/Relations/BelongsTo.php: -------------------------------------------------------------------------------- 1 | 16 | */ 17 | class BelongsTo extends EloquentBelongsTo 18 | { 19 | /** 20 | * Get the key for comparing against the parent key in "has" query. 21 | * 22 | * @return string 23 | */ 24 | public function getHasCompareKey() 25 | { 26 | return $this->ownerKey; 27 | } 28 | 29 | /** @inheritdoc */ 30 | #[Override] 31 | public function addConstraints() 32 | { 33 | if (static::$constraints) { 34 | // For belongs to relationships, which are essentially the inverse of has one 35 | // or has many relationships, we need to actually query on the primary key 36 | // of the related models matching on the foreign key that's on a parent. 37 | $this->query->where($this->ownerKey, '=', $this->parent->{$this->foreignKey}); 38 | } 39 | } 40 | 41 | /** @inheritdoc */ 42 | #[Override] 43 | public function addEagerConstraints(array $models) 44 | { 45 | // We'll grab the primary key name of the related models since it could be set to 46 | // a non-standard name and not "id". We will then construct the constraint for 47 | // our eagerly loading query so it returns the proper models from execution. 48 | $this->query->whereIn($this->ownerKey, $this->getEagerModelKeys($models)); 49 | } 50 | 51 | /** @inheritdoc */ 52 | #[Override] 53 | public function getRelationExistenceQuery(Builder $query, Builder $parentQuery, $columns = ['*']) 54 | { 55 | return $query; 56 | } 57 | 58 | /** 59 | * Get the name of the "where in" method for eager loading. 60 | * 61 | * @param string $key 62 | * 63 | * @return string 64 | */ 65 | #[Override] 66 | protected function whereInMethod(Model $model, $key) 67 | { 68 | return 'whereIn'; 69 | } 70 | 71 | #[Override] 72 | public function getQualifiedForeignKeyName(): string 73 | { 74 | return $this->foreignKey; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /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/EmbedsRelations.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/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 | -------------------------------------------------------------------------------- /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 | }, 36 | "require-dev": { 37 | "laravel/scout": "^10.3", 38 | "league/flysystem-gridfs": "^3.28", 39 | "league/flysystem-read-only": "^3.0", 40 | "phpunit/phpunit": "^10.3|^11.5.3", 41 | "orchestra/testbench": "^8.0|^9.0|^10.0", 42 | "mockery/mockery": "^1.4.4", 43 | "doctrine/coding-standard": "12.0.x-dev", 44 | "spatie/laravel-query-builder": "^5.6|^6", 45 | "phpstan/phpstan": "^1.10", 46 | "rector/rector": "^1.2" 47 | }, 48 | "conflict": { 49 | "illuminate/bus": "< 10.37.2" 50 | }, 51 | "suggest": { 52 | "league/flysystem-gridfs": "Filesystem storage in MongoDB with GridFS" 53 | }, 54 | "minimum-stability": "dev", 55 | "prefer-stable": true, 56 | "replace": { 57 | "jenssegers/mongodb": "self.version" 58 | }, 59 | "autoload": { 60 | "psr-4": { 61 | "MongoDB\\Laravel\\": "src/" 62 | } 63 | }, 64 | "autoload-dev": { 65 | "psr-4": { 66 | "MongoDB\\Laravel\\Tests\\": "tests/" 67 | } 68 | }, 69 | "extra": { 70 | "laravel": { 71 | "providers": [ 72 | "MongoDB\\Laravel\\MongoDBServiceProvider", 73 | "MongoDB\\Laravel\\MongoDBBusServiceProvider" 74 | ] 75 | } 76 | }, 77 | "scripts": { 78 | "test": "phpunit", 79 | "test:coverage": "phpunit --coverage-clover ./coverage.xml", 80 | "cs": "phpcs", 81 | "cs:fix": "phpcbf", 82 | "rector": "rector" 83 | }, 84 | "config": { 85 | "allow-plugins": { 86 | "dealerdirect/phpcodesniffer-composer-installer": true 87 | } 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/Session/MongoDbSessionHandler.php: -------------------------------------------------------------------------------- 1 | getCollection()->deleteMany(['last_activity' => ['$lt' => $this->getUTCDateTime(-$lifetime)]]); 31 | 32 | return $result->getDeletedCount() ?? 0; 33 | } 34 | 35 | #[Override] 36 | public function destroy($sessionId): bool 37 | { 38 | $this->getCollection()->deleteOne(['_id' => (string) $sessionId]); 39 | 40 | return true; 41 | } 42 | 43 | #[Override] 44 | public function read($sessionId): string|false 45 | { 46 | $result = $this->getCollection()->findOne( 47 | ['_id' => (string) $sessionId, 'expires_at' => ['$gte' => $this->getUTCDateTime()]], 48 | [ 49 | 'projection' => ['_id' => false, 'payload' => true], 50 | 'typeMap' => ['root' => 'bson'], 51 | ], 52 | ); 53 | 54 | if ($result instanceof Document) { 55 | return (string) $result->payload; 56 | } 57 | 58 | return false; 59 | } 60 | 61 | #[Override] 62 | public function write($sessionId, $data): bool 63 | { 64 | $payload = $this->getDefaultPayload($data); 65 | 66 | $this->getCollection()->replaceOne( 67 | ['_id' => (string) $sessionId], 68 | $payload, 69 | ['upsert' => true], 70 | ); 71 | 72 | return true; 73 | } 74 | 75 | /** Creates a TTL index that automatically deletes expired objects. */ 76 | public function createTTLIndex(): void 77 | { 78 | $this->collection->createIndex( 79 | // UTCDateTime field that holds the expiration date 80 | ['expires_at' => 1], 81 | // Delay to remove items after expiration 82 | ['expireAfterSeconds' => 0], 83 | ); 84 | } 85 | 86 | #[Override] 87 | protected function getDefaultPayload($data): array 88 | { 89 | $payload = [ 90 | 'payload' => new Binary($data), 91 | 'last_activity' => $this->getUTCDateTime(), 92 | 'expires_at' => $this->getUTCDateTime($this->minutes * 60), 93 | ]; 94 | 95 | if (! $this->container) { 96 | return $payload; 97 | } 98 | 99 | return tap($payload, function (&$payload) { 100 | $this->addUserInformation($payload) 101 | ->addRequestInformation($payload); 102 | }); 103 | } 104 | 105 | private function getCollection(): Collection 106 | { 107 | return $this->collection ??= $this->connection->getCollection($this->table); 108 | } 109 | 110 | private function getUTCDateTime(int $additionalSeconds = 0): UTCDateTime 111 | { 112 | return new UTCDateTime((time() + $additionalSeconds) * 1000); 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /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/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/Queue/MongoQueue.php: -------------------------------------------------------------------------------- 1 | retryAfter = $retryAfter; 36 | } 37 | 38 | /** 39 | * @return MongoJob|null 40 | * 41 | * @inheritdoc 42 | */ 43 | #[Override] 44 | public function pop($queue = null) 45 | { 46 | $queue = $this->getQueue($queue); 47 | 48 | if ($this->retryAfter !== null) { 49 | $this->releaseJobsThatHaveBeenReservedTooLong($queue); 50 | } 51 | 52 | $job = $this->getNextAvailableJobAndReserve($queue); 53 | if (! $job) { 54 | return null; 55 | } 56 | 57 | return new MongoJob( 58 | $this->container, 59 | $this, 60 | $job, 61 | $this->connectionName, 62 | $queue, 63 | ); 64 | } 65 | 66 | /** 67 | * Get the next available job for the queue and mark it as reserved. 68 | * When using multiple daemon queue listeners to process jobs there 69 | * is a possibility that multiple processes can end up reading the 70 | * same record before one has flagged it as reserved. 71 | * This race condition can result in random jobs being run more than 72 | * once. To solve this we use findOneAndUpdate to lock the next jobs 73 | * record while flagging it as reserved at the same time. 74 | * 75 | * @param string|null $queue 76 | * 77 | * @return stdClass|null 78 | */ 79 | protected function getNextAvailableJobAndReserve($queue) 80 | { 81 | $job = $this->database->getCollection($this->table)->findOneAndUpdate( 82 | [ 83 | 'queue' => $this->getQueue($queue), 84 | 'reserved' => ['$ne' => 1], 85 | 'available_at' => ['$lte' => Carbon::now()->getTimestamp()], 86 | ], 87 | [ 88 | '$set' => [ 89 | 'reserved' => 1, 90 | 'reserved_at' => Carbon::now()->getTimestamp(), 91 | ], 92 | '$inc' => ['attempts' => 1], 93 | ], 94 | [ 95 | 'returnDocument' => FindOneAndUpdate::RETURN_DOCUMENT_AFTER, 96 | 'sort' => ['available_at' => 1], 97 | ], 98 | ); 99 | 100 | if ($job) { 101 | $job->id = $job->_id; 102 | } 103 | 104 | return $job; 105 | } 106 | 107 | /** 108 | * Release the jobs that have been reserved for too long. 109 | * 110 | * @param string $queue 111 | * 112 | * @return void 113 | */ 114 | protected function releaseJobsThatHaveBeenReservedTooLong($queue) 115 | { 116 | $expiration = Carbon::now()->subSeconds($this->retryAfter)->getTimestamp(); 117 | 118 | $reserved = $this->database->table($this->table) 119 | ->where('queue', $this->getQueue($queue)) 120 | ->whereNotNull('reserved_at') 121 | ->where('reserved_at', '<=', $expiration) 122 | ->get(); 123 | 124 | foreach ($reserved as $job) { 125 | $this->releaseJob($job->id, $job->attempts); 126 | } 127 | } 128 | 129 | /** 130 | * Release the given job ID from reservation. 131 | * 132 | * @param string $id 133 | * @param int $attempts 134 | * 135 | * @return void 136 | */ 137 | protected function releaseJob($id, $attempts) 138 | { 139 | $this->database->table($this->table)->where('_id', $id)->update([ 140 | 'reserved' => 0, 141 | 'reserved_at' => null, 142 | 'attempts' => $attempts, 143 | ]); 144 | } 145 | 146 | /** @inheritdoc */ 147 | #[Override] 148 | public function deleteReserved($queue, $id) 149 | { 150 | $this->database->table($this->table)->where('_id', $id)->delete(); 151 | } 152 | 153 | /** @inheritdoc */ 154 | #[Override] 155 | public function deleteAndRelease($queue, $job, $delay) 156 | { 157 | $this->deleteReserved($queue, $job->getJobId()); 158 | $this->release($queue, $job->getJobRecord(), $delay); 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /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 | #[Override] 45 | public function acquire(): bool 46 | { 47 | // The lock can be acquired if: it doesn't exist, it has expired, 48 | // or it is already owned by the same lock instance. 49 | $isExpiredOrAlreadyOwned = [ 50 | '$or' => [ 51 | ['$lte' => ['$expires_at', $this->getUTCDateTime()]], 52 | ['$eq' => ['$owner', $this->owner]], 53 | ], 54 | ]; 55 | $result = $this->collection->findOneAndUpdate( 56 | ['_id' => $this->name], 57 | [ 58 | [ 59 | '$set' => [ 60 | 'owner' => [ 61 | '$cond' => [ 62 | 'if' => $isExpiredOrAlreadyOwned, 63 | 'then' => $this->owner, 64 | 'else' => '$owner', 65 | ], 66 | ], 67 | 'expires_at' => [ 68 | '$cond' => [ 69 | 'if' => $isExpiredOrAlreadyOwned, 70 | 'then' => $this->getUTCDateTime($this->seconds), 71 | 'else' => '$expires_at', 72 | ], 73 | ], 74 | ], 75 | ], 76 | ], 77 | [ 78 | 'upsert' => true, 79 | 'returnDocument' => FindOneAndUpdate::RETURN_DOCUMENT_AFTER, 80 | 'projection' => ['owner' => 1], 81 | ], 82 | ); 83 | 84 | if ($this->lottery[0] <= 0 && random_int(1, $this->lottery[1]) <= $this->lottery[0]) { 85 | $this->collection->deleteMany(['expires_at' => ['$lte' => $this->getUTCDateTime()]]); 86 | } 87 | 88 | // Compare the owner to check if the lock is owned. Acquiring the same lock 89 | // with the same owner at the same instant would lead to not update the document 90 | return $result['owner'] === $this->owner; 91 | } 92 | 93 | /** 94 | * Release the lock. 95 | */ 96 | #[Override] 97 | public function release(): bool 98 | { 99 | $result = $this->collection 100 | ->deleteOne([ 101 | '_id' => $this->name, 102 | 'owner' => $this->owner, 103 | ]); 104 | 105 | return $result->getDeletedCount() > 0; 106 | } 107 | 108 | /** 109 | * Releases this lock in disregard of ownership. 110 | */ 111 | #[Override] 112 | public function forceRelease(): void 113 | { 114 | $this->collection->deleteOne([ 115 | '_id' => $this->name, 116 | ]); 117 | } 118 | 119 | /** Creates a TTL index that automatically deletes expired objects. */ 120 | public function createTTLIndex(): void 121 | { 122 | $this->collection->createIndex( 123 | // UTCDateTime field that holds the expiration date 124 | ['expires_at' => 1], 125 | // Delay to remove items after expiration 126 | ['expireAfterSeconds' => 0], 127 | ); 128 | } 129 | 130 | /** 131 | * Returns the owner value written into the driver for this lock. 132 | */ 133 | #[Override] 134 | protected function getCurrentOwner(): ?string 135 | { 136 | return $this->collection->findOne( 137 | [ 138 | '_id' => $this->name, 139 | 'expires_at' => ['$gte' => $this->getUTCDateTime()], 140 | ], 141 | ['projection' => ['owner' => 1]], 142 | )['owner'] ?? null; 143 | } 144 | 145 | private function getUTCDateTime(int $additionalSeconds = 0): UTCDateTime 146 | { 147 | return new UTCDateTime(Carbon::now()->addSeconds($additionalSeconds)); 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /src/Concerns/ManagesTransactions.php: -------------------------------------------------------------------------------- 1 | session; 34 | } 35 | 36 | private function getSessionOrCreate(): Session 37 | { 38 | if ($this->session === null) { 39 | $this->session = $this->getClient()->startSession(); 40 | } 41 | 42 | return $this->session; 43 | } 44 | 45 | private function getSessionOrThrow(): Session 46 | { 47 | $session = $this->getSession(); 48 | 49 | if ($session === null) { 50 | throw new RuntimeException('There is no active session.'); 51 | } 52 | 53 | return $session; 54 | } 55 | 56 | /** 57 | * Starts a transaction on the active session. An active session will be created if none exists. 58 | */ 59 | public function beginTransaction(array $options = []): void 60 | { 61 | $this->runCallbacksBeforeTransaction(); 62 | 63 | $this->getSessionOrCreate()->startTransaction($options); 64 | 65 | $this->handleInitialTransactionState(); 66 | } 67 | 68 | private function handleInitialTransactionState(): void 69 | { 70 | $this->transactions = 1; 71 | 72 | $this->transactionsManager?->begin( 73 | $this->getName(), 74 | $this->transactions, 75 | ); 76 | 77 | $this->fireConnectionEvent('beganTransaction'); 78 | } 79 | 80 | /** 81 | * Commit transaction in this session. 82 | */ 83 | public function commit(): void 84 | { 85 | $this->fireConnectionEvent('committing'); 86 | $this->getSessionOrThrow()->commitTransaction(); 87 | 88 | $this->handleCommitState(); 89 | } 90 | 91 | private function handleCommitState(): void 92 | { 93 | [$levelBeingCommitted, $this->transactions] = [ 94 | $this->transactions, 95 | max(0, $this->transactions - 1), 96 | ]; 97 | 98 | $this->transactionsManager?->commit( 99 | $this->getName(), 100 | $levelBeingCommitted, 101 | $this->transactions, 102 | ); 103 | 104 | $this->fireConnectionEvent('committed'); 105 | } 106 | 107 | /** 108 | * Abort transaction in this session. 109 | */ 110 | public function rollBack($toLevel = null): void 111 | { 112 | $session = $this->getSessionOrThrow(); 113 | if ($session->isInTransaction()) { 114 | $session->abortTransaction(); 115 | } 116 | 117 | $this->handleRollbackState(); 118 | } 119 | 120 | private function handleRollbackState(): void 121 | { 122 | $this->transactions = 0; 123 | 124 | $this->transactionsManager?->rollback( 125 | $this->getName(), 126 | $this->transactions, 127 | ); 128 | 129 | $this->fireConnectionEvent('rollingBack'); 130 | } 131 | 132 | private function runCallbacksBeforeTransaction(): void 133 | { 134 | // ToDo: remove conditional once we stop supporting Laravel 10.x 135 | if (property_exists(Connection::class, 'beforeStartingTransaction')) { 136 | foreach ($this->beforeStartingTransaction as $beforeTransactionCallback) { 137 | $beforeTransactionCallback($this); 138 | } 139 | } 140 | } 141 | 142 | /** 143 | * Static transaction function realize the with_transaction functionality provided by MongoDB. 144 | * 145 | * @param int $attempts 146 | * 147 | * @throws Throwable 148 | */ 149 | public function transaction(Closure $callback, $attempts = 1, array $options = []): mixed 150 | { 151 | $attemptsLeft = $attempts; 152 | $callbackResult = null; 153 | $throwable = null; 154 | 155 | $callbackFunction = function (Session $session) use ($callback, &$attemptsLeft, &$callbackResult, &$throwable) { 156 | $attemptsLeft--; 157 | 158 | if ($attemptsLeft < 0) { 159 | $session->abortTransaction(); 160 | $this->handleRollbackState(); 161 | 162 | return; 163 | } 164 | 165 | $this->runCallbacksBeforeTransaction(); 166 | $this->handleInitialTransactionState(); 167 | 168 | // Catch, store, and re-throw any exception thrown during execution 169 | // of the callable. The last exception is re-thrown if the transaction 170 | // was aborted because the number of callback attempts has been exceeded. 171 | try { 172 | $callbackResult = $callback($this); 173 | $this->fireConnectionEvent('committing'); 174 | } catch (Throwable $throwable) { 175 | throw $throwable; 176 | } 177 | }; 178 | 179 | with_transaction($this->getSessionOrCreate(), $callbackFunction, $options); 180 | 181 | if ($attemptsLeft < 0 && $throwable) { 182 | $this->handleRollbackState(); 183 | throw $throwable; 184 | } 185 | 186 | $this->handleCommitState(); 187 | 188 | return $callbackResult; 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /src/MongoDBServiceProvider.php: -------------------------------------------------------------------------------- 1 | app['db']); 44 | 45 | Model::setEventDispatcher($this->app['events']); 46 | } 47 | 48 | /** 49 | * Register the service provider. 50 | */ 51 | #[Override] 52 | public function register() 53 | { 54 | // Add database driver. 55 | $this->app->resolving('db', function ($db) { 56 | $db->extend('mongodb', function ($config, $name) { 57 | $config['name'] = $name; 58 | 59 | return new Connection($config); 60 | }); 61 | }); 62 | 63 | // Session handler for MongoDB 64 | $this->app->resolving(SessionManager::class, function (SessionManager $sessionManager) { 65 | $sessionManager->extend('mongodb', function (Application $app) { 66 | $connectionName = $app->config->get('session.connection') ?: 'mongodb'; 67 | $connection = $app->make('db')->connection($connectionName); 68 | 69 | assert($connection instanceof Connection, new InvalidArgumentException(sprintf('The database connection "%s" used for the session does not use the "mongodb" driver.', $connectionName))); 70 | 71 | return new MongoDbSessionHandler( 72 | $connection, 73 | $app->config->get('session.table', 'sessions'), 74 | $app->config->get('session.lifetime'), 75 | $app, 76 | ); 77 | }); 78 | }); 79 | 80 | // Add cache and lock drivers. 81 | $this->app->resolving('cache', function (CacheManager $cache) { 82 | $cache->extend('mongodb', function (Application $app, array $config): Repository { 83 | // The closure is bound to the CacheManager 84 | assert($this instanceof CacheManager); 85 | 86 | $store = new MongoStore( 87 | $app['db']->connection($config['connection'] ?? null), 88 | $config['collection'] ?? 'cache', 89 | $this->getPrefix($config), 90 | $app['db']->connection($config['lock_connection'] ?? $config['connection'] ?? null), 91 | $config['lock_collection'] ?? ($config['collection'] ?? 'cache') . '_locks', 92 | $config['lock_lottery'] ?? [2, 100], 93 | $config['lock_timeout'] ?? 86400, 94 | ); 95 | 96 | return $this->repository($store, $config); 97 | }); 98 | }); 99 | 100 | // Add connector for queue support. 101 | $this->app->resolving('queue', function ($queue) { 102 | $queue->addConnector('mongodb', function () { 103 | return new MongoConnector($this->app['db']); 104 | }); 105 | }); 106 | 107 | $this->registerFlysystemAdapter(); 108 | $this->registerScoutEngine(); 109 | } 110 | 111 | private function registerFlysystemAdapter(): void 112 | { 113 | // GridFS adapter for filesystem 114 | $this->app->resolving('filesystem', static function (FilesystemManager $filesystemManager) { 115 | $filesystemManager->extend('gridfs', static function (Application $app, array $config) { 116 | if (! class_exists(GridFSAdapter::class)) { 117 | throw new RuntimeException('GridFS adapter for Flysystem is missing. Try running "composer require league/flysystem-gridfs"'); 118 | } 119 | 120 | $bucket = $config['bucket'] ?? null; 121 | 122 | if ($bucket instanceof Closure) { 123 | // Get the bucket from a factory function 124 | $bucket = $bucket($app, $config); 125 | } elseif (is_string($bucket) && $app->has($bucket)) { 126 | // Get the bucket from a service 127 | $bucket = $app->get($bucket); 128 | } elseif (is_string($bucket) || $bucket === null) { 129 | // Get the bucket from the database connection 130 | $connection = $app['db']->connection($config['connection']); 131 | if (! $connection instanceof Connection) { 132 | throw new InvalidArgumentException(sprintf('The database connection "%s" does not use the "mongodb" driver.', $config['connection'] ?? $app['config']['database.default'])); 133 | } 134 | 135 | $bucket = $connection->getClient() 136 | ->getDatabase($config['database'] ?? $connection->getDatabaseName()) 137 | ->selectGridFSBucket(['bucketName' => $config['bucket'] ?? 'fs', 'disableMD5' => true]); 138 | } 139 | 140 | if (! $bucket instanceof Bucket) { 141 | throw new InvalidArgumentException(sprintf('Unexpected value for GridFS "bucket" configuration. Expecting "%s". Got "%s"', Bucket::class, get_debug_type($bucket))); 142 | } 143 | 144 | $adapter = new GridFSAdapter($bucket, $config['prefix'] ?? ''); 145 | 146 | /** @see FilesystemManager::createFlysystem() */ 147 | if ($config['read-only'] ?? false) { 148 | if (! class_exists(ReadOnlyFilesystemAdapter::class)) { 149 | throw new RuntimeException('Read-only Adapter for Flysystem is missing. Try running "composer require league/flysystem-read-only"'); 150 | } 151 | 152 | $adapter = new ReadOnlyFilesystemAdapter($adapter); 153 | } 154 | 155 | /** Prevent using backslash on Windows in {@see FilesystemAdapter::__construct()} */ 156 | $config['directory_separator'] = '/'; 157 | 158 | return new FilesystemAdapter(new Filesystem($adapter, $config), $adapter, $config); 159 | }); 160 | }); 161 | } 162 | 163 | private function registerScoutEngine(): void 164 | { 165 | $this->app->resolving(EngineManager::class, function (EngineManager $engineManager) { 166 | $engineManager->extend('mongodb', function (Container $app) { 167 | $connectionName = $app->get('config')->get('scout.mongodb.connection', 'mongodb'); 168 | $connection = $app->get('db')->connection($connectionName); 169 | $softDelete = (bool) $app->get('config')->get('scout.soft_delete', false); 170 | $indexDefinitions = $app->get('config')->get('scout.mongodb.index-definitions', []); 171 | 172 | assert($connection instanceof Connection, new InvalidArgumentException(sprintf('The connection "%s" is not a MongoDB connection.', $connectionName))); 173 | 174 | return new ScoutEngine($connection->getDatabase(), $softDelete, $indexDefinitions); 175 | }); 176 | 177 | return $engineManager; 178 | }); 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /src/Helpers/QueriesRelationships.php: -------------------------------------------------------------------------------- 1 | =', $count = 1, $boolean = 'and', ?Closure $callback = null) 46 | { 47 | if (is_string($relation)) { 48 | if (str_contains($relation, '.')) { 49 | return $this->hasNested($relation, $operator, $count, $boolean, $callback); 50 | } 51 | 52 | $relation = $this->getRelationWithoutConstraints($relation); 53 | } 54 | 55 | // If this is a hybrid relation then we can not use a normal whereExists() query that relies on a subquery 56 | // We need to use a `whereIn` query 57 | if (Model::isDocumentModel($this->getModel()) || $this->isAcrossConnections($relation)) { 58 | return $this->addHybridHas($relation, $operator, $count, $boolean, $callback); 59 | } 60 | 61 | // If we only need to check for the existence of the relation, then we can optimize 62 | // the subquery to only run a "where exists" clause instead of this full "count" 63 | // clause. This will make these queries run much faster compared with a count. 64 | $method = $this->canUseExistsForExistenceCheck($operator, $count) 65 | ? 'getRelationExistenceQuery' 66 | : 'getRelationExistenceCountQuery'; 67 | 68 | $hasQuery = $relation->{$method}( 69 | $relation->getRelated()->newQuery(), 70 | $this 71 | ); 72 | 73 | // Next we will call any given callback as an "anonymous" scope so they can get the 74 | // proper logical grouping of the where clauses if needed by this Eloquent query 75 | // builder. Then, we will be ready to finalize and return this query instance. 76 | if ($callback) { 77 | $hasQuery->callScope($callback); 78 | } 79 | 80 | return $this->addHasWhere( 81 | $hasQuery, 82 | $relation, 83 | $operator, 84 | $count, 85 | $boolean, 86 | ); 87 | } 88 | 89 | /** @return bool */ 90 | protected function isAcrossConnections(Relation $relation) 91 | { 92 | return $relation->getParent()->getConnectionName() !== $relation->getRelated()->getConnectionName(); 93 | } 94 | 95 | /** 96 | * Compare across databases. 97 | * 98 | * @param string $operator 99 | * @param int $count 100 | * @param string $boolean 101 | * 102 | * @return mixed 103 | * 104 | * @throws Exception 105 | */ 106 | public function addHybridHas(Relation $relation, $operator = '>=', $count = 1, $boolean = 'and', ?Closure $callback = null) 107 | { 108 | $this->assertHybridRelationSupported($relation); 109 | 110 | $hasQuery = $relation->getQuery(); 111 | if ($callback) { 112 | $hasQuery->callScope($callback); 113 | } 114 | 115 | // If the operator is <, <= or !=, we will use whereNotIn. 116 | $not = in_array($operator, ['<', '<=', '!=']); 117 | // If we are comparing to 0, we need an additional $not flip. 118 | if ($count === 0) { 119 | $not = ! $not; 120 | } 121 | 122 | $relations = match (true) { 123 | $relation instanceof MorphToMany => $relation->getInverse() ? 124 | $this->handleMorphedByMany($hasQuery, $relation) : 125 | $this->handleMorphToMany($hasQuery, $relation), 126 | default => $hasQuery->pluck($this->getHasCompareKey($relation)) 127 | }; 128 | 129 | $relatedIds = $this->getConstrainedRelatedIds($relations, $operator, $count); 130 | 131 | return $this->whereIn($this->getRelatedConstraintKey($relation), $relatedIds, $boolean, $not); 132 | } 133 | 134 | /** 135 | * @param Relation $relation 136 | * 137 | * @return void 138 | * 139 | * @throws Exception 140 | */ 141 | private function assertHybridRelationSupported(Relation $relation): void 142 | { 143 | if ( 144 | $relation instanceof HasOneOrMany 145 | || $relation instanceof BelongsTo 146 | || ($relation instanceof BelongsToMany && ! $this->isAcrossConnections($relation)) 147 | ) { 148 | return; 149 | } 150 | 151 | throw new LogicException(class_basename($relation) . ' is not supported for hybrid query constraints.'); 152 | } 153 | 154 | /** 155 | * @param Builder $hasQuery 156 | * @param Relation $relation 157 | * 158 | * @return Collection 159 | */ 160 | private function handleMorphToMany($hasQuery, $relation) 161 | { 162 | // First we select the parent models that have a relation to our related model, 163 | // Then extracts related model's ids from the pivot column 164 | $hasQuery->where($relation->getTable() . '.' . $relation->getMorphType(), $relation->getParent()::class); 165 | $relations = $hasQuery->pluck($relation->getTable()); 166 | $relations = $relation->extractIds($relations->flatten(1)->toArray(), $relation->getForeignPivotKeyName()); 167 | 168 | return collect($relations); 169 | } 170 | 171 | /** 172 | * @param Builder $hasQuery 173 | * @param Relation $relation 174 | * 175 | * @return Collection 176 | */ 177 | private function handleMorphedByMany($hasQuery, $relation) 178 | { 179 | $hasQuery->whereNotNull($relation->getForeignPivotKeyName()); 180 | 181 | return $hasQuery->pluck($relation->getForeignPivotKeyName())->flatten(1); 182 | } 183 | 184 | /** @return string */ 185 | protected function getHasCompareKey(Relation $relation) 186 | { 187 | if (method_exists($relation, 'getHasCompareKey')) { 188 | return $relation->getHasCompareKey(); 189 | } 190 | 191 | return $relation instanceof HasOneOrMany ? $relation->getForeignKeyName() : $relation->getOwnerKeyName(); 192 | } 193 | 194 | /** 195 | * @param Collection $relations 196 | * @param string $operator 197 | * @param int $count 198 | * 199 | * @return array 200 | */ 201 | protected function getConstrainedRelatedIds($relations, $operator, $count) 202 | { 203 | $relationCount = array_count_values(array_map(function ($id) { 204 | return (string) $id; // Convert Back ObjectIds to Strings 205 | }, is_array($relations) ? $relations : $relations->flatten()->toArray())); 206 | // Remove unwanted related objects based on the operator and count. 207 | $relationCount = array_filter($relationCount, function ($counted) use ($count, $operator) { 208 | // If we are comparing to 0, we always need all results. 209 | if ($count === 0) { 210 | return true; 211 | } 212 | 213 | switch ($operator) { 214 | case '>=': 215 | case '<': 216 | return $counted >= $count; 217 | case '>': 218 | case '<=': 219 | return $counted > $count; 220 | case '=': 221 | case '!=': 222 | return $counted === $count; 223 | } 224 | }); 225 | 226 | // All related ids. 227 | return array_keys($relationCount); 228 | } 229 | 230 | /** 231 | * Returns key we are constraining this parent model's query with. 232 | * 233 | * @return string 234 | * 235 | * @throws Exception 236 | */ 237 | protected function getRelatedConstraintKey(Relation $relation) 238 | { 239 | $this->assertHybridRelationSupported($relation); 240 | 241 | if ($relation instanceof HasOneOrMany) { 242 | return $relation->getLocalKeyName(); 243 | } 244 | 245 | if ($relation instanceof BelongsTo) { 246 | return $relation->getForeignKeyName(); 247 | } 248 | 249 | if ($relation instanceof BelongsToMany) { 250 | return $this->model->getKeyName(); 251 | } 252 | 253 | throw new Exception(class_basename($relation) . ' is not supported for hybrid query constraints.'); 254 | } 255 | } 256 | -------------------------------------------------------------------------------- /src/Bus/MongoBatchRepository.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 | #[Override] 220 | public function pruneUnfinished(DateTimeInterface $before): int 221 | { 222 | $result = $this->collection->deleteMany( 223 | [ 224 | 'finished_at' => null, 225 | 'created_at' => ['$lt' => new UTCDateTime($before)], 226 | ], 227 | ); 228 | 229 | return $result->getDeletedCount(); 230 | } 231 | 232 | /** Prune all the cancelled entries older than the given date. */ 233 | #[Override] 234 | public function pruneCancelled(DateTimeInterface $before): int 235 | { 236 | $result = $this->collection->deleteMany( 237 | [ 238 | 'cancelled_at' => ['$ne' => null], 239 | 'created_at' => ['$lt' => new UTCDateTime($before)], 240 | ], 241 | ); 242 | 243 | return $result->getDeletedCount(); 244 | } 245 | 246 | /** @param array $batch */ 247 | #[Override] 248 | protected function toBatch($batch): Batch 249 | { 250 | return $this->factory->make( 251 | $this, 252 | $batch['_id'], 253 | $batch['name'], 254 | $batch['total_jobs'], 255 | $batch['pending_jobs'], 256 | $batch['failed_jobs'], 257 | $batch['failed_job_ids'], 258 | unserialize($batch['options']), 259 | $this->toCarbon($batch['created_at']), 260 | $this->toCarbon($batch['cancelled_at']), 261 | $this->toCarbon($batch['finished_at']), 262 | ); 263 | } 264 | 265 | private function getUTCDateTime(): UTCDateTime 266 | { 267 | // Using Carbon so the current time can be modified for tests 268 | return new UTCDateTime(Carbon::now()); 269 | } 270 | 271 | /** @return ($date is null ? null : CarbonImmutable) */ 272 | private function toCarbon(?UTCDateTime $date): ?CarbonImmutable 273 | { 274 | if ($date === null) { 275 | return null; 276 | } 277 | 278 | return CarbonImmutable::createFromTimestampMsUTC((string) $date); 279 | } 280 | } 281 | -------------------------------------------------------------------------------- /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/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/BelongsToMany.php: -------------------------------------------------------------------------------- 1 | 29 | */ 30 | class BelongsToMany extends EloquentBelongsToMany 31 | { 32 | /** 33 | * Get the key for comparing against the parent key in "has" query. 34 | * 35 | * @return string 36 | */ 37 | public function getHasCompareKey() 38 | { 39 | return $this->getForeignKey(); 40 | } 41 | 42 | /** @inheritdoc */ 43 | #[Override] 44 | public function getRelationExistenceQuery(Builder $query, Builder $parentQuery, $columns = ['*']) 45 | { 46 | return $query; 47 | } 48 | 49 | /** @inheritdoc */ 50 | #[Override] 51 | protected function hydratePivotRelation(array $models) 52 | { 53 | // Do nothing. 54 | } 55 | 56 | /** 57 | * Set the select clause for the relation query. 58 | * 59 | * @return array 60 | */ 61 | protected function getSelectColumns(array $columns = ['*']) 62 | { 63 | return $columns; 64 | } 65 | 66 | /** @inheritdoc */ 67 | #[Override] 68 | protected function shouldSelect(array $columns = ['*']) 69 | { 70 | return $columns; 71 | } 72 | 73 | /** @inheritdoc */ 74 | #[Override] 75 | public function addConstraints() 76 | { 77 | if (static::$constraints) { 78 | $this->setWhere(); 79 | } 80 | } 81 | 82 | /** 83 | * Set the where clause for the relation query. 84 | * 85 | * @return $this 86 | */ 87 | protected function setWhere() 88 | { 89 | $foreign = $this->getForeignKey(); 90 | 91 | $this->query->where($foreign, '=', $this->parent->{$this->parentKey}); 92 | 93 | return $this; 94 | } 95 | 96 | /** @inheritdoc */ 97 | #[Override] 98 | public function save(Model $model, array $pivotAttributes = [], $touch = true) 99 | { 100 | $model->save(['touch' => false]); 101 | 102 | $this->attach($model, $pivotAttributes, $touch); 103 | 104 | return $model; 105 | } 106 | 107 | /** @inheritdoc */ 108 | #[Override] 109 | public function create(array $attributes = [], array $joining = [], $touch = true) 110 | { 111 | $instance = $this->related->newInstance($attributes); 112 | 113 | // Once we save the related model, we need to attach it to the base model via 114 | // through intermediate table so we'll use the existing "attach" method to 115 | // accomplish this which will insert the record and any more attributes. 116 | $instance->save(['touch' => false]); 117 | 118 | $this->attach($instance, $joining, $touch); 119 | 120 | return $instance; 121 | } 122 | 123 | /** @inheritdoc */ 124 | #[Override] 125 | public function sync($ids, $detaching = true) 126 | { 127 | $changes = [ 128 | 'attached' => [], 129 | 'detached' => [], 130 | 'updated' => [], 131 | ]; 132 | 133 | if ($ids instanceof Collection) { 134 | $ids = $this->parseIds($ids); 135 | } elseif ($ids instanceof Model) { 136 | $ids = $this->parseIds($ids); 137 | } 138 | 139 | // First we need to attach any of the associated models that are not currently 140 | // in this joining table. We'll spin through the given IDs, checking to see 141 | // if they exist in the array of current ones, and if not we will insert. 142 | $current = match (\MongoDB\Laravel\Eloquent\Model::isDocumentModel($this->parent)) { 143 | true => $this->parent->{$this->relatedPivotKey} ?: [], 144 | false => $this->parent->{$this->relationName} ?: [], 145 | }; 146 | 147 | if ($current instanceof Collection) { 148 | $current = $this->parseIds($current); 149 | } 150 | 151 | $records = $this->formatRecordsList($ids); 152 | 153 | $current = Arr::wrap($current); 154 | 155 | $detach = array_diff($current, array_keys($records)); 156 | 157 | // We need to make sure we pass a clean array, so that it is not interpreted 158 | // as an associative array. 159 | $detach = array_values($detach); 160 | 161 | // Next, we will take the differences of the currents and given IDs and detach 162 | // all of the entities that exist in the "current" array but are not in the 163 | // the array of the IDs given to the method which will complete the sync. 164 | if ($detaching && count($detach) > 0) { 165 | $this->detach($detach); 166 | 167 | $changes['detached'] = (array) array_map(function ($v) { 168 | return is_numeric($v) ? (int) $v : (string) $v; 169 | }, $detach); 170 | } 171 | 172 | // Now we are finally ready to attach the new records. Note that we'll disable 173 | // touching until after the entire operation is complete so we don't fire a 174 | // ton of touch operations until we are totally done syncing the records. 175 | $changes = array_replace( 176 | $changes, 177 | $this->attachNew($records, $current, false), 178 | ); 179 | 180 | if (count($changes['attached']) || count($changes['updated'])) { 181 | $this->touchIfTouching(); 182 | } 183 | 184 | return $changes; 185 | } 186 | 187 | /** @inheritdoc */ 188 | #[Override] 189 | public function updateExistingPivot($id, array $attributes, $touch = true) 190 | { 191 | // Do nothing, we have no pivot table. 192 | return $this; 193 | } 194 | 195 | /** @inheritdoc */ 196 | #[Override] 197 | public function attach($id, array $attributes = [], $touch = true) 198 | { 199 | if ($id instanceof Model) { 200 | $model = $id; 201 | 202 | $id = $this->parseId($model); 203 | 204 | // Attach the new parent id to the related model. 205 | $model->push($this->foreignPivotKey, $this->parent->{$this->parentKey}, true); 206 | } else { 207 | if ($id instanceof Collection) { 208 | $id = $this->parseIds($id); 209 | } 210 | 211 | $query = $this->newRelatedQuery(); 212 | 213 | $query->whereIn($this->relatedKey, (array) $id); 214 | 215 | // Attach the new parent id to the related model. 216 | $query->push($this->foreignPivotKey, $this->parent->{$this->parentKey}, true); 217 | } 218 | 219 | // Attach the new ids to the parent model. 220 | if (\MongoDB\Laravel\Eloquent\Model::isDocumentModel($this->parent)) { 221 | $this->parent->push($this->relatedPivotKey, (array) $id, true); 222 | } else { 223 | $instance = new $this->related(); 224 | $instance->forceFill([$this->relatedKey => $id]); 225 | $relationData = $this->parent->{$this->relationName}->push($instance)->unique($this->relatedKey); 226 | $this->parent->setRelation($this->relationName, $relationData); 227 | } 228 | 229 | if (! $touch) { 230 | return; 231 | } 232 | 233 | $this->touchIfTouching(); 234 | } 235 | 236 | /** @inheritdoc */ 237 | #[Override] 238 | public function detach($ids = [], $touch = true) 239 | { 240 | if ($ids instanceof Model) { 241 | $ids = $this->parseIds($ids); 242 | } 243 | 244 | $query = $this->newRelatedQuery(); 245 | 246 | // If associated IDs were passed to the method we will only delete those 247 | // associations, otherwise all of the association ties will be broken. 248 | // We'll return the numbers of affected rows when we do the deletes. 249 | $ids = (array) $ids; 250 | 251 | // Detach all ids from the parent model. 252 | if (DocumentModel::isDocumentModel($this->parent)) { 253 | $this->parent->pull($this->relatedPivotKey, $ids); 254 | } else { 255 | $value = $this->parent->{$this->relationName} 256 | ->filter(fn ($rel) => ! in_array($rel->{$this->relatedKey}, $ids)); 257 | $this->parent->setRelation($this->relationName, $value); 258 | } 259 | 260 | // Prepare the query to select all related objects. 261 | if (count($ids) > 0) { 262 | $query->whereIn($this->relatedKey, $ids); 263 | } 264 | 265 | // Remove the relation to the parent. 266 | assert($this->parent instanceof Model); 267 | assert($query instanceof \MongoDB\Laravel\Eloquent\Builder); 268 | $query->pull($this->foreignPivotKey, $this->parent->{$this->parentKey}); 269 | 270 | if ($touch) { 271 | $this->touchIfTouching(); 272 | } 273 | 274 | return count($ids); 275 | } 276 | 277 | /** @inheritdoc */ 278 | #[Override] 279 | protected function buildDictionary(Collection $results) 280 | { 281 | $foreign = $this->foreignPivotKey; 282 | 283 | // First we will build a dictionary of child models keyed by the foreign key 284 | // of the relation so that we will easily and quickly match them to their 285 | // parents without having a possibly slow inner loops for every models. 286 | $dictionary = []; 287 | 288 | foreach ($results as $result) { 289 | foreach ($result->$foreign as $item) { 290 | $dictionary[$item][] = $result; 291 | } 292 | } 293 | 294 | return $dictionary; 295 | } 296 | 297 | /** @inheritdoc */ 298 | #[Override] 299 | public function newPivotQuery() 300 | { 301 | return $this->newRelatedQuery(); 302 | } 303 | 304 | /** 305 | * Create a new query builder for the related model. 306 | * 307 | * @return Builder|Model 308 | */ 309 | public function newRelatedQuery() 310 | { 311 | return $this->related->newQuery(); 312 | } 313 | 314 | /** 315 | * Get the fully qualified foreign key for the relation. 316 | * 317 | * @return string 318 | */ 319 | public function getForeignKey() 320 | { 321 | return $this->foreignPivotKey; 322 | } 323 | 324 | /** @inheritdoc */ 325 | #[Override] 326 | public function getQualifiedForeignPivotKeyName() 327 | { 328 | return $this->foreignPivotKey; 329 | } 330 | 331 | /** @inheritdoc */ 332 | #[Override] 333 | public function getQualifiedRelatedPivotKeyName() 334 | { 335 | return $this->relatedPivotKey; 336 | } 337 | 338 | /** 339 | * Get the name of the "where in" method for eager loading. 340 | * 341 | * @inheritdoc 342 | */ 343 | #[Override] 344 | protected function whereInMethod(Model $model, $key) 345 | { 346 | return 'whereIn'; 347 | } 348 | } 349 | -------------------------------------------------------------------------------- /src/Schema/Blueprint.php: -------------------------------------------------------------------------------- 1 | fluent($columns); 46 | 47 | // Columns are passed as a default array. 48 | if (is_array($columns) && is_int(key($columns))) { 49 | // Transform the columns to the required array format. 50 | $transform = []; 51 | 52 | foreach ($columns as $column) { 53 | $transform[$column] = 1; 54 | } 55 | 56 | $columns = $transform; 57 | } 58 | 59 | if ($name !== null) { 60 | $options['name'] = $name; 61 | } 62 | 63 | $this->collection->createIndex($columns, $options); 64 | 65 | return $this; 66 | } 67 | 68 | /** @inheritdoc */ 69 | #[Override] 70 | public function primary($columns = null, $name = null, $algorithm = null, $options = []) 71 | { 72 | return $this->unique($columns, $name, $algorithm, $options); 73 | } 74 | 75 | /** @inheritdoc */ 76 | #[Override] 77 | public function dropIndex($index = null) 78 | { 79 | $index = $this->transformColumns($index); 80 | 81 | $this->collection->dropIndex($index); 82 | 83 | return $this; 84 | } 85 | 86 | /** 87 | * Indicate that the given index should be dropped, but do not fail if it didn't exist. 88 | * 89 | * @param string|array $indexOrColumns 90 | * 91 | * @return Blueprint 92 | */ 93 | public function dropIndexIfExists($indexOrColumns = null) 94 | { 95 | if ($this->hasIndex($indexOrColumns)) { 96 | $this->dropIndex($indexOrColumns); 97 | } 98 | 99 | return $this; 100 | } 101 | 102 | /** 103 | * Check whether the given index exists. 104 | * 105 | * @param string|array $indexOrColumns 106 | * 107 | * @return bool 108 | */ 109 | public function hasIndex($indexOrColumns = null) 110 | { 111 | $indexOrColumns = $this->transformColumns($indexOrColumns); 112 | foreach ($this->collection->listIndexes() as $index) { 113 | if (is_array($indexOrColumns) && in_array($index->getName(), $indexOrColumns)) { 114 | return true; 115 | } 116 | 117 | if (is_string($indexOrColumns) && $index->getName() === $indexOrColumns) { 118 | return true; 119 | } 120 | } 121 | 122 | return false; 123 | } 124 | 125 | public function jsonSchema( 126 | array $schema = [], 127 | ?string $validationLevel = null, 128 | ?string $validationAction = null, 129 | ): void { 130 | $options = array_merge( 131 | [ 132 | 'validator' => [ 133 | '$jsonSchema' => $schema, 134 | ], 135 | ], 136 | $validationLevel ? ['validationLevel' => $validationLevel] : [], 137 | $validationAction ? ['validationAction' => $validationAction] : [], 138 | ); 139 | 140 | $this->connection->getDatabase()->modifyCollection($this->collection->getCollectionName(), $options); 141 | } 142 | 143 | /** 144 | * @param string|array $indexOrColumns 145 | * 146 | * @return string 147 | */ 148 | protected function transformColumns($indexOrColumns) 149 | { 150 | if (is_array($indexOrColumns)) { 151 | $indexOrColumns = $this->fluent($indexOrColumns); 152 | 153 | // Transform the columns to the index name. 154 | $transform = []; 155 | 156 | foreach ($indexOrColumns as $key => $value) { 157 | if (is_int($key)) { 158 | // There is no sorting order, use the default. 159 | $column = $value; 160 | $sorting = '1'; 161 | } else { 162 | // This is a column with sorting order e.g 'my_column' => -1. 163 | $column = $key; 164 | $sorting = $value; 165 | } 166 | 167 | $transform[$column] = $column . '_' . $sorting; 168 | } 169 | 170 | $indexOrColumns = implode('_', $transform); 171 | } 172 | 173 | return $indexOrColumns; 174 | } 175 | 176 | /** @inheritdoc */ 177 | #[Override] 178 | public function unique($columns = null, $name = null, $algorithm = null, $options = []) 179 | { 180 | $columns = $this->fluent($columns); 181 | 182 | $options['unique'] = true; 183 | 184 | $this->index($columns, $name, $algorithm, $options); 185 | 186 | return $this; 187 | } 188 | 189 | /** 190 | * Specify a sparse index for the collection. 191 | * 192 | * @param string|array $columns 193 | * @param array $options 194 | * 195 | * @return Blueprint 196 | */ 197 | public function sparse($columns = null, $options = []) 198 | { 199 | $columns = $this->fluent($columns); 200 | 201 | $options['sparse'] = true; 202 | 203 | $this->index($columns, null, null, $options); 204 | 205 | return $this; 206 | } 207 | 208 | /** 209 | * Specify a geospatial index for the collection. 210 | * 211 | * @param string|array $columns 212 | * @param string $index 213 | * @param array $options 214 | * 215 | * @return Blueprint 216 | */ 217 | public function geospatial($columns = null, $index = '2d', $options = []) 218 | { 219 | if ($index === '2d' || $index === '2dsphere') { 220 | $columns = $this->fluent($columns); 221 | 222 | $columns = array_flip($columns); 223 | 224 | foreach ($columns as $column => $value) { 225 | $columns[$column] = $index; 226 | } 227 | 228 | $this->index($columns, null, null, $options); 229 | } 230 | 231 | return $this; 232 | } 233 | 234 | /** 235 | * Specify the number of seconds after which a document should be considered expired based, 236 | * on the given single-field index containing a date. 237 | * 238 | * @param string|array $columns 239 | * @param int $seconds 240 | * 241 | * @return Blueprint 242 | */ 243 | public function expire($columns, $seconds) 244 | { 245 | $columns = $this->fluent($columns); 246 | 247 | $this->index($columns, null, null, ['expireAfterSeconds' => $seconds]); 248 | 249 | return $this; 250 | } 251 | 252 | /** 253 | * Indicate that the collection needs to be created. 254 | * 255 | * @param array $options 256 | * 257 | * @return void 258 | */ 259 | #[Override] 260 | public function create($options = []) 261 | { 262 | $collection = $this->collection->getCollectionName(); 263 | 264 | $db = $this->connection->getDatabase(); 265 | 266 | // Ensure the collection is created. 267 | $db->createCollection($collection, $options); 268 | } 269 | 270 | /** @inheritdoc */ 271 | #[Override] 272 | public function drop() 273 | { 274 | $this->collection->drop(); 275 | 276 | return $this; 277 | } 278 | 279 | /** @inheritdoc */ 280 | #[Override] 281 | public function renameColumn($from, $to) 282 | { 283 | $this->collection->updateMany([$from => ['$exists' => true]], ['$rename' => [$from => $to]]); 284 | 285 | return $this; 286 | } 287 | 288 | /** @inheritdoc */ 289 | #[Override] 290 | public function addColumn($type, $name, array $parameters = []) 291 | { 292 | $this->fluent($name); 293 | 294 | return $this; 295 | } 296 | 297 | /** 298 | * Specify a sparse and unique index for the collection. 299 | * 300 | * @param string|array $columns 301 | * @param array $options 302 | * 303 | * @return Blueprint 304 | * 305 | * phpcs:disable PSR1.Methods.CamelCapsMethodName.NotCamelCaps 306 | */ 307 | public function sparse_and_unique($columns = null, $options = []) 308 | { 309 | $columns = $this->fluent($columns); 310 | 311 | $options['sparse'] = true; 312 | $options['unique'] = true; 313 | 314 | $this->index($columns, null, null, $options); 315 | 316 | return $this; 317 | } 318 | 319 | /** 320 | * Create an Atlas Search Index. 321 | * 322 | * @see https://www.mongodb.com/docs/manual/reference/command/createSearchIndexes/#std-label-search-index-definition-create 323 | * 324 | * @phpstan-param array{ 325 | * analyzer?: string, 326 | * analyzers?: list, 327 | * searchAnalyzer?: string, 328 | * mappings: array{dynamic: true} | array{dynamic?: bool, fields: array}, 329 | * storedSource?: bool|array, 330 | * synonyms?: list, 331 | * ... 332 | * } $definition 333 | */ 334 | public function searchIndex(array $definition, string $name = 'default'): static 335 | { 336 | $this->collection->createSearchIndex($definition, ['name' => $name, 'type' => 'search']); 337 | 338 | return $this; 339 | } 340 | 341 | /** 342 | * Create an Atlas Vector Search Index. 343 | * 344 | * @see https://www.mongodb.com/docs/manual/reference/command/createSearchIndexes/#std-label-vector-search-index-definition-create 345 | * 346 | * @phpstan-param array{fields: array} $definition 347 | */ 348 | public function vectorSearchIndex(array $definition, string $name = 'default'): static 349 | { 350 | $this->collection->createSearchIndex($definition, ['name' => $name, 'type' => 'vectorSearch']); 351 | 352 | return $this; 353 | } 354 | 355 | /** 356 | * Drop an Atlas Search or Vector Search index 357 | */ 358 | public function dropSearchIndex(string $name): static 359 | { 360 | $this->collection->dropSearchIndex($name); 361 | 362 | return $this; 363 | } 364 | 365 | /** 366 | * Allow fluent columns. 367 | * 368 | * @param string|array $columns 369 | * 370 | * @return string|array 371 | */ 372 | protected function fluent($columns = null) 373 | { 374 | if ($columns === null) { 375 | return $this->columns; 376 | } 377 | 378 | if (is_string($columns)) { 379 | return $this->columns = [$columns]; 380 | } 381 | 382 | return $this->columns = $columns; 383 | } 384 | 385 | /** 386 | * Allows the use of unsupported schema methods. 387 | * 388 | * @param string $method 389 | * @param array $parameters 390 | * 391 | * @return Blueprint 392 | */ 393 | public function __call($method, $parameters) 394 | { 395 | // Dummy. 396 | return $this; 397 | } 398 | } 399 | -------------------------------------------------------------------------------- /src/Relations/EmbedsOneOrMany.php: -------------------------------------------------------------------------------- 1 | 30 | */ 31 | abstract class EmbedsOneOrMany extends Relation 32 | { 33 | /** 34 | * The local key of the parent model. 35 | * 36 | * @var string 37 | */ 38 | protected $localKey; 39 | 40 | /** 41 | * The foreign key of the parent model. 42 | * 43 | * @var string 44 | */ 45 | protected $foreignKey; 46 | 47 | /** 48 | * The "name" of the relationship. 49 | * 50 | * @var string 51 | */ 52 | protected $relation; 53 | 54 | /** 55 | * Create a new embeds many relationship instance. 56 | */ 57 | public function __construct(Builder $query, Model $parent, Model $related, string $localKey, string $foreignKey, string $relation) 58 | { 59 | if (! DocumentModel::isDocumentModel($parent)) { 60 | throw new LogicException('Parent model must be a document model.'); 61 | } 62 | 63 | if (! DocumentModel::isDocumentModel($related)) { 64 | throw new LogicException('Related model must be a document model.'); 65 | } 66 | 67 | parent::__construct($query, $parent); 68 | 69 | $this->related = $related; 70 | $this->localKey = $localKey; 71 | $this->foreignKey = $foreignKey; 72 | $this->relation = $relation; 73 | 74 | // If this is a nested relation, we need to get the parent query instead. 75 | $parentRelation = $this->getParentRelation(); 76 | if ($parentRelation) { 77 | $this->query = $parentRelation->getQuery(); 78 | } 79 | } 80 | 81 | /** @inheritdoc */ 82 | #[Override] 83 | public function addConstraints() 84 | { 85 | if (static::$constraints) { 86 | $this->query->where($this->getQualifiedParentKeyName(), '=', $this->getParentKey()); 87 | } 88 | } 89 | 90 | /** @inheritdoc */ 91 | #[Override] 92 | public function addEagerConstraints(array $models) 93 | { 94 | // There are no eager loading constraints. 95 | } 96 | 97 | /** @inheritdoc */ 98 | #[Override] 99 | public function match(array $models, Collection $results, $relation) 100 | { 101 | foreach ($models as $model) { 102 | $results = $model->$relation()->getResults(); 103 | 104 | $model->setParentRelation($this); 105 | 106 | $model->setRelation($relation, $results); 107 | } 108 | 109 | return $models; 110 | } 111 | 112 | #[Override] 113 | public function get($columns = ['*']) 114 | { 115 | return $this->getResults(); 116 | } 117 | 118 | /** 119 | * Get the number of embedded models. 120 | * 121 | * @param Expression|string $columns 122 | * 123 | * @throws LogicException|Throwable 124 | * 125 | * @note The $column parameter is not used to count embedded models. 126 | */ 127 | public function count($columns = '*'): int 128 | { 129 | throw_if($columns !== '*', new LogicException('The columns parameter should not be used.')); 130 | 131 | return count($this->getEmbedded()); 132 | } 133 | 134 | /** 135 | * Attach a model instance to the parent model. 136 | * 137 | * @return Model|bool 138 | */ 139 | public function save(Model $model) 140 | { 141 | $model->setParentRelation($this); 142 | 143 | return $model->save() ? $model : false; 144 | } 145 | 146 | /** 147 | * Attach a collection of models to the parent instance. 148 | * 149 | * @param Collection|array $models 150 | * 151 | * @return Collection|array 152 | */ 153 | public function saveMany($models) 154 | { 155 | foreach ($models as $model) { 156 | $this->save($model); 157 | } 158 | 159 | return $models; 160 | } 161 | 162 | /** 163 | * Create a new instance of the related model. 164 | * 165 | * @return Model 166 | */ 167 | public function create(array $attributes = []) 168 | { 169 | // Here we will set the raw attributes to avoid hitting the "fill" method so 170 | // that we do not have to worry about a mass accessor rules blocking sets 171 | // on the models. Otherwise, some of these attributes will not get set. 172 | $instance = $this->related->newInstance($attributes); 173 | 174 | $instance->setParentRelation($this); 175 | 176 | $instance->save(); 177 | 178 | return $instance; 179 | } 180 | 181 | /** 182 | * Create an array of new instances of the related model. 183 | * 184 | * @return array 185 | */ 186 | public function createMany(array $records) 187 | { 188 | $instances = []; 189 | 190 | foreach ($records as $record) { 191 | $instances[] = $this->create($record); 192 | } 193 | 194 | return $instances; 195 | } 196 | 197 | /** 198 | * Transform single ID, single Model or array of Models into an array of IDs. 199 | * 200 | * @param mixed $ids 201 | * 202 | * @return array 203 | */ 204 | protected function getIdsArrayFrom($ids) 205 | { 206 | if ($ids instanceof \Illuminate\Support\Collection) { 207 | $ids = $ids->all(); 208 | } 209 | 210 | if (! is_array($ids)) { 211 | $ids = [$ids]; 212 | } 213 | 214 | foreach ($ids as &$id) { 215 | if ($id instanceof Model) { 216 | $id = $id->getKey(); 217 | } 218 | } 219 | 220 | return $ids; 221 | } 222 | 223 | /** @inheritdoc */ 224 | protected function getEmbedded() 225 | { 226 | // Get raw attributes to skip relations and accessors. 227 | $attributes = $this->parent->getAttributes(); 228 | 229 | // Get embedded models form parent attributes. 230 | return isset($attributes[$this->localKey]) ? (array) $attributes[$this->localKey] : null; 231 | } 232 | 233 | /** @inheritdoc */ 234 | protected function setEmbedded($records) 235 | { 236 | // Assign models to parent attributes array. 237 | $attributes = $this->parent->getAttributes(); 238 | $attributes[$this->localKey] = $records; 239 | 240 | // Set raw attributes to skip mutators. 241 | $this->parent->setRawAttributes($attributes); 242 | 243 | // Set the relation on the parent. 244 | return $this->parent->setRelation($this->relation, $records === null ? null : $this->getResults()); 245 | } 246 | 247 | /** 248 | * Get the foreign key value for the relation. 249 | * 250 | * @param mixed $id 251 | * 252 | * @return mixed 253 | */ 254 | protected function getForeignKeyValue($id) 255 | { 256 | if ($id instanceof Model) { 257 | $id = $id->getKey(); 258 | } 259 | 260 | // Convert the id to MongoId if necessary. 261 | return $this->toBase()->convertKey($id); 262 | } 263 | 264 | /** 265 | * Convert an array of records to a Collection. 266 | * 267 | * @return Collection 268 | */ 269 | protected function toCollection(array $records = []) 270 | { 271 | $models = []; 272 | 273 | foreach ($records as $attributes) { 274 | $models[] = $this->toModel($attributes); 275 | } 276 | 277 | if (count($models) > 0) { 278 | $models = $this->eagerLoadRelations($models); 279 | } 280 | 281 | return $this->related->newCollection($models); 282 | } 283 | 284 | /** 285 | * Create a related model instanced. 286 | * 287 | * @param mixed $attributes 288 | * 289 | * @return Model | null 290 | */ 291 | protected function toModel(mixed $attributes = []): Model|null 292 | { 293 | if ($attributes === null) { 294 | return null; 295 | } 296 | 297 | $connection = $this->related->getConnection(); 298 | 299 | $model = $this->related->newFromBuilder( 300 | (array) $attributes, 301 | $connection?->getName(), 302 | ); 303 | 304 | $model->setParentRelation($this); 305 | 306 | $model->setRelation($this->foreignKey, $this->parent); 307 | 308 | // If you remove this, you will get segmentation faults! 309 | $model->setHidden(array_merge($model->getHidden(), [$this->foreignKey])); 310 | 311 | return $model; 312 | } 313 | 314 | /** 315 | * Get the relation instance of the parent. 316 | * 317 | * @return Relation 318 | */ 319 | protected function getParentRelation() 320 | { 321 | return $this->parent->getParentRelation(); 322 | } 323 | 324 | /** @inheritdoc */ 325 | #[Override] 326 | public function getQuery() 327 | { 328 | // Because we are sharing this relation instance to models, we need 329 | // to make sure we use separate query instances. 330 | return clone $this->query; 331 | } 332 | 333 | /** @inheritdoc */ 334 | #[Override] 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 | #[Override] 371 | public function getQualifiedParentKeyName() 372 | { 373 | $parentRelation = $this->getParentRelation(); 374 | if ($parentRelation) { 375 | return $parentRelation->getPathHierarchy() . '.' . $this->parent->getKeyName(); 376 | } 377 | 378 | return $this->parent->getKeyName(); 379 | } 380 | 381 | /** 382 | * Get the primary key value of the parent. 383 | * 384 | * @return string 385 | */ 386 | protected function getParentKey() 387 | { 388 | return $this->parent->getKey(); 389 | } 390 | 391 | /** 392 | * Return update values. 393 | * 394 | * @param array $array 395 | * @param string $prepend 396 | * 397 | * @return array 398 | */ 399 | public static function getUpdateValues($array, $prepend = '') 400 | { 401 | $results = []; 402 | 403 | foreach ($array as $key => $value) { 404 | if (str_starts_with($key, '$')) { 405 | assert(is_array($value), 'Update operator value must be an array.'); 406 | $results[$key] = static::getUpdateValues($value, $prepend); 407 | } else { 408 | $results[$prepend . $key] = $value; 409 | } 410 | } 411 | 412 | return $results; 413 | } 414 | 415 | /** 416 | * Get the foreign key for the relationship. 417 | * 418 | * @return string 419 | */ 420 | public function getQualifiedForeignKeyName() 421 | { 422 | return $this->foreignKey; 423 | } 424 | 425 | /** 426 | * Get the name of the "where in" method for eager loading. 427 | * 428 | * @param EloquentModel $model 429 | * 430 | * @inheritdoc 431 | */ 432 | #[Override] 433 | protected function whereInMethod(EloquentModel $model, $key) 434 | { 435 | return 'whereIn'; 436 | } 437 | } 438 | -------------------------------------------------------------------------------- /src/Eloquent/Builder.php: -------------------------------------------------------------------------------- 1 | toBase()->aggregate($function, $columns); 80 | 81 | return $result ?: $this; 82 | } 83 | 84 | /** 85 | * Performs a full-text search of the field or fields in an Atlas collection. 86 | * 87 | * @see https://www.mongodb.com/docs/atlas/atlas-search/aggregation-stages/search/ 88 | * 89 | * @return Collection 90 | */ 91 | public function search( 92 | SearchOperatorInterface|array $operator, 93 | ?string $index = null, 94 | ?array $highlight = null, 95 | ?bool $concurrent = null, 96 | ?string $count = null, 97 | ?string $searchAfter = null, 98 | ?string $searchBefore = null, 99 | ?bool $scoreDetails = null, 100 | ?array $sort = null, 101 | ?bool $returnStoredSource = null, 102 | ?array $tracking = null, 103 | ): Collection { 104 | $results = $this->toBase()->search($operator, $index, $highlight, $concurrent, $count, $searchAfter, $searchBefore, $scoreDetails, $sort, $returnStoredSource, $tracking); 105 | 106 | return $this->model->hydrate($results->all()); 107 | } 108 | 109 | /** 110 | * Performs a semantic search on data in your Atlas Vector Search index. 111 | * NOTE: $vectorSearch is only available for MongoDB Atlas clusters, and is not available for self-managed deployments. 112 | * 113 | * @see https://www.mongodb.com/docs/atlas/atlas-vector-search/vector-search-stage/ 114 | * 115 | * @return Collection 116 | */ 117 | public function vectorSearch( 118 | string $index, 119 | string $path, 120 | array $queryVector, 121 | int $limit, 122 | bool $exact = false, 123 | QueryInterface|array $filter = [], 124 | int|null $numCandidates = null, 125 | ): Collection { 126 | $results = $this->toBase()->vectorSearch($index, $path, $queryVector, $limit, $exact, $filter, $numCandidates); 127 | 128 | return $this->model->hydrate($results->all()); 129 | } 130 | 131 | /** 132 | * @param array $options 133 | * 134 | * @inheritdoc 135 | */ 136 | #[Override] 137 | public function update(array $values, array $options = []) 138 | { 139 | // Intercept operations on embedded models and delegate logic 140 | // to the parent relation instance. 141 | $relation = $this->model->getParentRelation(); 142 | if ($relation) { 143 | $relation->performUpdate($this->model, $values); 144 | 145 | return 1; 146 | } 147 | 148 | return $this->toBase()->update($this->addUpdatedAtColumn($values), $options); 149 | } 150 | 151 | /** @inheritdoc */ 152 | public function insert(array $values) 153 | { 154 | // Intercept operations on embedded models and delegate logic 155 | // to the parent relation instance. 156 | $relation = $this->model->getParentRelation(); 157 | if ($relation) { 158 | $relation->performInsert($this->model, $values); 159 | 160 | return true; 161 | } 162 | 163 | return parent::insert($values); 164 | } 165 | 166 | /** @inheritdoc */ 167 | public function insertGetId(array $values, $sequence = null) 168 | { 169 | // Intercept operations on embedded models and delegate logic 170 | // to the parent relation instance. 171 | $relation = $this->model->getParentRelation(); 172 | if ($relation) { 173 | $relation->performInsert($this->model, $values); 174 | 175 | return $this->model->getKey(); 176 | } 177 | 178 | return parent::insertGetId($values, $sequence); 179 | } 180 | 181 | /** @inheritdoc */ 182 | public function delete() 183 | { 184 | // Intercept operations on embedded models and delegate logic 185 | // to the parent relation instance. 186 | $relation = $this->model->getParentRelation(); 187 | if ($relation) { 188 | $relation->performDelete($this->model); 189 | 190 | return $this->model->getKey(); 191 | } 192 | 193 | return parent::delete(); 194 | } 195 | 196 | /** @inheritdoc */ 197 | public function increment($column, $amount = 1, array $extra = []) 198 | { 199 | // Intercept operations on embedded models and delegate logic 200 | // to the parent relation instance. 201 | $relation = $this->model->getParentRelation(); 202 | if ($relation) { 203 | $value = $this->model->{$column}; 204 | 205 | // When doing increment and decrements, Eloquent will automatically 206 | // sync the original attributes. We need to change the attribute 207 | // temporary in order to trigger an update query. 208 | $this->model->{$column} = null; 209 | 210 | $this->model->syncOriginalAttribute($column); 211 | 212 | return $this->model->update([$column => $value]); 213 | } 214 | 215 | return parent::increment($column, $amount, $extra); 216 | } 217 | 218 | /** @inheritdoc */ 219 | public function decrement($column, $amount = 1, array $extra = []) 220 | { 221 | // Intercept operations on embedded models and delegate logic 222 | // to the parent relation instance. 223 | $relation = $this->model->getParentRelation(); 224 | if ($relation) { 225 | $value = $this->model->{$column}; 226 | 227 | // When doing increment and decrements, Eloquent will automatically 228 | // sync the original attributes. We need to change the attribute 229 | // temporary in order to trigger an update query. 230 | $this->model->{$column} = null; 231 | 232 | $this->model->syncOriginalAttribute($column); 233 | 234 | return $this->model->update([$column => $value]); 235 | } 236 | 237 | return parent::decrement($column, $amount, $extra); 238 | } 239 | 240 | /** 241 | * @param (Closure():T)|Expression|null $value 242 | * 243 | * @return ($value is Closure ? T : ($value is null ? Collection : Expression)) 244 | * 245 | * @template T 246 | */ 247 | public function raw($value = null) 248 | { 249 | // Get raw results from the query builder. 250 | $results = $this->query->raw($value); 251 | 252 | // Convert MongoCursor results to a collection of models. 253 | if ($results instanceof CursorInterface) { 254 | $results->setTypeMap(['root' => 'array', 'document' => 'array', 'array' => 'array']); 255 | $results = array_map(fn ($document) => $this->query->aliasIdForResult($document), iterator_to_array($results)); 256 | 257 | return $this->model->hydrate($results); 258 | } 259 | 260 | // Convert MongoDB Document to a single object. 261 | if (is_object($results) && (property_exists($results, '_id') || property_exists($results, 'id'))) { 262 | $results = (array) match (true) { 263 | $results instanceof BSONDocument => $results->getArrayCopy(), 264 | $results instanceof Document => $results->toPHP(['root' => 'array', 'document' => 'array', 'array' => 'array']), 265 | default => $results, 266 | }; 267 | } 268 | 269 | // The result is a single object. 270 | if (is_array($results) && (array_key_exists('_id', $results) || array_key_exists('id', $results))) { 271 | $results = $this->query->aliasIdForResult($results); 272 | 273 | return $this->model->newFromBuilder($results); 274 | } 275 | 276 | return $results; 277 | } 278 | 279 | #[Override] 280 | public function firstOrCreate(array $attributes = [], array $values = []) 281 | { 282 | $instance = (clone $this)->where($attributes)->first(); 283 | if ($instance !== null) { 284 | return $instance; 285 | } 286 | 287 | // createOrFirst is not supported in transaction. 288 | if ($this->getConnection()->getSession()?->isInTransaction()) { 289 | return $this->create(array_replace($attributes, $values)); 290 | } 291 | 292 | return $this->createOrFirst($attributes, $values); 293 | } 294 | 295 | #[Override] 296 | public function createOrFirst(array $attributes = [], array $values = []) 297 | { 298 | // The duplicate key error would abort the transaction. Using the regular firstOrCreate in that case. 299 | if ($this->getConnection()->getSession()?->isInTransaction()) { 300 | return $this->firstOrCreate($attributes, $values); 301 | } 302 | 303 | try { 304 | return $this->create(array_replace($attributes, $values)); 305 | } catch (BulkWriteException $e) { 306 | if ($e->getCode() === self::DUPLICATE_KEY_ERROR) { 307 | return $this->where($attributes)->first() ?? throw $e; 308 | } 309 | 310 | throw $e; 311 | } 312 | } 313 | 314 | /** 315 | * Add the "updated at" column to an array of values. 316 | * TODO Remove if https://github.com/laravel/framework/commit/6484744326531829341e1ff886cc9b628b20d73e 317 | * will be reverted 318 | * Issue in laravel/frawework https://github.com/laravel/framework/issues/27791. 319 | */ 320 | #[Override] 321 | protected function addUpdatedAtColumn(array $values) 322 | { 323 | if (! $this->model->usesTimestamps() || $this->model->getUpdatedAtColumn() === null) { 324 | return $values; 325 | } 326 | 327 | $column = $this->model->getUpdatedAtColumn(); 328 | if (isset($values['$set'][$column])) { 329 | return $values; 330 | } 331 | 332 | $values = array_replace( 333 | [$column => $this->model->freshTimestampString()], 334 | $values, 335 | ); 336 | 337 | return $values; 338 | } 339 | 340 | public function getConnection(): Connection 341 | { 342 | return $this->query->getConnection(); 343 | } 344 | 345 | /** @inheritdoc */ 346 | #[Override] 347 | protected function ensureOrderForCursorPagination($shouldReverse = false) 348 | { 349 | if (empty($this->query->orders)) { 350 | $this->enforceOrderBy(); 351 | } 352 | 353 | if ($shouldReverse) { 354 | $this->query->orders = collect($this->query->orders) 355 | ->map(static fn (int $direction) => $direction === 1 ? -1 : 1) 356 | ->toArray(); 357 | } 358 | 359 | return collect($this->query->orders) 360 | ->map(static fn ($direction, $column) => [ 361 | 'column' => $column, 362 | 'direction' => $direction === 1 ? 'asc' : 'desc', 363 | ])->values(); 364 | } 365 | } 366 | -------------------------------------------------------------------------------- /src/Connection.php: -------------------------------------------------------------------------------- 1 | config = $config; 66 | 67 | // Build the connection string 68 | $dsn = $this->getDsn($config); 69 | 70 | // You can pass options directly to the MongoDB constructor 71 | $options = $config['options'] ?? []; 72 | 73 | // Create the connection 74 | $this->connection = $this->createConnection($dsn, $config, $options); 75 | $this->database = $this->getDefaultDatabaseName($dsn, $config); 76 | 77 | // Select database 78 | $this->db = $this->connection->getDatabase($this->database); 79 | 80 | $this->tablePrefix = $config['prefix'] ?? ''; 81 | 82 | $this->useDefaultPostProcessor(); 83 | 84 | $this->useDefaultSchemaGrammar(); 85 | 86 | $this->useDefaultQueryGrammar(); 87 | 88 | $this->renameEmbeddedIdField = $config['rename_embedded_id_field'] ?? true; 89 | } 90 | 91 | /** 92 | * Begin a fluent query against a database collection. 93 | * 94 | * @param string $table The name of the MongoDB collection 95 | * @param string|null $as Ignored. Not supported by MongoDB 96 | * 97 | * @return Query\Builder 98 | */ 99 | #[Override] 100 | public function table($table, $as = null) 101 | { 102 | $query = new Query\Builder($this, $this->getQueryGrammar(), $this->getPostProcessor()); 103 | 104 | return $query->from($table); 105 | } 106 | 107 | /** 108 | * Get a MongoDB collection. 109 | * 110 | * @param string $name 111 | * 112 | * @return Collection 113 | */ 114 | public function getCollection($name): Collection 115 | { 116 | return $this->db->selectCollection($this->tablePrefix . $name); 117 | } 118 | 119 | /** @inheritdoc */ 120 | #[Override] 121 | public function getSchemaBuilder() 122 | { 123 | return new Schema\Builder($this); 124 | } 125 | 126 | /** 127 | * Get the MongoDB database object. 128 | * 129 | * @deprecated since mongodb/laravel-mongodb:5.2, use getDatabase() instead 130 | * 131 | * @return Database 132 | */ 133 | public function getMongoDB() 134 | { 135 | trigger_error(sprintf('Since mongodb/laravel-mongodb:5.2, Method "%s()" is deprecated, use "getDatabase()" instead.', __FUNCTION__), E_USER_DEPRECATED); 136 | 137 | return $this->db; 138 | } 139 | 140 | /** 141 | * Get the MongoDB database object. 142 | * 143 | * @param string|null $name Name of the database, if not provided the default database will be returned. 144 | * 145 | * @return Database 146 | */ 147 | public function getDatabase(?string $name = null): Database 148 | { 149 | if ($name && $name !== $this->database) { 150 | return $this->connection->getDatabase($name); 151 | } 152 | 153 | return $this->db; 154 | } 155 | 156 | /** 157 | * Return MongoDB object. 158 | * 159 | * @deprecated since mongodb/laravel-mongodb:5.2, use getClient() instead 160 | * 161 | * @return Client 162 | */ 163 | public function getMongoClient() 164 | { 165 | trigger_error(sprintf('Since mongodb/laravel-mongodb:5.2, method "%s()" is deprecated, use "getClient()" instead.', __FUNCTION__), E_USER_DEPRECATED); 166 | 167 | return $this->getClient(); 168 | } 169 | 170 | /** 171 | * Get the MongoDB client. 172 | */ 173 | public function getClient(): ?Client 174 | { 175 | return $this->connection; 176 | } 177 | 178 | /** @inheritdoc */ 179 | #[Override] 180 | public function enableQueryLog() 181 | { 182 | parent::enableQueryLog(); 183 | 184 | if (! $this->commandSubscriber) { 185 | $this->commandSubscriber = new CommandSubscriber($this); 186 | $this->connection->addSubscriber($this->commandSubscriber); 187 | } 188 | } 189 | 190 | #[Override] 191 | public function disableQueryLog() 192 | { 193 | parent::disableQueryLog(); 194 | 195 | if ($this->commandSubscriber) { 196 | $this->connection->removeSubscriber($this->commandSubscriber); 197 | $this->commandSubscriber = null; 198 | } 199 | } 200 | 201 | #[Override] 202 | protected function withFreshQueryLog($callback) 203 | { 204 | try { 205 | return parent::withFreshQueryLog($callback); 206 | } finally { 207 | // The parent method enable query log using enableQueryLog() 208 | // but disables it by setting $loggingQueries to false. We need to 209 | // remove the subscriber for performance. 210 | if (! $this->loggingQueries) { 211 | $this->disableQueryLog(); 212 | } 213 | } 214 | } 215 | 216 | /** 217 | * Get the name of the default database based on db config or try to detect it from dsn. 218 | * 219 | * @throws InvalidArgumentException 220 | */ 221 | protected function getDefaultDatabaseName(string $dsn, array $config): string 222 | { 223 | if (empty($config['database'])) { 224 | if (! preg_match('/^mongodb(?:[+]srv)?:\\/\\/.+?\\/([^?&]+)/s', $dsn, $matches)) { 225 | throw new InvalidArgumentException('Database is not properly configured.'); 226 | } 227 | 228 | $config['database'] = $matches[1]; 229 | } 230 | 231 | return $config['database']; 232 | } 233 | 234 | /** 235 | * Create a new MongoDB connection. 236 | */ 237 | protected function createConnection(string $dsn, array $config, array $options): Client 238 | { 239 | // By default driver options is an empty array. 240 | $driverOptions = []; 241 | 242 | if (isset($config['driver_options']) && is_array($config['driver_options'])) { 243 | $driverOptions = $config['driver_options']; 244 | } 245 | 246 | $driverOptions['driver'] = [ 247 | 'name' => 'laravel-mongodb', 248 | 'version' => self::getVersion(), 249 | ]; 250 | 251 | // Check if the credentials are not already set in the options 252 | if (! isset($options['username']) && ! empty($config['username'])) { 253 | $options['username'] = $config['username']; 254 | } 255 | 256 | if (! isset($options['password']) && ! empty($config['password'])) { 257 | $options['password'] = $config['password']; 258 | } 259 | 260 | if (isset($config['name'])) { 261 | $driverOptions += ['connectionName' => $config['name']]; 262 | } 263 | 264 | return new Client($dsn, $options, $driverOptions); 265 | } 266 | 267 | /** 268 | * Check the connection to the MongoDB server 269 | * 270 | * @throws ConnectionException if connection to the server fails (for reasons other than authentication). 271 | * @throws AuthenticationException if authentication is needed and fails. 272 | * @throws RuntimeException if a server matching the read preference could not be found. 273 | */ 274 | public function ping(): void 275 | { 276 | $this->getClient()->getManager()->selectServer(new ReadPreference(ReadPreference::PRIMARY_PREFERRED)); 277 | } 278 | 279 | /** @inheritdoc */ 280 | public function disconnect() 281 | { 282 | $this->disableQueryLog(); 283 | $this->connection = null; 284 | } 285 | 286 | /** 287 | * Determine if the given configuration array has a dsn string. 288 | * 289 | * @deprecated 290 | */ 291 | protected function hasDsnString(array $config): bool 292 | { 293 | return ! empty($config['dsn']); 294 | } 295 | 296 | /** 297 | * Get the DSN string form configuration. 298 | */ 299 | protected function getDsnString(array $config): string 300 | { 301 | return $config['dsn']; 302 | } 303 | 304 | /** 305 | * Get the DSN string for a host / port configuration. 306 | */ 307 | protected function getHostDsn(array $config): string 308 | { 309 | // Treat host option as array of hosts 310 | $hosts = is_array($config['host']) ? $config['host'] : [$config['host']]; 311 | 312 | foreach ($hosts as &$host) { 313 | // ipv6 314 | if (filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) { 315 | $host = '[' . $host . ']'; 316 | if (! empty($config['port'])) { 317 | $host .= ':' . $config['port']; 318 | } 319 | } else { 320 | // Check if we need to add a port to the host 321 | if (! str_contains($host, ':') && ! empty($config['port'])) { 322 | $host .= ':' . $config['port']; 323 | } 324 | } 325 | } 326 | 327 | // Check if we want to authenticate against a specific database. 328 | $authDatabase = isset($config['options']) && ! empty($config['options']['database']) ? $config['options']['database'] : null; 329 | 330 | return 'mongodb://' . implode(',', $hosts) . ($authDatabase ? '/' . $authDatabase : ''); 331 | } 332 | 333 | /** 334 | * Create a DSN string from a configuration. 335 | */ 336 | protected function getDsn(array $config): string 337 | { 338 | if (! empty($config['dsn'])) { 339 | return $this->getDsnString($config); 340 | } 341 | 342 | if (! empty($config['host'])) { 343 | return $this->getHostDsn($config); 344 | } 345 | 346 | throw new InvalidArgumentException('MongoDB connection configuration requires "dsn" or "host" key.'); 347 | } 348 | 349 | /** @inheritdoc */ 350 | #[Override] 351 | public function getDriverName() 352 | { 353 | return 'mongodb'; 354 | } 355 | 356 | /** @inheritdoc */ 357 | public function getDriverTitle() 358 | { 359 | return 'MongoDB'; 360 | } 361 | 362 | /** @inheritdoc */ 363 | #[Override] 364 | protected function getDefaultPostProcessor() 365 | { 366 | return new Query\Processor(); 367 | } 368 | 369 | /** @inheritdoc */ 370 | #[Override] 371 | protected function getDefaultQueryGrammar() 372 | { 373 | // Argument added in Laravel 12 374 | return new Query\Grammar($this); 375 | } 376 | 377 | /** @inheritdoc */ 378 | #[Override] 379 | protected function getDefaultSchemaGrammar() 380 | { 381 | // Argument added in Laravel 12 382 | return new Schema\Grammar($this); 383 | } 384 | 385 | /** 386 | * Set database. 387 | */ 388 | public function setDatabase(Database $db) 389 | { 390 | $this->db = $db; 391 | } 392 | 393 | /** @inheritdoc */ 394 | public function threadCount() 395 | { 396 | $status = $this->db->command(['serverStatus' => 1])->toArray(); 397 | 398 | return $status[0]['connections']['current']; 399 | } 400 | 401 | /** 402 | * Dynamically pass methods to the connection. 403 | * 404 | * @param string $method 405 | * @param array $parameters 406 | * 407 | * @return mixed 408 | */ 409 | public function __call($method, $parameters) 410 | { 411 | return $this->db->$method(...$parameters); 412 | } 413 | 414 | /** Set whether to rename "id" field into "_id" for embedded documents. */ 415 | public function setRenameEmbeddedIdField(bool $rename): void 416 | { 417 | $this->renameEmbeddedIdField = $rename; 418 | } 419 | 420 | /** Get whether to rename "id" field into "_id" for embedded documents. */ 421 | public function getRenameEmbeddedIdField(): bool 422 | { 423 | return $this->renameEmbeddedIdField; 424 | } 425 | 426 | /** 427 | * Return the server version of one of the MongoDB servers: primary for 428 | * replica sets and standalone, and the selected server for sharded clusters. 429 | * 430 | * @internal 431 | */ 432 | public function getServerVersion(): string 433 | { 434 | return $this->db->command(['buildInfo' => 1])->toArray()[0]['version']; 435 | } 436 | 437 | private static function getVersion(): string 438 | { 439 | return self::$version ?? self::lookupVersion(); 440 | } 441 | 442 | private static function lookupVersion(): string 443 | { 444 | try { 445 | try { 446 | return self::$version = InstalledVersions::getPrettyVersion('mongodb/laravel-mongodb') ?? 'unknown'; 447 | } catch (OutOfBoundsException) { 448 | return self::$version = InstalledVersions::getPrettyVersion('jenssegers/mongodb') ?? 'unknown'; 449 | } 450 | } catch (Throwable) { 451 | return self::$version = 'error'; 452 | } 453 | } 454 | } 455 | -------------------------------------------------------------------------------- /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 | #[Override] 103 | public function hasTable($table) 104 | { 105 | return $this->hasCollection($table); 106 | } 107 | 108 | /** @inheritdoc */ 109 | #[Override] 110 | public function table($table, Closure $callback) 111 | { 112 | $blueprint = $this->createBlueprint($table); 113 | 114 | if ($callback) { 115 | $callback($blueprint); 116 | } 117 | } 118 | 119 | /** @inheritdoc */ 120 | #[Override] 121 | public function create($table, ?Closure $callback = null, array $options = []) 122 | { 123 | $blueprint = $this->createBlueprint($table); 124 | 125 | $blueprint->create($options); 126 | 127 | if ($callback) { 128 | $callback($blueprint); 129 | } 130 | } 131 | 132 | /** @inheritdoc */ 133 | #[Override] 134 | public function dropIfExists($table) 135 | { 136 | if ($this->hasCollection($table)) { 137 | $this->drop($table); 138 | } 139 | } 140 | 141 | /** @inheritdoc */ 142 | #[Override] 143 | public function drop($table) 144 | { 145 | $blueprint = $this->createBlueprint($table); 146 | 147 | $blueprint->drop(); 148 | } 149 | 150 | /** 151 | * @inheritdoc 152 | * 153 | * Drops the entire database instead of deleting each collection individually. 154 | * 155 | * In MongoDB, dropping the whole database is much faster than dropping collections 156 | * one by one. The database will be automatically recreated when a new connection 157 | * writes to it. 158 | */ 159 | #[Override] 160 | public function dropAllTables() 161 | { 162 | $this->connection->getDatabase()->drop(); 163 | } 164 | 165 | /** 166 | * @param string|null $schema Database name 167 | * 168 | * @inheritdoc 169 | */ 170 | #[Override] 171 | public function getTables($schema = null) 172 | { 173 | return $this->getCollectionRows('collection', $schema); 174 | } 175 | 176 | /** 177 | * @param string|null $schema Database name 178 | * 179 | * @inheritdoc 180 | */ 181 | #[Override] 182 | public function getViews($schema = null) 183 | { 184 | return $this->getCollectionRows('view', $schema); 185 | } 186 | 187 | /** 188 | * @param string|null $schema 189 | * @param bool $schemaQualified If a schema is provided, prefix the collection names with the schema name 190 | * 191 | * @return array 192 | */ 193 | #[Override] 194 | public function getTableListing($schema = null, $schemaQualified = false) 195 | { 196 | $collections = []; 197 | 198 | if ($schema === null || is_string($schema)) { 199 | $collections[$schema ?? 0] = iterator_to_array($this->connection->getDatabase($schema)->listCollectionNames()); 200 | } elseif (is_array($schema)) { 201 | foreach ($schema as $db) { 202 | $collections[$db] = iterator_to_array($this->connection->getDatabase($db)->listCollectionNames()); 203 | } 204 | } 205 | 206 | if ($schema && $schemaQualified) { 207 | $collections = array_map(fn ($db, $collections) => array_map(static fn ($collection) => $db . '.' . $collection, $collections), array_keys($collections), $collections); 208 | } 209 | 210 | $collections = array_merge(...array_values($collections)); 211 | 212 | sort($collections); 213 | 214 | return $collections; 215 | } 216 | 217 | #[Override] 218 | public function getColumns($table) 219 | { 220 | $db = null; 221 | if (str_contains($table, '.')) { 222 | [$db, $table] = explode('.', $table, 2); 223 | } 224 | 225 | $stats = $this->connection->getDatabase($db)->getCollection($table)->aggregate([ 226 | // Sample 1,000 documents to get a representative sample of the collection 227 | ['$sample' => ['size' => 1_000]], 228 | // Convert each document to an array of fields 229 | ['$project' => ['fields' => ['$objectToArray' => '$$ROOT']]], 230 | // Unwind to get one document per field 231 | ['$unwind' => '$fields'], 232 | // Group by field name, count the number of occurrences and get the types 233 | [ 234 | '$group' => [ 235 | '_id' => '$fields.k', 236 | 'total' => ['$sum' => 1], 237 | 'types' => ['$addToSet' => ['$type' => '$fields.v']], 238 | ], 239 | ], 240 | // Get the most seen field names 241 | ['$sort' => ['total' => -1]], 242 | // Limit to 1,000 fields 243 | ['$limit' => 1_000], 244 | // Sort by field name 245 | ['$sort' => ['_id' => 1]], 246 | ], [ 247 | 'typeMap' => ['array' => 'array'], 248 | 'allowDiskUse' => true, 249 | ])->toArray(); 250 | 251 | $columns = []; 252 | foreach ($stats as $stat) { 253 | sort($stat->types); 254 | $type = implode(', ', $stat->types); 255 | $name = $stat->_id; 256 | if ($name === '_id') { 257 | $name = 'id'; 258 | } 259 | 260 | $columns[] = [ 261 | 'name' => $name, 262 | 'type_name' => $type, 263 | 'type' => $type, 264 | 'collation' => null, 265 | 'nullable' => $name !== 'id', 266 | 'default' => null, 267 | 'auto_increment' => false, 268 | 'comment' => sprintf('%d occurrences', $stat->total), 269 | 'generation' => $name === 'id' ? ['type' => 'objectId', 'expression' => null] : null, 270 | ]; 271 | } 272 | 273 | return $columns; 274 | } 275 | 276 | #[Override] 277 | public function getIndexes($table) 278 | { 279 | $collection = $this->connection->getDatabase()->selectCollection($table); 280 | assert($collection instanceof Collection); 281 | $indexList = []; 282 | 283 | $indexes = $collection->listIndexes(); 284 | foreach ($indexes as $index) { 285 | assert($index instanceof IndexInfo); 286 | $indexList[] = [ 287 | 'name' => $index->getName(), 288 | 'columns' => array_keys($index->getKey()), 289 | 'primary' => $index->getKey() === ['_id' => 1], 290 | 'type' => match (true) { 291 | $index->isText() => 'text', 292 | $index->is2dSphere() => '2dsphere', 293 | $index->isTtl() => 'ttl', 294 | default => null, 295 | }, 296 | 'unique' => $index->isUnique(), 297 | ]; 298 | } 299 | 300 | try { 301 | $indexes = $collection->listSearchIndexes(['typeMap' => ['root' => 'array', 'array' => 'array', 'document' => 'array']]); 302 | foreach ($indexes as $index) { 303 | // Status 'DOES_NOT_EXIST' means the index has been dropped but is still in the process of being removed 304 | if ($index['status'] === 'DOES_NOT_EXIST') { 305 | continue; 306 | } 307 | 308 | $indexList[] = [ 309 | 'name' => $index['name'], 310 | 'columns' => match ($index['type']) { 311 | 'search' => array_merge( 312 | $index['latestDefinition']['mappings']['dynamic'] ? ['dynamic'] : [], 313 | array_keys($index['latestDefinition']['mappings']['fields'] ?? []), 314 | ), 315 | 'vectorSearch' => array_column($index['latestDefinition']['fields'], 'path'), 316 | }, 317 | 'type' => $index['type'], 318 | 'primary' => false, 319 | 'unique' => false, 320 | ]; 321 | } 322 | } catch (ServerException $exception) { 323 | if (! self::isAtlasSearchNotSupportedException($exception)) { 324 | throw $exception; 325 | } 326 | } 327 | 328 | return $indexList; 329 | } 330 | 331 | #[Override] 332 | public function getForeignKeys($table) 333 | { 334 | return []; 335 | } 336 | 337 | /** 338 | * @return Blueprint 339 | * 340 | * @inheritdoc 341 | */ 342 | #[Override] 343 | protected function createBlueprint($table, ?Closure $callback = null) 344 | { 345 | return new Blueprint($this->connection, $table); 346 | } 347 | 348 | /** 349 | * Get collection. 350 | * 351 | * @param string $name 352 | * 353 | * @return bool|CollectionInfo 354 | */ 355 | public function getCollection($name) 356 | { 357 | $db = $this->connection->getDatabase(); 358 | 359 | $collections = iterator_to_array($db->listCollections([ 360 | 'filter' => ['name' => $name], 361 | ]), false); 362 | 363 | return count($collections) ? current($collections) : false; 364 | } 365 | 366 | /** 367 | * Get all the collections names for the database. 368 | * 369 | * @deprecated 370 | * 371 | * @return array 372 | */ 373 | protected function getAllCollections() 374 | { 375 | trigger_error(sprintf('Since mongodb/laravel-mongodb:5.4, Method "%s()" is deprecated without replacement.', __METHOD__), E_USER_DEPRECATED); 376 | 377 | $collections = []; 378 | foreach ($this->connection->getDatabase()->listCollections() as $collection) { 379 | $collections[] = $collection->getName(); 380 | } 381 | 382 | return $collections; 383 | } 384 | 385 | /** @internal */ 386 | public static function isAtlasSearchNotSupportedException(ServerException $e): bool 387 | { 388 | return in_array($e->getCode(), [ 389 | 59, // MongoDB 4 to 6, 7-community: no such command: 'createSearchIndexes' 390 | 40324, // MongoDB 4 to 6: Unrecognized pipeline stage name: '$listSearchIndexes' 391 | 115, // MongoDB 7-ent: Search index commands are only supported with Atlas. 392 | 6047401, // MongoDB 7: $listSearchIndexes stage is only allowed on MongoDB Atlas 393 | 31082, // MongoDB 8: Using Atlas Search Database Commands and the $listSearchIndexes aggregation stage requires additional configuration. 394 | ], true); 395 | } 396 | 397 | /** @param string|null $schema Database name */ 398 | private function getCollectionRows(string $collectionType, $schema = null) 399 | { 400 | $db = $this->connection->getDatabase($schema); 401 | $collections = []; 402 | 403 | foreach ($db->listCollections() as $collectionInfo) { 404 | $collectionName = $collectionInfo->getName(); 405 | 406 | if ($collectionInfo->getType() !== $collectionType) { 407 | continue; 408 | } 409 | 410 | $options = $collectionInfo->getOptions(); 411 | $collation = $options['collation'] ?? []; 412 | 413 | // Aggregation is not supported on views 414 | $stats = $collectionType !== 'view' ? $db->selectCollection($collectionName)->aggregate([ 415 | ['$collStats' => ['storageStats' => ['scale' => 1]]], 416 | ['$project' => ['storageStats.totalSize' => 1]], 417 | ])->toArray() : null; 418 | 419 | $collections[] = [ 420 | 'name' => $collectionName, 421 | 'schema' => $db->getDatabaseName(), 422 | 'schema_qualified_name' => $db->getDatabaseName() . '.' . $collectionName, 423 | 'size' => $stats[0]?->storageStats?->totalSize ?? null, 424 | 'comment' => null, 425 | 'collation' => $this->collationToString($collation), 426 | 'engine' => null, 427 | ]; 428 | } 429 | 430 | usort($collections, fn ($a, $b) => $a['name'] <=> $b['name']); 431 | 432 | return $collections; 433 | } 434 | 435 | private function collationToString(array $collation): string 436 | { 437 | $map = [ 438 | 'locale' => 'l', 439 | 'strength' => 's', 440 | 'caseLevel' => 'cl', 441 | 'caseFirst' => 'cf', 442 | 'numericOrdering' => 'no', 443 | 'alternate' => 'a', 444 | 'maxVariable' => 'mv', 445 | 'normalization' => 'n', 446 | 'backwards' => 'b', 447 | ]; 448 | 449 | $parts = []; 450 | foreach ($collation as $key => $value) { 451 | if (array_key_exists($key, $map)) { 452 | $shortKey = $map[$key]; 453 | $shortValue = is_bool($value) ? ($value ? '1' : '0') : $value; 454 | $parts[] = $shortKey . '=' . $shortValue; 455 | } 456 | } 457 | 458 | return implode(';', $parts); 459 | } 460 | } 461 | -------------------------------------------------------------------------------- /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 | --------------------------------------------------------------------------------