Skip to main content
DirectQuery is using the external database to feed some aggregate providers and answer some queries. This can result in some large queries with heavy load on the database, and if the same cube is started multiple times, the same query can be repeated. Aggregate tables are a way to cache some aggregated data in the external database to make some queries faster and cheaper.

How does it work

Aggregate Tables are tables added in the external database as a table fed from aggregated data. These tables typically denormalize fields from joined tables into group-by columns, but can also preserve joins to keep the aggregate table smaller while still accessing fields from other tables at query time. Whenever possible, DirectQuery will query the Aggregate Table instead of the base Schema for a more optimized query: For instance, consider a Sales table containing sales such as this one: It is possible to aggregate the sum of the Quantity per Date and Product in the external database into an other table agg_sales: If this table is added as an Aggregate Table in DirectQuery, it will be used whenever possible instead of the original Sales table. For instance when asking for the SUM(Quantity) per Product in the Sales table, DirectQuery will retrieve the SUM(sum_of_quantity) per product in agg_sales. As the agg_sales is smaller and already aggregated the result will be faster to compute and will potentially avoid a huge scan of the database, saving both time and compute costs.

Use cases

There are a few classic use cases for using an Aggregate Table:
  • Feeding an aggregate provider faster by pre-aggregating over the provider fields
  • Having an “external database aggregate provider”, i.e. being able to answer some queries from the pre-aggregated data in the external database but without having to store additional data in-memory.
  • Feeding some hierarchies faster

Supported aggregations

The following aggregation function can be used in an Aggregate Table:
  • Sum
  • Min
  • Max
  • Average
  • Sum product
  • Count
  • Long
  • Short
  • Gross
  • Square sum

Custom Aggregations

To use a user defined aggregation function (UDAF) in an aggregate table, map it with the generic MeasureMapping.agg(pluginKey, originFields, targetColumnNames), where pluginKey is the plugin key shared by the Copper UDAF and the DirectQuery UDAF. How DirectQuery uses the aggregate table depends on the granularity of the query:
  • When the query groups by all the group-by columns of the aggregate table, DirectQuery reads the rows of the table as they are. Any DirectQuery UDAF can be used.
  • When the query is at a coarser granularity, DirectQuery re-aggregates the table in the external database, which requires the DirectQuery UDAF to be re-aggregable. For instance, a sum product stored per date and product is re-aggregated with SUM to get the sum product per date. If the DirectQuery UDAF is not re-aggregable, these queries run on the base tables. No error is raised at start-up.
The re-aggregation function only runs in the external database. In memory, aggregate providers and query execution merge aggregates with the merge function of the Copper UDAF. A re-aggregable UDAF is stored in a single column of the aggregate table. For instance, this ClickHouse table stores the sum product UDAF per date and product:
The aggregate table maps the UDAF through its plugin key, MY_SUM_PRODUCT:

Examples

Create a simple Aggregate Table to feed an aggregate provider

Let’s create a new table that pre-aggregates the quantity of the tutorial sales table per product. Unlike the agg_sales table above, this table has no date column: it answers the queries on the product only, which is the granularity of the aggregate provider defined below.
We feed it with the data from the other tables:
The pre-aggregated data looks like this: Let’s first discover the SQL table corresponding to the Aggregate Table:
We will map the PRODUCT field of the sales table to the product field of the Aggregate Table:
A mapped field and its group-by column must be of the same type. We will also map the sum of the sales quantity column into a column called quantity_sum, and the number of contributing rows into contributors_count. Every aggregate table needs this count column:
At startup, every origin field of a measure mapping, meaning the field it aggregates (here the quantity column), must be reachable from the base table of the aggregate table. The path starts from the base table and lists every join leading to the field, as described in Join key fields and join chains. Otherwise, declaring the schema fails with an invalid schema exception whose message names the field and the base table. Without this check, such a mapping would never match a query, and the aggregate table would never be used. We can now build the AggregateTable:
And add it to the schema:
Let’s now define a bitmap aggregate provider on the sum of quantity per Product, the feeding of the in-memory provider will be done directly from the Aggregate Table:
To make sure that the feeding can be done from the Aggregate Table, we can call a validator that will check that the provider is compatible with the Aggregate Table before starting the application:

More complex aggregations such as average

In the previous example, SUM was based on a single input and pre-aggregated into a single column, but there are some more complex cases. For instance:
  • count has no input and produces 1 pre-aggregated column
  • sumProduct takes N input columns and produces 1 pre-aggregated column, mapped with MeasureMapping.sumProduct(originFields, targetColumnName)
  • average takes a single input column but requires 2 pre-aggregated columns in order to be able to re-aggregate: one column for the sum and one for the count
The following mapping declares an average. For an aggregation with several input columns, see Custom Aggregations.

Create a simple Aggregate Table to feed a hierarchy

For an aggregate table to be used to feed a hierarchy, it needs to be defined with all the levels of the hierarchy as GroupByFieldsMapping and the Count as MeasuresMappings. In the external database, the table for a hierarchy with the levels (Country, City) would look like:

Count

Every query that DirectQuery sends to the external database also computes the number of contributing rows. Atoti uses this count to tell the locations that have data from the empty ones. An aggregate table must therefore contain a column with the count, mapped with MeasureMapping.count(targetColumnName): without it, the table could never answer a query, so declaring it fails. To re-aggregate the table at a coarser granularity, DirectQuery sums this column.

