From 3a0a564f70667811baa25fd3c80f75a6fd1764be Mon Sep 17 00:00:00 2001 From: Martin Ledvinka Date: Thu, 2 Jul 2026 17:39:28 +0200 Subject: [PATCH 1/4] [Enhancement #448] Minor refactor of SOQL parsing to clarify count and distinct interaction. --- .../jopa/query/soql/AggregateFunction.java | 42 +++++++++++++++++++ .../jopa/query/soql/SelectProjection.java | 24 +++++++++++ .../jopa/query/soql/SoqlQueryListener.java | 38 ++++++++--------- .../jopa/query/soql/SoqlQueryParserTest.java | 14 ++++++- 4 files changed, 96 insertions(+), 22 deletions(-) create mode 100644 jopa-impl/src/main/java/cz/cvut/kbss/jopa/query/soql/AggregateFunction.java create mode 100644 jopa-impl/src/main/java/cz/cvut/kbss/jopa/query/soql/SelectProjection.java diff --git a/jopa-impl/src/main/java/cz/cvut/kbss/jopa/query/soql/AggregateFunction.java b/jopa-impl/src/main/java/cz/cvut/kbss/jopa/query/soql/AggregateFunction.java new file mode 100644 index 000000000..6a3d349f4 --- /dev/null +++ b/jopa-impl/src/main/java/cz/cvut/kbss/jopa/query/soql/AggregateFunction.java @@ -0,0 +1,42 @@ +/* + * JOPA + * Copyright (C) 2026 Czech Technical University in Prague + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3.0 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library. + */ +package cz.cvut.kbss.jopa.query.soql; + +/** + * Aggregate functions supported in SOQL select expressions. + */ +enum AggregateFunction { + + COUNT(SoqlConstants.Functions.COUNT, "?count"); + + private final String soqlName; + private final String resultVariable; + + AggregateFunction(String soqlName, String resultVariable) { + this.soqlName = soqlName; + this.resultVariable = resultVariable; + } + + String soqlName() { + return soqlName; + } + + String resultVariable() { + return resultVariable; + } +} diff --git a/jopa-impl/src/main/java/cz/cvut/kbss/jopa/query/soql/SelectProjection.java b/jopa-impl/src/main/java/cz/cvut/kbss/jopa/query/soql/SelectProjection.java new file mode 100644 index 000000000..4a041be28 --- /dev/null +++ b/jopa-impl/src/main/java/cz/cvut/kbss/jopa/query/soql/SelectProjection.java @@ -0,0 +1,24 @@ +package cz.cvut.kbss.jopa.query.soql; + +/** + * Captures the kind of projection in the SOQL SELECT clause. + * + * @param aggregateFunction the aggregate function wrapping the projected variable, or {@code null} for a plain + * projection + * @param distinct whether the outer {@code SELECT DISTINCT} modifier is present + * @param aggregateDistinct whether {@code DISTINCT} is present inside the aggregate argument + */ +record SelectProjection(AggregateFunction aggregateFunction, boolean distinct, boolean aggregateDistinct) { + + SelectProjection withAggregateFunction(AggregateFunction fn) { + return new SelectProjection(fn, distinct, aggregateDistinct); + } + + SelectProjection withDistinct(boolean value) { + return new SelectProjection(aggregateFunction, value, aggregateDistinct); + } + + SelectProjection withAggregateDistinct(boolean value) { + return new SelectProjection(aggregateFunction, distinct, value); + } +} diff --git a/jopa-impl/src/main/java/cz/cvut/kbss/jopa/query/soql/SoqlQueryListener.java b/jopa-impl/src/main/java/cz/cvut/kbss/jopa/query/soql/SoqlQueryListener.java index f7a5dd759..4c47c5bc3 100644 --- a/jopa-impl/src/main/java/cz/cvut/kbss/jopa/query/soql/SoqlQueryListener.java +++ b/jopa-impl/src/main/java/cz/cvut/kbss/jopa/query/soql/SoqlQueryListener.java @@ -78,9 +78,7 @@ public class SoqlQueryListener extends SoqlBaseListener { private final Map, TriplePatternEnhancer> tpEnhancers = new HashMap<>(); - private boolean isSelectedParamDistinct = false; - - private boolean isSelectedParamCount = false; + private SelectProjection selectProjection = new SelectProjection(null, false, false); private boolean isInObjectIdentifierExpression = false; @@ -155,9 +153,7 @@ private SoqlNode linkSimpleSubpath(ParserRuleContext ctx) { public void enterSelectClause(SoqlParser.SelectClauseContext ctx) { this.typeDef = QueryType.SELECT.getKeyword(); - if (ctx.DISTINCT() != null) { - this.isSelectedParamDistinct = true; - } + this.selectProjection = selectProjection.withDistinct(ctx.DISTINCT() != null); } private void pushNewAttribute(SoqlAttribute myAttr) { @@ -175,7 +171,7 @@ private void popAttribute() { @Override public void exitSelectExpression(SoqlParser.SelectExpressionContext ctx) { - if (!isSelectedParamCount) { + if (selectProjection.aggregateFunction() == null) { this.projectedVariable = ctx.getText(); } } @@ -183,10 +179,8 @@ public void exitSelectExpression(SoqlParser.SelectExpressionContext ctx) { @Override public void enterAggregateExpression(SoqlParser.AggregateExpressionContext ctx) { if (ctx.COUNT() != null) { - isSelectedParamCount = true; - if (ctx.DISTINCT() != null) { - isSelectedParamDistinct = true; - } + selectProjection = selectProjection.withAggregateFunction(AggregateFunction.COUNT) + .withAggregateDistinct(ctx.DISTINCT() != null); if (ctx.simpleSubpath() != null) { this.projectedVariable = ctx.simpleSubpath().getText(); @@ -550,10 +544,13 @@ private void buildSparqlQueryString() { String selectParameter = getSelectParameter(attributes.get(0)); StringBuilder newQueryBuilder = new StringBuilder(typeDef); - if (isSelectedParamCount) { - newQueryBuilder.append(getCountPart(selectParameter)); + if (selectProjection.aggregateFunction() != null) { + if (selectProjection.distinct()) { + newQueryBuilder.append(' ').append(SoqlConstants.DISTINCT); + } + newQueryBuilder.append(getAggregatePart(selectParameter)); } else { - if (isSelectedParamDistinct) { + if (selectProjection.distinct()) { newQueryBuilder.append(' ').append(SoqlConstants.DISTINCT); } newQueryBuilder.append(' ').append(selectParameter).append(' '); @@ -578,13 +575,14 @@ private void buildSparqlQueryString() { LOG.trace("Translated SOQL query '{}' to SPARQL '{}'.", soql, sparql); } - private StringBuilder getCountPart(String selectParameter) { - StringBuilder countPart = new StringBuilder(" (COUNT("); - if (isSelectedParamDistinct) { - countPart.append(SoqlConstants.DISTINCT).append(' '); + private StringBuilder getAggregatePart(String selectParameter) { + final AggregateFunction fn = selectProjection.aggregateFunction(); + final StringBuilder aggregatePart = new StringBuilder(" (").append(fn.soqlName()).append('('); + if (selectProjection.aggregateDistinct()) { + aggregatePart.append(SoqlConstants.DISTINCT).append(' '); } - countPart.append(selectParameter).append(") AS ?count) "); - return countPart; + aggregatePart.append(selectParameter).append(") AS ").append(fn.resultVariable()).append(") "); + return aggregatePart; } private StringBuilder processSupremeAttributes() { diff --git a/jopa-impl/src/test/java/cz/cvut/kbss/jopa/query/soql/SoqlQueryParserTest.java b/jopa-impl/src/test/java/cz/cvut/kbss/jopa/query/soql/SoqlQueryParserTest.java index 1cddf1a33..7cd382152 100644 --- a/jopa-impl/src/test/java/cz/cvut/kbss/jopa/query/soql/SoqlQueryParserTest.java +++ b/jopa-impl/src/test/java/cz/cvut/kbss/jopa/query/soql/SoqlQueryParserTest.java @@ -111,6 +111,16 @@ public void testParseCountQuery() { @Test public void testParseDistinctCountQuery() { final String soqlQuery = "SELECT DISTINCT COUNT(p) FROM Person p"; + final String expectedSparqlQuery = + "SELECT DISTINCT (COUNT(?x) AS ?count) WHERE { ?x a " + strUri(Vocabulary.c_Person) + " . }"; + final QueryHolder holder = sut.parseQuery(soqlQuery); + assertEquals(expectedSparqlQuery, holder.getQuery()); + assertEquals(2, holder.getParameters().size()); + } + + @Test + void testParseCountDistinctQuery() { + final String soqlQuery = "SELECT COUNT(DISTINCT p) FROM Person p"; final String expectedSparqlQuery = "SELECT (COUNT(DISTINCT ?x) AS ?count) WHERE { ?x a " + strUri(Vocabulary.c_Person) + " . }"; final QueryHolder holder = sut.parseQuery(soqlQuery); @@ -769,8 +779,8 @@ void parseQuerySupportsCountWithProjectedAttribute() { } @Test - void parseQuerySupportsDistinctCountWithProjectedAttribute() { - final String soqlIdFirst = "SELECT DISTINCT COUNT(d.owlClassA) FROM OWLClassD d WHERE d.uri = :uri"; + void parseQuerySupportsCountDistinctWithProjectedAttribute() { + final String soqlIdFirst = "SELECT COUNT(DISTINCT d.owlClassA) FROM OWLClassD d WHERE d.uri = :uri"; final String expectedSparql = "SELECT (COUNT(DISTINCT ?owlClassA) AS ?count) WHERE { ?uri a " + strUri(Vocabulary.c_OwlClassD) + " . " + "?uri " + strUri(Vocabulary.p_h_hasA) + " ?owlClassA . }"; parseAndAssertEquality(expectedSparql, soqlIdFirst); From 65a600ef478dd318b12a2dccdb0924e43541cc13 Mon Sep 17 00:00:00 2001 From: Martin Ledvinka Date: Fri, 3 Jul 2026 09:35:53 +0200 Subject: [PATCH 2/4] [Enhancement #448] Implement support for countDistinct in Criteria API. --- .../model/query/criteria/CriteriaBuilder.java | 13 +++++++++++-- .../query/criteria/CriteriaBuilderImpl.java | 7 +++++++ .../expressions/AbstractFunctionExpression.java | 6 +++++- .../expressions/CountDistinctFunction.java | 17 +++++++++++++++++ .../CriteriaQueryTranslateQueryTest.java | 11 +++++++++++ 5 files changed, 51 insertions(+), 3 deletions(-) create mode 100644 jopa-impl/src/main/java/cz/cvut/kbss/jopa/query/criteria/expressions/CountDistinctFunction.java diff --git a/jopa-api/src/main/java/cz/cvut/kbss/jopa/model/query/criteria/CriteriaBuilder.java b/jopa-api/src/main/java/cz/cvut/kbss/jopa/model/query/criteria/CriteriaBuilder.java index 55e6cf8ef..10e832b84 100644 --- a/jopa-api/src/main/java/cz/cvut/kbss/jopa/model/query/criteria/CriteriaBuilder.java +++ b/jopa-api/src/main/java/cz/cvut/kbss/jopa/model/query/criteria/CriteriaBuilder.java @@ -57,14 +57,23 @@ public interface CriteriaBuilder extends PredicateFactory { Expression floor(Expression x); /** - * Create an aggregate expression applying the count operation. Return type of count function in SPARQL is - * xsd:integer which JOPA internally represents as Integer. + * Create an aggregate expression applying the count operation. * * @param x expression representing input value to count operation * @return count expression + * @implNote Return type of count function in SPARQL is xsd:integer which JOPA internally represents as Integer. */ Expression count(Expression x); + /** + * Create an aggregate expression applying the count distinct operation. + * + * @param x expression representing input value to count operation + * @return count distinct expression + * @implNote Return type of count function in SPARQL is xsd:integer which JOPA internally represents as Integer. + */ + Expression countDistinct(Expression x); + /** * Create expression to return length of a string. * diff --git a/jopa-impl/src/main/java/cz/cvut/kbss/jopa/query/criteria/CriteriaBuilderImpl.java b/jopa-impl/src/main/java/cz/cvut/kbss/jopa/query/criteria/CriteriaBuilderImpl.java index a6139c56f..c2ac1b38c 100644 --- a/jopa-impl/src/main/java/cz/cvut/kbss/jopa/query/criteria/CriteriaBuilderImpl.java +++ b/jopa-impl/src/main/java/cz/cvut/kbss/jopa/query/criteria/CriteriaBuilderImpl.java @@ -29,6 +29,7 @@ import cz.cvut.kbss.jopa.query.criteria.expressions.AbstractExpression; import cz.cvut.kbss.jopa.query.criteria.expressions.AbstractPathExpression; import cz.cvut.kbss.jopa.query.criteria.expressions.CeilFunction; +import cz.cvut.kbss.jopa.query.criteria.expressions.CountDistinctFunction; import cz.cvut.kbss.jopa.query.criteria.expressions.CountFunction; import cz.cvut.kbss.jopa.query.criteria.expressions.ExpressionEqualImpl; import cz.cvut.kbss.jopa.query.criteria.expressions.ExpressionGreaterThanImpl; @@ -84,6 +85,12 @@ public Expression count(Expression x) { return new CountFunction((AbstractPathExpression) x, this); } + @Override + public Expression countDistinct(Expression x) { + validateFunctionArgument(x); + return new CountDistinctFunction((AbstractPathExpression) x, this); + } + @Override public Expression ceil(Expression x) { validateFunctionArgument(x); diff --git a/jopa-impl/src/main/java/cz/cvut/kbss/jopa/query/criteria/expressions/AbstractFunctionExpression.java b/jopa-impl/src/main/java/cz/cvut/kbss/jopa/query/criteria/expressions/AbstractFunctionExpression.java index 46d49fcf7..03f9b279f 100644 --- a/jopa-impl/src/main/java/cz/cvut/kbss/jopa/query/criteria/expressions/AbstractFunctionExpression.java +++ b/jopa-impl/src/main/java/cz/cvut/kbss/jopa/query/criteria/expressions/AbstractFunctionExpression.java @@ -34,13 +34,17 @@ public AbstractFunctionExpression(Class type, CriteriaBuilder cb, AbstractPat @Override public void setExpressionToQuery(StringBuilder query, CriteriaParameterFiller parameterFiller) { query.append(getFunctionName()).append("("); + constructFunctionArguments(query, parameterFiller); + query.append(")"); + } + + protected void constructFunctionArguments(StringBuilder query, CriteriaParameterFiller parameterFiller) { for (int i = 0; i < argumentExpression.size(); i++) { argumentExpression.get(i).setExpressionToQuery(query, parameterFiller); if (i < argumentExpression.size() - 1) { query.append(','); } } - query.append(")"); } public abstract String getFunctionName(); diff --git a/jopa-impl/src/main/java/cz/cvut/kbss/jopa/query/criteria/expressions/CountDistinctFunction.java b/jopa-impl/src/main/java/cz/cvut/kbss/jopa/query/criteria/expressions/CountDistinctFunction.java new file mode 100644 index 000000000..559f02a69 --- /dev/null +++ b/jopa-impl/src/main/java/cz/cvut/kbss/jopa/query/criteria/expressions/CountDistinctFunction.java @@ -0,0 +1,17 @@ +package cz.cvut.kbss.jopa.query.criteria.expressions; + +import cz.cvut.kbss.jopa.model.query.criteria.CriteriaBuilder; +import cz.cvut.kbss.jopa.query.criteria.CriteriaParameterFiller; + +public class CountDistinctFunction extends CountFunction { + + public CountDistinctFunction(AbstractPathExpression expression, CriteriaBuilder cb) { + super(expression, cb); + } + + @Override + protected void constructFunctionArguments(StringBuilder query, CriteriaParameterFiller parameterFiller) { + query.append("DISTINCT "); + super.constructFunctionArguments(query, parameterFiller); + } +} diff --git a/jopa-impl/src/test/java/cz/cvut/kbss/jopa/query/criteria/CriteriaQueryTranslateQueryTest.java b/jopa-impl/src/test/java/cz/cvut/kbss/jopa/query/criteria/CriteriaQueryTranslateQueryTest.java index 30c5c2be6..fcf8030e5 100644 --- a/jopa-impl/src/test/java/cz/cvut/kbss/jopa/query/criteria/CriteriaQueryTranslateQueryTest.java +++ b/jopa-impl/src/test/java/cz/cvut/kbss/jopa/query/criteria/CriteriaQueryTranslateQueryTest.java @@ -125,6 +125,17 @@ public void testTranslateQueryDistinctCount() { assertEquals(expectedSoqlQuery, generatedSoqlQuery); } + @Test + void testTranslationQueryCountDistinct() { + CriteriaQueryImpl query = cb.createQuery(Integer.class); + Root root = query.from(OWLClassA.class); + query.select(cb.countDistinct(root)); + + final String generatedSoqlQuery = query.translateQuery(criteriaParameterFiller); + final String expectedSoqlQuery = "SELECT COUNT(DISTINCT owlclassa) FROM OWLClassA owlclassa"; + assertEquals(expectedSoqlQuery, generatedSoqlQuery); + } + @Test public void testTranslateQuerySelectAllOrderByEntityDesc() { CriteriaQueryImpl query = cb.createQuery(OWLClassA.class); From da5259c4b4e8cdd1f7b96910d342f6e755a85dbf Mon Sep 17 00:00:00 2001 From: Martin Ledvinka Date: Fri, 3 Jul 2026 10:29:12 +0200 Subject: [PATCH 3/4] [Fix] Disable flaky test on OWLAPI driver. Suspected issue in OWL2Query. --- .../cvut/kbss/jopa/test/query/owlapi/TypedQueryTest.java | 7 +++++++ .../cvut/kbss/jopa/test/query/runner/TypedQueryRunner.java | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/jopa-integration-tests-owlapi/src/test/java/cz/cvut/kbss/jopa/test/query/owlapi/TypedQueryTest.java b/jopa-integration-tests-owlapi/src/test/java/cz/cvut/kbss/jopa/test/query/owlapi/TypedQueryTest.java index 5141384e8..0b0594351 100644 --- a/jopa-integration-tests-owlapi/src/test/java/cz/cvut/kbss/jopa/test/query/owlapi/TypedQueryTest.java +++ b/jopa-integration-tests-owlapi/src/test/java/cz/cvut/kbss/jopa/test/query/owlapi/TypedQueryTest.java @@ -148,4 +148,11 @@ public void querySupportsNamedFetchGraphs() { public void querySupportsNestedNamedFetchGraphs() { // OWL2Query does not support OPTIONAL } + + @Disabled + @Override + public void querySupportsMappingResultsDirectlyToEntity() { + // OWL2Query does not seem to correctly implement ORDER BY, causing result rows not always to be consecutive by subject identifier + // And thus the query sometimes fails + } } diff --git a/jopa-integration-tests/src/main/java/cz/cvut/kbss/jopa/test/query/runner/TypedQueryRunner.java b/jopa-integration-tests/src/main/java/cz/cvut/kbss/jopa/test/query/runner/TypedQueryRunner.java index 7f18dd2f9..ffa74a6f9 100644 --- a/jopa-integration-tests/src/main/java/cz/cvut/kbss/jopa/test/query/runner/TypedQueryRunner.java +++ b/jopa-integration-tests/src/main/java/cz/cvut/kbss/jopa/test/query/runner/TypedQueryRunner.java @@ -449,7 +449,7 @@ public void querySupportsMappingResultsDirectlyToEntity() { ?x a ?type ; ?hasString ?stringAttribute; a ?types . - }""", OWLClassA.class) + } ORDER BY ?x""", OWLClassA.class) .setParameter("type", URI.create(Vocabulary.C_OWL_CLASS_A)) .setParameter("hasString", URI.create(Vocabulary.P_A_STRING_ATTRIBUTE)) .getResultList(); From 6189f8f8af60ef95391a060e62e437c73a92b5c3 Mon Sep 17 00:00:00 2001 From: Martin Ledvinka Date: Fri, 3 Jul 2026 10:44:22 +0200 Subject: [PATCH 4/4] [Fix] Fix unknown Javadoc tag error. --- pom.xml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/pom.xml b/pom.xml index e96d2daee..fd1917119 100644 --- a/pom.xml +++ b/pom.xml @@ -128,6 +128,15 @@ maven-javadoc-plugin ${maven.javadoc.plugin.version} + + + + implNote + a + Implementation Note: + + + attach-javadocs @@ -175,6 +184,15 @@ maven-javadoc-plugin ${maven.javadoc.plugin.version} + + + + implNote + a + Implementation Note: + + + attach-javadocs