diff --git a/src/main/java/com/onixbyte/deltaforceguide/manager/ModificationManager.java b/src/main/java/com/onixbyte/deltaforceguide/manager/ModificationManager.java index 8dfdc92..5a5d618 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/manager/ModificationManager.java +++ b/src/main/java/com/onixbyte/deltaforceguide/manager/ModificationManager.java @@ -11,6 +11,9 @@ import com.onixbyte.deltaforceguide.domain.dto.ModificationResponse; import com.onixbyte.deltaforceguide.domain.dto.TuningRequest; import com.onixbyte.deltaforceguide.repository.FirearmRepository; import com.onixbyte.deltaforceguide.repository.ModificationRepository; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.domain.Specification; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; @@ -129,4 +132,9 @@ public class ModificationManager { tuning.setTuningValue(request.tuningValue()); return tuning; } + + @Transactional(readOnly = true) + public Page findBySpec(Specification spec, Pageable pageable) { + return modificationRepository.findAll(spec, pageable); + } } diff --git a/src/main/java/com/onixbyte/deltaforceguide/repository/ModificationRepository.java b/src/main/java/com/onixbyte/deltaforceguide/repository/ModificationRepository.java index 73173f9..b8a824d 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/repository/ModificationRepository.java +++ b/src/main/java/com/onixbyte/deltaforceguide/repository/ModificationRepository.java @@ -4,6 +4,7 @@ import com.onixbyte.deltaforceguide.domain.entity.Modification; import org.jspecify.annotations.NonNull; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.domain.Specification; import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaSpecificationExecutor; @@ -16,45 +17,23 @@ import java.util.Optional; /** * Spring Data JPA repository for {@link Modification} entity operations, - * including native JSONB tag filtering for Postgres. + * including JSONB tag filtering for Postgres via the specification API. * * @author zihluwang */ @Repository public interface ModificationRepository extends JpaRepository, JpaSpecificationExecutor { + @Override @EntityGraph(attributePaths = {"firearm"}) - Page findAllBy(Pageable pageable); - - @EntityGraph(attributePaths = {"firearm"}) - Page findAllByFirearm_Id(Long firearmId, Pageable pageable); + @NonNull + Page findAll(Specification spec, @NonNull Pageable pageable); @Override @EntityGraph(attributePaths = {"firearm"}) @NonNull Optional findById(@NonNull Long id); - /** - * Page query modifications with optional firearm and JSONB tag filtering. - * - * @param firearmId optional firearm ID filter (nullable) - * @param tagsJson optional JSON array of tags to match via Postgres {@code @>} operator (nullable) - * @param pageable pagination information - * @return a page of matching modifications - */ - @Query(value = """ - SELECT * FROM modification m - WHERE (:firearmId IS NULL OR m.firearm_id = :firearmId) - AND (CAST(:tagsJson AS text) IS NULL OR cast(m.tags as jsonb) @> cast(CAST(:tagsJson AS text) as jsonb)) - """, - countQuery = """ - SELECT count(*) FROM modification m - WHERE (:firearmId IS NULL OR m.firearm_id = :firearmId) - AND (CAST(:tagsJson AS text) IS NULL OR cast(m.tags as jsonb) @> cast(CAST(:tagsJson AS text) as jsonb)) - """, - nativeQuery = true) - Page pageQueryByFirearmAndTags(@Param("firearmId") Long firearmId, @Param("tagsJson") String tagsJson, Pageable pageable); - /** * Retrieve all distinct tag values from modifications, optionally filtered by firearm. * diff --git a/src/main/java/com/onixbyte/deltaforceguide/service/ModificationService.java b/src/main/java/com/onixbyte/deltaforceguide/service/ModificationService.java index d2f27c5..de63bee 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/service/ModificationService.java +++ b/src/main/java/com/onixbyte/deltaforceguide/service/ModificationService.java @@ -1,5 +1,7 @@ package com.onixbyte.deltaforceguide.service; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; import com.onixbyte.deltaforceguide.domain.dto.AccessoryRequest; import com.onixbyte.deltaforceguide.domain.dto.ModificationRequest; import com.onixbyte.deltaforceguide.domain.dto.ModificationResponse; @@ -8,15 +10,14 @@ import com.onixbyte.deltaforceguide.domain.dto.TuningRequest; import com.onixbyte.deltaforceguide.domain.entity.Accessory; import com.onixbyte.deltaforceguide.domain.entity.Firearm; import com.onixbyte.deltaforceguide.domain.entity.Modification; -import com.onixbyte.deltaforceguide.domain.entity.User; import com.onixbyte.deltaforceguide.domain.entity.Tuning; +import com.onixbyte.deltaforceguide.domain.entity.User; import com.onixbyte.deltaforceguide.manager.ModificationManager; import com.onixbyte.deltaforceguide.repository.FirearmRepository; import com.onixbyte.deltaforceguide.repository.ModificationRepository; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; -import org.springframework.data.domain.Page; +import com.onixbyte.deltaforceguide.specification.ModificationSpecification; import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.domain.Specification; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.web.server.ResponseStatusException; @@ -55,8 +56,8 @@ public class ModificationService { * Queries modifications with optional firearm and tag filters. * * @param firearmId optional firearm ID filter - * @param tags optional tag list filter - * @param pageable pagination parameters + * @param tags optional tag list filter + * @param pageable pagination parameters * @return a paginated response of modification records */ public PageResponse pageQuery(Long firearmId, List tags, Pageable pageable) { @@ -68,13 +69,13 @@ public class ModificationService { throw new RuntimeException("Failed to serialize tags", e); } } - - Page page; - if (tagsJson != null || firearmId != null) { - page = modificationRepository.pageQueryByFirearmAndTags(firearmId, tagsJson, pageable); - } else { - page = modificationRepository.findAllBy(pageable); - } + + var spec = Specification.allOf( + ModificationSpecification.hasFirearmId(firearmId), + ModificationSpecification.containsTags(tagsJson) + ); + + var page = modificationManager.findBySpec(spec, pageable); return PageResponse.from(page.map(ModificationResponse::from)); } @@ -126,7 +127,7 @@ public class ModificationService { /** * Updates an existing modification identified by ID. * - * @param id the modification ID + * @param id the modification ID * @param request the updated modification data * @return the updated modification response */ diff --git a/src/main/java/com/onixbyte/deltaforceguide/specification/ModificationSpecification.java b/src/main/java/com/onixbyte/deltaforceguide/specification/ModificationSpecification.java new file mode 100644 index 0000000..17799fd --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/specification/ModificationSpecification.java @@ -0,0 +1,41 @@ +package com.onixbyte.deltaforceguide.specification; + +import com.onixbyte.deltaforceguide.domain.entity.Modification; +import org.springframework.data.jpa.domain.Specification; + +import java.util.Objects; + +public class ModificationSpecification { + + /** + * Filters by firearm ID if specified. + */ + public static Specification hasFirearmId(Long firearmId) { + return (root, query, cb) -> { + if (Objects.isNull(firearmId)) { + return null; + } + return cb.equal(root.get("firearm").get("id"), firearmId); + }; + } + + /** + * Checks if the jsonb tags contain the given JSON string. + */ + public static Specification containsTags(String tagsJson) { + return (root, query, cb) -> { + if (Objects.isNull(tagsJson)) { + return null; + } + // Invoke the PostgreSQL native function 'jsonb_contains' (backing the + // '@>' operator). The argument is cast via the 'jsonb' function-style + // cast so PostgreSQL resolves the correct overload instead of treating + // the bound String as varchar. + return cb.isTrue( + cb.function("jsonb_contains", Boolean.class, + root.get("tags"), + cb.function("jsonb", String.class, cb.literal(tagsJson))) + ); + }; + } +}