Skip to main content

Summary Class

A summary entity is an entity that contains pre-aggregated data from the source entity. To work with this package, you need to create one or more summary entities for each source entity that you want to analyze.

Example Source Entity​

This is the example of an entity that we would like to analyze. Here, we have an Order:

use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity()]
class Order
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;

#[ORM\ManyToOne()]
private ?Item $item = null;

#[ORM\ManyToOne()]
private ?Customer $customer = null;

#[ORM\Column(type: Types::DATETIME_MUTABLE)]
private ?\DateTimeInterface $time = null;

// setters, getters and other logic are omitted for brevity
}

The source entity must have a key field that has the following properties:

  • Permanent, the value should not change in the lifetime of the entity. If it is changed through the ORM, both the old and the new partition are refreshed, but a change made outside the ORM is not detected.
  • Monotonic, new entities get increasing values. Entities committed slightly out of order, e.g. by concurrent transactions, are still summarized.

Most of the time, you should be able to use the primary key of the entity as the key field.

The source entity and its summary entities must be managed by the same entity manager. Otherwise, a MetadataException is thrown when the metadata of the summary is built.

The Summary Entity​

This is an example summary entity for the above Order entity. A summary table is a standard Doctrine entity with additional attributes that define how the data is rolled up from the source entity:

use Brick\Money\Money;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Rekalogika\Analytics\Core\AggregateFunction\Count;
use Rekalogika\Analytics\Core\AggregateFunction\Sum;
use Rekalogika\Analytics\Metadata\Attribute as Analytics;
use Rekalogika\Analytics\Core\Partition\DefaultIntegerPartition;
use Rekalogika\Analytics\Core\Entity\BaseSummary;
use Rekalogika\Analytics\Core\ValueResolver\IdentifierValue;
use Rekalogika\Analytics\Core\ValueResolver\IntegerValue;
use Rekalogika\Analytics\Core\ValueResolver\PropertyValue;
use Rekalogika\Analytics\Time\Dimension\System\GregorianDateWithHour;
use Rekalogika\Analytics\Time\Metadata\TimeProperties;
use Symfony\Component\Translation\TranslatableMessage;

#[ORM\Entity()]
#[Analytics\Summary(
sourceClass: Order::class,
label: new TranslatableMessage('Orders'),
)]
class OrderSummary extends BaseSummary
{
// 1. Partition

#[ORM\Embedded()]
#[Analytics\Partition(new IntegerValue('id'))]
private DefaultIntegerPartition $partition;

// 2. Dimensions

#[ORM\Embedded()]
#[Analytics\Dimension(
source: new PropertyValue('time'),
label: new TranslatableMessage('Time'),
)]
#[TimeProperties(
sourceTimeZone: new \DateTimeZone('UTC'),
summaryTimeZone: new \DateTimeZone('Asia/Jakarta'),
)]
private GregorianDateWithHour $time;

#[ORM\ManyToOne()]
#[Analytics\Dimension(
source: new IdentifierValue('customer.country'),
label: new TranslatableMessage('Customer Country'),
)]
private ?Country $customerCountry = null;

#[ORM\ManyToOne()]
#[Analytics\Dimension(
source: new IdentifierValue('customer.country.region'),
label: new TranslatableMessage('Customer Region'),
)]
private ?Region $customerRegion = null;

#[ORM\Column(enumType: Gender::class, nullable: true)]
#[Analytics\Dimension(
source: new PropertyValue('customer.gender'),
label: new TranslatableMessage('Customer Gender'),
)]
private ?Gender $customerGender = null;

// 3. Measures

#[ORM\Column(type: Types::INTEGER)]
#[Analytics\Measure(
function: new Sum('item.price'),
label: new TranslatableMessage('Price'),
)]
private ?int $price = null;

#[ORM\Column(type: Types::INTEGER)]
#[Analytics\Measure(
function: new Count('id'),
label: new TranslatableMessage('Count'),
)]
private ?int $count = null;

// 4. An example getter with business logic

public function getPrice(): ?Money
{
if ($this->price === null) {
return null;
}

return Money::ofMinor($this->price, 'EUR');
}
}

Sections​

1. Partition​

The partition attribute is used to define how the data is partitioned. The default DefaultIntegerPartition here should be sufficient for auto-incrementing primary key of the source entity.

Read more about partitions in the partitioning section.

2. Dimensions​

These are the properties that have distinct, descriptive values. You will use these properties to filter and group the data. Using SQL as an analogy, these are the fields that you would use in a GROUP BY and/or WHERE clause.

