Skip to content

Commit 63b4a04

Browse files
authored
Add TypeWrapperFactoryExpression for Type-Safe Custom Number Mapping in Querydsl Aggregations (#1181)
* Introduce TypeWrapper to wrap Expression results into custom types - Implement TypeWrapper<S, T> as a FactoryExpression - Allows converting a source Expression (e.g. BigDecimal) to a domain type (e.g. Money) - Prepares QueryDSL core to support custom aggregation projections without core API changes * Add JPAQueryCustomTypeWrapperTest covering success and failure scenarios - Verify IllegalArgumentException for unsupported custom types without wrapper - Verify sum-and-wrap to Money via TypeWrapper in both direct and DTO projection use cases - Ensure both positive and negative paths are exercised
1 parent 7255092 commit 63b4a04

File tree

6 files changed

+371
-0
lines changed

6 files changed

+371
-0
lines changed
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
package com.querydsl.core.types.dsl;
2+
3+
import com.querydsl.core.types.Expression;
4+
import com.querydsl.core.types.FactoryExpression;
5+
import com.querydsl.core.types.Visitor;
6+
import java.util.List;
7+
import java.util.function.Function;
8+
9+
/**
10+
* A {@link FactoryExpression} implementation that wraps a single source {@link Expression} and
11+
* converts its result into a custom target type.
12+
*
13+
* <p>Typical usage: convert a {@link java.math.BigDecimal} sum result from a JPA query into a
14+
* domain-specific value object such as {@code Money}.
15+
*
16+
* @param <S> the source expression type (e.g. {@link java.math.BigDecimal})
17+
* @param <T> the target type to convert to (e.g. a custom {@code Money} class)
18+
* @author chadongmin
19+
* @see com.querydsl.core.types.FactoryExpression
20+
* @since 6.11
21+
*/
22+
public class TypeWrapper<S, T> implements FactoryExpression<T> {
23+
private final Class<T> valueClass;
24+
private final Function<S, T> factory;
25+
private final List<Expression<?>> args;
26+
27+
/**
28+
* Create a new TypeWrapper.
29+
*
30+
* @param arg the source expression whose value will be converted
31+
* @param valueClass the target type class
32+
* @param factory a function that maps the source value to the target type
33+
*/
34+
public TypeWrapper(Expression<S> arg, Class<T> valueClass, Function<S, T> factory) {
35+
this.valueClass = valueClass;
36+
this.factory = factory;
37+
this.args = List.of(arg);
38+
}
39+
40+
@Override
41+
public <R, C> R accept(Visitor<R, C> v, C context) {
42+
return v.visit(this, context);
43+
}
44+
45+
@Override
46+
public Class<? extends T> getType() {
47+
return valueClass;
48+
}
49+
50+
@Override
51+
public List<Expression<?>> getArgs() {
52+
return args;
53+
}
54+
55+
@Override
56+
public T newInstance(Object... args) {
57+
@SuppressWarnings("unchecked")
58+
S arg = (S) args[0];
59+
return factory.apply(arg);
60+
}
61+
}
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
package com.querydsl.jpa;
2+
3+
import static org.assertj.core.api.Assertions.assertThat;
4+
import static org.assertj.core.api.Assertions.assertThatThrownBy;
5+
6+
import com.querydsl.core.types.dsl.NumberExpression;
7+
import com.querydsl.core.types.dsl.TypeWrapper;
8+
import com.querydsl.jpa.domain.*;
9+
import com.querydsl.jpa.impl.JPAQuery;
10+
import com.querydsl.jpa.testutil.JPATestRunner;
11+
import jakarta.persistence.EntityManager;
12+
import java.math.BigDecimal;
13+
import java.util.List;
14+
import org.junit.Before;
15+
import org.junit.Test;
16+
import org.junit.runner.RunWith;
17+
18+
@RunWith(JPATestRunner.class)
19+
public class JPAQueryCustomTypeWrapperTest implements JPATest {
20+
21+
private EntityManager em;
22+
23+
@Override
24+
public void setEntityManager(EntityManager em) {
25+
this.em = em;
26+
}
27+
28+
@Before
29+
public void setupData() {
30+
em.createQuery("delete from Invoice").executeUpdate();
31+
em.persist(new Invoice("00000000-0000-0000-0000-000000000001", "A", new Money("111")));
32+
em.persist(new Invoice("00000000-0000-0000-0000-000000000002", "A", new Money("222")));
33+
em.persist(new Invoice("00000000-0000-0000-0000-000000000003", "B", new Money("333")));
34+
em.flush();
35+
em.clear();
36+
}
37+
38+
@Test
39+
public void sumWithoutWrapper_shouldThrowUnsupportedTargetTypeException() {
40+
// Expect IllegalArgumentException due to unsupported target type Money
41+
assertThatThrownBy(
42+
() ->
43+
new JPAQuery<>(em)
44+
.select(QInvoice.invoice.amount.sumAggregate())
45+
.from(QInvoice.invoice)
46+
.fetchOne())
47+
.isInstanceOf(IllegalArgumentException.class)
48+
.hasMessageContaining("Unsupported target type : Money");
49+
}
50+
51+
@Test
52+
public void projectionWithoutWrapper_shouldThrowException() {
53+
// Expect IllegalArgumentException due to unsupported target type in projection
54+
assertThatThrownBy(
55+
() ->
56+
new JPAQuery<>(em)
57+
.select(
58+
new QInvoiceSummary(
59+
QInvoice.invoice.category, QInvoice.invoice.amount.sumAggregate()))
60+
.from(QInvoice.invoice)
61+
.groupBy(QInvoice.invoice.category)
62+
.fetch())
63+
.isInstanceOf(IllegalArgumentException.class)
64+
.hasMessageContaining("Unsupported target type");
65+
}
66+
67+
@Test
68+
public void projectionWithTypeWrapper_shouldReturnInvoiceSummary() {
69+
// When: Use TypeWrapper to convert BigDecimal to Money in projection
70+
List<InvoiceSummary> results =
71+
new JPAQuery<>(em)
72+
.select(
73+
new QInvoiceSummary(
74+
QInvoice.invoice.category,
75+
new TypeWrapper<>(
76+
QInvoice.invoice.amount.sumAggregate().castToNum(BigDecimal.class),
77+
Money.class,
78+
Money::new)))
79+
.from(QInvoice.invoice)
80+
.groupBy(QInvoice.invoice.category)
81+
.fetch();
82+
83+
// Then: The results should match the expected InvoiceSummary values
84+
assertThat(results)
85+
.containsExactlyInAnyOrder(
86+
new InvoiceSummary("A", new Money("333.00")), // 111 + 222
87+
new InvoiceSummary("B", new Money("333.00")) // 333
88+
);
89+
}
90+
91+
@Test
92+
public void sumWithTypeWrapper_shouldWrapToMoney() {
93+
// Given: persist some Invoice instances ...
94+
95+
// Create a sum expression and cast to BigDecimal
96+
NumberExpression<BigDecimal> sumExpr =
97+
QInvoice.invoice.amount.sumAggregate().castToNum(BigDecimal.class);
98+
99+
// Use TypeWrapper to convert the sum result to Money
100+
Money result =
101+
new JPAQuery<>(em)
102+
.select(new TypeWrapper<>(sumExpr, Money.class, Money::new))
103+
.from(QInvoice.invoice)
104+
.fetchOne();
105+
106+
assertThat(result).isEqualTo(new Money("666.00")); // 111 + 222 + 333 = 666
107+
108+
em.clear();
109+
}
110+
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
package com.querydsl.jpa.domain;
2+
3+
import jakarta.persistence.Column;
4+
import jakarta.persistence.Convert;
5+
import jakarta.persistence.Entity;
6+
import jakarta.persistence.Id;
7+
import java.util.Comparator;
8+
import java.util.UUID;
9+
10+
@Entity
11+
public class Invoice {
12+
13+
@Id private UUID id;
14+
15+
@Column(nullable = false)
16+
private String category;
17+
18+
@Column(nullable = false, precision = 30, scale = 10)
19+
@Convert(converter = MoneyConverter.class)
20+
private Money amount;
21+
22+
public Invoice() {
23+
// JPA 스펙용
24+
}
25+
26+
public Invoice(String uuid, String category, Money amount) {
27+
this.id = UUID.fromString(uuid);
28+
this.category = category;
29+
this.amount = amount;
30+
}
31+
32+
public static Comparator<Invoice> comparator() {
33+
return Comparator.comparing(Invoice::getId)
34+
.thenComparing(Invoice::getCategory)
35+
.thenComparing(Invoice::getAmount);
36+
}
37+
38+
@Override
39+
public String toString() {
40+
return "Invoice{" + "id=" + id + ", category='" + category + '\'' + ", amount=" + amount + '}';
41+
}
42+
43+
public UUID getId() {
44+
return id;
45+
}
46+
47+
public void setId(UUID id) {
48+
this.id = id;
49+
}
50+
51+
public String getCategory() {
52+
return category;
53+
}
54+
55+
public void setCategory(String category) {
56+
this.category = category;
57+
}
58+
59+
public Money getAmount() {
60+
return amount;
61+
}
62+
63+
public void setAmount(Money amount) {
64+
this.amount = amount;
65+
}
66+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package com.querydsl.jpa.domain;
2+
3+
import com.querydsl.core.annotations.QueryProjection;
4+
import java.util.Objects;
5+
6+
public class InvoiceSummary {
7+
8+
private final String category;
9+
private final Money totalAmount;
10+
11+
@QueryProjection
12+
public InvoiceSummary(String category, Money totalAmount) {
13+
this.category = category;
14+
this.totalAmount = totalAmount;
15+
}
16+
17+
public String getCategory() {
18+
return category;
19+
}
20+
21+
public Money getTotalAmount() {
22+
return totalAmount;
23+
}
24+
25+
@Override
26+
public boolean equals(Object o) {
27+
if (this == o) return true;
28+
if (!(o instanceof InvoiceSummary)) return false;
29+
InvoiceSummary that = (InvoiceSummary) o;
30+
return Objects.equals(category, that.category) && Objects.equals(totalAmount, that.totalAmount);
31+
}
32+
33+
@Override
34+
public int hashCode() {
35+
return Objects.hash(category, totalAmount);
36+
}
37+
38+
@Override
39+
public String toString() {
40+
return "com.querydsl.jpa.domain.InvoiceSummary{"
41+
+ "category='"
42+
+ category
43+
+ '\''
44+
+ ", totalAmount="
45+
+ totalAmount
46+
+ '}';
47+
}
48+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
package com.querydsl.jpa.domain;
2+
3+
import java.io.Serial;
4+
import java.math.BigDecimal;
5+
import java.util.Objects;
6+
7+
public class Money extends Number implements Comparable<Money> {
8+
9+
@Serial private static final long serialVersionUID = 1L;
10+
11+
private final BigDecimal value;
12+
13+
public Money(BigDecimal value) {
14+
this.value = value;
15+
}
16+
17+
public Money(String value) {
18+
this.value = new BigDecimal(value);
19+
}
20+
21+
public BigDecimal getValue() {
22+
return value;
23+
}
24+
25+
@Override
26+
public String toString() {
27+
return value.toPlainString();
28+
}
29+
30+
@Override
31+
public boolean equals(Object o) {
32+
if (this == o) return true;
33+
if (!(o instanceof Money)) return false;
34+
Money that = (Money) o;
35+
return Objects.equals(value, that.value);
36+
}
37+
38+
@Override
39+
public int hashCode() {
40+
return Objects.hash(value);
41+
}
42+
43+
@Override
44+
public long longValue() {
45+
return value.longValue();
46+
}
47+
48+
@Override
49+
public int intValue() {
50+
return value.intValue();
51+
}
52+
53+
@Override
54+
public float floatValue() {
55+
return value.floatValue();
56+
}
57+
58+
@Override
59+
public double doubleValue() {
60+
return value.doubleValue();
61+
}
62+
63+
@Override
64+
public int compareTo(Money o) {
65+
return this.value.compareTo(o.value);
66+
}
67+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package com.querydsl.jpa.domain;
2+
3+
import jakarta.persistence.AttributeConverter;
4+
import jakarta.persistence.Converter;
5+
import java.math.BigDecimal;
6+
7+
@Converter(autoApply = true)
8+
public class MoneyConverter implements AttributeConverter<Money, BigDecimal> {
9+
10+
@Override
11+
public BigDecimal convertToDatabaseColumn(Money attribute) {
12+
return attribute != null ? attribute.getValue() : null;
13+
}
14+
15+
@Override
16+
public Money convertToEntityAttribute(BigDecimal dbData) {
17+
return dbData != null ? new Money(dbData) : null;
18+
}
19+
}

0 commit comments

Comments
 (0)