Как стать автором
Обновить
98.69
Холдинг Т1
Многопрофильный ИТ-холдинг

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

Уровень сложностиСредний
Время на прочтение16 мин
Количество просмотров3.6K
Автор оригинала: pfilaretov42

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

Теги:
Хабы:
Всего голосов 6: ↑5 и ↓1+7
Комментарии1

Публикации

Информация

Сайт
t1.ru
Дата регистрации
Дата основания
Численность
свыше 10 000 человек
Местоположение
Россия
Представитель
ИТ-холдинг Т1