diff --git a/scm-annotations/src/main/java/sonia/scm/store/QueryableType.java b/scm-annotations/src/main/java/sonia/scm/store/QueryableType.java index 10248530b5..baff25e490 100644 --- a/scm-annotations/src/main/java/sonia/scm/store/QueryableType.java +++ b/scm-annotations/src/main/java/sonia/scm/store/QueryableType.java @@ -35,7 +35,7 @@ import java.lang.annotation.Target; * possible to store objects related to repositories; with this annotation it is possible to use other objects as * parents, too, like for instance users). * - * @since 3.7.0 + * @since 3.8.0 */ @Documented @Target(ElementType.TYPE) diff --git a/scm-core-annotation-processor/src/main/java/sonia/scm/annotation/NumberQueryFieldHandler.java b/scm-core-annotation-processor/src/main/java/sonia/scm/annotation/NumberQueryFieldHandler.java index a6d5d11acc..66ab390db6 100644 --- a/scm-core-annotation-processor/src/main/java/sonia/scm/annotation/NumberQueryFieldHandler.java +++ b/scm-core-annotation-processor/src/main/java/sonia/scm/annotation/NumberQueryFieldHandler.java @@ -20,21 +20,17 @@ import com.squareup.javapoet.ClassName; import com.squareup.javapoet.TypeName; class NumberQueryFieldHandler extends QueryFieldHandler { - public NumberQueryFieldHandler(String packageName, String className) { - this(packageName, className, null); - } - - public NumberQueryFieldHandler(String packageName, String className, String suffix) { + NumberQueryFieldHandler(String className) { super( - "NumberQueryField", - new TypeName[]{ClassName.get(packageName, className)}, + className + "QueryField", + new TypeName[]{}, (fieldBuilder, element, fieldClass, fieldName) -> fieldBuilder .initializer( "new $T<>($S)", ClassName.get("sonia.scm.store", "QueryableStore").nestedClass(fieldClass), fieldName ), - suffix + null ); } } diff --git a/scm-core-annotation-processor/src/main/java/sonia/scm/annotation/QueryFieldClassCreator.java b/scm-core-annotation-processor/src/main/java/sonia/scm/annotation/QueryFieldClassCreator.java index 53cdf88caf..d342edba74 100644 --- a/scm-core-annotation-processor/src/main/java/sonia/scm/annotation/QueryFieldClassCreator.java +++ b/scm-core-annotation-processor/src/main/java/sonia/scm/annotation/QueryFieldClassCreator.java @@ -264,19 +264,15 @@ class QueryFieldClassCreator { SIMPLE_INITIALIZER)); case "int", "java.lang.Integer" -> List.of( new NumberQueryFieldHandler( - "java.lang", "Integer")); case "long", "java.lang.Long" -> List.of( new NumberQueryFieldHandler( - "java.lang", "Long")); case "float", "java.lang.Float" -> List.of( new NumberQueryFieldHandler( - "java.lang", "Float")); case "double", "java.lang.Double" -> List.of( new NumberQueryFieldHandler( - "java.lang", "Double")); case "java.util.Date", "java.time.Instant" -> List.of( new QueryFieldHandler( diff --git a/scm-core-annotation-processor/src/test/resources/sonia/scm/testing/DQueryFields.java b/scm-core-annotation-processor/src/test/resources/sonia/scm/testing/DQueryFields.java index 808476c484..9e3c282894 100644 --- a/scm-core-annotation-processor/src/test/resources/sonia/scm/testing/DQueryFields.java +++ b/scm-core-annotation-processor/src/test/resources/sonia/scm/testing/DQueryFields.java @@ -16,35 +16,31 @@ package sonia.scm.testing; -import java.lang.Double; -import java.lang.Float; -import java.lang.Integer; -import java.lang.Long; import sonia.scm.store.QueryableStore; public final class DQueryFields { public static final QueryableStore.IdQueryField INTERNAL_ID = new QueryableStore.IdQueryField<>(); - public static final QueryableStore.NumberQueryField AGE = - new QueryableStore.NumberQueryField<>("age"); - public static final QueryableStore.NumberQueryField WEIGHT = - new QueryableStore.NumberQueryField<>("weight"); + public static final QueryableStore.IntegerQueryField AGE = + new QueryableStore.IntegerQueryField<>("age"); + public static final QueryableStore.IntegerQueryField WEIGHT = + new QueryableStore.IntegerQueryField<>("weight"); - public static final QueryableStore.NumberQueryField CREATIONTIME = - new QueryableStore.NumberQueryField<>("creationTime"); - public static final QueryableStore.NumberQueryField LASTMODIFIED = - new QueryableStore.NumberQueryField<>("lastModified"); + public static final QueryableStore.LongQueryField CREATIONTIME = + new QueryableStore.LongQueryField<>("creationTime"); + public static final QueryableStore.LongQueryField LASTMODIFIED = + new QueryableStore.LongQueryField<>("lastModified"); - public static final QueryableStore.NumberQueryField HEIGHT = - new QueryableStore.NumberQueryField<>("height"); - public static final QueryableStore.NumberQueryField WIDTH = - new QueryableStore.NumberQueryField<>("width"); + public static final QueryableStore.FloatQueryField HEIGHT = + new QueryableStore.FloatQueryField<>("height"); + public static final QueryableStore.FloatQueryField WIDTH = + new QueryableStore.FloatQueryField<>("width"); - public static final QueryableStore.NumberQueryField PRICE = - new QueryableStore.NumberQueryField<>("price"); - public static final QueryableStore.NumberQueryField MARGIN = - new QueryableStore.NumberQueryField<>("margin"); + public static final QueryableStore.DoubleQueryField PRICE = + new QueryableStore.DoubleQueryField<>("price"); + public static final QueryableStore.DoubleQueryField MARGIN = + new QueryableStore.DoubleQueryField<>("margin"); private DQueryFields() { } diff --git a/scm-core/src/main/java/sonia/scm/plugin/ExtensionProcessor.java b/scm-core/src/main/java/sonia/scm/plugin/ExtensionProcessor.java index f94f7f2796..782854b11a 100644 --- a/scm-core/src/main/java/sonia/scm/plugin/ExtensionProcessor.java +++ b/scm-core/src/main/java/sonia/scm/plugin/ExtensionProcessor.java @@ -80,7 +80,7 @@ public interface ExtensionProcessor /** * Returns all queryable types. - * @since 3.7.0 + * @since 3.8.0 */ default Iterable getQueryableTypes() { return emptySet(); diff --git a/scm-core/src/main/java/sonia/scm/plugin/ScmModule.java b/scm-core/src/main/java/sonia/scm/plugin/ScmModule.java index 0109779257..045e23ec4d 100644 --- a/scm-core/src/main/java/sonia/scm/plugin/ScmModule.java +++ b/scm-core/src/main/java/sonia/scm/plugin/ScmModule.java @@ -116,7 +116,7 @@ public class ScmModule { } /** - * @since 3.7.0 + * @since 3.8.0 */ public Iterable getQueryableTypes() { return nonNull(queryableTypes); diff --git a/scm-core/src/main/java/sonia/scm/store/QueryableMutableStore.java b/scm-core/src/main/java/sonia/scm/store/QueryableMutableStore.java index e80aa6ccba..ec3794c86e 100644 --- a/scm-core/src/main/java/sonia/scm/store/QueryableMutableStore.java +++ b/scm-core/src/main/java/sonia/scm/store/QueryableMutableStore.java @@ -29,7 +29,7 @@ import java.util.function.BooleanSupplier; * processor for the annotated type. * * @param The type of the objects to query. - * @since 3.7.0 + * @since 3.8.0 */ public interface QueryableMutableStore extends DataStore, QueryableStore, AutoCloseable { void transactional(BooleanSupplier callback); diff --git a/scm-core/src/main/java/sonia/scm/store/QueryableStore.java b/scm-core/src/main/java/sonia/scm/store/QueryableStore.java index 01799dad26..8e2c9401a1 100644 --- a/scm-core/src/main/java/sonia/scm/store/QueryableStore.java +++ b/scm-core/src/main/java/sonia/scm/store/QueryableStore.java @@ -23,6 +23,7 @@ import java.util.Collection; import java.util.Date; import java.util.List; import java.util.Optional; +import java.util.function.Consumer; /** * This interface is used to query objects annotated with {@link QueryableType}. It will be created by the @@ -32,7 +33,7 @@ import java.util.Optional; * processor for the annotated type. * * @param The type of the objects to query. - * @since 3.7.0 + * @since 3.8.0 */ public interface QueryableStore extends AutoCloseable { @@ -90,7 +91,9 @@ public interface QueryableStore extends AutoCloseable { /** * Returns all objects that match the query. If the query returns no result, an empty list will be returned. */ - List findAll(); + default List findAll() { + return findAll(0, Integer.MAX_VALUE); + } /** * Returns a subset of all objects that match the query. If the query returns no result or the {@code offset} and @@ -101,6 +104,24 @@ public interface QueryableStore extends AutoCloseable { */ List findAll(long offset, long limit); + /** + * Calls the given consumer for all objects that match the query. + * + * @param consumer The consumer that will be called for each single found object. + */ + default void forEach(Consumer consumer) { + forEach(consumer, 0, Integer.MAX_VALUE); + } + + /** + * Calls the given consumer for a subset of all objects that match the query. + * + * @param consumer The consumer that will be called for each single found object. + * @param offset The offset to start feeding results to the consumer. + * @param limit The maximum number of results. + */ + void forEach(Consumer consumer, long offset, long limit); + /** * Returns the found objects in combination with the parent ids they belong to. This is useful if you are using a * queryable store that is not scoped to specific parent objects, and you therefore want to know to which parent @@ -124,6 +145,26 @@ public interface QueryableStore extends AutoCloseable { * Returns the count of all objects that match the query. */ long count(); + + /** + * Returns the minimum value of the given field that match the query. + */ + A min(AggregatableQueryField field); + + /** + * Returns the maximum value of the given field that match the query. + */ + A max(AggregatableQueryField field); + + /** + * Returns the sum of the given field that match the query. + */ + A sum(AggregatableNumberQueryField field); + + /** + * Returns the average value of the given field that match the query. + */ + Double average(AggregatableNumberQueryField field); } /** @@ -160,10 +201,17 @@ public interface QueryableStore extends AutoCloseable { * @param The type of the field. */ @SuppressWarnings("unused") - class QueryField { - final String name; + interface QueryField { + String getName(); - public QueryField(String name) { + boolean isIdField(); + } + + @SuppressWarnings("unused") + abstract class BaseQueryField implements QueryField { + private final String name; + + BaseQueryField(String name) { this.name = name; } @@ -185,6 +233,19 @@ public interface QueryableStore extends AutoCloseable { } } + /** + * Query fields implementing this can compute aggregates like minimum or maximum. + */ + interface AggregatableQueryField extends QueryField { + Class getFieldType(); + } + + /** + * Query fields implementing this can compute aggregates like sum or average. + */ + interface AggregatableNumberQueryField extends AggregatableQueryField { + } + /** * This class is used to create conditions for queries. Instances of this class will be created by the annotation * processor for each {@link String} field of a class annotated with {@link QueryableType}. @@ -193,7 +254,7 @@ public interface QueryableStore extends AutoCloseable { * * @param The type of the objects this condition is used for. */ - class StringQueryField extends QueryField { + class StringQueryField extends BaseQueryField implements AggregatableQueryField { public StringQueryField(String name) { super(name); @@ -239,6 +300,11 @@ public interface QueryableStore extends AutoCloseable { public Condition in(Collection values) { return in(values.toArray(new String[0])); } + + @Override + public Class getFieldType() { + return String.class; + } } /** @@ -274,9 +340,9 @@ public interface QueryableStore extends AutoCloseable { * @param The type of the objects this condition is used for. * @param The type of the number field. */ - class NumberQueryField extends QueryField { + abstract class NumberQueryField extends BaseQueryField { - public NumberQueryField(String name) { + NumberQueryField(String name) { super(name); } @@ -362,6 +428,50 @@ public interface QueryableStore extends AutoCloseable { } } + class IntegerQueryField extends NumberQueryField implements AggregatableNumberQueryField { + public IntegerQueryField(String name) { + super(name); + } + + @Override + public Class getFieldType() { + return Integer.class; + } + } + + class LongQueryField extends NumberQueryField implements AggregatableNumberQueryField { + public LongQueryField(String name) { + super(name); + } + + @Override + public Class getFieldType() { + return Long.class; + } + } + + class FloatQueryField extends NumberQueryField implements AggregatableNumberQueryField { + public FloatQueryField(String name) { + super(name); + } + + @Override + public Class getFieldType() { + return Float.class; + } + } + + class DoubleQueryField extends NumberQueryField implements AggregatableNumberQueryField { + public DoubleQueryField(String name) { + super(name); + } + + @Override + public Class getFieldType() { + return Double.class; + } + } + /** * This class is used to create conditions for queries. Instances of this class will be created by the annotation * processor for each date field of a class annotated with {@link QueryableType}. @@ -370,7 +480,7 @@ public interface QueryableStore extends AutoCloseable { * * @param The type of the objects this condition is used for. */ - class InstantQueryField extends QueryField { + class InstantQueryField extends BaseQueryField { public InstantQueryField(String name) { super(name); } @@ -467,7 +577,7 @@ public interface QueryableStore extends AutoCloseable { * * @param The type of the objects this condition is used for. */ - class BooleanQueryField extends QueryField { + class BooleanQueryField extends BaseQueryField { public BooleanQueryField(String name) { super(name); @@ -511,7 +621,7 @@ public interface QueryableStore extends AutoCloseable { * @param The type of the objects this condition is used for. * @param The type of the enum field. */ - class EnumQueryField> extends QueryField> { + class EnumQueryField> extends BaseQueryField> { public EnumQueryField(String name) { super(name); } @@ -556,7 +666,7 @@ public interface QueryableStore extends AutoCloseable { * * @param The type of the objects this condition is used for. */ - class CollectionQueryField extends QueryField { + class CollectionQueryField extends BaseQueryField { public CollectionQueryField(String name) { super(name); } @@ -582,7 +692,7 @@ public interface QueryableStore extends AutoCloseable { * * @param The type of the objects this condition is used for. */ - class CollectionSizeQueryField extends NumberQueryField { + class CollectionSizeQueryField extends NumberQueryField implements AggregatableNumberQueryField { public CollectionSizeQueryField(String name) { super(name); } @@ -595,6 +705,11 @@ public interface QueryableStore extends AutoCloseable { public Condition isEmpty() { return eq(0L); } + + @Override + public Class getFieldType() { + return Long.class; + } } /** @@ -606,7 +721,7 @@ public interface QueryableStore extends AutoCloseable { * * @param The type of the objects this condition is used for. */ - class MapQueryField extends QueryField { + class MapQueryField extends BaseQueryField { public MapQueryField(String name) { super(name); } @@ -642,7 +757,7 @@ public interface QueryableStore extends AutoCloseable { * * @param The type of the objects this condition is used for. */ - class MapSizeQueryField extends NumberQueryField { + class MapSizeQueryField extends NumberQueryField implements AggregatableNumberQueryField { public MapSizeQueryField(String name) { super(name); } @@ -655,6 +770,11 @@ public interface QueryableStore extends AutoCloseable { public Condition isEmpty() { return eq(0L); } + + @Override + public Class getFieldType() { + return Long.class; + } } /** diff --git a/scm-core/src/main/java/sonia/scm/store/QueryableStoreFactory.java b/scm-core/src/main/java/sonia/scm/store/QueryableStoreFactory.java index 8efe749b9d..6a3de83424 100644 --- a/scm-core/src/main/java/sonia/scm/store/QueryableStoreFactory.java +++ b/scm-core/src/main/java/sonia/scm/store/QueryableStoreFactory.java @@ -29,7 +29,7 @@ package sonia.scm.store; * Implementations probably are backed by a database or a similar storage system instead of the familiar * file based storage using XML. * - * @since 3.7.0 + * @since 3.8.0 */ public interface QueryableStoreFactory { diff --git a/scm-persistence/src/main/java/sonia/scm/store/sqlite/BadStoreNameException.java b/scm-persistence/src/main/java/sonia/scm/store/sqlite/BadStoreNameException.java index cc02ceac5f..9c8bfb58cb 100644 --- a/scm-persistence/src/main/java/sonia/scm/store/sqlite/BadStoreNameException.java +++ b/scm-persistence/src/main/java/sonia/scm/store/sqlite/BadStoreNameException.java @@ -21,7 +21,7 @@ import sonia.scm.store.StoreException; /** * This exception is thrown if a name for a store element doesn't meet the internal verification requirements. * - * @since 3.7.0 + * @since 3.8.0 */ class BadStoreNameException extends StoreException { BadStoreNameException(String badName) { diff --git a/scm-persistence/src/main/java/sonia/scm/store/sqlite/SQLAggregate.java b/scm-persistence/src/main/java/sonia/scm/store/sqlite/SQLAggregate.java new file mode 100644 index 0000000000..cb3b30937f --- /dev/null +++ b/scm-persistence/src/main/java/sonia/scm/store/sqlite/SQLAggregate.java @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2020 - present Cloudogu GmbH + * + * This program is free software: you can redistribute it and/or modify it under + * the terms of the GNU Affero General Public License as published by the Free + * Software Foundation, version 3. + * + * This program 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 Affero General Public License for more + * details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package sonia.scm.store.sqlite; + +import sonia.scm.store.QueryableStore; + +class SQLAggregate extends SQLField { + public SQLAggregate(String operator, QueryableStore.AggregatableQueryField queryField) { + super(operator + "(" + SQLFieldHelper.computeSQLField(queryField) + ") "); + } +} diff --git a/scm-persistence/src/main/java/sonia/scm/store/sqlite/SQLCondition.java b/scm-persistence/src/main/java/sonia/scm/store/sqlite/SQLCondition.java index 37205f28e0..42f2830f98 100644 --- a/scm-persistence/src/main/java/sonia/scm/store/sqlite/SQLCondition.java +++ b/scm-persistence/src/main/java/sonia/scm/store/sqlite/SQLCondition.java @@ -26,12 +26,10 @@ import java.sql.PreparedStatement; import java.sql.SQLException; import java.time.Instant; -import static sonia.scm.store.sqlite.SQLiteIdentifiers.computeColumnIdentifier; - /** * SQLCondition represents a condition given in an agnostic SQL statement. * - * @since 3.7.0 + * @since 3.8.0 */ @Getter @Setter @@ -96,14 +94,8 @@ class SQLCondition implements SQLNodeWithValue { return "select * from json_each(payload, '$." + queryField.getName() + "') where "; } else if (queryField instanceof QueryableStore.InstantQueryField) { return "json_extract(payload, '$." + queryField.getName() + "')"; - } else if (queryField instanceof QueryableStore.CollectionSizeQueryField) { - return "json_array_length(payload, '$." + queryField.getName() + "')"; - } else if (queryField instanceof QueryableStore.MapSizeQueryField) { - return "(select count(*) from json_each(payload, '$." + queryField.getName() + "')) "; - } else if (queryField.isIdField()) { - return computeColumnIdentifier(queryField.getName()); } else { - return "json_extract(payload, '$." + queryField.getName() + "')"; + return SQLFieldHelper.computeSQLField(queryField); } } diff --git a/scm-persistence/src/main/java/sonia/scm/store/sqlite/SQLField.java b/scm-persistence/src/main/java/sonia/scm/store/sqlite/SQLField.java index 965e92ede0..29e05435cf 100644 --- a/scm-persistence/src/main/java/sonia/scm/store/sqlite/SQLField.java +++ b/scm-persistence/src/main/java/sonia/scm/store/sqlite/SQLField.java @@ -21,7 +21,7 @@ import lombok.Getter; /** * Representation of a value of a row within an {@link SQLTable}. * - * @since 3.7.0 + * @since 3.8.0 */ @Getter class SQLField implements SQLNode { diff --git a/scm-persistence/src/main/java/sonia/scm/store/sqlite/SQLFieldHelper.java b/scm-persistence/src/main/java/sonia/scm/store/sqlite/SQLFieldHelper.java new file mode 100644 index 0000000000..46ecd7f1e6 --- /dev/null +++ b/scm-persistence/src/main/java/sonia/scm/store/sqlite/SQLFieldHelper.java @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2020 - present Cloudogu GmbH + * + * This program is free software: you can redistribute it and/or modify it under + * the terms of the GNU Affero General Public License as published by the Free + * Software Foundation, version 3. + * + * This program 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 Affero General Public License for more + * details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package sonia.scm.store.sqlite; + +import sonia.scm.store.QueryableStore; + +import static sonia.scm.store.sqlite.SQLiteIdentifiers.computeColumnIdentifier; + +final class SQLFieldHelper { + + private SQLFieldHelper() { + } + + static String computeSQLField(QueryableStore.QueryField queryField) { + if (queryField instanceof QueryableStore.CollectionSizeQueryField) { + return "json_array_length(payload, '$." + queryField.getName() + "')"; + } else if (queryField instanceof QueryableStore.MapSizeQueryField) { + return "(select count(*) from json_each(payload, '$." + queryField.getName() + "')) "; + } else if (queryField.isIdField()) { + return computeColumnIdentifier(queryField.getName()); + } else { + return "json_extract(payload, '$." + queryField.getName() + "')"; + } + } +} diff --git a/scm-persistence/src/main/java/sonia/scm/store/sqlite/SQLValue.java b/scm-persistence/src/main/java/sonia/scm/store/sqlite/SQLValue.java index 6510378f8d..61022ed816 100644 --- a/scm-persistence/src/main/java/sonia/scm/store/sqlite/SQLValue.java +++ b/scm-persistence/src/main/java/sonia/scm/store/sqlite/SQLValue.java @@ -25,7 +25,7 @@ import java.util.List; /** * Representation of a column or a list of columns within an {@link SQLTable}. * - * @since 3.7.0 + * @since 3.8.0 */ @Slf4j class SQLValue implements SQLNodeWithValue { diff --git a/scm-persistence/src/main/java/sonia/scm/store/sqlite/SQLiteQueryableStore.java b/scm-persistence/src/main/java/sonia/scm/store/sqlite/SQLiteQueryableStore.java index 1da7a2cffe..af152b3c52 100644 --- a/scm-persistence/src/main/java/sonia/scm/store/sqlite/SQLiteQueryableStore.java +++ b/scm-persistence/src/main/java/sonia/scm/store/sqlite/SQLiteQueryableStore.java @@ -48,6 +48,7 @@ import java.util.Optional; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReadWriteLock; import java.util.function.BooleanSupplier; +import java.util.function.Consumer; import java.util.stream.Stream; import static sonia.scm.store.sqlite.SQLiteIdentifiers.computeColumnIdentifier; @@ -305,12 +306,14 @@ class SQLiteQueryableStore implements QueryableStore, QueryableMaintenance } @Override - public List findAll() { - return findAll(0, Integer.MAX_VALUE); + public List findAll(long offset, long limit) { + List result = new ArrayList<>(); + forEach(result::add, offset, limit); + return Collections.unmodifiableList(result); } @Override - public List findAll(long offset, long limit) { + public void forEach(Consumer consumer, long offset, long limit) { StringBuilder orderByBuilder = new StringBuilder(); if (orderBy != null && !orderBy.isEmpty()) { toOrderBySQL(orderByBuilder); @@ -326,15 +329,15 @@ class SQLiteQueryableStore implements QueryableStore, QueryableMaintenance offset ); - return executeRead( + executeRead( sqlSelectQuery, statement -> { - List result = new ArrayList<>(); - ResultSet resultSet = statement.executeQuery(); - while (resultSet.next()) { - result.add(extractResult(resultSet)); + try (ResultSet resultSet = statement.executeQuery()) { + while (resultSet.next()) { + consumer.accept(extractResult(resultSet)); + } } - return Collections.unmodifiableList(result); + return null; } ); } @@ -366,6 +369,49 @@ class SQLiteQueryableStore implements QueryableStore, QueryableMaintenance ); } + @Override + public A min(AggregatableQueryField field) { + return aggregate(field, "MIN", field.getFieldType()); + } + + @Override + public A max(AggregatableQueryField field) { + return aggregate(field, "MAX", field.getFieldType()); + } + + @Override + public A sum(AggregatableNumberQueryField field) { + return aggregate(field, "SUM", field.getFieldType()); + } + + @Override + public Double average(AggregatableNumberQueryField field) { + return aggregate(field, "AVG", Double.class); + } + + private A aggregate(AggregatableQueryField field, String aggregate, Class resultType) { + SQLSelectStatement sqlStatementQuery = + new SQLSelectStatement( + List.of(new SQLAggregate(aggregate, field)), + computeFromTable(), + computeCondition() + ); + + return executeRead( + sqlStatementQuery, + statement -> { + ResultSet resultSet = statement.executeQuery(); + if (resultSet.next()) { + if (resultSet.getObject(1) == null) { + return null; + } + return resultSet.getObject(1, resultType); + } + throw new IllegalStateException("failed to read count for type " + queryableTypeDescriptor); + } + ); + } + @Override public Query orderBy(QueryField field, Order order) { List> extendedOrderBy = new ArrayList<>(this.orderBy); diff --git a/scm-persistence/src/test/java/sonia/scm/store/sqlite/SQLiteQueryableStoreTest.java b/scm-persistence/src/test/java/sonia/scm/store/sqlite/SQLiteQueryableStoreTest.java index 0dbf0fa299..54e306fe3b 100644 --- a/scm-persistence/src/test/java/sonia/scm/store/sqlite/SQLiteQueryableStoreTest.java +++ b/scm-persistence/src/test/java/sonia/scm/store/sqlite/SQLiteQueryableStoreTest.java @@ -64,7 +64,7 @@ class SQLiteQueryableStoreTest { store.put(new Spaceship("Heart Of Gold", Range.INTER_GALACTIC)); List all = store - .query(SPACESHIP_RANGE_ENUM_QUERY_FIELD.eq(Range.SOLAR_SYSTEM)) + .query(SPACESHIP_RANGE.eq(Range.SOLAR_SYSTEM)) .findAll(); assertThat(all).hasSize(1); @@ -82,7 +82,7 @@ class SQLiteQueryableStoreTest { store.put(arthur); List all = store.query( - CREATION_DATE_QUERY_FIELD.lessOrEquals(9999999999L) + CREATION_DATE.lessOrEquals(9999999999L) ) .findAll(); @@ -102,7 +102,7 @@ class SQLiteQueryableStoreTest { store.put(arthur); List all = store.query( - CREATION_DATE_AS_INTEGER_QUERY_FIELD.less(40) + CREATION_DATE_AS_INTEGER.less(40) ) .findAll(); @@ -122,7 +122,7 @@ class SQLiteQueryableStoreTest { store.put(arthur); List all = store.query( - ACTIVE_QUERY_FIELD.isTrue() + ACTIVE.isTrue() ) .findAll(); @@ -142,7 +142,7 @@ class SQLiteQueryableStoreTest { store.put(arthur); long count = store.query( - ACTIVE_QUERY_FIELD.isTrue() + ACTIVE.isTrue() ) .count(); @@ -161,7 +161,7 @@ class SQLiteQueryableStoreTest { store.put(new Spaceship("Heart Of Gold", "Trillian", "Arthur", "Ford", "Zaphod", "Marvin")); List result = store.query( - SPACESHIP_CREW_QUERY_FIELD.contains("Marvin") + SPACESHIP_CREW.contains("Marvin") ).findAll(); assertThat(result).hasSize(1); @@ -174,7 +174,7 @@ class SQLiteQueryableStoreTest { store.put(new Spaceship("Heart Of Gold", "Trillian", "Arthur", "Ford", "Zaphod", "Marvin")); long result = store.query( - SPACESHIP_CREW_QUERY_FIELD.contains("Marvin") + SPACESHIP_CREW.contains("Marvin") ).count(); assertThat(result).isEqualTo(1); @@ -191,6 +191,90 @@ class SQLiteQueryableStoreTest { assertThat(result).isEqualTo(2); } + @Test + void shouldHandleEmptyCollectionWithMaxString() { + SQLiteQueryableMutableStore store = new StoreTestBuilder(connectionString).forClassWithIds(Spaceship.class); + Integer result = store.query().max( + SPACESHIP_FLIGHT_COUNT + ); + + assertThat(result).isNull(); + } + + @Nested + class ForAggregations { + + SQLiteQueryableMutableStore store; + + @BeforeEach + void createData() { + store = new StoreTestBuilder(connectionString).forClassWithIds(Spaceship.class); + Spaceship spaceshuttle = new Spaceship("Spaceshuttle", "Buzz", "Anndre"); + spaceshuttle.setFlightCount(12); + store.put("Spaceshuttle", spaceshuttle); + Spaceship heartOfGold = new Spaceship("Heart Of Gold", "Trillian", "Arthur", "Ford", "Zaphod", "Marvin"); + heartOfGold.setFlightCount(42); + store.put("Heart Of Gold", heartOfGold); + Spaceship vogon = new Spaceship("Vogon", "Prostetnic Vogon Jeltz"); + vogon.setFlightCount(321); + store.put("Vogon", vogon); + } + + @Test + void shouldGetMaxString() { + String result = store.query().max( + SPACESHIP_NAME + ); + + assertThat(result).isEqualTo("Vogon"); + } + + @Test + void shouldGetMaxOfCollectionSize() { + Long result = store.query().max( + SPACESHIP_CREW_SIZE + ); + + assertThat(result).isEqualTo(5); + } + + @Test + void shouldGetMinOfId() { + String result = store.query().min( + SPACESHIP_ID + ); + + assertThat(result).isEqualTo("Heart Of Gold"); + } + + @Test + void shouldGetMinNumber() { + int result = store.query().min( + SPACESHIP_FLIGHT_COUNT + ); + + assertThat(result).isEqualTo(12); + } + + @Test + void shouldGetAverageNumber() { + double result = store.query().average( + SPACESHIP_FLIGHT_COUNT + ); + + assertThat(result).isEqualTo(125); + } + + @Test + void shouldGetSum() { + int result = store.query().sum( + SPACESHIP_FLIGHT_COUNT + ); + + assertThat(result).isEqualTo(375); + } + } + @Test void shouldHandleCollectionSize() { SQLiteQueryableMutableStore store = new StoreTestBuilder(connectionString).forClassWithIds(Spaceship.class); @@ -199,20 +283,20 @@ class SQLiteQueryableStoreTest { store.put(new Spaceship("MillenniumFalcon")); List onlyEmpty = store.query( - SPACESHIP_CREW_SIZE_QUERY_FIELD.isEmpty() + SPACESHIP_CREW_SIZE.isEmpty() ).findAll(); assertThat(onlyEmpty).hasSize(1); assertThat(onlyEmpty.get(0).getName()).isEqualTo("MillenniumFalcon"); List exactlyTwoCrewMates = store.query( - SPACESHIP_CREW_SIZE_QUERY_FIELD.eq(2L) + SPACESHIP_CREW_SIZE.eq(2L) ).findAll(); assertThat(exactlyTwoCrewMates).hasSize(1); assertThat(exactlyTwoCrewMates.get(0).getName()).isEqualTo("Spaceshuttle"); List moreThanTwoCrewMates = store.query( - SPACESHIP_CREW_SIZE_QUERY_FIELD.greater(2L) + SPACESHIP_CREW_SIZE.greater(2L) ).findAll(); assertThat(moreThanTwoCrewMates).hasSize(1); assertThat(moreThanTwoCrewMates.get(0).getName()).isEqualTo("Heart of Gold"); @@ -226,13 +310,13 @@ class SQLiteQueryableStoreTest { store.put(new Spaceship("MillenniumFalcon", Map.of("dagobah", false))); List keyResult = store.query( - SPACESHIP_DESTINATIONS_QUERY_FIELD.containsKey("vogon") + SPACESHIP_DESTINATIONS.containsKey("vogon") ).findAll(); assertThat(keyResult).hasSize(1); assertThat(keyResult.get(0).getName()).isEqualTo("Heart of Gold"); List valueResult = store.query( - SPACESHIP_DESTINATIONS_QUERY_FIELD.containsValue(false) + SPACESHIP_DESTINATIONS.containsValue(false) ).findAll(); assertThat(valueResult).hasSize(1); assertThat(valueResult.get(0).getName()).isEqualTo("MillenniumFalcon"); @@ -246,14 +330,14 @@ class SQLiteQueryableStoreTest { store.put(new Spaceship("MillenniumFalcon", Map.of("dagobah", false))); long keyResult = store.query( - SPACESHIP_DESTINATIONS_QUERY_FIELD.containsKey("vogon") + SPACESHIP_DESTINATIONS.containsKey("vogon") ).count(); assertThat(keyResult).isEqualTo(1); long valueResult = store.query( - SPACESHIP_DESTINATIONS_QUERY_FIELD.containsValue(false) + SPACESHIP_DESTINATIONS.containsValue(false) ).count(); assertThat(valueResult).isEqualTo(1); } @@ -267,20 +351,20 @@ class SQLiteQueryableStoreTest { store.put(new Spaceship("MillenniumFalcon", Map.of())); List onlyEmpty = store.query( - SPACESHIP_DESTINATIONS_SIZE_QUERY_FIELD.isEmpty() + SPACESHIP_DESTINATIONS_SIZE.isEmpty() ).findAll(); assertThat(onlyEmpty).hasSize(1); assertThat(onlyEmpty.get(0).getName()).isEqualTo("MillenniumFalcon"); List exactlyTwoDestinations = store.query( - SPACESHIP_DESTINATIONS_SIZE_QUERY_FIELD.eq(2L) + SPACESHIP_DESTINATIONS_SIZE.eq(2L) ).findAll(); assertThat(exactlyTwoDestinations).hasSize(1); assertThat(exactlyTwoDestinations.get(0).getName()).isEqualTo("Spaceshuttle"); List moreThanTwoDestinations = store.query( - SPACESHIP_DESTINATIONS_SIZE_QUERY_FIELD.greater(2L) + SPACESHIP_DESTINATIONS_SIZE.greater(2L) ).findAll(); assertThat(moreThanTwoDestinations).hasSize(1); assertThat(moreThanTwoDestinations.get(0).getName()).isEqualTo("Heart of Gold"); @@ -298,22 +382,22 @@ class SQLiteQueryableStoreTest { store.put(falcon); List resultEqOperator = store.query( - SPACESHIP_INSERVICE_QUERY_FIELD.eq(Instant.parse("2015-12-21T10:00:00Z"))).findAll(); + SPACESHIP_INSERVICE.eq(Instant.parse("2015-12-21T10:00:00Z"))).findAll(); assertThat(resultEqOperator).hasSize(1); assertThat(resultEqOperator.get(0).getName()).isEqualTo("Falcon9"); List resultBeforeOperator = store.query( - SPACESHIP_INSERVICE_QUERY_FIELD.before(Instant.parse("2000-12-21T10:00:00Z"))).findAll(); + SPACESHIP_INSERVICE.before(Instant.parse("2000-12-21T10:00:00Z"))).findAll(); assertThat(resultBeforeOperator).hasSize(1); assertThat(resultBeforeOperator.get(0).getName()).isEqualTo("Spaceshuttle"); List resultAfterOperator = store.query( - SPACESHIP_INSERVICE_QUERY_FIELD.after(Instant.parse("2000-01-01T00:00:00Z"))).findAll(); + SPACESHIP_INSERVICE.after(Instant.parse("2000-01-01T00:00:00Z"))).findAll(); assertThat(resultAfterOperator).hasSize(1); assertThat(resultAfterOperator.get(0).getName()).isEqualTo("Falcon9"); List resultBetweenOperator = store.query( - SPACESHIP_INSERVICE_QUERY_FIELD.between(Instant.parse("1980-04-12T10:00:00Z"), Instant.parse("2016-12-21T10:00:00Z"))).findAll(); + SPACESHIP_INSERVICE.between(Instant.parse("1980-04-12T10:00:00Z"), Instant.parse("2016-12-21T10:00:00Z"))).findAll(); assertThat(resultBetweenOperator).hasSize(2); } @@ -344,8 +428,8 @@ class SQLiteQueryableStoreTest { store.put(new User("marvin", "Marvin", "marvin@hog.org")); List all = store.query() - .orderBy(USER_NAME_QUERY_FIELD, QueryableStore.Order.ASC) - .orderBy(DISPLAY_NAME_QUERY_FIELD, QueryableStore.Order.DESC) + .orderBy(USER_NAME, QueryableStore.Order.ASC) + .orderBy(DISPLAY_NAME, QueryableStore.Order.DESC) .findAll(); assertThat(all) @@ -364,7 +448,7 @@ class SQLiteQueryableStoreTest { store.put("3", new User("arthur", "Arthur Dent", "arthur@hog.org")); List all = store.query( - ID_QUERY_FIELD.eq("1") + ID.eq("1") ) .findAll(); @@ -385,7 +469,7 @@ class SQLiteQueryableStoreTest { SQLiteQueryableStore store = new StoreTestBuilder(connectionString, Group.class.getName()).withIds(); List all = store.query( - GROUP_QUERY_FIELD.eq("42") + GROUP.eq("42") ) .findAll(); @@ -402,7 +486,7 @@ class SQLiteQueryableStoreTest { store.put("dent", new User("arthur", "Arthur Dent", "arthur@hog.org")); List all = store.query( - USER_NAME_QUERY_FIELD.contains("ri") + USER_NAME.contains("ri") ) .findAll(); @@ -416,7 +500,7 @@ class SQLiteQueryableStoreTest { store.put("dent", new User("arthur", "Arthur Dent", "arthur@hog.org")); List all = store.query( - DISPLAY_NAME_QUERY_FIELD.isNull() + DISPLAY_NAME.isNull() ) .findAll(); @@ -432,7 +516,7 @@ class SQLiteQueryableStoreTest { store.put("dent", new User("arthur", "Arthur Dent", "arthur@hog.org")); List all = store.query( - Conditions.not(DISPLAY_NAME_QUERY_FIELD.isNull()) + Conditions.not(DISPLAY_NAME.isNull()) ) .findAll(); @@ -450,8 +534,8 @@ class SQLiteQueryableStoreTest { List all = store.query( Conditions.or( - DISPLAY_NAME_QUERY_FIELD.eq("Tricia"), - DISPLAY_NAME_QUERY_FIELD.eq("Trillian McMillan") + DISPLAY_NAME.eq("Tricia"), + DISPLAY_NAME.eq("Trillian McMillan") ) ) .findAll(); @@ -510,7 +594,7 @@ class SQLiteQueryableStoreTest { .withIds("1337") .put("tricia", new User("trillian", "Trillian McMillan", "tricia@hog.org")); - List all = store.query(USER_NAME_QUERY_FIELD.eq("trillian")).findAll(); + List all = store.query(USER_NAME.eq("trillian")).findAll(); assertThat(all) .extracting("displayName") @@ -525,7 +609,7 @@ class SQLiteQueryableStoreTest { store.put(new User("zaphod", "Beeblebrox", "zaphod@hog.org")); List all = store.query( - USER_NAME_QUERY_FIELD.in("trillian", "arthur") + USER_NAME.in("trillian", "arthur") ) .findAll(); @@ -551,7 +635,7 @@ class SQLiteQueryableStoreTest { store.put("tricia", new User("trillian")); store.put("dent", new User("arthur")); - List all = store.query(USER_NAME_QUERY_FIELD.eq("trillian")).findAll(); + List all = store.query(USER_NAME.eq("trillian")).findAll(); assertThat(all).hasSize(1); } @@ -582,8 +666,8 @@ class SQLiteQueryableStoreTest { store.put("dent", new User("arthur", "Arthur Dent", "arthur@hog.org")); List all = store.query( - USER_NAME_QUERY_FIELD.eq("trillian"), - DISPLAY_NAME_QUERY_FIELD.eq("Tricia") + USER_NAME.eq("trillian"), + DISPLAY_NAME.eq("Tricia") ) .findAll(); @@ -614,7 +698,7 @@ class SQLiteQueryableStoreTest { @Test void shouldReturnEmptyOptionalIfNoResultFound() { SQLiteQueryableMutableStore store = new StoreTestBuilder(connectionString).forClassWithIds(Spaceship.class); - assertThat(store.query(SPACESHIP_NAME_QUERY_FIELD.eq("Heart Of Gold")).findOne()).isEmpty(); + assertThat(store.query(SPACESHIP_NAME.eq("Heart Of Gold")).findOne()).isEmpty(); } @Test @@ -622,7 +706,7 @@ class SQLiteQueryableStoreTest { SQLiteQueryableMutableStore store = new StoreTestBuilder(connectionString).forClassWithIds(Spaceship.class); Spaceship expectedShip = new Spaceship("Heart Of Gold", Range.INNER_GALACTIC); store.put(expectedShip); - Spaceship ship = store.query(SPACESHIP_NAME_QUERY_FIELD.eq("Heart Of Gold")).findOne().get(); + Spaceship ship = store.query(SPACESHIP_NAME.eq("Heart Of Gold")).findOne().get(); assertThat(ship).isEqualTo(expectedShip); } @@ -634,7 +718,7 @@ class SQLiteQueryableStoreTest { Spaceship localShip = new Spaceship("Heart Of Gold", Range.SOLAR_SYSTEM); store.put(expectedShip); store.put(localShip); - assertThatThrownBy(() -> store.query(SPACESHIP_NAME_QUERY_FIELD.eq("Heart Of Gold")).findOne().get()) + assertThatThrownBy(() -> store.query(SPACESHIP_NAME.eq("Heart Of Gold")).findOne().get()) .isInstanceOf(QueryableStore.TooManyResultsException.class); } } @@ -651,7 +735,7 @@ class SQLiteQueryableStoreTest { store.put("3", new User("arthur", "Arthur Dent", "arthur@hog.org")); Optional user = store.query( - USER_NAME_QUERY_FIELD.eq("trillian") + USER_NAME.eq("trillian") ) .findFirst(); @@ -669,8 +753,8 @@ class SQLiteQueryableStoreTest { store.put("4", new User("arthur", "Arthur Dent", "arthur@hog.org")); Optional user = store.query( - USER_NAME_QUERY_FIELD.eq("trillian"), - MAIL_QUERY_FIELD.eq("mcmillan-alternate@gmail.com") + USER_NAME.eq("trillian"), + MAIL.eq("mcmillan-alternate@gmail.com") ) .findFirst(); @@ -692,11 +776,11 @@ class SQLiteQueryableStoreTest { Optional user = store.query( Conditions.and( Conditions.and( - DISPLAY_NAME_QUERY_FIELD.eq("Trillian McMillan"), - MAIL_QUERY_FIELD.eq("mcmillan@gmail.com") + DISPLAY_NAME.eq("Trillian McMillan"), + MAIL.eq("mcmillan@gmail.com") ), Conditions.not( - ID_QUERY_FIELD.eq("1") + ID.eq("1") ) ) ).findFirst(); @@ -708,7 +792,7 @@ class SQLiteQueryableStoreTest { void shouldReturnEmptyOptionalIfNoResultFound() { SQLiteQueryableMutableStore store = new StoreTestBuilder(connectionString).withIds(); Optional user = store.query( - USER_NAME_QUERY_FIELD.eq("dave") + USER_NAME.eq("dave") ) .findFirst(); assertThat(user).isEmpty(); @@ -870,39 +954,43 @@ class SQLiteQueryableStoreTest { } } - private static final QueryableStore.IdQueryField ID_QUERY_FIELD = + private static final QueryableStore.IdQueryField ID = new QueryableStore.IdQueryField<>(); - private static final QueryableStore.IdQueryField GROUP_QUERY_FIELD = + private static final QueryableStore.IdQueryField GROUP = new QueryableStore.IdQueryField<>(Group.class); - private static final QueryableStore.StringQueryField USER_NAME_QUERY_FIELD = + private static final QueryableStore.StringQueryField USER_NAME = new QueryableStore.StringQueryField<>("name"); - private static final QueryableStore.StringQueryField DISPLAY_NAME_QUERY_FIELD = + private static final QueryableStore.StringQueryField DISPLAY_NAME = new QueryableStore.StringQueryField<>("displayName"); - private static final QueryableStore.StringQueryField MAIL_QUERY_FIELD = + private static final QueryableStore.StringQueryField MAIL = new QueryableStore.StringQueryField<>("mail"); - private static final QueryableStore.NumberQueryField CREATION_DATE_QUERY_FIELD = - new QueryableStore.NumberQueryField<>("creationDate"); - private static final QueryableStore.NumberQueryField CREATION_DATE_AS_INTEGER_QUERY_FIELD = - new QueryableStore.NumberQueryField<>("creationDate"); - private static final QueryableStore.BooleanQueryField ACTIVE_QUERY_FIELD = + private static final QueryableStore.LongQueryField CREATION_DATE = + new QueryableStore.LongQueryField<>("creationDate"); + private static final QueryableStore.IntegerQueryField CREATION_DATE_AS_INTEGER = + new QueryableStore.IntegerQueryField<>("creationDate"); + private static final QueryableStore.BooleanQueryField ACTIVE = new QueryableStore.BooleanQueryField<>("active"); enum Range { SOLAR_SYSTEM, INNER_GALACTIC, INTER_GALACTIC } - private static final QueryableStore.StringQueryField SPACESHIP_NAME_QUERY_FIELD = + private static final QueryableStore.StringQueryField SPACESHIP_ID = + new QueryableStore.IdQueryField<>(); + private static final QueryableStore.StringQueryField SPACESHIP_NAME = new QueryableStore.StringQueryField<>("name"); - private static final QueryableStore.EnumQueryField SPACESHIP_RANGE_ENUM_QUERY_FIELD = + private static final QueryableStore.EnumQueryField SPACESHIP_RANGE = new QueryableStore.EnumQueryField<>("range"); - private static final QueryableStore.CollectionQueryField SPACESHIP_CREW_QUERY_FIELD = + private static final QueryableStore.CollectionQueryField SPACESHIP_CREW = new QueryableStore.CollectionQueryField<>("crew"); - private static final QueryableStore.CollectionSizeQueryField SPACESHIP_CREW_SIZE_QUERY_FIELD = + private static final QueryableStore.CollectionSizeQueryField SPACESHIP_CREW_SIZE = new QueryableStore.CollectionSizeQueryField<>("crew"); - private static final QueryableStore.MapQueryField SPACESHIP_DESTINATIONS_QUERY_FIELD = + private static final QueryableStore.MapQueryField SPACESHIP_DESTINATIONS = new QueryableStore.MapQueryField<>("destinations"); - private static final QueryableStore.MapSizeQueryField SPACESHIP_DESTINATIONS_SIZE_QUERY_FIELD = + private static final QueryableStore.MapSizeQueryField SPACESHIP_DESTINATIONS_SIZE = new QueryableStore.MapSizeQueryField<>("destinations"); - private static final QueryableStore.InstantQueryField SPACESHIP_INSERVICE_QUERY_FIELD = + private static final QueryableStore.InstantQueryField SPACESHIP_INSERVICE = new QueryableStore.InstantQueryField<>("inServiceSince"); + private static final QueryableStore.IntegerQueryField SPACESHIP_FLIGHT_COUNT = + new QueryableStore.IntegerQueryField<>("flightCount"); } diff --git a/scm-persistence/src/test/java/sonia/scm/store/sqlite/Spaceship.java b/scm-persistence/src/test/java/sonia/scm/store/sqlite/Spaceship.java index 7241f37b99..8356e24362 100644 --- a/scm-persistence/src/test/java/sonia/scm/store/sqlite/Spaceship.java +++ b/scm-persistence/src/test/java/sonia/scm/store/sqlite/Spaceship.java @@ -37,6 +37,7 @@ class Spaceship { Collection crew; Map destinations; Instant inServiceSince; + int flightCount; public Spaceship() { } @@ -95,4 +96,12 @@ class Spaceship { public void setInServiceSince(Instant inServiceSince) { this.inServiceSince = inServiceSince; } + + public int getFlightCount() { + return flightCount; + } + + public void setFlightCount(int flightCount) { + this.flightCount = flightCount; + } }