Spring Boot Avengers: объединяем Spring Data JDBC и JSONB в PostgreSQL

от автора

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 -&gt;             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&lt;*&gt; {         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 работает. Однако теперь у нас есть два типа конвертеров:

  1. StringReadingConverter для чтения из базы данных во FriendsEntity;

  2. 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&lt;*&gt; {         return mutableListOf(superpowerEntityWritingConverter, superpowerEntityReadingConverter)     } }

И заменить преобразование objectMapper.readValue() во FriendsMapperImpl на простое создание FriendsSuperpowerDto:

@Component class FriendsMapperImpl : FriendsMapper {     override fun toDto(entities: List) = FriendsResponseDto(         friends = entities.map { entity -&gt;             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&gt; {     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&gt; {     override fun convert(pgObject: PGobject): Map {         val source = pgObject.value         return objectMapper.readValue(source, object : TypeReference&gt;() {})     } }

…и добавить их в 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&lt;*&gt; {         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(), ему нужно определить, является ли операция созданием или обновлением сущности. Для этого он проверяет поле с аннотацией @IdFriendsEntity.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 -&gt; friendsRepository.findBySuperpowerRatingGreaterThan(rating)         else -&gt; 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-&gt;&gt;'rating')::NUMERIC &gt; :rating") fun findBySuperpowerRatingGreaterThan(rating: Int): List

Теперь приложение запускается без ошибок. И если вызвать API…

GET http://localhost:8080/api/v1/friends/by-superpower?rating=50&amp;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/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *