@MapsId
@MapsId is similar to the annotation with the same name in JPA. It marks an owning association whose foreign key columns are mapped into the id of the current entity.
Physical Motivation
@MapsId is not merely about declaring an object association. Its real purpose is to describe a physical table design where the foreign key columns of an association are the same columns as the primary key of the owner, or part of that primary key.
In other words, use @MapsId when:
- the FK of the association is exactly the PK of the current table, or
- the FK of the association is one segment of a composite PK.
This is not limited to a column literally named id. The key point is column identity in the database schema, not the Java/Kotlin property name.
Two common schemas are:
-
Shared primary key one-to-one:
message_delivery.message_idis both:- the primary key of
message_delivery - the foreign key to
message
- the primary key of
-
Composite primary key whose one part is also a foreign key:
tenant_document.tenant_idis both:- one part of the primary key of
tenant_document - the foreign key to
tenant
- one part of the primary key of
The first case maps the whole PK from the target. The second case maps only one path inside the composite PK.
Whole Id Mapping
If the annotation argument is omitted, the whole id of the current entity is mapped from the target entity.
- Java
- Kotlin
@Entity
public interface MessageDelivery {
@Id
long messageId();
String status();
@OneToOne
@MapsId
@JoinColumn
Message message();
}
@Entity
public interface Message {
@Id
long id();
String text();
}
@Entity
interface MessageDelivery {
@Id
val messageId: Long
val status: String
@OneToOne
@MapsId
@JoinColumn
val message: Message
}
@Entity
interface Message {
@Id
val id: Long
val text: String
}
Here, MessageDelivery.messageId is the real id property of the current entity. It is not an @IdView, so it should not be annotated with @IdView.
@JoinColumn is still required because this is the owning side. However, its name does not need to be specified here. Jimmer determines the column name according to the active naming strategy. Under the default naming strategy, the property name message is mapped to the FK column message_id.
When @MapsId maps a whole scalar id, naming the owner id as messageId, tenantId, and so on is legal. That property is still a normal @Id property.
When saving an object, Jimmer keeps the id property and the owning association consistent:
- Java
- Kotlin
MessageDelivery delivery =
MessageDeliveryDraft.$.produce(draft -> {
draft.setMessageId(101L);
draft.setStatus("READ");
draft.applyMessage(message -> {
message.setId(101L);
message.setText("Hi");
});
});
val delivery = MessageDelivery {
messageId = 101L
status = "READ"
message {
id = 101L
text = "Hi"
}
}
Partial Id Mapping
If the annotation argument is specified, it points to a path inside an embedded id.
- Java
- Kotlin
@Embeddable
public interface TenantDocumentId {
long tenantId();
long documentId();
}
@Entity
public interface TenantDocument {
@Id
@PropOverride(prop = "tenantId", columnName = "TENANT_ID")
@PropOverride(prop = "documentId", columnName = "DOCUMENT_ID")
TenantDocumentId id();
String name();
@ManyToOne
@MapsId("tenantId")
@JoinColumn(name = "TENANT_ID", referencedColumnName = "ID")
Tenant tenant();
}
@Embeddable
interface TenantDocumentId {
val tenantId: Long
val documentId: Long
}
@Entity
interface TenantDocument {
@Id
@PropOverride(prop = "tenantId", columnName = "TENANT_ID")
@PropOverride(prop = "documentId", columnName = "DOCUMENT_ID")
val id: TenantDocumentId
val name: String
@ManyToOne
@MapsId("tenantId")
@JoinColumn(name = "TENANT_ID", referencedColumnName = "ID")
val tenant: Tenant
}
Here, only TenantDocument.id.tenantId is mapped from Tenant.id, while documentId is still an ordinary local part of the id.
Another typical whole-id example is a detail table whose primary key is also the foreign key to its parent table. In that design, the detail row cannot exist without the parent row, and @MapsId is the natural mapping for that shape.
Query Optimization
Because mapped id columns already exist on the owner table, many id-oriented operations do not need to join the target table.
-
Using associated ids in predicates, ordering, grouping, and selections can reuse the owner columns directly.
- Java
- Kotlin
q.where(document.asTableEx().tenant().id().eq(1L));
q.groupBy(document.asTableEx().tenant().id());
q.orderBy(document.asTableEx().tenant().id().asc());
return q.select(document.asTableEx().tenant().id());where(table.tenant.id eq 1L)
groupBy(table.tenant.id)
orderBy(table.tenant.id.asc())
select(table.tenant.id)Generated SQL:
select tb_1_.TENANT_ID
from TENANT_DOCUMENT tb_1_
where tb_1_.TENANT_ID = ?
group by tb_1_.TENANT_ID
order by tb_1_.TENANT_ID asc -
When a mapped-id association is only used as a bridge, Jimmer can remove the middle join.
q.where(
document
.asTableEx()
.tenant()
.documents()
.name()
.eq("Spec")
);
return q.select(document.id());Generated SQL:
select tb_1_.TENANT_ID, tb_1_.DOCUMENT_ID
from TENANT_DOCUMENT tb_1_
inner join TENANT_DOCUMENT tb_2_ on tb_1_.TENANT_ID = tb_2_.TENANT_ID
where tb_2_.NAME = ?
This optimization is only applied when it preserves query semantics. In practice, that means the nullability of the path must allow the join to be removed, and no columns or predicates of the bridge target may be needed.
If the bridge target itself is used, Jimmer keeps the join:
q.where(document.asTableEx().tenant().name().eq("Tenant"));
q.where(document.asTableEx().tenant().documents().name().eq("Spec"));
return q.select(document.id());
Generated SQL:
select tb_1_.TENANT_ID, tb_1_.DOCUMENT_ID
from TENANT_DOCUMENT tb_1_
inner join TENANT tb_2_ on tb_1_.TENANT_ID = tb_2_.ID
inner join TENANT_DOCUMENT tb_3_ on tb_2_.ID = tb_3_.TENANT_ID
where tb_2_.NAME = ? and tb_3_.NAME = ?