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