Repository: squashql/squashql
Pull request #239 (2024-01-16 07:49:21)

Improve array operation capabilities


Patch:
diff --git a/bigquery/src/main/java/io/squashql/query/database/BigQueryEngine.java b/bigquery/src/main/java/io/squashql/query/database/BigQueryEngine.java
index fa5fe71..0a3d589 100644
--- a/bigquery/src/main/java/io/squashql/query/database/BigQueryEngine.java
+++ b/bigquery/src/main/java/io/squashql/query/database/BigQueryEngine.java
@@ -100,6 +100,8 @@ public class BigQueryEngine extends AQueryEngine<BigQueryDatastore> {
           return o.getDoubleValue();
         } else if (listClass.equals(Lists.LongList.class)) {
           return o.getLongValue();
+        } else if (listClass.equals(Lists.LocalDateList.class)) {
+          return LocalDate.parse(o.getStringValue());
         } else {
           return o.getValue(); // it will be a string
         }
diff --git a/core/src/main/java/io/squashql/jdbc/JdbcUtil.java b/core/src/main/java/io/squashql/jdbc/JdbcUtil.java
index d0d8a12..a8247f8 100644
--- a/core/src/main/java/io/squashql/jdbc/JdbcUtil.java
+++ b/core/src/main/java/io/squashql/jdbc/JdbcUtil.java
@@ -9,6 +9,7 @@ import io.squashql.type.TableTypedField;
 
 import java.math.BigInteger;
 import java.sql.*;
+import java.sql.Date;
 import java.time.LocalDate;
 import java.time.LocalDateTime;
 import java.util.*;
@@ -191,6 +192,11 @@ public final class JdbcUtil {
       return stream
               .map(e -> (Double) e)
               .collect(Collectors.toCollection(Lists.DoubleList::new));
+    } else if (listClass == Lists.LocalDateList.class) {
+      return stream
+              // Clickhouse does not return a java.sql.Date but a LocalDate directly but not Spark
+              .map(e -> e instanceof LocalDate ? (LocalDate) e : ((Date) e).toLocalDate())
+              .collect(Collectors.toCollection(Lists.LocalDateList::new));
     } else {
       return stream.toList();
     }
@@ -201,6 +207,8 @@ public final class JdbcUtil {
       return Lists.DoubleList.class;
     } else if (elementClass.equals(long.class) || elementClass.equals(int.class)) {
       return Lists.LongList.class;
+    } else if (elementClass.equals(LocalDate.class)) {
+      return Lists.LocalDateList.class;
     } else {
       return List.class; // we convert Array to List
     }
diff --git a/core/src/main/java/io/squashql/list/Lists.java b/core/src/main/java/io/squashql/list/Lists.java
index e2ae9e6..e6c116a 100644
--- a/core/src/main/java/io/squashql/list/Lists.java
+++ b/core/src/main/java/io/squashql/list/Lists.java
@@ -1,5 +1,6 @@
 package io.squashql.list;
 
+import java.time.LocalDate;
 import java.util.ArrayList;
 
 public final class Lists {
@@ -26,4 +27,14 @@ public final class Lists {
       super(initialCapacity);
     }
   }
+
+  public static class LocalDateList extends ArrayList<LocalDate> {
+
+    public LocalDateList() {
+    }
+
+    public LocalDateList(int initialCapacity) {
+      super(initialCapacity);
+    }
+  }
 }
