
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.idnon-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() для операций создания и обновления и возможные варианты реализации данных операций.
