diff --git a/composer.json b/composer.json index ff4bdef8b6..d3e7d0ada1 100644 --- a/composer.json +++ b/composer.json @@ -18,7 +18,7 @@ "symfony/console": "~2.3|~3.0|^4.0", "doctrine/annotations": "~1.2", "doctrine/collections": "~1.1", - "doctrine/common": "~2.4", + "doctrine/common": "~2.6", "doctrine/cache": "~1.0", "doctrine/instantiator": "^1.0.1", "mongodb/mongodb": "^1.2.0" diff --git a/docs/en/reference/annotations-reference.rst b/docs/en/reference/annotations-reference.rst index 4310b94430..5412fd8fda 100644 --- a/docs/en/reference/annotations-reference.rst +++ b/docs/en/reference/annotations-reference.rst @@ -143,24 +143,24 @@ managed by ODM. Optional attributes: - - db - By default, the document manager will use the MongoDB database defined - in the configuration, but this option may be used to override the database - for a particular document class. + ``db`` - By default, the document manager will use the MongoDB database + defined in the configuration, but this option may be used to override the + database for a particular document class. - - collection - By default, the collection name is derived from the document's - class name, but this option may be used to override that behavior. + ``collection`` - By default, the collection name is derived from the + document's class name, but this option may be used to override that behavior. - - repositoryClass - Specifies a custom repository class to use. + ``repositoryClass`` - Specifies a custom repository class to use. - - indexes - Specifies an array of indexes for this document. + ``indexes`` - Specifies an array of indexes for this document. - - readOnly - Prevents document from being updated: it can only be inserted, + ``readOnly`` - Prevents document from being updated: it can only be inserted, upserted or removed. - - writeConcern - Specifies the write concern for this document that overwrites - the default write concern specified in the configuration. It does not overwrite - a write concern given as :ref:`option ` to the ``flush`` - method when committing your documents. + ``writeConcern`` - Specifies the write concern for this document that + overwrites the default write concern specified in the configuration. It does + not overwrite a write concern given as :ref:`option ` to the + ``flush`` method when committing your documents. .. code-block:: php @@ -191,22 +191,24 @@ document, it embeds a collection of documents. Optional attributes: - - targetDocument - A |FQCN| of the target document. + ``targetDocument`` - A |FQCN| of the target document. - - discriminatorField - The database field name to store the discriminator + ``discriminatorField`` - The database field name to store the discriminator value within the embedded document. - - discriminatorMap - Map of discriminator values to class names. + ``discriminatorMap`` - Map of discriminator values to class names. - - defaultDiscriminatorValue - A default value for discriminatorField if no value - has been set in the embedded document. + ``defaultDiscriminatorValue`` - A default value for discriminatorField if no + value has been set in the embedded document. - - strategy - The strategy used to persist changes to the collection. Possible - values are ``addToSet``, ``pushAll``, ``set``, and ``setArray``. ``pushAll`` - is the default. See :ref:`storage_strategies` for more information. + ``strategy`` - The strategy used to persist changes to the collection. + Possible values are ``addToSet``, ``pushAll``, ``set``, and ``setArray``. + ``pushAll`` is the default. See :ref:`storage_strategies` for more + information. - - collectionClass - A |FQCN| of class that implements ``Collection`` interface - and is used to hold documents. Doctrine's ``ArrayCollection`` is used by default. + ``collectionClass`` - A |FQCN| of class that implements ``Collection`` + interface and is used to hold documents. Doctrine's ``ArrayCollection`` is + used by default. .. code-block:: php @@ -249,15 +251,15 @@ following excerpt from the MongoDB documentation: Optional attributes: - - targetDocument - A |FQCN| of the target document. + ``targetDocument`` - A |FQCN| of the target document. - - discriminatorField - The database field name to store the discriminator + ``discriminatorField`` - The database field name to store the discriminator value within the embedded document. - - discriminatorMap - Map of discriminator values to class names. + ``discriminatorMap`` - Map of discriminator values to class names. - - defaultDiscriminatorValue - A default value for discriminatorField if no value - has been set in the embedded document. + ``defaultDiscriminatorValue`` - A default value for discriminatorField if no + value has been set in the embedded document. .. code-block:: php @@ -284,7 +286,8 @@ document classes. ----------------- Marks the document as embeddable. This annotation is required for any documents -to be stored within an `@EmbedOne`_ or `@EmbedMany`_ relationship. +to be stored within an `@EmbedOne`_, `@EmbedMany`_ or `@File\\Metadata`_ +relationship. .. code-block:: php @@ -328,7 +331,7 @@ multiple document classes, and even other embedded documents! Optional attributes: - - indexes - Specifies an array of indexes for this embedded document, to be + ``indexes`` - Specifies an array of indexes for this embedded document, to be included in the schemas of any embedding documents. @Field @@ -341,16 +344,16 @@ lifecycle. Optional attributes: - - type - Name of the ODM type, which will determine the value's representation - in PHP and BSON (i.e. MongoDB). See :ref:`doctrine_mapping_types` for a list - of types. Defaults to "string". + ``type`` - Name of the ODM type, which will determine the value's + representation in PHP and BSON (i.e. MongoDB). See + :ref:`doctrine_mapping_types` for a list of types. Defaults to "string". - - name - By default, the property name is used for the field name in MongoDB; - however, this option may be used to specify a database field name. + ``name`` - By default, the property name is used for the field name in + MongoDB; however, this option may be used to specify a database field name. - - nullable - By default, ODM will ``$unset`` fields in MongoDB if the PHP value - is null. Specify true for this option to force ODM to store a null value in - the database instead of unsetting the field. + ``nullable`` - By default, ODM will ``$unset`` fields in MongoDB if the PHP + value is null. Specify true for this option to force ODM to store a null + value in the database instead of unsetting the field. Examples: @@ -373,6 +376,88 @@ Examples: */ protected $height; +.. _file: + +@File +----- + +This marks the document as a GridFS file. GridFS allow storing larger amounts of +data than regular documents. + +Optional attributes: + +- + ``db`` - By default, the document manager will use the MongoDB database + defined in the configuration, but this option may be used to override the + database for a particular file. +- + ``bucketName`` - By default, files are stored in a bucket called ``fs``. You + can customize that bucket name with this property. +- + ``repositoryClass`` - Specifies a custom repository class to use. The class + must extend the ``Doctrine\ODM\MongoDB\Repository\GridFSRepository`` + interface. +- + ``indexes`` - Specifies an array of indexes for this document. +- + ``readOnly`` - Prevents the file from being updated: it can only be inserted, + upserted or removed. +- + ``writeConcern`` - Specifies the write concern for this file that overwrites + the default write concern specified in the configuration. + +.. _file_chunksize: + +@File\ChunkSize +--------------- + +This maps the ``chunkSize`` property of a GridFS file to a property. It contains +the size of a single file chunk in bytes. No other options can be set. + +.. _file_filename: + +@File\Filename +-------------- + +This maps the ``filename`` property of a GridFS file to a property. No other +options can be set. + +.. _file_length: + +@File\Length +------------ + +This maps the ``length`` property of a GridFS file to a property. It contains +the size of the entire file in bytes. No other options can be set. + +.. _file_metadata: + +@File\Metadata +-------------- + +This maps the ``metadata`` property of a GridFS file to a property. Metadata can +be used to store additional properties in a file. The metadata document must be +an embedded document mapped using `@EmbeddedDocument`_. + +Optional attributes: + +- + ``targetDocument`` - A |FQCN| of the target document. +- + ``discriminatorField`` - The database field name to store the discriminator + value within the embedded document. +- + ``discriminatorMap`` - Map of discriminator values to class names. +- + ``defaultDiscriminatorValue`` - A default value for ``discriminatorField`` + if no value has been set in the embedded document. + +@File\UploadDate +---------------- + +This maps the ``uploadDate`` property of a GridFS file to a property. No other +options can be set. + .. _haslifecyclecallbacks: @HasLifecycleCallbacks @@ -425,12 +510,12 @@ single-field indexes. Optional attributes: - - keys - Mapping of indexed fields to their ordering or index type. ODM will - allow "asc" and "desc" to be used in place of ``1`` and ``-1``, - respectively. Special index types (e.g. "2dsphere") should be specified as + ``keys`` - Mapping of indexed fields to their ordering or index type. ODM + will allow ``asc`` and ``desc`` to be used in place of ``1`` and ``-1``, + respectively. Special index types (e.g. ``2dsphere``) should be specified as strings. This is required when `@Index`_ is used at the class level. - - options - Options for creating the index + ``options`` - Options for creating the index The ``keys`` and ``options`` attributes correspond to the arguments for `MongoCollection::createIndex() `_. @@ -823,48 +908,53 @@ documents. Optional attributes: - - targetDocument - A |FQCN| of the target document. A ``targetDocument`` is - required when using ``storeAs: id``. + ``targetDocument`` - A |FQCN| of the target document. A ``targetDocument`` + is required when using ``storeAs: id``. - - storeAs - Indicates how to store the reference. ``id`` stores the identifier, - ``ref`` an embedded object containing the ``id`` field and (optionally) a - discriminator. ``dbRef`` and ``dbRefWithDb`` store a `DBRef`_ object and - are deprecated in favor of ``ref``. Note that ``id`` references are not - compatible with the discriminators. + ``storeAs`` - Indicates how to store the reference. ``id`` stores the + identifier, ``ref`` an embedded object containing the ``id`` field and + (optionally) a discriminator. ``dbRef`` and ``dbRefWithDb`` store a `DBRef`_ + object and are deprecated in favor of ``ref``. Note that ``id`` references + are not compatible with the discriminators. - - cascade - Cascade Option + ``cascade`` - Cascade Option - - discriminatorField - The field name to store the discriminator value within + ``discriminatorField`` - The field name to store the discriminator value within the reference object. - - discriminatorMap - Map of discriminator values to class names. + ``discriminatorMap`` - Map of discriminator values to class names. - - defaultDiscriminatorValue - A default value for discriminatorField if no value - has been set in the referenced document. + ``defaultDiscriminatorValue`` - A default value for ``discriminatorField`` + if no value has been set in the referenced document. - - inversedBy - The field name of the inverse side. Only allowed on owning side. + ``inversedBy`` - The field name of the inverse side. Only allowed on owning side. - - mappedBy - The field name of the owning side. Only allowed on the inverse side. + ``mappedBy`` - The field name of the owning side. Only allowed on the + inverse side. - - repositoryMethod - The name of the repository method to call to populate this reference. + ``repositoryMethod`` - The name of the repository method to call to populate + this reference. - - sort - The default sort for the query that loads the reference. + ``sort`` - The default sort for the query that loads the reference. - - criteria - Array of default criteria for the query that loads the reference. + ``criteria`` - Array of default criteria for the query that loads the + reference. - - limit - Limit for the query that loads the reference. + ``limit`` - Limit for the query that loads the reference. - - skip - Skip for the query that loads the reference. + ``skip`` - Skip for the query that loads the reference. - - strategy - The strategy used to persist changes to the collection. Possible - values are ``addToSet``, ``pushAll``, ``set``, and ``setArray``. ``pushAll`` - is the default. See :ref:`storage_strategies` for more information. + ``strategy`` - The strategy used to persist changes to the collection. + Possible values are ``addToSet``, ``pushAll``, ``set``, and ``setArray``. + ``pushAll`` is the default. See :ref:`storage_strategies` for more + information. - - collectionClass - A |FQCN| of class that implements ``Collection`` interface - and is used to hold documents. Doctrine's ``ArrayCollection`` is used by default + ``collectionClass`` - A |FQCN| of class that implements ``Collection`` + interface and is used to hold documents. Doctrine's ``ArrayCollection`` is + used by default - - prime - A list of references contained in the target document that will be - initialized when the collection is loaded. Only allowed for inverse + ``prime`` - A list of references contained in the target document that will + be initialized when the collection is loaded. Only allowed for inverse references. .. code-block:: php @@ -897,38 +987,42 @@ Defines an instance variable holds a related document instance. Optional attributes: - - targetDocument - A |FQCN| of the target document. A ``targetDocument`` is - required when using ``storeAs: id``. + ``targetDocument`` - A |FQCN| of the target document. A ``targetDocument`` + is required when using ``storeAs: id``. - - storeAs - Indicates how to store the reference. ``id`` stores the identifier, - ``ref`` an embedded object containing the ``id`` field and (optionally) a - discriminator. ``dbRef`` and ``dbRefWithDb`` store a `DBRef`_ object and - are deprecated in favor of ``ref``. Note that ``id`` references are not - compatible with the discriminators. + ``storeAs`` - Indicates how to store the reference. ``id`` stores the + identifier, ``ref`` an embedded object containing the ``id`` field and + (optionally) a discriminator. ``dbRef`` and ``dbRefWithDb`` store a `DBRef`_ + object and are deprecated in favor of ``ref``. Note that ``id`` references + are not compatible with the discriminators. - - cascade - Cascade Option + ``cascade`` - Cascade Option - - discriminatorField - The field name to store the discriminator value within - the reference object. + ``discriminatorField`` - The field name to store the discriminator value + within the reference object. - - discriminatorMap - Map of discriminator values to class names. + ``discriminatorMap`` - Map of discriminator values to class names. - - defaultDiscriminatorValue - A default value for discriminatorField if no value - has been set in the referenced document. + ``defaultDiscriminatorValue`` - A default value for ``discriminatorField`` + if no value has been set in the referenced document. - - inversedBy - The field name of the inverse side. Only allowed on owning side. + ``inversedBy`` - The field name of the inverse side. Only allowed on owning + side. - - mappedBy - The field name of the owning side. Only allowed on the inverse side. + ``mappedBy`` - The field name of the owning side. Only allowed on the + inverse side. - - repositoryMethod - The name of the repository method to call to populate this reference. + ``repositoryMethod`` - The name of the repository method to call to populate + this reference. - - sort - The default sort for the query that loads the reference. + ``sort`` - The default sort for the query that loads the reference. - - criteria - Array of default criteria for the query that loads the reference. + ``criteria`` - Array of default criteria for the query that loads the + reference. - - limit - Limit for the query that loads the reference. + ``limit`` - Limit for the query that loads the reference. - - skip - Skip for the query that loads the reference. + ``skip`` - Skip for the query that loads the reference. .. code-block:: php diff --git a/docs/en/reference/storing-files-with-mongogridfs.rst b/docs/en/reference/storing-files-with-mongogridfs.rst index dd5c5e184e..17cfbe5eb5 100644 --- a/docs/en/reference/storing-files-with-mongogridfs.rst +++ b/docs/en/reference/storing-files-with-mongogridfs.rst @@ -1,17 +1,39 @@ -Storing Files with MongoGridFS -============================== - -The PHP Mongo extension provides a nice and convenient way to store -files in chunks of data with the -`MongoGridFS `_. - -It uses two database collections, one to store the metadata for the -file, and another to store the contents of the file. The contents -are stored in chunks to avoid going over the maximum allowed size -of a MongoDB document. - -You can easily setup a Document that is stored using the -MongoGridFS: +Storing Files with GridFS +========================= + +About GridFS +------------ + +With GridFS, MongoDB provides a specification for storing and retrieving files +that exceed the document size limit of 16 MB. GridFS uses two collections to +store files. One collection stores the file chunks, and the other stores file +metadata. More information on GridFS can be found in the +`MongoDB GridFS documentation `_. + +GridFS files provide the following properties +- + ``_id`` stores the identifier of the file. By default, it uses a BSON + ObjectId, although you can override this in the mapping. +- + ``chunkSize`` stores the size of a single chunk in bytes. By default, chunks + are 261120 bytes (i.e. 255 KiB) in size. +- + ``filename`` is the name of the file as assigned. Note that filenames don't + need to be unique: instead, multiple files with the same name are treated + as revisions of that same file, with the last file uploaded being the latest + revision. +- + ``length`` stores the size of the file in bytes. +- + ``metadata`` is an optional embedded document that can be used to store + additional data along with the file. +- + ``uploadDate`` stores the date when the file was originally persisted to the + GridFS bucket. It is also used to track revisions of multiple files with the + same filename. + +Mapping documents as GridFS files +--------------------------------- .. code-block:: php @@ -19,172 +41,207 @@ MongoGridFS: namespace Documents; - /** @Document */ + use Doctrine\ODM\MongoDB\Mapping\Annotations\File; + use Doctrine\ODM\MongoDB\Mapping\Annotations\Id; + + /** @File(bucketName='image') */ class Image { /** @Id */ private $id; - /** @Field */ + /** @File\Filename */ private $name; - /** @File */ - private $file; - - /** @Field */ + /** @File\UploadDate */ private $uploadDate; - /** @Field */ + /** @File\Length */ private $length; - /** @Field */ + /** @File\ChunkSize */ private $chunkSize; - /** @Field */ - private $md5; + /** @File\Metadata(targetDocument=ImageMetadata::class) */ + private $metadata; public function getId(): ?string { return $this->id; } - public function setName(string $name): void + public function getName(): ?string + { + return $this->name; + } + + public function getChunkSize(): ?int { - $this->name = $name; + return $this->chunkSize; } - public function getName(): ?string + public function getLength(): ?int { - return $this->name; + return $this->length; } - public function getFile(): ?string + public function getUploadDate(): \DateTimeInterface { - return $this->file; + return $this->uploadDate; } - public function setFile(string $file): void + public function getMetadata(): ?ImageMetadata { - $this->file = $file; + return $this->metadata; } } -Notice how we annotated the $file property with @File. This is what -tells the Document that it is to be stored using the MongoGridFS -and the MongoGridFSFile instance is placed in the $file property -for you to access the actual file itself. +If you would rather use XML to map metadata, the corresponding mapping would +look like this: -The $uploadDate, $chunkSize and $md5 properties are automatically filled in -for each file stored in GridFS (whether you like that or not). -Feel free to create getters in your document to actually make use of them, -but keep in mind that their values will be initially unset for new objects -until the next time the document is hydrated (fetched from the database). +..code-block:: xml -First you need to create a new Image: + -.. code-block:: php + - + + + + + - $image = new Image(); - $image->setName('Test image'); - $image->setFile('/path/to/image.png'); + + + - $dm->persist($image); - $dm->flush(); +With XML mappings, the fields are automatically mapped to camel-cased properties. +To change property names, simply override the ``fieldName`` attribute for each +field. You cannot override any other options for GridFS fields. -Now you can later query for the Image and render it: +The ``ImageMetadata`` class must be an embedded document: -.. code-block:: php +..code-block:: php createQueryBuilder('Documents\Image') - ->field('name')->equals('Test image') - ->getQuery() - ->getSingleResult(); + namespace Documents; - header('Content-type: image/png;'); - echo $image->getFile()->getBytes(); + use Doctrine\ODM\MongoDB\Mapping\Annotations\EmbeddedDocument; + use Doctrine\ODM\MongoDB\Mapping\Annotations\Field; -You can of course make references to this Image document from -another document. Imagine you had a Profile document and you wanted -every Profile to have a profile image: + /** @EmbeddedDocument */ + class ImageMetadata + { + /** @Field(type="string") */ + private $contentType; + + public function __construct(string $contentType) + { + $this->contentType = $contentType; + } + + public function getContentType(): ?string + { + return $this->contentType; + } + } + +Inserting files into GridFS buckets +----------------------------------- + +To insert a new file, you have to upload its contents using the repository. You +have the option to upload contents from a file or a stream. Alternatively, you +can also open an upload stream and write contents yourself. .. code-block:: php getRepository(Documents\Image::class); + $file = $repository->uploadFromFile('image.jpg', '/tmp/path/to/image'); - /** @Document */ - class Profile - { - /** @Id */ - private $id; +When using the default GridFS repository implementation, the ``uploadFromFile`` +and ``uploadFromStream`` methods return a proxy object of the file you just +uploaded. - /** @Field */ - private $name; +If you want to pass options, such as a metadata object to the uploaded file, you +can pass an ``UploadOptions`` object as the last argument to the +``uploadFromFile``, ``uploadFromStream``, or ``openUploadStream`` method call: - /** @ReferenceOne(targetDocument="Documents\Image") */ - private $image; +.. code-block:: php - public function getId(): ?string - { - return $this->id; - } + name; - } + use Doctrine\ODM\MongoDB\Repository\UploadOptions; - public function setName(string $name): void - { - $this->name = $name; - } + $uploadOptions = new UploadOptions(); + $uploadOptions->metadata = new Documents\ImageMetadata('image/jpeg'); + $uploadOptions->chunkSizeBytes = 1024 * 1024; - public function getImage(): ?Image - { - return $this->image; - } + $repository = $documentManager->getRepository(Documents\Image::class); + $file = $repository->uploadFromFile('image.jpg', '/tmp/path/to/image', $uploadOptions); - public function setImage(Image $image): void - { - $this->image = $image; - } - } +Reading files from GridFS buckets +--------------------------------- -Now you can create a new Profile and give it an Image: +When reading GridFS files, they behave like all other documents. You can query +for them using the ``find*`` methods in the repository, create query or +aggregation pipeline builders, and also use them as ``targetDocument`` in +references. You can access all properties of the file including metadata, but +not file content. + +The GridFS specification uses streams to deal with file contents. To avoid +having this resource overhead every time you fetch a file from the database, +file contents are only provided through the ``downloadToStream`` repository +method. Accessors to provide a stream in the document may be implemented in +future versions. + +The following code sample puts the file contents into a different file after +uploading: .. code-block:: php setName('Test image'); - $image->setFile('/path/to/image.png'); + $repository = $documentManager->getRepository(Documents\Image::class); + $file = $repository->uploadFromFile('image.jpg', '/tmp/path/to/image', new Documents\ImageMetadata('image/jpeg')); - $profile = new Profile(); - $profile->setName('Jonathan H. Wage'); - $profile->setImage($image); + $stream = fopen('tmp/path/to/copy', 'w+'); + try { + $repository->downloadToStream($file->getId(), $stream); + finally { + fclose($stream); + } - $dm->persist($profile); - $dm->flush(); +The ``downloadToStream`` method takes the identifier of a file as first argument +and a writable stream as the second arguments. If you need to manipulate the +file contents before writing it to disk or sending it to the client, consider +using a memory stream using the ``php://memory`` stream wrapper. -If you want to query for the Profile and load the Image reference -in a query you can use: +Alternatively, you can also use the ``openDownloadStream`` method which returns +a stream from where you can read file contents: .. code-block:: php createQueryBuilder('Profile') - ->field('name')->equals('Jonathan H. Wage') - ->getQuery() - ->getSingleResult(); + use Doctrine\ODM\MongoDB\Repository\UploadOptions; - $image = $profile->getImage(); + $uploadOptions = new UploadOptions(); + $uploadOptions->metadata = new Documents\ImageMetadata('image/jpeg'); + + $repository = $documentManager->getRepository(Documents\Image::class); + $file = $repository->uploadFromFile('image.jpg', '/tmp/path/to/image', $uploadOptions); + + $stream = $repository->openDownloadStream($file->getId()); + try { + $contents = stream_get_contents($stream); + finally { + fclose($stream); + } - header('Content-type: image/png;'); - echo $image->getFile()->getBytes(); diff --git a/doctrine-mongo-mapping.xsd b/doctrine-mongo-mapping.xsd index 9180fa1f1c..c8cfdcfa06 100644 --- a/doctrine-mongo-mapping.xsd +++ b/doctrine-mongo-mapping.xsd @@ -19,6 +19,7 @@ + @@ -35,6 +36,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -108,6 +135,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/lib/Doctrine/ODM/MongoDB/Configuration.php b/lib/Doctrine/ODM/MongoDB/Configuration.php index 6512ebd28a..a7110c174b 100644 --- a/lib/Doctrine/ODM/MongoDB/Configuration.php +++ b/lib/Doctrine/ODM/MongoDB/Configuration.php @@ -14,7 +14,10 @@ use Doctrine\ODM\MongoDB\PersistentCollection\DefaultPersistentCollectionGenerator; use Doctrine\ODM\MongoDB\PersistentCollection\PersistentCollectionFactory; use Doctrine\ODM\MongoDB\PersistentCollection\PersistentCollectionGenerator; +use Doctrine\ODM\MongoDB\Repository\DefaultGridFSRepository; use Doctrine\ODM\MongoDB\Repository\DefaultRepositoryFactory; +use Doctrine\ODM\MongoDB\Repository\DocumentRepository; +use Doctrine\ODM\MongoDB\Repository\GridFSRepository; use Doctrine\ODM\MongoDB\Repository\RepositoryFactory; use function trim; @@ -342,7 +345,7 @@ public function getFilterParameters(string $name): ?array /** * @throws MongoDBException If not is a ObjectRepository. */ - public function setDefaultRepositoryClassName(string $className): void + public function setDefaultDocumentRepositoryClassName(string $className): void { $reflectionClass = new \ReflectionClass($className); @@ -350,12 +353,31 @@ public function setDefaultRepositoryClassName(string $className): void throw MongoDBException::invalidDocumentRepository($className); } - $this->attributes['defaultRepositoryClassName'] = $className; + $this->attributes['defaultDocumentRepositoryClassName'] = $className; } - public function getDefaultRepositoryClassName(): string + public function getDefaultDocumentRepositoryClassName(): string { - return $this->attributes['defaultRepositoryClassName'] ?? DocumentRepository::class; + return $this->attributes['defaultDocumentRepositoryClassName'] ?? DocumentRepository::class; + } + + /** + * @throws MongoDBException If the class does not implement the GridFSRepository interface. + */ + public function setDefaultGridFSRepositoryClassName(string $className): void + { + $reflectionClass = new \ReflectionClass($className); + + if (! $reflectionClass->implementsInterface(GridFSRepository::class)) { + throw MongoDBException::invalidGridFSRepository($className); + } + + $this->attributes['defaultGridFSRepositoryClassName'] = $className; + } + + public function getDefaultGridFSRepositoryClassName(): string + { + return $this->attributes['defaultGridFSRepositoryClassName'] ?? DefaultGridFSRepository::class; } public function setRepositoryFactory(RepositoryFactory $repositoryFactory): void diff --git a/lib/Doctrine/ODM/MongoDB/DocumentManager.php b/lib/Doctrine/ODM/MongoDB/DocumentManager.php index e247821ffa..38335ab4c7 100644 --- a/lib/Doctrine/ODM/MongoDB/DocumentManager.php +++ b/lib/Doctrine/ODM/MongoDB/DocumentManager.php @@ -18,6 +18,7 @@ use MongoDB\Collection; use MongoDB\Database; use MongoDB\Driver\ReadPreference; +use MongoDB\GridFS\Bucket; use function array_search; use function get_class; use function gettype; @@ -114,6 +115,13 @@ class DocumentManager implements ObjectManager */ private $documentCollections = []; + /** + * Array of cached document bucket instances that are lazily loaded. + * + * @var Bucket[] + */ + private $documentBuckets = []; + /** * Whether the DocumentManager is closed or not. * @@ -312,7 +320,7 @@ public function getDocumentDatabases() } /** - * Returns the MongoCollection instance for a class. + * Returns the collection instance for a class. * * @param string $className The class name. * @throws MongoDBException When the $className param is not mapped to a collection. @@ -322,7 +330,12 @@ public function getDocumentCollection($className) { $className = ltrim($className, '\\'); + /** @var ClassMetadata $metadata */ $metadata = $this->metadataFactory->getMetadataFor($className); + if ($metadata->isFile) { + return $this->getDocumentBucket($className)->getFilesCollection(); + } + $collectionName = $metadata->getCollection(); if (! $collectionName) { @@ -343,6 +356,42 @@ public function getDocumentCollection($className) return $this->documentCollections[$className]; } + /** + * Returns the bucket instance for a class. + * + * @param string $className The class name. + * @throws MongoDBException When the $className param is not mapped to a collection. + */ + public function getDocumentBucket(string $className): Bucket + { + $className = ltrim($className, '\\'); + + /** @var ClassMetadata $metadata */ + $metadata = $this->metadataFactory->getMetadataFor($className); + if (! $metadata->isFile) { + throw MongoDBException::documentBucketOnlyAvailableForGridFSFiles($className); + } + + $bucketName = $metadata->getBucketName(); + + if (! $bucketName) { + throw MongoDBException::documentNotMappedToCollection($className); + } + + if (! isset($this->documentBuckets[$className])) { + $db = $this->getDocumentDatabase($className); + + $options = ['bucketName' => $bucketName]; + if ($metadata->readPreference !== null) { + $options['readPreference'] = new ReadPreference($metadata->readPreference, $metadata->readPreferenceTags); + } + + $this->documentBuckets[$className] = $db->selectGridFSBucket($options); + } + + return $this->documentBuckets[$className]; + } + /** * Gets the array of instantiated document collection instances. * diff --git a/lib/Doctrine/ODM/MongoDB/Mapping/Annotations/File.php b/lib/Doctrine/ODM/MongoDB/Mapping/Annotations/File.php new file mode 100644 index 0000000000..b9cc381c2f --- /dev/null +++ b/lib/Doctrine/ODM/MongoDB/Mapping/Annotations/File.php @@ -0,0 +1,37 @@ +isFile) { + throw MappingException::discriminatorNotAllowedForGridFS($this->name); + } + if ($discriminatorField === null) { $this->discriminatorField = null; @@ -771,6 +797,10 @@ public function setDiscriminatorField($discriminatorField) */ public function setDiscriminatorMap(array $map) { + if ($this->isFile) { + throw MappingException::discriminatorNotAllowedForGridFS($this->name); + } + foreach ($map as $value => $className) { $this->discriminatorMap[$value] = $className; if ($this->name === $className) { @@ -796,6 +826,10 @@ public function setDiscriminatorMap(array $map) */ public function setDefaultDiscriminatorValue($defaultDiscriminatorValue) { + if ($this->isFile) { + throw MappingException::discriminatorNotAllowedForGridFS($this->name); + } + if ($defaultDiscriminatorValue === null) { $this->defaultDiscriminatorValue = null; @@ -815,9 +849,15 @@ public function setDefaultDiscriminatorValue($defaultDiscriminatorValue) * collection. * * @param string $value + * + * @throws MappingException */ public function setDiscriminatorValue($value) { + if ($this->isFile) { + throw MappingException::discriminatorNotAllowedForGridFS($this->name); + } + $this->discriminatorMap[$value] = $this->name; $this->discriminatorValue = $value; } @@ -1104,6 +1144,27 @@ public function setCollection($name) } } + public function getBucketName(): ?string + { + return $this->bucketName; + } + + public function setBucketName(string $bucketName): void + { + $this->bucketName = $bucketName; + $this->setCollection($bucketName . '.files'); + } + + public function getChunkSizeBytes(): ?int + { + return $this->chunkSizeBytes; + } + + public function setChunkSizeBytes(int $chunkSizeBytes): void + { + $this->chunkSizeBytes = $chunkSizeBytes; + } + /** * Get whether or not the documents collection is capped. * @@ -2002,11 +2063,6 @@ public function mapField(array $mapping) $mapping['discriminatorField'] = self::DEFAULT_DISCRIMINATOR_FIELD; } - /* - if (isset($mapping['type']) && ($mapping['type'] === 'one' || $mapping['type'] === 'many')) { - $mapping['type'] = $mapping['type'] === 'one' ? self::ONE : self::MANY; - } - */ if (isset($mapping['version'])) { $mapping['notSaved'] = true; $this->setVersionMapping($mapping); @@ -2039,6 +2095,10 @@ public function mapField(array $mapping) throw MappingException::referencePrimersOnlySupportedForInverseReferenceMany($this->name, $mapping['fieldName']); } + if ($this->isFile && ! $this->isAllowedGridFSField($mapping['name'])) { + throw MappingException::fieldNotAllowedForGridFS($this->name, $mapping['fieldName']); + } + $this->applyStorageStrategy($mapping); $this->fieldMappings[$mapping['fieldName']] = $mapping; @@ -2118,6 +2178,12 @@ public function __sleep() $serialized[] = 'isQueryResultDocument'; } + if ($this->isFile) { + $serialized[] = 'isFile'; + $serialized[] = 'bucketName'; + $serialized[] = 'chunkSizeBytes'; + } + if ($this->isVersioned) { $serialized[] = 'isVersioned'; $serialized[] = 'versionField'; @@ -2170,4 +2236,9 @@ public function newInstance() { return $this->instantiator->instantiate($this->name); } + + private function isAllowedGridFSField(string $name): bool + { + return in_array($name, self::ALLOWED_GRIDFS_FIELDS, true); + } } diff --git a/lib/Doctrine/ODM/MongoDB/Mapping/Driver/AnnotationDriver.php b/lib/Doctrine/ODM/MongoDB/Mapping/Driver/AnnotationDriver.php index 4845f30c50..ae3612b156 100644 --- a/lib/Doctrine/ODM/MongoDB/Mapping/Driver/AnnotationDriver.php +++ b/lib/Doctrine/ODM/MongoDB/Mapping/Driver/AnnotationDriver.php @@ -33,6 +33,7 @@ class AnnotationDriver extends AbstractAnnotationDriver ODM\MappedSuperclass::class => 2, ODM\EmbeddedDocument::class => 3, ODM\QueryResultDocument::class => 4, + ODM\File::class => 5, ]; /** @@ -95,13 +96,24 @@ public function loadMetadataForClass($className, \Doctrine\Common\Persistence\Ma $class->isEmbeddedDocument = true; } elseif ($documentAnnot instanceof ODM\QueryResultDocument) { $class->isQueryResultDocument = true; + } elseif ($documentAnnot instanceof ODM\File) { + $class->isFile = true; + + if ($documentAnnot->chunkSizeBytes !== null) { + $class->setChunkSizeBytes($documentAnnot->chunkSizeBytes); + } } + if (isset($documentAnnot->db)) { $class->setDatabase($documentAnnot->db); } if (isset($documentAnnot->collection)) { $class->setCollection($documentAnnot->collection); } + // Store bucketName as collection name for GridFS files + if (isset($documentAnnot->bucketName)) { + $class->setBucketName($documentAnnot->bucketName); + } if (isset($documentAnnot->repositoryClass)) { $class->setCustomRepositoryClass($documentAnnot->repositoryClass); } diff --git a/lib/Doctrine/ODM/MongoDB/Mapping/Driver/XmlDriver.php b/lib/Doctrine/ODM/MongoDB/Mapping/Driver/XmlDriver.php index 3199ac196f..5d346188a0 100644 --- a/lib/Doctrine/ODM/MongoDB/Mapping/Driver/XmlDriver.php +++ b/lib/Doctrine/ODM/MongoDB/Mapping/Driver/XmlDriver.php @@ -38,6 +38,29 @@ class XmlDriver extends FileDriver { public const DEFAULT_FILE_EXTENSION = '.dcm.xml'; + private const DEFAULT_GRIDFS_MAPPINGS = [ + 'length' => [ + 'name' => 'length', + 'type' => 'int', + 'notSaved' => true, + ], + 'chunk-size' => [ + 'name' => 'chunkSize', + 'type' => 'int', + 'notSaved' => true, + ], + 'filename' => [ + 'name' => 'filename', + 'type' => 'string', + 'notSaved' => true, + ], + 'upload-date' => [ + 'name' => 'uploadDate', + 'type' => 'date', + 'notSaved' => true, + ], + ]; + /** * {@inheritDoc} */ @@ -71,10 +94,18 @@ public function loadMetadataForClass($className, \Doctrine\Common\Persistence\Ma $class->isEmbeddedDocument = true; } elseif ($xmlRoot->getName() === 'query-result-document') { $class->isQueryResultDocument = true; + } elseif ($xmlRoot->getName() === 'gridfs-file') { + $class->isFile = true; + + if (isset($xmlRoot['chunk-size-bytes'])) { + $class->setChunkSizeBytes((int) $xmlRoot['chunk-size-bytes']); + } } + if (isset($xmlRoot['db'])) { $class->setDatabase((string) $xmlRoot['db']); } + if (isset($xmlRoot['collection'])) { if (isset($xmlRoot['capped-collection'])) { $config = ['name' => (string) $xmlRoot['collection']]; @@ -90,6 +121,9 @@ public function loadMetadataForClass($className, \Doctrine\Common\Persistence\Ma $class->setCollection((string) $xmlRoot['collection']); } } + if (isset($xmlRoot['bucket-name'])) { + $class->setBucketName((string) $xmlRoot['bucket-name']); + } if (isset($xmlRoot['write-concern'])) { $class->setWriteConcern((string) $xmlRoot['write-concern']); } @@ -191,6 +225,9 @@ public function loadMetadataForClass($className, \Doctrine\Common\Persistence\Ma $this->addFieldMapping($class, $mapping); } } + + $this->addGridFSMappings($class, $xmlRoot); + if (isset($xmlRoot->{'embed-one'})) { foreach ($xmlRoot->{'embed-one'} as $embed) { $this->addEmbedMapping($class, $embed, 'one'); @@ -546,7 +583,7 @@ protected function loadMappingFile($file) $xmlElement = simplexml_load_file($file); - foreach (['document', 'embedded-document', 'mapped-superclass', 'query-result-document'] as $type) { + foreach (['document', 'embedded-document', 'mapped-superclass', 'query-result-document', 'gridfs-file'] as $type) { if (! isset($xmlElement->$type)) { continue; } @@ -587,4 +624,30 @@ private function formatErrors(array $xmlErrors): string return sprintf('Line %d:%d: %s', $error->line, $error->column, $error->message); }, $xmlErrors)); } + + private function addGridFSMappings(ClassMetadata $class, \SimpleXMLElement $xmlRoot): void + { + if (! $class->isFile) { + return; + } + + foreach (self::DEFAULT_GRIDFS_MAPPINGS as $name => $mapping) { + if (! isset($xmlRoot->{$name})) { + continue; + } + + if (isset($xmlRoot->{$name}->attributes()['fieldName'])) { + $mapping['fieldName'] = (string) $xmlRoot->{$name}->attributes()['fieldName']; + } + + $this->addFieldMapping($class, $mapping); + } + + if (! isset($xmlRoot->metadata)) { + return; + } + + $xmlRoot->metadata->addAttribute('field', 'metadata'); + $this->addEmbedMapping($class, $xmlRoot->metadata, 'one'); + } } diff --git a/lib/Doctrine/ODM/MongoDB/Mapping/MappingException.php b/lib/Doctrine/ODM/MongoDB/Mapping/MappingException.php index 312f665bee..05b2de0811 100644 --- a/lib/Doctrine/ODM/MongoDB/Mapping/MappingException.php +++ b/lib/Doctrine/ODM/MongoDB/Mapping/MappingException.php @@ -401,4 +401,14 @@ public static function xmlMappingFileInvalid(string $filename, string $errorDeta { return new self(sprintf("The mapping file %s is invalid: \n%s", $filename, $errorDetails)); } + + public static function fieldNotAllowedForGridFS($className, $fieldName) + { + return new self(sprintf("Field '%s' in class '%s' is not a valid field for GridFS documents. You should move it to an embedded metadata document.", $fieldName, $className)); + } + + public static function discriminatorNotAllowedForGridFS($className) + { + return new self(sprintf("Class '%s' cannot be discriminated because it is marked as a GridFS file", $className)); + } } diff --git a/lib/Doctrine/ODM/MongoDB/MongoDBException.php b/lib/Doctrine/ODM/MongoDB/MongoDBException.php index 6965b679d8..4b26b78538 100644 --- a/lib/Doctrine/ODM/MongoDB/MongoDBException.php +++ b/lib/Doctrine/ODM/MongoDB/MongoDBException.php @@ -5,6 +5,7 @@ namespace Doctrine\ODM\MongoDB; use Doctrine\Common\Persistence\ObjectRepository; +use Doctrine\ODM\MongoDB\Repository\GridFSRepository; use function array_slice; use function end; use function get_class; @@ -80,6 +81,15 @@ public static function invalidDocumentRepository($className) return new self(sprintf("Invalid repository class '%s'. It must be a %s.", $className, ObjectRepository::class)); } + /** + * @param string $className + * @return MongoDBException + */ + public static function invalidGridFSRepository($className) + { + return new self(sprintf("Invalid repository class '%s'. It must be a %s.", $className, GridFSRepository::class)); + } + /** * @param string $type * @param string|array $expected @@ -160,4 +170,19 @@ public static function commitInProgress() { return new self('There is already a commit operation in progress. Did you call flush from an event listener?'); } + + public static function documentBucketOnlyAvailableForGridFSFiles(string $className): self + { + return new self(sprintf('Cannot fetch document bucket for document "%s".', $className)); + } + + public static function cannotPersistGridFSFile(string $className): self + { + return new self(sprintf('Cannot persist GridFS file for class "%s" through UnitOfWork.', $className)); + } + + public static function cannotReadGridFSSourceFile(string $filename): self + { + return new self(sprintf('Cannot open file "%s" for uploading to GridFS.', $filename)); + } } diff --git a/lib/Doctrine/ODM/MongoDB/Persisters/DocumentPersister.php b/lib/Doctrine/ODM/MongoDB/Persisters/DocumentPersister.php index b10546e070..510cf12326 100644 --- a/lib/Doctrine/ODM/MongoDB/Persisters/DocumentPersister.php +++ b/lib/Doctrine/ODM/MongoDB/Persisters/DocumentPersister.php @@ -28,6 +28,7 @@ use MongoDB\Driver\Cursor; use MongoDB\Driver\Exception\Exception as DriverException; use MongoDB\Driver\Exception\WriteException; +use MongoDB\GridFS\Bucket; use function array_combine; use function array_fill; use function array_intersect_key; @@ -93,6 +94,9 @@ class DocumentPersister */ private $collection; + /** @var Bucket|null */ + private $bucket; + /** * Array of queued inserts for the persister to insert. * @@ -148,6 +152,12 @@ public function __construct( $this->class = $class; $this->collection = $dm->getDocumentCollection($class->name); $this->cp = $this->uow->getCollectionPersister(); + + if (! $class->isFile) { + return; + } + + $this->bucket = $dm->getDocumentBucket($class->name); } /** @@ -451,6 +461,15 @@ public function update($document, array $options = []) */ public function delete($document, array $options = []) { + if ($this->bucket instanceof Bucket) { + $documentIdentifier = $this->uow->getDocumentIdentifier($document); + $databaseIdentifier = $this->class->getDatabaseIdentifierValue($documentIdentifier); + + $this->bucket->delete($databaseIdentifier); + + return; + } + $query = $this->getQueryForDocument($document); if ($this->class->isLockable) { diff --git a/lib/Doctrine/ODM/MongoDB/Repository/AbstractRepositoryFactory.php b/lib/Doctrine/ODM/MongoDB/Repository/AbstractRepositoryFactory.php index 6b05f65508..5525ff0857 100644 --- a/lib/Doctrine/ODM/MongoDB/Repository/AbstractRepositoryFactory.php +++ b/lib/Doctrine/ODM/MongoDB/Repository/AbstractRepositoryFactory.php @@ -48,12 +48,19 @@ public function getRepository(DocumentManager $documentManager, $documentName) * @param DocumentManager $documentManager The DocumentManager instance. * @param string $documentName The name of the document. * - * @return ObjectRepository + * @return ObjectRepository|GridFSRepository */ protected function createRepository(DocumentManager $documentManager, $documentName) { - $metadata = $documentManager->getClassMetadata($documentName); - $repositoryClassName = $metadata->customRepositoryClassName ?: $documentManager->getConfiguration()->getDefaultRepositoryClassName(); + $metadata = $documentManager->getClassMetadata($documentName); + + if ($metadata->customRepositoryClassName) { + $repositoryClassName = $metadata->customRepositoryClassName; + } elseif ($metadata->isFile) { + $repositoryClassName = $documentManager->getConfiguration()->getDefaultGridFSRepositoryClassName(); + } else { + $repositoryClassName = $documentManager->getConfiguration()->getDefaultDocumentRepositoryClassName(); + } return $this->instantiateRepository($repositoryClassName, $documentManager, $metadata); } diff --git a/lib/Doctrine/ODM/MongoDB/Repository/DefaultGridFSRepository.php b/lib/Doctrine/ODM/MongoDB/Repository/DefaultGridFSRepository.php new file mode 100644 index 0000000000..8c281a6f87 --- /dev/null +++ b/lib/Doctrine/ODM/MongoDB/Repository/DefaultGridFSRepository.php @@ -0,0 +1,105 @@ +getDocumentBucket()->openDownloadStream($this->class->getDatabaseIdentifierValue($id)); + } catch (FileNotFoundException $e) { + throw DocumentNotFoundException::documentNotFound($this->getClassName(), $id); + } + } + + /** + * @see Bucket::downloadToStream + */ + public function downloadToStream($id, $destination): void + { + try { + $this->getDocumentBucket()->downloadToStream($this->class->getDatabaseIdentifierValue($id), $destination); + } catch (FileNotFoundException $e) { + throw DocumentNotFoundException::documentNotFound($this->getClassName(), $id); + } + } + + /** + * @see Bucket::openUploadStream + */ + public function openUploadStream(string $filename, ?UploadOptions $uploadOptions = null) + { + $options = $this->prepareOptions($uploadOptions); + + return $this->getDocumentBucket()->openUploadStream($filename, $options); + } + + /** + * @see Bucket::uploadFromStream + */ + public function uploadFromStream(string $filename, $source, ?UploadOptions $uploadOptions = null) + { + $options = $this->prepareOptions($uploadOptions); + + $databaseIdentifier = $this->getDocumentBucket()->uploadFromStream($filename, $source, $options); + $documentIdentifier = $this->class->getPHPIdentifierValue($databaseIdentifier); + + return $this->dm->getReference($this->getClassName(), $documentIdentifier); + } + + public function uploadFromFile(string $source, ?string $filename = null, ?UploadOptions $uploadOptions = null) + { + $resource = fopen($source, 'r'); + if ($resource === false) { + throw MongoDBException::cannotReadGridFSSourceFile($source); + } + + if ($filename === null) { + $filename = pathinfo($source, PATHINFO_BASENAME); + } + + try { + return $this->uploadFromStream($filename, $resource, $uploadOptions); + } finally { + fclose($resource); + } + } + + private function getDocumentBucket(): Bucket + { + return $this->dm->getDocumentBucket($this->documentName); + } + + private function prepareOptions(?UploadOptions $uploadOptions = null): array + { + if ($uploadOptions === null) { + $uploadOptions = new UploadOptions(); + } + + $options = [ + 'chunkSizeBytes' => $uploadOptions->chunkSizeBytes ?: $this->class->getChunkSizeBytes(), + ]; + + if (is_object($uploadOptions->metadata)) { + $options += ['metadata' => (object) $this->uow->getPersistenceBuilder()->prepareInsertData($uploadOptions->metadata)]; + } + + return $options; + } +} diff --git a/lib/Doctrine/ODM/MongoDB/DocumentRepository.php b/lib/Doctrine/ODM/MongoDB/Repository/DocumentRepository.php similarity index 93% rename from lib/Doctrine/ODM/MongoDB/DocumentRepository.php rename to lib/Doctrine/ODM/MongoDB/Repository/DocumentRepository.php index c50fe75fbc..d1bd17b088 100644 --- a/lib/Doctrine/ODM/MongoDB/DocumentRepository.php +++ b/lib/Doctrine/ODM/MongoDB/Repository/DocumentRepository.php @@ -2,15 +2,22 @@ declare(strict_types=1); -namespace Doctrine\ODM\MongoDB; +namespace Doctrine\ODM\MongoDB\Repository; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Criteria; use Doctrine\Common\Collections\Selectable; use Doctrine\Common\Persistence\ObjectRepository; +use Doctrine\ODM\MongoDB\Aggregation\Builder as AggregationBuilder; +use Doctrine\ODM\MongoDB\DocumentManager; +use Doctrine\ODM\MongoDB\LockException; +use Doctrine\ODM\MongoDB\LockMode; use Doctrine\ODM\MongoDB\Mapping\ClassMetadata; +use Doctrine\ODM\MongoDB\Mapping\MappingException; +use Doctrine\ODM\MongoDB\Query\Builder as QueryBuilder; use Doctrine\ODM\MongoDB\Query\QueryExpressionVisitor; +use Doctrine\ODM\MongoDB\UnitOfWork; use function is_array; /** @@ -54,7 +61,7 @@ public function __construct(DocumentManager $dm, UnitOfWork $uow, ClassMetadata /** * Creates a new Query\Builder instance that is preconfigured for this document name. * - * @return Query\Builder $qb + * @return QueryBuilder $qb */ public function createQueryBuilder() { @@ -64,7 +71,7 @@ public function createQueryBuilder() /** * Creates a new Aggregation\Builder instance that is prepopulated for this document name. * - * @return Aggregation\Builder + * @return AggregationBuilder */ public function createAggregationBuilder() { @@ -86,7 +93,7 @@ public function clear() * @param mixed $id Identifier. * @param int $lockMode Optional. Lock mode; one of the LockMode constants. * @param int $lockVersion Optional. Expected version. - * @throws Mapping\MappingException + * @throws MappingException * @throws LockException * @return object|null The document, if found, otherwise null. */ @@ -192,7 +199,7 @@ public function getDocumentManager() } /** - * @return Mapping\ClassMetadata + * @return ClassMetadata */ public function getClassMetadata() { diff --git a/lib/Doctrine/ODM/MongoDB/Repository/GridFSRepository.php b/lib/Doctrine/ODM/MongoDB/Repository/GridFSRepository.php new file mode 100644 index 0000000000..814c082c8a --- /dev/null +++ b/lib/Doctrine/ODM/MongoDB/Repository/GridFSRepository.php @@ -0,0 +1,52 @@ + 1, 'n' => 1]; + + private const GRIDFS_CHUNKS_COLLECTION_INDEX = ['filename' => 1, 'uploadDate' => 1]; + /** @var DocumentManager */ protected $dm; @@ -40,6 +44,7 @@ public function ensureIndexes($timeout = null) if ($class->isMappedSuperclass || $class->isEmbeddedDocument || $class->isQueryResultDocument) { continue; } + $this->ensureDocumentIndexes($class->name, $timeout); } } @@ -58,6 +63,7 @@ public function updateIndexes($timeout = null) if ($class->isMappedSuperclass || $class->isEmbeddedDocument || $class->isQueryResultDocument) { continue; } + $this->updateDocumentIndexes($class->name, $timeout); } } @@ -151,6 +157,7 @@ private function doGetDocumentIndexes($documentName, array &$visited) } else { continue; } + foreach ($possibleEmbeds as $embed) { if (isset($embeddedDocumentIndexes[$embed])) { $embeddedIndexes = $embeddedDocumentIndexes[$embed]; @@ -158,11 +165,13 @@ private function doGetDocumentIndexes($documentName, array &$visited) $embeddedIndexes = $this->doGetDocumentIndexes($embed, $visited); $embeddedDocumentIndexes[$embed] = $embeddedIndexes; } + foreach ($embeddedIndexes as $embeddedIndex) { foreach ($embeddedIndex['keys'] as $key => $value) { $embeddedIndex['keys'][$fieldMapping['name'] . '.' . $key] = $value; unset($embeddedIndex['keys'][$key]); } + $indexes[] = $embeddedIndex; } } @@ -173,12 +182,15 @@ private function doGetDocumentIndexes($documentName, array &$visited) if ($key === $fieldMapping['name']) { $key = ClassMetadata::getReferenceFieldName($fieldMapping['storeAs'], $key); } + $newKeys[$key] = $v; } + $indexes[$idx]['keys'] = $newKeys; } } } + return $indexes; } @@ -196,6 +208,7 @@ private function prepareIndexes(ClassMetadata $class) 'keys' => [], 'options' => $index['options'], ]; + foreach ($index['keys'] as $key => $value) { $key = $persister->prepareFieldName($key); if ($class->hasField($key)) { @@ -226,6 +239,10 @@ public function ensureDocumentIndexes($documentName, $timeout = null) throw new \InvalidArgumentException('Cannot create document indexes for mapped super classes, embedded documents or query result documents.'); } + if ($class->isFile) { + $this->ensureGridFSIndexes($class); + } + $indexes = $this->getDocumentIndexes($documentName); if (! $indexes) { return; @@ -254,6 +271,7 @@ public function deleteIndexes() if ($class->isMappedSuperclass || $class->isEmbeddedDocument || $class->isQueryResultDocument) { continue; } + $this->deleteDocumentIndexes($class->name); } } @@ -270,6 +288,7 @@ public function deleteDocumentIndexes($documentName) if ($class->isMappedSuperclass || $class->isEmbeddedDocument || $class->isQueryResultDocument) { throw new \InvalidArgumentException('Cannot delete document indexes for mapped super classes, embedded documents or query result documents.'); } + $this->dm->getDocumentCollection($documentName)->dropIndexes(); } @@ -300,6 +319,13 @@ public function createDocumentCollection($documentName) throw new \InvalidArgumentException('Cannot create document collection for mapped super classes, embedded documents or query result documents.'); } + if ($class->isFile) { + $this->dm->getDocumentDatabase($documentName)->createCollection($class->getBucketName() . '.files'); + $this->dm->getDocumentDatabase($documentName)->createCollection($class->getBucketName() . '.chunks'); + + return; + } + $this->dm->getDocumentDatabase($documentName)->createCollection( $class->getCollection(), [ @@ -319,6 +345,7 @@ public function dropCollections() if ($class->isMappedSuperclass || $class->isEmbeddedDocument || $class->isQueryResultDocument) { continue; } + $this->dropDocumentCollection($class->name); } } @@ -335,7 +362,14 @@ public function dropDocumentCollection($documentName) if ($class->isMappedSuperclass || $class->isEmbeddedDocument || $class->isQueryResultDocument) { throw new \InvalidArgumentException('Cannot delete document indexes for mapped super classes, embedded documents or query result documents.'); } + $this->dm->getDocumentCollection($documentName)->drop(); + + if (! $class->isFile) { + return; + } + + $this->dm->getDocumentBucket($documentName)->getChunksCollection()->drop(); } /** @@ -347,6 +381,7 @@ public function dropDatabases() if ($class->isMappedSuperclass || $class->isEmbeddedDocument || $class->isQueryResultDocument) { continue; } + $this->dropDocumentDatabase($class->name); } } @@ -363,6 +398,7 @@ public function dropDocumentDatabase($documentName) if ($class->isMappedSuperclass || $class->isEmbeddedDocument || $class->isQueryResultDocument) { throw new \InvalidArgumentException('Cannot drop document database for mapped super classes, embedded documents or query result documents.'); } + $this->dm->getDocumentDatabase($documentName)->drop(); } @@ -623,4 +659,34 @@ private function runShardCollectionCommand($documentName) return $result; } + + private function ensureGridFSIndexes(ClassMetadata $class): void + { + $this->ensureChunksIndex($class); + $this->ensureFilesIndex($class); + } + + private function ensureChunksIndex(ClassMetadata $class): void + { + $chunksCollection = $this->dm->getDocumentBucket($class->getName())->getChunksCollection(); + foreach ($chunksCollection->listIndexes() as $index) { + if ($index->isUnique() && $index->getKey() === self::GRIDFS_FILE_COLLECTION_INDEX) { + return; + } + } + + $chunksCollection->createIndex(self::GRIDFS_FILE_COLLECTION_INDEX, ['unique' => true]); + } + + private function ensureFilesIndex(ClassMetadata $class): void + { + $filesCollection = $this->dm->getDocumentCollection($class->getName()); + foreach ($filesCollection->listIndexes() as $index) { + if ($index->getKey() === self::GRIDFS_CHUNKS_COLLECTION_INDEX) { + return; + } + } + + $filesCollection->createIndex(self::GRIDFS_CHUNKS_COLLECTION_INDEX); + } } diff --git a/lib/Doctrine/ODM/MongoDB/Types/FileType.php b/lib/Doctrine/ODM/MongoDB/Types/FileType.php deleted file mode 100644 index 284351d1dc..0000000000 --- a/lib/Doctrine/ODM/MongoDB/Types/FileType.php +++ /dev/null @@ -1,13 +0,0 @@ -isFile) { + try { + $gridFSMetadata = $class->getFieldMappingByDbFieldName('metadata'); + $gridFSMetadataProperty = $gridFSMetadata['fieldName']; + } catch (MappingException $e) { + } + } + foreach ($actualData as $propName => $actualValue) { // skip not saved fields - if (isset($class->fieldMappings[$propName]['notSaved']) && $class->fieldMappings[$propName]['notSaved'] === true) { + if ((isset($class->fieldMappings[$propName]['notSaved']) && $class->fieldMappings[$propName]['notSaved'] === true) || + ($class->isFile && $propName !== $gridFSMetadataProperty)) { continue; } @@ -1642,6 +1654,10 @@ private function doPersist($document, array &$visited) } break; case self::STATE_NEW: + if ($class->isFile) { + throw MongoDBException::cannotPersistGridFSFile($class->name); + } + $this->persistNew($class, $document); break; diff --git a/tests/Doctrine/ODM/MongoDB/Tests/Functional/CustomCollectionsTest.php b/tests/Doctrine/ODM/MongoDB/Tests/Functional/CustomCollectionsTest.php index 457b036974..268a34eaeb 100644 --- a/tests/Doctrine/ODM/MongoDB/Tests/Functional/CustomCollectionsTest.php +++ b/tests/Doctrine/ODM/MongoDB/Tests/Functional/CustomCollectionsTest.php @@ -9,6 +9,7 @@ use Doctrine\ODM\MongoDB\Mapping\ClassMetadata; use Doctrine\ODM\MongoDB\PersistentCollection; use Doctrine\ODM\MongoDB\PersistentCollection\PersistentCollectionInterface; +use Doctrine\ODM\MongoDB\Repository\GridFSRepository; use Doctrine\ODM\MongoDB\Tests\BaseTest; use Documents\File; use Documents\ProfileNotify; @@ -143,12 +144,14 @@ public function testModifyingCollectionByCustomMethod() public function testModifyingCollectionInChangeTrackingNotifyDocument() { + /** @var GridFSRepository $repository */ + $repository = $this->dm->getRepository(File::class); + + $f1 = $repository->uploadFromFile(__FILE__); + $f2 = $repository->uploadFromFile(__FILE__); + $profile = new ProfileNotify(); - $f1 = new File(); - $f1->setName('av.jpeg'); $profile->getImages()->add($f1); - $f2 = new File(); - $f2->setName('ghost.gif'); $profile->getImages()->add($f2); $this->dm->persist($profile); $this->dm->flush(); @@ -160,8 +163,8 @@ public function testModifyingCollectionInChangeTrackingNotifyDocument() $profile = $this->dm->find(get_class($profile), $profile->getProfileId()); $this->assertCount(2, $profile->getImages()); - $this->assertEquals($f2->getName(), $profile->getImages()[0]->getName()); - $this->assertEquals($f1->getName(), $profile->getImages()[1]->getName()); + $this->assertEquals($f2->getId(), $profile->getImages()[0]->getId()); + $this->assertEquals($f1->getId(), $profile->getImages()[1]->getId()); } } diff --git a/tests/Doctrine/ODM/MongoDB/Tests/Functional/OrphanRemovalEmbedTest.php b/tests/Doctrine/ODM/MongoDB/Tests/Functional/OrphanRemovalEmbedTest.php index e632243c98..feaabae61c 100644 --- a/tests/Doctrine/ODM/MongoDB/Tests/Functional/OrphanRemovalEmbedTest.php +++ b/tests/Doctrine/ODM/MongoDB/Tests/Functional/OrphanRemovalEmbedTest.php @@ -4,8 +4,8 @@ namespace Doctrine\ODM\MongoDB\Tests\Functional; -use Doctrine\ODM\MongoDB\DocumentRepository; use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; +use Doctrine\ODM\MongoDB\Repository\DocumentRepository; use Doctrine\ODM\MongoDB\Tests\BaseTest; /** diff --git a/tests/Doctrine/ODM/MongoDB/Tests/Functional/OrphanRemovalTest.php b/tests/Doctrine/ODM/MongoDB/Tests/Functional/OrphanRemovalTest.php index 0bbd5f5d17..78357ae055 100644 --- a/tests/Doctrine/ODM/MongoDB/Tests/Functional/OrphanRemovalTest.php +++ b/tests/Doctrine/ODM/MongoDB/Tests/Functional/OrphanRemovalTest.php @@ -4,8 +4,8 @@ namespace Doctrine\ODM\MongoDB\Tests\Functional; -use Doctrine\ODM\MongoDB\DocumentRepository; use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; +use Doctrine\ODM\MongoDB\Repository\DocumentRepository; use Doctrine\ODM\MongoDB\Tests\BaseTest; class OrphanRemovalTest extends BaseTest diff --git a/tests/Doctrine/ODM/MongoDB/Tests/Functional/Ticket/GH1232Test.php b/tests/Doctrine/ODM/MongoDB/Tests/Functional/Ticket/GH1232Test.php index ceeeaa412d..ff31385372 100644 --- a/tests/Doctrine/ODM/MongoDB/Tests/Functional/Ticket/GH1232Test.php +++ b/tests/Doctrine/ODM/MongoDB/Tests/Functional/Ticket/GH1232Test.php @@ -5,8 +5,8 @@ namespace Doctrine\ODM\MongoDB\Tests\Functional\Ticket; use Doctrine\Common\Collections\ArrayCollection; -use Doctrine\ODM\MongoDB\DocumentRepository; use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; +use Doctrine\ODM\MongoDB\Repository\DocumentRepository; use Doctrine\ODM\MongoDB\Tests\BaseTest; class GH1232Test extends BaseTest diff --git a/tests/Doctrine/ODM/MongoDB/Tests/Functional/Ticket/GH1572Test.php b/tests/Doctrine/ODM/MongoDB/Tests/Functional/Ticket/GH1572Test.php index 95e3bc99d1..d6c39a0fb0 100644 --- a/tests/Doctrine/ODM/MongoDB/Tests/Functional/Ticket/GH1572Test.php +++ b/tests/Doctrine/ODM/MongoDB/Tests/Functional/Ticket/GH1572Test.php @@ -4,9 +4,9 @@ namespace Doctrine\ODM\MongoDB\Tests\Functional\Ticket; -use Doctrine\ODM\MongoDB\DocumentRepository; use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; use Doctrine\ODM\MongoDB\PersistentCollection\PersistentCollectionInterface; +use Doctrine\ODM\MongoDB\Repository\DocumentRepository; use Doctrine\ODM\MongoDB\Tests\BaseTest; class GH1572Test extends BaseTest diff --git a/tests/Doctrine/ODM/MongoDB/Tests/Mapping/AbstractMappingDriverTest.php b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/AbstractMappingDriverTest.php index 6cbd6b9bad..20073639e6 100644 --- a/tests/Doctrine/ODM/MongoDB/Tests/Mapping/AbstractMappingDriverTest.php +++ b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/AbstractMappingDriverTest.php @@ -21,13 +21,7 @@ abstract protected function _loadDriver(); */ public function testLoadMapping() { - $className = AbstractMappingDriverUser::class; - $mappingDriver = $this->_loadDriver(); - - $class = new ClassMetadata($className); - $mappingDriver->loadMetadataForClass($className, $class); - - return $class; + return $this->loadMetadata(AbstractMappingDriverUser::class); } /** @@ -386,6 +380,60 @@ public function testShardKey($class) $this->assertTrue(isset($shardKey['options']['numInitialChunks']), 'Shard key option is not mapped'); $this->assertEquals(4096, $shardKey['options']['numInitialChunks'], 'Shard key option has wrong value'); } + + public function testGridFSMapping() + { + $class = $this->loadMetadata(AbstractMappingDriverFile::class); + + $this->assertTrue($class->isFile); + $this->assertSame(12345, $class->getChunkSizeBytes()); + + $this->assertArraySubset([ + 'name' => '_id', + 'type' => 'id', + ], $class->getFieldMapping('id'), true); + + $this->assertArraySubset([ + 'name' => 'length', + 'type' => 'int', + 'notSaved' => true, + ], $class->getFieldMapping('size'), true); + + $this->assertArraySubset([ + 'name' => 'chunkSize', + 'type' => 'int', + 'notSaved' => true, + ], $class->getFieldMapping('chunkSize'), true); + + $this->assertArraySubset([ + 'name' => 'filename', + 'type' => 'string', + 'notSaved' => true, + ], $class->getFieldMapping('name'), true); + + $this->assertArraySubset([ + 'name' => 'uploadDate', + 'type' => 'date', + 'notSaved' => true, + ], $class->getFieldMapping('uploadDate'), true); + + $this->assertArraySubset([ + 'name' => 'metadata', + 'type' => 'one', + 'embedded' => true, + 'targetDocument' => AbstractMappingDriverFileMetadata::class, + ], $class->getFieldMapping('metadata'), true); + } + + protected function loadMetadata($className): ClassMetadata + { + $mappingDriver = $this->_loadDriver(); + + $class = new ClassMetadata($className); + $mappingDriver->loadMetadataForClass($className, $class); + + return $class; + } } /** @@ -603,3 +651,33 @@ class InvalidMappingDocument { public $id; } + +/** + * @ODM\File(chunkSizeBytes=12345) + */ +class AbstractMappingDriverFile +{ + /** @ODM\Id */ + public $id; + + /** @ODM\File\Length */ + public $size; + + /** @ODM\File\ChunkSize */ + public $chunkSize; + + /** @ODM\File\Filename */ + public $name; + + /** @ODM\File\Metadata(targetDocument=AbstractMappingDriverFileMetadata::class) */ + public $metadata; + + /** @ODM\File\UploadDate */ + public $uploadDate; +} + +class AbstractMappingDriverFileMetadata +{ + /** @ODM\Field */ + public $contentType; +} diff --git a/tests/Doctrine/ODM/MongoDB/Tests/Mapping/ClassMetadataTest.php b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/ClassMetadataTest.php index 77e9f7ad8f..1a039c1918 100644 --- a/tests/Doctrine/ODM/MongoDB/Tests/Mapping/ClassMetadataTest.php +++ b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/ClassMetadataTest.php @@ -4,12 +4,12 @@ namespace Doctrine\ODM\MongoDB\Tests\Mapping; -use Doctrine\ODM\MongoDB\DocumentRepository; use Doctrine\ODM\MongoDB\Events; use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; use Doctrine\ODM\MongoDB\Mapping\ClassMetadata; use Doctrine\ODM\MongoDB\Mapping\MappingException; use Doctrine\ODM\MongoDB\Proxy\Proxy; +use Doctrine\ODM\MongoDB\Repository\DocumentRepository; use Doctrine\ODM\MongoDB\Tests\BaseTest; use Doctrine\ODM\MongoDB\Utility\CollectionHelper; use Documents\Account; @@ -786,6 +786,21 @@ public function testNoReferenceManyInShardKey() $cm->mapManyEmbedded(['fieldName' => 'referenceMany']); $cm->setShardKey(['referenceMany' => 1]); } + + public function testArbitraryFieldInGridFSFileThrowsException(): void + { + $object = new class { + public $contentType; + }; + + $cm = new ClassMetadata(get_class($object)); + $cm->isFile = true; + + $this->expectException(MappingException::class); + $this->expectExceptionMessageRegExp("#^Field 'contentType' in class '.+' is not a valid field for GridFS documents. You should move it to an embedded metadata document.$#"); + + $cm->mapField(['type' => 'string', 'fieldName' => 'contentType']); + } } class TestCustomRepositoryClass extends DocumentRepository diff --git a/tests/Doctrine/ODM/MongoDB/Tests/Mapping/xml/Doctrine.ODM.MongoDB.Tests.Mapping.AbstractMappingDriverFile.dcm.xml b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/xml/Doctrine.ODM.MongoDB.Tests.Mapping.AbstractMappingDriverFile.dcm.xml new file mode 100644 index 0000000000..453080e73e --- /dev/null +++ b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/xml/Doctrine.ODM.MongoDB.Tests.Mapping.AbstractMappingDriverFile.dcm.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + diff --git a/tests/Doctrine/ODM/MongoDB/Tests/Repository/DefaultGridFSRepositoryTest.php b/tests/Doctrine/ODM/MongoDB/Tests/Repository/DefaultGridFSRepositoryTest.php new file mode 100644 index 0000000000..fa7a181166 --- /dev/null +++ b/tests/Doctrine/ODM/MongoDB/Tests/Repository/DefaultGridFSRepositoryTest.php @@ -0,0 +1,231 @@ +getRepository()->openUploadStream('somefile.txt'); + self::assertInternalType('resource', $uploadStream); + + fwrite($uploadStream, 'contents'); + fclose($uploadStream); + + /** @var File $file */ + $file = $this->getRepository()->findOneBy(['filename' => 'somefile.txt']); + self::assertInstanceOf(File::class, $file); + + self::assertSame('somefile.txt', $file->getFilename()); + self::assertSame(8, $file->getLength()); + self::assertSame(12345, $file->getChunkSize()); + self::assertEquals(new \DateTime(), $file->getUploadDate(), '', 1); + self::assertNull($file->getMetadata()); + } + + public function testOpenUploadStreamUsesChunkSizeFromOptions(): void + { + $uploadOptions = new UploadOptions(); + $uploadOptions->chunkSizeBytes = 1234; + + $uploadStream = $this->getRepository()->openUploadStream('somefile.txt', $uploadOptions); + self::assertInternalType('resource', $uploadStream); + + fwrite($uploadStream, 'contents'); + fclose($uploadStream); + + /** @var File $file */ + $file = $this->getRepository()->findOneBy(['filename' => 'somefile.txt']); + self::assertInstanceOf(File::class, $file); + + self::assertSame('somefile.txt', $file->getFilename()); + self::assertSame(8, $file->getLength()); + self::assertSame(1234, $file->getChunkSize()); + self::assertEquals(new \DateTime(), $file->getUploadDate(), '', 1); + self::assertNull($file->getMetadata()); + } + + public function testUploadFromStreamStoresFile(): void + { + $uploadOptions = new UploadOptions(); + $uploadOptions->metadata = new FileMetadata(); + + $fileResource = fopen(__FILE__, 'r'); + + try { + /** @var File $file */ + $file = $this->getRepository()->uploadFromStream('somefile.txt', $fileResource, $uploadOptions); + } finally { + fclose($fileResource); + } + + self::assertInstanceOf(File::class, $file); + + $expectedSize = filesize(__FILE__); + + // Check if the file is actually there + self::assertInstanceOf(File::class, $this->getRepository()->findOneBy(['filename' => 'somefile.txt'])); + + self::assertSame('somefile.txt', $file->getFilename()); + self::assertSame($expectedSize, $file->getLength()); + self::assertSame(12345, $file->getChunkSize()); + self::assertEquals(new \DateTime(), $file->getUploadDate(), '', 1); + self::assertInstanceOf(FileMetadata::class, $file->getMetadata()); + + $stream = tmpfile(); + $this->getRepository()->downloadToStream($file->getId(), $stream); + + fseek($stream, 0); + $stat = fstat($stream); + self::assertSame($expectedSize, $stat['size']); + fclose($stream); + } + + public function testOpenDownloadStreamAllowsReadingFile(): void + { + /** @var File $file */ + $file = $this->getRepository()->uploadFromFile(__FILE__); + self::assertInstanceOf(File::class, $file); + + $expectedSize = filesize(__FILE__); + + $stream = $this->getRepository()->openDownloadStream($file->getId()); + + fseek($stream, 0); + $stat = fstat($stream); + self::assertSame($expectedSize, $stat['size']); + fclose($stream); + } + + public function testUploadFromStreamPassesChunkSize(): void + { + $uploadOptions = new UploadOptions(); + $uploadOptions->metadata = new FileMetadata(); + $uploadOptions->chunkSizeBytes = 1234; + + $fileResource = fopen(__FILE__, 'r'); + + try { + /** @var File $file */ + $file = $this->getRepository()->uploadFromStream('somefile.txt', $fileResource, $uploadOptions); + } finally { + fclose($fileResource); + } + + self::assertInstanceOf(File::class, $file); + + $expectedSize = filesize(__FILE__); + + // Check if the file is actually there + self::assertInstanceOf(File::class, $this->getRepository()->findOneBy(['filename' => 'somefile.txt'])); + + self::assertSame('somefile.txt', $file->getFilename()); + self::assertSame($expectedSize, $file->getLength()); + self::assertSame(1234, $file->getChunkSize()); + self::assertEquals(new \DateTime(), $file->getUploadDate(), '', 1); + self::assertInstanceOf(FileMetadata::class, $file->getMetadata()); + + $stream = tmpfile(); + $this->getRepository()->downloadToStream($file->getId(), $stream); + + fseek($stream, 0); + $stat = fstat($stream); + self::assertSame($expectedSize, $stat['size']); + fclose($stream); + } + + public function testUploadFromFileWithoutFilenamePicksAFilename(): void + { + /** @var File $file */ + $file = $this->getRepository()->uploadFromFile(__FILE__); + + $expectedSize = filesize(__FILE__); + + self::assertSame('DefaultGridFSRepositoryTest.php', $file->getFilename()); + self::assertSame($expectedSize, $file->getLength()); + self::assertSame(12345, $file->getChunkSize()); + + // Check if the file is actually there + self::assertInstanceOf(File::class, $this->getRepository()->findOneBy(['filename' => $file->getFilename()])); + } + + public function testUploadFromFileUsesProvidedFilename(): void + { + $uploadOptions = new UploadOptions(); + $uploadOptions->chunkSizeBytes = 1234; + + /** @var File $file */ + $file = $this->getRepository()->uploadFromFile(__FILE__, 'test.php', $uploadOptions); + self::assertSame('test.php', $file->getFilename()); + self::assertSame(1234, $file->getChunkSize()); + } + + public function testReadingFileAllowsUpdatingMetadata(): void + { + $uploadOptions = new UploadOptions(); + $uploadOptions->metadata = new FileMetadata(); + + $file = $this->uploadFile(__FILE__, $uploadOptions); + $file->getMetadata()->setOwner(new User()); + + $this->dm->persist($file); + $this->dm->flush(); + + $this->dm->clear(); + + /** @var File $file */ + $file = $this->getRepository()->find($file->getId()); + self::assertInstanceOf(File::class, $file); + self::assertInstanceOf(User::class, $file->getMetadata()->getOwner()); + } + + public function testDeletingFileAlsoDropsChunks(): void + { + $file = $this->uploadFile(__FILE__); + + $this->dm->remove($file); + $this->dm->flush(); + + $bucket = $this->dm->getDocumentBucket(File::class); + + self::assertSame(0, $bucket->getFilesCollection()->count()); + self::assertSame(0, $bucket->getChunksCollection()->count()); + } + + private function getRepository(): GridFSRepository + { + return $this->dm->getRepository(File::class); + } + + private function uploadFile($filename, ?UploadOptions $uploadOptions = null): File + { + $fileResource = fopen($filename, 'r'); + + try { + /** @var File $file */ + $file = $this->getRepository()->uploadFromStream('somefile.txt', $fileResource, $uploadOptions); + } finally { + fclose($fileResource); + } + + self::assertInstanceOf(File::class, $file); + + return $file; + } +} diff --git a/tests/Doctrine/ODM/MongoDB/Tests/SchemaManagerTest.php b/tests/Doctrine/ODM/MongoDB/Tests/SchemaManagerTest.php index ca2785bcd5..ceae71eb63 100644 --- a/tests/Doctrine/ODM/MongoDB/Tests/SchemaManagerTest.php +++ b/tests/Doctrine/ODM/MongoDB/Tests/SchemaManagerTest.php @@ -6,6 +6,7 @@ use Doctrine\Common\EventManager; use Doctrine\ODM\MongoDB\Configuration; +use Doctrine\ODM\MongoDB\Mapping\ClassMetadata; use Doctrine\ODM\MongoDB\Mapping\ClassMetadataFactory; use Doctrine\ODM\MongoDB\Mapping\Driver\AnnotationDriver; use Doctrine\ODM\MongoDB\Persisters\DocumentPersister; @@ -20,12 +21,15 @@ use Documents\CmsProduct; use Documents\CmsUser; use Documents\Comment; +use Documents\File; use Documents\Sharded\ShardedUser; use Documents\SimpleReferenceUser; use MongoDB\Client; use MongoDB\Collection; use MongoDB\Database; +use MongoDB\GridFS\Bucket; use MongoDB\Model\IndexInfoIteratorIterator; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use const DOCTRINE_MONGODB_DATABASE; use function in_array; @@ -58,8 +62,16 @@ class SchemaManagerTest extends TestCase 'Documents/SubCategory', ]; + /** @var ClassMetadata[] */ private $classMetadatas = []; + + /** @var Collection[]|MockObject[] */ private $documentCollections = []; + + /** @var Bucket[]|MockObject[] */ + private $documentBuckets = []; + + /** @var Database[]|MockObject[] */ private $documentDatabases = []; /** @var SchemaManager */ @@ -76,7 +88,12 @@ public function setUp() $map = []; foreach ($cmf->getAllMetadata() as $cm) { - $this->documentCollections[$cm->name] = $this->getMockCollection(); + if ($cm->isFile) { + $this->documentBuckets[$cm->name] = $this->getMockBucket(); + } else { + $this->documentCollections[$cm->name] = $this->getMockCollection(); + } + $this->documentDatabases[$cm->name] = $this->getMockDatabase(); $this->classMetadatas[$cm->name] = $cm; } @@ -84,6 +101,7 @@ public function setUp() $this->dm->unitOfWork = $this->getMockUnitOfWork(); $this->dm->metadataFactory = $cmf; $this->dm->documentCollections = $this->documentCollections; + $this->dm->documentBuckets = $this->documentBuckets; $this->dm->documentDatabases = $this->documentDatabases; $this->schemaManager = new SchemaManager($this->dm, $cmf); @@ -100,6 +118,30 @@ public function testEnsureIndexes() } } + foreach ($this->documentBuckets as $class => $bucket) { + $bucket->getFilesCollection() + ->expects($this->any()) + ->method('listIndexes') + ->willReturn([]) + ; + $bucket->getFilesCollection() + ->expects($this->once()) + ->method('createIndex') + ->with(['filename' => 1, 'uploadDate' => 1]) + ; + + $bucket->getChunksCollection() + ->expects($this->any()) + ->method('listIndexes') + ->willReturn([]) + ; + $bucket->getChunksCollection() + ->expects($this->once()) + ->method('createIndex') + ->with(['files_id' => 1, 'n' => 1], ['unique' => true]) + ; + } + $this->schemaManager->ensureIndexes(); } @@ -116,6 +158,44 @@ public function testEnsureDocumentIndexes() $this->schemaManager->ensureDocumentIndexes(CmsArticle::class); } + public function testEnsureDocumentIndexesForGridFSFile() + { + foreach ($this->documentCollections as $class => $collection) { + $collection->expects($this->never())->method('createIndex'); + } + + foreach ($this->documentBuckets as $class => $bucket) { + if ($class === File::class) { + $bucket->getFilesCollection() + ->expects($this->any()) + ->method('listIndexes') + ->willReturn([]) + ; + $bucket->getFilesCollection() + ->expects($this->once()) + ->method('createIndex') + ->with(['filename' => 1, 'uploadDate' => 1]) + ; + + $bucket->getChunksCollection() + ->expects($this->any()) + ->method('listIndexes') + ->willReturn([]) + ; + $bucket->getChunksCollection() + ->expects($this->once()) + ->method('createIndex') + ->with(['files_id' => 1, 'n' => 1], ['unique' => true]) + ; + } else { + $bucket->getFilesCollection()->expects($this->never())->method('createIndex'); + $bucket->getChunksCollection()->expects($this->never())->method('createIndex'); + } + } + + $this->schemaManager->ensureDocumentIndexes(File::class); + } + public function testEnsureDocumentIndexesWithTwoLevelInheritance() { $collection = $this->documentCollections[CmsProduct::class]; @@ -219,6 +299,21 @@ public function testCreateDocumentCollection() $this->schemaManager->createDocumentCollection(CmsArticle::class); } + public function testCreateDocumentCollectionForFile() + { + $database = $this->documentDatabases[File::class]; + $database->expects($this->at(0)) + ->method('createCollection') + ->with('fs.files') + ; + $database->expects($this->at(1)) + ->method('createCollection') + ->with('fs.chunks') + ; + + $this->schemaManager->createDocumentCollection(File::class); + } + public function testCreateCollections() { foreach ($this->documentDatabases as $class => $database) { @@ -262,6 +357,31 @@ public function testDropDocumentCollection() $this->schemaManager->dropDocumentCollection(CmsArticle::class); } + public function testDropDocumentCollectionForGridFSFile() + { + foreach ($this->documentCollections as $class => $collection) { + $collection->expects($this->never())->method('drop'); + } + + foreach ($this->documentBuckets as $class => $bucket) { + if ($class === File::class) { + $bucket->getFilesCollection() + ->expects($this->once()) + ->method('drop') + ; + $bucket->getChunksCollection() + ->expects($this->once()) + ->method('drop') + ; + } else { + $bucket->getFilesCollection()->expects($this->never())->method('drop'); + $bucket->getChunksCollection()->expects($this->never())->method('drop'); + } + } + + $this->schemaManager->dropDocumentCollection(File::class); + } + public function testDropDocumentDatabase() { foreach ($this->documentDatabases as $class => $database) { @@ -770,11 +890,23 @@ public function testEnableShardingForDbIgnoresAlreadyShardedError() $this->schemaManager->enableShardingForDbByDocumentName(ShardedUser::class); } + /** @return Bucket|MockObject */ + private function getMockBucket() + { + $mock = $this->createMock(Bucket::class); + $mock->expects($this->any())->method('getFilesCollection')->willReturn($this->getMockCollection()); + $mock->expects($this->any())->method('getChunksCollection')->willReturn($this->getMockCollection()); + + return $mock; + } + + /** @return Collection|MockObject */ private function getMockCollection() { return $this->createMock(Collection::class); } + /** @return Database|MockObject */ private function getMockDatabase() { return $this->createMock(Database::class); diff --git a/tests/Doctrine/ODM/MongoDB/Tests/UnitOfWorkTest.php b/tests/Doctrine/ODM/MongoDB/Tests/UnitOfWorkTest.php index 434be9c433..969798b54a 100644 --- a/tests/Doctrine/ODM/MongoDB/Tests/UnitOfWorkTest.php +++ b/tests/Doctrine/ODM/MongoDB/Tests/UnitOfWorkTest.php @@ -12,6 +12,7 @@ use Doctrine\ODM\MongoDB\Hydrator\HydratorFactory; use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; use Doctrine\ODM\MongoDB\Mapping\ClassMetadata; +use Doctrine\ODM\MongoDB\MongoDBException; use Doctrine\ODM\MongoDB\Persisters\PersistenceBuilder; use Doctrine\ODM\MongoDB\Proxy\Proxy; use Doctrine\ODM\MongoDB\Tests\Mocks\DocumentPersisterMock; @@ -20,6 +21,8 @@ use Doctrine\ODM\MongoDB\UnitOfWork; use Documents\Address; use Documents\CmsPhonenumber; +use Documents\File; +use Documents\FileWithoutMetadata; use Documents\ForumAvatar; use Documents\ForumUser; use Documents\Functional\NotSaved; @@ -27,6 +30,7 @@ use MongoDB\BSON\ObjectId; use function get_class; use function spl_object_hash; +use function sprintf; use function ucfirst; class UnitOfWorkTest extends BaseTest @@ -334,6 +338,67 @@ public function testNotSaved() $this->assertArrayNotHasKey('notSaved', $changeset); } + public function testNoUpdatesOnGridFSFields(): void + { + $file = new File(); + + $access = \Closure::bind(function (string $property, $value): void { + $this->$property = $value; + }, $file, $file); + + $access('id', 1234); + $access('filename', 'foo'); + $access('length', 123); + $access('uploadDate', new \DateTime()); + $access('chunkSize', 1234); + + $owner = new User(); + $this->uow->persist($owner); + + $file->getOrCreateMetadata()->setOwner($owner); + + $data = [ + '_id' => 123, + 'filename' => 'file.txt', + 'chunkSize' => 256, + 'length' => 0, + 'uploadDate' => new \DateTime(), + ]; + + $this->uow->registerManaged($file, spl_object_hash($file), $data); + + $this->uow->computeChangeSets(); + $changeset = $this->uow->getDocumentChangeSet($file); + $this->assertArrayNotHasKey('filename', $changeset); + $this->assertArrayNotHasKey('chunkSize', $changeset); + $this->assertArrayNotHasKey('length', $changeset); + $this->assertArrayNotHasKey('uploadDate', $changeset); + $this->assertArrayHasKey('metadata', $changeset); + } + + public function testComputingChangesetForFileWithoutMetadataThrowsNoError(): void + { + $file = new FileWithoutMetadata(); + + $access = \Closure::bind(function (string $property, $value): void { + $this->$property = $value; + }, $file, $file); + + $access('filename', 'foo'); + + $data = [ + '_id' => 123, + 'filename' => 'file.txt', + ]; + + $this->uow->registerManaged($file, spl_object_hash($file), $data); + + $this->uow->computeChangeSets(); + $changeset = $this->uow->getDocumentChangeSet($file); + + $this->assertSame([], $changeset); + } + /** * @dataProvider getScheduleForUpdateWithArraysTests */ @@ -440,6 +505,16 @@ public function testRegisterManagedEmbeddedDocumentWithMappedIdStrategyNoneAndNu $this->assertEquals($oid, $this->uow->getDocumentIdentifier($document)); } + public function testPersistNewGridFSFile(): void + { + $file = new File(); + + $this->expectException(MongoDBException::class); + $this->expectExceptionMessage(sprintf('Cannot persist GridFS file for class "%s" through UnitOfWork', File::class)); + + $this->uow->persist($file); + } + public function testPersistRemovedDocument() { $user = new ForumUser(); diff --git a/tests/Documents/BaseCategoryRepository.php b/tests/Documents/BaseCategoryRepository.php index 67a8e8e699..88d106011a 100644 --- a/tests/Documents/BaseCategoryRepository.php +++ b/tests/Documents/BaseCategoryRepository.php @@ -4,7 +4,7 @@ namespace Documents; -use Doctrine\ODM\MongoDB\DocumentRepository; +use Doctrine\ODM\MongoDB\Repository\DocumentRepository; class BaseCategoryRepository extends DocumentRepository { diff --git a/tests/Documents/CommentRepository.php b/tests/Documents/CommentRepository.php index 0c8f98c75f..354051b999 100644 --- a/tests/Documents/CommentRepository.php +++ b/tests/Documents/CommentRepository.php @@ -4,7 +4,7 @@ namespace Documents; -use Doctrine\ODM\MongoDB\DocumentRepository; +use Doctrine\ODM\MongoDB\Repository\DocumentRepository; /** FIXME: reflection chokes if this class doesn't have a doc comment */ class CommentRepository extends DocumentRepository diff --git a/tests/Documents/CustomRepository/Repository.php b/tests/Documents/CustomRepository/Repository.php index 5ad3723534..8575ad6a76 100644 --- a/tests/Documents/CustomRepository/Repository.php +++ b/tests/Documents/CustomRepository/Repository.php @@ -4,7 +4,7 @@ namespace Documents\CustomRepository; -use Doctrine\ODM\MongoDB\DocumentRepository; +use Doctrine\ODM\MongoDB\Repository\DocumentRepository; class Repository extends DocumentRepository { diff --git a/tests/Documents/File.php b/tests/Documents/File.php index 54d4cd5c11..4a9a4484fe 100644 --- a/tests/Documents/File.php +++ b/tests/Documents/File.php @@ -6,64 +6,63 @@ use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; -/** @ODM\Document */ +/** @ODM\File(chunkSizeBytes=12345) */ class File { /** @ODM\Id */ private $id; - /** @ODM\Field(type="string") */ - private $name; - - /** @ODM\Field(type="string") */ + /** @ODM\File\Filename */ private $filename; - /** @ODM\NotSaved(type="int") */ - private $length; + /** @ODM\File\ChunkSize */ + private $chunkSize; - /** @ODM\NotSaved(type="string") */ - private $md5; + /** @ODM\File\Length */ + private $length; - /** @ODM\NotSaved(type="date") */ + /** @ODM\File\UploadDate */ private $uploadDate; - public function getId() + /** @ODM\File\Metadata(targetDocument=FileMetadata::class) */ + private $metadata; + + public function getId(): ?string { return $this->id; } - public function setName($name) + public function getFilename(): ?string { - $this->name = $name; + return $this->filename; } - public function getName() + public function getChunkSize(): ?int { - return $this->name; + return $this->chunkSize; } - public function setFilename($filename) + public function getLength(): ?int { - $this->filename = $filename; + return $this->length; } - public function getFilename() + public function getUploadDate(): \DateTimeInterface { - return $this->filename; + return $this->uploadDate; } - public function getLength() + public function getMetadata(): ?FileMetadata { - return $this->length; + return $this->metadata; } - public function getMd5() + public function getOrCreateMetadata(): FileMetadata { - return $this->md5; - } + if (! $this->metadata) { + $this->metadata = new FileMetadata(); + } - public function getUploadDate() - { - return $this->uploadDate; + return $this->getMetadata(); } } diff --git a/tests/Documents/FileMetadata.php b/tests/Documents/FileMetadata.php new file mode 100644 index 0000000000..4ec80bfcbd --- /dev/null +++ b/tests/Documents/FileMetadata.php @@ -0,0 +1,27 @@ +owner; + } + + public function setOwner(?User $owner): void + { + $this->owner = $owner; + } +} diff --git a/tests/Documents/FileWithoutMetadata.php b/tests/Documents/FileWithoutMetadata.php new file mode 100644 index 0000000000..7717868996 --- /dev/null +++ b/tests/Documents/FileWithoutMetadata.php @@ -0,0 +1,27 @@ +id; + } + + public function getFilename(): ?string + { + return $this->filename; + } +} diff --git a/tools/sandbox/Documents/UserRepository.php b/tools/sandbox/Documents/UserRepository.php index 5cc39a1957..d20adc0509 100644 --- a/tools/sandbox/Documents/UserRepository.php +++ b/tools/sandbox/Documents/UserRepository.php @@ -4,7 +4,7 @@ namespace Documents; -use Doctrine\ODM\MongoDB\DocumentRepository; +use Doctrine\ODM\MongoDB\Repository\DocumentRepository; /** * UserRepository