diff --git a/core/src/main/java/io/squashql/query/AComparisonExecutor.java b/core/src/main/java/io/squashql/query/AComparisonExecutor.java
index 5813cb2..bb11241 100644
--- a/core/src/main/java/io/squashql/query/AComparisonExecutor.java
+++ b/core/src/main/java/io/squashql/query/AComparisonExecutor.java
@@ -44,8 +44,9 @@ public abstract class AComparisonExecutor {
     List<Object> result = new ArrayList<>((int) writeToTable.count());
     List<Object> readAggregateValues = readFromTable.getAggregateValues(cm.measure());
     List<Object> writeAggregateValues = writeToTable.getAggregateValues(cm.measure());
-    BiFunction<Number, Number, Number> comparisonBiFunction = BinaryOperations.createComparisonBiFunction(
-            cm.comparisonMethod(), readFromTable.getHeader(cm.measure()).type());
+    BiFunction<Object, Object, Object> comparisonBiFunction = cm.comparisonMethod() != null
+            ? (a, b) -> BinaryOperations.createComparisonBiFunction(cm.comparisonMethod(), readFromTable.getHeader(cm.measure()).type()).apply((Number) a, (Number) b)
+            : cm.comparisonOperator();
     int[] rowIndex = new int[1];
     IntIntMap mapping = buildMapping(writeToTable, readFromTable); // columns might be in a different order
     writeToTable.forEach(row -> {
@@ -63,7 +64,7 @@ public abstract class AComparisonExecutor {
       if (success && readPosition != -1) {
         Object currentValue = writeAggregateValues.get(rowIndex[0]);
         Object referenceValue = readAggregateValues.get(readPosition);
-        Object diff = comparisonBiFunction.apply((Number) currentValue, (Number) referenceValue);
+        Object diff = comparisonBiFunction.apply(currentValue, referenceValue);
         result.add(diff);
       } else {
         result.add(null); // nothing to compare with
diff --git a/core/src/main/java/io/squashql/query/ComparisonMeasureReferencePosition.java b/core/src/main/java/io/squashql/query/ComparisonMeasureReferencePosition.java
index 5337a1a..a49082a 100644
--- a/core/src/main/java/io/squashql/query/ComparisonMeasureReferencePosition.java
+++ b/core/src/main/java/io/squashql/query/ComparisonMeasureReferencePosition.java
@@ -5,6 +5,7 @@ import lombok.*;
 
 import java.util.List;
 import java.util.Map;
+import java.util.function.BiFunction;
 
 @ToString
 @EqualsAndHashCode
@@ -17,6 +18,7 @@ public class ComparisonMeasureReferencePosition implements Measure {
   public String expression;
   @Getter
   public ComparisonMethod comparisonMethod;
+  public BiFunction<Object, Object, Object> comparisonOperator;
   @Getter
   public Measure measure;
   public ColumnSetKey columnSetKey;
@@ -29,7 +31,7 @@ public class ComparisonMeasureReferencePosition implements Measure {
                                             @NonNull Measure measure,
                                             @NonNull Map<Field, String> referencePosition,
                                             @NonNull Period period) {
-    this(alias, comparisonMethod, measure, referencePosition, period, null, null);
+    this(alias, comparisonMethod, null, measure, referencePosition, period, null, null);
   }
 
   public ComparisonMeasureReferencePosition(@NonNull String alias,
@@ -37,25 +39,37 @@ public class ComparisonMeasureReferencePosition implements Measure {
                                             @NonNull Measure measure,
                                             @NonNull Map<Field, String> referencePosition,
                                             @NonNull ColumnSetKey columnSetKey) {
-    this(alias, comparisonMethod, measure, referencePosition, null, columnSetKey, null);
+    this(alias, comparisonMethod, null, measure, referencePosition, null, columnSetKey, null);
   }
 
   public ComparisonMeasureReferencePosition(@NonNull String alias,
                                             @NonNull ComparisonMethod comparisonMethod,
                                             @NonNull Measure measure,
                                             @NonNull List<Field> ancestors) {
-    this(alias, comparisonMethod, measure, null, null, null, ancestors);
+    this(alias, comparisonMethod, null, measure, null, null, null, ancestors);
   }
 
-  private ComparisonMeasureReferencePosition(@NonNull String alias,
-                                             @NonNull ComparisonMethod comparisonMethod,
-                                             @NonNull Measure measure,
+  /**
+   * For internal use.
+   */
+  public ComparisonMeasureReferencePosition(@NonNull String alias,
+                                            @NonNull BiFunction<Object, Object, Object> comparisonOperator,
+                                            @NonNull Measure measure,
+                                            @NonNull List<Field> ancestors) {
+    this(alias, null, comparisonOperator, measure, null, null, null, ancestors);
+  }
+
+  private ComparisonMeasureReferencePosition(String alias,
+                                             ComparisonMethod comparisonMethod,
+                                             BiFunction<Object, Object, Object> comparisonOperator,
+                                             Measure measure,
                                              Map<Field, String> referencePosition,
                                              Period period,
                                              ColumnSetKey columnSetKey,
                                              List<Field> ancestors) {
     this.alias = alias;
     this.comparisonMethod = comparisonMethod;
+    this.comparisonOperator = comparisonOperator;
     this.measure = measure;
     this.referencePosition = referencePosition;
     this.period = period;
@@ -72,5 +86,4 @@ public class ComparisonMeasureReferencePosition implements Measure {
   public String expression() {
     return this.expression;
   }
-
 }
diff --git a/core/src/main/java/io/squashql/query/ParentComparisonExecutor.java b/core/src/main/java/io/squashql/query/ParentComparisonExecutor.java
index 1914902..aecd44d 100644
--- a/core/src/main/java/io/squashql/query/ParentComparisonExecutor.java
+++ b/core/src/main/java/io/squashql/query/ParentComparisonExecutor.java
@@ -2,6 +2,7 @@ package io.squashql.query;
 
 import io.squashql.query.compiled.CompiledComparisonMeasure;
 import io.squashql.query.database.SQLTranslator;
+import io.squashql.query.database.SqlUtils;
 import io.squashql.type.TypedField;
 import org.eclipse.collections.api.map.primitive.ObjectIntMap;
 
@@ -39,7 +40,7 @@ public class ParentComparisonExecutor extends AComparisonExecutor {
     public boolean test(Object[] row, Header[] headers) {
       for (TypedField ancestor : this.ancestors) {
         // Is it expressed ?
-        String name = ancestor.name();
+        String name = SqlUtils.squashqlExpression(ancestor);
         if (this.indexByColumn.containsKey(name)) {
           int index = this.indexByColumn.get(name);
           if (!SQLTranslator.TOTAL_CELL.equals(row[index])) {
diff --git a/core/src/main/java/io/squashql/query/QueryExecutor.java b/core/src/main/java/io/squashql/query/QueryExecutor.java
index 6dd795b..7f46ae2 100644
--- a/core/src/main/java/io/squashql/query/QueryExecutor.java
+++ b/core/src/main/java/io/squashql/query/QueryExecutor.java
@@ -165,19 +165,21 @@ public class QueryExecutor {
       QueryCache.QueryCacheKey queryCacheKey = new QueryCache.QueryCacheKey(scope, user);
       QueryCache queryCache = getQueryCache((QueryCacheParameter) query.parameters.getOrDefault(QueryCacheParameter.KEY, new QueryCacheParameter(QueryCacheParameter.Action.USE)), user);
 
+      Set<CompiledMeasure> measuresToExcludeFromCache = new HashSet<>(); // the measures not to put in cache
       Set<CompiledMeasure> cached = new HashSet<>();
       Set<CompiledMeasure> notCached = new HashSet<>();
-      Collection<CompiledMeasure> groupingMeasures = generateGroupingMeasures(scope).values();
       for (CompiledMeasure measure : measures) {
         if (MeasureUtils.isPrimitive(measure)) {
-          // Make sure to never cache the grouping measures. It could cause issue in some cases.
-          if (queryCache.contains(measure, queryCacheKey) && !groupingMeasures.contains(measure)) {
+          if (!canBeCached(measure, scope)) {
+            measuresToExcludeFromCache.add(measure);
+          } else if (queryCache.contains(measure, queryCacheKey)) {
             cached.add(measure);
           } else {
             notCached.add(measure);
           }
         }
       }
+      notCached.addAll(measuresToExcludeFromCache);
 
       Table result;
       if (!notCached.isEmpty()) {
@@ -191,7 +193,7 @@ public class QueryExecutor {
       }
 
       queryCache.contributeToResult(result, cached, queryCacheKey);
-      Set<CompiledMeasure> measuresToCache = notCached.stream().filter(m -> !groupingMeasures.contains(m)).collect(Collectors.toSet());
+      Set<CompiledMeasure> measuresToCache = notCached.stream().filter(m -> !measuresToExcludeFromCache.contains(m)).collect(Collectors.toSet());
       queryCache.contributeToCache(result, measuresToCache, queryCacheKey);
 
       // The table in the cache contains null values for totals but in this map, we need to replace the nulls with totals
@@ -242,6 +244,15 @@ public class QueryExecutor {
     return result;
   }
 
+  private static boolean canBeCached(CompiledMeasure measure, QueryScope scope) {
+    // Make sure to never cache the grouping measures. It could cause issue in some cases.
+    if(generateGroupingMeasures(scope).values().contains(measure)) {
+      return false;
+    }
+    // In case of vectors, we can rely only on the alias of the measure.
+    return SqlUtils.extractFieldFromGroupingAlias(measure.alias()) == null;
+  }
+
   private static DependencyGraph<QueryPlanNodeKey> computeDependencyGraph(
           List<TypedField> columns,
           List<TypedField> bucketColumns,
@@ -278,6 +289,38 @@ public class QueryExecutor {
                            List<List<TypedField>> groupingSets,
                            VirtualTableDto virtualTable,
                            int limit) {
+
+    @Override
+    public String toString() {
+      final StringBuilder sb = new StringBuilder("QueryScope{");
+      sb.append("table=").append(table);
+      if (subQuery != null) {
+        sb.append(", subQuery=").append(subQuery);
+      }
+      if (columns != null && !columns.isEmpty()) {
+        sb.append(", columns=").append(columns);
+      }
+      if (whereCriteria != null) {
+        sb.append(", whereCriteria=").append(whereCriteria);
+      }
+      if (havingCriteria != null) {
+        sb.append(", havingCriteria=").append(havingCriteria);
+      }
+      if (rollupColumns != null && !rollupColumns.isEmpty()) {
+        sb.append(", rollupColumns=").append(rollupColumns);
+      }
+      if (groupingSets != null && !groupingSets.isEmpty()) {
+        sb.append(", groupingSets=").append(groupingSets);
+      }
+      if (virtualTable != null) {
+        sb.append(", virtualTable=").append(virtualTable);
+      }
+      if (limit > 0) {
+        sb.append(", limit=").append(limit);
+      }
+      sb.append('}');
+      return sb.toString();
+    }
   }
 
   public record QueryPlanNodeKey(QueryScope queryScope, CompiledMeasure measure) {
diff --git a/core/src/main/java/io/squashql/query/QueryResolver.java b/core/src/main/java/io/squashql/query/QueryResolver.java
index 1b22c02..685d9f5 100644
--- a/core/src/main/java/io/squashql/query/QueryResolver.java
+++ b/core/src/main/java/io/squashql/query/QueryResolver.java
@@ -8,6 +8,7 @@ import io.squashql.store.Store;
 import io.squashql.type.*;
 import lombok.Data;
 import lombok.Value;
+import org.eclipse.collections.impl.tuple.Tuples;
 
 import java.util.*;
 import java.util.concurrent.ConcurrentHashMap;
@@ -261,6 +262,8 @@ public class QueryResolver {
         compiledMeasure = compileComparisonMeasure((ComparisonMeasureReferencePosition) m, topMeasure);
       } else if (m instanceof VectorAggMeasure v) {
         compiledMeasure = compileVectorAggMeasure(v);
+      } else if (m instanceof VectorTupleAggMeasure v) {
+        compiledMeasure = compileVectorTupleAggMeasure(v);
       } else {
         throw new IllegalArgumentException("Unknown type of measure " + m.getClass().getSimpleName());
       }
@@ -303,6 +306,7 @@ public class QueryResolver {
     return new CompiledComparisonMeasure(
             m.alias,
             m.comparisonMethod,
+            m.comparisonOperator,
             compileMeasure(m.measure, topMeasure),
             m.referencePosition == null ? null : m.referencePosition.entrySet().stream().collect(Collectors.toMap(e -> resolveField(e.getKey()), Map.Entry::getValue)),
             compilePeriod(m.period),
@@ -331,6 +335,14 @@ public class QueryResolver {
     return new CompiledVectorAggMeasure(m.alias, resolveField(m.fieldToAggregate), m.aggregationFunction, resolveField(m.vectorAxis));
   }
 
+  private CompiledMeasure compileVectorTupleAggMeasure(VectorTupleAggMeasure m) {
+    return new CompiledVectorTupleAggMeasure(
+            m.alias,
+            m.fieldToAggregateAndAggFunc.stream().map(p -> Tuples.pair(resolveField(p.getOne()), p.getTwo())).toList(),
+            resolveField(m.vectorAxis),
+            m.transformer);
+  }
+
   private Map<ColumnSetKey, CompiledColumnSet> compiledColumnSets(Map<ColumnSetKey, ColumnSet> columnSets) {
     Map<ColumnSetKey, CompiledColumnSet> m = new HashMap<>();
     for (Map.Entry<ColumnSetKey, ColumnSet> entry : columnSets.entrySet()) {
diff --git a/core/src/main/java/io/squashql/query/VectorTupleAggMeasure.java b/core/src/main/java/io/squashql/query/VectorTupleAggMeasure.java
new file mode 100644
index 0000000..e2575b2
--- /dev/null
+++ b/core/src/main/java/io/squashql/query/VectorTupleAggMeasure.java
@@ -0,0 +1,44 @@
+package io.squashql.query;
+
+import lombok.*;
+import org.eclipse.collections.api.tuple.Pair;
+
+import java.util.List;
+import java.util.function.Function;
+
+/**
+ * Internal ONLY!
+ */
+@ToString
+@EqualsAndHashCode
+@NoArgsConstructor // For Jackson
+@AllArgsConstructor
+public class VectorTupleAggMeasure implements Measure {
+
+  public String alias;
+  public List<Pair<Field, String>> fieldToAggregateAndAggFunc;
+  public Field vectorAxis;
+  public Function<List<Object>, Object> transformer;
+  @With
+  public String expression;
+
+  public VectorTupleAggMeasure(String alias,
+                               List<Pair<Field, String>> fieldToAggregateAndAggFunc,
+                               Field vectorAxis,
+                               Function<List<Object>, Object> transformer) {
+    this.alias = alias;
+    this.fieldToAggregateAndAggFunc = fieldToAggregateAndAggFunc;
+    this.vectorAxis = vectorAxis;
+    this.transformer = transformer;
+  }
+
+  @Override
+  public String alias() {
+    return this.alias;
+  }
+
+  @Override
+  public String expression() {
+    return this.expression;
+  }
+}
diff --git a/core/src/main/java/io/squashql/query/agg/AggregationFunction.java b/core/src/main/java/io/squashql/query/agg/AggregationFunction.java
index 9fb526a..9acf48b 100644
--- a/core/src/main/java/io/squashql/query/agg/AggregationFunction.java
+++ b/core/src/main/java/io/squashql/query/agg/AggregationFunction.java
@@ -6,6 +6,7 @@ public final class AggregationFunction {
   public static final String MIN = "min";
   public static final String MAX = "max";
   public static final String AVG = "avg";
+  public static final String ANY_VALUE = "any_value";
   public static final String COUNT = "count";
   public static final String GROUPING = "grouping";
   public static final String ARRAY_AGG = "array_agg";
diff --git a/core/src/main/java/io/squashql/query/compiled/CompiledComparisonMeasure.java b/core/src/main/java/io/squashql/query/compiled/CompiledComparisonMeasure.java
index 58af178..7ec1ba8 100644
--- a/core/src/main/java/io/squashql/query/compiled/CompiledComparisonMeasure.java
+++ b/core/src/main/java/io/squashql/query/compiled/CompiledComparisonMeasure.java
@@ -7,9 +7,11 @@ import io.squashql.type.TypedField;
 
 import java.util.List;
 import java.util.Map;
+import java.util.function.BiFunction;
 
 public record CompiledComparisonMeasure(String alias,
                                         ComparisonMethod comparisonMethod,
+                                        BiFunction<Object, Object, Object> comparisonOperator,
                                         CompiledMeasure measure,
                                         Map<TypedField, String> referencePosition,
                                         CompiledPeriod period,
diff --git a/core/src/main/java/io/squashql/query/compiled/CompiledTable.java b/core/src/main/java/io/squashql/query/compiled/CompiledTable.java
index a8bc003..8548d0b 100644
--- a/core/src/main/java/io/squashql/query/compiled/CompiledTable.java
+++ b/core/src/main/java/io/squashql/query/compiled/CompiledTable.java
@@ -35,4 +35,15 @@ public record CompiledTable(String name, List<CompiledJoin> joins) {
       return statement.toString();
     }
   }
+
+  @Override
+  public String toString() {
+    final StringBuilder sb = new StringBuilder("CompiledTable{");
+    sb.append("name='").append(this.name).append('\'');
+    if (this.joins != null && !joins().isEmpty()) {
+      sb.append(", joins=").append(this.joins);
+    }
+    sb.append('}');
+    return sb.toString();
+  }
 }
diff --git a/core/src/main/java/io/squashql/query/compiled/CompiledVectorTupleAggMeasure.java b/core/src/main/java/io/squashql/query/compiled/CompiledVectorTupleAggMeasure.java
new file mode 100644
index 0000000..4c684fc
--- /dev/null
+++ b/core/src/main/java/io/squashql/query/compiled/CompiledVectorTupleAggMeasure.java
@@ -0,0 +1,29 @@
+package io.squashql.query.compiled;
+
+import io.squashql.query.database.QueryRewriter;
+import io.squashql.type.TypedField;
+import org.eclipse.collections.api.tuple.Pair;
+
+import java.util.List;
+import java.util.function.Function;
+
+public record CompiledVectorTupleAggMeasure(String alias,
+                                            List<Pair<TypedField, String>> fieldToAggregateAndAggFunc,
+                                            TypedField vectorAxis,
+                                            Function<List<Object>, Object> transformer) implements CompiledMeasure {
+
+  @Override
+  public String sqlExpression(QueryRewriter queryRewriter, boolean withAlias) {
+    throw new IllegalStateException("Incorrect path of execution");
+  }
+
+  @Override
+  public String alias() {
+    return this.alias;
+  }
+
+  @Override
+  public <R> R accept(MeasureVisitor<R> visitor) {
+    return visitor.visit(this);
+  }
+}
diff --git a/core/src/main/java/io/squashql/query/compiled/Evaluator.java b/core/src/main/java/io/squashql/query/compiled/Evaluator.java
index 34bc1d5..a072ea9 100644
--- a/core/src/main/java/io/squashql/query/compiled/Evaluator.java
+++ b/core/src/main/java/io/squashql/query/compiled/Evaluator.java
@@ -4,8 +4,10 @@ import io.squashql.query.*;
 import io.squashql.query.QueryExecutor.ExecutionContext;
 import io.squashql.query.QueryExecutor.QueryPlanNodeKey;
 import io.squashql.query.comp.BinaryOperations;
+import io.squashql.store.UnknownType;
 import io.squashql.table.Table;
 import io.squashql.type.TypedField;
+import io.squashql.util.ListUtils;
 
 import java.util.ArrayList;
 import java.util.Collections;
@@ -81,7 +83,8 @@ public class Evaluator implements BiConsumer<QueryPlanNodeKey, ExecutionContext>
 
   private static void executeComparator(CompiledComparisonMeasure cm, Table writeToTable, Table readFromTable, AComparisonExecutor executor) {
     List<Object> agg = executor.compare(cm, writeToTable, readFromTable);
-    Header header = new Header(cm.alias(), BinaryOperations.getComparisonOutputType(cm.comparisonMethod(), writeToTable.getHeader(cm.measure()).type()), true);
+    Class<?> outputType = cm.comparisonOperator() != null ? UnknownType.class : BinaryOperations.getComparisonOutputType(cm.comparisonMethod(), writeToTable.getHeader(cm.measure()).type());
+    Header header = new Header(cm.alias(), outputType, true);
     writeToTable.addAggregates(header, cm, agg);
   }
 
@@ -139,4 +142,40 @@ public class Evaluator implements BiConsumer<QueryPlanNodeKey, ExecutionContext>
     writeToTable.transferAggregates(readTable, measure);
     return null;
   }
+
+  @Override
+  public Void visit(CompiledVectorTupleAggMeasure measure) {
+    // Retrieve the query scope use for the prefetch, the logic should be the same to retrieve the result.
+    QueryExecutor.QueryScope prefetchQueryScope = new PrefetchVisitor(this.executionContext.columns(), this.executionContext.bucketColumns(), this.executionContext.queryScope())
+            .visit(measure)
+            .keySet()
+            .iterator()
+            .next();
+    Table readTable = this.executionContext.tableByScope().get(prefetchQueryScope);
+    Table writeToTable = this.executionContext.getWriteToTable();
+
+    List<List<Object>> columnValues = new ArrayList<>();
+    // We use the same logic for the measure names as the PrefetchVisitor to retrieve the values
+    int size = measure.fieldToAggregateAndAggFunc().size();
+    for (int i = 0; i < size; i++) {
+      String alias = size > 1 ? measure.alias() + "_" + i : measure.alias();
+      columnValues.add(readTable.getColumnValues(alias));
+    }
+
+    List<Object> vectorValues = ListUtils.createListWithNulls((int) readTable.count());
+    writeToTable.pointDictionary().forEach((point, index) -> {
+      int position = readTable.pointDictionary().getPosition(point);
+      if (position >= 0) {
+        List<Object> v = new ArrayList<>(size);
+        for (int field = 0; field < size; field++) {
+          v.add(columnValues.get(field).get(position));
+        }
+        vectorValues.set(index, measure.transformer() != null ? measure.transformer().apply(v) : v);
+      }
+    });
+
+    Header header = new Header(measure.alias(), Object.class, true);
+    writeToTable.addAggregates(header, measure, vectorValues);
+    return null;
+  }
 }
diff --git a/core/src/main/java/io/squashql/query/compiled/MeasureVisitor.java b/core/src/main/java/io/squashql/query/compiled/MeasureVisitor.java
index 107f435..dd1803c 100644
--- a/core/src/main/java/io/squashql/query/compiled/MeasureVisitor.java
+++ b/core/src/main/java/io/squashql/query/compiled/MeasureVisitor.java
@@ -15,4 +15,6 @@ public interface MeasureVisitor<R> {
   R visit(CompiledLongConstantMeasure measure);
 
   R visit(CompiledVectorAggMeasure measure);
+
+  R visit(CompiledVectorTupleAggMeasure compiledVectorTupleAggMeasure);
 }
diff --git a/core/src/main/java/io/squashql/query/compiled/PrefetchVisitor.java b/core/src/main/java/io/squashql/query/compiled/PrefetchVisitor.java
index 92ab9de..264d387 100644
--- a/core/src/main/java/io/squashql/query/compiled/PrefetchVisitor.java
+++ b/core/src/main/java/io/squashql/query/compiled/PrefetchVisitor.java
@@ -7,7 +7,9 @@ import io.squashql.query.database.SqlUtils;
 import io.squashql.type.AliasedTypedField;
 import io.squashql.type.TypedField;
 import lombok.RequiredArgsConstructor;
+import org.eclipse.collections.api.tuple.Pair;
 import org.eclipse.collections.impl.set.mutable.MutableSetFactoryImpl;
+import org.eclipse.collections.impl.tuple.Tuples;
 
 import java.util.*;
 import java.util.stream.Stream;
@@ -108,12 +110,24 @@ public class PrefetchVisitor implements MeasureVisitor<Map<QueryScope, Set<Compi
      *               from (subquery)
      *               group by ___alias___ticker___, ___alias___riskType___
      */
-    TypedField vectorAxis = vectorAggMeasure.vectorAxis();
-    TypedField fieldToAggregate = vectorAggMeasure.fieldToAggregate();
-    String vectorAggFunc = vectorAggMeasure.aggregationFunction();
+    return visit(new CompiledVectorTupleAggMeasure(
+            vectorAggMeasure.alias(),
+            List.of(Tuples.pair(vectorAggMeasure.fieldToAggregate(), vectorAggMeasure.aggregationFunction())),
+            vectorAggMeasure.vectorAxis(),
+            null));
+  }
+
+  @Override
+  public Map<QueryScope, Set<CompiledMeasure>> visit(CompiledVectorTupleAggMeasure vecMeasure) {
+    TypedField vectorAxis = vecMeasure.vectorAxis();
+    List<TypedField> fieldToAggregates = vecMeasure.fieldToAggregateAndAggFunc().stream().map(Pair::getOne).toList();
+    List<String> vectorAggFuncs = vecMeasure.fieldToAggregateAndAggFunc().stream().map(Pair::getTwo).toList();
     if (this.originalQueryScope.columns().contains(vectorAxis)) {
-      var m = new CompiledAggregatedMeasure(vectorAggMeasure.alias(), fieldToAggregate, vectorAggFunc, null, false);
-      return Map.of(this.originalQueryScope, Set.of(m));
+      Set<CompiledMeasure> m = new HashSet<>();
+      for (int i = 0; i < fieldToAggregates.size(); i++) {
+        m.add(new CompiledAggregatedMeasure(vecMeasure.alias(), fieldToAggregates.get(i), vectorAggFuncs.get(i), null, false));
+      }
+      return Map.of(this.originalQueryScope, m);
     } else {
       Set<CompiledMeasure> subQueryMeasures = new HashSet<>();
       List<TypedField> topQuerySelectColumns = new ArrayList<>();
@@ -135,7 +149,6 @@ public class PrefetchVisitor implements MeasureVisitor<Map<QueryScope, Set<Compi
                 topQueryMeasures.add(new CompiledAggregatedMeasure(SqlUtils.groupingAlias(alias), new AliasedTypedField(groupingAlias), MAX, null, false));
               });
 
-
       String vectorAxisAlias = safeColumnAlias(SqlUtils.squashqlExpression(vectorAxis));
       List<TypedField> subQueryRollupColumns = new ArrayList<>();
       List<List<TypedField>> subQueryGroupingSets = new ArrayList<>();
@@ -159,8 +172,14 @@ public class PrefetchVisitor implements MeasureVisitor<Map<QueryScope, Set<Compi
         }
       }
 
-      String subQueryMeasureAlias = safeColumnAlias(fieldToAggregate.name() + "_" + vectorAggFunc);
-      subQueryMeasures.add(new CompiledAggregatedMeasure(subQueryMeasureAlias, fieldToAggregate, vectorAggFunc, null, false));
+      List<String> subQueryMeasureAliases = new ArrayList<>();
+      for (int i = 0; i < fieldToAggregates.size(); i++) {
+        TypedField fieldToAggregate = fieldToAggregates.get(i);
+        String vectorAggFunc = vectorAggFuncs.get(i);
+        String subQueryMeasureAlias = safeColumnAlias(fieldToAggregate.name() + "_" + vectorAggFunc);
+        subQueryMeasureAliases.add(subQueryMeasureAlias);
+        subQueryMeasures.add(new CompiledAggregatedMeasure(subQueryMeasureAlias, fieldToAggregate, vectorAggFunc, null, false));
+      }
 
       DatabaseQuery subQuery = new DatabaseQuery(this.originalQueryScope.virtualTable(),
               this.originalQueryScope.table(),
@@ -184,7 +203,11 @@ public class PrefetchVisitor implements MeasureVisitor<Map<QueryScope, Set<Compi
               this.originalQueryScope.virtualTable(),
               this.originalQueryScope.limit());
 
-      topQueryMeasures.add(new CompiledAggregatedMeasure(vectorAggMeasure.alias(), new AliasedTypedField(subQueryMeasureAlias), ARRAY_AGG, null, false));
+      int size = subQueryMeasureAliases.size();
+      for (int i = 0; i < size; i++) {
+        String alias = size > 1 ? vecMeasure.alias() + "_" + i : vecMeasure.alias();
+        topQueryMeasures.add(new CompiledAggregatedMeasure(alias, new AliasedTypedField(subQueryMeasureAliases.get(i)), ARRAY_AGG, null, false));
+      }
       return Map.of(topQueryScope, topQueryMeasures);
     }
   }
diff --git a/core/src/main/java/io/squashql/query/compiled/PrimitiveMeasureVisitor.java b/core/src/main/java/io/squashql/query/compiled/PrimitiveMeasureVisitor.java
index 33ce12f..0c41c32 100644
--- a/core/src/main/java/io/squashql/query/compiled/PrimitiveMeasureVisitor.java
+++ b/core/src/main/java/io/squashql/query/compiled/PrimitiveMeasureVisitor.java
@@ -34,7 +34,13 @@ public class PrimitiveMeasureVisitor implements MeasureVisitor<Boolean> {
 
   @Override
   public Boolean visit(CompiledVectorAggMeasure measure) {
-    // This measure is "indirectly" computed by the underlying DB but it is not a primitive. See PrefetchVisitor.
+    // This measure is "indirectly" computed by the underlying DB, but it is not a primitive. See PrefetchVisitor.
+    return false;
+  }
+
+  @Override
+  public Boolean visit(CompiledVectorTupleAggMeasure compiledVectorTupleAggMeasure) {
+    // This measure is "indirectly" computed by the underlying DB, but it is not a primitive. See PrefetchVisitor.
     return false;
   }
 }
diff --git a/core/src/main/java/io/squashql/query/database/DatabaseQuery.java b/core/src/main/java/io/squashql/query/database/DatabaseQuery.java
index cbce065..31dec28 100644
--- a/core/src/main/java/io/squashql/query/database/DatabaseQuery.java
+++ b/core/src/main/java/io/squashql/query/database/DatabaseQuery.java
@@ -7,13 +7,11 @@ import io.squashql.query.dto.VirtualTableDto;
 import io.squashql.type.TypedField;
 import lombok.EqualsAndHashCode;
 import lombok.RequiredArgsConstructor;
-import lombok.ToString;
 
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Set;
 
-@ToString
 @EqualsAndHashCode
 @RequiredArgsConstructor
 public class DatabaseQuery {
@@ -22,8 +20,8 @@ public class DatabaseQuery {
   public final CompiledTable table;
   public final DatabaseQuery subQuery;
   public final Set<TypedField> select;
-  public final CompiledCriteria whereCriteriaDto;
-  public final CompiledCriteria havingCriteriaDto;
+  public final CompiledCriteria whereCriteria;
+  public final CompiledCriteria havingCriteria;
   public final List<TypedField> rollup;
   public final List<List<TypedField>> groupingSets;
   public final int limit;
@@ -33,4 +31,37 @@ public class DatabaseQuery {
     this.measures.add(m);
     return this;
   }
+
+  @Override
+  public String toString() {
+    final StringBuilder sb = new StringBuilder("DatabaseQuery{");
+    sb.append("table=").append(table);
+    if (subQuery != null) {
+      sb.append(", subQuery=").append(subQuery);
+    }
+    if (select != null && !select.isEmpty()) {
+      sb.append(", columns=").append(select);
+    }
+    if (whereCriteria != null) {
+      sb.append(", whereCriteria=").append(whereCriteria);
+    }
+    if (havingCriteria != null) {
+      sb.append(", havingCriteria=").append(havingCriteria);
+    }
+    if (rollup != null && !rollup.isEmpty()) {
+      sb.append(", rollupColumns=").append(rollup);
+    }
+    if (groupingSets != null && !groupingSets.isEmpty()) {
+      sb.append(", groupingSets=").append(groupingSets);
+    }
+    if (virtualTableDto != null) {
+      sb.append(", virtualTable=").append(virtualTableDto);
+    }
+    if (limit > 0) {
+      sb.append(", limit=").append(limit);
+    }
+    sb.append(", measures=").append(measures);
+    sb.append('}');
+    return sb.toString();
+  }
 }
diff --git a/core/src/main/java/io/squashql/query/database/SQLTranslator.java b/core/src/main/java/io/squashql/query/database/SQLTranslator.java
index 4fd5543..ed727c0 100644
--- a/core/src/main/java/io/squashql/query/database/SQLTranslator.java
+++ b/core/src/main/java/io/squashql/query/database/SQLTranslator.java
@@ -49,7 +49,7 @@ public class SQLTranslator {
     } else {
       addGroupByAndRollup(groupBy, query.rollup.stream().map(queryRewriter::rollup).toList(), queryRewriter.usePartialRollupSyntax(), statement);
     }
-    addHavingConditions(statement, query.havingCriteriaDto, queryRewriter);
+    addHavingConditions(statement, query.havingCriteria, queryRewriter);
     addLimit(query.limit, statement);
     return statement.toString();
   }
@@ -161,8 +161,8 @@ public class SQLTranslator {
   }
 
   protected static void addWhereConditions(StringBuilder statement, DatabaseQuery query, QueryRewriter queryRewriter) {
-    if (query.whereCriteriaDto != null) {
-      String whereClause = query.whereCriteriaDto.sqlExpression(queryRewriter);
+    if (query.whereCriteria != null) {
+      String whereClause = query.whereCriteria.sqlExpression(queryRewriter);
       if (whereClause != null) {
         statement
                 .append(" where ")
diff --git a/core/src/main/java/io/squashql/table/ColumnarTable.java b/core/src/main/java/io/squashql/table/ColumnarTable.java
index 254fc5b..921dbe6 100644
--- a/core/src/main/java/io/squashql/table/ColumnarTable.java
+++ b/core/src/main/java/io/squashql/table/ColumnarTable.java
@@ -4,6 +4,7 @@ import com.google.common.base.Suppliers;
 import io.squashql.query.Header;
 import io.squashql.query.compiled.CompiledMeasure;
 import io.squashql.query.dictionary.ObjectArrayDictionary;
+import io.squashql.util.ListUtils;
 
 import java.util.*;
 import java.util.function.Supplier;
@@ -51,7 +52,6 @@ public class ColumnarTable implements Table {
     this.values.add(values);
   }
 
-
   /**
    * BE CAREFUL !! This method assumes this table and the table from passed in arguments have the same headers
    * {@code Header#isMeasure == false} in the same order.
@@ -67,10 +67,7 @@ public class ColumnarTable implements Table {
     }
 
     if (from instanceof ColumnarTable ct) {
-      List<Object> values = new ArrayList<>((int) count());
-      for (int i = 0; i < (int) count(); i++) {
-        values.add(null);
-      }
+      List<Object> values = ListUtils.createListWithNulls((int) count());
       List<Object> aggregateValues = from.getAggregateValues(measure);
       this.pointDictionary.get().forEach((point, index) -> {
         int position = ct.pointDictionary.get().getPosition(point);
diff --git a/core/src/main/java/io/squashql/table/PivotTableUtils.java b/core/src/main/java/io/squashql/table/PivotTableUtils.java
index 4a64874..d8fd69a 100644
--- a/core/src/main/java/io/squashql/table/PivotTableUtils.java
+++ b/core/src/main/java/io/squashql/table/PivotTableUtils.java
@@ -1,5 +1,7 @@
 package io.squashql.table;
 
+import io.squashql.util.ListUtils;
+
 import java.util.*;
 import java.util.function.Supplier;
 
@@ -20,11 +22,7 @@ public final class PivotTableUtils {
     int size = columnHeaderValues.size() * values.size();
     // Prepare the lists
     columns.forEach(__ -> {
-      List<Object> r = new ArrayList<>(size);
-      for (int i = 0; i < size; i++) {
-        r.add(null);
-      }
-      headerColumns.add(r);
+      headerColumns.add(ListUtils.createListWithNulls(size));
     });
 
     // Fill the lists
diff --git a/core/src/main/java/io/squashql/table/TableUtils.java b/core/src/main/java/io/squashql/table/TableUtils.java
index 739d652..0e41257 100644
--- a/core/src/main/java/io/squashql/table/TableUtils.java
+++ b/core/src/main/java/io/squashql/table/TableUtils.java
@@ -20,6 +20,8 @@ import java.util.function.Predicate;
 import java.util.function.Supplier;
 import java.util.stream.Stream;
 
+import static io.squashql.util.ListUtils.reorder;
+
 public class TableUtils {
 
   public static String toString(Iterable<List<Object>> rows,
@@ -234,14 +236,6 @@ public class TableUtils {
     return new ColumnarTable(headers, table.measures, values);
   }
 
-  public static List<Object> reorder(List<?> list, int[] order) {
-    List<Object> ordered = new ArrayList<>(list);
-    for (int i = 0; i < list.size(); i++) {
-      ordered.set(i, list.get(order[i]));
-    }
-    return ordered;
-  }
-
   /**
    * Replaces cell values containing {@link SQLTranslator#TOTAL_CELL} with {@link QueryEngine#GRAND_TOTAL} or
    * {@link QueryEngine#TOTAL}.
diff --git a/core/src/main/java/io/squashql/util/ListUtils.java b/core/src/main/java/io/squashql/util/ListUtils.java
new file mode 100644
index 0000000..7a07dee
--- /dev/null
+++ b/core/src/main/java/io/squashql/util/ListUtils.java
@@ -0,0 +1,26 @@
+package io.squashql.util;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public final class ListUtils {
+
+  private ListUtils() {
+  }
+
+  public static <T> List<T> reorder(List<T> list, int[] order) {
+    List<T> ordered = new ArrayList<>(list);
+    for (int i = 0; i < list.size(); i++) {
+      ordered.set(i, list.get(order[i]));
+    }
+    return ordered;
+  }
+
+  public static <T> List<T> createListWithNulls(int size) {
+    List<T> l = new ArrayList<>(size);
+    for (int i = 0; i < size; i++) {
+      l.add(null);
+    }
+    return l;
+  }
+}
diff --git a/duckdb/src/main/java/io/squashql/DuckDBUtil.java b/duckdb/src/main/java/io/squashql/DuckDBUtil.java
index fb0df67..9142b12 100644
--- a/duckdb/src/main/java/io/squashql/DuckDBUtil.java
+++ b/duckdb/src/main/java/io/squashql/DuckDBUtil.java
@@ -35,6 +35,8 @@ public final class DuckDBUtil {
           return Lists.DoubleList.class;
         } else if (typeName.equals("LONG[]") || typeName.equals("HUGEINT[]") || typeName.equals("INT[]")) {
           return Lists.LongList.class;
+        } else if (typeName.equals("DATE[]")) {
+          return Lists.LocalDateList.class;
         } else {
           return List.class; // we convert Array to List
         }


Test patch:
diff --git a/bigquery/src/test/java/io/squashql/query/TestBigQueryVectorOperation.java b/bigquery/src/test/java/io/squashql/query/TestBigQueryVectorOperation.java
new file mode 100644
index 0000000..6497a76
--- /dev/null
+++ b/bigquery/src/test/java/io/squashql/query/TestBigQueryVectorOperation.java
@@ -0,0 +1,51 @@
+package io.squashql.query;
+
+import io.squashql.BigQueryDatastore;
+import io.squashql.BigQueryServiceAccountDatastore;
+import io.squashql.query.database.BigQueryEngine;
+import io.squashql.query.database.QueryEngine;
+import io.squashql.store.Datastore;
+import io.squashql.transaction.BigQueryDataLoader;
+import io.squashql.transaction.DataLoader;
+import org.junit.jupiter.api.AfterAll;
+
+import static io.squashql.query.BigQueryTestUtil.DATASET_NAME;
+import static io.squashql.query.BigQueryTestUtil.PROJECT_ID;
+
+/**
+ * Do not edit this class, it has been generated automatically by {@link io.squashql.template.BigQueryClassTemplateGenerator}.
+ */
+public class TestBigQueryVectorOperation extends ATestVectorOperation {
+
+  @AfterAll
+  void tearDown() {
+    this.fieldsByStore.forEach((store, fields) -> BigQueryTestUtil.deleteTable(((BigQueryDatastore) this.datastore).getBigquery(), store));
+  }
+
+  @Override
+  protected void createTables() {
+    BigQueryDataLoader tm = (BigQueryDataLoader) this.tm;
+    BigQueryTestUtil.createDatasetIfDoesNotExist(tm.getBigQuery(), DATASET_NAME);
+    this.fieldsByStore.forEach((store, fields) -> tm.dropAndCreateInMemoryTable(store, fields));
+  }
+
+  @Override
+  protected QueryEngine createQueryEngine(Datastore datastore) {
+    return new BigQueryEngine((BigQueryDatastore) datastore);
+  }
+
+  @Override
+  protected Datastore createDatastore() {
+    return new BigQueryServiceAccountDatastore(BigQueryTestUtil.createServiceAccountCredentials(), PROJECT_ID, DATASET_NAME);
+  }
+
+  @Override
+  protected DataLoader createDataLoader() {
+    return new BigQueryDataLoader(((BigQueryDatastore) this.datastore).getBigquery(), DATASET_NAME);
+  }
+
+  @Override
+  protected Object translate(Object o) {
+    return BigQueryTestUtil.translate(o);
+  }
+}
diff --git a/clickhouse/src/test/java/io/squashql/query/TestClickHouseVectorOperation.java b/clickhouse/src/test/java/io/squashql/query/TestClickHouseVectorOperation.java
new file mode 100644
index 0000000..810547e
--- /dev/null
+++ b/clickhouse/src/test/java/io/squashql/query/TestClickHouseVectorOperation.java
@@ -0,0 +1,53 @@
+package io.squashql.query;
+
+import io.squashql.ClickHouseDatastore;
+import io.squashql.query.database.ClickHouseQueryEngine;
+import io.squashql.query.database.QueryEngine;
+import io.squashql.store.Datastore;
+import io.squashql.template.ClickHouseClassTemplateGenerator;
+import io.squashql.transaction.ClickHouseDataLoader;
+import io.squashql.transaction.DataLoader;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+
+/**
+ * Do not edit this class, it has been generated automatically by {@link ClickHouseClassTemplateGenerator}.
+ */
+public class TestClickHouseVectorOperation extends ATestVectorOperation {
+
+  public org.testcontainers.containers.GenericContainer container = ClickHouseTestUtil.createClickHouseContainer();
+
+  @BeforeAll
+  @Override
+  void setup() {
+    this.container.start();
+    super.setup();
+  }
+
+  @AfterAll
+  void tearDown() {
+    // we do not stop the container to be able to reuse it between tests.
+    ((ClickHouseDataLoader) this.tm).dropTables(this.fieldsByStore.keySet());
+  }
+
+  @Override
+  protected void createTables() {
+    ClickHouseDataLoader tm = (ClickHouseDataLoader) this.tm;
+    this.fieldsByStore.forEach((store, fields) -> tm.dropAndCreateInMemoryTable(store, fields));
+  }
+
+  @Override
+  protected QueryEngine createQueryEngine(Datastore datastore) {
+    return new ClickHouseQueryEngine((ClickHouseDatastore) datastore);
+  }
+
+  @Override
+  protected Datastore createDatastore() {
+    return new ClickHouseDatastore(ClickHouseTestUtil.jdbcUrl.apply(this.container));
+  }
+
+  @Override
+  protected DataLoader createDataLoader() {
+    return new ClickHouseDataLoader(((ClickHouseDatastore) this.datastore).dataSource);
+  }
+}
diff --git a/core/src/test/java/io/squashql/query/ATestParentComparison.java b/core/src/test/java/io/squashql/query/ATestParentComparison.java
index eadfe05..318b316 100644
--- a/core/src/test/java/io/squashql/query/ATestParentComparison.java
+++ b/core/src/test/java/io/squashql/query/ATestParentComparison.java
@@ -24,7 +24,11 @@ import static io.squashql.transaction.DataLoader.MAIN_SCENARIO_NAME;
 @TestInstance(TestInstance.Lifecycle.PER_CLASS)
 public abstract class ATestParentComparison extends ABaseTestQuery {
 
-  protected String storeName = "store" + getClass().getSimpleName().toLowerCase();
+  private String storeName = "store" + getClass().getSimpleName().toLowerCase();
+  private TableField city = new TableField(this.storeName, "city");
+  private TableField country = new TableField(this.storeName, "country");
+  private TableField continent = new TableField(this.storeName, "continent");
+  private TableField population = new TableField(this.storeName, "population");
 
   @Override
   protected Map<String, List<TableTypedField>> getFieldsByStore() {
@@ -52,8 +56,8 @@ public abstract class ATestParentComparison extends ABaseTestQuery {
 
   @Test
   void testSimple() {
-    Measure pop = Functions.sum("population", "population");
-    final List<Field> fields = tableFields(List.of("continent", "country", "city"));
+    Measure pop = Functions.sum("population", this.population);
+    final List<Field> fields = List.of(this.continent, this.country, this.city);
     ComparisonMeasureReferencePosition pOp = new ComparisonMeasureReferencePosition("percentOfParent", DIVIDE, pop, fields);
     QueryDto query = Query
             .from(this.storeName)
@@ -74,7 +78,7 @@ public abstract class ATestParentComparison extends ABaseTestQuery {
 
     query = Query
             .from(this.storeName)
-            .select(tableFields(List.of("continent", "country", "city")), List.of(pOp))
+            .select(List.of(this.continent, this.country, this.city), List.of(pOp))
             .build(); // query only parent
 
     result = this.executor.executeQuery(query);
@@ -92,12 +96,12 @@ public abstract class ATestParentComparison extends ABaseTestQuery {
 
   @Test
   void testClearFilter() {
-    Measure pop = Functions.sum("population", "population");
-    List<Field> fields = tableFields(List.of("continent", "country", "city"));
+    Measure pop = Functions.sum("population", this.population);
+    List<Field> fields = List.of(this.continent, this.country, this.city);
     ComparisonMeasureReferencePosition pOp = new ComparisonMeasureReferencePosition("percentOfParent", DIVIDE, pop, fields);
     QueryDto query = Query
             .from(this.storeName)
-            .where(criterion("city", in("montreal", "toronto")))
+            .where(criterion(this.city, in("montreal", "toronto")))
             .select(fields, List.of(pOp))
             .build(); // query only parent
 
@@ -108,8 +112,8 @@ public abstract class ATestParentComparison extends ABaseTestQuery {
 
     query = Query
             .from(this.storeName)
-            .where(criterion("country", eq("canada")))
-            .select(tableFields(List.of("continent", "country", "city")), List.of(pOp))
+            .where(criterion(this.country, eq("canada")))
+            .select(List.of(this.continent, this.country, this.city), List.of(pOp))
             .build(); // query only parent
 
     result = this.executor.executeQuery(query);
@@ -120,8 +124,8 @@ public abstract class ATestParentComparison extends ABaseTestQuery {
 
     query = Query
             .from(this.storeName)
-            .where(criterion("continent", eq("eu")))
-            .select(tableFields(List.of("continent", "country", "city")), List.of(pOp))
+            .where(criterion(this.continent, eq("eu")))
+            .select(List.of(this.continent, this.country, this.city), List.of(pOp))
             .build(); // query only parent
     result = this.executor.executeQuery(query);
     Assertions.assertThat(result).containsExactly(
@@ -133,11 +137,11 @@ public abstract class ATestParentComparison extends ABaseTestQuery {
 
   @Test
   void testWithMissingAncestor() {
-    Measure pop = Functions.sum("population", "population");
+    Measure pop = Functions.sum("population", this.population);
     ComparisonMeasureReferencePosition pOp = new ComparisonMeasureReferencePosition("percentOfParent", DIVIDE, pop, tableFields(List.of("country", "continent")));
     QueryDto query = Query
             .from(this.storeName)
-            .select(tableFields(List.of("continent", "country", "city")), List.of(pop, pOp))
+            .select(List.of(this.continent, this.country, this.city), List.of(pop, pOp))
             .build(); // query only parent
 
     Table result = this.executor.executeQuery(query);
@@ -158,14 +162,14 @@ public abstract class ATestParentComparison extends ABaseTestQuery {
   void testWithCalculatedMeasure() {
     // Note this calculation may look weird but the goal of this test is to make sure we can compute the percent of
     // parent with a measure computed by SquashQL.
-    Measure pop = Functions.sum("populationsum", "population");
+    Measure pop = Functions.sum("populationsum", this.population);
     Measure pop2 = Functions.plus("pop2", pop, Functions.integer(2));
-    List<Field> select = tableFields(List.of("continent", "country", "city"));
+    List<Field> select = List.of(this.continent, this.country, this.city);
     ComparisonMeasureReferencePosition pOp = new ComparisonMeasureReferencePosition("percentOfParent", DIVIDE, pop, select);
     ComparisonMeasureReferencePosition pOp2 = new ComparisonMeasureReferencePosition("percentOfParent2", DIVIDE, pop2, select);
     QueryDto query = Query
             .from(this.storeName)
-            .where(criterion("country", eq("canada"))) // use a filter to limit the number of rows
+            .where(criterion(this.country, eq("canada"))) // use a filter to limit the number of rows
             .select(select, List.of(pop, pOp, pop2, pOp2))
             .build(); // query only parent
 
@@ -179,8 +183,8 @@ public abstract class ATestParentComparison extends ABaseTestQuery {
 
   @Test
   void testSimpleWithTotals() {
-    Measure pop = Functions.sum("population", "population");
-    List<Field> fields = tableFields(List.of("continent", "country", "city"));
+    Measure pop = Functions.sum("population", this.population);
+    List<Field> fields = List.of(this.continent, this.country, this.city);
     ComparisonMeasureReferencePosition pOp = new ComparisonMeasureReferencePosition("percentOfParent", DIVIDE, pop, fields);
     QueryDto query = Query
             .from(this.storeName)
diff --git a/core/src/test/java/io/squashql/query/ATestVectorAggregation.java b/core/src/test/java/io/squashql/query/ATestVectorAggregation.java
index 8aec4bd..cb3c0ae 100644
--- a/core/src/test/java/io/squashql/query/ATestVectorAggregation.java
+++ b/core/src/test/java/io/squashql/query/ATestVectorAggregation.java
@@ -11,6 +11,7 @@ import io.squashql.table.Table;
 import io.squashql.type.TableTypedField;
 import io.squashql.util.TestUtil;
 import org.assertj.core.api.Assertions;
+import org.eclipse.collections.impl.tuple.Tuples;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.TestInstance;
 
@@ -18,6 +19,7 @@ import java.time.LocalDate;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
+import java.util.function.BiConsumer;
 
 import static io.squashql.query.agg.AggregationFunction.SUM;
 import static io.squashql.query.database.QueryEngine.GRAND_TOTAL;
@@ -35,7 +37,11 @@ public abstract class ATestVectorAggregation extends ABaseTestQuery {
   static final LocalDate d1 = LocalDate.of(2023, 1, 1);
   static final LocalDate d2 = LocalDate.of(2023, 1, 2);
   static final LocalDate d3 = LocalDate.of(2023, 1, 3);
-  String storeName = "store" + getClass().getSimpleName().toLowerCase();
+  final String storeName = "store" + getClass().getSimpleName().toLowerCase();
+  final Field ean = new TableField(this.storeName, "ean");
+  final Field competitor = new TableField(this.storeName, "competitor");
+  final Field value = new TableField(this.storeName, "price");
+  final Field date = new TableField(this.storeName, "date");
   GlobalCache queryCache;
 
   @Override
@@ -79,19 +85,15 @@ public abstract class ATestVectorAggregation extends ABaseTestQuery {
 
   @Test
   void testCrossjoinOneWithTotals() {
-    Field ean = new TableField(this.storeName, "ean");
-    Field value = new TableField(this.storeName, "price");
-    Field date = new TableField(this.storeName, "date");
-
-    Measure vector = new VectorAggMeasure("vector", value, SUM, date);
+    Measure vector = new VectorAggMeasure("vector", this.value, SUM, this.date);
     QueryDto query = Query
             .from(this.storeName)
-            .select(List.of(ean), List.of(vector))
-            .rollup(List.of(ean))
+            .select(List.of(this.ean), List.of(vector))
+            .rollup(List.of(this.ean))
             .build();
     Table result = this.executor.executeQuery(query);
     Assertions.assertThat(result.headers().stream().map(Header::name))
-            .containsExactly(ean.name(), vector.alias());
+            .containsExactly(this.ean.name(), vector.alias());
     List<List<Object>> points = List.of(List.of(GRAND_TOTAL), List.of(productA), List.of(productB));
     List<List<Number>> expectedVectors = List.of(
             List.of(6006d, 33033d, 303303d),
@@ -102,18 +104,14 @@ public abstract class ATestVectorAggregation extends ABaseTestQuery {
 
   @Test
   void testCrossjoinOneWithoutTotals() {
-    Field ean = new TableField(this.storeName, "ean");
-    Field value = new TableField(this.storeName, "price");
-    Field date = new TableField(this.storeName, "date");
-
-    Measure vector = new VectorAggMeasure("vector", value, SUM, date);
+    Measure vector = new VectorAggMeasure("vector", this.value, SUM, this.date);
     QueryDto query = Query
             .from(this.storeName)
-            .select(List.of(ean), List.of(vector, CountMeasure.INSTANCE))
+            .select(List.of(this.ean), List.of(vector, CountMeasure.INSTANCE))
             .build();
     Table result = this.executor.executeQuery(query);
     Assertions.assertThat(result.headers().stream().map(Header::name))
-            .containsExactly(ean.name(), vector.alias(), CountMeasure.ALIAS);
+            .containsExactly(this.ean.name(), vector.alias(), CountMeasure.ALIAS);
     List<List<Object>> points = List.of(List.of(productA), List.of(productB));
     List<List<Number>> expectedVectors = List.of(
             List.of(6d, 33d, 303d),
@@ -124,20 +122,15 @@ public abstract class ATestVectorAggregation extends ABaseTestQuery {
 
   @Test
   void testCrossjoinTwoWithTotals() {
-    Field ean = new TableField(this.storeName, "ean");
-    Field competitor = new TableField(this.storeName, "competitor");
-    Field value = new TableField(this.storeName, "price");
-    Field date = new TableField(this.storeName, "date");
-
-    Measure vector = new VectorAggMeasure("vector", value, SUM, date);
+    Measure vector = new VectorAggMeasure("vector", this.value, SUM, this.date);
     QueryDto query = Query
             .from(this.storeName)
-            .select(List.of(ean, competitor), List.of(vector))
-            .rollup(List.of(ean, competitor))
+            .select(List.of(this.ean, this.competitor), List.of(vector))
+            .rollup(List.of(this.ean, this.competitor))
             .build();
     Table result = this.executor.executeQuery(query);
     Assertions.assertThat(result.headers().stream().map(Header::name))
-            .containsExactly(ean.name(), competitor.name(), vector.alias());
+            .containsExactly(this.ean.name(), this.competitor.name(), vector.alias());
     List<List<Object>> points = List.of(
             List.of(GRAND_TOTAL, GRAND_TOTAL),
             List.of(productA, TOTAL),
@@ -163,19 +156,14 @@ public abstract class ATestVectorAggregation extends ABaseTestQuery {
 
   @Test
   void testCrossjoinTwoWithoutTotals() {
-    Field ean = new TableField(this.storeName, "ean");
-    Field competitor = new TableField(this.storeName, "competitor");
-    Field value = new TableField(this.storeName, "price");
-    Field date = new TableField(this.storeName, "date");
-
-    Measure vector = new VectorAggMeasure("vector", value, SUM, date);
+    Measure vector = new VectorAggMeasure("vector", this.value, SUM, this.date);
     QueryDto query = Query
             .from(this.storeName)
-            .select(List.of(ean, competitor), List.of(vector, CountMeasure.INSTANCE))
+            .select(List.of(this.ean, this.competitor), List.of(vector, CountMeasure.INSTANCE))
             .build();
     Table result = this.executor.executeQuery(query);
     Assertions.assertThat(result.headers().stream().map(Header::name))
-            .containsExactly(ean.name(), competitor.name(), vector.alias(), CountMeasure.ALIAS);
+            .containsExactly(this.ean.name(), this.competitor.name(), vector.alias(), CountMeasure.ALIAS);
     List<List<Object>> points = List.of(
             List.of(productA, competitorZ),
             List.of(productA, competitorY),
@@ -198,20 +186,16 @@ public abstract class ATestVectorAggregation extends ABaseTestQuery {
    */
   @Test
   void testSimpleWithVectorAxisInSelect() {
-    Field ean = new TableField(this.storeName, "ean");
-    Field value = new TableField(this.storeName, "price");
-    Field date = new TableField(this.storeName, "date");
-
-    Measure vector = new VectorAggMeasure("vector", value, SUM, date);
-    Measure vectorSum = new AggregatedMeasure("vectorSum", value, SUM, false);
+    Measure vector = new VectorAggMeasure("vector", this.value, SUM, this.date);
+    Measure vectorSum = new AggregatedMeasure("vectorSum", this.value, SUM, false);
     QueryDto query = Query
             .from(this.storeName)
-            .select(List.of(ean, date), List.of(vector, vectorSum))
-            .rollup(List.of(ean, date))
+            .select(List.of(this.ean, this.date), List.of(vector, vectorSum))
+            .rollup(List.of(this.ean, this.date))
             .build();
     Table result = this.executor.executeQuery(query);
     Assertions.assertThat(result.headers().stream().map(Header::name))
-            .containsExactly(ean.name(), date.name(), vector.alias(), vectorSum.alias());
+            .containsExactly(this.ean.name(), this.date.name(), vector.alias(), vectorSum.alias());
     Assertions.assertThat(result).containsExactly(
             List.of(GRAND_TOTAL, GRAND_TOTAL, 342342d, 342342d),
             List.of(productA, TOTAL, 342d, 342d),
@@ -226,20 +210,15 @@ public abstract class ATestVectorAggregation extends ABaseTestQuery {
 
   @Test
   void testSimpleWithOtherMeasure() {
-    Field ean = new TableField(this.storeName, "ean");
-    Field competitor = new TableField(this.storeName, "competitor");
-    Field value = new TableField(this.storeName, "price");
-    Field date = new TableField(this.storeName, "date");
-
-    Measure vector = new VectorAggMeasure("vector", value, SUM, date);
+    Measure vector = new VectorAggMeasure("vector", this.value, SUM, this.date);
     QueryDto query = Query
             .from(this.storeName)
-            .select(List.of(ean, competitor), List.of(vector, CountMeasure.INSTANCE))
-            .rollup(List.of(ean, competitor))
+            .select(List.of(this.ean, this.competitor), List.of(vector, CountMeasure.INSTANCE))
+            .rollup(List.of(this.ean, this.competitor))
             .build();
     Table result = this.executor.executeQuery(query);
     Assertions.assertThat(result.headers().stream().map(Header::name))
-            .containsExactly(ean.name(), competitor.name(), vector.alias(), CountMeasure.ALIAS);
+            .containsExactly(this.ean.name(), this.competitor.name(), vector.alias(), CountMeasure.ALIAS);
     List<List<Object>> points = List.of(
             List.of(GRAND_TOTAL, GRAND_TOTAL),
             List.of(productA, TOTAL),
@@ -267,22 +246,19 @@ public abstract class ATestVectorAggregation extends ABaseTestQuery {
 
   @Test
   void testCache() {
-    Field ean = new TableField(this.storeName, "ean");
-    Field competitor = new TableField(this.storeName, "competitor");
-    Field value = new TableField(this.storeName, "price");
-    Field date = new TableField(this.storeName, "date");
+    this.queryCache.clear();
 
-    Measure vector = new VectorAggMeasure("vector", value, SUM, date);
+    Measure vector = new VectorAggMeasure("vector", this.value, SUM, this.date);
     QueryDto query = Query
             .from(this.storeName)
-            .select(List.of(ean, competitor), List.of(vector))
-            .rollup(List.of(ean, competitor))
+            .select(List.of(this.ean, this.competitor), List.of(vector))
+            .rollup(List.of(this.ean, this.competitor))
             .build();
 
     Runnable r = () -> {
       Table result = this.executor.executeQuery(query);
       Assertions.assertThat(result.headers().stream().map(Header::name))
-              .containsExactly(ean.name(), competitor.name(), vector.alias());
+              .containsExactly(this.ean.name(), this.competitor.name(), vector.alias());
       List<List<Object>> points = List.of(
               List.of(GRAND_TOTAL, GRAND_TOTAL),
               List.of(productA, TOTAL),
@@ -309,10 +285,73 @@ public abstract class ATestVectorAggregation extends ABaseTestQuery {
     int hitCount = (int) this.queryCache.stats(null).hitCount;
     int missCount = (int) this.queryCache.stats(null).missCount;
     r.run();
-    TestUtil.assertCacheStats(this.queryCache, hitCount + 0, missCount + 5);
+    TestUtil.assertCacheStats(this.queryCache, hitCount, missCount + 3);
 
     r.run();
-    TestUtil.assertCacheStats(this.queryCache, hitCount + 4, missCount + 5);
+    TestUtil.assertCacheStats(this.queryCache, hitCount + 2, missCount + 3);
+  }
+
+  @Test
+  void testGroupingMeasuresAreNotCached() {
+    this.queryCache.clear();
+
+    // Do not use the same alia and the same transformer instance. We want the measure not to be equal.
+    Measure vectorWoTransformer = new VectorTupleAggMeasure("vectorWoTransformer", List.of(Tuples.pair(this.value, SUM)), this.date, a -> a.get(0));
+    Measure vectorWithTransformer = new VectorTupleAggMeasure("vectorWithTransformer", List.of(Tuples.pair(this.value, SUM)), this.date, a -> a.get(0));
+    QueryDto q1 = Query
+            .from(this.storeName)
+            .select(List.of(this.ean, this.competitor), List.of(vectorWoTransformer))
+            .rollup(List.of(this.ean, this.competitor))
+            .build();
+
+    int hitCount = (int) this.queryCache.stats(null).hitCount;
+    int missCount = (int) this.queryCache.stats(null).missCount;
+    // Make sure to not cache the grouping measures, see testWithNullValueAndRollup. Similar issue.
+    Table result = this.executor.executeQuery(q1);
+    TestUtil.assertCacheStats(this.queryCache, hitCount, missCount + 3);
+    Assertions.assertThat(result.headers().stream().map(Header::name))
+            .containsExactly(this.ean.name(), this.competitor.name(), vectorWoTransformer.alias());
+
+    BiConsumer<Table, Measure> resultChecker = (table, vector) -> {
+      List<List<Object>> points = List.of(
+              List.of(GRAND_TOTAL, GRAND_TOTAL),
+              List.of(productA, TOTAL),
+              List.of(productA, competitorZ),
+              List.of(productA, competitorY),
+              List.of(productA, competitorX),
+              List.of(productB, TOTAL),
+              List.of(productB, competitorZ),
+              List.of(productB, competitorY),
+              List.of(productB, competitorX));
+      List<List<Number>> expectedVectors = List.of(
+              List.of(6006d, 33033d, 303303d),
+              List.of(6d, 33d, 303d),
+              List.of(3.0, 12.0, 102.0),
+              List.of(2.0, 11.0, 101.0),
+              List.of(1.0, 10.0, 100.0),
+              List.of(6000d, 33000d, 303000d),
+              List.of(3000.0, 12000.0, 102000.0),
+              List.of(2000.0, 11000.0, 101000.0),
+              List.of(1000.0, 10000.0, 100000.0));
+      assertVectorValues((ColumnarTable) table, vector, points, expectedVectors);
+    };
+
+    resultChecker.accept(result, vectorWoTransformer);
+
+    // We use the vector with transformer here to have the same scope as the previous measure.
+    QueryDto q2 = Query
+            .from(this.storeName)
+            .select(List.of(this.ean, this.competitor), List.of(vectorWithTransformer))
+            .rollup(List.of(this.ean, this.competitor))
+            .build();
+    hitCount = (int) this.queryCache.stats(null).hitCount;
+    missCount = (int) this.queryCache.stats(null).missCount;
+    Table table = this.executor.executeQuery(q2);
+    Assertions.assertThat(table.headers().stream().map(Header::name))
+            .containsExactly(this.ean.name(), this.competitor.name(), vectorWithTransformer.alias());
+    resultChecker.accept(table, vectorWithTransformer);
+    // hit -> count; miss -> vectorWithTransformer
+    TestUtil.assertCacheStats(this.queryCache, hitCount + 1, missCount + 1);
   }
 
   @Test
diff --git a/core/src/test/java/io/squashql/query/ATestVectorOperation.java b/core/src/test/java/io/squashql/query/ATestVectorOperation.java
new file mode 100644
index 0000000..15dff7a
--- /dev/null
+++ b/core/src/test/java/io/squashql/query/ATestVectorOperation.java
@@ -0,0 +1,229 @@
+package io.squashql.query;
+
+import io.squashql.TestClass;
+import io.squashql.list.Lists.DoubleList;
+import io.squashql.list.Lists.LocalDateList;
+import io.squashql.query.builder.Query;
+import io.squashql.query.compiled.CompiledExpressionMeasure;
+import io.squashql.query.dto.QueryDto;
+import io.squashql.table.ColumnarTable;
+import io.squashql.table.Table;
+import io.squashql.type.TableTypedField;
+import io.squashql.util.MultipleColumnsSorter;
+import org.assertj.core.api.Assertions;
+import org.eclipse.collections.impl.tuple.Tuples;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestInstance;
+
+import java.time.LocalDate;
+import java.util.*;
+import java.util.function.BiFunction;
+import java.util.function.Function;
+
+import static io.squashql.query.agg.AggregationFunction.ANY_VALUE;
+import static io.squashql.query.agg.AggregationFunction.SUM;
+import static io.squashql.query.database.QueryEngine.GRAND_TOTAL;
+import static io.squashql.query.database.QueryEngine.TOTAL;
+import static io.squashql.util.ListUtils.reorder;
+import static java.util.Comparator.naturalOrder;
+
+@TestClass(ignore = TestClass.Type.SNOWFLAKE)
+@TestInstance(TestInstance.Lifecycle.PER_CLASS)
+public abstract class ATestVectorOperation extends ABaseTestQuery {
+  static final String productA = "A";
+  static final String productB = "B";
+  static final String competitorX = "X";
+  static final String competitorY = "Y";
+  static final String competitorZ = "Z";
+  static final int day = 5;
+  static final int month = 4;
+  final String storeName = "mystore" + getClass().getSimpleName().toLowerCase();
+  final Field competitor = new TableField(this.storeName, "competitor");
+  final Field ean = new TableField(this.storeName, "ean");
+  final Field price = new TableField(this.storeName, "price");
+  final Field date = new TableField(this.storeName, "date");
+
+  @Override
+  protected Map<String, List<TableTypedField>> getFieldsByStore() {
+    TableTypedField ean = new TableTypedField(this.storeName, "ean", String.class);
+    TableTypedField date = new TableTypedField(this.storeName, "date", LocalDate.class);
+    TableTypedField competitor = new TableTypedField(this.storeName, "competitor", String.class);
+    TableTypedField price = new TableTypedField(this.storeName, "price", double.class);
+    return Map.of(this.storeName, List.of(ean, date, competitor, price));
+  }
+
+  @Override
+  protected void loadData() {
+    // Simulate historical prices
+    List<Object[]> l = new ArrayList<>();
+    for (String product : List.of(productA, productB)) {
+      for (String competitor : List.of(competitorX, competitorY, competitorZ)) {
+        for (int d = 1; d < day; d++) {
+          for (int m = 1; m < month; m++) {
+            l.add(new Object[]{product, LocalDate.of(2023, m, d), competitor, (double) d * m});
+          }
+        }
+      }
+    }
+    Object[][] array = l.toArray(new Object[0][]);
+    this.tm.load(this.storeName, List.of(array));
+  }
+
+  @Test
+  void testWithoutTotals() {
+    Measure vector = new VectorTupleAggMeasure("vector", List.of(Tuples.pair(this.price, SUM), Tuples.pair(this.date, ANY_VALUE)), this.date, null);
+    QueryDto query = Query
+            .from(this.storeName)
+            .select(List.of(this.competitor, this.ean), List.of(vector))
+            .build();
+    Table result = this.executor.executeQuery(query);
+    Assertions.assertThat(result.headers().stream().map(Header::name))
+            .containsExactly(this.competitor.name(), this.ean.name(), vector.alias());
+    assertVectorTuples(result, vector);
+  }
+
+  @Test
+  void testTotals() {
+    Measure vector = new VectorTupleAggMeasure("vector", List.of(Tuples.pair(this.price, SUM), Tuples.pair(this.date, ANY_VALUE)), this.date, null);
+    QueryDto query = Query
+            .from(this.storeName)
+            .select(List.of(this.competitor, this.ean), List.of(vector))
+            .rollup(List.of(this.competitor, this.ean))
+            .build();
+    Table result = this.executor.executeQuery(query);
+
+    List<Object> columnValues = result.getColumnValues(vector.alias());
+    List<Object> orderedDatesList = new ArrayList<>((int) result.count());
+    List<Object> orderedPricesList = new ArrayList<>((int) result.count());
+    for (Object columnValue : columnValues) {
+      List<Object> tuple = (List<Object>) columnValue;// expected size 2
+      Assertions.assertThat(tuple.size()).isEqualTo(2);
+      DoubleList prices = (DoubleList) tuple.get(0);
+      LocalDateList dates = (LocalDateList) tuple.get(1);
+      int[] sort = MultipleColumnsSorter.sort(List.of(dates), List.of(naturalOrder()), new int[0]);
+      orderedPricesList.add(reorder(prices, sort));
+      orderedDatesList.add(reorder(dates, sort));
+    }
+
+    // Create a new table with "fake" measures to be able to check the result
+    ColumnarTable orderedTable = new ColumnarTable(
+            List.of(result.getHeader(this.competitor.name()), result.getHeader(this.ean.name()), new Header("orderedPrices", DoubleList.class, true), new Header("orderedDates", List.class, true)),
+            Set.of(new CompiledExpressionMeasure("orderedPrices", ""), new CompiledExpressionMeasure("orderedDates", "")),
+            List.of(result.getColumnValues(this.competitor.name()), result.getColumnValues(this.ean.name()), orderedPricesList, orderedDatesList));
+
+    Assertions.assertThat(orderedTable.headers().stream().map(Header::name))
+            .containsExactly(this.competitor.name(), this.ean.name(), "orderedPrices", "orderedDates");
+    List<LocalDate> expectedLocalDates = new ArrayList<>();
+    for (int d = 1; d < day; d++) {
+      for (int m = 1; m < month; m++) {
+        expectedLocalDates.add(LocalDate.of(2023, m, d));
+      }
+    }
+    Collections.sort(expectedLocalDates);
+    Assertions.assertThat(orderedTable).containsExactly(
+            List.of(GRAND_TOTAL, GRAND_TOTAL, List.of(6.0, 12.0, 18.0, 24.0, 12.0, 24.0, 36.0, 48.0, 18.0, 36.0, 54.0, 72.0), expectedLocalDates),
+            List.of(competitorX, TOTAL, List.of(2.0, 4.0, 6.0, 8.0, 4.0, 8.0, 12.0, 16.0, 6.0, 12.0, 18.0, 24.0), expectedLocalDates),
+            List.of(competitorX, productA, List.of(1.0, 2.0, 3.0, 4.0, 2.0, 4.0, 6.0, 8.0, 3.0, 6.0, 9.0, 12.0), expectedLocalDates),
+            List.of(competitorX, productB, List.of(1.0, 2.0, 3.0, 4.0, 2.0, 4.0, 6.0, 8.0, 3.0, 6.0, 9.0, 12.0), expectedLocalDates),
+            List.of(competitorY, TOTAL, List.of(2.0, 4.0, 6.0, 8.0, 4.0, 8.0, 12.0, 16.0, 6.0, 12.0, 18.0, 24.0), expectedLocalDates),
+            List.of(competitorY, productA, List.of(1.0, 2.0, 3.0, 4.0, 2.0, 4.0, 6.0, 8.0, 3.0, 6.0, 9.0, 12.0), expectedLocalDates),
+            List.of(competitorY, productB, List.of(1.0, 2.0, 3.0, 4.0, 2.0, 4.0, 6.0, 8.0, 3.0, 6.0, 9.0, 12.0), expectedLocalDates),
+            List.of(competitorZ, TOTAL, List.of(2.0, 4.0, 6.0, 8.0, 4.0, 8.0, 12.0, 16.0, 6.0, 12.0, 18.0, 24.0), expectedLocalDates),
+            List.of(competitorZ, productA, List.of(1.0, 2.0, 3.0, 4.0, 2.0, 4.0, 6.0, 8.0, 3.0, 6.0, 9.0, 12.0), expectedLocalDates),
+            List.of(competitorZ, productB, List.of(1.0, 2.0, 3.0, 4.0, 2.0, 4.0, 6.0, 8.0, 3.0, 6.0, 9.0, 12.0), expectedLocalDates));
+  }
+
+  @Test
+  void testTransformerWithoutTotals() {
+    // Dummy transformer. Find an index from the date array and use this index to pick a single price. It is to show
+    // we can perform any operation with two vectors
+    Function<List<Object>, Object> transformer = (list) -> {
+      DoubleList prices = (DoubleList) list.get(0);
+      List<LocalDate> dates = (List<LocalDate>) list.get(1);
+      int index = -1;
+      for (int i = 0; i < dates.size(); i++) {
+        if (dates.get(i).equals(LocalDate.of(2023, 3, 3))) {
+          index = i;
+          break;
+        }
+      }
+      return prices.get(index);
+    };
+
+    Measure vector = new VectorTupleAggMeasure(
+            "price_at_2023_3_3",
+            List.of(Tuples.pair(this.price, SUM), Tuples.pair(this.date, ANY_VALUE)),
+            this.date,
+            transformer);
+    QueryDto query = Query
+            .from(this.storeName)
+            .select(List.of(this.competitor, this.ean), List.of(vector))
+            .rollup(List.of(this.competitor, this.ean))
+            .build();
+    Table result = this.executor.executeQuery(query);
+    Assertions.assertThat(result.headers().stream().map(Header::name))
+            .containsExactly(this.competitor.name(), this.ean.name(), vector.alias());
+    Assertions.assertThat(result).containsExactly(
+            List.of(GRAND_TOTAL, GRAND_TOTAL, 54d),
+            List.of(competitorX, TOTAL, 18d),
+            List.of(competitorX, productA, 9d),
+            List.of(competitorX, productB, 9d),
+            List.of(competitorY, TOTAL, 18d),
+            List.of(competitorY, productA, 9d),
+            List.of(competitorY, productB, 9d),
+            List.of(competitorZ, TOTAL, 18d),
+            List.of(competitorZ, productA, 9d),
+            List.of(competitorZ, productB, 9d));
+  }
+
+  @Test
+  void testParentComparison() {
+    Measure vector = new VectorTupleAggMeasure("vector", List.of(Tuples.pair(this.price, SUM), Tuples.pair(this.date, ANY_VALUE)), this.date, null);
+    List<Field> fields = List.of(this.competitor, this.ean);
+    BiFunction<Object, Object, Object> operator = (a, b) -> {
+      DoubleList currentValue = (DoubleList) ((List) a).get(0);
+      DoubleList parentValue = (DoubleList) ((List) b).get(0);
+      // We have to reorder both arrays and use their associated date array because they are not in the same order!
+      // Order by date to get a deterministic order
+      List<Double> currentValueOrdered = reorder(currentValue, MultipleColumnsSorter.sort(List.of((LocalDateList) ((List) a).get(1)), List.of(naturalOrder()), new int[0]));
+      List<Double> parentValueOrdered = reorder(parentValue, MultipleColumnsSorter.sort(List.of((LocalDateList) ((List) b).get(1)), List.of(naturalOrder()), new int[0]));
+      DoubleList ratio = new DoubleList(currentValue.size());
+      for (int i = 0; i < currentValue.size(); i++) {
+        ratio.add(currentValueOrdered.get(i) - parentValueOrdered.get(i));
+      }
+      return ratio;
+    };
+    ComparisonMeasureReferencePosition pOp = new ComparisonMeasureReferencePosition("percentOfParent", operator, vector, fields);
+
+    QueryDto query = Query
+            .from(this.storeName)
+            .select(fields, List.of(pOp))
+            .rollup(fields)
+            .build();
+    Table result = this.executor.executeQuery(query);
+    Assertions.assertThat(result).containsExactly(
+            List.of(GRAND_TOTAL, GRAND_TOTAL, List.of(.0, .0, .0, .0, .0, .0, .0, .0, .0, .0, .0, .0)),
+            List.of(competitorX, TOTAL, List.of(-4., -8., -12., -16., -8., -16., -24., -32., -12., -24., -36., -48.)),
+            List.of(competitorX, productA, List.of(-1.0, -2.0, -3.0, -4.0, -2.0, -4.0, -6.0, -8.0, -3.0, -6.0, -9.0, -12.0)),
+            List.of(competitorX, productB, List.of(-1.0, -2.0, -3.0, -4.0, -2.0, -4.0, -6.0, -8.0, -3.0, -6.0, -9.0, -12.0)),
+            List.of(competitorY, TOTAL, List.of(-4., -8., -12., -16., -8., -16., -24., -32., -12., -24., -36., -48.)),
+            List.of(competitorY, productA, List.of(-1.0, -2.0, -3.0, -4.0, -2.0, -4.0, -6.0, -8.0, -3.0, -6.0, -9.0, -12.0)),
+            List.of(competitorY, productB, List.of(-1.0, -2.0, -3.0, -4.0, -2.0, -4.0, -6.0, -8.0, -3.0, -6.0, -9.0, -12.0)),
+            List.of(competitorZ, TOTAL, List.of(-4., -8., -12., -16., -8., -16., -24., -32., -12., -24., -36., -48.)),
+            List.of(competitorZ, productA, List.of(-1.0, -2.0, -3.0, -4.0, -2.0, -4.0, -6.0, -8.0, -3.0, -6.0, -9.0, -12.0)),
+            List.of(competitorZ, productB, List.of(-1.0, -2.0, -3.0, -4.0, -2.0, -4.0, -6.0, -8.0, -3.0, -6.0, -9.0, -12.0)));
+  }
+
+  private void assertVectorTuples(Table result, Measure vector) {
+    List<Object> columnValues = result.getColumnValues(vector.alias());
+    for (Object columnValue : columnValues) {
+      List<Object> tuple = (List<Object>) columnValue;// expected size 2
+      Assertions.assertThat(tuple.size()).isEqualTo(2);
+      DoubleList prices = (DoubleList) tuple.get(0);
+      List<LocalDate> dates = (List<LocalDate>) tuple.get(1);
+      for (int i = 0; i < dates.size(); i++) {
+        Assertions.assertThat(prices.get(i).intValue()).isEqualTo(dates.get(i).getMonth().getValue() * dates.get(i).getDayOfMonth());
+      }
+    }
+  }
+}
diff --git a/core/src/test/java/io/squashql/table/ATestMergeTables.java b/core/src/test/java/io/squashql/table/ATestMergeTables.java
index 6d91206..60da63f 100644
--- a/core/src/test/java/io/squashql/table/ATestMergeTables.java
+++ b/core/src/test/java/io/squashql/table/ATestMergeTables.java
@@ -5,6 +5,7 @@ import io.squashql.query.compiled.CompiledAggregatedMeasure;
 import io.squashql.query.dto.JoinType;
 import io.squashql.type.TableTypedField;
 import io.squashql.type.TypedField;
+import io.squashql.util.ListUtils;
 import org.assertj.core.api.Assertions;
 import org.junit.jupiter.api.Test;
 
@@ -438,14 +439,10 @@ abstract class ATestMergeTables {
   abstract Table getMergeTablesWithoutCommonColumns();
 
   private static Table reorderColumns(ColumnarTable table, List<String> order) {
-    List<List<Object>> newValues = new ArrayList<>(table.getColumns().size());
-    List<Header> newHeaders = new ArrayList<>(table.getColumns().size());
-    for (int i = 0; i < table.getColumns().size(); i++) {
-      newValues.add(null);
-      newHeaders.add(null);
-    }
+    List<List<Object>> newValues = ListUtils.createListWithNulls(table.getColumns().size());
+    List<Header> newHeaders = ListUtils.createListWithNulls(table.getColumns().size());
     for (int i = 0; i < order.size(); i++) {
-      int newIndex = table.headers().stream().map(h -> h.name()).toList().indexOf(order.get(i));
+      int newIndex = table.headers().stream().map(Header::name).toList().indexOf(order.get(i));
       newValues.set(i, table.getColumns().get(newIndex));
       newHeaders.set(i, table.headers().get(newIndex));
     }
diff --git a/core/src/test/java/io/squashql/util/TestMultipleColumnsSorter.java b/core/src/test/java/io/squashql/util/TestMultipleColumnsSorter.java
index 607f654..e237f4e 100644
--- a/core/src/test/java/io/squashql/util/TestMultipleColumnsSorter.java
+++ b/core/src/test/java/io/squashql/util/TestMultipleColumnsSorter.java
@@ -1,13 +1,13 @@
 package io.squashql.util;
 
-import io.squashql.table.ColumnarTable;
 import io.squashql.query.Header;
+import io.squashql.table.ColumnarTable;
 import org.assertj.core.api.Assertions;
 import org.junit.jupiter.api.Test;
 
 import java.util.*;
 
-import static io.squashql.table.TableUtils.reorder;
+import static io.squashql.util.ListUtils.reorder;
 import static java.util.Comparator.naturalOrder;
 import static java.util.Comparator.nullsLast;
 
diff --git a/duckdb/src/test/java/io/squashql/query/TestDuckDBVectorOperation.java b/duckdb/src/test/java/io/squashql/query/TestDuckDBVectorOperation.java
new file mode 100644
index 0000000..db55621
--- /dev/null
+++ b/duckdb/src/test/java/io/squashql/query/TestDuckDBVectorOperation.java
@@ -0,0 +1,35 @@
+package io.squashql.query;
+
+import io.squashql.DuckDBDatastore;
+import io.squashql.query.database.DuckDBQueryEngine;
+import io.squashql.query.database.QueryEngine;
+import io.squashql.store.Datastore;
+import io.squashql.transaction.DataLoader;
+import io.squashql.transaction.DuckDBDataLoader;
+
+/**
+ * Do not edit this class, it has been generated automatically by {@link io.squashql.template.DuckDBClassTemplateGenerator}.
+ */
+public class TestDuckDBVectorOperation extends ATestVectorOperation {
+
+  @Override
+  protected QueryEngine createQueryEngine(Datastore datastore) {
+    return new DuckDBQueryEngine((DuckDBDatastore) datastore);
+  }
+
+  @Override
+  protected Datastore createDatastore() {
+    return new DuckDBDatastore();
+  }
+
+  @Override
+  protected DataLoader createDataLoader() {
+    return new DuckDBDataLoader((DuckDBDatastore) this.datastore);
+  }
+
+  @Override
+  protected void createTables() {
+    DuckDBDataLoader tm = (DuckDBDataLoader) this.tm;
+    this.fieldsByStore.forEach(tm::createOrReplaceTable);
+  }
+}
diff --git a/spark/src/test/java/io/squashql/query/TestSparkVectorOperation.java b/spark/src/test/java/io/squashql/query/TestSparkVectorOperation.java
new file mode 100644
index 0000000..c7522be
--- /dev/null
+++ b/spark/src/test/java/io/squashql/query/TestSparkVectorOperation.java
@@ -0,0 +1,42 @@
+package io.squashql.query;
+
+import io.squashql.SparkDatastore;
+import io.squashql.query.database.QueryEngine;
+import io.squashql.query.database.SparkQueryEngine;
+import io.squashql.store.Datastore;
+import io.squashql.transaction.DataLoader;
+import io.squashql.transaction.SparkDataLoader;
+import org.junit.jupiter.api.AfterAll;
+
+/**
+ * Do not edit this class, it has been generated automatically by {@link io.squashql.template.SparkClassTemplateGenerator}.
+ */
+public class TestSparkVectorOperation extends ATestVectorOperation {
+
+  @AfterAll
+  void tearDown() {
+    this.fieldsByStore.keySet().forEach(storeName -> ((SparkDatastore) this.datastore).spark.catalog().dropTempView(storeName));
+  }
+
+  @Override
+  protected QueryEngine createQueryEngine(Datastore datastore) {
+    return new SparkQueryEngine((SparkDatastore) datastore);
+  }
+
+  @Override
+  protected Datastore createDatastore() {
+    return new SparkDatastore();
+  }
+
+  @Override
+  protected DataLoader createDataLoader() {
+    SparkDatastore ds = (SparkDatastore) this.datastore;
+    return new SparkDataLoader(ds.spark);
+  }
+
+  @Override
+  protected void createTables() {
+    SparkDataLoader tm = (SparkDataLoader) this.tm;
+    this.fieldsByStore.forEach((store, fields) -> tm.createTemporaryTable(store, fields));
+  }
+}