Join Table Filter
Two Scenarios
Jimmer supports join table filters, with two usage scenarios:
1. Multiple associations between same entity types
- Java
- Kotlin
@Entity
public interface Shop {
@Id
long id();
String name();
@ManyToMany
@JoinTable(
name = "SHOP_CUSTOMER_MAPPING",
joinColumnName = "SHOP_ID",
inverseJoinColumnName = "CUSTOMER_ID",
readonly = true
)
List<Customer> customers();
@ManyToMany
@JoinTable(
name = "SHOP_CUSTOMER_MAPPING",
joinColumnName = "SHOP_ID",
inverseJoinColumnName = "CUSTOMER_ID",
filter = @JoinTable.JoinTableFilter(
columnName = "MAPPING_TYPE",
type = String.class,
values = "VIP"
)
)
List<Customer> vipCustomers();
@ManyToMany
@JoinTable(
name = "SHOP_CUSTOMER_MAPPING",
joinColumnName = "SHOP_ID",
inverseJoinColumnName = "CUSTOMER_ID",
filter = @JoinTable.JoinTableFilter(
columnName = "MAPPING_TYPE",
type = String.class,
values = "ORDINARY"
)
)
List<Customer> ordinaryCustomers();
}
@Entity
interface Shop {
@Id
val id: Long
val name: String
@ManyToMany
@JoinTable(
name = "SHOP_CUSTOMER_MAPPING",
joinColumnName = "SHOP_ID",
inverseJoinColumnName = "CUSTOMER_ID",
readonly = true
)
val customers: List<Customer>
@ManyToMany
@JoinTable(
name = "SHOP_CUSTOMER_MAPPING",
joinColumnName = "SHOP_ID",
inverseJoinColumnName = "CUSTOMER_ID",
filter = JoinTable.JoinTableFilter(
columnName = "MAPPING_TYPE",
type = String.class,
values = "VIP"
)
)
val vipCustomers: List<Customer>
@ManyToMany
@JoinTable(
name = "SHOP_CUSTOMER_MAPPING",
joinColumnName = "SHOP_ID",
inverseJoinColumnName = "CUSTOMER_ID",
filter = JoinTable.JoinTableFilter(
columnName = "MAPPING_TYPE",
type = String.class,
values = "ORDINARY"
)
)
val ordinaryCustomers: List<Customer>
}
In this example, Shop.customers
, Shop.vipCustomers
, and Shop.ordinaryCustomers
share the same join table
create table SHOP_CUSTOMER_MAPPING(
SHOP_ID bigint not null,
CUSTOMER_ID bigint not null,
MAPPING_TYPE varchar(8) not null
);
// Whether the primary key of the join table contains
// the filter column depends on the specific case.
// In this example, a mapping between a `Shop` and `Customer`
// cannot be both "VIP" and "ORDINARY" at the same time.
// So the primary key does not contain `MAPPING_TYPE`.
alter table SHOP_CUSTOMER_MAPPING
add constraint pk_SHOP_CUSTOMER_MAPPING
primary key(SHOP_ID, CUSTOMER_ID);
alter table SHOP_CUSTOMER_MAPPING
add constraint fk_SHOP_CUSTOMER_MAPPING__SHOP
foreign key(SHOP_ID)
references SHOP(ID);
alter table SHOP_CUSTOMER_MAPPING
add constraint fk_SHOP_CUSTOMER_MAPPING__CUSTOMER
foreign key(CUSTOMER_ID)
references CUSTOMER(ID);
alter table SHOP_CUSTOMER_MAPPING
add constraint fk_SHOP_CUSTOMER_MAPPING__MAPPING_TYPE
check(MAPPING_TYPE in ('VIP', 'ORDINARY'));
The table stores data like below:
SHOP_ID | CUSTOMER_ID | MAPPING_TYPE |
---|---|---|
21 | 3176 | VIP |
21 | 1087 | ORDINARY |
21 | 9155 | ORDINARY |
40 | 8347 | VIP |
40 | 4365 | ORDINARY |
40 | 3478 | ORDINARY |
Where Shop.vipCustomers
corresponds to two rows, Shop.ordinaryCustomers
to four rows, and Shop.customers
can see all rows:
Shop.customers = Shop.vipCustomers + Shop.ordinaryCustomers
We can see that Shop.customers
is not a base association, but the union of other associations. Let's call it a union association.
Union associations cannot be directly modified, so readonly = true
must be declared.
Actually, the union association Shop.customers
has an equivalent alternative syntax:
- Java
- Kotlin
@ManyToMany
@JoinTable(
...other params omitted...,
filter = @JoinTable.JoinTableFilter(
columnName = "MAPPING_TYPE",
type = String.class,
values = {"VIP", "ORDINARY"}
)
)
List<Customer> customers();
@ManyToMany
@JoinTable(
...other params omitted...,
filter = @JoinTable.JoinTableFilter(
columnName = "MAPPING_TYPE",
type = String.class,
values = {"VIP", "ORDINARY"}
)
)
val customers: List<Customer>
Finally, let's look at the difference in JOIN behavior between these three associations:
-
Shop.customers
- Java
- Kotlin
ShopTable table = Tables.SHOP_TABLE;
List<Long> shopIds = sqlClient
.create(table)
.where(
table
.asTableEx()
.customers()
.name().eq("Smith")
)
.select(table.id())
.distinct()
.execute();val shopIds = sqlClient
.create(Shop::class) {
where(
table
.asTableEx()
.customers
.name eq "Smith"
)
select(table.id())
}
.distinct()
.execute();The generated SQL is:
select distinct tb_1_.ID, tb_1_.NAME
from SHOP tb_1_
inner join SHOP_CUSTOMER_MAPPING tb_2_
/* highlight-next-line */
on tb_1_.ID = tb_2_.SHOP_ID
inner join CUSTOMER tb_3_
on tb_2_.CUSTOMER_ID = tb_3_.ID
where tb_3_.NAME = ? /* Smith */ -
Shop.vipCustomers
- Java
- Kotlin
ShopTable table = Tables.SHOP_TABLE;
List<Long> shopIds = sqlClient
.create(table)
.where(
table
.asTableEx()
.vipCustomers()
.name().eq("Smith")
)
.select(table.id())
.distinct()
.execute();val shopIds = sqlClient
.create(Shop::class) {
where(
table
.asTableEx()
.vipCustomers
.name eq "Smith"
)
select(table.id())
}
.distinct()
.execute();The generated SQL is:
select distinct tb_1_.ID, tb_1_.NAME
from SHOP tb_1_
inner join SHOP_CUSTOMER_MAPPING tb_2_
/* highlight-next-line */
on tb_1_.ID = tb_2_.SHOP_ID
/* highlight-next-line */
and tb_2_.MAPPING_TYPE = ? /* VIP */
and
tb_2_.type = ?
inner join CUSTOMER tb_3_
on tb_2_.CUSTOMER_ID = tb_3_.ID
where tb_3_.NAME = ? /* Smith */ -
Shop.ordinaryCustomers
- Java
- Kotlin
ShopTable table = Tables.SHOP_TABLE;
List<Long> shopIds = sqlClient
.create(table)
.where(
table
.asTableEx()
.ordinaryCustomers()
.name().eq("Smith")
)
.select(table.id())
.distinct()
.execute();val shopIds = sqlClient
.create(Shop::class) {
where(
table
.asTableEx()
.ordinaryCustomers
.name eq "Smith"
)
select(table.id())
}
.distinct()
.execute();The generated SQL is:
select distinct tb_1_.ID, tb_1_.NAME
from SHOP tb_1_
inner join SHOP_CUSTOMER_MAPPING tb_2_
/* highlight-next-line */
on tb_1_.ID = tb_2_.SHOP_ID
/* highlight-next-line */
and tb_2_.MAPPING_TYPE = ? /* ORDINARY */
and
tb_2_.type = ?
inner join CUSTOMER tb_3_
on tb_2_.CUSTOMER_ID = tb_3_.ID
where tb_3_.NAME = ? /* Smith */
2. Multiple associations between different entity types
A common case is when a project has many kinds of data, and each kind of data can be tagged with multiple Tag
s.
-
Tag
- Java
- Kotlin
@Entity
public interface Tag {
@Id
long id();
String name();
...other properties omitted...
}@Entity
interface Tag {
@Id
val id: Long
val name: String
...other properties omitted...
} -
BookStore
- Java
- Kotlin
@Entity
public interface BookStore {
@ManyToMany
@JoinTable(
name = "SOURCE_TAG_MAPPING",
joinColumns = @joinColumn(
name = "SOURCE_ID",
foreignKeyType = ForeignKeyType.FAKE
),
inverseJoinColumnName = "TAG_ID",
filter = @JoinTable.JoinTableFilter(
columnName = "SOURCE_TYPE",
type = String.class,
values = "BOOK_STORE",
)
)
List<Tag> tags();
...other properties omitted...
}@Entity
interface BookStore {
@ManyToMany
@JoinTable(
name = "SOURCE_TAG_MAPPING",
joinColumns = @joinColumn(
name = "SOURCE_ID",
foreignKeyType = ForeignKeyType.FAKE
),
inverseJoinColumnName = "TAG_ID",
filter = JoinTable.JoinTableFilter(
columnName = "SOURCE_TYPE",
type = String.class,
values = "BOOK_STORE",
)
)
val tags: List<Tag>
...other properties omitted...
} -
Book
- Java
- Kotlin
@Entity
public interface Book {
@ManyToMany
@JoinTable(
name = "SOURCE_TAG_MAPPING",
joinColumns = @joinColumn(
name = "SOURCE_ID",
foreignKeyType = ForeignKeyType.FAKE
),
inverseJoinColumnName = "TAG_ID",
filter = @JoinTable.JoinTableFilter(
columnName = "SOURCE_TYPE",
type = String.class,
values = "BOOK",
)
)
List<Tag> tags();
...other properties omitted...
}@Entity
interface Book {
@ManyToMany
@JoinTable(
name = "SOURCE_TAG_MAPPING",
joinColumns = @joinColumn(
name = "SOURCE_ID",
foreignKeyType = ForeignKeyType.FAKE
),
inverseJoinColumnName = "TAG_ID",
filter = JoinTable.JoinTableFilter(
columnName = "SOURCE_TYPE",
type = String.class,
values = "BOOK",
)
)
val tags: List<Tag>
...other properties omitted...
} -
Author
- Java
- Kotlin
@Entity
public interface Author {
@ManyToMany
@JoinTable(
name = "SOURCE_TAG_MAPPING",
joinColumns = @joinColumn(
name = "SOURCE_ID",
foreignKeyType = ForeignKeyType.FAKE
),
inverseJoinColumnName = "TAG_ID",
filter = @JoinTable.JoinTableFilter(
columnName = "SOURCE_TYPE",
type = String.class,
values = "AUTHOR",
)
)
List<Tag> tags();
...other properties omitted...
}@Entity
interface Author {
@ManyToMany
@JoinTable(
name = "SOURCE_TAG_MAPPING",
joinColumns = @joinColumn(
name = "SOURCE_ID",
foreignKeyType = ForeignKeyType.FAKE
),
inverseJoinColumnName = "TAG_ID",
filter = JoinTable.JoinTableFilter(
columnName = "SOURCE_TYPE",
type = String.class,
values = "AUTHOR",
)
)
val tags: List<Tag>
...other properties omitted...
}
For the join table SOURCE_TAG_MAPPING
, although the foreign key TAG_ID
references the TAG
table,
the other foreign key SOURCE_ID
does not have a fixed target - it could reference BOOK_STORE
, BOOK
or AUTHOR
.
Therefore we cannot add a foreign key constraint on SOURCE_ID
.
create table SOURCE_TAG_MAPPING(
SOURCE_ID bigint not null,
TAG_ID bigint not null,
SOURCE_TYPE varchar(10) not null
);
// Whether the primary key contains the filter column
// depends on the specific case.
// In this example, `SOURCE_ID` of different types could be equal,
// so the primary key constraint needs to contain `SOURCE_TYPE`.
alter table SOURCE_TAG_MAPPING
add constraint pk_SHOP_CUSTOMER_MAPPING
primary key(SOURCE_ID, TAG_ID, SOURCE_TYPE);
// highlight-next-line
// `SOURCE_ID` could reference `BOOK_STORE`, `BOOK` or `AUTHOR`,
// highlight-next-line
// so we cannot add a foreign key constraint
alter table SOURCE_TAG_MAPPING
add constraint fk_SOURCE_TAG_MAPPING__TAG
primary key(TAG_ID)
references TAG(ID);
alter table SOURCE_TAG_MAPPING
add constraint ck_SOURCE_TAG_MAPPING__SOURCE_TYPE
check(SOURCE_TYPE in ('BOOK_STORE', 'BOOK', 'AUTHOR'));
Since SOURCE_ID
cannot have a foreign key constraint, it is a fake foreign key.
That's why in the code above:
@JoinTable(
joinColumns = @joinColumn(
name = "SOURCE_ID",
foreignKeyType = ForeignKeyType.FAKE
),
...other params omitted...
)
Sample data could look like:
SOURCE_ID | TARGET_ID | SOURCE_TYPE |
---|---|---|
9 | 2 | BOOK_STORE |
9 | 4 | BOOK_STORE |
86 | 3 | BOOK |
86 | 4 | BOOK |
781 | 1 | AUTHOR |
781 | 3 | AUTHOR |
Mixing One-to-Many and Many-to-Many
Both one-to-many and many-to-many can use join tables, so we can merge their join tables into one table. But please note:
It is strongly recommended to only use this pattern when the database supports conditional indexes.
That is, do not use this pattern on databases without conditional index support like MySQL.
Define Associations
- Java
- Kotlin
@Entity
public interface Book {
@ManyToMany
@JoinTable(
name = "BOOK_AUTHOR_MAPPING",
joinColumnName = "BOOK_ID",
inverseJoinColumnName = "AUTHOR_ID",
readonly = true
)
List<Author> authors();
@Nullable
@ManyToOne
@JoinTable(
name = "BOOK_AUTHOR_MAPPING",
joinColumnName = "BOOK_ID",
inverseJoinColumnName = "AUTHOR_ID",
filter = @JoinTable.JoinTableFilter(
columnName = "MAPPING_TYPE",
type = String.class,
values = "PRIMARY"
)
)
Author primaryAuthor();
@ManyToMany
@JoinTable(
name = "BOOK_AUTHOR_MAPPING",
joinColumnName = "BOOK_ID",
inverseJoinColumnName = "AUTHOR_ID",
filter = @JoinTable.JoinTableFilter(
columnName = "MAPPING_TYPE",
type = String.class,
values = "SECONDARY"
)
)
List<Author> secondaryAuthors();
...other properties omitted...
}
@Entity
interface Book {
@ManyToMany
@JoinTable(
name = "BOOK_AUTHOR_MAPPING",
joinColumnName = "BOOK_ID",
inverseJoinColumnName = "AUTHOR_ID",
readonly = true
)
val authors: List<Author>
@Nullable
@ManyToOne
@JoinTable(
name = "BOOK_AUTHOR_MAPPING",
joinColumnName = "BOOK_ID",
inverseJoinColumnName = "AUTHOR_ID",
filter = JoinTable.JoinTableFilter(
columnName = "MAPPING_TYPE",
type = String.class,
values = "PRIMARY"
)
)
val primaryAuthor: Author?
@ManyToMany
@JoinTable(
name = "BOOK_AUTHOR_MAPPING",
joinColumnName = "BOOK_ID",
inverseJoinColumnName = "AUTHOR_ID",
filter = @JoinTable.JoinTableFilter(
columnName = "MAPPING_TYPE",
type = String.class,
values = "SECONDARY"
)
)
val secondaryAuthors: List<Author>
...other properties omitted...
}
Clearly:
authors
(many-to-many) = primaryAuthor
(many-to-one) + secondaryAuthors
(many-to-many)
Conditional Indexes
create table BOOK_AUTHOR_MAPPING(
BOOK_ID bigint not null,
AUTHOR_ID bigint not null,
MAPPING_TYPE varchar(9) not null
);
// Whether the primary key contains the filter column depends on the specific case.
// In this example, a mapping between an `Author` and a `Book`
// cannot be both "PRIMARY" and "SECONDARY" at the same time.
// So the primary key constraint does not contain `MAPPING_TYPE`.
alter table BOOK_AUTHOR_MAPPING
add constraint pk_BOOK_AUTHOR_MAPPING
primary key(BOOK_ID, AUTHOR_ID);
alter table BOOK_AUTHOR_MAPPING
add constraint fk_BOOK_AUTHOR_MAPPING__BOOK
foreign key(BOOK_ID)
references BOOK(ID);
alter table BOOK_AUTHOR_MAPPING
add constraint fk_BOOK_AUTHOR_MAPPING__BOOK
foreign key(AUTHOR_ID)
references AUTHOR(ID);
alter table BOOK_AUTHOR_MAPPING
add constraint ck_BOOK_AUTHOR_MAPPING__MAPPING_TYPE
check(MAPPING_TYPE in ('PRIMARY', 'SECONDARY'));
// highlight-start
// Syntax for conditional indexes may differ slightly between databases,
// here is an example in Postgres
create unique index BOOK_AUTHOR_MAPPING__PRIMARY_INDEX
on BOOK_AUTHOR_MAPPING(AUTHOR_ID)
when MAPPING_TYPE = 'PRIMARY';
// highlight-end
The when
clause is very important, indicating this is a conditional index.
Syntax for conditional indexes may differ slightly between databases, here is an example in Postgres. See https://www.postgresql.org/docs/current/indexes-partial.html for more details.
-
When
MAPPING_TYPE
is "PRIMARY", a unique index is added on theAUTHOR_ID
foreign key to ensure eachBook
has maximum one primaryAuthor
.
That is, to ensureBook.primaryAuthor
is a many-to-one association. -
Otherwise, there is no uniqueness constraint, so each
Book
can have any number of secondaryAuthor
s.
That is,Book.secondaryAuthors
remains a many-to-many association.