Эксперт Spring АйО и по совместительству Spring Data контрибьютор Михаил Поливаха прокомментировал статью, переведенную командой Spring АйО, про поддержку составных ключей со стороны Spring Data JDBC и R2DBC, начиная с версии 4.0.0-M4 — то, чего так не хватало при работе с моделями, где первичный ключ состоит из нескольких полей. Теперь достаточно просто описать record
с нужными полями, пометить его как @Id
, и Spring Data сам корректно построит SQL-сущность. В статье наглядно показано, как использовать новую возможность, какие аннотации пригодятся и как обойти ограничение с автоинкрементом через BeforeConvertCallback
.
Рады сообщить, что начиная с версии 4.0.0-M4 Spring Data JDBC и R2DBC наконец-то получили поддержку составных идентификаторов.
Комментарий от Михаила Поливахи
Обратите внимание, что Spring Data JDBC/R2DBC 4.x ещё не вышла, ещё нет GA релиза. Речь в статье идёт про milestone, то есть эта фича точно будет срелижена в 4-ой версии Spring Data JDBC/R2DBC, что будет уже очень скоро, наряду с Spring Framework 7
Скорее всего, большинство из вас уже знает, но на всякий случай повторим: с точки зрения базы данных составной (композиционный) идентификатор (или составной ключ) — это первичный ключ, состоящий из нескольких столбцов. В Java такие столбцы мапятся на сущность, содержащую соответствующее поле для каждого столбца. Использование должно быть интуитивно понятным, и в этой статье я продемонстрирую его на примере JDBC. Использование в R2DBC аналогично.
Для начала просто добавьте аннотацию @Id
к полю в корне агрегата, которое ссылается на составной ключ, а не на примитив, обёртку над примитивом и т.п.
class Employee {
@Id
EmployeeId id;
String name;
// ...
}
record EmployeeId(
Organization organization,
Long employeeNumber) {
}
enum Organization {
RND,
SALES,
MARKETING,
PURCHASING
}
Такая ссылка автоматически встраивается в сущность и превращается в составной ключ. Иными словами, поля сущности превращаются в столбцы таблицы в корне агрегата.
create table employee
(
organization varchar(20),
employee_number int,
name varchar(100)
);
Если вы хотите изменить имена столбцов, можно добавить аннотацию @Embedded
, которая позволяет указать префикс. Это может выглядеть немного странно — указывать, что должно происходить при загрузке сущности, когда все её значения равны null
. Но с @Embedded
это необходимо, хотя первичный ключ, состоящий из null-значений, приведёт к возникновению проблем практически везде и просто не будет работать.
Комментарий от Михаила Поливахи
Здесь речь идет о том, как изначально работала аннотация Embedded
, а конкретно про её параметр onEmpty
: https://github.com/spring-projects/spring-data-relational/blob/main/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/Embedded.java#L52
Дело в том, что изначально эта аннотация использовалась для представления embedded
типов. Используя @Embedded
вы были обязаны указывать аттрибут onEmpty чтобы явно сказать Spring Data JDBC, создавать ли пустой enclosing-embedded
объект, если все поля в рамках этого embedded
объекта и так null
. Для простых embedded
типов это реально имело смысл.
Когда эту аннотацию переиспользовали в рамках композитных ключей, то стало ясно, что ни одно значение в рамках аттрибута onEmpty
смысла не имеет, т.к. в большинстве случаев не бывает такого композитного Primary Key, который содержит везде null
в своих колонках.
Но Вам все равно надо указать onEmpty
аттрибут, пусть и фиктивно. Или использовать шорткаты типа @Embedded.Nullable
как делает Jens.
Об этом и говорит Team Lead проекта.
Мы ещё подумаем, что с этим сделать. Но пока в релиз скорее всего оно уйдёт именно таким.
class Employee {
@Id
@Embedded.Nullable(prefix = "id_")
EmployeeId id;
String name;
// ...
}
create table employee
(
id_organization varchar(20),
id_employee_number int,
name varchar(100)
);
Как и в случае с обычными идентификаторами, Spring Data Relational использует значение идентификатора как признак новой сущности: если значение самого embedded
идентификатора, а не его составных частей, равно null, сущность считается новой и будет выполнена операция вставки. Если значение идентификатора не null
, выполняется обновление.
Комментарий от команды Spring АйО
Spring Data Relational – это такой термин "зонтик" для обозначения как Spring Data JDBC, так и Spring Data R2DBC.
При сохранении новой сущности с составным идентификатором возникает небольшая проблема: составные идентификаторы не могут быть легко сгенерированы с помощью IDENTITY
колонки в БД, поскольку по определению они состоят из нескольких столбцов. Один из способов решения этой задачи — использование BeforeConvertCallback
.
@Bean
BeforeConvertCallback<Employee> idGeneration() {
return new BeforeConvertCallback<>() {
AtomicLong counter = new AtomicLong();
@Override
public Employee onBeforeConvert(Employee employee) {
if (employee.id == null) {
employee.id = new EmployeeId(Organization.RND, counter.addAndGet(1));
}
return employee;
}
};
}
repository.save(new Employee("Mark Paluch"));
В большинстве случаев при использовании составного идентификатора, вероятно, проще задать его заранее и либо использовать optimistic locking — то есть null
в поле версии будет означать, что сущность новая — либо явно вызвать JdbcAggregateTemplate.insert
.
interface EmployeeRepository extends Repository<Employee, EmployeeId>, InsertRepository<Employee> {
Employee findById(EmployeeId id);
Employee save(Employee employee);
}
interface InsertRepository<E> {
E insert(E employee);
}
class InsertRepositoryImpl<E> implements InsertRepository<E> {
@Autowired
private JdbcAggregateTemplate template;
@Override
public E insert(E employee) {
return template.insert(employee);
}
}
@Autowired
EmployeeRepository repository;
// ...
repository.insert(new Employee(new EmployeeId(Organization.RND, 23L), "Jens Schauder"));
Полный исходный код примеров, использованных в этой статье, доступен по адресу.

Присоединяйтесь к русскоязычному сообществу разработчиков на Spring Boot в телеграм — Spring АйО, чтобы быть в курсе последних новостей из мира разработки на Spring Boot и всего, что с ним связано.