Slicing hierarchies

Slicing hierarchies are always expressed in the queries sent to the database. When a field expressed in a query is not part of the Aggregate Table, the query will not use the Aggregate Table. Therefore it is recommended to include the slicing hierarchies in Aggregate Table definition.

Aggregate tables with vectors

Arrays can be aggregated in an aggregate table but they need to have a specific format depending on the type of original array being aggregated: See the DirectQuery vector documentation for more information about array types. When a query is at a coarser granularity than the aggregate table, DirectQuery re-aggregates a vector stored in a native array column in the external database, provided the database can aggregate that array natively, as described in Native support. If the database cannot aggregate the array natively, or the array is emulated (multi-column), DirectQuery reads the rows of the aggregate table as they are and Atoti aggregates them. The aggregate table is still used.

Generate SQL for Aggregate Table matching an Aggregate Provider

Aggregate Tables are a good tool to feed Aggregate Providers quickly because they let the provider use pre-aggregated data instead of running a new aggregation. The examples above declare an Aggregate Table that already exists in the external database. When it does not exist yet, DirectQuery provides a bootstrapper that generates the SQL to create and feed an Aggregate Table matching an Aggregate Provider. The bootstrapper is a development-time tool: use it once to obtain the SQL, run that SQL on the external database, then declare the resulting table in the schema of your application like any other Aggregate Table. Let’s imagine we have the following Aggregate Provider defined:
We can create a bootstrapper for it:
This bootstrapper can provide the SQL to create and feed the provider:
The schema given to the bootstrapper only needs the base tables: it does not contain the Aggregate Table being bootstrapped. Execute this SQL yourself on the external database, or hand it to the database owner: Atoti does not assume that it has write access to the external database. Creating and feeding the table only needs to be done once.
Do not build or run the bootstrapper when the application starts. Creating and feeding the table at start-up runs the full aggregation over the base table every time, which is the cost Aggregate Tables exist to avoid. It also requires write rights on the database, which production deployments usually do not grant.
Once the table exists, declare it in the schema of your application like any other Aggregate Table: discover the table, build an AggregateTableDescription and add it to the SchemaDescription, as described in Create a simple Aggregate Table to feed an aggregate provider. The description returned by toAggregateTable() describes the generated table and can serve as a model for this declaration.

Preserving joins with joinedTableNames

By default, the bootstrapper denormalizes all fields from joined tables into the aggregate table as group-by columns. However, when dealing with dimension tables with many columns, this denormalization can make the aggregate table unnecessarily large. The joinToKeepNormalized option allows specifying tables whose fields should remain accessible through joins at query time instead of being denormalized. This keeps the aggregate table smaller while still allowing queries to reference fields from those tables. When using joinToKeepNormalized:
  • Fields accessible through the specified join are not included as group-by columns in the aggregate table
  • Join key fields (source fields of the join leading to those tables) are automatically added as group-by columns
  • At query time, the aggregate table can still answer queries involving fields from joined tables by performing joins
  • This reduces the number of denormalized columns while maintaining query compatibility
To use this feature, call the joinToKeepNormalized method on the bootstrapper builder with the source table and the join name:
In this example, fields accessible through the SALES_TO_PRODUCTS join (starting from SALES) will not be denormalized into the aggregate table. Instead, queries referencing these fields will use joins to access them. Note that preserving joins comes with a trade-off: queries referencing fields from joined tables will require the join to be performed at query time, which most of the time increases compute costs and result in slower responses.

Join key fields and join chains

Consider the following schema:
By including STORE_ID from Sales as a group-by field in the aggregate table, queries can access fields from Stores and, by transitivity, from Regions as well — without denormalizing those fields into the aggregate table.
Use this feature when:
  • Dimension tables have many columns that would bloat the aggregate table
  • Reducing storage footprint and improving aggregate table creation time is a priority

Database specific features

Creating a view for an Aggregate Table is not a solution as the view must be recomputed for each query. Creating a table requires to update it when the data changes. Some databases offers native implementations to automatically create and materialize the content of a view (so it is fast to access) while automatically updating it (so it is always up-to-date).
In Snowflake there is a powerful feature called Dynamic tables. As these Dynamic tables also support time travel it is the recommended way to implement an Aggregate Table in Snowflake.

Aggregate tables and clustering fields

When an aggregate table serves a query, the query is split on the clustering fields of the aggregate table, never on those of the base table, so an aggregate table without clustering fields is read in one query. Clustering fields can be declared on the aggregate table itself. See Clustering fields and aggregate tables for the details.

Is my query using the Aggregate Table?

The simplest solution to check whether the query is using an aggregate table is to look at the query in the external database. If that is the case, then the aggregate table name should appear in the SQL query as the base table (the first table in the FROM clause). Additionally, for databases supporting tags such as Snowflake, the tag aggregate_table_name will be added to the query with the name of the aggregate table used. While building a project, to have more information about why a query is compatible or not with aggregate tables, it is possible to add additional logs by setting the logger atoti.server.directquery.query_resolution.aggregate_table (see Logger naming) to FINE. The additional logs look like this: