From c994ee05ce8ad593ad0be6e77ad2438ba8373233 Mon Sep 17 00:00:00 2001 From: psmagin Date: Fri, 6 Feb 2026 12:38:06 +0200 Subject: [PATCH] fix: creating records with id will not fail Closes: MODNOTES-293 --- NEWS.md | 2 +- .../folio/notes/domain/entity/BaseEntity.java | 16 ++++- .../folio/notes/domain/entity/NoteEntity.java | 3 +- .../folio/notes/domain/mapper/LinkMapper.java | 2 + .../notes/domain/mapper/NoteTypesMapper.java | 2 + .../notes/domain/mapper/NotesMapper.java | 2 + .../service/impl/NoteTypesServiceImpl.java | 3 +- .../notes/service/impl/NotesServiceImpl.java | 20 +++--- .../java/org/folio/notes/util/JpaUtils.java | 18 ++++++ .../notes/controller/NotesControllerIT.java | 63 ++++++++++++++----- 10 files changed, 100 insertions(+), 31 deletions(-) create mode 100644 src/main/java/org/folio/notes/util/JpaUtils.java diff --git a/NEWS.md b/NEWS.md index 2a930dc0..31becf27 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,6 +1,6 @@ ## v8.0.0 YYYY-mm-DD ### Breaking changes -* Migrate to Spring Boot v4.x ([MODNOTES-282](https://folio-org.atlassian.net/browse/MODNOTES-282)) +* Migrate to Spring Boot v4.x ([MODNOTES-282](https://folio-org.atlassian.net/browse/MODNOTES-282)), ([MODNOTES-293](https://folio-org.atlassian.net/browse/MODNOTES-293)) ### New APIs versions * Provides `API_NAME vX.Y` diff --git a/src/main/java/org/folio/notes/domain/entity/BaseEntity.java b/src/main/java/org/folio/notes/domain/entity/BaseEntity.java index 274c147f..36f3cf7c 100644 --- a/src/main/java/org/folio/notes/domain/entity/BaseEntity.java +++ b/src/main/java/org/folio/notes/domain/entity/BaseEntity.java @@ -1,20 +1,30 @@ package org.folio.notes.domain.entity; import jakarta.persistence.Column; -import jakarta.persistence.GeneratedValue; import jakarta.persistence.Id; import jakarta.persistence.MappedSuperclass; +import jakarta.persistence.PostLoad; +import jakarta.persistence.PostPersist; +import jakarta.persistence.Transient; import java.util.UUID; import lombok.Getter; import lombok.Setter; +import org.springframework.data.domain.Persistable; @Getter @Setter @MappedSuperclass -public abstract class BaseEntity { +public abstract class BaseEntity implements Persistable { @Id - @GeneratedValue @Column(name = "id", updatable = false, nullable = false) private UUID id; + + private @Transient boolean isNew = false; + + @PostLoad + @PostPersist + void markNotNew() { + this.isNew = false; + } } diff --git a/src/main/java/org/folio/notes/domain/entity/NoteEntity.java b/src/main/java/org/folio/notes/domain/entity/NoteEntity.java index 173c72b7..d84c0af1 100644 --- a/src/main/java/org/folio/notes/domain/entity/NoteEntity.java +++ b/src/main/java/org/folio/notes/domain/entity/NoteEntity.java @@ -2,6 +2,7 @@ import jakarta.persistence.Column; import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; import jakarta.persistence.Index; import jakarta.persistence.JoinColumn; import jakarta.persistence.JoinTable; @@ -57,7 +58,7 @@ public class NoteEntity extends AuditableEntity { @Column(name = "pop_up_on_check_out") private boolean popUpOnCheckOut; - @ManyToOne(optional = false) + @ManyToOne(optional = false, fetch = FetchType.LAZY) @JoinColumn(name = "type_id", nullable = false) private NoteTypeEntity type; diff --git a/src/main/java/org/folio/notes/domain/mapper/LinkMapper.java b/src/main/java/org/folio/notes/domain/mapper/LinkMapper.java index c5a38e1a..d115fcb1 100644 --- a/src/main/java/org/folio/notes/domain/mapper/LinkMapper.java +++ b/src/main/java/org/folio/notes/domain/mapper/LinkMapper.java @@ -15,6 +15,8 @@ public interface LinkMapper { Link toDtoLink(LinkEntity entity); @Mapping(target = "id", ignore = true) + @Mapping(target = "notes", ignore = true) + @Mapping(target = "new", ignore = true) @InheritInverseConfiguration LinkEntity toEntityLink(Link dto); } diff --git a/src/main/java/org/folio/notes/domain/mapper/NoteTypesMapper.java b/src/main/java/org/folio/notes/domain/mapper/NoteTypesMapper.java index 89a01961..76f77b53 100644 --- a/src/main/java/org/folio/notes/domain/mapper/NoteTypesMapper.java +++ b/src/main/java/org/folio/notes/domain/mapper/NoteTypesMapper.java @@ -21,6 +21,7 @@ public interface NoteTypesMapper { @Mapping(target = "metadata", source = "entity", qualifiedByName = "BaseMetadataMapper") NoteType toDto(NoteTypeEntity entity); + @Mapping(target = "new", ignore = true) @Mapping(target = "createdDate", ignore = true) @Mapping(target = "updatedDate", ignore = true) @Mapping(target = "createdBy", ignore = true) @@ -37,6 +38,7 @@ default NoteTypeCollection toDtoCollection(Page entityList, Map< List toDtoList(List entityList); + @Mapping(target = "new", ignore = true) @Mapping(target = "id", ignore = true) @Mapping(target = "createdDate", ignore = true) @Mapping(target = "updatedDate", ignore = true) diff --git a/src/main/java/org/folio/notes/domain/mapper/NotesMapper.java b/src/main/java/org/folio/notes/domain/mapper/NotesMapper.java index 91e14979..09e059ed 100644 --- a/src/main/java/org/folio/notes/domain/mapper/NotesMapper.java +++ b/src/main/java/org/folio/notes/domain/mapper/NotesMapper.java @@ -19,6 +19,7 @@ public interface NotesMapper { @Mapping(target = "type", expression = "java(entity.getType().getName())") Note toDto(NoteEntity entity); + @Mapping(target = "new", ignore = true) @Mapping(target = "type.id", source = "typeId") @Mapping(target = "indexedContent", ignore = true) @Mapping(target = "createdDate", ignore = true) @@ -27,6 +28,7 @@ public interface NotesMapper { @Mapping(target = "updatedBy", ignore = true) NoteEntity toEntity(Note dto); + @Mapping(target = "new", ignore = true) @Mapping(target = "id", ignore = true) @Mapping(target = "type.id", source = "typeId") @Mapping(target = "indexedContent", ignore = true) diff --git a/src/main/java/org/folio/notes/service/impl/NoteTypesServiceImpl.java b/src/main/java/org/folio/notes/service/impl/NoteTypesServiceImpl.java index ebd9a5a0..aaf6daca 100644 --- a/src/main/java/org/folio/notes/service/impl/NoteTypesServiceImpl.java +++ b/src/main/java/org/folio/notes/service/impl/NoteTypesServiceImpl.java @@ -15,6 +15,7 @@ import org.folio.notes.exception.NoteTypeNotFoundException; import org.folio.notes.exception.NoteTypesLimitReached; import org.folio.notes.service.NoteTypesService; +import org.folio.notes.util.JpaUtils; import org.folio.spring.data.OffsetRequest; import org.springframework.stereotype.Service; @@ -58,7 +59,7 @@ public NoteType getNoteType(UUID id) { public NoteType createNoteType(NoteType noteType) { log.debug("createNoteType:: trying to create note type with name: {}", noteType.getName()); validateNoteTypeLimit(); - NoteTypeEntity entity = repository.save(mapper.toEntity(noteType)); + NoteTypeEntity entity = repository.save(JpaUtils.initNewEntity(mapper.toEntity(noteType))); log.info("createNoteType:: created note type with name: {}", entity.getName()); return mapper.toDto(entity); } diff --git a/src/main/java/org/folio/notes/service/impl/NotesServiceImpl.java b/src/main/java/org/folio/notes/service/impl/NotesServiceImpl.java index c4e40780..8de228eb 100644 --- a/src/main/java/org/folio/notes/service/impl/NotesServiceImpl.java +++ b/src/main/java/org/folio/notes/service/impl/NotesServiceImpl.java @@ -5,6 +5,7 @@ import static org.folio.notes.domain.repository.NoteRepository.linkIs; import static org.folio.notes.domain.repository.NoteRepository.linkIsNot; import static org.folio.notes.domain.repository.NoteRepository.typeNameIn; +import static org.folio.notes.util.JpaUtils.initNewEntity; import java.util.List; import java.util.Map; @@ -26,11 +27,11 @@ import org.folio.notes.domain.entity.LinkEntity; import org.folio.notes.domain.entity.NoteEntity; import org.folio.notes.domain.entity.NoteEntity_; -import org.folio.notes.domain.entity.NoteTypeEntity; import org.folio.notes.domain.mapper.NoteCollectionMapper; import org.folio.notes.domain.mapper.NotesMapper; import org.folio.notes.domain.repository.LinkRepository; import org.folio.notes.domain.repository.NoteRepository; +import org.folio.notes.domain.repository.NoteTypesRepository; import org.folio.notes.exception.NoteNotFoundException; import org.folio.notes.service.NotesService; import org.folio.notes.util.HtmlSanitizer; @@ -71,6 +72,7 @@ public class NotesServiceImpl implements NotesService { private final NoteRepository noteRepository; private final LinkRepository linkRepository; + private final NoteTypesRepository noteTypesRepository; private final NotesMapper notesMapper; private final NoteCollectionMapper noteCollectionMapper; private final HtmlSanitizer sanitizer; @@ -126,7 +128,7 @@ public Note getNote(UUID id) { public Note createNote(Note note) { log.debug("createNote:: trying to create note by title: {}, domain: {}, type: {}", note.getTitle(), note.getDomain(), note.getType()); - NoteEntity entity = saveNote(note, notesMapper::toEntity); + NoteEntity entity = saveNote(note, dto -> initNewEntity(notesMapper.toEntity(dto))); log.info("createNote:: created note by title: {}, domain: {}, type: {}", note.getTitle(), note.getDomain(), note.getType()); return notesMapper.toDto(entity); @@ -230,7 +232,9 @@ private Sort.Direction getOrderDirection(NotesOrderBy orderBy, OrderDirection or private Function noteMapFunction(Note dto, NoteEntity noteEntity) { if (!dto.getTypeId().equals(noteEntity.getType().getId())) { - noteEntity.setType(new NoteTypeEntity()); + var noteType = noteTypesRepository.findById(dto.getTypeId()) + .orElseThrow(() -> new IllegalArgumentException("Note type with ID [" + dto.getTypeId() + "] was not found")); + noteEntity.setType(noteType); } return noteDto -> notesMapper.updateNote(noteDto, noteEntity); @@ -254,20 +258,20 @@ private void manageNoteLinks(NoteEntity noteEntity) { } } + private LinkEntity fetchOrSaveLink(LinkEntity linkEntity) { + return fetchOrSaveLink(linkEntity.getObjectId(), linkEntity.getObjectType()); + } + private LinkEntity fetchOrSaveLink(String objectId, String objectType) { return linkRepository.findByObjectIdAndObjectType(objectId, objectType) .orElseGet(() -> { LinkEntity linkEntity = new LinkEntity(); linkEntity.setObjectId(objectId); linkEntity.setObjectType(objectType); - return linkRepository.save(linkEntity); + return linkRepository.save(initNewEntity(linkEntity)); }); } - private LinkEntity fetchOrSaveLink(LinkEntity linkEntity) { - return fetchOrSaveLink(linkEntity.getObjectId(), linkEntity.getObjectType()); - } - private NoteNotFoundException notFoundException(UUID id) { return new NoteNotFoundException(id); } diff --git a/src/main/java/org/folio/notes/util/JpaUtils.java b/src/main/java/org/folio/notes/util/JpaUtils.java new file mode 100644 index 00000000..ceb68a17 --- /dev/null +++ b/src/main/java/org/folio/notes/util/JpaUtils.java @@ -0,0 +1,18 @@ +package org.folio.notes.util; + +import java.util.UUID; +import lombok.experimental.UtilityClass; +import org.folio.notes.domain.entity.BaseEntity; +import org.jspecify.annotations.NonNull; + +@UtilityClass +public class JpaUtils { + + public static E initNewEntity(@NonNull E entity) { + if (entity.getId() == null) { + entity.setId(UUID.randomUUID()); + } + entity.setNew(true); + return entity; + } +} diff --git a/src/test/java/org/folio/notes/controller/NotesControllerIT.java b/src/test/java/org/folio/notes/controller/NotesControllerIT.java index aa2cf331..dda567d6 100644 --- a/src/test/java/org/folio/notes/controller/NotesControllerIT.java +++ b/src/test/java/org/folio/notes/controller/NotesControllerIT.java @@ -5,7 +5,6 @@ import static org.folio.notes.support.DatabaseHelper.LINK; import static org.folio.notes.support.DatabaseHelper.NOTE; import static org.folio.notes.support.DatabaseHelper.TYPE; -import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.emptyOrNullString; import static org.hamcrest.Matchers.equalTo; @@ -47,6 +46,7 @@ import org.folio.notes.support.TestApiBase; import org.folio.spring.cql.CqlQueryValidationException; import org.hamcrest.Matcher; +import org.hamcrest.MatcherAssert; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -228,6 +228,35 @@ void createNewNote() throws Exception { assertEquals(1, rowsInTable); } + @Test + @DisplayName("Create new note with links and id") + void createNewNoteWithLinksAndId() throws Exception { + String title = "First"; + var note = new Note() + .id(UUID.randomUUID()) + .title(title) + .domain("eholdings") + .content("

This is test content

") + .links(List.of(new Link("18-3207206", "package"))); + + var noteType = new NoteType().name(insecure().nextAlphabetic(100)); + var contentAsString = mockMvc.perform(postNoteType(noteType)).andReturn().getResponse().getContentAsString(); + var existingNoteType = OBJECT_MAPPER.readValue(contentAsString, NoteType.class); + + note.setTypeId(existingNoteType.getId()); + + mockMvc.perform(postNote(note)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.title", is(title))) + .andExpect(jsonPath("$.metadata.createdByUserId").value(USER_ID.toString())) + .andExpect(jsonPath("$.metadata.createdDate").isNotEmpty()) + .andExpect(header().string(HttpHeaders.LOCATION, + matchesRegex( + NOTE_URL + "/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$"))); + var rowsInTable = databaseHelper.countRowsInTable(TENANT, NOTE); + assertEquals(1, rowsInTable); + } + // Tests for POST @Test @@ -509,7 +538,7 @@ void shouldRemoveNoteWhenLastLinkIsRemoved() throws Exception { .type(PACKAGE_TYPE) )); - mockMvc.perform(postNote(note)).andExpect(status().isCreated()); + mockMvc.perform(putById(note.getId(), note)).andExpect(status().isNoContent()); removeLinks(note.getId()); List notes = getNotes(); @@ -755,8 +784,8 @@ void shouldReturnListOfNotesSortedByCreatedDateAsc() throws Exception { var firstNote = generateNote(); var secondNote = generateNote(); - mockMvc.perform(postNote(firstNote)).andExpect(status().isCreated()); - mockMvc.perform(postNote(secondNote)).andExpect(status().isCreated()); + mockMvc.perform(putById(firstNote.getId(), firstNote)).andExpect(status().isNoContent()); + mockMvc.perform(putById(secondNote.getId(), secondNote)).andExpect(status().isNoContent()); createLinks(firstNote.getId()); createLinks(secondNote.getId()); @@ -775,8 +804,8 @@ void shouldReturnListOfNotesSortedByCreatedDateDesc() throws Exception { var firstNote = generateNote(); var secondNote = generateNote(); - mockMvc.perform(postNote(firstNote)).andExpect(status().isCreated()); - mockMvc.perform(postNote(secondNote)).andExpect(status().isCreated()); + mockMvc.perform(putById(firstNote.getId(), firstNote)).andExpect(status().isNoContent()); + mockMvc.perform(putById(secondNote.getId(), secondNote)).andExpect(status().isNoContent()); createLinks(firstNote.getId()); createLinks(secondNote.getId()); @@ -831,7 +860,7 @@ void shouldReturnListOfNotesSearchedByContent() throws Exception { @DisplayName("Should interpret special regex characters literally") void shouldInterpretSpecialRegexCharactersLiterally() throws Exception { var firstNote = generateNote().title("a[abc1}{]z"); - mockMvc.perform(postNote(firstNote)).andExpect(status().isCreated()); + mockMvc.perform(putById(firstNote.getId(), firstNote)).andExpect(status().isNoContent()); var content = getNoteLinks("/note-links/domain/" + DOMAIN + "/type/" + PACKAGE_TYPE + "/id/" + PACKAGE_ID_1 + "?search=a[abc1}{]z"); @@ -848,9 +877,9 @@ void shouldReturnListOfAssignedNotesSearchedAndSortedByTitle() throws Exception var secondNote = generateNote().title("Title ZZZ ABC"); var thirdNote = generateNote().title("Title BBB"); - mockMvc.perform(postNote(firstNote)).andExpect(status().isCreated()); - mockMvc.perform(postNote(secondNote)).andExpect(status().isCreated()); - mockMvc.perform(postNote(thirdNote)).andExpect(status().isCreated()); + mockMvc.perform(putById(firstNote.getId(), firstNote)).andExpect(status().isNoContent()); + mockMvc.perform(putById(secondNote.getId(), secondNote)).andExpect(status().isNoContent()); + mockMvc.perform(putById(thirdNote.getId(), thirdNote)).andExpect(status().isNoContent()); createLinks(firstNote.getId()); createLinks(secondNote.getId()); @@ -870,9 +899,9 @@ void shouldReturnListOfAssignedNotesSearchedAndSortedByTitleOrderDesc() throws E var secondNote = generateNote().title("Title ZZZ ABC"); var thirdNote = generateNote().title("Title BBB"); - mockMvc.perform(postNote(firstNote)).andExpect(status().isCreated()); - mockMvc.perform(postNote(secondNote)).andExpect(status().isCreated()); - mockMvc.perform(postNote(thirdNote)).andExpect(status().isCreated()); + mockMvc.perform(putById(firstNote.getId(), firstNote)).andExpect(status().isNoContent()); + mockMvc.perform(putById(secondNote.getId(), secondNote)).andExpect(status().isNoContent()); + mockMvc.perform(putById(thirdNote.getId(), thirdNote)).andExpect(status().isNoContent()); createLinks(firstNote.getId()); createLinks(secondNote.getId()); @@ -954,9 +983,9 @@ void shouldReturnNoteListWhenSearchByTitleAndNoteType() throws Exception { + "?search=" + noteTitle + "¬eType=" + NOTE_TYPE_NAME_1 + "&order=ASC"); var notes = OBJECT_MAPPER.readValue(content, NoteCollection.class).getNotes(); - assertThat(notes.size(), equalTo(1)); - assertThat(notes.getFirst().getTypeId(), equalTo(UUID.fromString(NOTE_TYPE_ID_2))); - assertThat(notes.getFirst().getTitle(), equalTo(noteTitle)); + MatcherAssert.assertThat(notes.size(), equalTo(1)); + MatcherAssert.assertThat(notes.getFirst().getTypeId(), equalTo(UUID.fromString(NOTE_TYPE_ID_2))); + MatcherAssert.assertThat(notes.getFirst().getTitle(), equalTo(noteTitle)); } @Test @@ -1178,7 +1207,7 @@ private ResultMatcher errorMessageMatch(Matcher errorMessageMatcher) { } private ResultMatcher exceptionMatch(Class type) { - return result -> assertThat(result.getResolvedException(), instanceOf(type)); + return result -> MatcherAssert.assertThat(result.getResolvedException(), instanceOf(type)); } }