From 9832343bc1e59a174243042748d44a6ac674c217 Mon Sep 17 00:00:00 2001 From: "alexis.drai@etu.uca.fr" Date: Wed, 31 May 2023 20:45:34 +0200 Subject: [PATCH] :construction: WIP make it so 2 trainers can't share 1 pokemong use sets where collection order doesn't matter and items should be unique make services call entity validation, to prevent additions and updates that are side-effects from bypassing the validator implement schema versioning and incremental migrations --- README.md | 60 ++++++- .../fr/uca/iut/codecs/move/MoveCodec.java | 44 +++++ .../iut/codecs/pokemong/PokemongCodec.java | 22 ++- .../uca/iut/codecs/trainer/TrainerCodec.java | 20 ++- .../iut/controllers/GenericController.java | 2 - .../iut/entities/GenericVersionedEntity.java | 21 +++ src/main/java/fr/uca/iut/entities/Move.java | 3 +- .../java/fr/uca/iut/entities/Pokemong.java | 14 +- .../java/fr/uca/iut/entities/Trainer.java | 20 ++- .../fr/uca/iut/entities/embedded/Type.java | 5 +- .../fr/uca/iut/services/GenericService.java | 21 ++- .../java/fr/uca/iut/services/MoveService.java | 97 +++++++---- .../fr/uca/iut/services/PokemongService.java | 160 ++++++++++-------- .../fr/uca/iut/services/TrainerService.java | 116 +++++++++---- src/main/resources/META-INF/openapi.yaml | 14 ++ 15 files changed, 449 insertions(+), 170 deletions(-) create mode 100644 src/main/java/fr/uca/iut/entities/GenericVersionedEntity.java diff --git a/README.md b/README.md index e82d5e6..c45f0a6 100644 --- a/README.md +++ b/README.md @@ -6,19 +6,71 @@ Instructions are [here](https://clientserveur-courses.clubinfo-clermont.fr/Notat ## About -A "Pokemong" is a playful term for a `MongoDB` pocket monster. +A "Pokemong" is a playful term for a MongoDB pocket monster. -The application is developed using the Quarkus framework and uses `MongoDB` as its database. +The application is developed using the Quarkus framework and uses MongoDB as its database. -This application is a RESTful service designed to emulate a basic Pokemong management system. It allows users to perform -CRUD operations on Pokemongs, trainers, moves, and types. +This application is a RESTful service designed to emulate a basic `Pokemong` management system. It allows users to +perform +CRUD operations on `Pokemongs`, `Trainers`, `Moves`, and `Types`. ### 🗂️ DCM + Data Concept Model ### 🧬 UML Class diagram + UML Class Diagram +### NoSQL Schema Versioning Strategy + +This application uses MongoDB, a NoSQL database, which provides flexibility in our data model. While this flexibility +has +its advantages, it poses a unique challenge when we need to update our data model, specifically when we want to +introduce breaking changes in the existing schema. + +We have adopted a schema versioning strategy to overcome this challenge and manage these changes efficiently. + +#### Schema Versioning Pattern + +Schema versioning is a pattern that involves tagging each document in a collection with a version number. This version +number corresponds to the schema of the document and is used to handle schema changes in the code that reads these +documents. + +Each entity in our model extends a `GenericVersionedEntity` class, which includes a `schemaVersion` field. This field is +an integer that starts at 1 and is to be incremented by one with each schema change. Every change to the schema needs to +involve the schema version number being incremented. + +#### Incremental Document Migration + +When a document is read from the database, the version number in the document is checked. If the version number is less +than the current version, the document is updated to the current version, and the updated document is written back to +the database. This process effectively migrates the document to the current version. + +In the example of the `Move` class, the codec's `decodeV1` method handles documents with a `schemaVersion` of less +than `2`. When it reads a document with this version, it updates the `schemaVersion` to `2`, and writes the updated +document back to the database. + +```java +Move decodeV1(Document document){ + // ... + // Increment the schemaVersion to the current version + move.setSchemaVersion(2); + + // Save the updated Move object back to the database + moveRepository.persistOrUpdate(move); + // ... + } +``` + +This strategy allows for graceful schema evolution in a NoSQL environment. Instead of requiring all documents to be +migrated at once, which can be a time-consuming operation for large collections, it enables incremental document +migration. This approach also helps to avoid downtime during schema migration, as the application continues to function +correctly regardless of the document version. As documents are read, they are updated to the current schema version, +allowing the schema migration to happen gradually over time. + +However, note that this strategy increases write operations to the database, which could affect application performance. + ## Prep steps ### ♨️ Java version diff --git a/src/main/java/fr/uca/iut/codecs/move/MoveCodec.java b/src/main/java/fr/uca/iut/codecs/move/MoveCodec.java index ba61209..9b1be5d 100644 --- a/src/main/java/fr/uca/iut/codecs/move/MoveCodec.java +++ b/src/main/java/fr/uca/iut/codecs/move/MoveCodec.java @@ -13,6 +13,7 @@ import org.bson.codecs.Codec; import org.bson.codecs.DecoderContext; import org.bson.codecs.EncoderContext; import org.bson.types.ObjectId; +import org.jetbrains.annotations.NotNull; public class MoveCodec extends GenericCodec { private final Codec documentCodec; @@ -28,6 +29,8 @@ public class MoveCodec extends GenericCodec { doc.put("_id", new ObjectId(move.getId())); + doc.put("schemaVersion", move.getSchemaVersion()); + doc.put("name", move.getName()); doc.put("category", move.getCategory()); @@ -55,12 +58,52 @@ public class MoveCodec extends GenericCodec { @Override public Move decode(BsonReader reader, DecoderContext decoderContext) { + Document document = documentCodec.decode(reader, decoderContext); + + Integer schemaVersion = document.getInteger("schemaVersion"); + + return switch (schemaVersion) { + case 1 -> decodeV1(document); + case 2 -> decodeV2(document); + default -> throw new IllegalArgumentException("Unsupported schema version: " + schemaVersion); + }; + } + + private @NotNull Move decodeV1(@NotNull Document document) { + Move move = new Move(); + + move.setId(document.getObjectId("_id") + .toString()); + + move.setSchemaVersion(document.getInteger("schemaVersion")); + + move.setName(document.getString("name")); + + move.setCategory(MoveCategoryName.valueOf(document.getString("category"))); + + move.setPower(document.getInteger("power")); + + move.setAccuracy(document.getInteger("accuracy")); + + Document typeDoc = (Document) document.get("type"); + + move.setType(TypeCodecUtil.extractType(typeDoc)); + + // Read and discard the old pp field + Integer pp = document.getInteger("pp"); + + return move; + } + + private @NotNull Move decodeV2(@NotNull Document document) { Move move = new Move(); move.setId(document.getObjectId("_id") .toString()); + move.setSchemaVersion(document.getInteger("schemaVersion")); + move.setName(document.getString("name")); move.setCategory(MoveCategoryName.valueOf(document.getString("category"))); @@ -75,4 +118,5 @@ public class MoveCodec extends GenericCodec { return move; } + } diff --git a/src/main/java/fr/uca/iut/codecs/pokemong/PokemongCodec.java b/src/main/java/fr/uca/iut/codecs/pokemong/PokemongCodec.java index a4bdd36..c2137b5 100644 --- a/src/main/java/fr/uca/iut/codecs/pokemong/PokemongCodec.java +++ b/src/main/java/fr/uca/iut/codecs/pokemong/PokemongCodec.java @@ -36,6 +36,8 @@ public class PokemongCodec extends GenericCodec { doc.put("_id", new ObjectId(pokemong.getId())); + doc.put("schemaVersion", pokemong.getSchemaVersion()); + doc.put("nickname", pokemong.getNickname()); doc.put("dob", @@ -103,11 +105,23 @@ public class PokemongCodec extends GenericCodec { @Override public Pokemong decode(BsonReader reader, DecoderContext decoderContext) { Document document = documentCodec.decode(reader, decoderContext); + + Integer schemaVersion = document.getInteger("schemaVersion"); + + return switch (schemaVersion) { + case 1 -> decodeV1(document); + default -> throw new IllegalArgumentException("Unsupported schema version: " + schemaVersion); + }; + } + + private @NotNull Pokemong decodeV1(@NotNull Document document) { Pokemong pokemong = new Pokemong(); pokemong.setId(document.getObjectId("_id") .toString()); + pokemong.setSchemaVersion(document.getInteger("schemaVersion")); + pokemong.setNickname(document.getString("nickname")); Date dob = document.getDate("dob"); @@ -134,10 +148,10 @@ public class PokemongCodec extends GenericCodec { pokemong.setTrainer(trainerId.toString()); } - List types = document.getList("types", Document.class) - .stream() - .map(TypeCodecUtil::extractType) - .collect(Collectors.toList()); + Set types = document.getList("types", Document.class) + .stream() + .map(TypeCodecUtil::extractType) + .collect(Collectors.toSet()); pokemong.setTypes(types); Set moveSet = document.getList("moveSet", Document.class) diff --git a/src/main/java/fr/uca/iut/codecs/trainer/TrainerCodec.java b/src/main/java/fr/uca/iut/codecs/trainer/TrainerCodec.java index d37e723..0f5a7d7 100644 --- a/src/main/java/fr/uca/iut/codecs/trainer/TrainerCodec.java +++ b/src/main/java/fr/uca/iut/codecs/trainer/TrainerCodec.java @@ -18,6 +18,7 @@ import java.time.LocalDate; import java.time.ZoneId; import java.util.Date; import java.util.List; +import java.util.Set; import java.util.stream.Collectors; public class TrainerCodec extends GenericCodec { @@ -34,6 +35,8 @@ public class TrainerCodec extends GenericCodec { doc.put("_id", new ObjectId(trainer.getId())); + doc.put("schemaVersion", trainer.getSchemaVersion()); + doc.put("name", trainer.getName()); LocalDate dob = trainer.getDob(); @@ -77,11 +80,24 @@ public class TrainerCodec extends GenericCodec { @Override public Trainer decode(BsonReader reader, DecoderContext decoderContext) { Document document = documentCodec.decode(reader, decoderContext); + + Integer schemaVersion = document.getInteger("schemaVersion"); + + return switch (schemaVersion) { + case 1 -> decodeV1(document); + default -> throw new IllegalArgumentException("Unsupported schema version: " + schemaVersion); + }; + } + + @NotNull + private static Trainer decodeV1(Document document) { Trainer trainer = new Trainer(); trainer.setId(document.getObjectId("_id") .toString()); + trainer.setSchemaVersion(document.getInteger("schemaVersion")); + trainer.setName(document.getString("name")); Date dob = document.getDate("dob"); @@ -101,7 +117,7 @@ public class TrainerCodec extends GenericCodec { .collect(Collectors.toList()); trainer.setPastOpponents(pastOpponentsIds); - List pokemongList = document + Set pokemongList = document .getList("pokemongs", Document.class) .stream() .map(pokemongDoc -> { @@ -111,7 +127,7 @@ public class TrainerCodec extends GenericCodec { pokemong.setSpecies(PokemongName.valueOf(pokemongDoc.getString("species"))); return pokemong; }) - .collect(Collectors.toList()); + .collect(Collectors.toSet()); trainer.setPokemongs(pokemongList); return trainer; } diff --git a/src/main/java/fr/uca/iut/controllers/GenericController.java b/src/main/java/fr/uca/iut/controllers/GenericController.java index 70b30d2..ddd16ec 100644 --- a/src/main/java/fr/uca/iut/controllers/GenericController.java +++ b/src/main/java/fr/uca/iut/controllers/GenericController.java @@ -47,7 +47,6 @@ public abstract class GenericController { public Response createOne(T entity) { try { - service.validateOne(entity); T newEntity = service.addOne(entity); return Response.status(Response.Status.CREATED) @@ -66,7 +65,6 @@ public abstract class GenericController { @Consumes(MediaType.APPLICATION_JSON) public Response updateOne(@PathParam("id") String id, T entity) { try { - service.validateOne(entity); entity.setId(id); T updatedEntity = service.updateOne(entity); diff --git a/src/main/java/fr/uca/iut/entities/GenericVersionedEntity.java b/src/main/java/fr/uca/iut/entities/GenericVersionedEntity.java new file mode 100644 index 0000000..7ede19a --- /dev/null +++ b/src/main/java/fr/uca/iut/entities/GenericVersionedEntity.java @@ -0,0 +1,21 @@ +package fr.uca.iut.entities; + +/** + * The strategy for incrementing the schema version number is simple. + *

+ * `schemaVersion` will have to start at 1, and need to be incremented by one at each schema change. + *

+ * Every change to the schema needs to involve the schema version number being incremented. + */ +public abstract class GenericVersionedEntity extends GenericEntity { + + private Integer schemaVersion; + + public Integer getSchemaVersion() { + return schemaVersion; + } + + public void setSchemaVersion(Integer schemaVersion) { + this.schemaVersion = schemaVersion; + } +} diff --git a/src/main/java/fr/uca/iut/entities/Move.java b/src/main/java/fr/uca/iut/entities/Move.java index b25cd45..050396c 100644 --- a/src/main/java/fr/uca/iut/entities/Move.java +++ b/src/main/java/fr/uca/iut/entities/Move.java @@ -3,8 +3,9 @@ package fr.uca.iut.entities; import fr.uca.iut.entities.embedded.Type; import fr.uca.iut.utils.enums.MoveCategoryName; -public class Move extends GenericEntity { +public class Move extends GenericVersionedEntity { public static final String COLLECTION_NAME = "moves"; + public static final Integer LATEST_SCHEMA_VERSION = 2; private String name; private MoveCategoryName category; diff --git a/src/main/java/fr/uca/iut/entities/Pokemong.java b/src/main/java/fr/uca/iut/entities/Pokemong.java index 3645a6e..2c30cd8 100644 --- a/src/main/java/fr/uca/iut/entities/Pokemong.java +++ b/src/main/java/fr/uca/iut/entities/Pokemong.java @@ -10,9 +10,11 @@ import java.util.Collections; import java.util.List; import java.util.Set; -public class Pokemong extends GenericEntity { +public class Pokemong extends GenericVersionedEntity { public static final String COLLECTION_NAME = "pokemongs"; + public static final Integer LATEST_SCHEMA_VERSION = 1; + @Nullable private String nickname; private LocalDate dob; @@ -22,7 +24,7 @@ public class Pokemong extends GenericEntity { private List evoTrack; @Nullable private String trainer; - private List types; + private Set types; /** * pokemong.moveSet: [{_id: ObjectId, name: String}] @@ -73,11 +75,11 @@ public class Pokemong extends GenericEntity { this.trainer = trainer; } - public List getTypes() { - return Collections.unmodifiableList(types); + public Set getTypes() { + return Collections.unmodifiableSet(types); } - public void setTypes(List types) { + public void setTypes(Set types) { this.types = types; } @@ -111,7 +113,7 @@ public class Pokemong extends GenericEntity { } public List getEvoTrack() { - return evoTrack; + return Collections.unmodifiableList(evoTrack); } public Integer getEvoStage() { diff --git a/src/main/java/fr/uca/iut/entities/Trainer.java b/src/main/java/fr/uca/iut/entities/Trainer.java index ea2fef7..a8dc22d 100644 --- a/src/main/java/fr/uca/iut/entities/Trainer.java +++ b/src/main/java/fr/uca/iut/entities/Trainer.java @@ -5,16 +5,18 @@ import fr.uca.iut.entities.denormalized.TrainerPokemong; import java.time.LocalDate; import java.util.Collections; import java.util.List; +import java.util.Set; -public class Trainer extends GenericEntity { +public class Trainer extends GenericVersionedEntity { public static final String COLLECTION_NAME = "trainers"; + public static final Integer LATEST_SCHEMA_VERSION = 1; private String name; private LocalDate dob; private Integer wins; private Integer losses; private List pastOpponents; - private List pokemongs; + private Set pokemongs; public Trainer() {} @@ -58,11 +60,19 @@ public class Trainer extends GenericEntity { this.pastOpponents = pastOpponents; } - public List getPokemongs() { - return Collections.unmodifiableList(pokemongs); + public Set getPokemongs() { + return Collections.unmodifiableSet(pokemongs); } - public void setPokemongs(List pokemongs) { + public void setPokemongs(Set pokemongs) { this.pokemongs = pokemongs; } + + public void addPokemong(TrainerPokemong trainerPokemong) { + pokemongs.add(trainerPokemong); + } + + public void removePokemong(TrainerPokemong trainerPokemong) { + pokemongs.remove(trainerPokemong); + } } diff --git a/src/main/java/fr/uca/iut/entities/embedded/Type.java b/src/main/java/fr/uca/iut/entities/embedded/Type.java index 5af9d0d..452a6bd 100644 --- a/src/main/java/fr/uca/iut/entities/embedded/Type.java +++ b/src/main/java/fr/uca/iut/entities/embedded/Type.java @@ -2,7 +2,6 @@ package fr.uca.iut.entities.embedded; import fr.uca.iut.utils.enums.TypeName; -import java.util.Collections; import java.util.List; import java.util.Objects; @@ -23,7 +22,7 @@ public class Type { } public List getWeakAgainst() { - return Collections.unmodifiableList(weakAgainst); + return weakAgainst; } public void setWeakAgainst(List weakAgainst) { @@ -31,7 +30,7 @@ public class Type { } public List getEffectiveAgainst() { - return Collections.unmodifiableList(effectiveAgainst); + return effectiveAgainst; } public void setEffectiveAgainst(List effectiveAgainst) { diff --git a/src/main/java/fr/uca/iut/services/GenericService.java b/src/main/java/fr/uca/iut/services/GenericService.java index 3bd1435..6f2ab6f 100644 --- a/src/main/java/fr/uca/iut/services/GenericService.java +++ b/src/main/java/fr/uca/iut/services/GenericService.java @@ -17,10 +17,20 @@ public abstract class GenericService { } public T addOne(@NotNull T entity) { + validateOne(entity); repository.persist(entity); return entity; } + /** + * Override me and start with `super.validateOne(entity);` + */ + public void validateOne(T entity) throws NonValidEntityException { + if (entity == null) { + throw new NonValidEntityException("entity was null"); + } + } + @Nullable public T getOneById(String id) { return repository.findById(id); @@ -37,15 +47,12 @@ public abstract class GenericService { } } - @Nullable - public abstract T updateOne(@NotNull T entity); - /** * Override me and start with `super.validateOne(entity);` */ - public void validateOne(T entity) { - if (entity == null) { - throw new NonValidEntityException("entity was null"); - } + @Nullable + public T updateOne(@NotNull T entity) { + validateOne(entity); + return entity; } } diff --git a/src/main/java/fr/uca/iut/services/MoveService.java b/src/main/java/fr/uca/iut/services/MoveService.java index ed30c0e..24f00b4 100644 --- a/src/main/java/fr/uca/iut/services/MoveService.java +++ b/src/main/java/fr/uca/iut/services/MoveService.java @@ -13,6 +13,8 @@ import org.jetbrains.annotations.NotNull; import java.util.ArrayList; import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; @ApplicationScoped public class MoveService extends GenericService { @@ -27,19 +29,70 @@ public class MoveService extends GenericService { setRepository(moveRepository); } + @Override + public void validateOne(Move move) { + + super.validateOne(move); + + List errors = new ArrayList<>(); + + if (StringUtils.isBlankStringOrNull(move.getName())) { + errors.add("move name was null, blank or empty"); + } + + if (move.getPower() == null || move.getPower() < 0) { + errors.add("move power was null or negative"); + } + + if (move.getCategory() == null) { + errors.add("move category was null or invalid"); + } + + if (move.getAccuracy() == null || move.getAccuracy() < 0) { + errors.add("move accuracy was null or negative"); + } + + if (move.getType() == null) { + errors.add("move type was null or invalid"); + } + + if (move.getSchemaVersion() == null || !Objects.equals(move.getSchemaVersion(), Move.LATEST_SCHEMA_VERSION)) { + errors.add("move schema version was null or not the latest version: " + Move.LATEST_SCHEMA_VERSION); + } + + if (!errors.isEmpty()) { + throw new NonValidEntityException("Validation errors: " + String.join(", ", errors)); + } + } + + @Nullable + @Override + public Move getOneById(String id) { + return migrateToV2(super.getOneById(id)); + } + + @Override + public List getAll() { + return super.getAll() + .stream() + .map(this::migrateToV2) + .collect(Collectors.toList()); + } + @Override public void deleteOneById(String id) { - super.deleteOneById(id); List pokemongs = pokemongService.findByMove(id); for (Pokemong pokemong : pokemongs) { pokemong.removeMove(id); pokemongService.updateOne(pokemong); } + super.deleteOneById(id); } @Override @Nullable public Move updateOne(@NotNull Move move) { + super.updateOne(move); Move existingMove = moveRepository.findById(move.getId()); if (existingMove != null) { if (!existingMove.getName() @@ -62,36 +115,20 @@ public class MoveService extends GenericService { return existingMove; } - @Override - public void validateOne(Move move) { - - super.validateOne(move); - - List errors = new ArrayList<>(); - - if (StringUtils.isBlankStringOrNull(move.getName())) { - errors.add("move name was null, blank or empty"); - } - - if (move.getPower() == null || move.getPower() < 0) { - errors.add("move power was null or negative"); - } - - if (move.getCategory() == null) { - errors.add("move category was null or invalid"); - } - - if (move.getAccuracy() == null || move.getAccuracy() < 0) { - errors.add("move accuracy was null or negative"); - } - - if (move.getType() == null) { - errors.add("move type was null or invalid"); - } - - if (!errors.isEmpty()) { - throw new NonValidEntityException("Validation errors: " + String.join(", ", errors)); + /** + * We want to migrate the documents incrementally, so we upgrade the + * schema version if it is less than the current schema version, + * and then save the updated document back to the database. + * + * @param move the Move found by the repository + * @return the Move(V2) based on the Move from the repository + */ + private Move migrateToV2(Move move) { + if (move != null && move.getSchemaVersion() < 2) { + move.setSchemaVersion(2); + moveRepository.persistOrUpdate(move); } + return move; } public boolean existsById(String moveId) { diff --git a/src/main/java/fr/uca/iut/services/PokemongService.java b/src/main/java/fr/uca/iut/services/PokemongService.java index c80886c..a047ef4 100644 --- a/src/main/java/fr/uca/iut/services/PokemongService.java +++ b/src/main/java/fr/uca/iut/services/PokemongService.java @@ -1,7 +1,8 @@ package fr.uca.iut.services; import com.mongodb.lang.Nullable; -import fr.uca.iut.entities.*; +import fr.uca.iut.entities.Pokemong; +import fr.uca.iut.entities.Trainer; import fr.uca.iut.entities.denormalized.PokemongMove; import fr.uca.iut.entities.denormalized.TrainerPokemong; import fr.uca.iut.entities.embedded.Type; @@ -39,81 +40,19 @@ public class PokemongService extends GenericService { @Override public Pokemong addOne(@NotNull Pokemong pokemong) { Pokemong persistedPokemong = super.addOne(pokemong); - - Trainer trainer = trainerService.getOneById(pokemong.getTrainer()); - if (trainer != null) { - TrainerPokemong trainerPokemong = new TrainerPokemong(); - trainerPokemong.setId(pokemong.getId()); - trainerPokemong.setNickname(pokemong.getNickname()); - trainerPokemong.setSpecies(pokemong.getSpecies()); - trainer.getPokemongs() - .add(trainerPokemong); - trainerService.updateOne(trainer); - } - return persistedPokemong; - } - - @Override - public void deleteOneById(String id) { - Pokemong pokemong = getOneById(id); - if (pokemong != null && pokemong.getTrainer() != null) { + String trainerId = pokemong.getTrainer(); + if (trainerId != null) { Trainer trainer = trainerService.getOneById(pokemong.getTrainer()); if (trainer != null) { - trainer.getPokemongs() - .removeIf(trainerPokemong -> trainerPokemong.getId() - .equals(id)); + TrainerPokemong trainerPokemong = new TrainerPokemong(); + trainerPokemong.setId(pokemong.getId()); + trainerPokemong.setNickname(pokemong.getNickname()); + trainerPokemong.setSpecies(pokemong.getSpecies()); + trainer.addPokemong(trainerPokemong); trainerService.updateOne(trainer); } } - super.deleteOneById(id); - } - - @Override - @Nullable - public Pokemong updateOne(@NotNull Pokemong pokemong) { - Pokemong existingPokemong = pokemongRepository.findById(pokemong.getId()); - if (existingPokemong != null) { - boolean nicknameChanged = !Objects.equals(existingPokemong.getNickname(), pokemong.getNickname()); - boolean evoStageChanged = !Objects.equals(existingPokemong.getEvoStage(), pokemong.getEvoStage()); - boolean evoTrackChanged = !Objects.equals(existingPokemong.getEvoTrack(), pokemong.getEvoTrack()); - - existingPokemong.setNickname(pokemong.getNickname()); - existingPokemong.setDob(pokemong.getDob()); - existingPokemong.setLevel(pokemong.getLevel()); - existingPokemong.setPokedexId(pokemong.getPokedexId()); - existingPokemong.setEvoStage(pokemong.getEvoStage()); - existingPokemong.setEvoTrack(pokemong.getEvoTrack()); - existingPokemong.setTrainer(pokemong.getTrainer()); - existingPokemong.setTypes(pokemong.getTypes()); - existingPokemong.setMoveSet(pokemong.getMoveSet()); - - pokemongRepository.persistOrUpdate(existingPokemong); - - if (nicknameChanged || evoStageChanged || evoTrackChanged) { - Trainer trainer = trainerService.getOneById(existingPokemong.getTrainer()); - if (trainer != null) { - TrainerPokemong trainerPokemong = trainer.getPokemongs() - .stream() - .filter(tp -> tp.getId() - .equals(existingPokemong.getId())) - .findFirst() - .orElse(null); - - if (trainerPokemong != null) { - if (nicknameChanged) { - trainerPokemong.setNickname(existingPokemong.getNickname()); - } - - if (evoStageChanged || evoTrackChanged) { - trainerPokemong.setSpecies(existingPokemong.getSpecies()); - } - - trainerService.updateOne(trainer); - } - } - } - } - return existingPokemong; + return persistedPokemong; } @Override @@ -143,7 +82,7 @@ public class PokemongService extends GenericService { errors.add("pokemong evo track was null or invalid"); } - List types = pokemong.getTypes(); + Set types = pokemong.getTypes(); if (types == null || types.size() == 0 || types.size() > 2) @@ -174,11 +113,82 @@ public class PokemongService extends GenericService { } } + if (pokemong.getSchemaVersion() == null || + !Objects.equals(pokemong.getSchemaVersion(), Pokemong.LATEST_SCHEMA_VERSION)) + { + errors.add( + "pokemong schema version was null or not the latest version: " + Pokemong.LATEST_SCHEMA_VERSION); + } + if (!errors.isEmpty()) { throw new NonValidEntityException("Validation errors: " + String.join(", ", errors)); } } + @Override + public void deleteOneById(String id) { + Pokemong pokemong = getOneById(id); + if (pokemong != null && pokemong.getTrainer() != null) { + Trainer trainer = trainerService.getOneById(pokemong.getTrainer()); + if (trainer != null) { + trainer.getPokemongs() + .removeIf(trainerPokemong -> trainerPokemong.getId() + .equals(id)); + trainerService.updateOne(trainer); + } + } + super.deleteOneById(id); + } + + @Override + @Nullable + public Pokemong updateOne(@NotNull Pokemong pokemong) { + super.updateOne(pokemong); + Pokemong existingPokemong = pokemongRepository.findById(pokemong.getId()); + if (existingPokemong != null) { + boolean nicknameChanged = !Objects.equals(existingPokemong.getNickname(), pokemong.getNickname()); + boolean evoStageChanged = !Objects.equals(existingPokemong.getEvoStage(), pokemong.getEvoStage()); + boolean evoTrackChanged = !Objects.equals(existingPokemong.getEvoTrack(), pokemong.getEvoTrack()); + + existingPokemong.setNickname(pokemong.getNickname()); + existingPokemong.setDob(pokemong.getDob()); + existingPokemong.setLevel(pokemong.getLevel()); + existingPokemong.setPokedexId(pokemong.getPokedexId()); + existingPokemong.setEvoStage(pokemong.getEvoStage()); + existingPokemong.setEvoTrack(pokemong.getEvoTrack()); + existingPokemong.setTrainer(pokemong.getTrainer()); + existingPokemong.setTypes(pokemong.getTypes()); + existingPokemong.setMoveSet(pokemong.getMoveSet()); + + pokemongRepository.persistOrUpdate(existingPokemong); + + if (nicknameChanged || evoStageChanged || evoTrackChanged) { + Trainer trainer = trainerService.getOneById(existingPokemong.getTrainer()); + if (trainer != null) { + TrainerPokemong trainerPokemong = trainer.getPokemongs() + .stream() + .filter(tp -> tp.getId() + .equals(existingPokemong.getId())) + .findFirst() + .orElse(null); + + if (trainerPokemong != null) { + if (nicknameChanged) { + trainerPokemong.setNickname(existingPokemong.getNickname()); + } + + if (evoStageChanged || evoTrackChanged) { + trainerPokemong.setSpecies(existingPokemong.getSpecies()); + } + + trainerService.updateOne(trainer); + } + } + } + } + return existingPokemong; + } + public List findByMove(String id) { return pokemongRepository.findByMove(id); } @@ -193,10 +203,12 @@ public class PokemongService extends GenericService { return repository.existsById(pokemongId); } - public void batchUpdatePokemongTrainers(@NotNull List trainerPokemongs, @Nullable String trainerId) { + public void batchUpdatePokemongTrainers(@NotNull Set trainerPokemongs, + @Nullable String trainerId) + { for (TrainerPokemong trainerPokemong : trainerPokemongs) { Pokemong pokemong = getOneById(trainerPokemong.getId()); - if (pokemong != null) { + if (pokemong != null && !Objects.equals(pokemong.getTrainer(), trainerId)) { pokemong.setTrainer(trainerId); updateOne(pokemong); } diff --git a/src/main/java/fr/uca/iut/services/TrainerService.java b/src/main/java/fr/uca/iut/services/TrainerService.java index 5817a99..82ccc30 100644 --- a/src/main/java/fr/uca/iut/services/TrainerService.java +++ b/src/main/java/fr/uca/iut/services/TrainerService.java @@ -12,8 +12,8 @@ import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import org.jetbrains.annotations.NotNull; -import java.util.ArrayList; -import java.util.List; +import java.util.*; +import java.util.stream.Collectors; @ApplicationScoped public class TrainerService extends GenericService { @@ -33,38 +33,14 @@ public class TrainerService extends GenericService { public Trainer addOne(@NotNull Trainer trainer) { Trainer persistedTrainer = super.addOne(trainer); + // If this trainer gained pokemongs, that pokemong's ex-trainer if any needs to lose said pokemong + transferNewlyArrivedTrainerPokemongs(new HashSet<>(), persistedTrainer.getPokemongs()); + // all owned pokemongs gain this trainer's reference pokemongService.batchUpdatePokemongTrainers(trainer.getPokemongs(), trainer.getId()); return persistedTrainer; } - @Override - public void deleteOneById(String id) { - Trainer trainer = getOneById(id); - - if (trainer != null) { - pokemongService.batchUpdatePokemongTrainers(trainer.getPokemongs(), null); - } - - super.deleteOneById(id); - } - - @Nullable - @Override - public Trainer updateOne(@NotNull Trainer trainer) { - Trainer existingTrainer = trainerRepository.findById(trainer.getId()); - if (existingTrainer != null) { - existingTrainer.setName(trainer.getName()); - existingTrainer.setDob(trainer.getDob()); - existingTrainer.setWins(trainer.getLosses()); - existingTrainer.setLosses(trainer.getLosses()); - existingTrainer.setPastOpponents(trainer.getPastOpponents()); - existingTrainer.setPokemongs(trainer.getPokemongs()); - trainerRepository.persistOrUpdate(existingTrainer); - } - return existingTrainer; - } - @Override public void validateOne(Trainer trainer) { @@ -95,13 +71,13 @@ public class TrainerService extends GenericService { } else { for (String trainerId : pastOpponents) { - if (StringUtils.isBlankStringOrNull(trainerId) || !trainerRepository.existsById(trainerId)) { - errors.add("trainer past opponents collection contained an invalid or unknown id"); + if (StringUtils.isBlankStringOrNull(trainerId)) { + errors.add("trainer past opponents collection contained an invalid id: " + trainerId); } } } - List pokemongs = trainer.getPokemongs(); + Set pokemongs = trainer.getPokemongs(); if (pokemongs == null) { errors.add("trainer pokemongs collection was null or invalid"); @@ -129,8 +105,84 @@ public class TrainerService extends GenericService { } } + if (trainer.getSchemaVersion() == null || + !Objects.equals(trainer.getSchemaVersion(), Trainer.LATEST_SCHEMA_VERSION)) + { + errors.add("trainer schema version was null or not the latest version: " + Trainer.LATEST_SCHEMA_VERSION); + } + if (!errors.isEmpty()) { throw new NonValidEntityException("Validation errors: " + String.join(", ", errors)); } } + + @Override + public void deleteOneById(String id) { + Trainer trainer = getOneById(id); + + if (trainer != null) { + pokemongService.batchUpdatePokemongTrainers(trainer.getPokemongs(), null); + } + + super.deleteOneById(id); + } + + @Nullable + @Override + public Trainer updateOne(@NotNull Trainer trainer) { + super.updateOne(trainer); + Trainer existingTrainer = trainerRepository.findById(trainer.getId()); + if (existingTrainer != null) { + Set oldPokemongs = existingTrainer.getPokemongs(); + + existingTrainer.setName(trainer.getName()); + existingTrainer.setDob(trainer.getDob()); + existingTrainer.setWins(trainer.getLosses()); + existingTrainer.setLosses(trainer.getLosses()); + existingTrainer.setPastOpponents(trainer.getPastOpponents()); + existingTrainer.setPokemongs(trainer.getPokemongs()); + trainerRepository.persistOrUpdate(existingTrainer); + + Set newPokemongs = trainer.getPokemongs(); + + // all old pokemongs who are not there anymore lose their trainer reference + pokemongService.batchUpdatePokemongTrainers( + oldPokemongs.stream() + .filter(tp -> !newPokemongs.contains(tp)) + .collect(Collectors.toSet()), + null); + // If this trainer gained a pokemong, that pokemong's ex-trainer if any needs to lose said pokemong + transferNewlyArrivedTrainerPokemongs(oldPokemongs, newPokemongs); + // all new pokemongs who were not there before gain this trainer's reference + pokemongService.batchUpdatePokemongTrainers( + newPokemongs.stream() + .filter(tp -> !oldPokemongs.contains(tp)) + .collect(Collectors.toSet()), + existingTrainer.getId()); + } + return existingTrainer; + } + + private void transferNewlyArrivedTrainerPokemongs( + @NotNull Set oldPokemongs, + @NotNull Set newPokemongs + ) + { + for (TrainerPokemong tp : newPokemongs) { + if (oldPokemongs.isEmpty() || !oldPokemongs.contains(tp)) { + Pokemong pokemong = pokemongService.getOneById(tp.getId()); + if (pokemong != null) { + String oldTrainerId = pokemong.getTrainer(); + // If the pokemong already had a trainer, remove it from the old trainer's pokemongs list + if (oldTrainerId != null) { + Trainer oldTrainer = getOneById(oldTrainerId); + if (oldTrainer != null) { + oldTrainer.removePokemong(tp); + updateOne(oldTrainer); + } + } + } + } + } + } } diff --git a/src/main/resources/META-INF/openapi.yaml b/src/main/resources/META-INF/openapi.yaml index b3192f0..86aea97 100644 --- a/src/main/resources/META-INF/openapi.yaml +++ b/src/main/resources/META-INF/openapi.yaml @@ -310,6 +310,7 @@ components: - evoTrack - types - moveSet + - schemaVersion properties: nickname: type: string @@ -347,6 +348,8 @@ components: maxItems: 4 items: $ref: '#/components/schemas/PokemongMove' + schemaVersion: + $ref: '#/components/schemas/SchemaVersion' Move: type: object @@ -356,6 +359,7 @@ components: - category - accuracy - type + - schemaVersion properties: name: type: string @@ -370,6 +374,8 @@ components: minimum: 0 type: $ref: '#/components/schemas/TypeName' + schemaVersion: + $ref: '#/components/schemas/SchemaVersion' Trainer: type: object @@ -380,6 +386,7 @@ components: - losses - pastOpponents - pokemongs + - schemaVersion properties: name: type: string @@ -402,6 +409,8 @@ components: type: array items: $ref: '#/components/schemas/TrainerPokemong' + schemaVersion: + $ref: '#/components/schemas/SchemaVersion' PokemongMove: type: object @@ -431,6 +440,11 @@ components: species: $ref: '#/components/schemas/PokemongName' + SchemaVersion: + type: integer + minimum: 1 + description: must be >= 1, and furthermore must be >= latest schema version + MoveCategoryName: type: string enum: [