diff --git a/app-server/build.gradle.kts b/app-server/build.gradle.kts index 4ba2ff46b..e6c70f702 100644 --- a/app-server/build.gradle.kts +++ b/app-server/build.gradle.kts @@ -4,6 +4,8 @@ import org.springframework.boot.gradle.tasks.bundling.BootJar plugins { kotlin("jvm") + kotlin("plugin.spring") + kotlin("plugin.jpa") id("org.springframework.boot") id("io.gitlab.arturbosch.detekt") id("io.spring.dependency-management") @@ -27,6 +29,8 @@ val detektExcludedProjects = listOf( ) subprojects { apply(plugin = "kotlin") + apply(plugin = "kotlin-spring") + apply(plugin = "kotlin-jpa") java { sourceCompatibility = JavaVersion.VERSION_17 diff --git a/app-server/settings.gradle.kts b/app-server/settings.gradle.kts index e490579e4..324925466 100644 --- a/app-server/settings.gradle.kts +++ b/app-server/settings.gradle.kts @@ -31,6 +31,8 @@ pluginManagement { plugins { kotlin("jvm") version kotlinVersion kotlin("plugin.serialization") version kotlinVersion + kotlin("plugin.spring") version kotlinVersion apply false + kotlin("plugin.jpa") version kotlinVersion apply false id("org.jetbrains.kotlin.plugin.spring") version kotlinVersion id("org.springframework.boot") version springBootVersion diff --git a/app-server/subprojects/bounded_context/user/domain/build.gradle.kts b/app-server/subprojects/bounded_context/user/domain/build.gradle.kts index e69de29bb..7a59cd129 100644 --- a/app-server/subprojects/bounded_context/user/domain/build.gradle.kts +++ b/app-server/subprojects/bounded_context/user/domain/build.gradle.kts @@ -0,0 +1,3 @@ +dependencies { + implementation("jakarta.persistence:jakarta.persistence-api") +} diff --git a/app-server/subprojects/bounded_context/user/domain/src/main/kotlin/club/staircrusher/user/domain/model/User.kt b/app-server/subprojects/bounded_context/user/domain/src/main/kotlin/club/staircrusher/user/domain/model/User.kt index d42663463..6bf1d9d5c 100644 --- a/app-server/subprojects/bounded_context/user/domain/src/main/kotlin/club/staircrusher/user/domain/model/User.kt +++ b/app-server/subprojects/bounded_context/user/domain/src/main/kotlin/club/staircrusher/user/domain/model/User.kt @@ -1,22 +1,51 @@ package club.staircrusher.user.domain.model +import jakarta.persistence.Column +import jakarta.persistence.Convert +import jakarta.persistence.Entity +import jakarta.persistence.Id +import jakarta.persistence.Table +import jakarta.persistence.Transient import java.time.Instant -data class User( +@Entity +@Table(name = "scc_user") +class User( + @Id val id: String, var nickname: String, @Deprecated("닉네임 로그인은 사라질 예정") var encryptedPassword: String?, var instagramId: String?, var email: String?, // FIXME: 레거시 계정이 모두 사라지면 non-nullable로 변경 + @Column(columnDefinition = "TEXT") + @Convert(converter = UserMobilityToolListToTextAttributeConverter::class) val mobilityTools: MutableList, val createdAt: Instant, ) { var deletedAt: Instant? = null // private으로 둘 방법이 없을까? 지금은 persistence_model 모듈에서 써야 해서 안 된다. + @get:Transient val isDeleted: Boolean get() = deletedAt != null fun delete(deletedAt: Instant) { this.deletedAt = deletedAt } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as User + + return id == other.id + } + + override fun hashCode(): Int { + return id.hashCode() + } + + override fun toString(): String { + return "User(nickname='$nickname', id='$id', encryptedPassword=$encryptedPassword, instagramId=$instagramId, email=$email, mobilityTools=$mobilityTools, createdAt=$createdAt, deletedAt=$deletedAt, isDeleted=$isDeleted)" + } } diff --git a/app-server/subprojects/bounded_context/user/domain/src/main/kotlin/club/staircrusher/user/domain/model/UserAuthInfo.kt b/app-server/subprojects/bounded_context/user/domain/src/main/kotlin/club/staircrusher/user/domain/model/UserAuthInfo.kt index 55d6abcc3..ecd9f3ecf 100644 --- a/app-server/subprojects/bounded_context/user/domain/src/main/kotlin/club/staircrusher/user/domain/model/UserAuthInfo.kt +++ b/app-server/subprojects/bounded_context/user/domain/src/main/kotlin/club/staircrusher/user/domain/model/UserAuthInfo.kt @@ -1,18 +1,24 @@ package club.staircrusher.user.domain.model import club.staircrusher.stdlib.clock.SccClock +import jakarta.persistence.Entity +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated +import jakarta.persistence.Id import java.time.Instant +@Entity class UserAuthInfo( + @Id val id: String, val userId: String, + @Enumerated(EnumType.STRING) val authProviderType: UserAuthProviderType, val externalId: String, var externalRefreshToken: String, var externalRefreshTokenExpiresAt: Instant, val createdAt: Instant = SccClock.instant() ) { - override fun equals(other: Any?): Boolean { if (this === other) return true if (javaClass != other?.javaClass) return false diff --git a/app-server/subprojects/bounded_context/user/domain/src/main/kotlin/club/staircrusher/user/domain/model/UserMobilityToolListToTextAttributeConverter.kt b/app-server/subprojects/bounded_context/user/domain/src/main/kotlin/club/staircrusher/user/domain/model/UserMobilityToolListToTextAttributeConverter.kt new file mode 100644 index 000000000..70a801446 --- /dev/null +++ b/app-server/subprojects/bounded_context/user/domain/src/main/kotlin/club/staircrusher/user/domain/model/UserMobilityToolListToTextAttributeConverter.kt @@ -0,0 +1,11 @@ +package club.staircrusher.user.domain.model + +import club.staircrusher.stdlib.jpa.ListToTextAttributeConverter +import jakarta.persistence.Converter + +@Converter +object UserMobilityToolListToTextAttributeConverter : ListToTextAttributeConverter() { + override fun convertElementFromTextColumn(text: String): UserMobilityTool { + return UserMobilityTool.valueOf(text) + } +} diff --git a/app-server/subprojects/bounded_context/user/infra/build.gradle.kts b/app-server/subprojects/bounded_context/user/infra/build.gradle.kts index c9606121d..ec81ad62a 100644 --- a/app-server/subprojects/bounded_context/user/infra/build.gradle.kts +++ b/app-server/subprojects/bounded_context/user/infra/build.gradle.kts @@ -2,6 +2,7 @@ plugins { id("org.springframework.boot") id("io.spring.dependency-management") kotlin("plugin.serialization") + kotlin("plugin.spring") } dependencies { @@ -10,6 +11,8 @@ dependencies { api(projects.apiSpecification.api) implementation(projects.crossCuttingConcern.infra.persistenceModel) implementation("org.springframework.boot:spring-boot-starter-web") + implementation("org.springframework.boot:spring-boot-starter-data-jpa") + implementation("org.springframework.boot:spring-boot-starter-jdbc") implementation("org.springframework:spring-webflux") implementation("io.projectreactor.netty:reactor-netty") diff --git a/app-server/subprojects/bounded_context/user/infra/src/main/kotlin/club/staircrusher/user/infra/adapter/out/persistence/Converters.kt b/app-server/subprojects/bounded_context/user/infra/src/main/kotlin/club/staircrusher/user/infra/adapter/out/persistence/Converters.kt deleted file mode 100644 index 72de8ed57..000000000 --- a/app-server/subprojects/bounded_context/user/infra/src/main/kotlin/club/staircrusher/user/infra/adapter/out/persistence/Converters.kt +++ /dev/null @@ -1,50 +0,0 @@ -package club.staircrusher.user.infra.adapter.out.persistence - -import club.staircrusher.infra.persistence.sqldelight.migration.Scc_user -import club.staircrusher.infra.persistence.sqldelight.migration.User_auth_info -import club.staircrusher.stdlib.time.toOffsetDateTime -import club.staircrusher.user.domain.model.UserAuthInfo - -fun Scc_user.toDomainModel() = club.staircrusher.user.domain.model.User( - id = id, - nickname = nickname, - encryptedPassword = encrypted_password.takeIf { it.isNotBlank() }, - instagramId = instagram_id, - createdAt = created_at.toInstant(), - email = email, - mobilityTools = mobility_tools?.toMutableList() ?: mutableListOf() -).apply { - deletedAt = deleted_at?.toInstant() -} - -fun club.staircrusher.user.domain.model.User.toPersistenceModel() = Scc_user( - id = id, - nickname = nickname, - encrypted_password = encryptedPassword ?: "", // SqlDelight 버그로 인해 DROP NOT NULL DDL을 제대로 인식하지 못한다. 어차피 encryptedPassword 필드는 곧 삭제될 것이므로, 그냥 empty string을 넣어준다. - instagram_id = instagramId, - email = email, - mobility_tools = mobilityTools, - created_at = createdAt.toOffsetDateTime(), - updated_at = createdAt.toOffsetDateTime(), - deleted_at = deletedAt?.toOffsetDateTime(), -) - -fun User_auth_info.toDomainModel() = UserAuthInfo( - id = id, - userId = user_id, - authProviderType = auth_provider_type, - externalId = external_id, - externalRefreshToken = external_refresh_token, - externalRefreshTokenExpiresAt = external_refresh_token_expires_at.toInstant(), - createdAt = created_at.toInstant(), -) - -fun UserAuthInfo.toPersistenceModel() = User_auth_info( - id = id, - user_id = userId, - auth_provider_type = authProviderType, - external_id = externalId, - external_refresh_token = externalRefreshToken, - external_refresh_token_expires_at = externalRefreshTokenExpiresAt.toOffsetDateTime(), - created_at = createdAt.toOffsetDateTime(), -) diff --git a/app-server/subprojects/bounded_context/user/infra/src/main/kotlin/club/staircrusher/user/infra/adapter/out/persistence/JpaUserAuthInfoRepository.kt b/app-server/subprojects/bounded_context/user/infra/src/main/kotlin/club/staircrusher/user/infra/adapter/out/persistence/JpaUserAuthInfoRepository.kt new file mode 100644 index 000000000..9ee5f77ab --- /dev/null +++ b/app-server/subprojects/bounded_context/user/infra/src/main/kotlin/club/staircrusher/user/infra/adapter/out/persistence/JpaUserAuthInfoRepository.kt @@ -0,0 +1,13 @@ +package club.staircrusher.user.infra.adapter.out.persistence + +import club.staircrusher.user.domain.model.UserAuthInfo +import club.staircrusher.user.domain.model.UserAuthProviderType +import org.springframework.data.repository.CrudRepository +import org.springframework.stereotype.Repository + +@Repository +interface JpaUserAuthInfoRepository : CrudRepository { + fun findFirstByAuthProviderTypeAndExternalId(authProviderType: UserAuthProviderType, externalId: String): UserAuthInfo? + fun findByUserId(userId: String): List + fun removeByUserId(userId: String) +} diff --git a/app-server/subprojects/bounded_context/user/infra/src/main/kotlin/club/staircrusher/user/infra/adapter/out/persistence/JpaUserRepository.kt b/app-server/subprojects/bounded_context/user/infra/src/main/kotlin/club/staircrusher/user/infra/adapter/out/persistence/JpaUserRepository.kt new file mode 100644 index 000000000..9122679d8 --- /dev/null +++ b/app-server/subprojects/bounded_context/user/infra/src/main/kotlin/club/staircrusher/user/infra/adapter/out/persistence/JpaUserRepository.kt @@ -0,0 +1,11 @@ +package club.staircrusher.user.infra.adapter.out.persistence + +import club.staircrusher.user.domain.model.User +import org.springframework.data.repository.CrudRepository +import org.springframework.stereotype.Repository + +@Repository +interface JpaUserRepository : CrudRepository { + fun findFirstByNickname(nickname: String): User? + fun findFirstByEmail(email: String): User? +} diff --git a/app-server/subprojects/bounded_context/user/infra/src/main/kotlin/club/staircrusher/user/infra/adapter/out/persistence/UserAuthInfoRepository.kt b/app-server/subprojects/bounded_context/user/infra/src/main/kotlin/club/staircrusher/user/infra/adapter/out/persistence/UserAuthInfoRepositoryImplWithJpa.kt similarity index 53% rename from app-server/subprojects/bounded_context/user/infra/src/main/kotlin/club/staircrusher/user/infra/adapter/out/persistence/UserAuthInfoRepository.kt rename to app-server/subprojects/bounded_context/user/infra/src/main/kotlin/club/staircrusher/user/infra/adapter/out/persistence/UserAuthInfoRepositoryImplWithJpa.kt index 7a0a46e34..12fe50283 100644 --- a/app-server/subprojects/bounded_context/user/infra/src/main/kotlin/club/staircrusher/user/infra/adapter/out/persistence/UserAuthInfoRepository.kt +++ b/app-server/subprojects/bounded_context/user/infra/src/main/kotlin/club/staircrusher/user/infra/adapter/out/persistence/UserAuthInfoRepositoryImplWithJpa.kt @@ -1,51 +1,43 @@ package club.staircrusher.user.infra.adapter.out.persistence -import club.staircrusher.infra.persistence.sqldelight.DB -import club.staircrusher.user.domain.model.UserAuthInfo import club.staircrusher.stdlib.di.annotation.Component +import club.staircrusher.user.domain.model.UserAuthInfo import club.staircrusher.user.domain.model.UserAuthProviderType +import org.springframework.data.repository.findByIdOrNull @Component -class UserAuthInfoRepository( - db: DB, +class UserAuthInfoRepositoryImplWithJpa( + private val delegatee: JpaUserAuthInfoRepository, ) : club.staircrusher.user.application.port.out.persistence.UserAuthInfoRepository { - private val queries = db.userAuthInfoQueries override fun save(entity: UserAuthInfo): UserAuthInfo { - queries.save(entity.toPersistenceModel()) - return entity + return delegatee.save(entity) } override fun saveAll(entities: Collection) { - entities.forEach(::save) + delegatee.saveAll(entities) } override fun removeAll() { - queries.removeAll() + delegatee.deleteAll() } override fun findById(id: String): UserAuthInfo { - return findByIdOrNull(id) ?: throw IllegalArgumentException("UserAuthInfo of id $id does not exist.") + return delegatee.findByIdOrNull(id) ?: throw IllegalArgumentException("UserAuthInfo of id $id does not exist.") } override fun findByIdOrNull(id: String): UserAuthInfo? { - return queries.findById(id = id) - .executeAsOneOrNull() - ?.toDomainModel() + return delegatee.findByIdOrNull(id) } override fun findByExternalId(authProviderType: UserAuthProviderType, externalId: String): UserAuthInfo? { - return queries.findByExternalId(authProviderType, externalId) - .executeAsOneOrNull() - ?.toDomainModel() + return delegatee.findFirstByAuthProviderTypeAndExternalId(authProviderType, externalId) } override fun findByUserId(userId: String): List { - return queries.findByUserId(userId) - .executeAsList() - .map { it.toDomainModel() } + return delegatee.findByUserId(userId) } override fun removeByUserId(userId: String) { - queries.removeByUserId(userId) + delegatee.removeByUserId(userId) } } diff --git a/app-server/subprojects/bounded_context/user/infra/src/main/kotlin/club/staircrusher/user/infra/adapter/out/persistence/UserRepository.kt b/app-server/subprojects/bounded_context/user/infra/src/main/kotlin/club/staircrusher/user/infra/adapter/out/persistence/UserRepository.kt deleted file mode 100644 index 966f719e0..000000000 --- a/app-server/subprojects/bounded_context/user/infra/src/main/kotlin/club/staircrusher/user/infra/adapter/out/persistence/UserRepository.kt +++ /dev/null @@ -1,64 +0,0 @@ -package club.staircrusher.user.infra.adapter.out.persistence - -import club.staircrusher.infra.persistence.sqldelight.DB -import club.staircrusher.user.domain.model.User -import club.staircrusher.user.application.port.out.persistence.UserRepository -import club.staircrusher.stdlib.di.annotation.Component - -@Component -class UserRepository( - db: DB, -) : UserRepository { - private val queries = db.userQueries - - override fun save(entity: User): User { - queries.save(entity.toPersistenceModel()) - return entity - } - - override fun saveAll(entities: Collection) { - entities.forEach(::save) - } - - override fun removeAll() { - queries.removeAll() - } - - override fun findById(id: String): User { - return findByIdOrNull(id) ?: throw IllegalArgumentException("User of id $id does not exist.") - } - - override fun findByIdOrNull(id: String): User? { - return queries.findById(id = id) - .executeAsOneOrNull() - ?.toDomainModel() - } - - override fun findByNickname(nickname: String): User? { - return queries.findByNickname(nickname = nickname) - .executeAsOneOrNull() - ?.toDomainModel() - } - - override fun findByEmail(email: String): User? { - return queries.findByEmail(email = email) - .executeAsOneOrNull() - ?.toDomainModel() - } - - override fun findByIdIn(ids: Collection): List { - if (ids.isEmpty()) { - // empty list로 쿼리를 할 경우 sqldelight가 제대로 처리하지 못하는 문제가 있다. - // select * from entity where entity.id in (); <- 이런 식으로 쿼리를 날리는데, () 부분이 syntax error이다. - // 따라서 ids가 empty면 early return을 해준다. - return emptyList() - } - return queries.findByIdIn(ids = ids) - .executeAsList() - .map { it.toDomainModel() } - } - - override fun findAll(): List { - return queries.findAll().executeAsList().map { it.toDomainModel() } - } -} diff --git a/app-server/subprojects/bounded_context/user/infra/src/main/kotlin/club/staircrusher/user/infra/adapter/out/persistence/UserRepositoryImplWithJpa.kt b/app-server/subprojects/bounded_context/user/infra/src/main/kotlin/club/staircrusher/user/infra/adapter/out/persistence/UserRepositoryImplWithJpa.kt new file mode 100644 index 000000000..456693ab1 --- /dev/null +++ b/app-server/subprojects/bounded_context/user/infra/src/main/kotlin/club/staircrusher/user/infra/adapter/out/persistence/UserRepositoryImplWithJpa.kt @@ -0,0 +1,47 @@ +package club.staircrusher.user.infra.adapter.out.persistence + +import club.staircrusher.stdlib.di.annotation.Component +import club.staircrusher.user.application.port.out.persistence.UserRepository +import club.staircrusher.user.domain.model.User +import org.springframework.data.repository.findByIdOrNull + +@Component +class UserRepositoryImplWithJpa( + private val delegatee: JpaUserRepository +) : UserRepository { + override fun save(entity: User): User { + return delegatee.save(entity) + } + + override fun saveAll(entities: Collection) { + delegatee.saveAll(entities) + } + + override fun removeAll() { + delegatee.deleteAll() + } + + override fun findById(id: String): User { + return delegatee.findByIdOrNull(id) ?: throw IllegalArgumentException("User of id $id does not exist.") + } + + override fun findByIdOrNull(id: String): User? { + return delegatee.findByIdOrNull(id) + } + + override fun findByNickname(nickname: String): User? { + return delegatee.findFirstByNickname(nickname = nickname) + } + + override fun findByEmail(email: String): User? { + return delegatee.findFirstByEmail(email = email) + } + + override fun findByIdIn(ids: Collection): List { + return delegatee.findAllById(ids).toList() + } + + override fun findAll(): List { + return delegatee.findAll().toList() + } +} diff --git a/app-server/subprojects/cross_cutting_concern/infra/persistence_model/build.gradle.kts b/app-server/subprojects/cross_cutting_concern/infra/persistence_model/build.gradle.kts index fe720ca68..b6e990a47 100644 --- a/app-server/subprojects/cross_cutting_concern/infra/persistence_model/build.gradle.kts +++ b/app-server/subprojects/cross_cutting_concern/infra/persistence_model/build.gradle.kts @@ -24,6 +24,9 @@ dependencies { val jacksonModuleKotlinVersion: String by project implementation("com.fasterxml.jackson.module:jackson-module-kotlin:$jacksonModuleKotlinVersion") + + implementation("org.springframework.boot:spring-boot-starter-data-jpa") + implementation("org.springframework.boot:spring-boot-starter-jdbc") } idea { diff --git a/app-server/subprojects/cross_cutting_concern/infra/persistence_model/src/main/kotlin/club/staircrusher/infra/persistence/SccJpaTransactionManager.kt b/app-server/subprojects/cross_cutting_concern/infra/persistence_model/src/main/kotlin/club/staircrusher/infra/persistence/SccJpaTransactionManager.kt new file mode 100644 index 000000000..443ef0b7a --- /dev/null +++ b/app-server/subprojects/cross_cutting_concern/infra/persistence_model/src/main/kotlin/club/staircrusher/infra/persistence/SccJpaTransactionManager.kt @@ -0,0 +1,52 @@ +package club.staircrusher.infra.persistence + +import club.staircrusher.stdlib.persistence.TransactionIsolationLevel +import club.staircrusher.stdlib.persistence.TransactionManager +import org.springframework.stereotype.Component +import org.springframework.transaction.TransactionDefinition +import org.springframework.transaction.support.DefaultTransactionDefinition +import org.springframework.transaction.support.TransactionSynchronization +import org.springframework.transaction.support.TransactionSynchronizationManager +import org.springframework.transaction.support.TransactionTemplate + +@Component +class SccJpaTransactionManager( // Spring이 제공하는 JpaTransactionManager bean과 이름이 겹치지 않도록 한다. + private val transactionTemplate: TransactionTemplate, +) : TransactionManager { + override fun doInTransaction(block: () -> T): T { + /** + * 전체 엔티티에 대해 한 번에 jpa로 변환하는 게 아닌 이상, sqldelight와 jpa를 동시에 사용하는 기간이 반드시 발생한다. + * + * sqldelight는 어차피 ORM이 아니라 SQL을 kotlin API로 type-safe하게 쓰는 역할만 하므로, + * JPA의 트랜잭션 라이프사이클에 의존해도 괜찮다. + * 따라서 여기서는 JPA 트랜잭션을 사용한다. + */ + return transactionTemplate.execute { + block() + } as T + } + + override fun doInTransaction(isolationLevel: TransactionIsolationLevel, block: () -> T): T { + val transactionDefinition = DefaultTransactionDefinition().apply { + this.isolationLevel = isolationLevel.toSpring() + } + + return TransactionTemplate(transactionTemplate.transactionManager!!, transactionDefinition).execute { + block() + } as T + } + + override fun doAfterCommit(block: () -> Unit) { + TransactionSynchronizationManager.registerSynchronization(object : TransactionSynchronization { + override fun afterCommit() { + block() + } + }) + } + + private fun TransactionIsolationLevel.toSpring() = when (this) { + TransactionIsolationLevel.READ_COMMITTED -> TransactionDefinition.ISOLATION_READ_COMMITTED + TransactionIsolationLevel.REPEATABLE_READ -> TransactionDefinition.ISOLATION_REPEATABLE_READ + TransactionIsolationLevel.SERIALIZABLE -> TransactionDefinition.ISOLATION_SERIALIZABLE + } +} diff --git a/app-server/subprojects/cross_cutting_concern/infra/persistence_model/src/main/kotlin/club/staircrusher/infra/persistence/sqldelight/DB.kt b/app-server/subprojects/cross_cutting_concern/infra/persistence_model/src/main/kotlin/club/staircrusher/infra/persistence/sqldelight/DB.kt index 0210fe3f9..3a468ea6a 100644 --- a/app-server/subprojects/cross_cutting_concern/infra/persistence_model/src/main/kotlin/club/staircrusher/infra/persistence/sqldelight/DB.kt +++ b/app-server/subprojects/cross_cutting_concern/infra/persistence_model/src/main/kotlin/club/staircrusher/infra/persistence/sqldelight/DB.kt @@ -10,20 +10,13 @@ import club.staircrusher.infra.persistence.sqldelight.column_adapter.LocationLis import club.staircrusher.infra.persistence.sqldelight.column_adapter.PlaceCategoryStringColumnAdapter import club.staircrusher.infra.persistence.sqldelight.column_adapter.StairHeightLevelStringColumnAdapter import club.staircrusher.infra.persistence.sqldelight.column_adapter.StringListToTextColumnAdapter -import club.staircrusher.infra.persistence.sqldelight.column_adapter.UserAuthProviderTypeStringColumnAdapter -import club.staircrusher.infra.persistence.sqldelight.column_adapter.UserMobilityToolStringColumnAdapter import club.staircrusher.infra.persistence.sqldelight.migration.Accessibility_allowed_region import club.staircrusher.infra.persistence.sqldelight.migration.Building_accessibility import club.staircrusher.infra.persistence.sqldelight.migration.Challenge import club.staircrusher.infra.persistence.sqldelight.migration.Club_quest import club.staircrusher.infra.persistence.sqldelight.migration.Place import club.staircrusher.infra.persistence.sqldelight.migration.Place_accessibility -import club.staircrusher.infra.persistence.sqldelight.migration.Scc_user -import club.staircrusher.infra.persistence.sqldelight.migration.User_auth_info import club.staircrusher.stdlib.di.annotation.Component -import club.staircrusher.stdlib.persistence.Transaction -import club.staircrusher.stdlib.persistence.TransactionIsolationLevel -import club.staircrusher.stdlib.persistence.TransactionManager import com.fasterxml.jackson.annotation.JsonInclude import com.fasterxml.jackson.databind.DeserializationFeature import com.fasterxml.jackson.databind.SerializationFeature @@ -32,7 +25,7 @@ import com.fasterxml.jackson.module.kotlin.readValue import javax.sql.DataSource @Component -class DB(dataSource: DataSource) : TransactionManager { +class DB(dataSource: DataSource) { private val driver = SqlDelightJdbcDriver(dataSource) private val scc = scc( driver = driver, @@ -58,12 +51,6 @@ class DB(dataSource: DataSource) : TransactionManager { accessibility_allowed_regionAdapter = Accessibility_allowed_region.Adapter( boundary_verticesAdapter = LocationListToTextColumnAdapter, ), - user_auth_infoAdapter = User_auth_info.Adapter( - auth_provider_typeAdapter = UserAuthProviderTypeStringColumnAdapter, - ), - scc_userAdapter = Scc_user.Adapter( - mobility_toolsAdapter = UserMobilityToolStringColumnAdapter, - ), challengeAdapter = Challenge.Adapter( milestonesAdapter = IntListToTextColumnAdapter, conditionsAdapter = object : ListToTextColumnAdapter() { @@ -91,8 +78,6 @@ class DB(dataSource: DataSource) : TransactionManager { val placeAccessibilityCommentQueries = scc.placeAccessibilityCommentQueries val placeAccessibilityUpvoteQueries = scc.placeAccessibilityUpvoteQueries val accessibilityRankQueries = scc.accessibilityRankQueries - val userQueries = scc.userQueries - val userAuthInfoQueries = scc.userAuthInfoQueries val clubQuestQueries = scc.clubQuestQueries val clubQuestTargetBuildingQueries = scc.clubQuestTargetBuildingQueries val clubQuestTargetPlaceQueries = scc.clubQuestTargetPlaceQueries @@ -101,45 +86,6 @@ class DB(dataSource: DataSource) : TransactionManager { val challengeContributionQueries = scc.challengeContributionQueries val challengeParticipationQueries = scc.challengeParticipationQueries val challengeRankQueries = scc.challengeRankQueries - - - override fun doInTransaction(block: Transaction.() -> T): T { - // FIXME: 다른 bounded context의 기능을 호출하기 때문에 nested transaction이 반드시 발생한다. -// check(driver.isolationLevel == null) { -// """ -// Since SCC does not allow nested transaction, isolationLevel saved in -// thread local must be null. -// """.trimIndent() -// } - return scc.transactionWithResult(noEnclosing = false) { - SqlDelightTransaction(this).block() - } - } - - override fun doInTransaction( - isolationLevel: TransactionIsolationLevel, - block: Transaction.() -> T, - ): T { - // FIXME: 다른 bounded context의 기능을 호출하기 때문에 nested transaction이 반드시 발생한다. -// check(driver.isolationLevel == null) { -// """ -// Since SCC does not allow nested transaction, isolationLevel saved in -// thread local must be null. -// """.trimIndent() -// } - driver.isolationLevel = isolationLevel.toConnectionIsolationLevel() - return try { - scc.transactionWithResult(noEnclosing = false) { - SqlDelightTransaction(this).block() - } - } finally { - driver.isolationLevel = null - } - } - - override fun doAfterCommit(block: () -> Unit) { - block() // TODO: 올바르게 구현하기 - } } private val objectMapper = jacksonObjectMapper() diff --git a/app-server/subprojects/cross_cutting_concern/infra/persistence_model/src/main/kotlin/club/staircrusher/infra/persistence/sqldelight/SqlDelightTransaction.kt b/app-server/subprojects/cross_cutting_concern/infra/persistence_model/src/main/kotlin/club/staircrusher/infra/persistence/sqldelight/SqlDelightTransaction.kt deleted file mode 100644 index 8f2e75c4c..000000000 --- a/app-server/subprojects/cross_cutting_concern/infra/persistence_model/src/main/kotlin/club/staircrusher/infra/persistence/sqldelight/SqlDelightTransaction.kt +++ /dev/null @@ -1,20 +0,0 @@ -package club.staircrusher.infra.persistence.sqldelight - -import app.cash.sqldelight.TransactionWithReturn -import club.staircrusher.stdlib.persistence.Transaction - -class SqlDelightTransaction( - private val transaction: TransactionWithReturn -) : Transaction { - override fun afterCommit(block: () -> Unit) { - transaction.afterCommit(block) - } - - override fun afterRollback(block: () -> Unit) { - transaction.afterRollback(block) - } - - override fun rollback(returnValue: T) { - transaction.rollback(returnValue) - } -} diff --git a/app-server/subprojects/cross_cutting_concern/infra/persistence_model/src/main/sqldelight/club/staircrusher/infra/persistence/sqldelight/query/user/User.sq b/app-server/subprojects/cross_cutting_concern/infra/persistence_model/src/main/sqldelight/club/staircrusher/infra/persistence/sqldelight/query/user/User.sq deleted file mode 100644 index 4ff82fef2..000000000 --- a/app-server/subprojects/cross_cutting_concern/infra/persistence_model/src/main/sqldelight/club/staircrusher/infra/persistence/sqldelight/query/user/User.sq +++ /dev/null @@ -1,39 +0,0 @@ -save: -INSERT INTO scc_user -VALUES :scc_user -ON CONFLICT(id) DO UPDATE SET - id = EXCLUDED.id, - nickname = EXCLUDED.nickname, - encrypted_password = EXCLUDED.encrypted_password, - instagram_id = EXCLUDED.instagram_id, - created_at = EXCLUDED.created_at, - deleted_at = EXCLUDED.deleted_at, - email = EXCLUDED.email, - mobility_tools = EXCLUDED.mobility_tools; - -removeAll: -DELETE FROM scc_user; - -findById: -SELECT * -FROM scc_user -WHERE scc_user.id = :id; - -findByNickname: -SELECT * -FROM scc_user -WHERE scc_user.nickname = :nickname; - -findByEmail: -SELECT * -FROM scc_user -WHERE scc_user.email = :email; - -findByIdIn: -SELECT * -FROM scc_user -WHERE scc_user.id IN :ids; - -findAll: -SELECT * -FROM scc_user; diff --git a/app-server/subprojects/cross_cutting_concern/infra/persistence_model/src/main/sqldelight/club/staircrusher/infra/persistence/sqldelight/query/user/UserAuthInfo.sq b/app-server/subprojects/cross_cutting_concern/infra/persistence_model/src/main/sqldelight/club/staircrusher/infra/persistence/sqldelight/query/user/UserAuthInfo.sq deleted file mode 100644 index eb79d012e..000000000 --- a/app-server/subprojects/cross_cutting_concern/infra/persistence_model/src/main/sqldelight/club/staircrusher/infra/persistence/sqldelight/query/user/UserAuthInfo.sq +++ /dev/null @@ -1,42 +0,0 @@ -save: -INSERT INTO user_auth_info -VALUES :user_auth_info -ON CONFLICT(id) DO UPDATE SET - id = EXCLUDED.id, - user_id = EXCLUDED.user_id, - auth_provider_type = EXCLUDED.auth_provider_type, - external_id = EXCLUDED.external_id, - external_refresh_token = EXCLUDED.external_refresh_token, - external_refresh_token_expires_at = EXCLUDED.external_refresh_token_expires_at, - created_at = EXCLUDED.created_at; - -removeAll: -DELETE FROM user_auth_info; - -findById: -SELECT * -FROM user_auth_info -WHERE user_auth_info.id = :id; - -findAll: -SELECT * -FROM user_auth_info; - -findByExternalId: -SELECT * -FROM user_auth_info -WHERE - user_auth_info.auth_provider_type = :authProviderType - AND user_auth_info.external_id = :externalId; - -findByUserId: -SELECT * -FROM user_auth_info -WHERE - user_auth_info.user_id = :userId; - -removeByUserId: -DELETE -FROM user_auth_info -WHERE - user_auth_info.user_id = :userId; diff --git a/app-server/subprojects/cross_cutting_concern/infra/spring_web/build.gradle.kts b/app-server/subprojects/cross_cutting_concern/infra/spring_web/build.gradle.kts index be8905d14..799ad022e 100644 --- a/app-server/subprojects/cross_cutting_concern/infra/spring_web/build.gradle.kts +++ b/app-server/subprojects/cross_cutting_concern/infra/spring_web/build.gradle.kts @@ -10,12 +10,14 @@ dependencies { implementation(projects.boundedContext.user.application) implementation("org.springframework.boot:spring-boot-starter-web") + implementation("org.springframework.boot:spring-boot-starter-data-jpa") + implementation("org.springframework.boot:spring-boot-starter-jdbc") api("org.springframework.boot:spring-boot-starter-security") val kotlinLoggingVersion: String by project implementation("io.github.microutils:kotlin-logging-jvm:$kotlinLoggingVersion") val jacksonModuleKotlinVersion: String by project implementation("com.fasterxml.jackson.module:jackson-module-kotlin:$jacksonModuleKotlinVersion") + integrationTestImplementation(projects.crossCuttingConcern.test.springIt) integrationTestImplementation("com.fasterxml.jackson.module:jackson-module-kotlin:$jacksonModuleKotlinVersion") - integrationTestImplementation("org.springframework.boot:spring-boot-starter-test") } diff --git a/app-server/subprojects/cross_cutting_concern/infra/spring_web/src/integrationTest/kotlin/club/staircrusher/spring_web/mock/InMemoryUserAuthInfoRepository.kt b/app-server/subprojects/cross_cutting_concern/infra/spring_web/src/integrationTest/kotlin/club/staircrusher/spring_web/mock/InMemoryUserAuthInfoRepository.kt index b190794c4..a9cd92a41 100644 --- a/app-server/subprojects/cross_cutting_concern/infra/spring_web/src/integrationTest/kotlin/club/staircrusher/spring_web/mock/InMemoryUserAuthInfoRepository.kt +++ b/app-server/subprojects/cross_cutting_concern/infra/spring_web/src/integrationTest/kotlin/club/staircrusher/spring_web/mock/InMemoryUserAuthInfoRepository.kt @@ -1,11 +1,13 @@ package club.staircrusher.spring_web.mock -import club.staircrusher.user.domain.model.UserAuthInfo import club.staircrusher.stdlib.di.annotation.Component import club.staircrusher.user.application.port.out.persistence.UserAuthInfoRepository +import club.staircrusher.user.domain.model.UserAuthInfo import club.staircrusher.user.domain.model.UserAuthProviderType +import org.springframework.context.annotation.Primary @Component +@Primary class InMemoryUserAuthInfoRepository : UserAuthInfoRepository { private val userById = mutableMapOf() override fun save(entity: UserAuthInfo): UserAuthInfo { diff --git a/app-server/subprojects/cross_cutting_concern/infra/spring_web/src/integrationTest/kotlin/club/staircrusher/spring_web/mock/InMemoryUserRepository.kt b/app-server/subprojects/cross_cutting_concern/infra/spring_web/src/integrationTest/kotlin/club/staircrusher/spring_web/mock/InMemoryUserRepository.kt index 08476791b..b5a3669e4 100644 --- a/app-server/subprojects/cross_cutting_concern/infra/spring_web/src/integrationTest/kotlin/club/staircrusher/spring_web/mock/InMemoryUserRepository.kt +++ b/app-server/subprojects/cross_cutting_concern/infra/spring_web/src/integrationTest/kotlin/club/staircrusher/spring_web/mock/InMemoryUserRepository.kt @@ -1,13 +1,12 @@ package club.staircrusher.spring_web.mock -import club.staircrusher.user.domain.model.User -import club.staircrusher.user.application.port.out.persistence.UserRepository import club.staircrusher.stdlib.di.annotation.Component -import org.junit.jupiter.api.Order -import org.springframework.core.Ordered +import club.staircrusher.user.application.port.out.persistence.UserRepository +import club.staircrusher.user.domain.model.User +import org.springframework.context.annotation.Primary @Component -@Order(Ordered.HIGHEST_PRECEDENCE) +@Primary class InMemoryUserRepository : UserRepository { private val userById = mutableMapOf() override fun findByNickname(nickname: String): User? { diff --git a/app-server/subprojects/cross_cutting_concern/infra/spring_web/src/integrationTest/kotlin/club/staircrusher/spring_web/mock/MockAppleLoginService.kt b/app-server/subprojects/cross_cutting_concern/infra/spring_web/src/integrationTest/kotlin/club/staircrusher/spring_web/mock/MockAppleLoginService.kt deleted file mode 100644 index 483e531f3..000000000 --- a/app-server/subprojects/cross_cutting_concern/infra/spring_web/src/integrationTest/kotlin/club/staircrusher/spring_web/mock/MockAppleLoginService.kt +++ /dev/null @@ -1,27 +0,0 @@ -package club.staircrusher.spring_web.mock - -import club.staircrusher.stdlib.clock.SccClock -import club.staircrusher.stdlib.di.annotation.Component -import club.staircrusher.user.application.port.out.web.login.apple.AppleIdToken -import club.staircrusher.user.application.port.out.web.login.apple.AppleLoginService -import club.staircrusher.user.application.port.out.web.login.apple.AppleLoginTokens -import org.springframework.context.annotation.Primary -import java.time.Duration - -@Primary -@Component -class MockAppleLoginService : AppleLoginService { - override suspend fun getAppleLoginTokens(authorizationCode: String): AppleLoginTokens { - return AppleLoginTokens( - accessToken = "dummy", - expiresAt = SccClock.instant() + Duration.ofHours(1), - refreshToken = "dummy", - idToken = AppleIdToken( - issuer = "dummy", - audience = "dummy", - expiresAtEpochSecond = (SccClock.instant() + Duration.ofHours(1)).epochSecond, - appleLoginUserId = "dummy", - ), - ) - } -} diff --git a/app-server/subprojects/cross_cutting_concern/infra/spring_web/src/integrationTest/kotlin/club/staircrusher/spring_web/mock/MockKakaoLoginService.kt b/app-server/subprojects/cross_cutting_concern/infra/spring_web/src/integrationTest/kotlin/club/staircrusher/spring_web/mock/MockKakaoLoginService.kt deleted file mode 100644 index 5de2de5c9..000000000 --- a/app-server/subprojects/cross_cutting_concern/infra/spring_web/src/integrationTest/kotlin/club/staircrusher/spring_web/mock/MockKakaoLoginService.kt +++ /dev/null @@ -1,20 +0,0 @@ -package club.staircrusher.spring_web.mock - -import club.staircrusher.stdlib.clock.SccClock -import club.staircrusher.stdlib.di.annotation.Component -import club.staircrusher.user.application.port.out.web.login.kakao.KakaoIdToken -import club.staircrusher.user.application.port.out.web.login.kakao.KakaoLoginService -import org.springframework.context.annotation.Primary - -@Primary -@Component -class MockKakaoLoginService : KakaoLoginService { - override fun parseIdToken(idToken: String): KakaoIdToken { - return KakaoIdToken( - issuer = "dummy", - audience = "dummy", - expiresAtEpochSecond = SccClock.instant().epochSecond, - kakaoSyncUserId = "dummy", - ) - } -} diff --git a/app-server/subprojects/cross_cutting_concern/infra/spring_web/src/integrationTest/kotlin/club/staircrusher/spring_web/mock/MockStibeeSubscriptionService.kt b/app-server/subprojects/cross_cutting_concern/infra/spring_web/src/integrationTest/kotlin/club/staircrusher/spring_web/mock/MockStibeeSubscriptionService.kt deleted file mode 100644 index afcb55415..000000000 --- a/app-server/subprojects/cross_cutting_concern/infra/spring_web/src/integrationTest/kotlin/club/staircrusher/spring_web/mock/MockStibeeSubscriptionService.kt +++ /dev/null @@ -1,13 +0,0 @@ -package club.staircrusher.spring_web.mock - -import club.staircrusher.stdlib.di.annotation.Component -import club.staircrusher.user.application.port.out.web.subscription.StibeeSubscriptionService -import org.springframework.context.annotation.Primary - -@Primary -@Component -class MockStibeeSubscriptionService : StibeeSubscriptionService { - override suspend fun registerSubscriber(email: String, name: String, isMarketingPushAgreed: Boolean): Boolean { - return true - } -} diff --git a/app-server/subprojects/cross_cutting_concern/infra/spring_web/src/integrationTest/kotlin/club/staircrusher/spring_web/security/NoopPasswordEncryptor.kt b/app-server/subprojects/cross_cutting_concern/infra/spring_web/src/integrationTest/kotlin/club/staircrusher/spring_web/mock/NoopPasswordEncryptor.kt similarity index 79% rename from app-server/subprojects/cross_cutting_concern/infra/spring_web/src/integrationTest/kotlin/club/staircrusher/spring_web/security/NoopPasswordEncryptor.kt rename to app-server/subprojects/cross_cutting_concern/infra/spring_web/src/integrationTest/kotlin/club/staircrusher/spring_web/mock/NoopPasswordEncryptor.kt index 8397a3c2e..cd55a6606 100644 --- a/app-server/subprojects/cross_cutting_concern/infra/spring_web/src/integrationTest/kotlin/club/staircrusher/spring_web/security/NoopPasswordEncryptor.kt +++ b/app-server/subprojects/cross_cutting_concern/infra/spring_web/src/integrationTest/kotlin/club/staircrusher/spring_web/mock/NoopPasswordEncryptor.kt @@ -1,9 +1,11 @@ -package club.staircrusher.spring_web.security +package club.staircrusher.spring_web.mock import club.staircrusher.stdlib.di.annotation.Component import club.staircrusher.user.domain.service.PasswordEncryptor +import org.springframework.context.annotation.Primary @Component +@Primary class NoopPasswordEncryptor : PasswordEncryptor { override fun encrypt(password: String): String { return password diff --git a/app-server/subprojects/cross_cutting_concern/infra/spring_web/src/integrationTest/kotlin/club/staircrusher/spring_web/mock/NoopTransactionManager.kt b/app-server/subprojects/cross_cutting_concern/infra/spring_web/src/integrationTest/kotlin/club/staircrusher/spring_web/mock/NoopTransactionManager.kt index 5730b0857..32e4f8126 100644 --- a/app-server/subprojects/cross_cutting_concern/infra/spring_web/src/integrationTest/kotlin/club/staircrusher/spring_web/mock/NoopTransactionManager.kt +++ b/app-server/subprojects/cross_cutting_concern/infra/spring_web/src/integrationTest/kotlin/club/staircrusher/spring_web/mock/NoopTransactionManager.kt @@ -1,39 +1,22 @@ package club.staircrusher.spring_web.mock import club.staircrusher.stdlib.di.annotation.Component -import club.staircrusher.stdlib.persistence.Transaction import club.staircrusher.stdlib.persistence.TransactionIsolationLevel import club.staircrusher.stdlib.persistence.TransactionManager -import org.springframework.core.Ordered -import org.springframework.core.annotation.Order +import org.springframework.context.annotation.Primary +@Primary @Component -@Order(Ordered.HIGHEST_PRECEDENCE) class NoopTransactionManager : TransactionManager { - override fun doInTransaction(block: Transaction.() -> T): T { - return NoopTransaction().block() + override fun doInTransaction(block: () -> T): T { + return block() } - override fun doInTransaction(isolationLevel: TransactionIsolationLevel, block: Transaction.() -> T): T { - return NoopTransaction().block() + override fun doInTransaction(isolationLevel: TransactionIsolationLevel, block: () -> T): T { + return block() } override fun doAfterCommit(block: () -> Unit) { block() } - - private class NoopTransaction : Transaction { - override fun afterCommit(block: () -> Unit) { - TODO("Not yet implemented") - } - - override fun afterRollback(block: () -> Unit) { - TODO("Not yet implemented") - } - - override fun rollback(returnValue: T) { - TODO("Not yet implemented") - } - - } } diff --git a/app-server/subprojects/cross_cutting_concern/infra/spring_web/src/integrationTest/resources/application.yaml b/app-server/subprojects/cross_cutting_concern/infra/spring_web/src/integrationTest/resources/application.yaml index 5dcb1db89..88a8e3f60 100644 --- a/app-server/subprojects/cross_cutting_concern/infra/spring_web/src/integrationTest/resources/application.yaml +++ b/app-server/subprojects/cross_cutting_concern/infra/spring_web/src/integrationTest/resources/application.yaml @@ -1,3 +1,53 @@ +spring: + datasource: + username: test + password: test + url: jdbc:postgresql://localhost:15432/scc_test + driver-class-name: org.postgresql.Driver + hikari: + connection-timeout: 2000 + data-source-properties.cachePrepStmts: true + data-source-properties.prepStmtCacheSize: 250 + data-source-properties.prepStmtCacheSqlLimit: 2048 + pool-name: writer + maximum-pool-size: 16 + minimum-idle: 10 + scc: + environment: test + + kakao: + apiKey: test + + naver: + open-api: + client-id: test + client-secret: test + + s3: + imageUpload: + bucketName: test + thumbnailBucketName: test + accessKey: test + secretKey: test + + cloudfront: + domain: cloudfronttest + admin: password: adminPassword + + kakao-login: + oauth-client-id: test + + apple-login: + service-id: test + client-secret: test + + stibee: + apiKey: test + listId: test + + nhn-cloud: + url-shortening: + appKey: test diff --git a/app-server/subprojects/cross_cutting_concern/infra/spring_web/src/main/kotlin/club/staircrusher/spring_web/persistence/JpaRepositoryConfig.kt b/app-server/subprojects/cross_cutting_concern/infra/spring_web/src/main/kotlin/club/staircrusher/spring_web/persistence/JpaRepositoryConfig.kt new file mode 100644 index 000000000..af6d73870 --- /dev/null +++ b/app-server/subprojects/cross_cutting_concern/infra/spring_web/src/main/kotlin/club/staircrusher/spring_web/persistence/JpaRepositoryConfig.kt @@ -0,0 +1,10 @@ +package club.staircrusher.spring_web.persistence + +import org.springframework.boot.autoconfigure.domain.EntityScan +import org.springframework.context.annotation.Configuration +import org.springframework.data.jpa.repository.config.EnableJpaRepositories + +@EntityScan("club.staircrusher") +@EnableJpaRepositories("club.staircrusher") +@Configuration +class JpaRepositoryConfig diff --git a/app-server/subprojects/cross_cutting_concern/stdlib/build.gradle.kts b/app-server/subprojects/cross_cutting_concern/stdlib/build.gradle.kts index 884561028..24b775629 100644 --- a/app-server/subprojects/cross_cutting_concern/stdlib/build.gradle.kts +++ b/app-server/subprojects/cross_cutting_concern/stdlib/build.gradle.kts @@ -9,6 +9,8 @@ dependencies { implementation("com.auth0:java-jwt:3.18.1") implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.11.0") + api("jakarta.persistence:jakarta.persistence-api") + ksp("at.kopyk:kopykat-ksp:$kopyKatVersion") compileOnly("at.kopyk:kopykat-annotations:$kopyKatVersion") diff --git a/app-server/subprojects/cross_cutting_concern/stdlib/src/main/kotlin/club/staircrusher/stdlib/jpa/ListToTextAttributeConverter.kt b/app-server/subprojects/cross_cutting_concern/stdlib/src/main/kotlin/club/staircrusher/stdlib/jpa/ListToTextAttributeConverter.kt new file mode 100644 index 000000000..3fbc1ac13 --- /dev/null +++ b/app-server/subprojects/cross_cutting_concern/stdlib/src/main/kotlin/club/staircrusher/stdlib/jpa/ListToTextAttributeConverter.kt @@ -0,0 +1,38 @@ +package club.staircrusher.stdlib.jpa + +import com.fasterxml.jackson.core.JsonProcessingException +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import jakarta.persistence.AttributeConverter + +abstract class ListToTextAttributeConverter : AttributeConverter, String> { + override fun convertToDatabaseColumn(attribute: List): String { + return objectMapper.writeValueAsString(attribute) + } + + override fun convertToEntityAttribute(column: String): List { + return try { + convertJsonColumnToEntityAttribute(column) + } catch (e: JsonProcessingException) { + convertLegacyColumnToEntityAttribute(column) + } + } + + private fun convertLegacyColumnToEntityAttribute(column: String): List { + return column.split(LEGACY_DELIMITER) + .filter { it.isNotBlank() } + .map(::convertElementFromTextColumn) + } + + private fun convertJsonColumnToEntityAttribute(column: String): List { + return objectMapper.readValue>(column) + .map(::convertElementFromTextColumn) + } + + abstract fun convertElementFromTextColumn(text: String): E + + companion object { + private val objectMapper = jacksonObjectMapper() + const val LEGACY_DELIMITER = ",," + } +} diff --git a/app-server/subprojects/cross_cutting_concern/stdlib/src/main/kotlin/club/staircrusher/stdlib/persistence/Transaction.kt b/app-server/subprojects/cross_cutting_concern/stdlib/src/main/kotlin/club/staircrusher/stdlib/persistence/Transaction.kt deleted file mode 100644 index d055dd0c1..000000000 --- a/app-server/subprojects/cross_cutting_concern/stdlib/src/main/kotlin/club/staircrusher/stdlib/persistence/Transaction.kt +++ /dev/null @@ -1,7 +0,0 @@ -package club.staircrusher.stdlib.persistence - -interface Transaction { - fun afterCommit(block: () -> Unit) - fun afterRollback(block: () -> Unit) - fun rollback(returnValue: T) -} diff --git a/app-server/subprojects/cross_cutting_concern/stdlib/src/main/kotlin/club/staircrusher/stdlib/persistence/TransactionManager.kt b/app-server/subprojects/cross_cutting_concern/stdlib/src/main/kotlin/club/staircrusher/stdlib/persistence/TransactionManager.kt index 4feb1a2f2..09eb5b171 100644 --- a/app-server/subprojects/cross_cutting_concern/stdlib/src/main/kotlin/club/staircrusher/stdlib/persistence/TransactionManager.kt +++ b/app-server/subprojects/cross_cutting_concern/stdlib/src/main/kotlin/club/staircrusher/stdlib/persistence/TransactionManager.kt @@ -1,8 +1,8 @@ package club.staircrusher.stdlib.persistence interface TransactionManager { - fun doInTransaction(block: Transaction.() -> T): T - fun doInTransaction(isolationLevel: TransactionIsolationLevel, block: Transaction.() -> T): T + fun doInTransaction(block: () -> T): T + fun doInTransaction(isolationLevel: TransactionIsolationLevel, block: () -> T): T fun doAfterCommit(block: () -> Unit) } diff --git a/app-server/subprojects/cross_cutting_concern/stdlib/src/unitTest/kotlin/club/staircrusher/stdlib/persistence/ListToTextAttributeConverterUT.kt b/app-server/subprojects/cross_cutting_concern/stdlib/src/unitTest/kotlin/club/staircrusher/stdlib/persistence/ListToTextAttributeConverterUT.kt new file mode 100644 index 000000000..b92cebff3 --- /dev/null +++ b/app-server/subprojects/cross_cutting_concern/stdlib/src/unitTest/kotlin/club/staircrusher/stdlib/persistence/ListToTextAttributeConverterUT.kt @@ -0,0 +1,43 @@ +package club.staircrusher.stdlib.persistence + +import club.staircrusher.stdlib.jpa.ListToTextAttributeConverter +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotEquals +import org.junit.jupiter.api.Test + +class ListToTextAttributeConverterUT { + object SomeEnumListToTextAttributeConverter : ListToTextAttributeConverter() { + override fun convertElementFromTextColumn(text: String): SomeEnum { + return SomeEnum.valueOf(text) + } + } + + enum class SomeEnum { + A, B, C, D, E + } + + private val sut = SomeEnumListToTextAttributeConverter + + @Test + fun `기본 동작 테스트`() { + val attribute = listOf(SomeEnum.A, SomeEnum.B, SomeEnum.D, SomeEnum.B) + val deserialized = sut.convertToDatabaseColumn(attribute) + assertEquals("[\"A\",\"B\",\"D\",\"B\"]", deserialized) + + val serialized = sut.convertToEntityAttribute(deserialized) + assertEquals(attribute, serialized) + } + + @Test + fun `하위호환 테스트`() { + val attribute = listOf(SomeEnum.A, SomeEnum.B, SomeEnum.D, SomeEnum.B) + val legacy = attribute.joinToString(ListToTextAttributeConverter.LEGACY_DELIMITER) + + val serialized = sut.convertToEntityAttribute(legacy) + assertEquals(attribute, serialized) + + val deserialized = sut.convertToDatabaseColumn(serialized) + assertNotEquals(legacy, deserialized) + assertEquals("[\"A\",\"B\",\"D\",\"B\"]", deserialized) + } +}