diff --git a/src/main/java/fr/inria/corese/core/next/query/impl/engine/pattern/Group.java b/src/main/java/fr/inria/corese/core/next/query/impl/engine/pattern/Group.java index 57b8f7c41..ff2828cce 100644 --- a/src/main/java/fr/inria/corese/core/next/query/impl/engine/pattern/Group.java +++ b/src/main/java/fr/inria/corese/core/next/query/impl/engine/pattern/Group.java @@ -1,66 +1,65 @@ package fr.inria.corese.core.next.query.impl.engine.pattern; import java.util.ArrayList; +import java.util.Arrays; import java.util.Comparator; +import java.util.LinkedHashMap; import java.util.List; -import java.util.TreeMap; +import java.util.Map; import fr.inria.corese.core.next.query.impl.engine.model.Node; import fr.inria.corese.core.next.query.impl.engine.solution.Mapping; import fr.inria.corese.core.next.query.impl.engine.solution.Mappings; /** - * select distinct ?x ?y select (count(distinct *) as ?c) - *

- * group by ?x ?y min(?l, groupBy(?x, ?y)) - *

- * 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 table = new HashMap<>(); + table.put(key, "stored"); + + source[0] = iri("changed"); + source[1] = integer("42"); + source[2] = literal("hello", "en"); + + assertEquals(initialHash, key.hashCode()); + assertEquals(expected, key); + assertEquals(expected.hashCode(), key.hashCode()); + assertEquals("stored", table.get(expected)); + assertNotEquals(key(source), key); + assertNull(table.get(key(source))); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("equalRdfTerms") + void equalRdfTermsProduceEqualKeysAndOneGroup( + String description, Node left, Node right) { + assertNotSame(left, right); + assertEquals(0, left.compare(right)); + + Object firstKey = key(left); + Object secondKey = key(right); + Object thirdKey = key(right); + + assertEquals(firstKey, firstKey); + assertEquals(firstKey, secondKey); + assertEquals(secondKey, firstKey); + assertEquals(secondKey, thirdKey); + assertEquals(firstKey, thirdKey); + assertEquals(firstKey.hashCode(), secondKey.hashCode()); + + Group group = Group.create(List.of(X)); + Mapping first = mapping(List.of(X), left); + Mapping second = mapping(List.of(X), right); + group.add(first); + group.add(second); + + assertEquals(1, groups(group).size()); + assertMembers(groups(group).get(0), first, second); + + Group distinct = Group.create(List.of(X)); + assertTrue(distinct.isDistinct(mapping(List.of(X), left))); + assertFalse(distinct.isDistinct(mapping(List.of(X), right))); + } + + static Stream equalRdfTerms() { + return Stream.of( + Arguments.of("URI identiques", + iri("resource"), iri("resource")), + Arguments.of("Littéraux simples identiques", + literal("hello"), literal("hello")), + Arguments.of("Littéraux typés identiques", + integer("42"), integer("42")), + Arguments.of("Littéraux de même langue", + literal("bonjour", "fr"), literal("bonjour", "fr")), + Arguments.of("Blank nodes de même identifiant", + NodeImpl.forBlank("b1"), NodeImpl.forBlank("b1"))); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("differentRdfTerms") + void differentRdfTermsRemainInSeparateGroups( + String description, Node left, Node right) { + Object firstKey = key(left); + Object secondKey = key(right); + + assertNotEquals(firstKey, secondKey); + assertNotEquals(secondKey, firstKey); + + // Des clés différentes peuvent légalement avoir le même hash. + Group group = Group.create(List.of(X)); + Mapping first = mapping(List.of(X), left); + Mapping second = mapping(List.of(X), right); + group.add(first); + group.add(second); + + List result = groups(group); + assertEquals(2, result.size()); + assertMembers(result.get(0), first); + assertMembers(result.get(1), second); + } + + static Stream differentRdfTerms() { + return Stream.of( + Arguments.of("URI différentes", iri("a"), iri("b")), + Arguments.of("Valeurs littérales différentes", + literal("a"), literal("b")), + Arguments.of("Langues différentes", + literal("chat", "fr"), literal("chat", "en")), + Arguments.of("Avec et sans langue", + literal("chat", "fr"), literal("chat")), + Arguments.of("Types RDF différents avec même label", + iri("same"), literal("urn:test:same")), + Arguments.of("Blank nodes différents", + NodeImpl.forBlank("b1"), NodeImpl.forBlank("b2"))); + } + + @Test + void groupKeyEqualityIncludesArityPositionAndNulls() { + Object tuple = key(iri("a"), null); + + assertEquals(tuple, key(iri("a"), null)); + assertEquals(tuple.hashCode(), key(iri("a"), null).hashCode()); + assertNotEquals(tuple, key(null, iri("a"))); + assertNotEquals(tuple, key(iri("a"))); + assertNotEquals(tuple, key(iri("a"), null, null)); + assertNotEquals(tuple, key()); + assertNotEquals(tuple, null); + assertNotEquals(tuple, "not a GroupKey"); + + assertNotEquals( + key(iri("a"), iri("b")), + key(iri("b"), iri("a"))); + assertEquals(key(), key()); + assertEquals(key().hashCode(), key().hashCode()); + } + + private static Group group(Mode mode, List variables) { + Group group = Group.create(variables); + group.setDistinct(mode == Mode.DISTINCT); + group.setExtend(mode == Mode.EXTEND); + return group; + } + + private static Mapping mapping(List variables, Node... values) { + assertEquals(variables.size(), values.length, "Tuple de test invalide"); + Mapping map = Mapping.create(variables.toArray(Node[]::new), values); + map.setGroupBy(new Node[0]); + return map; + } + + private static List variables(int count) { + List result = new ArrayList<>(); + for (int i = 0; i < count; i++) { + result.add(NodeImpl.forVariable("?v" + i)); + } + return result; + } + + private static List groups(Group group) { + List result = new ArrayList<>(); + group.getValues().forEach(result::add); + return result; + } + + private static void assertMembers( + Mappings actual, Mapping... expected) { + assertEquals(expected.length, actual.size()); + for (int i = 0; i < expected.length; i++) { + assertSame(expected[i], actual.get(i), + "Mapping incorrect à la position " + i); + } + } + + private static Node iri(String localName) { + return NodeImpl.forIRI("urn:test:" + localName); + } + + private static Node literal(String label) { + return NodeImpl.forLiteral(label, null, null); + } + + private static Node literal(String label, String language) { + return NodeImpl.forLiteral(label, null, language); + } + + private static Node integer(String lexicalValue) { + return NodeImpl.forLiteral(lexicalValue, XSD_INTEGER, null); + } + + /** + * Accès limité à la construction de la classe privée, pour vérifier + * directement son contrat equals/hashCode et la copie du tableau. + */ + @SuppressWarnings("java:S3011") // Reflective inspection of package-private GroupKey for equality verification. + private static Object key(Node... values) { + try { + Class keyClass = Class.forName( + Group.class.getName() + "$GroupKey"); + Constructor constructor = + keyClass.getDeclaredConstructor(Node[].class); + constructor.setAccessible(true); + return constructor.newInstance((Object) values); + } catch (ReflectiveOperationException exception) { + throw new AssertionError( + "Impossible de construire Group.GroupKey", exception); + } + } +} diff --git a/src/test/java/fr/inria/corese/core/next/query/impl/sparql/bridge/DirectGroupAggregationTest.java b/src/test/java/fr/inria/corese/core/next/query/impl/sparql/bridge/DirectGroupAggregationTest.java new file mode 100644 index 000000000..bbec4d74c --- /dev/null +++ b/src/test/java/fr/inria/corese/core/next/query/impl/sparql/bridge/DirectGroupAggregationTest.java @@ -0,0 +1,195 @@ +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.literal.XSDDatatype; +import fr.inria.corese.core.next.data.api.term.Literal; +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.model.ExpType.Type; +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 org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.math.BigInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class DirectGroupAggregationTest { + private static final Node GROUP = NodeImpl.forVariable("?g"); + private static final Node VALUE = NodeImpl.forVariable("?v"); + + @Test + void aggregatesAllScalarsOnFirstRepresentative() { + Query query = query(); + Mappings mappings = Mappings.create(query); + Mapping first = row("a", integer(10)); + mappings.add(first); + mappings.add(row("b", integer(7))); + mappings.add(row("a", integer(20))); + mappings.add(row("a", null)); + + assertTrue(DirectGroupAggregation.tryAggregate(query, mappings)); + assertEquals(2, mappings.size()); + assertSame(first, mappings.get(0)); + assertValue(first, "?all", "3"); + assertValue(first, "?count", "2"); + assertValue(first, "?sum", "30"); + assertValue(first, "?avg", "15"); + assertValue(first, "?min", "10"); + assertValue(first, "?max", "20"); + assertEquals(XSDDatatype.DECIMAL, + ((Literal) first.getNode("?avg").getDatatypeValue()).getCoreDatatype()); + } + + @Test + void allUnboundValuesAndUnboundGroupKey() { + Query query = query(); + Mappings mappings = Mappings.create(query); + Mapping first = row(null, null); + mappings.add(first); + mappings.add(row(null, null)); + assertTrue(DirectGroupAggregation.tryAggregate(query, mappings)); + assertEquals(1, mappings.size()); + assertValue(first, "?all", "2"); + assertValue(first, "?count", "0"); + assertValue(first, "?sum", "0"); + assertNull(first.getNode("?avg")); + assertNull(first.getNode("?min")); + assertNull(first.getNode("?max")); + } + + @Test + void numericErrorDoesNotAffectOtherAggregates() { + Query query = query(); + Mappings mappings = Mappings.create(query); + Mapping first = row("a", integer(10)); + mappings.add(first); + mappings.add(row("a", NodeImpl.forLiteral("text", null, null))); + assertTrue(DirectGroupAggregation.tryAggregate(query, mappings)); + assertNull(first.getNode("?sum")); + assertNull(first.getNode("?avg")); + assertValue(first, "?count", "2"); + assertValue(first, "?max", "text"); + } + + @Test + void promotesMixedNumericTypesAndPreservesLargeIntegers() { + Query query = query(); + Mappings mappings = Mappings.create(query); + Mapping first = row("a", integer(1)); + mappings.add(first); + mappings.add(row("a", NodeImpl.forValue(Values.factory().createLiteral(new BigDecimal("1.5"))))); + assertTrue(DirectGroupAggregation.tryAggregate(query, mappings)); + assertValue(first, "?sum", "2.5"); + assertValue(first, "?avg", "1.25"); + + mappings = Mappings.create(query); + BigInteger large = BigInteger.ONE.shiftLeft(80); + first = row("a", NodeImpl.forValue(Values.factory().createLiteral(large))); + mappings.add(first); + mappings.add(row("a", integer(1))); + assertTrue(DirectGroupAggregation.tryAggregate(query, mappings)); + assertValue(first, "?sum", large.add(BigInteger.ONE).toString()); + } + + @Test + void emptyGroupedInputProducesNoRepresentative() { + Query query = query(); + Mappings mappings = Mappings.create(query); + assertTrue(DirectGroupAggregation.tryAggregate(query, mappings)); + assertEquals(0, mappings.size()); + } + + @Test + void usesAllGroupKeysAndPreparedGroupValues() { + Query query = query(); + Node secondKey = NodeImpl.forVariable("?second"); + query.addGroupBy(secondKey); + Mappings mappings = Mappings.create(query); + Mapping first = row("a", integer(1)); + first.setGroupBy(new Node[]{integer(5), integer(1)}); + Mapping second = row("a", integer(2)); + second.setGroupBy(new Node[]{integer(5), integer(2)}); + Mapping third = row("b", integer(3)); + third.setGroupBy(new Node[]{integer(5), integer(1)}); + mappings.add(first); + mappings.add(second); + mappings.add(third); + assertTrue(DirectGroupAggregation.tryAggregate(query, mappings)); + assertEquals(2, mappings.size()); + assertSame(first, mappings.get(0)); + assertValue(first, "?sum", "4"); + assertValue(second, "?sum", "2"); + } + + @Test + void rejectsDistinctWithoutChangingInput() { + Query query = query(); + query.getSelectFun().add(projection("?distinct", AggregateFunction.COUNT, true, new VarAst("?v"))); + Mappings mappings = Mappings.create(query); + Mapping first = row("a", integer(1)); + mappings.add(first); + assertFalse(DirectGroupAggregation.isEligible(query)); + assertFalse(DirectGroupAggregation.tryAggregate(query, mappings)); + assertSame(first, mappings.get(0)); + assertNull(first.getNode("?sum")); + } + + @Test + void rejectsUnsupportedModifiersAndAggregateArguments() { + Query query = query(); + query.addOrderBy(GROUP); + assertFalse(DirectGroupAggregation.isEligible(query)); + query = query(); + query.getGroupBy().clear(); + assertFalse(DirectGroupAggregation.isEligible(query)); + query = query(); + query.getSelectFun().add(projection("?sample", AggregateFunction.SAMPLE, false, new VarAst("?v"))); + assertFalse(DirectGroupAggregation.isEligible(query)); + query = query(); + query.getSelectFun().add(projection("?invalid", AggregateFunction.SUM, false, null)); + assertFalse(DirectGroupAggregation.isEligible(query)); + } + + private static Query query() { + Query query = Query.create(); + query.addGroupBy(GROUP); + query.addSelect(GROUP); + query.getSelectFun().add(projection("?all", AggregateFunction.COUNT, false, null)); + query.getSelectFun().add(projection("?count", AggregateFunction.COUNT, false, new VarAst("?v"))); + query.getSelectFun().add(projection("?sum", AggregateFunction.SUM, false, new VarAst("?v"))); + query.getSelectFun().add(projection("?avg", AggregateFunction.AVG, false, new VarAst("?v"))); + query.getSelectFun().add(projection("?min", AggregateFunction.MIN, false, new VarAst("?v"))); + query.getSelectFun().add(projection("?max", AggregateFunction.MAX, false, new VarAst("?v"))); + return query; + } + + private static Exp projection(String target, AggregateFunction function, boolean distinct, VarAst variable) { + Exp exp = Exp.create(Type.NODE, NodeImpl.forVariable(target)); + exp.setFilter(new AstBackedExpr(new AggregateAst(function, distinct, variable, null)).getFilter()); + return exp; + } + + private static Mapping row(String group, Node value) { + Node groupValue = group == null ? null : NodeImpl.forLiteral(group, null, null); + return Mapping.create(new Node[]{GROUP, VALUE}, new Node[]{groupValue, value}); + } + + private static Node integer(long value) { + return NodeImpl.forValue(Values.factory().createLiteral(BigInteger.valueOf(value))); + } + + private static void assertValue(Mapping mapping, String variable, String expected) { + assertEquals(expected, mapping.getNode(variable).getDatatypeValue().stringValue()); + } +}