All dimensions are indicated by the #[Analytics\Dimension] attribute. The most important argument is the source argument. This argument is used to resolve the value of the dimension from the source entity. A PropertyValue points to the value of a property in the source entity. An IdentifierValue points to a related entity.

A dimension can be hierarchical, like the time dimension above. A hierarchical dimension is modeled using a Doctrine embeddable. Inside the class, the time dimension is further divided into year, month, day, and more.

Read more about dimensions in the dimensions section.

3. Measures​

These are the properties that you want to aggregate. Using SQL as an analogy, you would use these fields in a SUM, COUNT, and other aggregate functions.

Measures are indicated by the #[Analytics\Measure] attribute. The most important argument is the function argument. It is used to define the aggregation function.

Read more about measures in the measures section.

4. Getters​

Just like a regular Doctrine entity, you can define getters in the summary entity. You can also have simple business logic in these getters. For example, the getPrice() getter above converts the price to a Money object.

Labels and Translations​

All the items in the summary entity have a label attribute that accepts a string or a TranslatableInterface. These labels are used in the user interface to identify the item, for example in a table header or a chart legend.

If a TranslatableInterface is used, the label will be translated using the Symfony translation component.

Indexing​

The framework automatically creates indexes for the summary table. You don't need to create any indexes manually.

Changing Summary Entity​

A summary entity should not be changed after it is created and populated.

If you need to change the summary entity, you should create a new one, refresh the data and wait until it is completed, and then retire the old one. If you anticipate that you will have to change the summary entity, we suggest date-coding the summary entity class name, for example OrderSummary20250115.

Summary Entity is an Entity but not an Entity​

A summary entity is defined as a Doctrine entity. But it is mainly for defining the structure of the summary table and the summarization behavior. You will never interact with a real instance of the summary entity. Instead, you query the summary table using the SummaryManager, and get the result not in the form of a summary entity.

An event listener is installed to prevent you from accidentally persisting, updating, or deleting a summary entity.

Filtering the Source Entities​

By default, a summary covers every source entity. To summarize only some of them, use the filter argument of the Summary attribute. It takes a Doctrine Collections expression whose fields are property paths of the source entity:

use Doctrine\Common\Collections\Expr\Comparison;

#[ORM\Entity()]
#[Analytics\Summary(
sourceClass: Order::class,
filter: new Comparison('status', '<>', 'cancelled'),
)]
class OrderSummary extends BaseSummary
{
// ...
}

Use CompositeExpression to combine several conditions. A condition that is not a comparison, e.g. INSTANCE OF or a comparison between two properties, can be written in DQL using SourceCondition. As in a CustomExpression, property paths go in square brackets:

use Doctrine\Common\Collections\Expr\Comparison;
use Doctrine\Common\Collections\Expr\CompositeExpression;
use Rekalogika\Analytics\Core\Filter\SourceCondition;

#[ORM\Entity()]
#[Analytics\Summary(
sourceClass: Order::class,
filter: new CompositeExpression(CompositeExpression::TYPE_AND, [
new Comparison('status', '<>', 'cancelled'),
new Comparison('customer.country.id', 'IN', ['ID', 'SG']),
new SourceCondition('[customer.*] INSTANCE OF App\Entity\IndividualCustomer'),
new SourceCondition('[paidAt] > [createdAt]'),
]),
)]
class OrderSummary extends BaseSummary
{
// ...
}

PHP attribute arguments must be constant expressions, so build the expression with new Comparison(...) and new CompositeExpression(...). Criteria::expr() cannot be used here. The values must be constants.

A Comparison supports the operators =, <>, <, <=, >, >=, IN, NIN, CONTAINS, STARTS_WITH, and ENDS_WITH. Other operators and expression types cause a MetadataException.

The filter applies to every query the framework makes against the source entities. That includes rolling up the data into the summary table, determining the key range to refresh, and getting the source entities of a cell.

Change Detection​

The properties the filter reads are tracked in the same way as the properties used by the dimensions and measures. If a change moves a source entity into or out of the filter, the affected partition is refreshed.

In a SourceCondition, only the paths in square brackets are tracked. If you write a path without brackets, for example inside a subquery, changes to it are not detected.

Limitations​

  • A comparison through an association, e.g. customer.country.id, excludes the source entities whose association is null, unless the filter allows null explicitly.
  • A filter cannot read a path through a to-many association, because the join would count a source entity more than once. Such a filter causes a MetadataException. Use a SourceCondition with an EXISTS subquery instead.