Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

docs: add guide to using value classes #707

Merged
merged 3 commits into from
Jun 4, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,6 @@ out/
### NPM ###
package-lock.json
/node_modules/

### OS generated files ###
.DS_Store
141 changes: 141 additions & 0 deletions docs/ko/jpql-with-kotlin-jdsl/value-class-informal-guidance..md
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
## Value Class

엔티티의 프로퍼티를 kotlin의 [`value class`](https://kotlinlang.org/docs/inline-classes.html)로 선언할 수 있습니다.

```kotlin
@Entity
class User(
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
val id: UserId = UserId(0),
)

@JvmInline
value class UserId(private val value: Long)

```
erie0210 marked this conversation as resolved.
Show resolved Hide resolved

hibernate를 사용해 Kotlin JDSL을 통해 조회 시 에러가 발생합니다.
erie0210 marked this conversation as resolved.
Show resolved Hide resolved

```kotlin
@Service
class UserService(
private val jpqlRenderContext: JpqlRenderContext,
private val entityManager: EntityManager,
) {

fun findById(userId: UserId): User? {
val query = jpql {
select(
entity(User::class)
).from(
entity(User::class),
).where(
path(User::id).equal(userId) // 에러 발생 지점
)
}

return entityManager.createQuery(query, jpqlRenderContext).apply { maxResults = 1 }.resultList.firstOrNull()
}
}
```
erie0210 marked this conversation as resolved.
Show resolved Hide resolved

```
org.hibernate.type.descriptor.java.CoercionException: Cannot coerce value 'UserId(value=1)' [com.example.entity.UserId] to Long
...
```

이를 해결하려면 Kotlin JDSL이 매개 변수로 전달되는 `value class`의 unboxing이 필요합니다.
unboxing은 다음 방안 중 하나를 선택해서 수행할 수 있습니다.

### JpqlValue용 커스텀 JpqlSerializer

에러를 해결하기 위해 `EntityManager`에 인자들을 `value class` 그 자체로 넘기지 않고 unboxing한 값을 넘겨야합니다.
Kotlin JDSL은 `JpqlValueSerializer` 클래스에서 인자들을 추출하는 역할을 담당합니다.
따라서 기본 제공하는 클래스 대신 커스텀 Seriailzer를 등록해야 합니다.

먼저 다음과 같은 커스텀 Seriailzer를 생성합니다.

```kotlin
class CustomJpqlValueSerializer : JpqlSerializer<JpqlValue<*>> {
erie0210 marked this conversation as resolved.
Show resolved Hide resolved
override fun handledType(): KClass<JpqlValue<*>> {
return JpqlValue::class
}

override fun serialize(
part: JpqlValue<*>,
writer: JpqlWriter,
context: RenderContext,
) {
val value = part.value

// value class이면 relfection을 사용해 내부 값을 꺼내서 전달
if (value::class.isValue) {
val property = value::class.memberProperties.first()
val propertyValue = property.getter.call(value)

writer.writeParam(propertyValue)
return
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

value class가 java로 컴파일될 때 boxing과 unboxing 메소드가 생성되어서 이걸 호출해야 한다고 생각했는데, property를 통해서 접근하면 되는 거군요.

image

그런데 혹시 조회의 경우는 Spring에서 boxing 해서 반환해주나요?

select(
  path(User::id)
)
/// ...

위 Projection의 결과로 UseId를 얻을 수 있나 궁금해서요.

만약 된다면 Spring에서 구현한 코드를 통해서 가능한 것 같은데, 그렇다면 value class 지원은 Spring 기반에서만 동작하기 때문에 문서 최상단에 Spring을 사용하실 경우와 같이 한정자가 들어가야 할 것 같아서요.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

조회 시에 boxing 해서 반환하네요. 다만 boxing 하는 주체가 Spring인지 Hibernate인지는 추가적으로 확인해보겠습니다!

Copy link
Contributor Author

@erie0210 erie0210 May 26, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

java로 트랜스파일 한 결과에서는 long으로 변환되기에 jdsl의 findAll 호출 결과에는 long으로 담기는걸 확인했습니다.

data class RecipeDto(
   val id: RecipeId // value class
) 
public final class RecipeDto {
    private final long id;
    
    public final long getId_RwJKGeI/* $FF was: getId-RwJKGeI*/() {
      return this.id;
    }
}  

boxing이 되는 위치는 코틀린 코드에서 해당 필드를 참조하는 곳이고, Java로 트랜스파일 시 boxing하는 코드를 코틀린이 넣어주는거 같아요.

data class RecipeDto(
   val id: RecipeId // value class
) {
    fun temp() {
        println(id)
    }
}
public final void temp() {
  RecipeId var1 = RecipeId.box-impl(this.id);
  System.out.println(var1);
}

만약 value class를 nullable로 선언하면 long 타입 대신 RecipeId가 그대로 유지되고,
이 경우 Hibernate에서 에러가 발생합니다.

data class RecipeDto(
   val id: RecipeId? // value class
) 

다만 value class가 nullable인 경우는 value class 를 사용하는 의미가 없어지는 것 같아 해당 케이스는 고려 대상이 아닌 것 같습니다.

Copy link
Contributor Author

@erie0210 erie0210 May 26, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

더불어 답변 주신 내용에서 spring 이라고 말씀해주신 이유가 어떤 내용인지도 조금 더 설명해주실 수 있을까요?

만약 된다면 Spring에서 구현한 코드를 통해서 가능한 것 같은데, 그렇다면 value class 지원은 Spring 기반에서만 동작하기 때문에

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@erie0210 안녕하세요! 상세하게 확인해주셔서 감사합니다!

먼저 Spring을 언급한 이유는 Spring JPA에서 value class를 지원하게 되면서 Kotlin JDSL도 Spring과 같이 지원이 가능할지 고민하게 되었기 때문입니다.
image


다음으로 nullable한 value class도 충분히 사용하는 의미가 있다고 생각합니다. value class는 기본 자료형에 추가적인 validation이나 메소드들을 추가하기 위한 것으로 사용할 수 있기 때문입니다. BookPrice라는 value class가 있고 Book에 supplyPrice라는 필드가 있는데, 이 supplyPrice 필드가 nullable일 수 있다고 생각해요.


개인적으로는 nullable한 value class 또한 지원되어야 완벽한 지원이라 생각이 들기 떄문에, DTO Projection의 경우는 value class를 완벽하게 지원하지 않는 것으로 확인된 것 같습니다.

가이드 문서에 DTO Projection의 경우는 완벽한 지원이 안 되기 때문에 직접 value class를 사용하는 것보다, DTO Projection에서는 기본 자료형을 사용하고 조회 후에 map 등으로 변환을 추천한다는 내용을 추가해주실 수 있으신가요?


마지막으로 가이드 문서를 너무 잘 작성해주셔서 여러가지 부탁드리게 되네요. 도움을 주셔서 너무 감사합니다.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

말씀해주신 내용 반영했습니다.

map으로 변환하는 경우 class를 두 개 선언하는 불편함이 있을 것 같아
DTO class에서 value class로 변환하도록 예시 코드를 작성해봤는데 어떨까요? @shouwn

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@erie0210 오 좋다고 생각합니다!

}

if (value is KClass<*>) {
val introspector = context.getValue(JpqlRenderIntrospector)
val entity = introspector.introspect(value)

writer.write(entity.name)
} else {
writer.writeParam(part.value)
}
erie0210 marked this conversation as resolved.
Show resolved Hide resolved
}
}
```

이제 이 클래스를 `RenderContext`에 추가해야 합니다.
추가하는 방법은 [다음 문서](custom-dsl.md#serializer)를 참조할 수 있습니다.
만약 스프링 부트를 사용하는 경우 다음과 같은 코드를 통해 커스텀 Seriziler를 Bean으로 등록하면 됩니다.

```kotlin
@Configuration
class CustomJpqlRenderContextConfig {
@Bean
fun jpqlSerializer(): JpqlSerializer<*> {
return CustomJpqlValueSerializer()
}
}
```

### custom method 사용

JDSL에서 제공하는 [custom dsl](custom-dsl.md#dsl) 사용해 value class 에 사용되는 매서드를 추가할 수 있습니다.

```kotlin
class JDSLConfig : Jpql() {
erie0210 marked this conversation as resolved.
Show resolved Hide resolved
fun Expressionable<UserId>.equalValue(value: UserId): Predicate {
return Predicates.equal(this.toExpression(), Expressions.value(value.value))
}
}

val query = jpql(JDSLConfig) {
select(
entity(User::class)
).from(
entity(User::class),
).where(
path(User::id).equalValue(userId)
)
}
```

interface 도입과 오버로딩을 통해 다양한 value class에 대응할 수 있습니다.

```kotlin
interface PrimaryLongId { val value: Long }

value class UserId(override val value: Long) : PrimaryLongId

class JDSLConfig : Jpql() {
fun <T: PrimaryLongId> Expressionable<T>.equal(value: T): Predicate {
return Predicates.equal(this.toExpression(), Expressions.value(value.value))
}
}
```
Loading