
TL;DR
При работе со Spring Data JDBC и колонкой базы данных с типом jsonb
вы можете столкнуться с трудностями при выборе правильного типа для свойства jsonb
в entity, реализации конвертеров для преобразования объектов из/в базу данных и определении запросов Spring Data JDBC для вложенных свойств jsonb
.
Введение
Недавно я работал со Spring Data JDBC, и работа включала в себя создание API для сущности, часть данных которой сохраняется в формате JSON в PostgreSQL в колонке с типом jsonb
. В интернете мало информации о Spring Data JDBC (не Spring Data JPA) вместе с jsonb
. Поэтому сегодня я поделюсь своим опытом и некоторыми находками по этой теме. Мы сделаем API для создания, чтения, обновления и удаления друзей с суперспособностями. Полный исходный код доступен на GitHub: pfilaretov42/spring-data-jdbc-jsonb.
Итак, начнём.
Создание проекта
Первый шаг — создание нового проекта Spring Boot в IntelliJ IDEA. Или вместо IDEA можно использовать Spring Initializr. Я выберу Kotlin с JDK 21 и Gradle. Также нам понадобятся следующие зависимости:
Spring Web для создания REST API;
Spring Data JDBC для работы с базой данных;
PostgreSQL Driver — в этот раз нашим хранилищем будет PostgreSQL;
Liquibase Migration для управления изменениями в базе данных.
Создание таблицы базы данных
Итак, у нас есть базовая настройка проекта, поэтому давайте создадим таблицу базы данных. Прежде всего, нам понадобится запущенный экземпляр PostgreSQL, например, в Docker'е.
Вот пример docker-compose YAML (./docker/local-infra.yaml
), чтобы запустить PostgreSQL:
version: '3.9'
services:
db:
image: postgres:16.4-alpine3.20
shm_size: 128mb
environment:
POSTGRES_PASSWORD: postgres
ports:
- "5432:5432"
Запускаем PostgreSQL с помощью команды:
docker-compose -f ./docker/local-infra.yaml up
Подключаемся с пользователем/паролем postgres
/postgres
и создаём базу данных и пользователя:
create database spring_data_jdbc_jsonb;
create user spring_data_jdbc_jsonb with encrypted password 'spring_data_jdbc_jsonb';
grant all privileges on database spring_data_jdbc_jsonb to spring_data_jdbc_jsonb;
alter database spring_data_jdbc_jsonb owner to spring_data_jdbc_jsonb;
Теперь нужно прописать данные для подключения к базе в application.yaml
:
spring:
datasource:
url: jdbc:postgresql://localhost:5432/spring_data_jdbc_jsonb
username: spring_data_jdbc_jsonb
password: spring_data_jdbc_jsonb
liquibase:
driver-class-name: org.postgresql.Driver
change-log: db/changelog/changelog.xml
url: ${spring.datasource.url}
user: ${spring.datasource.username}
password: ${spring.datasource.password}
Здесь мы также определили параметры для Liquibase. Теперь создадим файл changelog.xml
...
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd">
<include file="db/changelog/changesets/0001-create-table.xml"/>
</databaseChangeLog>
...и добавим первый changeSet 0001-create-table.xml
для создания таблицы. Основная часть здесь — это createTable
:
<changeSet id="create table" author="pfilaretov42">
<createTable tableName="friends">
<column name="id" type="uuid" defaultValueComputed="uuid_generate_v4()">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="full_name" type="varchar(255)">
<constraints nullable="false"/>
</column>
<column name="alias" type="varchar(255)">
<constraints nullable="false"/>
</column>
<column name="superpower" type="jsonb">
<constraints nullable="false"/>
</column>
</createTable>
</changeSet>
Таблица friends
содержит несколько основных полей (id
, full_name
, alias
) и поле superpower
с типом jsonb
, которое будет хранить характеристики суперсилы в формате JSON. Предположим, что структура JSON у нас жёстко зафиксирована, например:
{
"abilities": [
...
],
"weapon": [
...
],
"rating": ...
}
Добавление данных
Теперь мы можем добавить данные, используя Liquibase-скрипт 0002-add-data.xml
. Вот одна из записей:
INSERT INTO friends(full_name, alias, superpower)
VALUES ('Peter Parker',
'Spider-Man',
'{
"abilities": [
"Superhuman strength",
"Precognitive spider-sense",
"Ability to cling to solid surfaces"
],
"weapon": [
"web-shooters"
],
"rating": 97
}');
Теперь всё готово, чтобы создать первый API.
Запрос списка друзей
Давайте начнём с API для получения списка друзей. Нам понадобится REST-контроллер FriendsController
...
@RestController
@RequestMapping("/api/v1/friends")
class FriendsController(
private val friendsService: FriendsService,
) {
@GetMapping
fun getAll(): FriendsResponseDto = friendsService.getAll()
}
...сервис FriendsService
...
@Service
class FriendsService(
private val friendsRepository: FriendsRepository,
private val friendsMapper: FriendsMapper,
) {
fun getAll(): FriendsResponseDto {
val entities = friendsRepository.findAll()
return friendsMapper.toDto(entities)
}
}
...Spring Data-репозиторий FriendsRepository
...
interface FriendsRepository : CrudRepository {
override fun findAll(): List
}
... и entity FriendsEntity
:
@Table("friends")
class FriendsEntity(
val id: UUID,
val fullName: String,
val alias: String,
val superpower: String,
)
Здесь мы определили поле superpower
просто как String
. Посмотрим, сработает ли это. Нам также понадобится mapper-интерфейс FriendsMapper
для преобразования entity в DTO...
interface FriendsMapper {
fun toDto(entities: List): FriendsResponseDto
}
...с реализацией FriendsMapperImpl
...
@Component
class FriendsMapperImpl(
private val objectMapper: ObjectMapper,
) : FriendsMapper {
override fun toDto(entities: List) = FriendsResponseDto(
friends = entities.map { entity ->
FriendsFullResponseDto(
id = entity.id,
fullName = entity.fullName,
alias = entity.alias,
superpower = objectMapper.readValue(entity.superpower, FriendsSuperpowerDto::class.java)
)
}
)
}
Тут нам приходится использовать objectMapper
, чтобы преобразовать строку entity.superpower
в объект FriendsSuperpowerDto
. DTO-классы — это просто POJO, в них нет ничего интересного, поэтому код для них я не привожу.
Итак, у нас есть всё, что нужно на данный момент, поэтому давайте запустим приложение и вызовем API для получения списка друзей:
GET http://localhost:8080/api/v1/friends
Что мы получили в ответ? HTTP 500 со следующим сообщением об ошибке в логах:
ConverterNotFoundException: No converter found capable of converting from type [org.postgresql.util.PGobject] to type [java.lang.String]
Да, похоже, что простое использование типа String
для поля FriendsEntity.superpower
в качестве хранилища для jsonb
не работает.
Исправляем ConverterNotFoundException
Какие у нас есть варианты для исправления ConverterNotFoundException
? Начнём с самого очевидного. Конвертер не найден? Не проблема, давайте его добавим! Для этого нам нужно будет добавить Spring configuration, которая наследует от AbstractJdbcConfiguration
:
@Configuration
class JdbcConfig(
private val stringWritingConverter: StringWritingConverter,
private val stringReadingConverter: StringReadingConverter,
) : AbstractJdbcConfiguration() {
override fun userConverters(): MutableList<*> {
return mutableListOf(
stringWritingConverter,
stringReadingConverter,
)
}
}
Переопределяя метод userConverters()
, мы предоставляем собственные конвертеры для записи строкового поля в колонку базы данных jsonb
...
@Component
@WritingConverter
class StringWritingConverter : Converter {
override fun convert(source: String): PGobject {
val jsonObject = PGobject()
jsonObject.type = "jsonb"
jsonObject.value = source
return jsonObject
}
}
...и для чтения из неё:
@Component
@ReadingConverter
class StringReadingConverter : Converter {
override fun convert(pgObject: PGobject): String? {
return pgObject.value
}
}
Вот и всё. Запускаем приложение и вызываем API для получения всех друзей:
GET http://localhost:8080/api/v1/friends
И теперь мы получили список друзей с суперспособностями! 🎉
Исправляем ConverterNotFoundException, часть 2: POJO
Итак, благодаря конвертерам string-to-pgobject, API работает. Однако теперь у нас есть два типа конвертеров:
StringReadingConverter
для чтения из базы данных воFriendsEntity
;objectMapper.readValue()
для преобразования строки в объект при маппинге сущности в DTO вFriendsMapperImpl.toDto()
.
Поскольку структура JSON в поле superpower
фиксирована, мы можем изменить тип поля FriendsEntity.superpower
со String
на собственный класс SuperpowerEntity
:
@Table("friends")
class FriendsEntity(
// other fields are the same
val superpower: SuperpowerEntity,
)
class SuperpowerEntity(
val abilities: List,
val weapon: List,
val rating: Int,
)
И также нам понадобятся новые @WritingConverter
и @ReadingConverter
, чтобы конвертировать из/в SuperpowerEntity
вместо String
:
@Component
@WritingConverter
class SuperpowerEntityWritingConverter(
private val objectMapper: ObjectMapper,
) : Converter {
override fun convert(source: SuperpowerEntity): PGobject {
val jsonObject = PGobject()
jsonObject.type = "jsonb"
jsonObject.value = objectMapper.writeValueAsString(source)
return jsonObject
}
}
@Component
@ReadingConverter
class SuperpowerEntityReadingConverter(
private val objectMapper: ObjectMapper,
) : Converter {
override fun convert(pgObject: PGobject): SuperpowerEntity {
val source = pgObject.value
return objectMapper.readValue(source, SuperpowerEntity::class.java)
}
}
Теперь мы можем обновить JdbcConfig
новыми конвертерами:
@Configuration
class JdbcConfig(
private val superpowerEntityWritingConverter: SuperpowerEntityWritingConverter,
private val superpowerEntityReadingConverter: SuperpowerEntityReadingConverter,
) : AbstractJdbcConfiguration() {
override fun userConverters(): MutableList<*> {
return mutableListOf(superpowerEntityWritingConverter, superpowerEntityReadingConverter)
}
}
И заменить преобразование objectMapper.readValue()
во FriendsMapperImpl
на простое создание FriendsSuperpowerDto
:
@Component
class FriendsMapperImpl : FriendsMapper {
override fun toDto(entities: List) = FriendsResponseDto(
friends = entities.map { entity ->
FriendsFullResponseDto(
id = entity.id,
fullName = entity.fullName,
alias = entity.alias,
superpower = toDto(entity.superpower),
)
}
)
private fun toDto(entity: SuperpowerEntity) = FriendsSuperpowerDto(
abilities = entity.abilities,
weapon = entity.weapon,
rating = entity.rating,
)
}
Запустим приложение, вызовем API...
GET http://localhost:8080/api/v1/friends
...и он по-прежнему работает. Отлично!
Исправляем ConverterNotFoundException, часть 3: Map
Поле FriendsEntity.superpower
строго типизировано, так как мы используем фиксированную JSON-структуру. Но что если нам нужен гибкий JSON с разными полями для разных записей в базе данных? Что мы делаем в любой непонятной ситуации? Правильно, используем Map
. Итак, давайте добавим поле FriendsEntity.extras
, которое может содержать что угодно:
@Table("friends")
class FriendsEntity(
// other fields are the same
val extras: Map?,
)
Вот пример значения поля extras
для Человека-паука:
{
"species": "Human mutate",
"publisher": "Marvel Comics",
"createdBy": [
"Stan Lee",
"Steve Ditko"
]
}
Нам также понадобится Liquibase-скрипт 0003-add-extras.xml
, чтобы добавить колонку и обновить данные:
<changeSet id="add extras column" author="pfilaretov42">
<addColumn tableName="friends">
<column name="extras" type="jsonb"/>
</addColumn>
</changeSet>
<changeSet id="update data with extras" author="pfilaretov42">
<update tableName="friends">
<column name="extras" value='
{
"species": "Human mutate",
"publisher": "Marvel Comics",
"createdBy": [
"Stan Lee",
"Steve Ditko"
]
}
'/>
<where>alias='Spider-Man'</where>
</update>
<!-- Some more updates here -->
</changeSet>
Теперь запустим приложение и вызовем API:
GET http://localhost:8080/api/v1/friends
И получаем HTTP 500:
IllegalArgumentException: Expected map like structure but found class org.postgresql.util.PGobject
Да, нам всё ещё нужны конвертеры для Map
.
Исправляем ConverterNotFoundException, часть 4: Map с конвертером
Чтобы исправить IllegalArgumentException
, нам нужно добавить бины @WritingConverter
и @ReadingConverter
для конвертации из/в Map
...
@Component
@WritingConverter
class MapWritingConverter(
private val objectMapper: ObjectMapper,
) : Converter, PGobject> {
override fun convert(source: Map): PGobject {
val jsonObject = PGobject()
jsonObject.type = "jsonb"
jsonObject.value = objectMapper.writeValueAsString(source)
return jsonObject
}
}
@Component
@ReadingConverter
class MapReadingConverter(
private val objectMapper: ObjectMapper,
) : Converter> {
override fun convert(pgObject: PGobject): Map {
val source = pgObject.value
return objectMapper.readValue(source, object : TypeReference>() {})
}
}
...и добавить их в JdbcConfig
, и теперь он выглядит так:
@Configuration
class JdbcConfig(
private val superpowerEntityWritingConverter: SuperpowerEntityWritingConverter,
private val superpowerEntityReadingConverter: SuperpowerEntityReadingConverter,
private val mapWritingConverter: MapWritingConverter,
private val mapReadingConverter: MapReadingConverter,
) : AbstractJdbcConfiguration() {
override fun userConverters(): MutableList<*> {
return mutableListOf(
superpowerEntityWritingConverter,
superpowerEntityReadingConverter,
mapWritingConverter,
mapReadingConverter,
)
}
}
Нам также нужно добавить поле extras
во FriendsFullResponseDto
...
class FriendsFullResponseDto(
// other fields are the same
val extras: Map?,
)
...и обновить FriendsMapperImpl.toDto()
, чтобы поддержать новое поле в DTO.
Теперь вы знаете, что делать: запустить приложение, вызвать API...
GET http://localhost:8080/api/v1/friends
...и всё работает!
Запрос друга по ID
Хорошо, теперь давайте добавим API для получения друга по ID. Нам нужно будет добавить endpoint во FriendsController
...
@GetMapping("/{id}")
fun get(@PathVariable("id") id: UUID): FriendsFullResponseDto =
friendsService.get(id)
...и метод во FriendsService
:
fun get(id: UUID): FriendsFullResponseDto {
val entity = friendsRepository.findByIdOrNull(id)
?: throw FriendsNotFoundException("Cannot find friend with id=$id")
return friendsMapper.toDto(entity)
}
Здесь entity преобразуется в DTO с помощью нового метода во FriendsMapperImpl
:
override fun toDto(entity: FriendsEntity) = FriendsFullResponseDto(
id = entity.id,
fullName = entity.fullName,
alias = entity.alias,
superpower = toDto(entity.superpower),
extras = entity.extras,
)
Нам также понадобится exception-класс для указания, что друг не найден...
class FriendsNotFoundException(message: String) : RuntimeException(message)
...а также @ExceptionHandler
, чтобы возвращать HTTP 404, когда вылетает FriendsNotFoundException
:
@RestControllerAdvice
class RestExceptionHandler : ResponseEntityExceptionHandler() {
@ExceptionHandler
fun handleNotFound(e: FriendsNotFoundException): ResponseEntity {
return ResponseEntity.notFound().build()
}
}
Всё готово. Давайте запустим приложение, найдём ID существующего друга в таблице friends
и вызовем API с этим ID:
GET http://localhost:8080/api/v1/friends/9463a880-4017-43fd-951e-233fd249091c
Результат:
IllegalStateException: Required identifier property not found for class dev.pfilaretov42.spring.data.jdbc.jsonb.entity.FriendsEntity
Так, Spring не может найти ID-поле. Нам нужно указать его с помощью аннотации @Id
у поля FriendsEntity.id
:
@Id
val id: UUID,
Ещё раз запускаем приложение, вызываем API:
GET http://localhost:8080/api/v1/friends/9463a880-4017-43fd-951e-233fd249091c
Теперь всё работает. И если мы вызовем его с несуществующим ID...
GET http://localhost:8080/api/v1/friends/9463a880-0000-0000-0000-233fd249091c
...то результат — HTTP 404, как и ожидалось.
Создание друга
Давайте на этот раз добавим API для создания друга. Нам потребуется новый endpoint контроллера FriendsController.createFriend()
с соответствующими DTO...
@PostMapping
fun createFriend(@RequestBody request: FriendsRequestDto): CreateFriendResponseDto =
friendsService.create(request)
...метод сервиса FriendsService.create()
...
@Transactional
fun create(request: FriendsRequestDto): CreateFriendResponseDto {
val entity = friendsMapper.fromDto(request)
val createdEntity = friendsRepository.save(entity)
return CreateFriendResponseDto(createdEntity.id)
}
...и метод маппера FriendsMapper.fromDto()
:
override fun fromDto(dto: FriendsRequestDto) = FriendsEntity(
id = UUID.randomUUID(),
fullName = dto.friend.fullName,
alias = dto.friend.alias,
superpower = fromDto(dto.friend.superpower),
extras = dto.friend.extras,
)
Запускаем приложение, вызываем новый API...
POST http://localhost:8080/api/v1/friends
Content-Type: application/json
{
"friend": {
"fullName": "Anthony Edward Stark",
"alias": "Iron Man",
"superpower": {
"abilities": [
"Genius-level intellect",
"Proficient scientist and engineer"
],
"weapon": [
"Powered armor suit"
],
"rating": 77
},
"extras": {
"publisher": "Marvel Comics",
"firstAppearance": {
"comicBook": "Tales of Suspense #39",
"year": 1963
},
"createdBy": [
"Stan Lee",
"Larry Lieber"
]
}
}
}
...и получаем результат — HTTP 500:
IncorrectUpdateSemanticsDataAccessException: Failed to update entity [dev.pfilaretov42.spring.data.jdbc.jsonb.entity.FriendsEntity@4081a76e]; Id [578f74ef-9721-4230-8be6-bc88f252c820] not found in database
Хм, это не то, что мы ожидали. А вот что произошло. ID сущности генерируется во FriendsMapperImpl.fromDto()
с помощью UUID.randomUUID()
. Когда вызывается метод CrudRepository.save()
, ему нужно определить, является ли операция созданием или обновлением сущности. Для этого он проверяет поле с аннотацией @Id
— FriendsEntity.id
:
если оно равно
null
, то создаётся новая записьentity
;если оно не равно
null
, то:если
entity
реализует интерфейсPersistable
, используется методPersistable.isNew()
для выбора между операциями создания и обновления;если
entity
не реализуетPersistable
, то выполняется обновлениеentity
.
В нашем случае поле entity.id
не равно null
, а FriendsEntity
не реализует интерфейс Persistable
. Поэтому вместо ожидаемой операции создания была вызвана операция обновления, которая упала, потому что такой записи в базе данных не существует.
Исправляем IncorrectUpdateSemanticsDataAccessException
Чтобы исправить IncorrectUpdateSemanticsDataAccessException
при создании сущности, у нас есть следующий выбор:
Убедиться, что
FriendsEntity.id
равенnull
, когда мы создаём новую сущность. Для этого нужно, чтобы полеFriendsEntity.id
было nullable:@Table("friends") class FriendsEntity( @Id val id: UUID?, // ... )
Этот подход довольно прост, но есть нюанс: нам нужно будет иметь дело с nullable
id
при преобразовании сущности в DTO. Но ведьid
всегда должен быть заполнен у существующей сущности, не так ли?
Реализовать интерфейс
Persistable
воFriendsEntity
. Тогда мы можем оставить полеFriendsEntity.id
non-nullable и генерировать новый ID во время создания объекта. Вот как будет выглядетьFriendsEntity
:@Table("friends") class FriendsEntity( @Id val id: UUID, // ... ) : Persistable { // ... }Я выберу второй вариант, потому что не люблю иметь дело с полями, допускающими null, которые на самом деле не могут его содержать. Итак, вот наш новый класс FriendsEntity:
Я выберу второй вариант, потому что не люблю иметь дело с полями, допускающими null
, которые на самом деле не могут его содержать. Итак, вот наш новый класс FriendsEntity
:
@Table("friends")
class FriendsEntity(
@Id
@Column("id")
val uuid: UUID,
// ...
) : Persistable {
@Transient
var isNewEntity = false
override fun getId(): UUID = uuid
override fun isNew(): Boolean = isNewEntity
}
Здесь у нас есть transient
-поле isNewEntity
, которое должно быть установлено в true
во время создания сущности в методе FriendsService.create()
. Тогда метод isNew()
будет возвращать true
, и всё должно работать при создании сущности.
Также обратите внимание, что нам пришлось переименовать свойство id
в uuid
. Это связано с тем, что интерфейс Persistable
имеет метод getId()
, который конфликтует со сгенерированным getter-ом для поля id
.
Хорошо, давайте запустим приложение и снова вызовем API для создания:
POST http://localhost:8080/api/v1/friends
Content-Type: application/json
{
"friend": {
"fullName": "Anthony Edward Stark",
"alias": "Iron Man",
"superpower": {
"abilities": [
"Genius-level intellect",
"Proficient scientist and engineer"
],
"weapon": [
"Powered armor suit"
],
"rating": 77
},
"extras": {
"publisher": "Marvel Comics",
"firstAppearance": {
"comicBook": "Tales of Suspense #39",
"year": 1963
},
"createdBy": [
"Stan Lee",
"Larry Lieber"
]
}
}
}
Теперь всё работает и сущность успешно создана.
Обновление друга
Давайте добавим API для обновления друзей. Нам понадобится новый endpoint контроллера FriendsController.updateFriend()
с соответствующими DTO...
@PutMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
fun updateFriend(@PathVariable("id") id: UUID, @RequestBody request: FriendsRequestDto) {
friendsService.update(id, request)
}
...и метод сервиса FriendsService.update()
:
@Transactional
fun update(id: UUID, request: FriendsRequestDto) {
val entity = friendsMapper.fromDto(id, request)
friendsRepository.save(entity)
}
Здесь при создании entity
мы оставляем флаг FriendsEntity.isNewEntity
по умолчанию равным false
.
Также нам нужно добавить параметр id
в метод FriendsMapperImpl.fromDto()
:
если это операция обновления, то
FriendsEntity.uuid
устанавливается в существующий ID;если это операция создания, то
FriendsEntity.uuid
устанавливается в рандомный UUID.
override fun fromDto(id: UUID?, dto: FriendsRequestDto) = FriendsEntity(
uuid = id ?: UUID.randomUUID(),
// ...
)
Запускаем приложение и вызываем API для обновления:
PUT http://localhost:8080/api/v1/friends/85670f8f-aae7-4feb-aa9c-a61574e8b60f
Content-Type: application/json
{
"friend": {
"fullName": "Tony Stark",
"alias": "Iron Man",
"superpower": {
"abilities": [
"Genius-level intellect",
"Proficient scientist and engineer"
],
"weapon": [
"Powered armor suit"
],
"rating": 77
},
"extras": {
"publisher": "Marvel Comics",
"firstAppearance": {
"comicBook": "Tales of Suspense #39",
"year": 1963
},
"createdBy": [
"Stan Lee",
"Larry Lieber"
]
}
}
}
И результат — HTTP 204, как и ожидалось.
Удаление друга
Теперь быстро добавим API для удаления друзей, так как здесь нет ничего интересного с точки зрения работы с jsonb
. Нам понадобится новый endpoint контроллера FriendsController.deleteFriend()
...
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
fun deleteFriend(@PathVariable("id") id: UUID) =
friendsService.delete(id)
...и соответствующий метод сервиса FriendsService.delete()
:
@Transactional
fun delete(id: UUID) =
friendsRepository.deleteById(id)
Вот и всё. Запускаем приложение и вызываем API:
DELETE http://localhost:8080/api/v1/friends/29663075-8ba3-468f-839b-63fefc5059ae
И результат — HTTP 204, как и ожидалось.
Поиск друзей
Теперь у нас есть базовый набор CRUD-операций. Пора добавить API для поиска друзей.
Предположим, что мы хотим найти всех друзей, у которых рейтинг суперспособности больше 50. Помните, где мы храним рейтинг? Он находится в Int
-поле FriendsEntity.superpower.rating
:
@Table("friends")
class FriendsEntity(
val superpower: SuperpowerEntity,
// ...
) : Persistable {
// ...
}
class SuperpowerEntity(
val rating: Int,
// ...
)
А поле superpower
— это колонка базы данных типа jsonb
.
Теперь давайте создадим метод Spring Data Repository
для поиска по рейтингу суперспособности:
interface FriendsRepository : CrudRepository {
fun findBySuperpowerRatingGreaterThan(rating: Int): List
// ...
}
Также нам понадобится новый endpoint контроллера FriendsController.getFriendsBySuperpowerRating()
...
@GetMapping("/by-superpower")
fun getFriendsBySuperpowerRating(
@RequestParam("rating") rating: Int,
@RequestParam("operator") operator: ComparisonOperator,
): FriendsResponseDto =
friendsService.getBySuperpowerRating(rating, operator)
...enum ComparisonOperator
...
enum class ComparisonOperator {
GT, GTE, LT, LTE, BETWEEN
}
...и метод сервиса FriendsService.getBySuperpowerRating()
, который использует ранее созданный метод репозитория FriendsRepository.findBySuperpowerRatingGreaterThan()
:
fun getBySuperpowerRating(rating: Int, operator: ComparisonOperator): FriendsResponseDto {
val entities = when (operator) {
ComparisonOperator.GT -> friendsRepository.findBySuperpowerRatingGreaterThan(rating)
else -> TODO("Not implemented yet")
}
return friendsMapper.toDto(entities)
}
Запускаем приложение, и... оно падает с ошибкой:
BeanCreationException: Error creating bean with name 'friendsRepository'
QueryCreationException: Could not create query for public abstract List findBySuperpower_RatingGreaterThan(int);
MappingException: Couldn't find PersistentEntity for property private final dev.pfilaretov42.spring.data.jdbc.jsonb.entity.SuperpowerEntity dev.pfilaretov42.spring.data.jdbc.jsonb.entity.FriendsEntity.superpower
Похоже, что Spring Data JDBC не может построить запрос к полю внутри jsonb
на основе текущей структуры сущности. Если попробовать другое имя метода, например, findBySuperpower_RatingGreaterThan(int)
, то результат будет тот же. Я не нашёл способа составить имя метода так, чтобы оно работало с колонкой jsonb
. Напишите, пожалуйста, в комментариях, если это возможно.
Исправляем MappingException
Чтобы исправить MappingException
, мы можем вручную определить запрос для метода FriendsRepository.findBySuperpowerRatingGreaterThan()
с помощью аннотации @Query
:
@Query("select * from friends where (superpower->>'rating')::NUMERIC > :rating")
fun findBySuperpowerRatingGreaterThan(rating: Int): List
Теперь приложение запускается без ошибок. И если вызвать API...
GET http://localhost:8080/api/v1/friends/by-superpower?rating=50&operator=GT
...мы получим ожидаемый результат.
Заключение
Итак, на основе Spring Boot и Spring Data JDBC мы сделали API для создания, обновления, удаления и поиска друзей, которые хранят часть данных в колонке PostgreSQL jsonb
. Сложности, с которыми мы столкнулись при реализации API:
выбор корректного типа для свойства
jsonb
в сущности (FriendsEntity.superpower
,FriendsEntity.extras
);реализация конвертеров для преобразования данных из/в
PGobject
;определение запросов Spring Data JDBC для вложенных свойств в
jsonb
(FriendsEntity.superpower.rating
).
Также мы рассмотрели логику метода CrudRepository.save()
для операций создания и обновления и возможные варианты реализации данных операций.