- * Compare Mapping using a TreeMap
+ * Groups mappings and tracks distinct solutions using immutable tuple keys.
+ * Groups are visited in insertion order.
*
* @author Olivier Corby, Edelweiss, INRIA 2011
*/
public class Group implements Comparator {
private static final boolean COMPARE_INDEX = false;
- TreeMapping table;
+ private static final Mappings DISTINCT_MARKER = new Mappings();
- boolean isDistinct = false;
- // min(?l, groupBy(?x, ?y))
- boolean isExtend = false;
+ private final Map table = new LinkedHashMap<>();
+ private boolean isDistinct;
+ private boolean isExtend;
private List nodes;
- private boolean isFake = false;
+ private boolean isFake;
+ /** Preserves the no-argument constructor used by Merge. */
public Group() {
-
+ this(List.of());
}
public Group(List list) {
this.nodes = list;
- table = new TreeMapping(list);
}
public static boolean isCompareIndex() {
return COMPARE_INDEX;
}
- public static Group create(List lNode) {
- return new Group(lNode);
+ public static Group create(List nodeList) {
+ return new Group(nodeList);
}
- @SuppressWarnings("java:S9391") // Preserve ordered accumulation without allocating a stream pipeline.
+ @SuppressWarnings("java:S9391") // Avoid allocating a stream pipeline.
public static Group createFromExp(List list) {
- List nodes = new ArrayList<>();
+ List nodeList = new ArrayList<>(list.size());
for (Exp exp : list) {
- nodes.add(exp.getNode());
+ nodeList.add(exp.getNode());
}
- return new Group(nodes);
+ return new Group(nodeList);
}
public boolean isFake() {
return isFake;
}
- public void setFake(boolean afake) {
- this.isFake = afake;
+ public void setFake(boolean fake) {
+ this.isFake = fake;
}
public List getNodeList() {
@@ -71,148 +70,135 @@ public void setNodeList(List nodes) {
this.nodes = nodes;
}
- public void setDistinct(boolean b) {
- isDistinct = b;
+ public void setDistinct(boolean distinct) {
+ isDistinct = distinct;
}
- public void setExtend(boolean b) {
- isExtend = b;
+ public void setExtend(boolean extend) {
+ isExtend = extend;
}
- public void setDuplicate(boolean b) {
- // Duplicates are managed by the mappings collection, not this grouping
- // comparator.
+ @SuppressWarnings("java:S1172") // Retained for API compatibility.
+ public void setDuplicate(boolean duplicate) {
+ // Duplicates are managed by the mappings collection.
}
- @SuppressWarnings("java:S1172")
+ @SuppressWarnings("java:S1172") // Retained as an extension point.
public boolean accept(Node node) {
return true;
}
@Override
- public int compare(Mappings lm1, Mappings lm2) {
- Mapping m1 = lm1.get(0);
- Mapping m2 = lm2.get(0);
- return lm1.compare(m1, m2);
+ public int compare(Mappings left, Mappings right) {
+ return left.compare(left.get(0), right.get(0));
}
- /**
- * add map in a group by
- */
+ /** Adds a mapping to its group in expected constant time per tuple lookup. */
public boolean add(Mapping map) {
+ GroupKey key = groupKey(map);
+ Mappings mappings = table.computeIfAbsent(key, ignored -> createNewMappings(isFake()));
+ mappings.add(map);
+ return true;
+ }
+
+ private GroupKey groupKey(Mapping map) {
if (isExtend) {
- // min(?l, groupBy(?x, ?y))
- // store value of ?x ?y in an array to speed up
map.setGroup(getNodeList());
+ return new GroupKey(map.getGroupNodes());
}
-
- Mappings lm = table.get(map);
- if (lm == null) {
- lm = new Mappings();
- lm.setFake(isFake());
- table.put(map, lm);
+ if (isDistinct) {
+ return distinctKey(map);
+ }
+ Node[] values = new Node[nodes.size()];
+ for (int i = 0; i < values.length; i++) {
+ values[i] = map.getGroupBy(nodes.get(i), i);
}
- lm.add(map);
+ return new GroupKey(values);
+ }
- return true;
+ private static Mappings createNewMappings(boolean fake) {
+ Mappings mappings = new Mappings();
+ mappings.setFake(fake);
+ return mappings;
}
- // select distinct *
- // select (avg(distinct ?x) as ?a
+ /** Returns true only for the first occurrence of the distinct tuple. */
public boolean isDistinct(Mapping map) {
- map.computeDistinct(getNodeList());
+ GroupKey key = distinctKey(map);
+ return table.putIfAbsent(key, DISTINCT_MARKER) == null;
+ }
- if (table.containsKey(map)) {
- return false;
- }
- table.put(map, null);
- return true;
+ private GroupKey distinctKey(Mapping map) {
+ map.computeDistinct(getNodeList());
+ return new GroupKey(map.getDistinctNodes());
}
public Iterable getValues() {
return table.values();
}
- class TreeMapping extends TreeMap {
- @java.io.Serial
- private static final long serialVersionUID = 1L;
-
-
- TreeMapping(List ln) {
- super(new Compare(ln));
- }
- }
-
- class Compare implements Comparator {
+ /**
+ * Owns a defensive copy of the tuple array, never exposed to callers.
+ * As with other hash keys, nodes must retain their term values and indices
+ * while stored in the table.
+ */
+ private static final class GroupKey {
- List list;
- int size;
+ private final Node[] values;
+ private final int hash;
- Compare(List ln) {
- list = ln;
- size = list.size();
+ private GroupKey(Node[] values) {
+ this.values = values.clone();
+ int[] componentHashes = new int[this.values.length];
+ for (int i = 0; i < componentHashes.length; i++) {
+ componentHashes[i] = nodeHash(this.values[i]);
+ }
+ hash = Arrays.hashCode(componentHashes);
}
@Override
- public int compare(Mapping m1, Mapping m2) {
- if (isExtend) {
- return compareExtend(m1, m2);
+ public boolean equals(Object other) {
+ if (this == other) {
+ return true;
}
-
- if (isDistinct) {
- return compareDistinct(m1, m2);
+ if (!(other instanceof GroupKey key) || values.length != key.values.length) {
+ return false;
}
-
- return compareGroup(m1, m2);
- }
-
- int compareExtend(Mapping m1, Mapping m2) {
- Node[] g1 = m1.getGroupNodes();
- Node[] g2 = m2.getGroupNodes();
-
- for (int i = 0; i < size; i++) {
- int res = compare(g1[i], g2[i]);
- if (res != 0) {
- return res;
+ for (int i = 0; i < values.length; i++) {
+ if (!sameNode(values[i], key.values[i])) {
+ return false;
}
}
- return 0;
+ return true;
}
- int compareDistinct(Mapping m1, Mapping m2) {
- for (int i = 0; i < size; i++) {
- int res = compare(m1.getDistinctNode(i), m2.getDistinctNode(i));
- if (res != 0) {
- return res;
- }
+ private static boolean sameNode(Node left, Node right) {
+ if (left == right) {
+ return true;
}
- return 0;
- }
-
- int compareGroup(Mapping m1, Mapping m2) {
- for (int i = 0; i < size; i++) {
- Node qNode = list.get(i);
- int res = compare(m1.getGroupBy(qNode, i), m2.getGroupBy(qNode, i));
- if (res != 0) {
- return res;
- }
+ if (left == null || right == null) {
+ return false;
}
- return 0;
+ if (isCompareIndex() && left.getIndex() != -1 && right.getIndex() != -1) {
+ return left.getIndex() == right.getIndex();
+ }
+ // Preserve the original grouping comparator, including datatype ordering.
+ return left.compare(right) == 0;
}
- int compare(Node n1, Node n2) {
- if (n1 == n2) {
+ private static int nodeHash(Node node) {
+ if (node == null) {
return 0;
- } else if (n1 == null) {
- return -1;
- } else if (n2 == null) {
- return +1;
- } else if (isCompareIndex() && n1.getIndex() != -1 && n2.getIndex() != -1) {
- return Integer.compare(n1.getIndex(), n2.getIndex());
- } else {
- return n1.compare(n2);
}
+ if (isCompareIndex() && node.getIndex() != -1) {
+ return Integer.hashCode(node.getIndex());
+ }
+ return node.hashCode();
}
- }
+ @Override
+ public int hashCode() {
+ return hash;
+ }
+ }
}
diff --git a/src/main/java/fr/inria/corese/core/next/query/impl/engine/solution/Mappings.java b/src/main/java/fr/inria/corese/core/next/query/impl/engine/solution/Mappings.java
index bd9b18cef..14bb642d4 100644
--- a/src/main/java/fr/inria/corese/core/next/query/impl/engine/solution/Mappings.java
+++ b/src/main/java/fr/inria/corese/core/next/query/impl/engine/solution/Mappings.java
@@ -655,6 +655,11 @@ public void aggregate(Query q, Evaluator evaluator, Memory memory, Producer p) {
}
}
+ if (fr.inria.corese.core.next.query.impl.sparql.bridge.DirectGroupAggregation.tryAggregate(q, this)) {
+ finish(q);
+ return;
+ }
+
// select (count(?n) as ?count)
aggregateExpList(q, evaluator, memory, p, q.getSelectFun(), true);
diff --git a/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/DirectGroupAggregation.java b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/DirectGroupAggregation.java
new file mode 100644
index 000000000..b3dd86bb5
--- /dev/null
+++ b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/DirectGroupAggregation.java
@@ -0,0 +1,256 @@
+package fr.inria.corese.core.next.query.impl.sparql.bridge;
+
+import fr.inria.corese.core.next.data.Values;
+import fr.inria.corese.core.next.data.api.exception.IncorrectOperationException;
+import fr.inria.corese.core.next.query.api.exception.QueryTypeErrorException;
+import fr.inria.corese.core.next.query.impl.engine.model.Node;
+import fr.inria.corese.core.next.query.impl.engine.model.NodeImpl;
+import fr.inria.corese.core.next.query.impl.engine.pattern.Exp;
+import fr.inria.corese.core.next.query.impl.engine.pattern.Query;
+import fr.inria.corese.core.next.query.impl.engine.solution.Mapping;
+import fr.inria.corese.core.next.query.impl.engine.solution.Mappings;
+import fr.inria.corese.core.next.query.impl.sparql.ast.AggregateAst;
+import fr.inria.corese.core.next.query.impl.sparql.ast.AggregateFunction;
+import fr.inria.corese.core.next.query.impl.sparql.ast.VarAst;
+
+import java.math.BigInteger;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.TreeMap;
+
+/**
+ * Direct aggregation of simple grouped SELECT projections.
+ *
+ *
Only the first mapping and scalar accumulator state are retained per group.
+ * Group keys use the same node order as the engine's ordinary grouping, including
+ * unbound keys. A sorted table avoids assuming that arbitrary Node implementations
+ * provide hashes consistent with {@link Node#compare(Node)}.
+ * This component does not evaluate HAVING, ORDER BY, or emit evaluator callbacks;
+ * callers requiring those facilities must use the ordinary aggregation path.
+ */
+public final class DirectGroupAggregation {
+
+ private DirectGroupAggregation() {
+ }
+
+ /** Returns whether the query can be reduced without evaluating expressions. */
+ public static boolean isEligible(Query query) {
+ if (!query.isSelect() || (query.isDistinct() && !query.isDistinctRedundantWithGroupBy()) || query.isListGroup()
+ || query.isConnect() || query.getGroupBy().isEmpty()
+ || query.getHaving() != null || !query.getOrderBy().isEmpty()) {
+ return false;
+ }
+ if (!query.getGroupBy().stream().allMatch(DirectGroupAggregation::isPlainGroup)) {
+ return false;
+ }
+ return eligibleProjections(query);
+ }
+
+ private static boolean isPlainGroup(Exp expression) {
+ return expression.getNode() != null && expression.getNode().isVariable()
+ && expression.getFilter() == null && !expression.isExpGroupBy();
+ }
+
+ private static boolean eligibleProjections(Query query) {
+ boolean hasAggregate = false;
+ for (Exp projection : query.getSelectFun()) {
+ if (projection.isExpGroupBy() || projection.getNode() == null) {
+ return false;
+ }
+ if (projection.isAggregate()) {
+ if (simpleAggregate(projection) == null) {
+ return false;
+ }
+ hasAggregate = true;
+ } else if (projection.getFilter() != null) {
+ return false;
+ }
+ }
+ return hasAggregate;
+ }
+
+ private static AggregateAst simpleAggregate(Exp projection) {
+ if (projection.getFilter() == null
+ || !(projection.getFilter().getExp() instanceof AstBackedExpr expression)
+ || !(expression.sourceAst().orElse(null) instanceof AggregateAst aggregate)
+ || aggregate.distinct()) {
+ return null;
+ }
+ boolean supported = switch (aggregate.function()) {
+ case COUNT, SUM, AVG, MIN, MAX -> true;
+ default -> false;
+ };
+ boolean simpleArgument = aggregate.expression() instanceof VarAst
+ || aggregate.function() == AggregateFunction.COUNT && aggregate.expression() == null;
+ return supported && simpleArgument ? aggregate : null;
+ }
+
+ /**
+ * Replaces input mappings by their aggregated representatives.
+ *
+ * @return false when ineligible, leaving input mappings untouched; true after
+ * successful reduction, including an empty grouped input
+ */
+ public static boolean tryAggregate(Query query, Mappings mappings) {
+ if (!isEligible(query) || mappings.isFake() || mappings.getGroup() != null) {
+ return false;
+ }
+ List projections = projections(query);
+ Node[] groupNodes = query.getGroupBy().stream().map(Exp::getNode).toArray(Node[]::new);
+ TreeMap groups = new TreeMap<>(GroupKey::compareTo);
+ for (Mapping mapping : mappings) {
+ GroupKey key = GroupKey.from(mapping, groupNodes);
+ groups.computeIfAbsent(key, ignored -> new GroupState(mapping, projections)).add(mapping);
+ }
+ List representatives = new ArrayList<>(groups.size());
+ for (GroupState state : groups.values()) {
+ representatives.add(state.finish(projections));
+ }
+ mappings.getList().clear();
+ mappings.getList().addAll(representatives);
+ return true;
+ }
+
+ private static List projections(Query query) {
+ List projections = new ArrayList<>();
+ for (Exp expression : query.getSelectFun()) {
+ if (expression.isAggregate()) {
+ AggregateAst aggregate = simpleAggregate(expression);
+ String variable = aggregate.expression() instanceof VarAst varAst ? varAst.name() : null;
+ projections.add(new Projection(expression.getNode(), variable, aggregate.function()));
+ }
+ }
+ return projections;
+ }
+
+ private record Projection(Node target, String variable, AggregateFunction function) {
+ }
+
+ private static final class GroupKey {
+ private final Node[] values;
+
+ private GroupKey(Node[] values) {
+ this.values = values;
+ }
+
+ static GroupKey from(Mapping mapping, Node[] variables) {
+ Node[] values = new Node[variables.length];
+ Node[] prepared = mapping.getGroupByNodes();
+ for (int i = 0; i < variables.length; i++) {
+ values[i] = prepared == null || prepared.length == 0
+ ? mapping.getNode(variables[i]) : prepared[i];
+ }
+ return new GroupKey(values);
+ }
+
+ int compareTo(GroupKey other) {
+ for (int i = 0; i < values.length; i++) {
+ int comparison = compare(values[i], other.values[i]);
+ if (comparison != 0) {
+ return comparison;
+ }
+ }
+ return 0;
+ }
+
+ private static int compare(Node left, Node right) {
+ if (left == right) {
+ return 0;
+ }
+ if (left == null) {
+ return -1;
+ }
+ return right == null ? 1 : left.compare(right);
+ }
+ }
+
+ private static final class GroupState {
+ private final Mapping representative;
+ private final Accumulator[] accumulators;
+
+ GroupState(Mapping mapping, List projections) {
+ representative = mapping;
+ accumulators = projections.stream().map(Accumulator::new)
+ .toArray(Accumulator[]::new);
+ }
+
+ void add(Mapping mapping) {
+ for (int i = 0; i < accumulators.length; i++) {
+ accumulators[i].add(mapping);
+ }
+ }
+
+ Mapping finish(List projections) {
+ for (int i = 0; i < accumulators.length; i++) {
+ representative.setNode(projections.get(i).target(), accumulators[i].result());
+ }
+ return representative;
+ }
+ }
+
+ private static final class Accumulator {
+ private final AggregateFunction function;
+ private final String variable;
+ private final NativeAggregateEvaluator.NumericAccumulator numeric;
+ private long count;
+ private Node best;
+ private boolean error;
+
+ Accumulator(Projection projection) {
+ function = projection.function();
+ variable = projection.variable();
+ numeric = function == AggregateFunction.SUM || function == AggregateFunction.AVG
+ ? new NativeAggregateEvaluator.NumericAccumulator() : null;
+ }
+
+ void add(Mapping mapping) {
+ Node value = variable == null ? null : mapping.getNode(variable);
+ if (function == AggregateFunction.COUNT) {
+ if (variable == null || value != null) {
+ count++;
+ }
+ } else if (value != null && !error) {
+ addValue(value);
+ }
+ }
+
+ private void addValue(Node value) {
+ try {
+ switch (function) {
+ case SUM, AVG -> numeric.add(value.getDatatypeValue());
+ case MIN, MAX -> updateBest(value);
+ default -> throw new IllegalStateException("Unsupported aggregate: " + function);
+ }
+ } catch (QueryTypeErrorException | IncorrectOperationException | ArithmeticException
+ | NumberFormatException e) {
+ // A type error invalidates this aggregate, not the other projections.
+ error = true;
+ }
+ }
+
+ private void updateBest(Node value) {
+ if (best == null) {
+ best = value;
+ return;
+ }
+ int comparison = value.compare(best);
+ if (function == AggregateFunction.MIN ? comparison < 0 : comparison > 0) {
+ best = value;
+ }
+ }
+
+ Node result() {
+ if (error) {
+ return null;
+ }
+ return switch (function) {
+ case COUNT -> NodeImpl.forValue(Values.factory().createLiteral(BigInteger.valueOf(count)));
+ case SUM -> NodeImpl.forValue(numeric.toSumLiteral(Values.factory()));
+ case AVG -> numeric.hasValues()
+ ? NodeImpl.forValue(numeric.toAvgLiteral(Values.factory())) : null;
+ case MIN, MAX -> best;
+ default -> throw new IllegalStateException("Unsupported aggregate: " + function);
+ };
+ }
+ }
+}
diff --git a/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/NativeAggregateEvaluator.java b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/NativeAggregateEvaluator.java
index 0736c9352..30253d674 100644
--- a/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/NativeAggregateEvaluator.java
+++ b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/bridge/NativeAggregateEvaluator.java
@@ -1,6 +1,7 @@
package fr.inria.corese.core.next.query.impl.sparql.bridge;
import fr.inria.corese.core.next.data.api.model.DatatypeValue;
+import fr.inria.corese.core.next.data.api.factory.ValueFactory;
import fr.inria.corese.core.next.data.api.term.Literal;
import fr.inria.corese.core.next.query.api.exception.QueryTypeErrorException;
import fr.inria.corese.core.next.query.impl.engine.solution.Mapping;
@@ -124,7 +125,7 @@ private static DatatypeValue evaluateSum(
if (!accumulator.hasValues()) {
return context.values().createLiteral(BigInteger.ZERO);
}
- return accumulator.toSumLiteral(context);
+ return accumulator.toSumLiteral(context.values());
}
private static DatatypeValue evaluateAvg(
@@ -153,7 +154,7 @@ private static DatatypeValue evaluateAvg(
throw new QueryTypeErrorException("AVG is undefined when all values are unbound");
}
- return accumulator.toAvgLiteral(context);
+ return accumulator.toAvgLiteral(context.values());
}
private static DatatypeValue evaluateMinMax(
@@ -276,7 +277,7 @@ private static String unquoteSeparator(String rawSeparator, NativeEvaluationCont
}
/** Helper class accumulating numeric values across solution mappings. */
- private static final class NumericAccumulator {
+ static final class NumericAccumulator {
private BigInteger intSum = BigInteger.ZERO;
private BigDecimal decSum = BigDecimal.ZERO;
private double doubleSum = 0.0;
@@ -323,22 +324,22 @@ boolean hasValues() {
return count > 0;
}
- DatatypeValue toSumLiteral(NativeEvaluationContext context) {
+ DatatypeValue toSumLiteral(ValueFactory values) {
return switch (currentKind) {
- case DOUBLE -> context.values().createLiteral(doubleSum);
- case FLOAT -> context.values().createLiteral((float) doubleSum);
- case DECIMAL -> context.values().createLiteral(decSum);
- case INTEGER -> context.values().createLiteral(intSum);
+ case DOUBLE -> values.createLiteral(doubleSum);
+ case FLOAT -> values.createLiteral((float) doubleSum);
+ case DECIMAL -> values.createLiteral(decSum);
+ case INTEGER -> values.createLiteral(intSum);
};
}
- DatatypeValue toAvgLiteral(NativeEvaluationContext context) {
+ DatatypeValue toAvgLiteral(ValueFactory values) {
return switch (currentKind) {
- case DOUBLE -> context.values().createLiteral(doubleSum / count);
- case FLOAT -> context.values().createLiteral((float) (doubleSum / count));
- case DECIMAL -> context.values().createLiteral(
+ case DOUBLE -> values.createLiteral(doubleSum / count);
+ case FLOAT -> values.createLiteral((float) (doubleSum / count));
+ case DECIMAL -> values.createLiteral(
decSum.divide(BigDecimal.valueOf(count), MathContext.DECIMAL128));
- case INTEGER -> context.values().createLiteral(
+ case INTEGER -> values.createLiteral(
new BigDecimal(intSum).divide(BigDecimal.valueOf(count), MathContext.DECIMAL128));
};
}
diff --git a/src/test/java/fr/inria/corese/core/next/query/impl/engine/pattern/GroupTest.java b/src/test/java/fr/inria/corese/core/next/query/impl/engine/pattern/GroupTest.java
new file mode 100644
index 000000000..76af11ffc
--- /dev/null
+++ b/src/test/java/fr/inria/corese/core/next/query/impl/engine/pattern/GroupTest.java
@@ -0,0 +1,548 @@
+package fr.inria.corese.core.next.query.impl.engine.pattern;
+
+import fr.inria.corese.core.next.query.impl.engine.model.Node;
+import fr.inria.corese.core.next.query.impl.engine.model.NodeImpl;
+import fr.inria.corese.core.next.query.impl.engine.solution.Mapping;
+import fr.inria.corese.core.next.query.impl.engine.solution.Mappings;
+
+import java.lang.reflect.Constructor;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Stream;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.EnumSource;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+@DisplayName("Group — regroupement par clés de tuples immuables")
+class GroupTest {
+
+ private static final String XSD_INTEGER =
+ "http://www.w3.org/2001/XMLSchema#integer";
+
+ private static final Node X = NodeImpl.forVariable("?x");
+ private static final Node Y = NodeImpl.forVariable("?y");
+ private static final Node L = NodeImpl.forVariable("?l");
+
+ enum Mode {
+ NORMAL, DISTINCT, EXTEND
+ }
+
+ @ParameterizedTest(name = "{0} variable(s) de groupement")
+ @ValueSource(ints = {1, 2, 5})
+ void groupsTuplesUsingEveryComponent(int arity) {
+ List variables = variables(arity);
+ Group group = Group.create(variables);
+
+ Node[] values = new Node[arity];
+ Node[] equivalentValues = new Node[arity];
+ for (int i = 0; i < arity; i++) {
+ values[i] = iri("value-" + i);
+ equivalentValues[i] = iri("value-" + i);
+ }
+
+ Mapping first = mapping(variables, values);
+ Mapping equivalent = mapping(variables, equivalentValues);
+
+ assertTrue(group.add(first));
+ assertTrue(group.add(equivalent));
+
+ // Chaque composante, y compris la dernière, participe à la clé.
+ List differentTuples = new ArrayList<>();
+ for (int i = 0; i < arity; i++) {
+ Node[] changed = equivalentValues.clone();
+ changed[i] = iri("different-" + i);
+ Mapping different = mapping(variables, changed);
+ differentTuples.add(different);
+ assertTrue(group.add(different));
+ }
+
+ List result = groups(group);
+ assertEquals(arity + 1, result.size());
+ assertMembers(result.get(0), first, equivalent);
+ for (int i = 0; i < arity; i++) {
+ assertMembers(result.get(i + 1), differentTuples.get(i));
+ }
+ }
+
+ @Test
+ void preservesGroupInsertionOrderAndMappingInsertionOrder() {
+ Group group = Group.create(List.of(X));
+
+ Mapping b1 = mapping(List.of(X), iri("b"));
+ Mapping a1 = mapping(List.of(X), iri("a"));
+ Mapping b2 = mapping(List.of(X), iri("b"));
+ Mapping c1 = mapping(List.of(X), iri("c"));
+ Mapping a2 = mapping(List.of(X), iri("a"));
+
+ group.add(b1);
+ group.add(a1);
+ group.add(b2);
+ group.add(c1);
+ group.add(a2);
+
+ List result = groups(group);
+ assertEquals(3, result.size());
+ assertMembers(result.get(0), b1, b2);
+ assertMembers(result.get(1), a1, a2);
+ assertMembers(result.get(2), c1);
+ }
+
+ @Test
+ void emptyGroupHasNoValues() {
+ assertTrue(groups(Group.create(List.of(X))).isEmpty());
+ }
+
+ @Test
+ void emptyGroupingTupleProducesOneGlobalGroup() {
+ Group group = new Group();
+ Mapping first = mapping(List.of(X), iri("a"));
+ Mapping second = mapping(List.of(X), iri("b"));
+
+ group.add(first);
+ group.add(second);
+
+ assertEquals(1, groups(group).size());
+ assertMembers(groups(group).get(0), first, second);
+ }
+
+ @Test
+ void normalModeUsesComputedGroupByValues() {
+ Group group = Group.create(List.of(X));
+
+ // Les bindings diffèrent, mais l'expression GROUP BY vaut pareil.
+ Mapping first = mapping(List.of(X), iri("binding-a"));
+ first.setGroupBy(new Node[]{literal("same")});
+
+ Mapping second = mapping(List.of(X), iri("binding-b"));
+ second.setGroupBy(new Node[]{literal("same")});
+
+ // Même binding que first, mais autre résultat de GROUP BY.
+ Mapping third = mapping(List.of(X), iri("binding-a"));
+ third.setGroupBy(new Node[]{literal("other")});
+
+ group.add(first);
+ group.add(second);
+ group.add(third);
+
+ List result = groups(group);
+ assertEquals(2, result.size());
+ assertMembers(result.get(0), first, second);
+ assertMembers(result.get(1), third);
+ }
+
+ @ParameterizedTest
+ @EnumSource(Mode.class)
+ void groupsUnboundVariablesByTheirPosition(Mode mode) {
+ List variables = List.of(X, Y);
+ Group group = group(mode, variables);
+
+ Mapping first = mapping(variables, null, iri("a"));
+ Mapping equivalent = mapping(variables, null, iri("a"));
+ Mapping otherValue = mapping(variables, null, iri("b"));
+ Mapping otherPosition = mapping(variables, iri("a"), null);
+ Mapping allNull = mapping(variables, null, null);
+ Mapping allNullAgain = mapping(variables, null, null);
+
+ group.add(first);
+ group.add(equivalent);
+ group.add(otherValue);
+ group.add(otherPosition);
+ group.add(allNull);
+ group.add(allNullAgain);
+
+ List result = groups(group);
+ assertEquals(4, result.size());
+ assertMembers(result.get(0), first, equivalent);
+ assertMembers(result.get(1), otherValue);
+ assertMembers(result.get(2), otherPosition);
+ assertMembers(result.get(3), allNull, allNullAgain);
+ }
+
+ @ParameterizedTest
+ @ValueSource(booleans = {false, true})
+ void propagatesFakeFlagToEveryCreatedMappings(boolean fake) {
+ Group group = Group.create(List.of(X));
+ assertFalse(group.isFake());
+ group.setFake(fake);
+ assertEquals(fake, group.isFake());
+
+ Mapping first = mapping(List.of(X), iri("a"));
+ Mapping duplicate = mapping(List.of(X), iri("a"));
+ Mapping other = mapping(List.of(X), iri("b"));
+
+ group.add(first);
+ group.add(duplicate);
+ group.add(other);
+
+ List result = groups(group);
+ assertEquals(2, result.size());
+ for (Mappings mappings : result) {
+ assertEquals(fake, mappings.isFake());
+ }
+ assertMembers(result.get(0), first, duplicate);
+ assertMembers(result.get(1), other);
+ }
+
+ @Test
+ void distinctAcceptsOnlyFirstOccurrenceOfSelectedTuple() {
+ Group distinct = Group.create(List.of(X));
+ distinct.setDistinct(true);
+
+ // ?y ne participe pas à COUNT(DISTINCT ?x) / AVG(DISTINCT ?x).
+ assertTrue(distinct.isDistinct(
+ mapping(List.of(X, Y), integer("1"), iri("a"))));
+ assertFalse(distinct.isDistinct(
+ mapping(List.of(X, Y), integer("1"), iri("b"))));
+ assertTrue(distinct.isDistinct(
+ mapping(List.of(X, Y), integer("2"), iri("a"))));
+ assertFalse(distinct.isDistinct(
+ mapping(List.of(X, Y), integer("2"), iri("c"))));
+
+ // Vérifie que la première clé reste présente après d'autres insertions.
+ assertFalse(distinct.isDistinct(
+ mapping(List.of(X, Y), integer("1"), iri("d"))));
+ }
+
+ @Test
+ void distinctTracksWholeTupleIncludingUnboundComponents() {
+ Group distinct = Group.create(List.of(X, Y));
+
+ assertTrue(distinct.isDistinct(
+ mapping(List.of(X, Y), iri("a"), null)));
+ assertFalse(distinct.isDistinct(
+ mapping(List.of(X, Y), iri("a"), null)));
+ assertTrue(distinct.isDistinct(
+ mapping(List.of(X, Y), iri("b"), null)));
+ assertTrue(distinct.isDistinct(
+ mapping(List.of(X, Y), null, iri("a"))));
+ assertTrue(distinct.isDistinct(
+ mapping(List.of(X, Y), null, null)));
+ assertFalse(distinct.isDistinct(
+ mapping(List.of(X, Y), null, null)));
+ }
+
+ @Test
+ void distinctWithEmptyTupleAcceptsExactlyOneOccurrence() {
+ Group distinct = Group.create(List.of());
+
+ assertTrue(distinct.isDistinct(mapping(List.of(X), iri("a"))));
+ assertFalse(distinct.isDistinct(mapping(List.of(X), iri("b"))));
+ }
+
+ @Test
+ void distinctAddUsesBindingsInsteadOfComputedGroupByValues() {
+ Group group = group(Mode.DISTINCT, List.of(X));
+
+ Mapping first = mapping(List.of(X), integer("1"));
+ first.setGroupBy(new Node[]{iri("computed-a")});
+
+ Mapping equivalent = mapping(List.of(X), integer("1"));
+ equivalent.setGroupBy(new Node[]{iri("computed-b")});
+
+ Mapping other = mapping(List.of(X), integer("2"));
+ other.setGroupBy(new Node[]{iri("computed-a")});
+
+ group.add(first);
+ group.add(equivalent);
+ group.add(other);
+
+ List result = groups(group);
+ assertEquals(2, result.size());
+
+ // add() stocke les mappings ; isDistinct() filtre les occurrences.
+ assertMembers(result.get(0), first, equivalent);
+ assertMembers(result.get(1), other);
+ }
+
+ @ParameterizedTest
+ @ValueSource(booleans = {false, true})
+ void extendGroupsByRequestedBindingsAndIgnoresAggregateValue(
+ boolean alsoDistinct) {
+ Group group = group(Mode.EXTEND, List.of(X, Y));
+ group.setDistinct(alsoDistinct);
+
+ Mapping first = mapping(
+ List.of(X, Y, L), iri("a"), iri("b"), integer("10"));
+ Mapping sameGroup = mapping(
+ List.of(X, Y, L), iri("a"), iri("b"), integer("3"));
+ Mapping otherGroup = mapping(
+ List.of(X, Y, L), iri("a"), iri("c"), integer("1"));
+
+ // Ces valeurs ne doivent pas servir à min(?l, groupBy(?x, ?y)).
+ first.setGroupBy(new Node[]{iri("ignored-1"), iri("ignored-2")});
+ sameGroup.setGroupBy(new Node[]{iri("ignored-3"), iri("ignored-4")});
+ otherGroup.setGroupBy(new Node[]{iri("ignored-1"), iri("ignored-2")});
+
+ group.add(first);
+ group.add(sameGroup);
+ group.add(otherGroup);
+
+ List result = groups(group);
+ assertEquals(2, result.size());
+ assertMembers(result.get(0), first, sameGroup);
+ assertMembers(result.get(1), otherGroup);
+
+ // Vérifie également le chemin setGroup(), prioritaire sur DISTINCT.
+ assertArrayEquals(
+ new Node[]{iri("a"), iri("b")}, first.getGroupNodes());
+ assertArrayEquals(
+ new Node[]{iri("a"), iri("b")}, sameGroup.getGroupNodes());
+ }
+
+ @ParameterizedTest
+ @EnumSource(Mode.class)
+ void modifyingMappingKeyArrayDoesNotCorruptStoredGroup(Mode mode) {
+ List variables = List.of(X, Y);
+ Group group = group(mode, variables);
+ Mapping original = mapping(variables, iri("a"), iri("b"));
+
+ if (mode == Mode.NORMAL) {
+ original.setGroupBy(new Node[]{iri("a"), iri("b")});
+ }
+ group.add(original);
+
+ Node[] exposedArray = switch (mode) {
+ case NORMAL -> original.getGroupByNodes();
+ case DISTINCT -> original.getDistinctNodes();
+ case EXTEND -> original.getGroupNodes();
+ };
+
+ exposedArray[0] = iri("changed");
+ exposedArray[1] = null;
+
+ Mapping originalTuple = mapping(variables, iri("a"), iri("b"));
+ Mapping changedTuple = mapping(variables, iri("changed"), null);
+
+ group.add(originalTuple);
+ group.add(changedTuple);
+
+ List result = groups(group);
+ assertEquals(2, result.size());
+ assertMembers(result.get(0), original, originalTuple);
+ assertMembers(result.get(1), changedTuple);
+ }
+
+ @Test
+ void modifyingDistinctArrayDoesNotCorruptMembership() {
+ Group distinct = Group.create(List.of(X, Y));
+ Mapping first = mapping(List.of(X, Y), iri("a"), iri("b"));
+
+ assertTrue(distinct.isDistinct(first));
+ first.getDistinctNodes()[0] = iri("changed");
+ first.getDistinctNodes()[1] = null;
+
+ assertFalse(distinct.isDistinct(
+ mapping(List.of(X, Y), iri("a"), iri("b"))));
+ assertTrue(distinct.isDistinct(
+ mapping(List.of(X, Y), iri("changed"), null)));
+ assertFalse(distinct.isDistinct(
+ mapping(List.of(X, Y), iri("changed"), null)));
+ }
+
+ @Test
+ void groupKeyDefensivelyCopiesInputAndKeepsStableHash() {
+ Node[] source = {iri("a"), null, literal("bonjour", "fr")};
+ Object key = key(source);
+ Object expected = key(iri("a"), null, literal("bonjour", "fr"));
+ int initialHash = key.hashCode();
+
+ Map