Skip to content

Commit 67991c2

Browse files
authored
feat: working entity inheritance (#108)
1 parent 8d2404d commit 67991c2

File tree

8 files changed

+126
-44
lines changed

8 files changed

+126
-44
lines changed

tzatziki-common/src/main/java/com/decathlon/tzatziki/utils/Types.java

Lines changed: 21 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
import java.lang.reflect.ParameterizedType;
99
import java.lang.reflect.Type;
10+
import java.lang.reflect.TypeVariable;
1011
import java.util.Map;
1112
import java.util.stream.Collectors;
1213
import java.util.stream.Stream;
@@ -20,15 +21,15 @@
2021
public final class Types {
2122

2223
private static final Map<Class<?>, Class<?>> WRAPPER_BY_PRIMITIVE = ImmutableMap.<Class<?>, Class<?>>builder()
23-
.put(byte.class, Byte.class)
24-
.put(short.class, Short.class)
25-
.put(int.class, Integer.class)
26-
.put(long.class, Long.class)
27-
.put(float.class, Float.class)
28-
.put(double.class, Double.class)
29-
.put(boolean.class, Boolean.class)
30-
.put(char.class, Character.class)
31-
.build();
24+
.put(byte.class, Byte.class)
25+
.put(short.class, Short.class)
26+
.put(int.class, Integer.class)
27+
.put(long.class, Long.class)
28+
.put(float.class, Float.class)
29+
.put(double.class, Double.class)
30+
.put(boolean.class, Boolean.class)
31+
.put(char.class, Character.class)
32+
.build();
3233

3334
public static <E> Class<E> rawTypeArgumentOf(Type type) {
3435
return rawTypeOf(typeArgumentOf(type));
@@ -41,7 +42,12 @@ public static Type typeArgumentOf(Type type) {
4142
public static Type typeArgumentOf(Type type, int argumentIndex) {
4243
if (type instanceof ParameterizedType parameterizedType) {
4344
if (parameterizedType.getActualTypeArguments().length > argumentIndex) {
44-
return parameterizedType.getActualTypeArguments()[argumentIndex];
45+
Type targetType = parameterizedType.getActualTypeArguments()[argumentIndex];
46+
if (targetType instanceof TypeVariable<?> variableTargetType) {
47+
Type[] bounds = variableTargetType.getBounds();
48+
targetType = bounds.length > 0 ? bounds[0] : targetType;
49+
}
50+
return targetType;
4551
}
4652
throw new IllegalArgumentException(parameterizedType.getTypeName() + " doesn't have a TypeArgument " + argumentIndex);
4753
} else {
@@ -90,10 +96,10 @@ private ParameterizedTypeImpl(Class<?> rawType, Type[] actualTypeArguments) {
9096
this.rawType = rawType;
9197
if (rawType.getTypeParameters().length != actualTypeArguments.length) {
9298
throw new IllegalArgumentException(
93-
"type %s expects %d argument%s but got %d".formatted(
94-
rawType, rawType.getTypeParameters().length,
95-
rawType.getTypeParameters().length > 1 ? "s" : "",
96-
actualTypeArguments.length));
99+
"type %s expects %d argument%s but got %d".formatted(
100+
rawType, rawType.getTypeParameters().length,
101+
rawType.getTypeParameters().length > 1 ? "s" : "",
102+
actualTypeArguments.length));
97103
}
98104
this.actualTypeArguments = actualTypeArguments;
99105
}
@@ -116,7 +122,7 @@ public Type getOwnerType() {
116122
@Override
117123
public String toString() {
118124
return "%s<%s>".formatted(rawType.getTypeName(),
119-
Stream.of(actualTypeArguments).map(Type::getTypeName).collect(Collectors.joining(", ")));
125+
Stream.of(actualTypeArguments).map(Type::getTypeName).collect(Collectors.joining(", ")));
120126
}
121127
}
122128
}

tzatziki-spring-jpa/src/main/java/com/decathlon/tzatziki/steps/SpringJPASteps.java

Lines changed: 56 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,23 @@
77
import io.cucumber.java.en.Then;
88
import org.apache.commons.lang3.reflect.TypeUtils;
99
import org.jetbrains.annotations.NotNull;
10+
import org.jetbrains.annotations.Nullable;
1011
import org.springframework.beans.factory.annotation.Autowired;
1112
import org.springframework.data.repository.CrudRepository;
1213
import org.springframework.jdbc.core.JdbcTemplate;
1314
import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean;
1415

1516
import javax.persistence.Entity;
1617
import javax.persistence.Table;
18+
import javax.persistence.spi.PersistenceUnitInfo;
1719
import javax.sql.DataSource;
1820
import java.lang.reflect.Type;
21+
import java.lang.reflect.TypeVariable;
22+
import java.util.ArrayList;
23+
import java.util.Collection;
1924
import java.util.List;
2025
import java.util.Map;
26+
import java.util.function.Function;
2127
import java.util.stream.Collectors;
2228
import java.util.stream.Stream;
2329
import java.util.stream.StreamSupport;
@@ -41,6 +47,7 @@ public class SpringJPASteps {
4147

4248
@Autowired(required = false)
4349
private List<LocalContainerEntityManagerFactoryBean> entityManagerFactories;
50+
private Map<String, Class<?>> entityClassByTableName;
4451

4552
private boolean disableTriggers = true;
4653
private final ObjectSteps objects;
@@ -59,6 +66,17 @@ public void before() {
5966
DatabaseCleaner.setTriggers(dataSource, schemaToClean, DatabaseCleaner.TriggerStatus.enable);
6067
});
6168
}
69+
70+
if (entityClassByTableName == null) {
71+
entityClassByTableName = entityManagerFactories.stream()
72+
.map(LocalContainerEntityManagerFactoryBean::getPersistenceUnitInfo)
73+
.map(PersistenceUnitInfo::getManagedClassNames)
74+
.flatMap(Collection::stream)
75+
.map(TypeParser::parse)
76+
.map(type -> (Class<?>) type)
77+
.filter(entityClass -> entityClass.isAnnotationPresent(Table.class))
78+
.collect(Collectors.toMap(entityClass -> entityClass.getAnnotation(Table.class).name(), Function.identity()));
79+
}
6280
}
6381

6482
@NotNull
@@ -76,12 +94,12 @@ public void the_repository_will_contain(Guard guard, Type repositoryType, Insert
7694

7795
@Given(THAT + GUARD + "the ([^ ]+) table will contain" + INSERTION_MODE + ":$")
7896
public void the_table_will_contain(Guard guard, String table, InsertionMode insertionMode, Object content) {
79-
the_repository_will_contain(guard, getRepositoryForTable(table), insertionMode, objects.resolve(content));
97+
the_repository_will_contain_with_type(guard, getRepositoryForTable(table), insertionMode, entityTypeByTableNameOrClassName(table), objects.resolve(content));
8098
}
8199

82100
@Given(THAT + GUARD + "the " + TYPE + " entities will contain" + INSERTION_MODE + ":$")
83101
public void the_entities_will_contain(Guard guard, Type type, InsertionMode insertionMode, Object content) {
84-
the_repository_will_contain(guard, getRepositoryForEntity(type), insertionMode, objects.resolve(content));
102+
the_repository_will_contain_with_type(guard, getRepositoryForEntity(type), insertionMode, type, objects.resolve(content));
85103
}
86104

87105
@Given(THAT + GUARD + "the triggers are (enable|disable)d$")
@@ -134,21 +152,27 @@ private void the_repository_contains_nothing(Guard guard, CrudRepository<Object,
134152
}
135153

136154
public <E> void the_repository_will_contain(Guard guard, CrudRepository<E, ?> repository, InsertionMode insertionMode, String entities) {
155+
the_repository_will_contain_with_type(guard, repository, insertionMode, getEntityType(repository), entities);
156+
}
157+
158+
public <E> void the_repository_will_contain_with_type(Guard guard, CrudRepository<E, ?> repository, InsertionMode insertionMode, Type entityType, String entities) {
137159
guard.in(objects, () -> {
160+
if (!(entityType instanceof Class<?>)) return;
161+
162+
Class<E> entityClass = (Class<E>) entityType;
138163
if (disableTriggers) {
139164
dataSources().forEach(dataSource -> DatabaseCleaner.setTriggers(dataSource, schemaToClean, DatabaseCleaner.TriggerStatus.disable));
140165
}
141-
Class<E> entityType = getEntityType(repository);
142166
if (insertionMode == InsertionMode.ONLY) {
143-
String table = entityType.getAnnotation(Table.class).name();
167+
String table = entityClass.getAnnotation(Table.class).name();
144168
DataSource dataSource = entityManagerFactories.stream()
145169
.filter(entityManagerFactory -> entityManagerFactory.getPersistenceUnitInfo() != null)
146-
.filter(entityManagerFactory -> entityManagerFactory.getPersistenceUnitInfo().getManagedClassNames().contains(entityType.getName()))
170+
.filter(entityManagerFactory -> entityManagerFactory.getPersistenceUnitInfo().getManagedClassNames().contains(entityClass.getName()))
147171
.map(LocalContainerEntityManagerFactoryBean::getDataSource).findFirst()
148172
.orElseThrow();
149173
new JdbcTemplate(dataSource).update("TRUNCATE %s RESTART IDENTITY CASCADE".formatted(table));
150174
}
151-
repository.saveAll(Mapper.readAsAListOf(entities, entityType));
175+
repository.saveAll(Mapper.readAsAListOf(entities, entityClass));
152176
if (disableTriggers) {
153177
dataSources().forEach(dataSource -> DatabaseCleaner.setTriggers(dataSource, schemaToClean, DatabaseCleaner.TriggerStatus.enable));
154178
}
@@ -168,20 +192,13 @@ public <E> void add_repository_content_to_variable(Guard guard, String name, Cru
168192
guard.in(objects, () -> objects.add(name, StreamSupport.stream(repository.findAll().spliterator(), false).toList()));
169193
}
170194

171-
@SuppressWarnings("unchecked")
172195
public <E> CrudRepository<E, ?> getRepositoryForTable(String table) {
173-
return spring.applicationContext().getBeansOfType(CrudRepository.class).values()
174-
.stream()
175-
.map(bean -> (CrudRepository<E, ?>) bean)
176-
.filter(r -> {
177-
Class<E> e = getEntityType(r);
178-
return e != null && (
179-
(e.isAnnotationPresent(Table.class) && e.getAnnotation(Table.class).name().equals(table))
180-
|| e.getSimpleName().equals(table)
181-
|| toSnakeCase(e.getSimpleName()).equals(table));
182-
}).findFirst().orElseThrow(() -> new AssertionError(
183-
"there was no CrudRepository found for table '%s'! If you don't need one in your app, you must create one in your tests!".formatted(table)
184-
));
196+
return getRepositoryForEntity(entityTypeByTableNameOrClassName(table));
197+
}
198+
199+
@Nullable
200+
private Type entityTypeByTableNameOrClassName(String entityTableOrClass) {
201+
return entityClassByTableName.containsKey(entityTableOrClass) ? entityClassByTableName.get(entityTableOrClass) : TypeParser.parse(entityTableOrClass);
185202
}
186203

187204
@SuppressWarnings({"unchecked"})
@@ -190,7 +207,26 @@ public <E> void add_repository_content_to_variable(Guard guard, String name, Cru
190207
return spring.applicationContext().getBeansOfType(CrudRepository.class).values()
191208
.stream()
192209
.map(bean -> (CrudRepository<E, ?>) bean)
193-
.filter(r -> type.equals(TypeUtils.unrollVariables(TypeUtils.getTypeArguments(r.getClass(), CrudRepository.class), CrudRepository.class.getTypeParameters()[0])))
210+
.filter(r -> {
211+
Map<TypeVariable<?>, Type> typeArguments = TypeUtils.getTypeArguments(r.getClass(), CrudRepository.class);
212+
if (type.equals(TypeUtils.unrollVariables(typeArguments, CrudRepository.class.getTypeParameters()[0])))
213+
return true;
214+
215+
if (type instanceof Class<?> clazz
216+
&& typeArguments.get(CrudRepository.class.getTypeParameters()[0]) instanceof TypeVariable<?> typeVariable) {
217+
Type handledEntitySuperclass = typeVariable.getBounds()[0];
218+
219+
List<Class<?>> superClasses = new ArrayList<>();
220+
while (clazz != Object.class) {
221+
superClasses.add(clazz);
222+
clazz = clazz.getSuperclass();
223+
}
224+
225+
return superClasses.contains(handledEntitySuperclass);
226+
}
227+
228+
return false;
229+
}).sorted((r1, r2) -> TypeUtils.getTypeArguments(r1.getClass(), CrudRepository.class).values().stream().findFirst().orElse(null) instanceof TypeVariable<?> typeVariable ? 1 : 0)
194230
.findFirst().orElseThrow(() -> new AssertionError("there was no CrudRepository found for entity %s! If you don't need one in your app, you must create one in your tests!".formatted(type.getTypeName())));
195231
}
196232
throw new AssertionError(type + " is not an Entity!");

tzatziki-spring-jpa/src/test/java/com/decathlon/tzatziki/app/api/UsersController.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
public class UsersController {
2020

2121
@Autowired
22-
private UserDataSpringRepository userDataSpringRepository;
22+
private UserDataSpringRepository<User> userDataSpringRepository;
2323

2424
@GetMapping("/users/{id}")
2525
public ResponseEntity<User> getUser(@PathVariable Integer id) {

tzatziki-spring-jpa/src/test/java/com/decathlon/tzatziki/app/dao/UserDataSpringRepository.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,4 @@
33
import com.decathlon.tzatziki.app.model.User;
44
import org.springframework.data.repository.CrudRepository;
55

6-
public interface UserDataSpringRepository extends CrudRepository<User, Integer> {}
6+
public interface UserDataSpringRepository<T extends User> extends CrudRepository<T, Integer> {}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package com.decathlon.tzatziki.app.model;
2+
3+
import lombok.Getter;
4+
import lombok.NoArgsConstructor;
5+
6+
import javax.persistence.Column;
7+
import javax.persistence.Entity;
8+
import javax.persistence.Table;
9+
10+
@Getter
11+
@Entity
12+
@NoArgsConstructor
13+
@Table(name = "superusers")
14+
public class SuperUser extends User {
15+
@Column(name = "role")
16+
String role;
17+
}

tzatziki-spring-jpa/src/test/java/com/decathlon/tzatziki/app/model/User.java

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,13 @@
66
import javax.persistence.*;
77
import java.time.Instant;
88

9-
@NoArgsConstructor
9+
import static javax.persistence.InheritanceType.JOINED;
10+
1011
@Getter
1112
@Entity
13+
@NoArgsConstructor
1214
@Table(name = "users")
15+
@Inheritance(strategy = JOINED)
1316
public class User {
1417

1518
@Id

tzatziki-spring-jpa/src/test/resources/com/decathlon/tzatziki/steps/spring-jpa.feature

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ Feature: to interact with a spring boot service having a persistence layer
7474
| 2 | Han | Solo |
7575

7676
And when we call "/users"
77-
Then we receive exactly:
77+
Then we receive only:
7878
"""yml
7979
- id: 1
8080
firstName: Darth
@@ -173,8 +173,10 @@ Feature: to interact with a spring boot service having a persistence layer
173173
| 2 | Han | Solo |
174174
Then usersTableContent is the users table content
175175
And usersTableContent.size is equal to 2
176-
And usersTableContent[0].id is equal to 1
177-
And usersTableContent[1].id is equal to 2
176+
And usersTableContent contains only:
177+
| id | firstName | lastName |
178+
| 1 | Darth | Vader |
179+
| 2 | Han | Solo |
178180

179181
Scenario: we can get entities
180182
Given that the User entities will contain only:
@@ -183,8 +185,10 @@ Feature: to interact with a spring boot service having a persistence layer
183185
| 2 | Han | Solo |
184186
Then userEntities is the User entities
185187
And userEntities.size is equal to 2
186-
And userEntities[0].id is equal to 1
187-
And userEntities[1].id is equal to 2
188+
And userEntities contains only:
189+
| id | firstName | lastName |
190+
| 1 | Darth | Vader |
191+
| 2 | Han | Solo |
188192

189193
Scenario: there shouldn't be any "within" implicit guard in JPA assertions
190194
Given that after 100ms the User entities will contain only:
@@ -225,4 +229,14 @@ Feature: to interact with a spring boot service having a persistence layer
225229
| 1 | true |
226230
Then it is not true that the evilness table contains:
227231
| id | evil |
228-
| 1 | false |
232+
| 1 | false |
233+
234+
Scenario: we can use extended entities and manage their tables (ex. superusers extends users)
235+
Given the superusers table will contain:
236+
| id | firstName | lastName | role |
237+
| 1 | Darth | Vader | admin |
238+
| 2 | Anakin | Skywalker | dummy |
239+
Then the superusers table contains:
240+
| id | firstName | lastName | role |
241+
| 1 | Darth | Vader | admin |
242+
| 2 | Anakin | Skywalker | dummy |

tzatziki-spring-jpa/src/test/resources/db/migration/V0__init.sql

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@ create table users
88
group_id INT
99
);
1010

11+
create table superusers
12+
(
13+
id SERIAL PRIMARY KEY REFERENCES users(id),
14+
role VARCHAR(255) NOT NULL
15+
);
16+
1117
create table groups
1218
(
1319
id SERIAL PRIMARY KEY,

0 commit comments

Comments
 (0)