From 69055152da2f0fcd73b60b1fe20ba72c94a59e86 Mon Sep 17 00:00:00 2001 From: Riccardo Vagelli Date: Fri, 5 Dec 2025 20:05:53 +0100 Subject: [PATCH 01/11] Add projection/select support for JPA, MongoDB, Elasticsearch - Add Select interface and SimpleSelect implementation - Add NativeSelectWrapper for native select support - Add hasSelect() and getSelect() to Query - Add selectBy() method to QueryBuilder (renamed from select to avoid Jackson conflict) - Add findAllProjected() method to Querity interface with default implementation - Implement projection support in JPA module (JpaSelect, JpaSimpleSelect, JpaQueryFactory) - Implement projection support in MongoDB module (MongodbSelect, MongodbSimpleSelect, MongodbQueryFactory) - Implement projection support in Elasticsearch module (ElasticsearchSelect, ElasticsearchSimpleSelect, ElasticsearchQueryFactory) - Add SELECT keyword to parser grammar - Add SelectUtils for native select wrapper resolution - Add tests for select in spring-web module Note: Using SimpleSelect (concrete class) instead of Select (interface) in Query to avoid Jackson deserialization issues with polymorphic types. --- .../querity/api/NativeSelectWrapper.java | 17 +++++ .../queritylib/querity/api/Querity.java | 23 ++++++ .../github/queritylib/querity/api/Query.java | 20 ++++- .../github/queritylib/querity/api/Select.java | 9 +++ .../queritylib/querity/api/SimpleSelect.java | 24 ++++++ .../querity/common/util/SelectUtils.java | 73 +++++++++++++++++++ .../querity/jpa/JpaNativeSelectWrapper.java | 48 ++++++++++++ .../querity/jpa/JpaQueryFactory.java | 73 ++++++++++++++++++- .../queritylib/querity/jpa/JpaSelect.java | 38 ++++++++++ .../querity/jpa/JpaSimpleSelect.java | 38 ++++++++++ .../querity/jpa/QuerityJpaImpl.java | 6 ++ .../queritylib/querity/parser/QueryLexer.g4 | 1 + .../queritylib/querity/parser/QueryParser.g4 | 4 +- .../querity/parser/QueryVisitor.java | 15 ++++ .../ElasticsearchQueryFactory.java | 20 +++++ .../elasticsearch/ElasticsearchSelect.java | 14 ++++ .../ElasticsearchSimpleSelect.java | 20 +++++ .../QuerityElasticsearchImpl.java | 33 +++++++++ .../data/mongodb/MongodbQueryFactory.java | 17 +++++ .../spring/data/mongodb/MongodbSelect.java | 13 ++++ .../data/mongodb/MongodbSimpleSelect.java | 26 +++++++ .../data/mongodb/QuerityMongodbImpl.java | 27 +++++++ .../spring/web/QueritySpringWebTests.java | 3 + 23 files changed, 555 insertions(+), 7 deletions(-) create mode 100644 querity-api/src/main/java/io/github/queritylib/querity/api/NativeSelectWrapper.java create mode 100644 querity-api/src/main/java/io/github/queritylib/querity/api/Select.java create mode 100644 querity-api/src/main/java/io/github/queritylib/querity/api/SimpleSelect.java create mode 100644 querity-common/src/main/java/io/github/queritylib/querity/common/util/SelectUtils.java create mode 100644 querity-jpa-common/src/main/java/io/github/queritylib/querity/jpa/JpaNativeSelectWrapper.java create mode 100644 querity-jpa-common/src/main/java/io/github/queritylib/querity/jpa/JpaSelect.java create mode 100644 querity-jpa-common/src/main/java/io/github/queritylib/querity/jpa/JpaSimpleSelect.java create mode 100644 querity-spring-data-elasticsearch/src/main/java/io/github/queritylib/querity/spring/data/elasticsearch/ElasticsearchSelect.java create mode 100644 querity-spring-data-elasticsearch/src/main/java/io/github/queritylib/querity/spring/data/elasticsearch/ElasticsearchSimpleSelect.java create mode 100644 querity-spring-data-mongodb/src/main/java/io/github/queritylib/querity/spring/data/mongodb/MongodbSelect.java create mode 100644 querity-spring-data-mongodb/src/main/java/io/github/queritylib/querity/spring/data/mongodb/MongodbSimpleSelect.java diff --git a/querity-api/src/main/java/io/github/queritylib/querity/api/NativeSelectWrapper.java b/querity-api/src/main/java/io/github/queritylib/querity/api/NativeSelectWrapper.java new file mode 100644 index 00000000..b2c5322b --- /dev/null +++ b/querity-api/src/main/java/io/github/queritylib/querity/api/NativeSelectWrapper.java @@ -0,0 +1,17 @@ +package io.github.queritylib.querity.api; + +import lombok.*; +import lombok.extern.jackson.Jacksonized; + +import java.util.List; + +@Builder(toBuilder = true) +@Jacksonized +@Getter +@EqualsAndHashCode +@ToString +public class NativeSelectWrapper implements Select { + @NonNull + @Singular + private List nativeSelections; +} diff --git a/querity-api/src/main/java/io/github/queritylib/querity/api/Querity.java b/querity-api/src/main/java/io/github/queritylib/querity/api/Querity.java index c2ae7543..e64af533 100644 --- a/querity-api/src/main/java/io/github/queritylib/querity/api/Querity.java +++ b/querity-api/src/main/java/io/github/queritylib/querity/api/Querity.java @@ -2,12 +2,24 @@ import java.util.Arrays; import java.util.List; +import java.util.Map; public interface Querity { List findAll(Class entityClass, Query query); Long count(Class entityClass, Condition condition); + /** + * Execute a projection query returning a list of maps with the selected properties. + * + * @param entityClass the entity class to query + * @param query the query with select clause + * @return a list of maps containing the selected properties + */ + default List> findAllProjected(Class entityClass, Query query) { + throw new UnsupportedOperationException("Projection queries are not supported by this implementation"); + } + static Query.QueryBuilder query() { return Query.builder(); } @@ -70,6 +82,17 @@ static NativeSortWrapper sortByNative(T nativeSort) { .build(); } + static SimpleSelect selectBy(String... propertyNames) { + return SimpleSelect.of(propertyNames); + } + + @SafeVarargs + static NativeSelectWrapper selectByNative(T... nativeSelections) { + return NativeSelectWrapper.builder() + .nativeSelections(Arrays.asList(nativeSelections)) + .build(); + } + static Query wrapConditionInQuery(Condition condition) { return Querity.query() .filter(condition) diff --git a/querity-api/src/main/java/io/github/queritylib/querity/api/Query.java b/querity-api/src/main/java/io/github/queritylib/querity/api/Query.java index 19d1c893..8b272d31 100644 --- a/querity-api/src/main/java/io/github/queritylib/querity/api/Query.java +++ b/querity-api/src/main/java/io/github/queritylib/querity/api/Query.java @@ -12,8 +12,8 @@ @Builder(toBuilder = true) @Jacksonized @Getter -@EqualsAndHashCode(of = {"filter", "pagination", "sort", "distinct"}) -@ToString(of = {"filter", "pagination", "sort", "distinct"}) +@EqualsAndHashCode(of = {"filter", "pagination", "sort", "distinct", "select"}) +@ToString(of = {"filter", "pagination", "sort", "distinct", "select"}) public class Query { private final Condition filter; private final Pagination pagination; @@ -23,6 +23,7 @@ public class Query { @JsonIgnore private List preprocessors; private boolean distinct; + private final SimpleSelect select; public boolean hasFilter() { return filter != null && !filter.isEmpty(); @@ -36,6 +37,10 @@ public boolean hasSort() { return Arrays.stream(sort).anyMatch(s -> true); } + public boolean hasSelect() { + return select != null; + } + public List getSort() { return Arrays.asList(sort); } @@ -50,6 +55,7 @@ public static class QueryBuilder { @SuppressWarnings({"java:S1068", "java:S1450"}) private Sort[] sort = new Sort[0]; private List preprocessors = new ArrayList<>(); + private SimpleSelect select; public QueryBuilder withPreprocessor(QueryPreprocessor preprocessor) { this.preprocessors.add(preprocessor); @@ -70,6 +76,16 @@ public QueryBuilder pagination(Integer page, Integer pageSize) { this.pagination = Querity.paged(page, pageSize); return this; } + + public QueryBuilder select(SimpleSelect select) { + this.select = select; + return this; + } + + public QueryBuilder selectBy(String... propertyNames) { + this.select = Querity.selectBy(propertyNames); + return this; + } } public Query preprocess() { diff --git a/querity-api/src/main/java/io/github/queritylib/querity/api/Select.java b/querity-api/src/main/java/io/github/queritylib/querity/api/Select.java new file mode 100644 index 00000000..e20b5639 --- /dev/null +++ b/querity-api/src/main/java/io/github/queritylib/querity/api/Select.java @@ -0,0 +1,9 @@ +package io.github.queritylib.querity.api; + +/** + * Marker interface for select options. + * Implementations include {@link SimpleSelect} for property-based selection + * and {@link NativeSelectWrapper} for native selection. + */ +public interface Select { +} diff --git a/querity-api/src/main/java/io/github/queritylib/querity/api/SimpleSelect.java b/querity-api/src/main/java/io/github/queritylib/querity/api/SimpleSelect.java new file mode 100644 index 00000000..1e4fd7c3 --- /dev/null +++ b/querity-api/src/main/java/io/github/queritylib/querity/api/SimpleSelect.java @@ -0,0 +1,24 @@ +package io.github.queritylib.querity.api; + +import lombok.*; +import lombok.extern.jackson.Jacksonized; + +import java.util.Arrays; +import java.util.List; + +@Builder(toBuilder = true) +@Jacksonized +@Getter +@EqualsAndHashCode +@ToString +public class SimpleSelect implements Select { + @NonNull + @Singular + private List propertyNames; + + public static SimpleSelect of(String... propertyNames) { + return SimpleSelect.builder() + .propertyNames(Arrays.asList(propertyNames)) + .build(); + } +} diff --git a/querity-common/src/main/java/io/github/queritylib/querity/common/util/SelectUtils.java b/querity-common/src/main/java/io/github/queritylib/querity/common/util/SelectUtils.java new file mode 100644 index 00000000..f13d4631 --- /dev/null +++ b/querity-common/src/main/java/io/github/queritylib/querity/common/util/SelectUtils.java @@ -0,0 +1,73 @@ +package io.github.queritylib.querity.common.util; + +import io.github.queritylib.querity.api.NativeSelectWrapper; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.lang.reflect.WildcardType; +import java.util.Arrays; +import java.util.Optional; +import java.util.Set; + +import static io.github.queritylib.querity.common.util.ReflectionUtils.constructInstanceWithArgument; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class SelectUtils { + + /** + * Find a select implementation that can handle the given NativeSelectWrapper based on its generic type. + * This looks for a class that has a constructor accepting a NativeSelectWrapper with a matching generic type. + */ + public static Optional getSelectImplementation(Set> implementationClasses, NativeSelectWrapper nativeSelectWrapper) { + if (nativeSelectWrapper.getNativeSelections().isEmpty()) { + return Optional.empty(); + } + Class wrappedType = nativeSelectWrapper.getNativeSelections().get(0).getClass(); + + return findClassWithNativeSelectWrapperConstructor(implementationClasses, wrappedType) + .map(clazz -> constructInstanceWithArgument(clazz, nativeSelectWrapper)); + } + + private static Optional> findClassWithNativeSelectWrapperConstructor( + Set> allClasses, Class wrappedType) { + return allClasses.stream() + .filter(clazz -> hasNativeSelectWrapperConstructorForType(clazz, wrappedType)) + .findAny(); + } + + private static boolean hasNativeSelectWrapperConstructorForType(Class clazz, Class wrappedType) { + return Arrays.stream(clazz.getDeclaredConstructors()) + .filter(constructor -> constructor.getParameterCount() == 1) + .anyMatch(constructor -> { + Type paramType = constructor.getGenericParameterTypes()[0]; + if (paramType instanceof ParameterizedType pt) { + if (NativeSelectWrapper.class.isAssignableFrom((Class) pt.getRawType())) { + Type[] typeArgs = pt.getActualTypeArguments(); + if (typeArgs.length == 1) { + Class expectedType = extractRawType(typeArgs[0]); + if (expectedType != null) { + return expectedType.isAssignableFrom(wrappedType); + } + } + } + } + return false; + }); + } + + private static Class extractRawType(Type type) { + if (type instanceof Class clazz) { + return clazz; + } else if (type instanceof ParameterizedType pt) { + return (Class) pt.getRawType(); + } else if (type instanceof WildcardType wt) { + Type[] upperBounds = wt.getUpperBounds(); + if (upperBounds.length > 0) { + return extractRawType(upperBounds[0]); + } + } + return null; + } +} diff --git a/querity-jpa-common/src/main/java/io/github/queritylib/querity/jpa/JpaNativeSelectWrapper.java b/querity-jpa-common/src/main/java/io/github/queritylib/querity/jpa/JpaNativeSelectWrapper.java new file mode 100644 index 00000000..909d072d --- /dev/null +++ b/querity-jpa-common/src/main/java/io/github/queritylib/querity/jpa/JpaNativeSelectWrapper.java @@ -0,0 +1,48 @@ +package io.github.queritylib.querity.jpa; + +import io.github.queritylib.querity.api.NativeSelectWrapper; +import io.github.queritylib.querity.common.util.SelectUtils; +import jakarta.persistence.criteria.CriteriaQuery; +import jakarta.persistence.criteria.Root; +import jakarta.persistence.criteria.Selection; +import jakarta.persistence.metamodel.Metamodel; +import org.reflections.Reflections; + +import java.util.List; +import java.util.Set; + +/** + * Abstract wrapper for native JPA select implementations. + */ +public abstract class JpaNativeSelectWrapper implements JpaSelect { + + private static Set> implementationClasses; + + protected final NativeSelectWrapper nativeSelectWrapper; + + protected JpaNativeSelectWrapper(NativeSelectWrapper nativeSelectWrapper) { + this.nativeSelectWrapper = nativeSelectWrapper; + } + + public static JpaSelect of(NativeSelectWrapper nativeSelectWrapper) { + return SelectUtils.getSelectImplementation(getImplementationClasses(), nativeSelectWrapper) + .orElseThrow(() -> new IllegalArgumentException( + "No JpaSelect implementation found for NativeSelectWrapper with type: " + + (nativeSelectWrapper.getNativeSelections().isEmpty() ? "empty" : + nativeSelectWrapper.getNativeSelections().get(0).getClass().getName()))); + } + + private static Set> getImplementationClasses() { + if (implementationClasses == null) { + Reflections reflections = new Reflections("io.github.queritylib.querity"); + implementationClasses = reflections.getSubTypesOf(JpaSelect.class); + } + return implementationClasses; + } + + @Override + public abstract List> toSelections(Metamodel metamodel, Root root, CriteriaQuery cq); + + @Override + public abstract List getPropertyNames(); +} diff --git a/querity-jpa-common/src/main/java/io/github/queritylib/querity/jpa/JpaQueryFactory.java b/querity-jpa-common/src/main/java/io/github/queritylib/querity/jpa/JpaQueryFactory.java index 0369d8b9..cabc71ec 100644 --- a/querity-jpa-common/src/main/java/io/github/queritylib/querity/jpa/JpaQueryFactory.java +++ b/querity-jpa-common/src/main/java/io/github/queritylib/querity/jpa/JpaQueryFactory.java @@ -1,9 +1,6 @@ package io.github.queritylib.querity.jpa; -import io.github.queritylib.querity.api.Condition; -import io.github.queritylib.querity.api.Pagination; -import io.github.queritylib.querity.api.Query; -import io.github.queritylib.querity.api.Sort; +import io.github.queritylib.querity.api.*; import jakarta.persistence.EntityManager; import jakarta.persistence.Tuple; import jakarta.persistence.TypedQuery; @@ -11,6 +8,7 @@ import jakarta.persistence.metamodel.Metamodel; import java.util.List; +import java.util.Map; import java.util.stream.Stream; public class JpaQueryFactory { @@ -52,6 +50,73 @@ public TypedQuery getJpaQuery() { return tq; } + /** + * Create a JPA TypedQuery<Tuple> for projection queries. + * The query will select only the specified fields and return them as a Tuple. + * + * @return A TypedQuery that can be executed to retrieve projected results. + */ + public TypedQuery getJpaProjectionQuery() { + CriteriaBuilder cb = entityManager.getCriteriaBuilder(); + CriteriaQuery cq = cb.createTupleQuery(); + Root root = cq.from(entityClass); + + Metamodel metamodel = entityManager.getMetamodel(); + + applyDistinct(cq); + applyFilters(metamodel, root, cq, cb); + applySorting(metamodel, root, cq, cb); + applyProjectionSelections(metamodel, cq, root); + + TypedQuery tq = createTypedQuery(cq); + + applyPagination(tq); + + return tq; + } + + /** + * Execute a projection query and return results as a list of maps. + * + * @return List of maps containing the projected property values + */ + public List> getProjectedResults() { + if (query == null || !query.hasSelect()) { + throw new IllegalStateException("Query must have a select clause for projection"); + } + + TypedQuery tq = getJpaProjectionQuery(); + JpaSelect jpaSelect = JpaSelect.of(query.getSelect()); + List propertyNames = jpaSelect.getPropertyNames(); + + return tq.getResultList().stream() + .map(tuple -> tupleToMap(tuple, propertyNames)) + .toList(); + } + + private Map tupleToMap(Tuple tuple, List propertyNames) { + java.util.LinkedHashMap map = new java.util.LinkedHashMap<>(); + for (int i = 0; i < propertyNames.size(); i++) { + String propertyName = propertyNames.get(i); + // Use the last part of the property name as key (e.g., "address.city" -> "city") + String key = propertyName.contains(".") ? + propertyName.substring(propertyName.lastIndexOf('.') + 1) : propertyName; + map.put(key, tuple.get(i)); + } + return map; + } + + /** + * Apply projection selections to the CriteriaQuery. + */ + private void applyProjectionSelections(Metamodel metamodel, CriteriaQuery cq, Root root) { + if (query != null && query.hasSelect()) { + JpaSelect jpaSelect = JpaSelect.of(query.getSelect()); + List> selections = jpaSelect.toSelections(metamodel, root, cq); + cq.multiselect(selections); + } + } + /** * Apply selections to the CriteriaQuery. * If there are sorting orders, they are added as selections to ensure they are included in the result set. diff --git a/querity-jpa-common/src/main/java/io/github/queritylib/querity/jpa/JpaSelect.java b/querity-jpa-common/src/main/java/io/github/queritylib/querity/jpa/JpaSelect.java new file mode 100644 index 00000000..e2e69bf2 --- /dev/null +++ b/querity-jpa-common/src/main/java/io/github/queritylib/querity/jpa/JpaSelect.java @@ -0,0 +1,38 @@ +package io.github.queritylib.querity.jpa; + +import jakarta.persistence.criteria.CriteriaQuery; +import jakarta.persistence.criteria.Root; +import jakarta.persistence.criteria.Selection; +import jakarta.persistence.metamodel.Metamodel; + +import java.util.List; + +/** + * Interface for JPA select implementations. + */ +public interface JpaSelect { + + /** + * Convert this select to a list of JPA Selections. + * + * @param metamodel the JPA metamodel + * @param root the root of the query + * @param cq the criteria query + * @return a list of JPA selections + */ + List> toSelections(Metamodel metamodel, Root root, CriteriaQuery cq); + + /** + * Get the property names for this select. + * + * @return list of property names + */ + List getPropertyNames(); + + /** + * Create a JpaSelect from an API SimpleSelect. + */ + static JpaSelect of(io.github.queritylib.querity.api.SimpleSelect select) { + return new JpaSimpleSelect(select); + } +} diff --git a/querity-jpa-common/src/main/java/io/github/queritylib/querity/jpa/JpaSimpleSelect.java b/querity-jpa-common/src/main/java/io/github/queritylib/querity/jpa/JpaSimpleSelect.java new file mode 100644 index 00000000..4a26f142 --- /dev/null +++ b/querity-jpa-common/src/main/java/io/github/queritylib/querity/jpa/JpaSimpleSelect.java @@ -0,0 +1,38 @@ +package io.github.queritylib.querity.jpa; + +import io.github.queritylib.querity.api.SimpleSelect; +import jakarta.persistence.criteria.CriteriaQuery; +import jakarta.persistence.criteria.Path; +import jakarta.persistence.criteria.Root; +import jakarta.persistence.criteria.Selection; +import jakarta.persistence.metamodel.Metamodel; + +import java.util.List; + +/** + * JPA implementation for SimpleSelect. + */ +public class JpaSimpleSelect implements JpaSelect { + + private final SimpleSelect simpleSelect; + + public JpaSimpleSelect(SimpleSelect simpleSelect) { + this.simpleSelect = simpleSelect; + } + + @Override + public List> toSelections(Metamodel metamodel, Root root, CriteriaQuery cq) { + return simpleSelect.getPropertyNames().stream() + .map(propertyName -> { + Path path = JpaPropertyUtils.getPath(root, propertyName, metamodel); + return path.alias(propertyName); + }) + .>map(p -> p) + .toList(); + } + + @Override + public List getPropertyNames() { + return simpleSelect.getPropertyNames(); + } +} diff --git a/querity-jpa-common/src/main/java/io/github/queritylib/querity/jpa/QuerityJpaImpl.java b/querity-jpa-common/src/main/java/io/github/queritylib/querity/jpa/QuerityJpaImpl.java index e7c94c1e..e2d5b97a 100644 --- a/querity-jpa-common/src/main/java/io/github/queritylib/querity/jpa/QuerityJpaImpl.java +++ b/querity-jpa-common/src/main/java/io/github/queritylib/querity/jpa/QuerityJpaImpl.java @@ -8,6 +8,7 @@ import jakarta.persistence.TypedQuery; import java.util.List; +import java.util.Map; public class QuerityJpaImpl implements Querity { @@ -32,6 +33,11 @@ public Long count(Class entityClass, Condition condition) { return jpaQuery.getSingleResult(); } + @Override + public List> findAllProjected(Class entityClass, Query query) { + return getJpaQueryFactory(entityClass, query).getProjectedResults(); + } + protected JpaQueryFactory getJpaQueryFactory(Class entityClass, Query query) { return new JpaQueryFactory<>(entityClass, query, entityManager); } diff --git a/querity-parser/src/main/antlr4/io/github/queritylib/querity/parser/QueryLexer.g4 b/querity-parser/src/main/antlr4/io/github/queritylib/querity/parser/QueryLexer.g4 index 83f4071e..18e4f8b5 100644 --- a/querity-parser/src/main/antlr4/io/github/queritylib/querity/parser/QueryLexer.g4 +++ b/querity-parser/src/main/antlr4/io/github/queritylib/querity/parser/QueryLexer.g4 @@ -4,6 +4,7 @@ DISTINCT : 'distinct'; AND : 'and'; OR : 'or'; NOT : 'not'; +SELECT : 'select'; SORT : 'sort by'; ASC : 'asc'; DESC : 'desc'; diff --git a/querity-parser/src/main/antlr4/io/github/queritylib/querity/parser/QueryParser.g4 b/querity-parser/src/main/antlr4/io/github/queritylib/querity/parser/QueryParser.g4 index 1f180bd2..aa382c75 100644 --- a/querity-parser/src/main/antlr4/io/github/queritylib/querity/parser/QueryParser.g4 +++ b/querity-parser/src/main/antlr4/io/github/queritylib/querity/parser/QueryParser.g4 @@ -2,7 +2,9 @@ parser grammar QueryParser; options { tokenVocab=QueryLexer; } -query : DISTINCT? (condition)? (SORT sortFields)? (PAGINATION paginationParams)? ; +query : DISTINCT? (selectClause)? (condition)? (SORT sortFields)? (PAGINATION paginationParams)? ; +selectClause : SELECT selectFields ; +selectFields : PROPERTY (COMMA PROPERTY)* ; condition : simpleCondition | conditionWrapper | notCondition; operator : NEQ | LTE | GTE | EQ | LT | GT | STARTS_WITH | ENDS_WITH | CONTAINS | IS_NULL | IS_NOT_NULL | IN | NOT_IN ; conditionWrapper : (AND | OR) LPAREN condition (COMMA condition)* RPAREN ; diff --git a/querity-parser/src/main/java/io/github/queritylib/querity/parser/QueryVisitor.java b/querity-parser/src/main/java/io/github/queritylib/querity/parser/QueryVisitor.java index 880da0f2..419831c6 100644 --- a/querity-parser/src/main/java/io/github/queritylib/querity/parser/QueryVisitor.java +++ b/querity-parser/src/main/java/io/github/queritylib/querity/parser/QueryVisitor.java @@ -10,18 +10,33 @@ class QueryVisitor extends QueryParserBaseVisitor { @Override public Query visitQuery(QueryParser.QueryContext ctx) { boolean distinct = ctx.DISTINCT() != null; + SimpleSelect select = ctx.selectClause() != null ? (SimpleSelect) visit(ctx.selectClause()) : null; Condition filter = ctx.condition() != null ? (Condition) visit(ctx.condition()) : null; Sort[] sorts = ctx.SORT() != null ? (Sort[]) visit(ctx.sortFields()) : new Sort[0]; Pagination pagination = ctx.PAGINATION() != null ? (Pagination) visit(ctx.paginationParams()) : null; return Querity.query() .distinct(distinct) + .select(select) .filter(filter) .pagination(pagination) .sort(sorts) .build(); } + @Override + public Object visitSelectClause(QueryParser.SelectClauseContext ctx) { + return visit(ctx.selectFields()); + } + + @Override + public Object visitSelectFields(QueryParser.SelectFieldsContext ctx) { + String[] propertyNames = ctx.PROPERTY().stream() + .map(node -> node.getText()) + .toArray(String[]::new); + return Querity.selectBy(propertyNames); + } + @Override public Object visitPaginationParams(QueryParser.PaginationParamsContext ctx) { return Pagination.builder() diff --git a/querity-spring-data-elasticsearch/src/main/java/io/github/queritylib/querity/spring/data/elasticsearch/ElasticsearchQueryFactory.java b/querity-spring-data-elasticsearch/src/main/java/io/github/queritylib/querity/spring/data/elasticsearch/ElasticsearchQueryFactory.java index ab310e62..bd827ee8 100644 --- a/querity-spring-data-elasticsearch/src/main/java/io/github/queritylib/querity/spring/data/elasticsearch/ElasticsearchQueryFactory.java +++ b/querity-spring-data-elasticsearch/src/main/java/io/github/queritylib/querity/spring/data/elasticsearch/ElasticsearchQueryFactory.java @@ -28,6 +28,26 @@ org.springframework.data.elasticsearch.core.query.Query getElasticsearchQuery() return q; } + org.springframework.data.elasticsearch.core.query.Query getElasticsearchProjectedQuery() { + if (query == null || !query.hasSelect()) { + throw new IllegalArgumentException("Query must have a select clause for projection queries"); + } + if (query.isDistinct()) { + log.debug("Distinct queries are not supported in Elasticsearch, ignoring the distinct flag"); + } + org.springframework.data.elasticsearch.core.query.Query q = initElasticsearchQuery(); + applyProjection(q); + q = applyPaginationAndSorting(q); + return q; + } + + private void applyProjection(org.springframework.data.elasticsearch.core.query.Query q) { + List fields = ElasticsearchSelect.of(query.getSelect()).getFields(); + String[] includes = fields.toArray(new String[0]); + q.addSourceFilter(org.springframework.data.elasticsearch.core.query.FetchSourceFilter.of( + sourceFilterBuilder -> sourceFilterBuilder.withIncludes(includes))); + } + private org.springframework.data.elasticsearch.core.query.Query initElasticsearchQuery() { return query == null || !query.hasFilter() ? new org.springframework.data.elasticsearch.core.query.CriteriaQuery(new Criteria()) : diff --git a/querity-spring-data-elasticsearch/src/main/java/io/github/queritylib/querity/spring/data/elasticsearch/ElasticsearchSelect.java b/querity-spring-data-elasticsearch/src/main/java/io/github/queritylib/querity/spring/data/elasticsearch/ElasticsearchSelect.java new file mode 100644 index 00000000..5f783aa0 --- /dev/null +++ b/querity-spring-data-elasticsearch/src/main/java/io/github/queritylib/querity/spring/data/elasticsearch/ElasticsearchSelect.java @@ -0,0 +1,14 @@ +package io.github.queritylib.querity.spring.data.elasticsearch; + +import io.github.queritylib.querity.api.SimpleSelect; + +import java.util.List; + +abstract class ElasticsearchSelect { + + public abstract List getFields(); + + public static ElasticsearchSelect of(SimpleSelect select) { + return new ElasticsearchSimpleSelect(select); + } +} diff --git a/querity-spring-data-elasticsearch/src/main/java/io/github/queritylib/querity/spring/data/elasticsearch/ElasticsearchSimpleSelect.java b/querity-spring-data-elasticsearch/src/main/java/io/github/queritylib/querity/spring/data/elasticsearch/ElasticsearchSimpleSelect.java new file mode 100644 index 00000000..70756f39 --- /dev/null +++ b/querity-spring-data-elasticsearch/src/main/java/io/github/queritylib/querity/spring/data/elasticsearch/ElasticsearchSimpleSelect.java @@ -0,0 +1,20 @@ +package io.github.queritylib.querity.spring.data.elasticsearch; + +import io.github.queritylib.querity.api.SimpleSelect; +import lombok.experimental.Delegate; + +import java.util.List; + +class ElasticsearchSimpleSelect extends ElasticsearchSelect { + @Delegate + private final SimpleSelect simpleSelect; + + public ElasticsearchSimpleSelect(SimpleSelect simpleSelect) { + this.simpleSelect = simpleSelect; + } + + @Override + public List getFields() { + return getPropertyNames(); + } +} diff --git a/querity-spring-data-elasticsearch/src/main/java/io/github/queritylib/querity/spring/data/elasticsearch/QuerityElasticsearchImpl.java b/querity-spring-data-elasticsearch/src/main/java/io/github/queritylib/querity/spring/data/elasticsearch/QuerityElasticsearchImpl.java index 27c468ab..578e164b 100644 --- a/querity-spring-data-elasticsearch/src/main/java/io/github/queritylib/querity/spring/data/elasticsearch/QuerityElasticsearchImpl.java +++ b/querity-spring-data-elasticsearch/src/main/java/io/github/queritylib/querity/spring/data/elasticsearch/QuerityElasticsearchImpl.java @@ -10,7 +10,9 @@ import org.springframework.data.elasticsearch.core.SearchHit; import org.springframework.data.elasticsearch.core.SearchHits; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; @Slf4j public class QuerityElasticsearchImpl implements Querity { @@ -23,6 +25,10 @@ public QuerityElasticsearchImpl(ElasticsearchOperations elasticsearchOperations) @Override public List findAll(Class entityClass, Query query) { + if (query != null && query.hasSelect()) { + throw new IllegalArgumentException( + "findAll() does not support projections. Use findAllProjected() instead."); + } org.springframework.data.elasticsearch.core.query.Query q = getElasticsearchQuery(entityClass, query); try { SearchHits hits = elasticsearchOperations.search(q, entityClass); @@ -40,6 +46,33 @@ public Long count(Class entityClass, Condition condition) { return elasticsearchOperations.count(q, entityClass); } + @Override + @SuppressWarnings("unchecked") + public List> findAllProjected(Class entityClass, Query query) { + org.springframework.data.elasticsearch.core.query.Query q = getElasticsearchQueryFactory(entityClass, query).getElasticsearchProjectedQuery(); + try { + SearchHits hits = elasticsearchOperations.search(q, Map.class, elasticsearchOperations.getIndexCoordinatesFor(entityClass)); + return hits.stream() + .map(SearchHit::getContent) + .map(this::sanitizeMap) + .toList(); + } catch (UncategorizedElasticsearchException e) { + log.error(((ElasticsearchException) e.getCause()).response().error().rootCause().get(0).reason()); + throw e; + } + } + + @SuppressWarnings("unchecked") + private Map sanitizeMap(Map source) { + Map result = new LinkedHashMap<>(); + source.forEach((key, value) -> { + if (key instanceof String strKey && !"_class".equals(strKey)) { + result.put(strKey, value); + } + }); + return result; + } + private org.springframework.data.elasticsearch.core.query.Query getElasticsearchQuery(Class entityClass, Query query) { return getElasticsearchQueryFactory(entityClass, query).getElasticsearchQuery(); } diff --git a/querity-spring-data-mongodb/src/main/java/io/github/queritylib/querity/spring/data/mongodb/MongodbQueryFactory.java b/querity-spring-data-mongodb/src/main/java/io/github/queritylib/querity/spring/data/mongodb/MongodbQueryFactory.java index f12a4768..20c99c79 100644 --- a/querity-spring-data-mongodb/src/main/java/io/github/queritylib/querity/spring/data/mongodb/MongodbQueryFactory.java +++ b/querity-spring-data-mongodb/src/main/java/io/github/queritylib/querity/spring/data/mongodb/MongodbQueryFactory.java @@ -28,6 +28,23 @@ org.springframework.data.mongodb.core.query.Query getMongodbQuery() { return q; } + org.springframework.data.mongodb.core.query.Query getMongodbProjectedQuery() { + if (query == null || !query.hasSelect()) { + throw new IllegalArgumentException("Query must have a select clause for projection queries"); + } + if (query.isDistinct()) { + log.debug("Distinct queries are not supported in MongoDB, ignoring the distinct flag"); + } + org.springframework.data.mongodb.core.query.Query q = initMongodbQuery(); + applyProjection(q); + q = applyPaginationAndSorting(q); + return q; + } + + private void applyProjection(org.springframework.data.mongodb.core.query.Query q) { + MongodbSelect.of(query.getSelect()).applyProjection(q.fields()); + } + private org.springframework.data.mongodb.core.query.Query initMongodbQuery() { return query == null || !query.hasFilter() ? new org.springframework.data.mongodb.core.query.Query() : diff --git a/querity-spring-data-mongodb/src/main/java/io/github/queritylib/querity/spring/data/mongodb/MongodbSelect.java b/querity-spring-data-mongodb/src/main/java/io/github/queritylib/querity/spring/data/mongodb/MongodbSelect.java new file mode 100644 index 00000000..98f2a8f0 --- /dev/null +++ b/querity-spring-data-mongodb/src/main/java/io/github/queritylib/querity/spring/data/mongodb/MongodbSelect.java @@ -0,0 +1,13 @@ +package io.github.queritylib.querity.spring.data.mongodb; + +import io.github.queritylib.querity.api.SimpleSelect; +import org.springframework.data.mongodb.core.query.Field; + +abstract class MongodbSelect { + + public abstract void applyProjection(Field field); + + public static MongodbSelect of(SimpleSelect select) { + return new MongodbSimpleSelect(select); + } +} diff --git a/querity-spring-data-mongodb/src/main/java/io/github/queritylib/querity/spring/data/mongodb/MongodbSimpleSelect.java b/querity-spring-data-mongodb/src/main/java/io/github/queritylib/querity/spring/data/mongodb/MongodbSimpleSelect.java new file mode 100644 index 00000000..57aba064 --- /dev/null +++ b/querity-spring-data-mongodb/src/main/java/io/github/queritylib/querity/spring/data/mongodb/MongodbSimpleSelect.java @@ -0,0 +1,26 @@ +package io.github.queritylib.querity.spring.data.mongodb; + +import io.github.queritylib.querity.api.SimpleSelect; +import lombok.experimental.Delegate; +import org.springframework.data.mongodb.core.query.Field; + +class MongodbSimpleSelect extends MongodbSelect { + @Delegate + private final SimpleSelect simpleSelect; + + public MongodbSimpleSelect(SimpleSelect simpleSelect) { + this.simpleSelect = simpleSelect; + } + + @Override + public void applyProjection(Field field) { + getPropertyNames().stream() + .map(MongodbSimpleSelect::mapFieldName) + .forEach(field::include); + } + + private static String mapFieldName(String fieldName) { + // Map 'id' to MongoDB's '_id' + return "id".equals(fieldName) ? "_id" : fieldName; + } +} diff --git a/querity-spring-data-mongodb/src/main/java/io/github/queritylib/querity/spring/data/mongodb/QuerityMongodbImpl.java b/querity-spring-data-mongodb/src/main/java/io/github/queritylib/querity/spring/data/mongodb/QuerityMongodbImpl.java index 36fae1c9..43bac3b6 100644 --- a/querity-spring-data-mongodb/src/main/java/io/github/queritylib/querity/spring/data/mongodb/QuerityMongodbImpl.java +++ b/querity-spring-data-mongodb/src/main/java/io/github/queritylib/querity/spring/data/mongodb/QuerityMongodbImpl.java @@ -3,9 +3,12 @@ import io.github.queritylib.querity.api.Condition; import io.github.queritylib.querity.api.Querity; import io.github.queritylib.querity.api.Query; +import org.bson.Document; import org.springframework.data.mongodb.core.MongoTemplate; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; public class QuerityMongodbImpl implements Querity { @@ -17,6 +20,10 @@ public QuerityMongodbImpl(MongoTemplate mongoTemplate) { @Override public List findAll(Class entityClass, Query query) { + if (query != null && query.hasSelect()) { + throw new IllegalArgumentException( + "findAll() does not support projections. Use findAllProjected() instead."); + } org.springframework.data.mongodb.core.query.Query q = getMongodbQuery(entityClass, query); return mongoTemplate.find(q, entityClass); } @@ -28,6 +35,26 @@ public Long count(Class entityClass, Condition condition) { return mongoTemplate.count(q, entityClass); } + @Override + public List> findAllProjected(Class entityClass, Query query) { + org.springframework.data.mongodb.core.query.Query q = getMongodbQueryFactory(entityClass, query).getMongodbProjectedQuery(); + return mongoTemplate.find(q, Document.class, mongoTemplate.getCollectionName(entityClass)).stream() + .map(this::documentToMap) + .toList(); + } + + private Map documentToMap(Document document) { + Map map = new LinkedHashMap<>(); + document.forEach((key, value) -> { + if ("_id".equals(key)) { + map.put("id", value); + } else if (!"_class".equals(key)) { + map.put(key, value); + } + }); + return map; + } + private org.springframework.data.mongodb.core.query.Query getMongodbQuery(Class entityClass, Query query) { return getMongodbQueryFactory(entityClass, query).getMongodbQuery(); } diff --git a/querity-spring-web/src/test/java/io/github/queritylib/querity/spring/web/QueritySpringWebTests.java b/querity-spring-web/src/test/java/io/github/queritylib/querity/spring/web/QueritySpringWebTests.java index ef18f327..90ffbcc6 100644 --- a/querity-spring-web/src/test/java/io/github/queritylib/querity/spring/web/QueritySpringWebTests.java +++ b/querity-spring-web/src/test/java/io/github/queritylib/querity/spring/web/QueritySpringWebTests.java @@ -36,6 +36,9 @@ class QueritySpringWebTests { /* not single condition */ "{\"filter\":{\"not\":{\"propertyName\":\"lastName\",\"operator\":\"EQUALS\",\"value\":\"Skywalker\"}}}", /* not conditions wrapper */ "{\"filter\":{\"not\":{\"and\":[{\"propertyName\":\"lastName\",\"operator\":\"EQUALS\",\"value\":\"Skywalker\"}]}}}", /* in array condition */ "{\"filter\":{\"propertyName\":\"lastName\",\"operator\":\"IN\",\"value\":[\"Skywalker\",\"Solo\"]}}", + /* simple select */ "{\"select\":{\"propertyNames\":[\"id\",\"firstName\",\"lastName\"]}}", + /* select with filter */ "{\"filter\":{\"propertyName\":\"lastName\",\"operator\":\"EQUALS\",\"value\":\"Skywalker\"},\"select\":{\"propertyNames\":[\"id\",\"name\"]}}", + /* select with sort */ "{\"select\":{\"propertyNames\":[\"id\"]},\"sort\":[{\"propertyName\":\"lastName\"}]}", }) void givenJsonQuery_whenGetQuery_thenReturnsTheSameQueryAsResponse(String query) throws Exception { mockMvc.perform(get("/query") From 6e7d2f2666db9a37ae30ed0a634962f17d314c99 Mon Sep 17 00:00:00 2001 From: Riccardo Vagelli Date: Fri, 5 Dec 2025 20:16:57 +0100 Subject: [PATCH 02/11] Add unit tests for select functionality - Add SimpleSelectTest for API module - Add JpaSelectTests for JPA module - Add parser tests for SELECT keyword in QuerityParserTests --- .../querity/api/SimpleSelectTest.java | 146 ++++++++++++++++++ .../querity/jpa/JpaSelectTests.java | 42 +++++ .../querity/parser/QuerityParserTests.java | 17 +- 3 files changed, 204 insertions(+), 1 deletion(-) create mode 100644 querity-api/src/test/java/io/github/queritylib/querity/api/SimpleSelectTest.java create mode 100644 querity-jpa-common/src/test/java/io/github/queritylib/querity/jpa/JpaSelectTests.java diff --git a/querity-api/src/test/java/io/github/queritylib/querity/api/SimpleSelectTest.java b/querity-api/src/test/java/io/github/queritylib/querity/api/SimpleSelectTest.java new file mode 100644 index 00000000..3e3bfeb9 --- /dev/null +++ b/querity-api/src/test/java/io/github/queritylib/querity/api/SimpleSelectTest.java @@ -0,0 +1,146 @@ +package io.github.queritylib.querity.api; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; + +import static io.github.queritylib.querity.api.Querity.selectBy; +import static org.assertj.core.api.Assertions.assertThat; + +class SimpleSelectTest { + + @Nested + class CreationTests { + @Test + void givenPropertyNames_whenSelectBy_thenReturnSimpleSelect() { + SimpleSelect select = selectBy("id", "name", "email"); + + assertThat(select).isNotNull(); + assertThat(select.getPropertyNames()).containsExactly("id", "name", "email"); + } + + @Test + void givenSinglePropertyName_whenSelectBy_thenReturnSimpleSelect() { + SimpleSelect select = selectBy("id"); + + assertThat(select).isNotNull(); + assertThat(select.getPropertyNames()).containsExactly("id"); + } + + @Test + void givenPropertyNames_whenOf_thenReturnSimpleSelect() { + SimpleSelect select = SimpleSelect.of("firstName", "lastName"); + + assertThat(select).isNotNull(); + assertThat(select.getPropertyNames()).containsExactly("firstName", "lastName"); + } + } + + @Nested + class EqualsAndHashCodeTests { + @Test + void givenTwoSelectsWithSameProperties_whenEquals_thenReturnTrue() { + SimpleSelect select1 = selectBy("id", "name"); + SimpleSelect select2 = selectBy("id", "name"); + + assertThat(select1).isEqualTo(select2); + assertThat(select1.hashCode()).isEqualTo(select2.hashCode()); + } + + @Test + void givenTwoSelectsWithDifferentProperties_whenEquals_thenReturnFalse() { + SimpleSelect select1 = selectBy("id", "name"); + SimpleSelect select2 = selectBy("id", "email"); + + assertThat(select1).isNotEqualTo(select2); + } + + @Test + void givenTwoSelectsWithSamePropertiesDifferentOrder_whenEquals_thenReturnFalse() { + SimpleSelect select1 = selectBy("id", "name"); + SimpleSelect select2 = selectBy("name", "id"); + + assertThat(select1).isNotEqualTo(select2); + } + } + + @Nested + class SelectInterfaceTests { + @Test + void givenSimpleSelect_whenCheckInterface_thenImplementsSelect() { + SimpleSelect select = selectBy("id"); + + assertThat(select).isInstanceOf(Select.class); + } + } + + @Nested + class BuilderTests { + @Test + void givenSimpleSelect_whenToBuilder_thenCanModify() { + SimpleSelect original = selectBy("id", "name"); + SimpleSelect modified = original.toBuilder() + .clearPropertyNames() + .propertyNames(Arrays.asList("email", "phone")) + .build(); + + assertThat(original.getPropertyNames()).containsExactly("id", "name"); + assertThat(modified.getPropertyNames()).containsExactly("email", "phone"); + } + + @Test + void givenSimpleSelect_whenToBuilderAndAddProperty_thenPropertyIsAdded() { + SimpleSelect original = selectBy("id"); + SimpleSelect modified = original.toBuilder() + .propertyName("name") + .build(); + + assertThat(original.getPropertyNames()).containsExactly("id"); + assertThat(modified.getPropertyNames()).containsExactly("id", "name"); + } + } + + @Nested + class UsageInQueryTests { + @Test + void givenSimpleSelect_whenUsedInQuery_thenQueryHasSelect() { + SimpleSelect select = selectBy("id", "name", "email"); + Query query = Querity.query() + .select(select) + .build(); + + assertThat(query.hasSelect()).isTrue(); + assertThat(query.getSelect()).isEqualTo(select); + } + + @Test + void givenPropertyNames_whenUsedInQueryBuilder_thenQueryHasSimpleSelect() { + Query query = Querity.query() + .selectBy("id", "name") + .build(); + + assertThat(query.hasSelect()).isTrue(); + assertThat(query.getSelect()).isInstanceOf(SimpleSelect.class); + assertThat(query.getSelect().getPropertyNames()).containsExactly("id", "name"); + } + + @Test + void givenNoSelect_whenQuery_thenQueryHasNoSelect() { + Query query = Querity.query().build(); + + assertThat(query.hasSelect()).isFalse(); + assertThat(query.getSelect()).isNull(); + } + } + + @Nested + class ToStringTests { + @Test + void givenSimpleSelect_whenToString_thenContainsPropertyNames() { + SimpleSelect select = selectBy("id", "name"); + + assertThat(select.toString()).contains("id", "name"); + } + } +} diff --git a/querity-jpa-common/src/test/java/io/github/queritylib/querity/jpa/JpaSelectTests.java b/querity-jpa-common/src/test/java/io/github/queritylib/querity/jpa/JpaSelectTests.java new file mode 100644 index 00000000..20d9e921 --- /dev/null +++ b/querity-jpa-common/src/test/java/io/github/queritylib/querity/jpa/JpaSelectTests.java @@ -0,0 +1,42 @@ +package io.github.queritylib.querity.jpa; + +import io.github.queritylib.querity.api.SimpleSelect; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static io.github.queritylib.querity.api.Querity.selectBy; +import static org.assertj.core.api.Assertions.assertThat; + +class JpaSelectTests { + + @Nested + class FactoryMethodTests { + @Test + void givenSimpleSelect_whenOf_thenReturnJpaSimpleSelect() { + SimpleSelect simpleSelect = selectBy("id", "name"); + + JpaSelect jpaSelect = JpaSelect.of(simpleSelect); + + assertThat(jpaSelect).isInstanceOf(JpaSimpleSelect.class); + } + } + + @Nested + class JpaSimpleSelectTests { + @Test + void givenSimpleSelect_whenGetPropertyNames_thenReturnPropertyNames() { + SimpleSelect simpleSelect = selectBy("id", "name", "email"); + JpaSimpleSelect jpaSimpleSelect = new JpaSimpleSelect(simpleSelect); + + assertThat(jpaSimpleSelect.getPropertyNames()).containsExactly("id", "name", "email"); + } + + @Test + void givenSimpleSelectWithNestedProperty_whenGetPropertyNames_thenReturnNestedPropertyNames() { + SimpleSelect simpleSelect = selectBy("id", "address.city", "address.street"); + JpaSimpleSelect jpaSimpleSelect = new JpaSimpleSelect(simpleSelect); + + assertThat(jpaSimpleSelect.getPropertyNames()).containsExactly("id", "address.city", "address.street"); + } + } +} diff --git a/querity-parser/src/test/java/io/github/queritylib/querity/parser/QuerityParserTests.java b/querity-parser/src/test/java/io/github/queritylib/querity/parser/QuerityParserTests.java index f7fc4260..d902a9e0 100644 --- a/querity-parser/src/test/java/io/github/queritylib/querity/parser/QuerityParserTests.java +++ b/querity-parser/src/test/java/io/github/queritylib/querity/parser/QuerityParserTests.java @@ -63,7 +63,22 @@ public static Stream provideArguments() { Arguments.of("distinct orders.rows.quantity>10", Querity.query().distinct(true).filter(filterBy("orders.rows.quantity", GREATER_THAN, 10)).build()), Arguments.of("sort by lastName asc, age desc page 1,10", - Querity.query().sort(sortBy("lastName", ASC), sortBy("age", DESC)).pagination(1, 10).build()) + Querity.query().sort(sortBy("lastName", ASC), sortBy("age", DESC)).pagination(1, 10).build()), + // Select tests + Arguments.of("select id, firstName, lastName", + Querity.query().selectBy("id", "firstName", "lastName").build()), + Arguments.of("select id", + Querity.query().selectBy("id").build()), + Arguments.of("select id, name lastName=\"Skywalker\"", + Querity.query().selectBy("id", "name").filter(filterBy("lastName", "Skywalker")).build()), + Arguments.of("select id, name lastName=\"Skywalker\" sort by name asc", + Querity.query().selectBy("id", "name").filter(filterBy("lastName", "Skywalker")).sort(sortBy("name", ASC)).build()), + Arguments.of("select id, name lastName=\"Skywalker\" sort by name asc page 1,10", + Querity.query().selectBy("id", "name").filter(filterBy("lastName", "Skywalker")).sort(sortBy("name", ASC)).pagination(1, 10).build()), + Arguments.of("distinct select id, firstName age>20", + Querity.query().distinct(true).selectBy("id", "firstName").filter(filterBy("age", GREATER_THAN, 20)).build()), + Arguments.of("select address.city, address.street", + Querity.query().selectBy("address.city", "address.street").build()) ); } From 4115d5f6f4002894c5cdfea05fc3b3dadbd69249 Mon Sep 17 00:00:00 2001 From: Riccardo Vagelli Date: Fri, 5 Dec 2025 20:34:59 +0100 Subject: [PATCH 03/11] Add SelectDeserializer for consistent Jackson deserialization - Create SelectDeserializer following the same pattern as ConditionDeserializer - Register SelectDeserializer in QuerityModule - Change Query.select from SimpleSelect to Select interface - Add getPropertyNames() method to Select interface - Update JpaSelect, MongodbSelect, ElasticsearchSelect to accept Select interface - Update QueryVisitor to use Select interface This approach is more consistent with how Condition is handled and allows for proper polymorphic deserialization of Select implementations. --- .../querity/api/NativeSelectWrapper.java | 7 +++ .../github/queritylib/querity/api/Query.java | 6 +-- .../github/queritylib/querity/api/Select.java | 9 +++- .../queritylib/querity/jpa/JpaSelect.java | 11 ++-- .../querity/parser/QueryVisitor.java | 2 +- .../elasticsearch/ElasticsearchSelect.java | 8 ++- .../spring/data/mongodb/MongodbSelect.java | 8 ++- .../spring/web/jackson/QuerityModule.java | 4 ++ .../web/jackson/SelectDeserializer.java | 53 +++++++++++++++++++ 9 files changed, 96 insertions(+), 12 deletions(-) create mode 100644 querity-spring-web/src/main/java/io/github/queritylib/querity/spring/web/jackson/SelectDeserializer.java diff --git a/querity-api/src/main/java/io/github/queritylib/querity/api/NativeSelectWrapper.java b/querity-api/src/main/java/io/github/queritylib/querity/api/NativeSelectWrapper.java index b2c5322b..fb21eea8 100644 --- a/querity-api/src/main/java/io/github/queritylib/querity/api/NativeSelectWrapper.java +++ b/querity-api/src/main/java/io/github/queritylib/querity/api/NativeSelectWrapper.java @@ -3,6 +3,7 @@ import lombok.*; import lombok.extern.jackson.Jacksonized; +import java.util.Collections; import java.util.List; @Builder(toBuilder = true) @@ -14,4 +15,10 @@ public class NativeSelectWrapper implements Select { @NonNull @Singular private List nativeSelections; + + @Override + public List getPropertyNames() { + // Native selects don't have simple property names + return Collections.emptyList(); + } } diff --git a/querity-api/src/main/java/io/github/queritylib/querity/api/Query.java b/querity-api/src/main/java/io/github/queritylib/querity/api/Query.java index 8b272d31..3a66d13b 100644 --- a/querity-api/src/main/java/io/github/queritylib/querity/api/Query.java +++ b/querity-api/src/main/java/io/github/queritylib/querity/api/Query.java @@ -23,7 +23,7 @@ public class Query { @JsonIgnore private List preprocessors; private boolean distinct; - private final SimpleSelect select; + private final Select select; public boolean hasFilter() { return filter != null && !filter.isEmpty(); @@ -55,7 +55,7 @@ public static class QueryBuilder { @SuppressWarnings({"java:S1068", "java:S1450"}) private Sort[] sort = new Sort[0]; private List preprocessors = new ArrayList<>(); - private SimpleSelect select; + private Select select; public QueryBuilder withPreprocessor(QueryPreprocessor preprocessor) { this.preprocessors.add(preprocessor); @@ -77,7 +77,7 @@ public QueryBuilder pagination(Integer page, Integer pageSize) { return this; } - public QueryBuilder select(SimpleSelect select) { + public QueryBuilder select(Select select) { this.select = select; return this; } diff --git a/querity-api/src/main/java/io/github/queritylib/querity/api/Select.java b/querity-api/src/main/java/io/github/queritylib/querity/api/Select.java index e20b5639..fa675225 100644 --- a/querity-api/src/main/java/io/github/queritylib/querity/api/Select.java +++ b/querity-api/src/main/java/io/github/queritylib/querity/api/Select.java @@ -1,9 +1,16 @@ package io.github.queritylib.querity.api; +import java.util.List; + /** - * Marker interface for select options. + * Interface for select options. * Implementations include {@link SimpleSelect} for property-based selection * and {@link NativeSelectWrapper} for native selection. */ public interface Select { + /** + * Get the property names to select. + * @return list of property names + */ + List getPropertyNames(); } diff --git a/querity-jpa-common/src/main/java/io/github/queritylib/querity/jpa/JpaSelect.java b/querity-jpa-common/src/main/java/io/github/queritylib/querity/jpa/JpaSelect.java index e2e69bf2..ebc3c428 100644 --- a/querity-jpa-common/src/main/java/io/github/queritylib/querity/jpa/JpaSelect.java +++ b/querity-jpa-common/src/main/java/io/github/queritylib/querity/jpa/JpaSelect.java @@ -1,5 +1,7 @@ package io.github.queritylib.querity.jpa; +import io.github.queritylib.querity.api.Select; +import io.github.queritylib.querity.api.SimpleSelect; import jakarta.persistence.criteria.CriteriaQuery; import jakarta.persistence.criteria.Root; import jakarta.persistence.criteria.Selection; @@ -30,9 +32,12 @@ public interface JpaSelect { List getPropertyNames(); /** - * Create a JpaSelect from an API SimpleSelect. + * Create a JpaSelect from an API Select. */ - static JpaSelect of(io.github.queritylib.querity.api.SimpleSelect select) { - return new JpaSimpleSelect(select); + static JpaSelect of(Select select) { + if (select instanceof SimpleSelect simpleSelect) { + return new JpaSimpleSelect(simpleSelect); + } + throw new IllegalArgumentException("Unsupported select type: " + select.getClass().getSimpleName()); } } diff --git a/querity-parser/src/main/java/io/github/queritylib/querity/parser/QueryVisitor.java b/querity-parser/src/main/java/io/github/queritylib/querity/parser/QueryVisitor.java index 419831c6..8c91ec8d 100644 --- a/querity-parser/src/main/java/io/github/queritylib/querity/parser/QueryVisitor.java +++ b/querity-parser/src/main/java/io/github/queritylib/querity/parser/QueryVisitor.java @@ -10,7 +10,7 @@ class QueryVisitor extends QueryParserBaseVisitor { @Override public Query visitQuery(QueryParser.QueryContext ctx) { boolean distinct = ctx.DISTINCT() != null; - SimpleSelect select = ctx.selectClause() != null ? (SimpleSelect) visit(ctx.selectClause()) : null; + Select select = ctx.selectClause() != null ? (Select) visit(ctx.selectClause()) : null; Condition filter = ctx.condition() != null ? (Condition) visit(ctx.condition()) : null; Sort[] sorts = ctx.SORT() != null ? (Sort[]) visit(ctx.sortFields()) : new Sort[0]; Pagination pagination = ctx.PAGINATION() != null ? (Pagination) visit(ctx.paginationParams()) : null; diff --git a/querity-spring-data-elasticsearch/src/main/java/io/github/queritylib/querity/spring/data/elasticsearch/ElasticsearchSelect.java b/querity-spring-data-elasticsearch/src/main/java/io/github/queritylib/querity/spring/data/elasticsearch/ElasticsearchSelect.java index 5f783aa0..ac475c39 100644 --- a/querity-spring-data-elasticsearch/src/main/java/io/github/queritylib/querity/spring/data/elasticsearch/ElasticsearchSelect.java +++ b/querity-spring-data-elasticsearch/src/main/java/io/github/queritylib/querity/spring/data/elasticsearch/ElasticsearchSelect.java @@ -1,5 +1,6 @@ package io.github.queritylib.querity.spring.data.elasticsearch; +import io.github.queritylib.querity.api.Select; import io.github.queritylib.querity.api.SimpleSelect; import java.util.List; @@ -8,7 +9,10 @@ abstract class ElasticsearchSelect { public abstract List getFields(); - public static ElasticsearchSelect of(SimpleSelect select) { - return new ElasticsearchSimpleSelect(select); + public static ElasticsearchSelect of(Select select) { + if (select instanceof SimpleSelect simpleSelect) { + return new ElasticsearchSimpleSelect(simpleSelect); + } + throw new IllegalArgumentException("Unsupported select type: " + select.getClass().getSimpleName()); } } diff --git a/querity-spring-data-mongodb/src/main/java/io/github/queritylib/querity/spring/data/mongodb/MongodbSelect.java b/querity-spring-data-mongodb/src/main/java/io/github/queritylib/querity/spring/data/mongodb/MongodbSelect.java index 98f2a8f0..47d54dea 100644 --- a/querity-spring-data-mongodb/src/main/java/io/github/queritylib/querity/spring/data/mongodb/MongodbSelect.java +++ b/querity-spring-data-mongodb/src/main/java/io/github/queritylib/querity/spring/data/mongodb/MongodbSelect.java @@ -1,5 +1,6 @@ package io.github.queritylib.querity.spring.data.mongodb; +import io.github.queritylib.querity.api.Select; import io.github.queritylib.querity.api.SimpleSelect; import org.springframework.data.mongodb.core.query.Field; @@ -7,7 +8,10 @@ abstract class MongodbSelect { public abstract void applyProjection(Field field); - public static MongodbSelect of(SimpleSelect select) { - return new MongodbSimpleSelect(select); + public static MongodbSelect of(Select select) { + if (select instanceof SimpleSelect simpleSelect) { + return new MongodbSimpleSelect(simpleSelect); + } + throw new IllegalArgumentException("Unsupported select type: " + select.getClass().getSimpleName()); } } diff --git a/querity-spring-web/src/main/java/io/github/queritylib/querity/spring/web/jackson/QuerityModule.java b/querity-spring-web/src/main/java/io/github/queritylib/querity/spring/web/jackson/QuerityModule.java index 92900481..b58d4a45 100644 --- a/querity-spring-web/src/main/java/io/github/queritylib/querity/spring/web/jackson/QuerityModule.java +++ b/querity-spring-web/src/main/java/io/github/queritylib/querity/spring/web/jackson/QuerityModule.java @@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.*; import com.fasterxml.jackson.databind.deser.Deserializers; import io.github.queritylib.querity.api.Condition; +import io.github.queritylib.querity.api.Select; public class QuerityModule extends com.fasterxml.jackson.databind.Module { @Override @@ -25,6 +26,9 @@ public JsonDeserializer findBeanDeserializer(JavaType type, DeserializationCo if (Condition.class.isAssignableFrom(raw)) { return new ConditionDeserializer(type); } + if (Select.class.isAssignableFrom(raw)) { + return new SelectDeserializer(type); + } return super.findBeanDeserializer(type, config, beanDesc); } }); diff --git a/querity-spring-web/src/main/java/io/github/queritylib/querity/spring/web/jackson/SelectDeserializer.java b/querity-spring-web/src/main/java/io/github/queritylib/querity/spring/web/jackson/SelectDeserializer.java new file mode 100644 index 00000000..187c80b9 --- /dev/null +++ b/querity-spring-web/src/main/java/io/github/queritylib/querity/spring/web/jackson/SelectDeserializer.java @@ -0,0 +1,53 @@ +package io.github.queritylib.querity.spring.web.jackson; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JavaType; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.deser.std.StdDeserializer; +import io.github.queritylib.querity.api.Select; +import io.github.queritylib.querity.api.SimpleSelect; + +import java.io.IOException; +import java.util.List; +import java.util.Spliterator; +import java.util.Spliterators; +import java.util.stream.StreamSupport; + +public class SelectDeserializer extends StdDeserializer