diff --git a/src/main/java/com/espacogeek/geek/data/impl/GenericMediaDataControllerImpl.java b/src/main/java/com/espacogeek/geek/data/impl/GenericMediaDataControllerImpl.java index 69457151..0c9986dd 100644 --- a/src/main/java/com/espacogeek/geek/data/impl/GenericMediaDataControllerImpl.java +++ b/src/main/java/com/espacogeek/geek/data/impl/GenericMediaDataControllerImpl.java @@ -32,6 +32,7 @@ import com.espacogeek.geek.services.GenreService; import com.espacogeek.geek.services.MediaService; import com.espacogeek.geek.services.SeasonService; +import com.espacogeek.geek.utils.MediaLazyLoader; import jakarta.persistence.Id; import jakarta.persistence.ManyToMany; @@ -48,6 +49,7 @@ public class GenericMediaDataControllerImpl implements MediaDataController { private final AlternativeTitlesService alternativeTitlesService; private final ExternalReferenceService externalReferenceService; private final SeasonService seasonService; + private final MediaLazyLoader mediaLazyLoader; @Autowired public GenericMediaDataControllerImpl( @@ -55,13 +57,15 @@ public GenericMediaDataControllerImpl( GenreService genreService, AlternativeTitlesService alternativeTitlesService, ExternalReferenceService externalReferenceService, - SeasonService seasonService + SeasonService seasonService, + MediaLazyLoader mediaLazyLoader ) { this.mediaService = mediaService; this.genreService = genreService; this.alternativeTitlesService = alternativeTitlesService; this.externalReferenceService = externalReferenceService; this.seasonService = seasonService; + this.mediaLazyLoader = mediaLazyLoader; } public GenericMediaDataControllerImpl getInstance() { @@ -73,6 +77,8 @@ public GenericMediaDataControllerImpl getInstance() { */ @Override public MediaModel updateAllInformation(MediaModel media, MediaModel result, TypeReferenceModel typeReference, MediaApi mediaApi) { + mediaLazyLoader.initializeCollections(media); + if (result == null) { Collection externalReferences = media.getExternalReference(); if (externalReferences == null || !Hibernate.isInitialized(externalReferences)) { diff --git a/src/main/java/com/espacogeek/geek/data/impl/MovieControllerImpl.java b/src/main/java/com/espacogeek/geek/data/impl/MovieControllerImpl.java index 36a1fbdc..88a981e6 100644 --- a/src/main/java/com/espacogeek/geek/data/impl/MovieControllerImpl.java +++ b/src/main/java/com/espacogeek/geek/data/impl/MovieControllerImpl.java @@ -28,6 +28,7 @@ import com.espacogeek.geek.services.MediaCategoryService; import com.espacogeek.geek.services.SeasonService; import com.espacogeek.geek.services.TypeReferenceService; +import com.espacogeek.geek.utils.MediaLazyLoader; import jakarta.annotation.PostConstruct; @@ -51,9 +52,10 @@ public MovieControllerImpl( GenreService genreService, AlternativeTitlesService alternativeTitlesService, ExternalReferenceService baseExternalReferenceService, - SeasonService seasonService + SeasonService seasonService, + MediaLazyLoader mediaLazyLoader ) { - super(mediaService, genreService, alternativeTitlesService, baseExternalReferenceService, seasonService); + super(mediaService, genreService, alternativeTitlesService, baseExternalReferenceService, seasonService, mediaLazyLoader); this.movieAPI = movieAPI; this.mediaCategoryService = mediaCategoryService; this.externalReferenceService = externalReferenceService; diff --git a/src/main/java/com/espacogeek/geek/data/impl/SerieControllerImpl.java b/src/main/java/com/espacogeek/geek/data/impl/SerieControllerImpl.java index 42154abd..e5477861 100644 --- a/src/main/java/com/espacogeek/geek/data/impl/SerieControllerImpl.java +++ b/src/main/java/com/espacogeek/geek/data/impl/SerieControllerImpl.java @@ -8,6 +8,7 @@ import java.util.List; import com.espacogeek.geek.services.*; +import com.espacogeek.geek.utils.MediaLazyLoader; import lombok.extern.slf4j.Slf4j; import org.json.simple.JSONObject; import org.springframework.beans.factory.annotation.Qualifier; @@ -33,8 +34,8 @@ public class SerieControllerImpl extends GenericMediaDataControllerImpl { private TypeReferenceModel typeReference; - public SerieControllerImpl(MediaService mediaService, GenreService genreService, AlternativeTitlesService alternativeTitlesService, ExternalReferenceService externalReferenceService, SeasonService seasonService, MediaApi tvSeriesApi, MediaCategoryService mediaCategoryService, ExternalReferenceService externalReferenceService1, TypeReferenceService typeReferenceService) { - super(mediaService, genreService, alternativeTitlesService, externalReferenceService, seasonService); + public SerieControllerImpl(MediaService mediaService, GenreService genreService, AlternativeTitlesService alternativeTitlesService, ExternalReferenceService externalReferenceService, SeasonService seasonService, MediaApi tvSeriesApi, MediaCategoryService mediaCategoryService, ExternalReferenceService externalReferenceService1, TypeReferenceService typeReferenceService, MediaLazyLoader mediaLazyLoader) { + super(mediaService, genreService, alternativeTitlesService, externalReferenceService, seasonService, mediaLazyLoader); this.tvSeriesApi = tvSeriesApi; this.mediaCategoryService = mediaCategoryService; this.externalReferenceService = externalReferenceService1; diff --git a/src/main/java/com/espacogeek/geek/repositories/AlternativeTitlesRepository.java b/src/main/java/com/espacogeek/geek/repositories/AlternativeTitlesRepository.java index 2d6f9752..98ce20f3 100644 --- a/src/main/java/com/espacogeek/geek/repositories/AlternativeTitlesRepository.java +++ b/src/main/java/com/espacogeek/geek/repositories/AlternativeTitlesRepository.java @@ -12,6 +12,8 @@ @Repository public interface AlternativeTitlesRepository extends JpaRepository { + List findByMedia(MediaModel media); + List findByMediaIn(Collection medias); } diff --git a/src/main/java/com/espacogeek/geek/repositories/GenreRepository.java b/src/main/java/com/espacogeek/geek/repositories/GenreRepository.java index 52202dc3..31a4dbd3 100644 --- a/src/main/java/com/espacogeek/geek/repositories/GenreRepository.java +++ b/src/main/java/com/espacogeek/geek/repositories/GenreRepository.java @@ -3,6 +3,8 @@ import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import com.espacogeek.geek.models.GenreModel; @@ -10,4 +12,7 @@ @Repository public interface GenreRepository extends JpaRepository { public List findAllByNameIn(List nameGenres); + + @Query("SELECT g FROM MediaModel m JOIN m.genre g WHERE m.id = :mediaId") + List findByMediaId(@Param("mediaId") Integer mediaId); } diff --git a/src/main/java/com/espacogeek/geek/repositories/SeasonRepository.java b/src/main/java/com/espacogeek/geek/repositories/SeasonRepository.java index 5b533809..46dd1633 100644 --- a/src/main/java/com/espacogeek/geek/repositories/SeasonRepository.java +++ b/src/main/java/com/espacogeek/geek/repositories/SeasonRepository.java @@ -12,6 +12,8 @@ @Repository public interface SeasonRepository extends JpaRepository { + List findByMedia(MediaModel media); + List findByMediaIn(Collection medias); } diff --git a/src/main/java/com/espacogeek/geek/services/AlternativeTitlesService.java b/src/main/java/com/espacogeek/geek/services/AlternativeTitlesService.java index f0f207aa..647cdcdf 100644 --- a/src/main/java/com/espacogeek/geek/services/AlternativeTitlesService.java +++ b/src/main/java/com/espacogeek/geek/services/AlternativeTitlesService.java @@ -5,7 +5,10 @@ import org.springframework.dao.DataIntegrityViolationException; import com.espacogeek.geek.models.AlternativeTitleModel; +import com.espacogeek.geek.models.MediaModel; public interface AlternativeTitlesService { public List saveAll(List alternativeTitles) throws DataIntegrityViolationException; + + public List findAll(MediaModel media); } diff --git a/src/main/java/com/espacogeek/geek/services/GenreService.java b/src/main/java/com/espacogeek/geek/services/GenreService.java index 20875748..2d3c4b28 100644 --- a/src/main/java/com/espacogeek/geek/services/GenreService.java +++ b/src/main/java/com/espacogeek/geek/services/GenreService.java @@ -3,9 +3,12 @@ import java.util.List; import com.espacogeek.geek.models.GenreModel; +import com.espacogeek.geek.models.MediaModel; public interface GenreService { public List findAllByNames(List names); + public List findAll(MediaModel media); + public List saveAll(List genres); } diff --git a/src/main/java/com/espacogeek/geek/services/SeasonService.java b/src/main/java/com/espacogeek/geek/services/SeasonService.java index 01fce901..7d06048b 100644 --- a/src/main/java/com/espacogeek/geek/services/SeasonService.java +++ b/src/main/java/com/espacogeek/geek/services/SeasonService.java @@ -2,8 +2,11 @@ import java.util.List; +import com.espacogeek.geek.models.MediaModel; import com.espacogeek.geek.models.SeasonModel; public interface SeasonService { public List saveAll(List seasons); + + public List findAll(MediaModel media); } diff --git a/src/main/java/com/espacogeek/geek/services/impl/AlternativeTitlesServiceImpl.java b/src/main/java/com/espacogeek/geek/services/impl/AlternativeTitlesServiceImpl.java index 8a6edf21..c3233f3b 100644 --- a/src/main/java/com/espacogeek/geek/services/impl/AlternativeTitlesServiceImpl.java +++ b/src/main/java/com/espacogeek/geek/services/impl/AlternativeTitlesServiceImpl.java @@ -7,6 +7,7 @@ import org.springframework.stereotype.Service; import com.espacogeek.geek.models.AlternativeTitleModel; +import com.espacogeek.geek.models.MediaModel; import com.espacogeek.geek.repositories.AlternativeTitlesRepository; import com.espacogeek.geek.services.AlternativeTitlesService; @@ -18,4 +19,9 @@ public class AlternativeTitlesServiceImpl implements AlternativeTitlesService { public List saveAll(List alternativeTitles) throws DataIntegrityViolationException { return alternativeTitlesRepository.saveAll(alternativeTitles); } + + @Override + public List findAll(MediaModel media) { + return alternativeTitlesRepository.findByMedia(media); + } } diff --git a/src/main/java/com/espacogeek/geek/services/impl/GenreServiceImpl.java b/src/main/java/com/espacogeek/geek/services/impl/GenreServiceImpl.java index e39f8225..74951bbe 100644 --- a/src/main/java/com/espacogeek/geek/services/impl/GenreServiceImpl.java +++ b/src/main/java/com/espacogeek/geek/services/impl/GenreServiceImpl.java @@ -6,6 +6,7 @@ import org.springframework.stereotype.Service; import com.espacogeek.geek.models.GenreModel; +import com.espacogeek.geek.models.MediaModel; import com.espacogeek.geek.repositories.GenreRepository; import com.espacogeek.geek.services.GenreService; @@ -23,6 +24,14 @@ public List findAllByNames(List names) { return genreRepository.findAllByNameIn(names); } + /** + * @see GenreService#findAll(MediaModel) + */ + @Override + public List findAll(MediaModel media) { + return genreRepository.findByMediaId(media.getId()); + } + /** * @see GenreService#saveAll(List) */ diff --git a/src/main/java/com/espacogeek/geek/services/impl/MediaServiceImpl.java b/src/main/java/com/espacogeek/geek/services/impl/MediaServiceImpl.java index 23e46bff..a11cc6fb 100644 --- a/src/main/java/com/espacogeek/geek/services/impl/MediaServiceImpl.java +++ b/src/main/java/com/espacogeek/geek/services/impl/MediaServiceImpl.java @@ -1,10 +1,6 @@ package com.espacogeek.geek.services.impl; -import java.lang.reflect.Field; -import java.lang.reflect.InvocationTargetException; -import java.lang.reflect.Method; import java.util.ArrayList; -import java.util.Collection; import java.util.List; import java.util.Optional; import java.util.concurrent.ThreadLocalRandom; @@ -31,13 +27,10 @@ import com.espacogeek.geek.types.MediaPage; import com.espacogeek.geek.types.MediaSimplefied; import com.espacogeek.geek.utils.MediaUtils; -import jakarta.persistence.ManyToMany; -import jakarta.persistence.OneToMany; +import com.espacogeek.geek.utils.MediaLazyLoader; import jakarta.transaction.Transactional; import org.hibernate.Hibernate; -import static com.espacogeek.geek.utils.TextUtils.capitalize; - /** * A Implementation class of MediaService @see MediaService */ @@ -69,6 +62,8 @@ public class MediaServiceImpl implements MediaService { @Qualifier("tvSeriesApi") private final MediaApi tvSeriesApi; + private final MediaLazyLoader mediaLazyLoader; + public MediaServiceImpl( MediaRepository mediaRepository, ExternalReferenceRepository externalsRepo, @@ -78,7 +73,8 @@ public MediaServiceImpl( TypeReferenceService typeReferenceService, @Qualifier("gamesAndVNsAPI") MediaApi gamesAndVNsAPI, @Qualifier("movieAPI") MediaApi movieAPI, - @Qualifier("tvSeriesApi") MediaApi tvSeriesApi + @Qualifier("tvSeriesApi") MediaApi tvSeriesApi, + MediaLazyLoader mediaLazyLoader ) { this.mediaRepository = mediaRepository; this.externalsRepo = externalsRepo; @@ -89,6 +85,7 @@ public MediaServiceImpl( this.gamesAndVNsAPI = gamesAndVNsAPI; this.movieAPI = movieAPI; this.tvSeriesApi = tvSeriesApi; + this.mediaLazyLoader = mediaLazyLoader; } /** @@ -175,31 +172,12 @@ public Optional findByReferenceAndTypeReference(ExternalReferenceMod @Override @Transactional public Optional findByIdEager(Integer id) { - var fieldList = new ArrayList(); MediaModel media = (MediaModel) mediaRepository.findById(id).orElse(null); if (media == null) return Optional.empty(); - for (Field field : media.getClass().getDeclaredFields()) { - field.setAccessible(true); - if (field.isAnnotationPresent(OneToMany.class) || field.isAnnotationPresent(ManyToMany.class)) { - fieldList.add(field); - } - } - - for (Field field : fieldList) { - try { - String getterName = "get" + capitalize(field.getName()); - Method getter = media.getClass().getMethod(getterName); - var fieldValue = getter.invoke(media); - if (fieldValue instanceof Collection) { - ((Collection) fieldValue).size(); // This will initialize the collection - } - } catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException e) { - log.error("Failed to initialize field {} for media id={}: {}", field.getName(), media.getId(), e.getMessage()); - } - } + mediaLazyLoader.initializeCollections(media); media = update(media); diff --git a/src/main/java/com/espacogeek/geek/services/impl/SeasonServiceImpl.java b/src/main/java/com/espacogeek/geek/services/impl/SeasonServiceImpl.java index 3d505de5..09650748 100644 --- a/src/main/java/com/espacogeek/geek/services/impl/SeasonServiceImpl.java +++ b/src/main/java/com/espacogeek/geek/services/impl/SeasonServiceImpl.java @@ -5,6 +5,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; +import com.espacogeek.geek.models.MediaModel; import com.espacogeek.geek.models.SeasonModel; import com.espacogeek.geek.repositories.SeasonRepository; import com.espacogeek.geek.services.SeasonService; @@ -18,4 +19,9 @@ public class SeasonServiceImpl implements SeasonService { public List saveAll(List seasons) { return seasonRepository.saveAll(seasons); } + + @Override + public List findAll(MediaModel media) { + return seasonRepository.findByMedia(media); + } } diff --git a/src/main/java/com/espacogeek/geek/utils/MediaLazyLoader.java b/src/main/java/com/espacogeek/geek/utils/MediaLazyLoader.java new file mode 100644 index 00000000..3c37c58d --- /dev/null +++ b/src/main/java/com/espacogeek/geek/utils/MediaLazyLoader.java @@ -0,0 +1,75 @@ +package com.espacogeek.geek.utils; + +import java.util.LinkedHashSet; + +import org.hibernate.Hibernate; +import org.springframework.stereotype.Component; + +import com.espacogeek.geek.models.MediaModel; +import com.espacogeek.geek.services.AlternativeTitlesService; +import com.espacogeek.geek.services.ExternalReferenceService; +import com.espacogeek.geek.services.GenreService; +import com.espacogeek.geek.services.SeasonService; + +/** + * Utility Spring bean that guarantees all lazy-loaded {@code Set<>} collections + * of a {@link MediaModel} are available for use outside an active Hibernate session. + * + *

With Java 21 virtual threads, thread-local Hibernate sessions can be closed + * before update methods finish processing a detached entity, causing + * {@code LazyInitializationException}. This class solves the problem once and for all + * by checking {@link Hibernate#isInitialized} for every lazy collection and falling + * back to a fresh repository query when the session is no longer active. + * + *

Covered collections: {@code externalReference}, {@code alternativeTitles}, + * {@code genre}, {@code season}. + */ +@Component +public class MediaLazyLoader { + + private final ExternalReferenceService externalReferenceService; + private final AlternativeTitlesService alternativeTitlesService; + private final GenreService genreService; + private final SeasonService seasonService; + + public MediaLazyLoader( + ExternalReferenceService externalReferenceService, + AlternativeTitlesService alternativeTitlesService, + GenreService genreService, + SeasonService seasonService) { + this.externalReferenceService = externalReferenceService; + this.alternativeTitlesService = alternativeTitlesService; + this.genreService = genreService; + this.seasonService = seasonService; + } + + /** + * Ensures all lazy {@link java.util.Set} collections of the given {@link MediaModel} + * are initialized. For each collection that is {@code null} or not yet initialized + * by Hibernate (e.g., because the session that loaded the entity has been closed), + * the data is loaded directly from the corresponding service and set back on the entity. + * + * @param media the {@link MediaModel} to initialize; no-op when {@code null} or unsaved + */ + public void initializeCollections(MediaModel media) { + if (media == null || media.getId() == null) { + return; + } + + if (media.getExternalReference() == null || !Hibernate.isInitialized(media.getExternalReference())) { + media.setExternalReference(new LinkedHashSet<>(externalReferenceService.findAll(media))); + } + + if (media.getAlternativeTitles() == null || !Hibernate.isInitialized(media.getAlternativeTitles())) { + media.setAlternativeTitles(new LinkedHashSet<>(alternativeTitlesService.findAll(media))); + } + + if (media.getGenre() == null || !Hibernate.isInitialized(media.getGenre())) { + media.setGenre(new LinkedHashSet<>(genreService.findAll(media))); + } + + if (media.getSeason() == null || !Hibernate.isInitialized(media.getSeason())) { + media.setSeason(new LinkedHashSet<>(seasonService.findAll(media))); + } + } +} diff --git a/src/test/java/com/espacogeek/geek/services/MediaServiceImplTest.java b/src/test/java/com/espacogeek/geek/services/MediaServiceImplTest.java index c76edf48..b55e87ab 100644 --- a/src/test/java/com/espacogeek/geek/services/MediaServiceImplTest.java +++ b/src/test/java/com/espacogeek/geek/services/MediaServiceImplTest.java @@ -34,6 +34,7 @@ import com.espacogeek.geek.repositories.ExternalReferenceRepository; import com.espacogeek.geek.repositories.MediaRepository; import com.espacogeek.geek.services.impl.MediaServiceImpl; +import com.espacogeek.geek.utils.MediaLazyLoader; @ExtendWith(MockitoExtension.class) @SuppressWarnings({"unchecked", "rawtypes"}) @@ -66,6 +67,9 @@ class MediaServiceImplTest { @Mock(name = "tvSeriesApi") private com.espacogeek.geek.data.api.MediaApi tvSeriesApi; + @Mock + private MediaLazyLoader mediaLazyLoader; + private MediaServiceImpl mediaService; @BeforeEach @@ -79,7 +83,8 @@ void setUp() { typeReferenceService, gamesAndVNsAPI, movieAPI, - tvSeriesApi); + tvSeriesApi, + mediaLazyLoader); } @Test diff --git a/src/test/java/com/espacogeek/geek/services/MediaServicePersistenceIntegrationTest.java b/src/test/java/com/espacogeek/geek/services/MediaServicePersistenceIntegrationTest.java index 83e0171b..38351f5c 100644 --- a/src/test/java/com/espacogeek/geek/services/MediaServicePersistenceIntegrationTest.java +++ b/src/test/java/com/espacogeek/geek/services/MediaServicePersistenceIntegrationTest.java @@ -39,6 +39,7 @@ import com.espacogeek.geek.services.impl.GenreServiceImpl; import com.espacogeek.geek.services.impl.MediaServiceImpl; import com.espacogeek.geek.types.MediaPage; +import com.espacogeek.geek.utils.MediaLazyLoader; @DataJpaTest @ActiveProfiles("test") @@ -92,18 +93,26 @@ void setUp() { SeasonService seasonService = mock(SeasonService.class); when(seasonService.saveAll(any())).thenAnswer(invocation -> invocation.getArgument(0)); + when(seasonService.findAll(any())).thenReturn(java.util.List.of()); gamesAndVNsAPI = mock(MediaApi.class); MediaApi movieAPI = mock(MediaApi.class); MediaApi tvSeriesApi = mock(MediaApi.class); MediaDataController serieController = mock(MediaDataController.class); + MediaLazyLoader mediaLazyLoader = new MediaLazyLoader( + externalReferenceService, + alternativeTitlesService, + genreService, + seasonService); + GenericMediaDataControllerImpl genericMediaDataController = new GenericMediaDataControllerImpl( null, genreService, alternativeTitlesService, externalReferenceService, - seasonService); + seasonService, + mediaLazyLoader); mediaService = new MediaServiceImpl( mediaRepository, @@ -114,7 +123,8 @@ void setUp() { typeReferenceService, gamesAndVNsAPI, movieAPI, - tvSeriesApi); + tvSeriesApi, + mediaLazyLoader); ReflectionTestUtils.setField(genericMediaDataController, "mediaService", mediaService); }