
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() для операций создания и обновления и возможные варианты реализации данных операций.
ссылка на оригинал статьи https://habr.com/ru/articles/858762/
Добавить комментарий