refactor(mod): page query via specifications

Unify the native JSONB query and findAllBy dual path through
JpaSpecificationExecutor:
- add ModificationSpecification for firearm and tag filters
- eagerly fetch the firearm association via entity graph
- filter by the firearm id attribute instead of a missing top-level field
This commit is contained in:
siujamo committed 2026-08-20 17:24:08 +08:00
1 parent e4df329f0f
commit 3aaf7feb4f
4 files changed
+68 -39

No files matched your search

@@ -11,6 +11,9 @@ import com.onixbyte.deltaforceguide.domain.dto.ModificationResponse;
import com.onixbyte.deltaforceguide.domain.dto.TuningRequest; import com.onixbyte.deltaforceguide.domain.dto.TuningRequest;
import com.onixbyte.deltaforceguide.repository.FirearmRepository; import com.onixbyte.deltaforceguide.repository.FirearmRepository;
import com.onixbyte.deltaforceguide.repository.ModificationRepository; 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.http.HttpStatus;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -129,4 +132,9 @@ public class ModificationManager {
tuning.setTuningValue(request.tuningValue()); tuning.setTuningValue(request.tuningValue());
return tuning; return tuning;
} }
@Transactional(readOnly = true)
public Page<Modification> findBySpec(Specification<Modification> spec, Pageable pageable) {
return modificationRepository.findAll(spec, pageable);
}
} }
@@ -4,6 +4,7 @@ import com.onixbyte.deltaforceguide.domain.entity.Modification;
import org.jspecify.annotations.NonNull; import org.jspecify.annotations.NonNull;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable; 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.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor; import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
@@ -16,45 +17,23 @@ import java.util.Optional;
/** /**
* Spring Data JPA repository for {@link Modification} entity operations, * 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 * @author zihluwang
*/ */
@Repository @Repository
public interface ModificationRepository extends JpaRepository<Modification, Long>, JpaSpecificationExecutor<Modification> { public interface ModificationRepository extends JpaRepository<Modification, Long>, JpaSpecificationExecutor<Modification> {
@Override
@EntityGraph(attributePaths = {"firearm"}) @EntityGraph(attributePaths = {"firearm"})
Page<Modification> findAllBy(Pageable pageable); @NonNull
Page<Modification> findAll(Specification<Modification> spec, @NonNull Pageable pageable);
@EntityGraph(attributePaths = {"firearm"})
Page<Modification> findAllByFirearm_Id(Long firearmId, Pageable pageable);
@Override @Override
@EntityGraph(attributePaths = {"firearm"}) @EntityGraph(attributePaths = {"firearm"})
@NonNull @NonNull
Optional<Modification> findById(@NonNull Long id); Optional<Modification> 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<Modification> pageQueryByFirearmAndTags(@Param("firearmId") Long firearmId, @Param("tagsJson") String tagsJson, Pageable pageable);
/** /**
* Retrieve all distinct tag values from modifications, optionally filtered by firearm. * Retrieve all distinct tag values from modifications, optionally filtered by firearm.
* *
@@ -1,5 +1,7 @@
package com.onixbyte.deltaforceguide.service; 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.AccessoryRequest;
import com.onixbyte.deltaforceguide.domain.dto.ModificationRequest; import com.onixbyte.deltaforceguide.domain.dto.ModificationRequest;
import com.onixbyte.deltaforceguide.domain.dto.ModificationResponse; 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.Accessory;
import com.onixbyte.deltaforceguide.domain.entity.Firearm; import com.onixbyte.deltaforceguide.domain.entity.Firearm;
import com.onixbyte.deltaforceguide.domain.entity.Modification; 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.Tuning;
import com.onixbyte.deltaforceguide.domain.entity.User;
import com.onixbyte.deltaforceguide.manager.ModificationManager; import com.onixbyte.deltaforceguide.manager.ModificationManager;
import com.onixbyte.deltaforceguide.repository.FirearmRepository; import com.onixbyte.deltaforceguide.repository.FirearmRepository;
import com.onixbyte.deltaforceguide.repository.ModificationRepository; import com.onixbyte.deltaforceguide.repository.ModificationRepository;
import com.fasterxml.jackson.core.JsonProcessingException; import com.onixbyte.deltaforceguide.specification.ModificationSpecification;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
@@ -55,8 +56,8 @@ public class ModificationService {
* Queries modifications with optional firearm and tag filters. * Queries modifications with optional firearm and tag filters.
* *
* @param firearmId optional firearm ID filter * @param firearmId optional firearm ID filter
* @param tags optional tag list filter * @param tags optional tag list filter
* @param pageable pagination parameters * @param pageable pagination parameters
* @return a paginated response of modification records * @return a paginated response of modification records
*/ */
public PageResponse<ModificationResponse> pageQuery(Long firearmId, List<String> tags, Pageable pageable) { public PageResponse<ModificationResponse> pageQuery(Long firearmId, List<String> tags, Pageable pageable) {
@@ -69,12 +70,12 @@ public class ModificationService {
} }
} }
Page<Modification> page; var spec = Specification.allOf(
if (tagsJson != null || firearmId != null) { ModificationSpecification.hasFirearmId(firearmId),
page = modificationRepository.pageQueryByFirearmAndTags(firearmId, tagsJson, pageable); ModificationSpecification.containsTags(tagsJson)
} else { );
page = modificationRepository.findAllBy(pageable);
} var page = modificationManager.findBySpec(spec, pageable);
return PageResponse.from(page.map(ModificationResponse::from)); return PageResponse.from(page.map(ModificationResponse::from));
} }
@@ -126,7 +127,7 @@ public class ModificationService {
/** /**
* Updates an existing modification identified by ID. * Updates an existing modification identified by ID.
* *
* @param id the modification ID * @param id the modification ID
* @param request the updated modification data * @param request the updated modification data
* @return the updated modification response * @return the updated modification response
*/ */
@@ -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<Modification> 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<Modification> 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)))
);
};
}
}