Repository: squashql/squashql
Pull request #252 (2024-01-30 13:29:50)

Query merge supports N queries 


Patch:
diff --git a/core/src/main/java/io/squashql/query/QueryExecutor.java b/core/src/main/java/io/squashql/query/QueryExecutor.java
index 28e38d5..e841452 100644
--- a/core/src/main/java/io/squashql/query/QueryExecutor.java
+++ b/core/src/main/java/io/squashql/query/QueryExecutor.java
@@ -367,12 +367,12 @@ public class QueryExecutor {
 
   }
 
-  public PivotTable executePivotQueryMerge(QueryDto first, QueryDto second, List<Field> rows, List<Field> columns, JoinType joinType, SquashQLUser user) {
-    return QueryMergeExecutor.executePivotQueryMerge(this, first, second, rows, columns, joinType, user);
+  public PivotTable executePivotQueryMerge(QueryMergeDto queryMerge, List<Field> rows, List<Field> columns, SquashQLUser user) {
+    return QueryMergeExecutor.executePivotQueryMerge(this, queryMerge, rows, columns, user);
   }
 
-  public Table executeQueryMerge(QueryDto first, QueryDto second, JoinType joinType, SquashQLUser user) {
-    return QueryMergeExecutor.executeQueryMerge(this, first, second, joinType, user);
+  public Table executeQueryMerge(QueryMergeDto queryMerge, SquashQLUser user) {
+    return QueryMergeExecutor.executeQueryMerge(this, queryMerge, user);
   }
 
   public Table executeExperimentalQueryMerge(QueryJoinDto queryJoin) {
diff --git a/core/src/main/java/io/squashql/query/QueryMergeExecutor.java b/core/src/main/java/io/squashql/query/QueryMergeExecutor.java
index 1f8f622..5a4cbc2 100644
--- a/core/src/main/java/io/squashql/query/QueryMergeExecutor.java
+++ b/core/src/main/java/io/squashql/query/QueryMergeExecutor.java
@@ -1,26 +1,22 @@
 package io.squashql.query;
 
 import io.squashql.query.dto.CacheStatsDto;
-import io.squashql.query.dto.JoinType;
 import io.squashql.query.dto.PivotTableQueryDto;
 import io.squashql.query.dto.QueryDto;
+import io.squashql.query.dto.QueryMergeDto;
 import io.squashql.query.exception.LimitExceedException;
 import io.squashql.table.*;
 import io.squashql.util.Queries;
 
-import java.util.Comparator;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
+import java.util.*;
 import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.CompletionException;
 import java.util.function.Function;
 import java.util.stream.Collectors;
-import java.util.stream.Stream;
 
 public class QueryMergeExecutor {
 
-  public static Table executeQueryMerge(QueryExecutor queryExecutor, QueryDto first, QueryDto second, JoinType joinType, SquashQLUser user) {
+  public static Table executeQueryMerge(QueryExecutor queryExecutor, QueryMergeDto queryMerge, SquashQLUser user) {
     Function<QueryDto, Table> executor = query -> queryExecutor.executeQuery(
             query,
             CacheStatsDto.builder(),
@@ -29,10 +25,10 @@ public class QueryMergeExecutor {
             limit -> {
               throw new LimitExceedException("Result of " + query + " is too big (limit=" + limit + ")");
             });
-    return execute(first, second, joinType, t -> (ColumnarTable) TableUtils.replaceTotalCellValues((ColumnarTable) t, true), executor);
+    return execute(queryMerge, t -> (ColumnarTable) TableUtils.replaceTotalCellValues((ColumnarTable) t, true), executor);
   }
 
-  public static PivotTable executePivotQueryMerge(QueryExecutor queryExecutor, QueryDto first, QueryDto second, List<Field> rows, List<Field> columns, JoinType joinType, SquashQLUser user) {
+  public static PivotTable executePivotQueryMerge(QueryExecutor queryExecutor, QueryMergeDto queryMerge, List<Field> rows, List<Field> columns, SquashQLUser user) {
     Function<QueryDto, Table> executor = query -> {
       Set<Field> columnsFromColumnSets = query.columnSets.values().stream().flatMap(cs -> cs.getNewColumns().stream()).collect(Collectors.toSet());
       return queryExecutor.executePivotQuery(
@@ -51,32 +47,33 @@ public class QueryMergeExecutor {
     Function<Table, ColumnarTable> replaceTotalCellValuesFunction = t -> (ColumnarTable) TableUtils.replaceTotalCellValues((ColumnarTable) t,
             rows.stream().map(Field::name).toList(),
             columns.stream().map(Field::name).toList());
-    ColumnarTable table = execute(first, second, joinType, replaceTotalCellValuesFunction, executor);
+    ColumnarTable table = execute(queryMerge, replaceTotalCellValuesFunction, executor);
     List<String> values = table.headers().stream().filter(Header::isMeasure).map(Header::name).toList();
     return new PivotTable(table, rows.stream().map(Field::name).toList(), columns.stream().map(Field::name).toList(), values);
   }
 
-  private static ColumnarTable execute(QueryDto first,
-                                       QueryDto second,
-                                       JoinType joinType,
+  private static ColumnarTable execute(QueryMergeDto queryMerge,
                                        Function<Table, ColumnarTable> replaceTotalCellValuesFunction,
                                        Function<QueryDto, Table> executor) {
-    Map<String, Comparator<?>> firstComparators = Queries.getComparators(first);
-    Map<String, Comparator<?>> secondComparators = Queries.getComparators(second);
-    secondComparators.putAll(firstComparators); // the comparators of the first query take precedence over the second's
+    Map<String, Comparator<?>> comparators = new HashMap<>();
+    Set<ColumnSet> columnSets = new HashSet<>();
+    for (int i = queryMerge.queries.size() - 1; i >= 0; i--) {
+      QueryDto q = queryMerge.queries.get(i);
+      comparators.putAll(Queries.getComparators(q)); // the comparators of the first query take precedence over the second's
+      columnSets.addAll(q.columnSets.values());
+    }
 
-    Set<ColumnSet> columnSets = Stream
-            .concat(first.columnSets.values().stream(), second.columnSets.values().stream())
-            .collect(Collectors.toSet());
+    List<CompletableFuture<Table>> futures = new ArrayList<>();
+    for (QueryDto q : queryMerge.queries) {
+      futures.add(CompletableFuture.supplyAsync(() -> executor.apply(q)));
+    }
 
-    CompletableFuture<Table> f1 = CompletableFuture.supplyAsync(() -> executor.apply(first));
-    CompletableFuture<Table> f2 = CompletableFuture.supplyAsync(() -> executor.apply(second));
     try {
-      return CompletableFuture.allOf(f1, f2)
+      return CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]))
               .thenApply(__ -> {
-                ColumnarTable table = (ColumnarTable) MergeTables.mergeTables(f1.join(), f2.join(), joinType);
+                ColumnarTable table = (ColumnarTable) MergeTables.mergeTables(futures.stream().map(CompletableFuture::join).toList(), queryMerge.joinTypes);
                 table = replaceTotalCellValuesFunction.apply(table);
-                return (ColumnarTable) TableUtils.orderRows(table, secondComparators, columnSets);
+                return (ColumnarTable) TableUtils.orderRows(table, comparators, columnSets);
               })
               .join();
     } catch (Exception e) {
diff --git a/core/src/main/java/io/squashql/query/dto/QueryMergeDto.java b/core/src/main/java/io/squashql/query/dto/QueryMergeDto.java
index 2953448..39e5142 100644
--- a/core/src/main/java/io/squashql/query/dto/QueryMergeDto.java
+++ b/core/src/main/java/io/squashql/query/dto/QueryMergeDto.java
@@ -1,17 +1,31 @@
 package io.squashql.query.dto;
 
-import lombok.AllArgsConstructor;
 import lombok.EqualsAndHashCode;
 import lombok.NoArgsConstructor;
 import lombok.ToString;
 
+import java.util.ArrayList;
+import java.util.List;
+
 @ToString
 @EqualsAndHashCode
 @NoArgsConstructor // For Jackson
-@AllArgsConstructor
 public class QueryMergeDto {
 
-  public QueryDto first;
-  public QueryDto second;
-  public JoinType joinType;
+  public List<QueryDto> queries = new ArrayList<>();
+  public List<JoinType> joinTypes = new ArrayList<>();
+
+  private QueryMergeDto(QueryDto q1) {
+    this.queries.add(q1);
+  }
+
+  public static QueryMergeDto from(QueryDto q) {
+    return new QueryMergeDto(q);
+  }
+
+  public QueryMergeDto join(QueryDto q, JoinType joinType) {
+    this.queries.add(q);
+    this.joinTypes.add(joinType);
+    return this;
+  }
 }
diff --git a/core/src/main/java/io/squashql/table/MergeTables.java b/core/src/main/java/io/squashql/table/MergeTables.java
index 22529a9..b439e0f 100644
--- a/core/src/main/java/io/squashql/table/MergeTables.java
+++ b/core/src/main/java/io/squashql/table/MergeTables.java
@@ -22,10 +22,10 @@ public class MergeTables {
   private MergeTables() {
   }
 
-  public static Table mergeTables(List<Table> tables, JoinType joinType) {
-    Table result = null;
-    for (Table table : tables) {
-      result = result == null ? table : mergeTables(result, table, joinType);
+  public static Table mergeTables(List<Table> tables, List<JoinType> joinTypes) {
+    Table result = tables.get(0);
+    for (int i = 0; i < joinTypes.size(); i++) {
+      result = mergeTables(result, tables.get(i + 1), joinTypes.get(i));
     }
     return result;
   }
diff --git a/documentation/DRILLING-ACROSS.md b/documentation/DRILLING-ACROSS.md
index 0b587ac..aa91797 100644
--- a/documentation/DRILLING-ACROSS.md
+++ b/documentation/DRILLING-ACROSS.md
@@ -4,6 +4,23 @@ Drilling across or Drill across refers to a reporting feature that let you query
 Aggregated results are then stitch together on shared columns. There are several strategies to *stitch*
 the results. Let's see various examples to learn how SquashQL does it.  
 
+### API
+
+The API supports joining N query results. 
+
+```typescript
+const query1 = Query.from("table1").select(...)
+const query2 = Query.from("table2").select(...)
+const query3 = Query.from("table3").select(...)
+
+const queryMerge = new QueryMerge(query1)
+        .join(query2, JoinType.LEFT)
+        .join(query3, JoinType.INNER)
+
+querier.executeQuery(queryMerge)
+        .then(result => console.log(result))
+```
+
 ### Examples
 
 #### Schema
@@ -110,7 +127,7 @@ Missing values will be filled with `null`.
 ##### FULL
 
 ```typescript
-querier.executeQuery(new QueryMerge(queryShipment, queryReturn, JoinType.FULL))
+querier.executeQuery(new QueryMerge(queryShipment).join(queryReturn, JoinType.FULL))
         .then(result => console.log(result))
 ```
 
@@ -133,7 +150,7 @@ entries are kept.
 ##### LEFT
 
 ```typescript
-querier.executeQuery(new QueryMerge(queryShipment, queryReturn, JoinType.LEFT))
+querier.executeQuery(new QueryMerge(queryShipment).join(queryReturn, JoinType.LEFT))
         .then(result => console.log(result))
 ```
 
@@ -154,7 +171,7 @@ table, there is no entry for product `D` but the join type is `LEFT` so this ent
 ##### INNER
 
 ```typescript
-querier.executeQuery(new QueryMerge(queryShipment, queryReturn, JoinType.INNER))
+querier.executeQuery(new QueryMerge(queryShipment).join(queryReturn, JoinType.INNER))
         .then(result => console.log(result))
 ```
 
@@ -238,7 +255,7 @@ values for quantity sold are simply copy from the first result.
 +-------------+-------------+---------------+-------------------+
 ```
 
-## Limit
+### Limit
 
 SquashQL has an implicit limit query result set to 10000. In case of drilling across, this limit is applied to the queries
 used in the `QueryMerge` before combining the result. Since the final result can be wrong if at least one of the results
diff --git a/js/typescript-library/build-from-query-merge-pivot.json b/js/typescript-library/build-from-query-merge-pivot.json
index df1d4e3..48993e6 100644
--- a/js/typescript-library/build-from-query-merge-pivot.json
+++ b/js/typescript-library/build-from-query-merge-pivot.json
@@ -1,1 +1,1 @@
-{"query":{"first":{"table":{"name":"myTable","joins":[]},"virtualTableDtos":[],"columns":[{"@class":"io.squashql.query.TableField","fullName":"a","fieldName":"a"},{"@class":"io.squashql.query.TableField","fullName":"b","fieldName":"b"}],"rollupColumns":[],"columnSets":{},"parameters":{},"measures":[{"@class":"io.squashql.query.AggregatedMeasure","field":{"@class":"io.squashql.query.TableField","fullName":"f1","fieldName":"f1"},"aggregationFunction":"sum","alias":"sum"}],"orders":{},"limit":-1},"second":{"table":{"name":"myTable","joins":[]},"virtualTableDtos":[],"columns":[{"@class":"io.squashql.query.TableField","fullName":"a","fieldName":"a"},{"@class":"io.squashql.query.TableField","fullName":"b","fieldName":"b"}],"rollupColumns":[],"columnSets":{},"parameters":{},"measures":[{"@class":"io.squashql.query.AggregatedMeasure","field":{"@class":"io.squashql.query.TableField","fullName":"f1","fieldName":"f1"},"aggregationFunction":"avg","alias":"sum"}],"orders":{},"limit":-1},"joinType":"LEFT"},"rows":[{"@class":"io.squashql.query.TableField","fullName":"a","fieldName":"a"}],"columns":[{"@class":"io.squashql.query.TableField","fullName":"b","fieldName":"b"}]}
\ No newline at end of file
+{"query":{"queries":[{"table":{"name":"myTable","joins":[]},"virtualTableDtos":[],"columns":[{"@class":"io.squashql.query.TableField","fullName":"a","fieldName":"a"},{"@class":"io.squashql.query.TableField","fullName":"b","fieldName":"b"}],"rollupColumns":[],"columnSets":{},"parameters":{},"measures":[{"@class":"io.squashql.query.AggregatedMeasure","field":{"@class":"io.squashql.query.TableField","fullName":"f1","fieldName":"f1"},"aggregationFunction":"sum","alias":"sum"}],"orders":{},"limit":-1},{"table":{"name":"myTable","joins":[]},"virtualTableDtos":[],"columns":[{"@class":"io.squashql.query.TableField","fullName":"a","fieldName":"a"},{"@class":"io.squashql.query.TableField","fullName":"b","fieldName":"b"}],"rollupColumns":[],"columnSets":{},"parameters":{},"measures":[{"@class":"io.squashql.query.AggregatedMeasure","field":{"@class":"io.squashql.query.TableField","fullName":"f1","fieldName":"f1"},"aggregationFunction":"avg","alias":"sum"}],"orders":{},"limit":-1}],"joinTypes":["LEFT"]},"rows":[{"@class":"io.squashql.query.TableField","fullName":"a","fieldName":"a"}],"columns":[{"@class":"io.squashql.query.TableField","fullName":"b","fieldName":"b"}]}
\ No newline at end of file
diff --git a/js/typescript-library/build-from-query-merge.json b/js/typescript-library/build-from-query-merge.json
index 9034675..c22f659 100644
--- a/js/typescript-library/build-from-query-merge.json
+++ b/js/typescript-library/build-from-query-merge.json
@@ -1,1 +1,1 @@
-{"first":{"table":{"name":"myTable","joins":[]},"virtualTableDtos":[],"columns":[{"@class":"io.squashql.query.TableField","fullName":"a","fieldName":"a"},{"@class":"io.squashql.query.TableField","fullName":"b","fieldName":"b"}],"rollupColumns":[],"columnSets":{},"parameters":{},"measures":[{"@class":"io.squashql.query.AggregatedMeasure","field":{"@class":"io.squashql.query.TableField","fullName":"f1","fieldName":"f1"},"aggregationFunction":"sum","alias":"sum"}],"orders":{},"limit":-1},"second":{"table":{"name":"myTable","joins":[]},"virtualTableDtos":[],"columns":[{"@class":"io.squashql.query.TableField","fullName":"a","fieldName":"a"},{"@class":"io.squashql.query.TableField","fullName":"b","fieldName":"b"}],"rollupColumns":[],"columnSets":{},"parameters":{},"measures":[{"@class":"io.squashql.query.AggregatedMeasure","field":{"@class":"io.squashql.query.TableField","fullName":"f1","fieldName":"f1"},"aggregationFunction":"avg","alias":"sum"}],"orders":{},"limit":-1},"joinType":"LEFT"}
\ No newline at end of file
+{"queries":[{"table":{"name":"myTable","joins":[]},"virtualTableDtos":[],"columns":[{"@class":"io.squashql.query.TableField","fullName":"a","fieldName":"a"},{"@class":"io.squashql.query.TableField","fullName":"b","fieldName":"b"}],"rollupColumns":[],"columnSets":{},"parameters":{},"measures":[{"@class":"io.squashql.query.AggregatedMeasure","field":{"@class":"io.squashql.query.TableField","fullName":"f1","fieldName":"f1"},"aggregationFunction":"sum","alias":"sum"}],"orders":{},"limit":-1},{"table":{"name":"myTable","joins":[]},"virtualTableDtos":[],"columns":[{"@class":"io.squashql.query.TableField","fullName":"a","fieldName":"a"},{"@class":"io.squashql.query.TableField","fullName":"b","fieldName":"b"}],"rollupColumns":[],"columnSets":{},"parameters":{},"measures":[{"@class":"io.squashql.query.AggregatedMeasure","field":{"@class":"io.squashql.query.TableField","fullName":"f1","fieldName":"f1"},"aggregationFunction":"avg","alias":"avg"}],"orders":{},"limit":-1},{"table":{"name":"myTable","joins":[]},"virtualTableDtos":[],"columns":[{"@class":"io.squashql.query.TableField","fullName":"a","fieldName":"a"},{"@class":"io.squashql.query.TableField","fullName":"b","fieldName":"b"}],"rollupColumns":[],"columnSets":{},"parameters":{},"measures":[{"@class":"io.squashql.query.AggregatedMeasure","field":{"@class":"io.squashql.query.TableField","fullName":"f1","fieldName":"f1"},"aggregationFunction":"max","alias":"max"}],"orders":{},"limit":-1}],"joinTypes":["LEFT","INNER"]}
\ No newline at end of file
diff --git a/js/typescript-library/src/generate-json-query-merge-pivot.ts b/js/typescript-library/src/generate-json-query-merge-pivot.ts
index 666a04b..985bb04 100644
--- a/js/typescript-library/src/generate-json-query-merge-pivot.ts
+++ b/js/typescript-library/src/generate-json-query-merge-pivot.ts
@@ -15,7 +15,7 @@ export function generateFromQueryMergePivot() {
       .select(fields, [], [avg("sum", new TableField("f1"))])
       .build()
 
-  const q = new QueryMerge(query1, query2, JoinType.LEFT)
+  const q = new QueryMerge(query1).join(query2, JoinType.LEFT)
 
   const pivotQuery = createPivotTableQueryMerge(q, {rows: [tableField("a")], columns: [tableField("b")]})
   const data = JSON.stringify(pivotQuery)
diff --git a/js/typescript-library/src/generate-json-query-merge.ts b/js/typescript-library/src/generate-json-query-merge.ts
index 42cbe3d..c71e94c 100644
--- a/js/typescript-library/src/generate-json-query-merge.ts
+++ b/js/typescript-library/src/generate-json-query-merge.ts
@@ -1,6 +1,6 @@
 import * as fs from "fs"
 import {TableField, tableFields} from "./field"
-import {avg, sum} from "./measure"
+import {avg, max, sum} from "./measure"
 import {JoinType, QueryMerge} from "./query"
 import {from} from "./queryBuilder"
 
@@ -11,10 +11,14 @@ export function generateFromQueryMerge() {
           .build()
 
   const query2 = from("myTable")
-          .select(fields, [], [avg("sum", new TableField("f1"))])
+          .select(fields, [], [avg("avg", new TableField("f1"))])
           .build()
 
-  const q = new QueryMerge(query1, query2, JoinType.LEFT)
+  const query3 = from("myTable")
+          .select(fields, [], [max("max", new TableField("f1"))])
+          .build()
+
+  const q = new QueryMerge(query1).join(query2, JoinType.LEFT).join(query3, JoinType.INNER)
   const data = JSON.stringify(q)
   fs.writeFileSync('build-from-query-merge.json', data)
 }
diff --git a/js/typescript-library/src/query.ts b/js/typescript-library/src/query.ts
index 2f5ec35..f5575b0 100644
--- a/js/typescript-library/src/query.ts
+++ b/js/typescript-library/src/query.ts
@@ -8,7 +8,27 @@ import {serializeMap} from "./util"
 import Criteria from "./criteria"
 
 export class QueryMerge {
-  constructor(readonly first: Query, readonly second: Query, readonly joinType: JoinType) {
+
+  private readonly _queries: Array<Query>
+  private readonly _joins: Array<JoinType>
+
+  constructor(query: Query) {
+    this._queries = []
+    this._joins = []
+    this._queries.push(query)
+  }
+
+  join(query: Query, joinType: JoinType): QueryMerge {
+    this._queries.push(query)
+    this._joins.push(joinType)
+    return this
+  }
+
+  toJSON() {
+    return {
+      "queries": this._queries,
+      "joinTypes": this._joins,
+    }
   }
 }
 
diff --git a/js/typescript-library/src/queryJoin.ts b/js/typescript-library/src/queryJoin.ts
index e4a26b0..f53bf95 100644
--- a/js/typescript-library/src/queryJoin.ts
+++ b/js/typescript-library/src/queryJoin.ts
@@ -5,8 +5,8 @@ import {serializeMap} from "./util"
 export class QueryJoin {
 
   private readonly _table: Table
+  private readonly _queries: Array<Query>
   private current: number = 0
-  private _queries: Array<Query>
   private _orders: Map<Field, Order>
   private _limit: number = -1
 
diff --git a/server/src/main/java/io/squashql/spring/web/rest/QueryController.java b/server/src/main/java/io/squashql/spring/web/rest/QueryController.java
index fc67e5b..d70b7c5 100644
--- a/server/src/main/java/io/squashql/spring/web/rest/QueryController.java
+++ b/server/src/main/java/io/squashql/spring/web/rest/QueryController.java
@@ -92,9 +92,7 @@ public class QueryController {
   @PostMapping(MAPPING_QUERY_MERGE)
   public ResponseEntity<QueryResultDto> executeAndMerge(@RequestBody QueryMergeDto queryMergeDto) {
     Table table = this.queryExecutor.executeQueryMerge(
-            queryMergeDto.first,
-            queryMergeDto.second,
-            queryMergeDto.joinType,
+            queryMergeDto,
             this.squashQLUserSupplier == null ? null : this.squashQLUserSupplier.get());
     return ResponseEntity.ok(createQueryResultDto(table));
   }
@@ -102,11 +100,9 @@ public class QueryController {
   @PostMapping(MAPPING_QUERY_MERGE_PIVOT)
   public ResponseEntity<PivotTableQueryResultDto> executeQueryMergePivot(@RequestBody PivotTableQueryMergeDto pivotTableQueryMergeDto) {
     PivotTable pt = this.queryExecutor.executePivotQueryMerge(
-            pivotTableQueryMergeDto.query.first,
-            pivotTableQueryMergeDto.query.second,
+            pivotTableQueryMergeDto.query,
             pivotTableQueryMergeDto.rows,
             pivotTableQueryMergeDto.columns,
-            pivotTableQueryMergeDto.query.joinType,
             this.squashQLUserSupplier == null ? null : this.squashQLUserSupplier.get()
     );
     QueryResultDto result = createQueryResultDto(pt.table);
@@ -141,9 +137,7 @@ public class QueryController {
   @PostMapping(MAPPING_QUERY_MERGE_STRINGIFY)
   public ResponseEntity<String> executeAndMergeStringify(@RequestBody QueryMergeDto queryMergeDto) {
     Table table = this.queryExecutor.executeQueryMerge(
-            queryMergeDto.first,
-            queryMergeDto.second,
-            queryMergeDto.joinType,
+            queryMergeDto,
             this.squashQLUserSupplier == null ? null : this.squashQLUserSupplier.get());
     return ResponseEntity.ok(table.toString());
   }
@@ -157,11 +151,9 @@ public class QueryController {
   @PostMapping(MAPPING_QUERY_MERGE_PIVOT_STRINGIFY)
   public ResponseEntity<String> executeAndMergePivotStringify(@RequestBody PivotTableQueryMergeDto pivotTableQueryMergeDto) {
     PivotTable pt = this.queryExecutor.executePivotQueryMerge(
-            pivotTableQueryMergeDto.query.first,
-            pivotTableQueryMergeDto.query.second,
+            pivotTableQueryMergeDto.query,
             pivotTableQueryMergeDto.rows,
             pivotTableQueryMergeDto.columns,
-            pivotTableQueryMergeDto.query.joinType,
             this.squashQLUserSupplier == null ? null : this.squashQLUserSupplier.get()
     );
     return ResponseEntity.ok(pt.toString());


Test patch:
diff --git a/core/src/test/java/io/squashql/query/ATestDocDrillacross.java b/core/src/test/java/io/squashql/query/ATestDocDrillacross.java
index 4dc312b..67cfc85 100644
--- a/core/src/test/java/io/squashql/query/ATestDocDrillacross.java
+++ b/core/src/test/java/io/squashql/query/ATestDocDrillacross.java
@@ -1,19 +1,21 @@
 package io.squashql.query;
 
-import static io.squashql.query.Functions.sum;
-import static io.squashql.query.TableField.tableFields;
-import static io.squashql.transaction.DataLoader.MAIN_SCENARIO_NAME;
-
 import io.squashql.TestClass;
 import io.squashql.query.builder.Query;
 import io.squashql.query.dto.JoinType;
 import io.squashql.query.dto.QueryDto;
+import io.squashql.query.dto.QueryMergeDto;
 import io.squashql.type.TableTypedField;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestInstance;
+
 import java.util.List;
 import java.util.Map;
 import java.util.function.BiConsumer;
-import org.junit.jupiter.api.Test;
-import org.junit.jupiter.api.TestInstance;
+
+import static io.squashql.query.Functions.sum;
+import static io.squashql.query.TableField.tableFields;
+import static io.squashql.transaction.DataLoader.MAIN_SCENARIO_NAME;
 
 @TestInstance(TestInstance.Lifecycle.PER_CLASS)
 @TestClass(ignore = {TestClass.Type.SPARK, TestClass.Type.BIGQUERY, TestClass.Type.SNOWFLAKE, TestClass.Type.CLICKHOUSE})
@@ -60,7 +62,7 @@ public abstract class ATestDocDrillacross extends ABaseTestQuery {
             .rollup(tableFields(List.of("product", "reason")))
             .build();
 
-    BiConsumer<QueryDto, QueryDto> runnable = (q1, q2) -> this.executor.executeQueryMerge(q1, q2, JoinType.FULL, null).show();
+    BiConsumer<QueryDto, QueryDto> runnable = (q1, q2) -> this.executor.executeQueryMerge(QueryMergeDto.from(q1).join(q2, JoinType.FULL), null).show();
     runnable.accept(query1, query2);
   }
 }
diff --git a/core/src/test/java/io/squashql/query/ATestPivotTable.java b/core/src/test/java/io/squashql/query/ATestPivotTable.java
index 292bbf1..830221f 100644
--- a/core/src/test/java/io/squashql/query/ATestPivotTable.java
+++ b/core/src/test/java/io/squashql/query/ATestPivotTable.java
@@ -3,10 +3,7 @@ package io.squashql.query;
 import com.google.common.collect.ImmutableList;
 import io.squashql.TestClass;
 import io.squashql.query.builder.Query;
-import io.squashql.query.dto.BucketColumnSetDto;
-import io.squashql.query.dto.JoinType;
-import io.squashql.query.dto.PivotTableQueryDto;
-import io.squashql.query.dto.QueryDto;
+import io.squashql.query.dto.*;
 import io.squashql.table.PivotTable;
 import io.squashql.table.Table;
 import io.squashql.type.TableTypedField;
@@ -408,7 +405,7 @@ public abstract class ATestPivotTable extends ABaseTestQuery {
   private void verifyResults(TestInfo testInfo, QueryDto query1, QueryDto query2, JoinType joinType, List<String> rows, List<String> columns) {
     PivotTable pt = query2 == null
             ? this.executor.executePivotQuery(new PivotTableQueryDto(query1, tableFields(rows), tableFields(columns)))
-            : this.executor.executePivotQueryMerge(query1, query2, tableFields(rows), tableFields(columns), joinType, null);
+            : this.executor.executePivotQueryMerge(QueryMergeDto.from(query1).join(query2, joinType), tableFields(rows), tableFields(columns), null);
     Table expectedTabular = tableFromFile(testInfo);
 
     Assertions.assertThat(pt.table).containsExactlyElementsOf(ImmutableList.copyOf(expectedTabular.iterator()));
diff --git a/core/src/test/java/io/squashql/query/ATestQueryExecutor.java b/core/src/test/java/io/squashql/query/ATestQueryExecutor.java
index f5646a1..8f8db92 100644
--- a/core/src/test/java/io/squashql/query/ATestQueryExecutor.java
+++ b/core/src/test/java/io/squashql/query/ATestQueryExecutor.java
@@ -683,7 +683,7 @@ public abstract class ATestQueryExecutor extends ABaseTestQuery {
             .rollup(tableFields(List.of("category", SCENARIO_FIELD_NAME)))
             .build();
 
-    Table result = this.executor.executeQueryMerge(query1, query2, JoinType.FULL, null);
+    Table result = this.executor.executeQueryMerge(QueryMergeDto.from(query1).join(query2, JoinType.FULL), null);
 
     Assertions.assertThat(result.headers().stream().map(Header::name).toList())
             .containsExactly("category", SCENARIO_FIELD_NAME, "p_sum", "p_min");
@@ -703,6 +703,35 @@ public abstract class ATestQueryExecutor extends ABaseTestQuery {
             Arrays.asList("food", "s2", null, 3d));
   }
 
+  @Test
+  void testMergeThreeTables() {
+    QueryDto query1 = Query
+            .from(this.storeName)
+            .select(tableFields(List.of("category")), List.of(sum("p_sum", "price")))
+            .build();
+
+    QueryDto query2 = Query
+            .from(this.storeName)
+            .select(tableFields(List.of("category")), List.of(min("p_min", "price")))
+            .build();
+
+    QueryDto query3 = Query
+            .from(this.storeName)
+            .select(tableFields(List.of("category")), List.of(max("p_max", "price")))
+            .build();
+
+    QueryMergeDto queryMerge = QueryMergeDto.from(query1)
+            .join(query2, JoinType.INNER)
+            .join(query3, JoinType.INNER);
+    Table result = this.executor.executeQueryMerge(queryMerge, null);
+    Assertions.assertThat(result.headers().stream().map(Header::name).toList())
+            .containsExactly("category", "p_sum", "p_min", "p_max");
+    Assertions.assertThat(result).containsExactly(
+            List.of("cloth", 30d, 10d, 10d),
+            List.of("drink", 7.5d, 1.5d, 4d),
+            List.of("food", 9d, 3d, 3d));
+  }
+
   @Test
   void testMergeWithComparators() {
     QueryDto query1 = Query
@@ -720,7 +749,7 @@ public abstract class ATestQueryExecutor extends ABaseTestQuery {
             .orderBy(tableField(SCENARIO_FIELD_NAME), List.of("s1", MAIN_SCENARIO_NAME, "s2"))
             .build();
 
-    Table result = this.executor.executeQueryMerge(query1, query2, JoinType.FULL, null);
+    Table result = this.executor.executeQueryMerge(QueryMergeDto.from(query1).join(query2, JoinType.FULL), null);
     Assertions.assertThat(result).containsExactly(
             Arrays.asList(GRAND_TOTAL, GRAND_TOTAL, 46.5d, 1.5d),
             Arrays.asList("food", TOTAL, 9d, 3d),
@@ -772,7 +801,7 @@ public abstract class ATestQueryExecutor extends ABaseTestQuery {
             .select_(List.of(group2), List.of(avg("p_avg", "price")))
             .build();
 
-    Table result = this.executor.executeQueryMerge(query1, query2, JoinType.FULL, null);
+    Table result = this.executor.executeQueryMerge(QueryMergeDto.from(query1).join(query2, JoinType.FULL), null);
     Assertions.assertThat(result).containsExactly(
             Arrays.asList("Food & Drink", "food", TOTAL, TOTAL, 9d, null),
             Arrays.asList("Food & Drink", "drink", TOTAL, TOTAL, 7.5d, null),
diff --git a/core/src/test/java/io/squashql/table/TestMergeThreeTables.java b/core/src/test/java/io/squashql/table/TestMergeThreeTables.java
index e80ad54..9fe6f36 100644
--- a/core/src/test/java/io/squashql/table/TestMergeThreeTables.java
+++ b/core/src/test/java/io/squashql/table/TestMergeThreeTables.java
@@ -116,7 +116,7 @@ class TestMergeThreeTables {
                     new ArrayList<>(Arrays.asList(450, null, null, null, 220, null, 180, null, null, 50)),
                     new ArrayList<>(Arrays.asList(0.09, null, null, null, 0.15, 0.15, -0.01, 0.02, -0.05, null)),
                     new ArrayList<>(Arrays.asList(101.5, 99.27, 105.1, 101, null, null, null, null, null, null))));
-    Table mergedTable = MergeTables.mergeTables(List.of(table1, table2, table3), JoinType.FULL);
+    Table mergedTable = MergeTables.mergeTables(List.of(table1, table2, table3), List.of(JoinType.FULL, JoinType.FULL));
     Assertions.assertThat(orderRows(mergedTable)).isEqualTo(orderRows(expectedTable));
   }
 
@@ -212,7 +212,7 @@ class TestMergeThreeTables {
                     new ArrayList<>(Arrays.asList(450, null, null, null, 220, null, 180, null, null, 50)),
                     new ArrayList<>(Arrays.asList(0.09, null, null, null, 0.15, 0.15, -0.01, 0.02, -0.05, null)),
                     new ArrayList<>(Arrays.asList(101.5, 99.27, 105.1, 101, null, null, null, null, null, null))));
-    Table mergedTable = MergeTables.mergeTables(List.of(table1, table2, table3), JoinType.FULL);
+    Table mergedTable = MergeTables.mergeTables(List.of(table1, table2, table3), List.of(JoinType.FULL, JoinType.FULL));
     Assertions.assertThat(orderRows(mergedTable)).isEqualTo(orderRows(expectedTable));
   }
 }
diff --git a/duckdb/src/test/java/io/squashql/TestDuckDBQuery.java b/duckdb/src/test/java/io/squashql/TestDuckDBQuery.java
index 9329742..7a77a00 100644
--- a/duckdb/src/test/java/io/squashql/TestDuckDBQuery.java
+++ b/duckdb/src/test/java/io/squashql/TestDuckDBQuery.java
@@ -6,6 +6,7 @@ import io.squashql.query.database.DuckDBQueryEngine;
 import io.squashql.query.dto.CacheStatsDto;
 import io.squashql.query.dto.JoinType;
 import io.squashql.query.dto.QueryDto;
+import io.squashql.query.dto.QueryMergeDto;
 import io.squashql.query.exception.LimitExceedException;
 import io.squashql.transaction.DuckDBDataLoader;
 import io.squashql.type.TableTypedField;
@@ -90,10 +91,11 @@ public class TestDuckDBQuery {
             .build();
 
     query1.limit = 2;
-    TestUtil.assertThatThrownBy(() -> this.executor.executeQueryMerge(query1, query2, JoinType.INNER, null))
+    QueryMergeDto queryMergeDto = QueryMergeDto.from(query1).join(query2, JoinType.INNER);
+    TestUtil.assertThatThrownBy(() -> this.executor.executeQueryMerge(queryMergeDto, null))
             .isInstanceOf(LimitExceedException.class)
             .hasMessageContaining("too big");
-    TestUtil.assertThatThrownBy(() -> this.executor.executePivotQueryMerge(query1, query2, List.of(tableField("eanId")), List.of(), JoinType.INNER, null))
+    TestUtil.assertThatThrownBy(() -> this.executor.executePivotQueryMerge(queryMergeDto, List.of(tableField("eanId")), List.of(), null))
             .isInstanceOf(LimitExceedException.class)
             .hasMessageContaining("too big");
   }
diff --git a/server/src/test/java/io/squashql/client/HttpClientQuerierTest.java b/server/src/test/java/io/squashql/client/HttpClientQuerierTest.java
index 5c734d6..19bfe63 100644
--- a/server/src/test/java/io/squashql/client/HttpClientQuerierTest.java
+++ b/server/src/test/java/io/squashql/client/HttpClientQuerierTest.java
@@ -83,7 +83,7 @@ public class HttpClientQuerierTest {
             .withColumn(tableField(SCENARIO_FIELD_NAME))
             .withMeasure(new AggregatedMeasure(("qa"), "quantity", "avg"));
 
-    QueryResultDto response = this.querier.queryMerge(new QueryMergeDto(query1, query2, JoinType.FULL));
+    QueryResultDto response = this.querier.queryMerge(QueryMergeDto.from(query1).join(query2, JoinType.FULL));
     Assertions.assertThat(response.table.rows).containsExactlyInAnyOrder(List.of("MDD up", 4000, 1000d),
             List.of("MN & MDD down", 4000, 1000d),
             List.of("MN & MDD up", 4000, 1000d),
diff --git a/server/src/test/java/io/squashql/js/TestJavascriptLibrary.java b/server/src/test/java/io/squashql/js/TestJavascriptLibrary.java
index f26844a..26afb42 100644
--- a/server/src/test/java/io/squashql/js/TestJavascriptLibrary.java
+++ b/server/src/test/java/io/squashql/js/TestJavascriptLibrary.java
@@ -175,11 +175,15 @@ public class TestJavascriptLibrary {
             .build();
     QueryDto q2 = from(table.name)
             .select(tableFields(List.of("a", "b")),
-                    List.of(avg("sum", "f1")))
+                    List.of(avg("avg", "f1")))
             .build();
 
-    QueryMergeDto query = new QueryMergeDto(q1, q2, JoinType.LEFT);
+    QueryDto q3 = from(table.name)
+            .select(tableFields(List.of("a", "b")),
+                    List.of(max("max", "f1")))
+            .build();
 
+    QueryMergeDto query = QueryMergeDto.from(q1).join(q2, JoinType.LEFT).join(q3, JoinType.INNER);
     String name = "build-from-query-merge.json"; // The content of this file is generated by the js code.
     QueryMergeDto qjs = JacksonUtil.deserialize(TestUtil.readAllLines(name), QueryMergeDto.class);
     Assertions.assertThat(qjs).isEqualTo(query);
@@ -210,7 +214,7 @@ public class TestJavascriptLibrary {
                     List.of(avg("sum", "f1")))
             .build();
 
-    QueryMergeDto query = new QueryMergeDto(q1, q2, JoinType.LEFT);
+    QueryMergeDto query = QueryMergeDto.from(q1).join(q2, JoinType.LEFT);
     PivotTableQueryMergeDto pivotTableQueryMergeDto = new PivotTableQueryMergeDto(query, tableFields(List.of("a")), tableFields(List.of("b")));
 
     String name = "build-from-query-merge-pivot.json"; // The content of this file is generated by the js code.
diff --git a/server/src/test/java/io/squashql/spring/web/rest/QueryControllerTest.java b/server/src/test/java/io/squashql/spring/web/rest/QueryControllerTest.java
index 1534334..b2f631c 100644
--- a/server/src/test/java/io/squashql/spring/web/rest/QueryControllerTest.java
+++ b/server/src/test/java/io/squashql/spring/web/rest/QueryControllerTest.java
@@ -245,7 +245,7 @@ public class QueryControllerTest {
             .build();
 
     this.mvc.perform(MockMvcRequestBuilders.post(QueryController.MAPPING_QUERY_MERGE)
-                    .content(JacksonUtil.serialize(new QueryMergeDto(query1, query2, JoinType.FULL)))
+                    .content(JacksonUtil.serialize(QueryMergeDto.from(query1).join(query2, JoinType.FULL)))
                     .contentType(MediaType.APPLICATION_JSON))
             .andExpect(result -> {
               String contentAsString = result.getResponse().getContentAsString();
@@ -268,9 +268,8 @@ public class QueryControllerTest {
             .select(tableFields(List.of("ean")), List.of(Functions.avg("capdv-avg", "capdv")))
             .build();
 
-    QueryMergeDto queryMergeDto = new QueryMergeDto(query1, query2, JoinType.FULL);
     this.mvc.perform(MockMvcRequestBuilders.post(QueryController.MAPPING_QUERY_MERGE_PIVOT)
-                    .content(JacksonUtil.serialize(new PivotTableQueryMergeDto(queryMergeDto, tableFields(List.of("ean")), List.of())))
+                    .content(JacksonUtil.serialize(new PivotTableQueryMergeDto(QueryMergeDto.from(query1).join(query2, JoinType.FULL), tableFields(List.of("ean")), List.of())))
                     .contentType(MediaType.APPLICATION_JSON))
             .andExpect(result -> {
               String contentAsString = result.getResponse().getContentAsString();
@@ -297,7 +296,7 @@ public class QueryControllerTest {
             .build();
 
     this.mvc.perform(MockMvcRequestBuilders.post(QueryController.MAPPING_QUERY_MERGE)
-                    .content(JacksonUtil.serialize(new QueryMergeDto(query1, query2, JoinType.FULL)))
+                    .content(JacksonUtil.serialize(QueryMergeDto.from(query1).join(query2, JoinType.FULL)))
                     .contentType(MediaType.APPLICATION_JSON))
             .andExpect(status().isInternalServerError())
             .andExpect(result -> Assertions.assertThat(result.getResolvedException()).isInstanceOf(LimitExceedException.class))
diff --git a/server/src/test/resources/build-from-query-merge-pivot.json b/server/src/test/resources/build-from-query-merge-pivot.json
index df1d4e3..48993e6 100644
--- a/server/src/test/resources/build-from-query-merge-pivot.json
+++ b/server/src/test/resources/build-from-query-merge-pivot.json
@@ -1,1 +1,1 @@
-{"query":{"first":{"table":{"name":"myTable","joins":[]},"virtualTableDtos":[],"columns":[{"@class":"io.squashql.query.TableField","fullName":"a","fieldName":"a"},{"@class":"io.squashql.query.TableField","fullName":"b","fieldName":"b"}],"rollupColumns":[],"columnSets":{},"parameters":{},"measures":[{"@class":"io.squashql.query.AggregatedMeasure","field":{"@class":"io.squashql.query.TableField","fullName":"f1","fieldName":"f1"},"aggregationFunction":"sum","alias":"sum"}],"orders":{},"limit":-1},"second":{"table":{"name":"myTable","joins":[]},"virtualTableDtos":[],"columns":[{"@class":"io.squashql.query.TableField","fullName":"a","fieldName":"a"},{"@class":"io.squashql.query.TableField","fullName":"b","fieldName":"b"}],"rollupColumns":[],"columnSets":{},"parameters":{},"measures":[{"@class":"io.squashql.query.AggregatedMeasure","field":{"@class":"io.squashql.query.TableField","fullName":"f1","fieldName":"f1"},"aggregationFunction":"avg","alias":"sum"}],"orders":{},"limit":-1},"joinType":"LEFT"},"rows":[{"@class":"io.squashql.query.TableField","fullName":"a","fieldName":"a"}],"columns":[{"@class":"io.squashql.query.TableField","fullName":"b","fieldName":"b"}]}
\ No newline at end of file
+{"query":{"queries":[{"table":{"name":"myTable","joins":[]},"virtualTableDtos":[],"columns":[{"@class":"io.squashql.query.TableField","fullName":"a","fieldName":"a"},{"@class":"io.squashql.query.TableField","fullName":"b","fieldName":"b"}],"rollupColumns":[],"columnSets":{},"parameters":{},"measures":[{"@class":"io.squashql.query.AggregatedMeasure","field":{"@class":"io.squashql.query.TableField","fullName":"f1","fieldName":"f1"},"aggregationFunction":"sum","alias":"sum"}],"orders":{},"limit":-1},{"table":{"name":"myTable","joins":[]},"virtualTableDtos":[],"columns":[{"@class":"io.squashql.query.TableField","fullName":"a","fieldName":"a"},{"@class":"io.squashql.query.TableField","fullName":"b","fieldName":"b"}],"rollupColumns":[],"columnSets":{},"parameters":{},"measures":[{"@class":"io.squashql.query.AggregatedMeasure","field":{"@class":"io.squashql.query.TableField","fullName":"f1","fieldName":"f1"},"aggregationFunction":"avg","alias":"sum"}],"orders":{},"limit":-1}],"joinTypes":["LEFT"]},"rows":[{"@class":"io.squashql.query.TableField","fullName":"a","fieldName":"a"}],"columns":[{"@class":"io.squashql.query.TableField","fullName":"b","fieldName":"b"}]}
\ No newline at end of file