From 7c6179386d85345ff430b2ac2f962f5b9d863527 Mon Sep 17 00:00:00 2001 From: Andreas Braun Date: Thu, 10 Oct 2024 09:59:09 +0200 Subject: [PATCH 01/16] Store metadata information for time series collections --- doctrine-mongo-mapping.xsd | 16 +++++ .../Mapping/Annotations/TimeSeries.php | 27 +++++++ .../ODM/MongoDB/Mapping/ClassMetadata.php | 28 ++++++++ .../Mapping/Driver/AttributeDriver.php | 7 ++ .../ODM/MongoDB/Mapping/Driver/XmlDriver.php | 31 ++++++-- .../ODM/MongoDB/Mapping/MappingException.php | 10 +++ .../Mapping/TimeSeries/Granularity.php | 12 ++++ phpstan-baseline.neon | 4 +- .../Mapping/AbstractMappingDriverTestCase.php | 37 ++++++++++ .../Tests/Mapping/ClassMetadataTest.php | 72 +++++++++++++++++++ ...actMappingDriverTimeSeriesDocument.dcm.xml | 15 ++++ 11 files changed, 253 insertions(+), 6 deletions(-) create mode 100644 lib/Doctrine/ODM/MongoDB/Mapping/Annotations/TimeSeries.php create mode 100644 lib/Doctrine/ODM/MongoDB/Mapping/TimeSeries/Granularity.php create mode 100644 tests/Doctrine/ODM/MongoDB/Tests/Mapping/xml/Doctrine.ODM.MongoDB.Tests.Mapping.AbstractMappingDriverTimeSeriesDocument.dcm.xml diff --git a/doctrine-mongo-mapping.xsd b/doctrine-mongo-mapping.xsd index e460afc95..5e7dd9f17 100644 --- a/doctrine-mongo-mapping.xsd +++ b/doctrine-mongo-mapping.xsd @@ -104,6 +104,7 @@ + @@ -634,4 +635,19 @@ + + + + + + + + + + + + + + + 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..6133ff09f --- /dev/null +++ b/lib/Doctrine/ODM/MongoDB/Mapping/Annotations/TimeSeries.php @@ -0,0 +1,27 @@ +rootClass = $rootClass; } + public function markAsTimeSeries(TimeSeries $options): void + { + $this->validateTimeSeriesOptions($options); + + $this->isTimeSeries = true; + $this->timeSeriesOptions = $options; + } + public function getFieldNames(): array { return array_keys($this->fieldMappings); @@ -2527,6 +2542,8 @@ public function __sleep() 'idGenerator', 'indexes', 'shardKey', + 'isTimeSeries', + 'timeSeriesOptions', ]; // The rest of the metadata is only serialized if necessary. @@ -2758,4 +2775,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 && ! $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..9bc7773b4 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; @@ -335,13 +337,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'})) { + return; } + + $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; + + $metadata->markAsTimeSeries(new TimeSeries( + timeField: (string) $attributes['time-field'], + metaField: $metaField, + granularity: $granularity, + expireAfterSeconds: $expireAfterSeconds, + )); } /** 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 @@ +\\:\\: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..e27cbd121 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,17 @@ public function testEnumType(): void self::assertTrue($metadata->fieldMappings['nullableSuit']['nullable']); self::assertInstanceOf(EnumReflectionProperty::class, $metadata->reflFields['nullableSuit']); } + + public function testTimeSeriesDocument(): void + { + $metadata = $this->dm->getClassMetadata(AbstractMappingDriverTimeSeriesDocument::class); + + self::assertTrue($metadata->isTimeSeries); + self::assertEquals( + new ODM\TimeSeries('time', 'metadata', Granularity::Seconds, 86400), + $metadata->timeSeriesOptions, + ); + } } /** @@ -1296,3 +1308,28 @@ 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('time', 'metadata', Granularity::Seconds, 86400)] +class AbstractMappingDriverTimeSeriesDocument +{ + /** @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..b54e03f5d 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 testDefaultTimeSeriesMapping(): void + { + $metadata = $this->dm->getClassMetadata(TimeSeriesTestDocument::class); + + self::assertFalse($metadata->isTimeSeries); + } + + public function testTimeSeriesMappingOnlyWithTimeField(): void + { + $metadata = $this->dm->getClassMetadata(TimeSeriesTestDocument::class); + $metadata->markAsTimeSeries(new ODM\TimeSeries('time')); + + self::assertTrue($metadata->isTimeSeries); + 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::assertTrue($metadata->isTimeSeries); + 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 testTimeSeriesMappingWithGranularity(): void + { + $metadata = $this->dm->getClassMetadata(TimeSeriesTestDocument::class); + $metadata->markAsTimeSeries(new ODM\TimeSeries('time', granularity: Granularity::Seconds)); + + self::assertSame(Granularity::Seconds, $metadata->timeSeriesOptions->granularity); + } + + public function testTimeSeriesMappingWithExpireAfterSeconds(): void + { + $metadata = $this->dm->getClassMetadata(TimeSeriesTestDocument::class); + $metadata->markAsTimeSeries(new ODM\TimeSeries('time', expireAfterSeconds: 10)); + + self::assertSame(10, $metadata->timeSeriesOptions->expireAfterSeconds); + } } /** @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.AbstractMappingDriverTimeSeriesDocument.dcm.xml b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/xml/Doctrine.ODM.MongoDB.Tests.Mapping.AbstractMappingDriverTimeSeriesDocument.dcm.xml new file mode 100644 index 000000000..017627c60 --- /dev/null +++ b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/xml/Doctrine.ODM.MongoDB.Tests.Mapping.AbstractMappingDriverTimeSeriesDocument.dcm.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + From a92a37038aaa7fb2558044d87cc8cf4727be2723 Mon Sep 17 00:00:00 2001 From: Andreas Braun Date: Thu, 10 Oct 2024 10:29:49 +0200 Subject: [PATCH 02/16] Specify time series options when creating collection --- lib/Doctrine/ODM/MongoDB/SchemaManager.php | 14 ++++++++++ .../ODM/MongoDB/Tests/SchemaManagerTest.php | 28 +++++++++++++++++++ .../TimeSeries/TimeSeriesDocument.php | 26 +++++++++++++++++ 3 files changed, 68 insertions(+) create mode 100644 tests/Documents/TimeSeries/TimeSeriesDocument.php diff --git a/lib/Doctrine/ODM/MongoDB/SchemaManager.php b/lib/Doctrine/ODM/MongoDB/SchemaManager.php index 63a7bd7c7..81ea9e3db 100644 --- a/lib/Doctrine/ODM/MongoDB/SchemaManager.php +++ b/lib/Doctrine/ODM/MongoDB/SchemaManager.php @@ -621,6 +621,20 @@ public function createDocumentCollection(string $documentName, ?int $maxTimeMs = $options['validationLevel'] = $class->getValidationLevel(); } + if ($class->isTimeSeries) { + $options['timeseries'] = array_filter( + [ + 'timeField' => $class->timeSeriesOptions->timeField, + 'metaField' => $class->timeSeriesOptions->metaField, + 'granularity' => $class->timeSeriesOptions->granularity?->value, + ], + ); + + if ($class->timeSeriesOptions->expireAfterSeconds) { + $options['expireAfterSeconds'] = $class->timeSeriesOptions->expireAfterSeconds; + } + } + $this->dm->getDocumentDatabase($documentName)->createCollection( $class->getCollection(), $this->getWriteOptions($maxTimeMs, $writeConcern, $options), diff --git a/tests/Doctrine/ODM/MongoDB/Tests/SchemaManagerTest.php b/tests/Doctrine/ODM/MongoDB/Tests/SchemaManagerTest.php index cdd3cdf8e..75913468c 100644 --- a/tests/Doctrine/ODM/MongoDB/Tests/SchemaManagerTest.php +++ b/tests/Doctrine/ODM/MongoDB/Tests/SchemaManagerTest.php @@ -20,6 +20,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 +763,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' => '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..ecdc0eb49 --- /dev/null +++ b/tests/Documents/TimeSeries/TimeSeriesDocument.php @@ -0,0 +1,26 @@ + Date: Wed, 16 Oct 2024 09:12:37 +0200 Subject: [PATCH 03/16] Use named arguments for attributes --- .../ODM/MongoDB/Tests/Mapping/AbstractMappingDriverTestCase.php | 2 +- tests/Documents/TimeSeries/TimeSeriesDocument.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Doctrine/ODM/MongoDB/Tests/Mapping/AbstractMappingDriverTestCase.php b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/AbstractMappingDriverTestCase.php index e27cbd121..241984983 100644 --- a/tests/Doctrine/ODM/MongoDB/Tests/Mapping/AbstractMappingDriverTestCase.php +++ b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/AbstractMappingDriverTestCase.php @@ -1314,7 +1314,7 @@ public function createViewAggregation(Builder $builder): void * @ODM\TimeSeries(timeField="time", metaField="metadata", granularity=Granularity::Seconds, expireAfterSeconds=86400) */ #[ODM\Document] -#[ODM\TimeSeries('time', 'metadata', Granularity::Seconds, 86400)] +#[ODM\TimeSeries(timeField: 'time', metaField: 'metadata', granularity: Granularity::Seconds, expireAfterSeconds: 86400)] class AbstractMappingDriverTimeSeriesDocument { /** @ODM\Id */ diff --git a/tests/Documents/TimeSeries/TimeSeriesDocument.php b/tests/Documents/TimeSeries/TimeSeriesDocument.php index ecdc0eb49..74426a414 100644 --- a/tests/Documents/TimeSeries/TimeSeriesDocument.php +++ b/tests/Documents/TimeSeries/TimeSeriesDocument.php @@ -9,7 +9,7 @@ use Doctrine\ODM\MongoDB\Mapping\TimeSeries\Granularity; #[ODM\Document] -#[ODM\TimeSeries('time', 'metadata', Granularity::Seconds, 86400)] +#[ODM\TimeSeries(timeField: 'time', metaField: 'metadata', granularity: Granularity::Seconds, expireAfterSeconds: 86400)] class TimeSeriesDocument { #[ODM\Id] From 1a15a995853b67d33143cf1ebf33982265e9c9b7 Mon Sep 17 00:00:00 2001 From: Andreas Braun Date: Wed, 16 Oct 2024 09:18:53 +0200 Subject: [PATCH 04/16] Don't skip empty metadata field names --- lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php b/lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php index 270f9d6bd..465c4cfcc 100644 --- a/lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php +++ b/lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php @@ -2782,7 +2782,7 @@ private function validateTimeSeriesOptions(TimeSeries $options): void throw MappingException::timeSeriesFieldNotFound($this->name, $options->timeField, 'time'); } - if ($options->metaField && ! $this->hasField($options->metaField)) { + if ($options->metaField !== null && ! $this->hasField($options->metaField)) { throw MappingException::timeSeriesFieldNotFound($this->name, $options->metaField, 'metadata'); } } From b60936434d9f531ef14bddf995d5662508108b12 Mon Sep 17 00:00:00 2001 From: Andreas Braun Date: Wed, 16 Oct 2024 09:19:03 +0200 Subject: [PATCH 05/16] Leave encoding of enum values to the MongoDB driver --- lib/Doctrine/ODM/MongoDB/SchemaManager.php | 3 ++- tests/Doctrine/ODM/MongoDB/Tests/SchemaManagerTest.php | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/Doctrine/ODM/MongoDB/SchemaManager.php b/lib/Doctrine/ODM/MongoDB/SchemaManager.php index 81ea9e3db..ef5dc462c 100644 --- a/lib/Doctrine/ODM/MongoDB/SchemaManager.php +++ b/lib/Doctrine/ODM/MongoDB/SchemaManager.php @@ -626,7 +626,8 @@ public function createDocumentCollection(string $documentName, ?int $maxTimeMs = [ 'timeField' => $class->timeSeriesOptions->timeField, 'metaField' => $class->timeSeriesOptions->metaField, - 'granularity' => $class->timeSeriesOptions->granularity?->value, + // ext-mongodb will automatically encode backed enums, so we can use the value directly here + 'granularity' => $class->timeSeriesOptions->granularity, ], ); diff --git a/tests/Doctrine/ODM/MongoDB/Tests/SchemaManagerTest.php b/tests/Doctrine/ODM/MongoDB/Tests/SchemaManagerTest.php index 75913468c..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; @@ -771,7 +772,7 @@ public function testCreateTimeSeriesCollection(array $expectedWriteOptions, ?int 'timeseries' => [ 'timeField' => 'time', 'metaField' => 'metadata', - 'granularity' => 'seconds', + 'granularity' => Granularity::Seconds, ], 'expireAfterSeconds' => 86400, ]; From bfaa88d7393e9c41ae913fa2896838899dc03796 Mon Sep 17 00:00:00 2001 From: Andreas Braun Date: Wed, 16 Oct 2024 09:20:09 +0200 Subject: [PATCH 06/16] Remove unused isTimeSeries option --- lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php | 5 ----- lib/Doctrine/ODM/MongoDB/SchemaManager.php | 2 +- .../MongoDB/Tests/Mapping/AbstractMappingDriverTestCase.php | 1 - .../ODM/MongoDB/Tests/Mapping/ClassMetadataTest.php | 6 +++--- 4 files changed, 4 insertions(+), 10 deletions(-) diff --git a/lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php b/lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php index 465c4cfcc..85dddf148 100644 --- a/lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php +++ b/lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php @@ -800,9 +800,6 @@ */ public $isReadOnly; - /** READ ONLY: indicates whether the collection is a time series collection */ - public bool $isTimeSeries = false; - /** READ ONLY: stores metadata about the time series collection */ public ?TimeSeries $timeSeriesOptions = null; @@ -2185,7 +2182,6 @@ public function markAsTimeSeries(TimeSeries $options): void { $this->validateTimeSeriesOptions($options); - $this->isTimeSeries = true; $this->timeSeriesOptions = $options; } @@ -2542,7 +2538,6 @@ public function __sleep() 'idGenerator', 'indexes', 'shardKey', - 'isTimeSeries', 'timeSeriesOptions', ]; diff --git a/lib/Doctrine/ODM/MongoDB/SchemaManager.php b/lib/Doctrine/ODM/MongoDB/SchemaManager.php index ef5dc462c..0cf8bc7b9 100644 --- a/lib/Doctrine/ODM/MongoDB/SchemaManager.php +++ b/lib/Doctrine/ODM/MongoDB/SchemaManager.php @@ -621,7 +621,7 @@ public function createDocumentCollection(string $documentName, ?int $maxTimeMs = $options['validationLevel'] = $class->getValidationLevel(); } - if ($class->isTimeSeries) { + if ($class->timeSeriesOptions !== null) { $options['timeseries'] = array_filter( [ 'timeField' => $class->timeSeriesOptions->timeField, diff --git a/tests/Doctrine/ODM/MongoDB/Tests/Mapping/AbstractMappingDriverTestCase.php b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/AbstractMappingDriverTestCase.php index 241984983..1860295dd 100644 --- a/tests/Doctrine/ODM/MongoDB/Tests/Mapping/AbstractMappingDriverTestCase.php +++ b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/AbstractMappingDriverTestCase.php @@ -683,7 +683,6 @@ public function testTimeSeriesDocument(): void { $metadata = $this->dm->getClassMetadata(AbstractMappingDriverTimeSeriesDocument::class); - self::assertTrue($metadata->isTimeSeries); self::assertEquals( new ODM\TimeSeries('time', 'metadata', Granularity::Seconds, 86400), $metadata->timeSeriesOptions, diff --git a/tests/Doctrine/ODM/MongoDB/Tests/Mapping/ClassMetadataTest.php b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/ClassMetadataTest.php index b54e03f5d..0718c301a 100644 --- a/tests/Doctrine/ODM/MongoDB/Tests/Mapping/ClassMetadataTest.php +++ b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/ClassMetadataTest.php @@ -987,7 +987,7 @@ public function testDefaultTimeSeriesMapping(): void { $metadata = $this->dm->getClassMetadata(TimeSeriesTestDocument::class); - self::assertFalse($metadata->isTimeSeries); + self::assertNull($metadata->timeSeriesOptions); } public function testTimeSeriesMappingOnlyWithTimeField(): void @@ -995,7 +995,7 @@ public function testTimeSeriesMappingOnlyWithTimeField(): void $metadata = $this->dm->getClassMetadata(TimeSeriesTestDocument::class); $metadata->markAsTimeSeries(new ODM\TimeSeries('time')); - self::assertTrue($metadata->isTimeSeries); + self::assertNotNull($metadata->timeSeriesOptions); self::assertSame('time', $metadata->timeSeriesOptions->timeField); } @@ -1012,7 +1012,7 @@ public function testTimeSeriesMappingWithMetadataField(): void $metadata = $this->dm->getClassMetadata(TimeSeriesTestDocument::class); $metadata->markAsTimeSeries(new ODM\TimeSeries('time', 'metadata')); - self::assertTrue($metadata->isTimeSeries); + self::assertNotNull($metadata->timeSeriesOptions); self::assertSame('metadata', $metadata->timeSeriesOptions->metaField); } From 8655f57f14784d31c5304e0473912c9b85edeb79 Mon Sep 17 00:00:00 2001 From: Andreas Braun Date: Wed, 16 Oct 2024 10:03:16 +0200 Subject: [PATCH 07/16] Disable early exit requirement in XML driver --- .../ODM/MongoDB/Mapping/Driver/XmlDriver.php | 43 ++++++++++--------- 1 file changed, 22 insertions(+), 21 deletions(-) diff --git a/lib/Doctrine/ODM/MongoDB/Mapping/Driver/XmlDriver.php b/lib/Doctrine/ODM/MongoDB/Mapping/Driver/XmlDriver.php index 9bc7773b4..de31aa475 100644 --- a/lib/Doctrine/ODM/MongoDB/Mapping/Driver/XmlDriver.php +++ b/lib/Doctrine/ODM/MongoDB/Mapping/Driver/XmlDriver.php @@ -80,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); @@ -343,30 +344,30 @@ public function loadMetadataForClass($className, \Doctrine\Persistence\Mapping\C } } - if (! isset($xmlRoot->{'time-series'})) { - return; - } + 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; - $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; - - $metadata->markAsTimeSeries(new TimeSeries( - timeField: (string) $attributes['time-field'], - metaField: $metaField, - granularity: $granularity, - expireAfterSeconds: $expireAfterSeconds, - )); + $metadata->markAsTimeSeries(new TimeSeries( + timeField: (string) $attributes['time-field'], + metaField: $metaField, + granularity: $granularity, + expireAfterSeconds: $expireAfterSeconds, + )); + } } + // phpcs:enable SlevomatCodingStandard.ControlStructures.EarlyExit.EarlyExitNotUsed + /** * @param ClassMetadata $class * @phpstan-param FieldMappingConfig $mapping From 945652a6a1b5b7c0484b734e8ded83ba0c997a33 Mon Sep 17 00:00:00 2001 From: Andreas Braun Date: Wed, 16 Oct 2024 10:05:11 +0200 Subject: [PATCH 08/16] Use explicit closure instead of empty() checks --- lib/Doctrine/ODM/MongoDB/SchemaManager.php | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/Doctrine/ODM/MongoDB/SchemaManager.php b/lib/Doctrine/ODM/MongoDB/SchemaManager.php index 0cf8bc7b9..0bf49ee41 100644 --- a/lib/Doctrine/ODM/MongoDB/SchemaManager.php +++ b/lib/Doctrine/ODM/MongoDB/SchemaManager.php @@ -629,6 +629,7 @@ public function createDocumentCollection(string $documentName, ?int $maxTimeMs = // ext-mongodb will automatically encode backed enums, so we can use the value directly here 'granularity' => $class->timeSeriesOptions->granularity, ], + static fn (mixed $value): bool => $value !== null, ); if ($class->timeSeriesOptions->expireAfterSeconds) { From 73d5787d40bcf4c35d41a91b326ecfa6a35bf213 Mon Sep 17 00:00:00 2001 From: Andreas Braun Date: Wed, 16 Oct 2024 10:15:36 +0200 Subject: [PATCH 09/16] Support bucketMaxSpanSeconds and bucketRoundingSeconds in time series collections --- doctrine-mongo-mapping.xsd | 9 ++++++ .../Mapping/Annotations/TimeSeries.php | 2 ++ lib/Doctrine/ODM/MongoDB/SchemaManager.php | 2 ++ .../Tests/Mapping/ClassMetadataTest.php | 29 +++++++++++++++++++ 4 files changed, 42 insertions(+) diff --git a/doctrine-mongo-mapping.xsd b/doctrine-mongo-mapping.xsd index 5e7dd9f17..90d3c8fde 100644 --- a/doctrine-mongo-mapping.xsd +++ b/doctrine-mongo-mapping.xsd @@ -640,6 +640,8 @@ + + @@ -650,4 +652,11 @@ + + + + + + + diff --git a/lib/Doctrine/ODM/MongoDB/Mapping/Annotations/TimeSeries.php b/lib/Doctrine/ODM/MongoDB/Mapping/Annotations/TimeSeries.php index 6133ff09f..e4fe593eb 100644 --- a/lib/Doctrine/ODM/MongoDB/Mapping/Annotations/TimeSeries.php +++ b/lib/Doctrine/ODM/MongoDB/Mapping/Annotations/TimeSeries.php @@ -22,6 +22,8 @@ public function __construct( public readonly ?string $metaField = null, public readonly ?Granularity $granularity = null, public readonly ?int $expireAfterSeconds = null, + public readonly ?int $bucketMaxSpanSeconds = null, + public readonly ?int $bucketRoundingSeconds = null, ) { } } diff --git a/lib/Doctrine/ODM/MongoDB/SchemaManager.php b/lib/Doctrine/ODM/MongoDB/SchemaManager.php index 0bf49ee41..ae6cd510e 100644 --- a/lib/Doctrine/ODM/MongoDB/SchemaManager.php +++ b/lib/Doctrine/ODM/MongoDB/SchemaManager.php @@ -628,6 +628,8 @@ public function createDocumentCollection(string $documentName, ?int $maxTimeMs = '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, ); diff --git a/tests/Doctrine/ODM/MongoDB/Tests/Mapping/ClassMetadataTest.php b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/ClassMetadataTest.php index 0718c301a..af7908750 100644 --- a/tests/Doctrine/ODM/MongoDB/Tests/Mapping/ClassMetadataTest.php +++ b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/ClassMetadataTest.php @@ -1039,6 +1039,35 @@ public function testTimeSeriesMappingWithExpireAfterSeconds(): void self::assertSame(10, $metadata->timeSeriesOptions->expireAfterSeconds); } + + public function testTimeSeriesMappingWithBucketMaxSpanSeconds(): void + { + $metadata = $this->dm->getClassMetadata(TimeSeriesTestDocument::class); + $metadata->markAsTimeSeries(new ODM\TimeSeries('time', bucketMaxSpanSeconds: 10)); + + // We don't throw for invalid settings here, e.g. bucketMaxSpanSeconds not being equal to bucketRoundingSeconds + self::assertSame(10, $metadata->timeSeriesOptions->bucketMaxSpanSeconds); + } + + public function testTimeSeriesMappingWithBucketRoundingSeconds(): void + { + $metadata = $this->dm->getClassMetadata(TimeSeriesTestDocument::class); + $metadata->markAsTimeSeries(new ODM\TimeSeries('time', bucketRoundingSeconds: 10)); + + // We don't throw for invalid settings here, e.g. bucketMaxSpanSeconds not being equal to bucketRoundingSeconds + self::assertSame(10, $metadata->timeSeriesOptions->bucketRoundingSeconds); + } + + 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, e.g. bucketMaxSpanSeconds not being equal to bucketRoundingSeconds + self::assertSame(Granularity::Hours, $metadata->timeSeriesOptions->granularity); + self::assertSame(15, $metadata->timeSeriesOptions->bucketMaxSpanSeconds); + self::assertSame(20, $metadata->timeSeriesOptions->bucketRoundingSeconds); + } } /** @template-extends DocumentRepository */ From 784bd6430e4b9c47469e63754c3aec4ac16cea19 Mon Sep 17 00:00:00 2001 From: Andreas Braun Date: Thu, 17 Oct 2024 09:42:08 +0200 Subject: [PATCH 10/16] Read bucket options for time series in XML driver --- .../ODM/MongoDB/Mapping/Driver/XmlDriver.php | 16 ++++---- .../Mapping/AbstractMappingDriverTestCase.php | 41 +++++++++++++++++-- ...DriverTimeSeriesDocumentWithBucket.dcm.xml | 15 +++++++ ...TimeSeriesDocumentWithGranularity.dcm.xml} | 2 +- 4 files changed, 61 insertions(+), 13 deletions(-) create mode 100644 tests/Doctrine/ODM/MongoDB/Tests/Mapping/xml/Doctrine.ODM.MongoDB.Tests.Mapping.AbstractMappingDriverTimeSeriesDocumentWithBucket.dcm.xml rename tests/Doctrine/ODM/MongoDB/Tests/Mapping/xml/{Doctrine.ODM.MongoDB.Tests.Mapping.AbstractMappingDriverTimeSeriesDocument.dcm.xml => Doctrine.ODM.MongoDB.Tests.Mapping.AbstractMappingDriverTimeSeriesDocumentWithGranularity.dcm.xml} (93%) diff --git a/lib/Doctrine/ODM/MongoDB/Mapping/Driver/XmlDriver.php b/lib/Doctrine/ODM/MongoDB/Mapping/Driver/XmlDriver.php index de31aa475..8b093dd2b 100644 --- a/lib/Doctrine/ODM/MongoDB/Mapping/Driver/XmlDriver.php +++ b/lib/Doctrine/ODM/MongoDB/Mapping/Driver/XmlDriver.php @@ -347,21 +347,19 @@ public function loadMetadataForClass($className, \Doctrine\Persistence\Mapping\C 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; + $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, )); } } diff --git a/tests/Doctrine/ODM/MongoDB/Tests/Mapping/AbstractMappingDriverTestCase.php b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/AbstractMappingDriverTestCase.php index 1860295dd..db287a771 100644 --- a/tests/Doctrine/ODM/MongoDB/Tests/Mapping/AbstractMappingDriverTestCase.php +++ b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/AbstractMappingDriverTestCase.php @@ -679,15 +679,25 @@ public function testEnumType(): void self::assertInstanceOf(EnumReflectionProperty::class, $metadata->reflFields['nullableSuit']); } - public function testTimeSeriesDocument(): void + public function testTimeSeriesDocumentWithGranularity(): void { - $metadata = $this->dm->getClassMetadata(AbstractMappingDriverTimeSeriesDocument::class); + $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, + ); + } } /** @@ -1314,7 +1324,32 @@ public function createViewAggregation(Builder $builder): void */ #[ODM\Document] #[ODM\TimeSeries(timeField: 'time', metaField: 'metadata', granularity: Granularity::Seconds, expireAfterSeconds: 86400)] -class AbstractMappingDriverTimeSeriesDocument +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] 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.AbstractMappingDriverTimeSeriesDocument.dcm.xml b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/xml/Doctrine.ODM.MongoDB.Tests.Mapping.AbstractMappingDriverTimeSeriesDocumentWithGranularity.dcm.xml similarity index 93% rename from tests/Doctrine/ODM/MongoDB/Tests/Mapping/xml/Doctrine.ODM.MongoDB.Tests.Mapping.AbstractMappingDriverTimeSeriesDocument.dcm.xml rename to tests/Doctrine/ODM/MongoDB/Tests/Mapping/xml/Doctrine.ODM.MongoDB.Tests.Mapping.AbstractMappingDriverTimeSeriesDocumentWithGranularity.dcm.xml index 017627c60..6cb5fddb7 100644 --- a/tests/Doctrine/ODM/MongoDB/Tests/Mapping/xml/Doctrine.ODM.MongoDB.Tests.Mapping.AbstractMappingDriverTimeSeriesDocument.dcm.xml +++ b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/xml/Doctrine.ODM.MongoDB.Tests.Mapping.AbstractMappingDriverTimeSeriesDocumentWithGranularity.dcm.xml @@ -5,7 +5,7 @@ xsi:schemaLocation="http://doctrine-project.org/schemas/odm/doctrine-mongo-mapping http://doctrine-project.org/schemas/odm/doctrine-mongo-mapping.xsd"> - + From 61c038398112f47bd4e3bb9ff5ba80903b536487 Mon Sep 17 00:00:00 2001 From: Andreas Braun Date: Thu, 17 Oct 2024 09:56:10 +0200 Subject: [PATCH 11/16] Add attribute documentation for time series collections --- docs/en/reference/attributes-reference.rst | 58 ++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/docs/en/reference/attributes-reference.rst b/docs/en/reference/attributes-reference.rst index b6d8a616e..0ae0cf67c 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 +`time-series data `__. + +.. code-block:: php + + Date: Thu, 17 Oct 2024 10:46:39 +0200 Subject: [PATCH 12/16] Add cookbook entry for time series data --- docs/en/cookbook/time-series-data.rst | 123 ++++++++++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 docs/en/cookbook/time-series-data.rst diff --git a/docs/en/cookbook/time-series-data.rst b/docs/en/cookbook/time-series-data.rst new file mode 100644 index 000000000..8dbe83d58 --- /dev/null +++ b/docs/en/cookbook/time-series-data.rst @@ -0,0 +1,123 @@ +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. + +.. 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 temperature sensors. Other +examples for time series include stock data, price information, website visitors, +and 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. This is done using the +``TimeSeries`` attribute. Since we'll be storing data from multiple sensors, we +store the ID of each sensor as metadata. We only use the temperature as a +measurement, but we could also add additional sensors. With that in mind, we can +add the ``TimeSeries`` attribute: + +.. code-block:: php + + persist($measurement); + $documentManager->flush(); + +Note that other functionality, such as querying, aggregating data using +aggregation pipeline, 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 only store them with a ``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 `__. From 335923244ceb5cb90cf77fc8aaa24f6c5414ef6a Mon Sep 17 00:00:00 2001 From: Andreas Braun Date: Fri, 18 Oct 2024 09:09:04 +0200 Subject: [PATCH 13/16] Update documentation links --- docs/en/cookbook/time-series-data.rst | 5 +++-- docs/en/reference/attributes-reference.rst | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/en/cookbook/time-series-data.rst b/docs/en/cookbook/time-series-data.rst index 8dbe83d58..34627222d 100644 --- a/docs/en/cookbook/time-series-data.rst +++ b/docs/en/cookbook/time-series-data.rst @@ -5,8 +5,9 @@ Storing Time Series Data 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 a sequence of data points in which insights are gained by analyzing changes +over time. Time series data is generally composed of these components: diff --git a/docs/en/reference/attributes-reference.rst b/docs/en/reference/attributes-reference.rst index 0ae0cf67c..dedaf906a 100644 --- a/docs/en/reference/attributes-reference.rst +++ b/docs/en/reference/attributes-reference.rst @@ -1148,7 +1148,7 @@ for sharding the document collection. ------------- This attribute may be used at the class level to mark a collection as containing -`time-series data `__. +:doc:`time-series data <../cookbook/time-series-data>`. .. code-block:: php From 0420682acf4cfe8b9b308719b94014833ed812ed Mon Sep 17 00:00:00 2001 From: Andreas Braun Date: Fri, 18 Oct 2024 09:15:21 +0200 Subject: [PATCH 14/16] Expand time series cookbook to use multiple measurements --- docs/en/cookbook/time-series-data.rst | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/docs/en/cookbook/time-series-data.rst b/docs/en/cookbook/time-series-data.rst index 34627222d..539c01b52 100644 --- a/docs/en/cookbook/time-series-data.rst +++ b/docs/en/cookbook/time-series-data.rst @@ -21,6 +21,11 @@ Time series data is generally composed of these components: - 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 @@ -30,9 +35,10 @@ Time series data is generally composed of these components: Creating The Model ------------------ -For this example, we'll be storing data from multiple temperature sensors. Other -examples for time series include stock data, price information, website visitors, -and vehicle telemetry (speed, position, etc.). +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: @@ -57,6 +63,8 @@ First, we define the model for our data: public int $sensorId, #[ODM\Field(type: 'float')] public float $temperature, + #[ODM\Field(type: 'float')] + public float $humidity, ) { $this->id = (string) new ObjectId(); } @@ -66,11 +74,10 @@ 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. This is done using the -``TimeSeries`` attribute. Since we'll be storing data from multiple sensors, we -store the ID of each sensor as metadata. We only use the temperature as a -measurement, but we could also add additional sensors. With that in mind, we can -add the ``TimeSeries`` attribute: +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 @@ -96,6 +103,7 @@ collection and let MongoDB optimise the storage for faster queries: time: new DateTimeImmutable(), sensorId: $sensorId, temperature: $temperature, + humidity: $humidity, ); $documentManager->persist($measurement); From db758a3e2e4f6b5b5c79430ad35a7265488cc2e8 Mon Sep 17 00:00:00 2001 From: Andreas Braun Date: Fri, 18 Oct 2024 09:16:57 +0200 Subject: [PATCH 15/16] Simplify markAsTimeSeries tests with granularity and bucket options --- .../Tests/Mapping/ClassMetadataTest.php | 39 +++---------------- 1 file changed, 5 insertions(+), 34 deletions(-) diff --git a/tests/Doctrine/ODM/MongoDB/Tests/Mapping/ClassMetadataTest.php b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/ClassMetadataTest.php index af7908750..c8f74769d 100644 --- a/tests/Doctrine/ODM/MongoDB/Tests/Mapping/ClassMetadataTest.php +++ b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/ClassMetadataTest.php @@ -983,13 +983,6 @@ public function testEmptySearchIndexDefinition(): void $cm->addSearchIndex(['mappings' => []]); } - public function testDefaultTimeSeriesMapping(): void - { - $metadata = $this->dm->getClassMetadata(TimeSeriesTestDocument::class); - - self::assertNull($metadata->timeSeriesOptions); - } - public function testTimeSeriesMappingOnlyWithTimeField(): void { $metadata = $this->dm->getClassMetadata(TimeSeriesTestDocument::class); @@ -1024,14 +1017,6 @@ public function testTimeSeriesMappingWithMissingMetadataField(): void $metadata->markAsTimeSeries(new ODM\TimeSeries('time', 'foo')); } - public function testTimeSeriesMappingWithGranularity(): void - { - $metadata = $this->dm->getClassMetadata(TimeSeriesTestDocument::class); - $metadata->markAsTimeSeries(new ODM\TimeSeries('time', granularity: Granularity::Seconds)); - - self::assertSame(Granularity::Seconds, $metadata->timeSeriesOptions->granularity); - } - public function testTimeSeriesMappingWithExpireAfterSeconds(): void { $metadata = $this->dm->getClassMetadata(TimeSeriesTestDocument::class); @@ -1040,30 +1025,16 @@ public function testTimeSeriesMappingWithExpireAfterSeconds(): void self::assertSame(10, $metadata->timeSeriesOptions->expireAfterSeconds); } - public function testTimeSeriesMappingWithBucketMaxSpanSeconds(): void - { - $metadata = $this->dm->getClassMetadata(TimeSeriesTestDocument::class); - $metadata->markAsTimeSeries(new ODM\TimeSeries('time', bucketMaxSpanSeconds: 10)); - - // We don't throw for invalid settings here, e.g. bucketMaxSpanSeconds not being equal to bucketRoundingSeconds - self::assertSame(10, $metadata->timeSeriesOptions->bucketMaxSpanSeconds); - } - - public function testTimeSeriesMappingWithBucketRoundingSeconds(): void - { - $metadata = $this->dm->getClassMetadata(TimeSeriesTestDocument::class); - $metadata->markAsTimeSeries(new ODM\TimeSeries('time', bucketRoundingSeconds: 10)); - - // We don't throw for invalid settings here, e.g. bucketMaxSpanSeconds not being equal to bucketRoundingSeconds - self::assertSame(10, $metadata->timeSeriesOptions->bucketRoundingSeconds); - } - 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, e.g. bucketMaxSpanSeconds not being equal to bucketRoundingSeconds + /* + * 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); From 4e8c52c1f121607279e61e1bd2324e23b637daf1 Mon Sep 17 00:00:00 2001 From: Andreas Braun Date: Fri, 18 Oct 2024 09:23:07 +0200 Subject: [PATCH 16/16] Apply wording suggestions from code review Co-authored-by: Jeremy Mikola --- docs/en/cookbook/time-series-data.rst | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/en/cookbook/time-series-data.rst b/docs/en/cookbook/time-series-data.rst index 539c01b52..4bc32b43b 100644 --- a/docs/en/cookbook/time-series-data.rst +++ b/docs/en/cookbook/time-series-data.rst @@ -92,8 +92,8 @@ measurement: // ... } -Once we created the schema, we can store our measurements in this time series -collection and let MongoDB optimise the storage for faster queries: +Once we create the schema, we can store our measurements in this time series +collection and let MongoDB optimize the storage for faster queries: .. code-block:: php @@ -109,8 +109,8 @@ collection and let MongoDB optimise the storage for faster queries: $documentManager->persist($measurement); $documentManager->flush(); -Note that other functionality, such as querying, aggregating data using -aggregation pipeline, or removing data works the same as with other collections. +Note that other functionality such as querying, using aggregation pipelines, or +removing data works the same as with other collections. Considerations -------------- @@ -122,7 +122,7 @@ 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 only store them with a ``minute`` granularity. This reduces the +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