diff --git a/docs/en/cookbook/time-series-data.rst b/docs/en/cookbook/time-series-data.rst new file mode 100644 index 000000000..4bc32b43b --- /dev/null +++ b/docs/en/cookbook/time-series-data.rst @@ -0,0 +1,132 @@ +Storing Time Series Data +======================== + +.. note:: + + Support for mapping time series data was added in ODM 2.10. + +`time-series data `__ +is a sequence of data points in which insights are gained by analyzing changes +over time. + +Time series data is generally composed of these components: + +- + Time when the data point was recorded + +- + Metadata, which is a label, tag, or other data that identifies a data series + and rarely changes + +- + Measurements, which are the data points tracked at increments in time. + +A time series document always contains a time value, and one or more measurement +fields. Metadata is optional, but cannot be added to a time series collection +after creating it. When using an embedded document for metadata, fields can be +added to this document after creating the collection. + +.. note:: + + Support for time series collections was added in MongoDB 5.0. Attempting to + use this functionality on older server versions will result in an error on + schema creation. + +Creating The Model +------------------ + +For this example, we'll be storing data from multiple sensors measuring +temperature and humidity. Other examples for time series include stock data, +price information, website visitors, or vehicle telemetry (speed, position, +etc.). + +First, we define the model for our data: + +.. code-block:: php + + id = (string) new ObjectId(); + } + } + +Note that we defined the entire model as readonly. While we could theoretically +change values in the document, in this example we'll assume that the data will +not change. + +Now we can mark the document as a time series document. To do so, we use the +``TimeSeries`` attribute, configuring appropriate values for the time and +metadata field, which in our case stores the ID of the sensor reporting the +measurement: + +.. code-block:: php + + persist($measurement); + $documentManager->flush(); + +Note that other functionality such as querying, using aggregation pipelines, or +removing data works the same as with other collections. + +Considerations +-------------- + +With the mapping above, data is stored with a granularity of seconds. Depending +on how often measurements come in, we can reduce the granularity to minutes or +hours. This changes how the data is stored internally by changing the bucket +size. This affects storage requirements and query performance. + +For example, with the default ``seconds`` granularity, each bucket groups +documents for one hour. If each sensor only reports data every few minutes, we'd +do well to configure ``minute`` granularity. This reduces the +number of buckets created, reducing storage and making queries more efficient. +However, if we were to choose ``hours`` for granularity, readings for a whole +month would be grouped into one bucket, resulting in slower queries as more +entries have to be traversed when reading data. + +More details on granularity and other consideration scan be found in the +`MongoDB documentation `__. diff --git a/docs/en/reference/attributes-reference.rst b/docs/en/reference/attributes-reference.rst index b6d8a616e..dedaf906a 100644 --- a/docs/en/reference/attributes-reference.rst +++ b/docs/en/reference/attributes-reference.rst @@ -1144,6 +1144,64 @@ for sharding the document collection. //... } +#[TimeSeries] +------------- + +This attribute may be used at the class level to mark a collection as containing +:doc:`time-series data <../cookbook/time-series-data>`. + +.. code-block:: php + + + @@ -634,4 +635,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/lib/Doctrine/ODM/MongoDB/Mapping/Annotations/TimeSeries.php b/lib/Doctrine/ODM/MongoDB/Mapping/Annotations/TimeSeries.php new file mode 100644 index 000000000..e4fe593eb --- /dev/null +++ b/lib/Doctrine/ODM/MongoDB/Mapping/Annotations/TimeSeries.php @@ -0,0 +1,29 @@ +rootClass = $rootClass; } + public function markAsTimeSeries(TimeSeries $options): void + { + $this->validateTimeSeriesOptions($options); + + $this->timeSeriesOptions = $options; + } + public function getFieldNames(): array { return array_keys($this->fieldMappings); @@ -2527,6 +2538,7 @@ public function __sleep() 'idGenerator', 'indexes', 'shardKey', + 'timeSeriesOptions', ]; // The rest of the metadata is only serialized if necessary. @@ -2758,4 +2770,15 @@ private function validateAndCompleteTypedManyAssociationMapping(array $mapping): return $mapping; } + + private function validateTimeSeriesOptions(TimeSeries $options): void + { + if (! $this->hasField($options->timeField)) { + throw MappingException::timeSeriesFieldNotFound($this->name, $options->timeField, 'time'); + } + + if ($options->metaField !== null && ! $this->hasField($options->metaField)) { + throw MappingException::timeSeriesFieldNotFound($this->name, $options->metaField, 'metadata'); + } + } } diff --git a/lib/Doctrine/ODM/MongoDB/Mapping/Driver/AttributeDriver.php b/lib/Doctrine/ODM/MongoDB/Mapping/Driver/AttributeDriver.php index 6a48f5fc1..fb3b00543 100644 --- a/lib/Doctrine/ODM/MongoDB/Mapping/Driver/AttributeDriver.php +++ b/lib/Doctrine/ODM/MongoDB/Mapping/Driver/AttributeDriver.php @@ -10,6 +10,7 @@ use Doctrine\ODM\MongoDB\Mapping\Annotations\AbstractIndex; use Doctrine\ODM\MongoDB\Mapping\Annotations\SearchIndex; use Doctrine\ODM\MongoDB\Mapping\Annotations\ShardKey; +use Doctrine\ODM\MongoDB\Mapping\Annotations\TimeSeries; use Doctrine\ODM\MongoDB\Mapping\ClassMetadata; use Doctrine\ODM\MongoDB\Mapping\MappingException; use Doctrine\Persistence\Mapping\ClassMetadata as PersistenceClassMetadata; @@ -288,6 +289,12 @@ public function loadMetadataForClass($className, PersistenceClassMetadata $metad $this->setShardKey($metadata, $classAttributes[ShardKey::class]); } + // Mark as time series only after mapping all fields + if (isset($classAttributes[TimeSeries::class])) { + assert($classAttributes[TimeSeries::class] instanceof TimeSeries); + $metadata->markAsTimeSeries($classAttributes[TimeSeries::class]); + } + foreach ($reflClass->getMethods(ReflectionMethod::IS_PUBLIC) as $method) { /* Filter for the declaring class only. Callbacks from parent * classes will already be registered. diff --git a/lib/Doctrine/ODM/MongoDB/Mapping/Driver/XmlDriver.php b/lib/Doctrine/ODM/MongoDB/Mapping/Driver/XmlDriver.php index 9035101f9..8b093dd2b 100644 --- a/lib/Doctrine/ODM/MongoDB/Mapping/Driver/XmlDriver.php +++ b/lib/Doctrine/ODM/MongoDB/Mapping/Driver/XmlDriver.php @@ -4,8 +4,10 @@ namespace Doctrine\ODM\MongoDB\Mapping\Driver; +use Doctrine\ODM\MongoDB\Mapping\Annotations\TimeSeries; use Doctrine\ODM\MongoDB\Mapping\ClassMetadata; use Doctrine\ODM\MongoDB\Mapping\MappingException; +use Doctrine\ODM\MongoDB\Mapping\TimeSeries\Granularity; use Doctrine\ODM\MongoDB\Utility\CollectionHelper; use Doctrine\Persistence\Mapping\Driver\FileDriver; use DOMDocument; @@ -78,6 +80,7 @@ public function __construct($locator, $fileExtension = self::DEFAULT_FILE_EXTENS parent::__construct($locator, $fileExtension); } + // phpcs:disable SlevomatCodingStandard.ControlStructures.EarlyExit.EarlyExitNotUsed public function loadMetadataForClass($className, \Doctrine\Persistence\Mapping\ClassMetadata $metadata) { assert($metadata instanceof ClassMetadata); @@ -335,15 +338,34 @@ public function loadMetadataForClass($className, \Doctrine\Persistence\Mapping\C } } - if (! isset($xmlRoot->{'also-load-methods'})) { - return; + if (isset($xmlRoot->{'also-load-methods'})) { + foreach ($xmlRoot->{'also-load-methods'}->{'also-load-method'} as $alsoLoadMethod) { + $metadata->registerAlsoLoadMethod((string) $alsoLoadMethod['method'], (string) $alsoLoadMethod['field']); + } } - foreach ($xmlRoot->{'also-load-methods'}->{'also-load-method'} as $alsoLoadMethod) { - $metadata->registerAlsoLoadMethod((string) $alsoLoadMethod['method'], (string) $alsoLoadMethod['field']); + if (isset($xmlRoot->{'time-series'})) { + $attributes = $xmlRoot->{'time-series'}->attributes(); + + $metaField = isset($attributes['meta-field']) ? (string) $attributes['meta-field'] : null; + $granularity = isset($attributes['granularity']) ? Granularity::from((string) $attributes['granularity']) : null; + $expireAfterSeconds = isset($attributes['expire-after-seconds']) ? (int) $attributes['expire-after-seconds'] : null; + $bucketMaxSpanSeconds = isset($attributes['bucket-max-span-seconds']) ? (int) $attributes['bucket-max-span-seconds'] : null; + $bucketRoundingSeconds = isset($attributes['bucket-rounding-seconds']) ? (int) $attributes['bucket-rounding-seconds'] : null; + + $metadata->markAsTimeSeries(new TimeSeries( + timeField: (string) $attributes['time-field'], + metaField: $metaField, + granularity: $granularity, + expireAfterSeconds: $expireAfterSeconds, + bucketMaxSpanSeconds: $bucketMaxSpanSeconds, + bucketRoundingSeconds: $bucketRoundingSeconds, + )); } } + // phpcs:enable SlevomatCodingStandard.ControlStructures.EarlyExit.EarlyExitNotUsed + /** * @param ClassMetadata $class * @phpstan-param FieldMappingConfig $mapping diff --git a/lib/Doctrine/ODM/MongoDB/Mapping/MappingException.php b/lib/Doctrine/ODM/MongoDB/Mapping/MappingException.php index 68438848c..e51e21c0a 100644 --- a/lib/Doctrine/ODM/MongoDB/Mapping/MappingException.php +++ b/lib/Doctrine/ODM/MongoDB/Mapping/MappingException.php @@ -296,4 +296,14 @@ public static function emptySearchIndexDefinition(string $className, string $ind { return new self(sprintf('%s search index "%s" must be dynamic or specify a field mapping', $className, $indexName)); } + + public static function timeSeriesFieldNotFound(string $className, string $fieldName, string $field): self + { + return new self(sprintf( + 'The %s field %s::%s was not found', + $field, + $className, + $fieldName, + )); + } } diff --git a/lib/Doctrine/ODM/MongoDB/Mapping/TimeSeries/Granularity.php b/lib/Doctrine/ODM/MongoDB/Mapping/TimeSeries/Granularity.php new file mode 100644 index 000000000..3727686f6 --- /dev/null +++ b/lib/Doctrine/ODM/MongoDB/Mapping/TimeSeries/Granularity.php @@ -0,0 +1,12 @@ +getValidationLevel(); } + if ($class->timeSeriesOptions !== null) { + $options['timeseries'] = array_filter( + [ + 'timeField' => $class->timeSeriesOptions->timeField, + 'metaField' => $class->timeSeriesOptions->metaField, + // ext-mongodb will automatically encode backed enums, so we can use the value directly here + 'granularity' => $class->timeSeriesOptions->granularity, + 'bucketMaxSpanSeconds' => $class->timeSeriesOptions->bucketMaxSpanSeconds, + 'bucketRoundingSeconds' => $class->timeSeriesOptions->bucketRoundingSeconds, + ], + static fn (mixed $value): bool => $value !== null, + ); + + if ($class->timeSeriesOptions->expireAfterSeconds) { + $options['expireAfterSeconds'] = $class->timeSeriesOptions->expireAfterSeconds; + } + } + $this->dm->getDocumentDatabase($documentName)->createCollection( $class->getCollection(), $this->getWriteOptions($maxTimeMs, $writeConcern, $options), diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 6e6aca3be..e2c6ab2d8 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -956,12 +956,12 @@ parameters: path: tests/Doctrine/ODM/MongoDB/Tests/Mapping/ClassMetadataLoadEventTest.php - - message: "#^Parameter \\#1 \\$mapping of method Doctrine\\\\ODM\\\\MongoDB\\\\Mapping\\\\ClassMetadata\\\\:\\:mapField\\(\\) expects array\\{type\\?\\: string, fieldName\\?\\: string, name\\?\\: string, strategy\\?\\: string, association\\?\\: int, id\\?\\: bool, isOwningSide\\?\\: bool, collectionClass\\?\\: class\\-string, \\.\\.\\.\\}, array\\{fieldName\\: 'enum', enumType\\: 'Documents\\\\\\\\Card'\\} given\\.$#" + message: "#^Parameter \\#1 \\$mapping of method Doctrine\\\\ODM\\\\MongoDB\\\\Mapping\\\\ClassMetadata\\\\:\\:mapField\\(\\) expects array\\{type\\?\\: string, fieldName\\?\\: string, name\\?\\: string, strategy\\?\\: string, association\\?\\: int, id\\?\\: bool, isOwningSide\\?\\: bool, collectionClass\\?\\: class\\-string, \\.\\.\\.\\}, array\\{fieldName\\: 'enum', enumType\\: 'Documents\\\\\\\\Card'\\} given\\.$#" count: 1 path: tests/Doctrine/ODM/MongoDB/Tests/Mapping/ClassMetadataTest.php - - message: "#^Parameter \\#1 \\$mapping of method Doctrine\\\\ODM\\\\MongoDB\\\\Mapping\\\\ClassMetadata\\\\:\\:mapField\\(\\) expects array\\{type\\?\\: string, fieldName\\?\\: string, name\\?\\: string, strategy\\?\\: string, association\\?\\: int, id\\?\\: bool, isOwningSide\\?\\: bool, collectionClass\\?\\: class\\-string, \\.\\.\\.\\}, array\\{fieldName\\: 'enum', enumType\\: 'Documents\\\\\\\\SuitNonBacked'\\} given\\.$#" + message: "#^Parameter \\#1 \\$mapping of method Doctrine\\\\ODM\\\\MongoDB\\\\Mapping\\\\ClassMetadata\\\\:\\:mapField\\(\\) expects array\\{type\\?\\: string, fieldName\\?\\: string, name\\?\\: string, strategy\\?\\: string, association\\?\\: int, id\\?\\: bool, isOwningSide\\?\\: bool, collectionClass\\?\\: class\\-string, \\.\\.\\.\\}, array\\{fieldName\\: 'enum', enumType\\: 'Documents\\\\\\\\SuitNonBacked'\\} given\\.$#" count: 1 path: tests/Doctrine/ODM/MongoDB/Tests/Mapping/ClassMetadataTest.php diff --git a/tests/Doctrine/ODM/MongoDB/Tests/Mapping/AbstractMappingDriverTestCase.php b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/AbstractMappingDriverTestCase.php index 1a20c1745..db287a771 100644 --- a/tests/Doctrine/ODM/MongoDB/Tests/Mapping/AbstractMappingDriverTestCase.php +++ b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/AbstractMappingDriverTestCase.php @@ -11,6 +11,7 @@ use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; use Doctrine\ODM\MongoDB\Mapping\ClassMetadata; use Doctrine\ODM\MongoDB\Mapping\MappingException; +use Doctrine\ODM\MongoDB\Mapping\TimeSeries\Granularity; use Doctrine\ODM\MongoDB\Repository\DefaultGridFSRepository; use Doctrine\ODM\MongoDB\Repository\DocumentRepository; use Doctrine\ODM\MongoDB\Repository\ViewRepository; @@ -677,6 +678,26 @@ public function testEnumType(): void self::assertTrue($metadata->fieldMappings['nullableSuit']['nullable']); self::assertInstanceOf(EnumReflectionProperty::class, $metadata->reflFields['nullableSuit']); } + + public function testTimeSeriesDocumentWithGranularity(): void + { + $metadata = $this->dm->getClassMetadata(AbstractMappingDriverTimeSeriesDocumentWithGranularity::class); + + self::assertEquals( + new ODM\TimeSeries('time', 'metadata', Granularity::Seconds, 86400), + $metadata->timeSeriesOptions, + ); + } + + public function testTimeSeriesDocumentWithBucket(): void + { + $metadata = $this->dm->getClassMetadata(AbstractMappingDriverTimeSeriesDocumentWithBucket::class); + + self::assertEquals( + new ODM\TimeSeries('time', 'metadata', expireAfterSeconds: 86400, bucketMaxSpanSeconds: 10, bucketRoundingSeconds: 15), + $metadata->timeSeriesOptions, + ); + } } /** @@ -1296,3 +1317,53 @@ public function createViewAggregation(Builder $builder): void ->includeFields(['name']); } } + +/** + * @ODM\Document(collection="cms_users", writeConcern=1, readOnly=true) + * @ODM\TimeSeries(timeField="time", metaField="metadata", granularity=Granularity::Seconds, expireAfterSeconds=86400) + */ +#[ODM\Document] +#[ODM\TimeSeries(timeField: 'time', metaField: 'metadata', granularity: Granularity::Seconds, expireAfterSeconds: 86400)] +class AbstractMappingDriverTimeSeriesDocumentWithGranularity +{ + /** @ODM\Id */ + #[ODM\Id] + public ?string $id = null; + + /** @ODM\Field(type="date") */ + #[ODM\Field(type: 'date')] + public DateTime $time; + + /** @ODM\Field */ + #[ODM\Field] + public string $metadata; + + /** @ODM\Field(type="int") */ + #[ODM\Field(type: 'int')] + public int $value; +} + +/** + * @ODM\Document(collection="cms_users", writeConcern=1, readOnly=true) + * @ODM\TimeSeries(timeField="time", metaField="metadata", expireAfterSeconds=86400, bucketMaxSpanSeconds=10, bucketRoundingSeconds=15) + */ +#[ODM\Document] +#[ODM\TimeSeries(timeField: 'time', metaField: 'metadata', expireAfterSeconds: 86400, bucketMaxSpanSeconds: 10, bucketRoundingSeconds: 15)] +class AbstractMappingDriverTimeSeriesDocumentWithBucket +{ + /** @ODM\Id */ + #[ODM\Id] + public ?string $id = null; + + /** @ODM\Field(type="date") */ + #[ODM\Field(type: 'date')] + public DateTime $time; + + /** @ODM\Field */ + #[ODM\Field] + public string $metadata; + + /** @ODM\Field(type="int") */ + #[ODM\Field(type: 'int')] + public int $value; +} diff --git a/tests/Doctrine/ODM/MongoDB/Tests/Mapping/ClassMetadataTest.php b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/ClassMetadataTest.php index cbf6d7fa7..c8f74769d 100644 --- a/tests/Doctrine/ODM/MongoDB/Tests/Mapping/ClassMetadataTest.php +++ b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/ClassMetadataTest.php @@ -4,10 +4,12 @@ namespace Doctrine\ODM\MongoDB\Tests\Mapping; +use DateTime; 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\Mapping\TimeSeries\Granularity; use Doctrine\ODM\MongoDB\Repository\DocumentRepository; use Doctrine\ODM\MongoDB\Tests\BaseTestCase; use Doctrine\ODM\MongoDB\Tests\ClassMetadataTestUtil; @@ -980,6 +982,63 @@ public function testEmptySearchIndexDefinition(): void $this->expectExceptionMessage('stdClass search index "default" must be dynamic or specify a field mapping'); $cm->addSearchIndex(['mappings' => []]); } + + public function testTimeSeriesMappingOnlyWithTimeField(): void + { + $metadata = $this->dm->getClassMetadata(TimeSeriesTestDocument::class); + $metadata->markAsTimeSeries(new ODM\TimeSeries('time')); + + self::assertNotNull($metadata->timeSeriesOptions); + self::assertSame('time', $metadata->timeSeriesOptions->timeField); + } + + public function testTimeSeriesMappingWithMissingTimeField(): void + { + $metadata = $this->dm->getClassMetadata(TimeSeriesTestDocument::class); + + self::expectExceptionObject(MappingException::timeSeriesFieldNotFound(TimeSeriesTestDocument::class, 'foo', 'time')); + $metadata->markAsTimeSeries(new ODM\TimeSeries('foo')); + } + + public function testTimeSeriesMappingWithMetadataField(): void + { + $metadata = $this->dm->getClassMetadata(TimeSeriesTestDocument::class); + $metadata->markAsTimeSeries(new ODM\TimeSeries('time', 'metadata')); + + self::assertNotNull($metadata->timeSeriesOptions); + self::assertSame('metadata', $metadata->timeSeriesOptions->metaField); + } + + public function testTimeSeriesMappingWithMissingMetadataField(): void + { + $metadata = $this->dm->getClassMetadata(TimeSeriesTestDocument::class); + + self::expectExceptionObject(MappingException::timeSeriesFieldNotFound(TimeSeriesTestDocument::class, 'foo', 'metadata')); + $metadata->markAsTimeSeries(new ODM\TimeSeries('time', 'foo')); + } + + public function testTimeSeriesMappingWithExpireAfterSeconds(): void + { + $metadata = $this->dm->getClassMetadata(TimeSeriesTestDocument::class); + $metadata->markAsTimeSeries(new ODM\TimeSeries('time', expireAfterSeconds: 10)); + + self::assertSame(10, $metadata->timeSeriesOptions->expireAfterSeconds); + } + + public function testTimeSeriesMappingWithGranularityAndBucketMaxSpanSeconds(): void + { + $metadata = $this->dm->getClassMetadata(TimeSeriesTestDocument::class); + $metadata->markAsTimeSeries(new ODM\TimeSeries('time', granularity: Granularity::Hours, bucketMaxSpanSeconds: 15, bucketRoundingSeconds: 20)); + + /* + * We don't throw for invalid settings here, including: + * - bucketMaxSpanSeconds not being equal to bucketRoundingSeconds + * - granularity and bucket settings applied together + */ + self::assertSame(Granularity::Hours, $metadata->timeSeriesOptions->granularity); + self::assertSame(15, $metadata->timeSeriesOptions->bucketMaxSpanSeconds); + self::assertSame(20, $metadata->timeSeriesOptions->bucketRoundingSeconds); + } } /** @template-extends DocumentRepository */ @@ -1008,3 +1067,16 @@ class EmbeddedAssociationsCascadeTest #[ODM\EmbedOne(targetDocument: Address::class)] public $addresses; } + +#[ODM\Document] +class TimeSeriesTestDocument +{ + #[ODM\Id] + public ?string $id = null; + + #[ODM\Field] + public DateTime $time; + + #[ODM\Field] + public string $metadata; +} diff --git a/tests/Doctrine/ODM/MongoDB/Tests/Mapping/xml/Doctrine.ODM.MongoDB.Tests.Mapping.AbstractMappingDriverTimeSeriesDocumentWithBucket.dcm.xml b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/xml/Doctrine.ODM.MongoDB.Tests.Mapping.AbstractMappingDriverTimeSeriesDocumentWithBucket.dcm.xml new file mode 100644 index 000000000..981a3b8dc --- /dev/null +++ b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/xml/Doctrine.ODM.MongoDB.Tests.Mapping.AbstractMappingDriverTimeSeriesDocumentWithBucket.dcm.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + diff --git a/tests/Doctrine/ODM/MongoDB/Tests/Mapping/xml/Doctrine.ODM.MongoDB.Tests.Mapping.AbstractMappingDriverTimeSeriesDocumentWithGranularity.dcm.xml b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/xml/Doctrine.ODM.MongoDB.Tests.Mapping.AbstractMappingDriverTimeSeriesDocumentWithGranularity.dcm.xml new file mode 100644 index 000000000..6cb5fddb7 --- /dev/null +++ b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/xml/Doctrine.ODM.MongoDB.Tests.Mapping.AbstractMappingDriverTimeSeriesDocumentWithGranularity.dcm.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + diff --git a/tests/Doctrine/ODM/MongoDB/Tests/SchemaManagerTest.php b/tests/Doctrine/ODM/MongoDB/Tests/SchemaManagerTest.php index cdd3cdf8e..c532a8bd8 100644 --- a/tests/Doctrine/ODM/MongoDB/Tests/SchemaManagerTest.php +++ b/tests/Doctrine/ODM/MongoDB/Tests/SchemaManagerTest.php @@ -8,6 +8,7 @@ use Doctrine\Common\EventManager; use Doctrine\ODM\MongoDB\DocumentManager; use Doctrine\ODM\MongoDB\Mapping\ClassMetadata; +use Doctrine\ODM\MongoDB\Mapping\TimeSeries\Granularity; use Doctrine\ODM\MongoDB\SchemaManager; use Documents\BaseDocument; use Documents\CmsAddress; @@ -20,6 +21,7 @@ use Documents\Sharded\ShardedOne; use Documents\Sharded\ShardedOneWithDifferentKey; use Documents\SimpleReferenceUser; +use Documents\TimeSeries\TimeSeriesDocument; use Documents\Tournament\Tournament; use Documents\UserName; use InvalidArgumentException; @@ -762,6 +764,33 @@ public function testCreateView(array $expectedWriteOptions, ?int $maxTimeMs, ?Wr /** @phpstan-param IndexOptions $expectedWriteOptions */ #[DataProvider('getWriteOptions')] + public function testCreateTimeSeriesCollection(array $expectedWriteOptions, ?int $maxTimeMs, ?WriteConcern $writeConcern): void + { + $metadata = $this->dm->getClassMetadata(TimeSeriesDocument::class); + + $options = [ + 'timeseries' => [ + 'timeField' => 'time', + 'metaField' => 'metadata', + 'granularity' => Granularity::Seconds, + ], + 'expireAfterSeconds' => 86400, + ]; + + $database = $this->documentDatabases[$this->getDatabaseName($metadata)]; + $database + ->expects($this->once()) + ->method('createCollection') + ->with( + 'TimeSeriesDocument', + $this->writeOptions($options + $expectedWriteOptions), + ); + + $this->schemaManager->createDocumentCollection(TimeSeriesDocument::class, $maxTimeMs, $writeConcern); + } + + /** @psalm-param IndexOptions $expectedWriteOptions */ + #[DataProvider('getWriteOptions')] public function testCreateCollections(array $expectedWriteOptions, ?int $maxTimeMs, ?WriteConcern $writeConcern): void { $class = $this->dm->getClassMetadata(Tournament::class); diff --git a/tests/Documents/TimeSeries/TimeSeriesDocument.php b/tests/Documents/TimeSeries/TimeSeriesDocument.php new file mode 100644 index 000000000..74426a414 --- /dev/null +++ b/tests/Documents/TimeSeries/TimeSeriesDocument.php @@ -0,0 +1,26 @@ +